Changelog¶
All notable changes to StatsPAI will be documented in this file.
[Unreleased]¶
⚠️ Correctness¶
Option scan round 6: output/, plots/ and fast/ (tests in
tests/test_output_option_scan_r6.py,
tests/test_plots_output_option_scan_r6.py and
tests/test_fast_option_scan_r6.py).
- ⚠️
sp.regtable/sp.esttab/sp.citeintervals at a level other than the fit's. They were recomputed on t(N - K) for every result. A clustered OLS fit refers its intervals to t(G - 1) and a logit to the normal, so the table disagreed with the result's ownconf_int(alpha). With 10 clusters,alpha=0.10printed [0.1880, 0.9640] for an interval the result reports as [0.1456, 1.0063]; a logit atalpha=0.10printed [0.8376, 1.4181] for [0.8389, 1.4167]. The rule now comes from the fit (data_info['inference']/df_inference, per-coefficient df), also aftervcov=, and survivesto_dict()/from_dict(). - ⚠️ A fit called with
alpha=had its stored bounds relabelled.sp.logit(..., alpha=0.10)stores 90% bounds;sp.regtable(se_type='ci')printed them under "95% CI" ([0.8389, 1.4167] for a 95% interval of [0.7836, 1.4721]). The same held for aCausalResult(sp.ipw(alpha=0.10)) and forsp.cite. Stored bounds are reused only at their own level. - ⚠️
sp.coefplot/sp.coefplot_tikzdrewb ± 1.96·sefor every model. They now draw the model's own interval: t(G - 1) under clustering, t(N - K) for OLS, the normal for likelihood fits. With 10 clusters the 95% bar moves from [0.1158, 1.0361] to [0.0448, 1.1071], the result'sconf_int(). Asymmetric stored intervals are drawn as such (+= / -=in the TikZ source). - ⚠️
sp.binscatter(absorb=)plotted the points around (0, 0). The residuals were put back on the means of the within-transformed variables, which are zero. They are now put back on the means ofxandy, as Stata'sbinscatterdoes. Withabsorb=andcontrols=together the controls are within-transformed too (one regression, asareg); before,yandxwere residualized on raw controls. - ⚠️
sp.binscatter(weights=)used the weights only for the bin means. The absorption, the residualization means, the quantile bins and the bin intervals now use them: weighted quantile bins, as Stata'sxtile [aw=]inbinscatter, and the analytic-weight SE of each bin mean (Stataregress y [aw=w]).fit_on_raw=Truefits the line by weighted least squares. The points equal Stata 18binscatter [aw=w], absorb()/controls()to the 8 digits it saves; unweighted calls withoutabsorb=are unchanged. The fixed effects are absorbed exactly (sparse direct solve) instead of ten alternating-projection sweeps. - ⚠️
sp.fast.event_studycharged the unit fixed effects to the CR1 factor although they are nested in the (default, unit) clusters. Its docstring promised thereghdfe/fixestrule, which leaves nested effects out of K. Standard errors fall by about 5%: 0.6332 → 0.6004 on the test panel, equal to Rfixest0.6004056911. The count now matchessp.fast.feols. -
⚠️
sp.fast.feols(...).tidy()p-values undervcov='cr1'used t(df_resid). They use t(G - 1), asfixest::pvalue()and Stataregress, vce(cluster): with 7 clusters, 0.000235 → 0.00973 (fixest 0.00973237596155).FeolsResultgainsn_clustersanddf_t.sp.fast.etabletakes its stars from each fit's own p-values (it readt(df_resid)critical values, also forfepois, whose tests are z): the example'sx2goes from**to*. Option scan round 6:diagnosticsandnetwork. Tests intests/test_diagnostics_scan_oct2026.pyandtests/test_network_scan_oct2026.py. -
⚠️
sp.estatafter a weightedsp.regressignored the weights.hettest,reset(ovtest),vifandlinktestreran their auxiliary regressions unweighted. They now do what Stata'sestatdoes afterregress [aw=]: on the test dataestat hettest, rhs iidgoes from 19.24 to 16.805138 (Stata 16.8051384368403),estat viffrom 1.011 to 1.0312529 (Stata 1.03125287945777) and the linktest t from -1.03 to -2.4274947 (Stata -2.42749473488544). Afterweights_as='sampling'(Statapweight)resetis the robust Wald test (3.3105622, Stata 3.31056215678665),linktestuses the robust SE andhettestis refused, as in Stata.whiteandimtestare refused after any weights (Stata: "imtest does not support weights"), and so arenormalityandleverage, whose formulas are those of an unweighted regression.bgodfrey,durbinalt,archlm,dwatsonandicalready agreed with Stata and are unchanged. - ⚠️
sp.estat(fit, 'linktest')aftersp.logit/sp.probit/sp.poissonregressed y on the fitted probabilities by OLS. It now refits the model on the linear index and its square and reports a z statistic, as Stata'slinktestdoes. Logit example: t 0.316 becomes z 0.2808 (Stata_hatsq.0384553, SE .1369572).hettest,white,imtest,reset,bgodfrey,durbinaltandarchlmnow refuse a likelihood fit instead of running on its response residuals, andsp.estat(fit, 'all')skips them. - ⚠️
sp.logit_gof,sp.logit_influenceandsp.estat(fit, 'classification')after a weighted logit returned unweighted statistics (Pearson 295.7 where Stata'sestat gofafter[iw=]gives 491.8). They now raise, as Stata does afterpweight. - ⚠️
sp.transitivityandsp.clusteringon a directed graph applied the undirected formula to the asymmetric matrix, which counts neither triangles nor triples (a test graph gave 0.395 globally). A directed graph is now read as undirected, asigraph::transitivitydoes: 0.3478261 on the test graph, equal to igraph. - ⚠️
sp.assortativityon a directed graph correlated the out-degrees of both ends of each arc. It now correlates the sender's out-degree with the receiver's in-degree: 0.0500 becomes 0.2876470 (igraph and networkx 0.28764698611864). - ⚠️
sp.network_modularityon a directed graph used the out-degree at both ends of the null model. It now uses the Leicht-Newman form with in-degrees for the receiving end: -0.0660 becomes -0.0642361 (igraphmodularity(directed = TRUE)-0.064236111111111). Themodularityreported bysp.community_detectionon a directed graph changes with it. - ⚠️
sp.hitsignored tie weights. It now uses them when the graph is weighted, like the other centralities (newweighted=argument;weighted=Falsegives the old binary scores). Values match igraph'shits_scoresand networkx to 1e-10. - ⚠️
sp.netlm(method='dsp')reported the intercept's QAP p-value as1 / (nperm + 1)whatever the data. The intercept is now semi-partialled like the predictors (sna adds it as a matrix of ones). On a test network its p-value goes from 0.0005 to 0.0999, against 0.0995 to 0.103 fromsna::netlm(nullhyp = "qapspp", test.statistic = "beta"). Predictor p-values are unchanged. - ⚠️
sp.ergmtermnodefactor:<attr>summed the attribute codes into one numeric term. It now has one term per level except the first, asergm'snodefactor; estimates and SEs matchergm(estimate = "MPLE")to 5e-7. - ⚠️
sp.olley_pakes/sp.levinsohn_petrin/sp.ackerberg_caves_frazer(andsp.prod_fn) no longer depend on the units of the log inputs. Stage 2 started at the linear coefficients of the raw stage-1 polynomial, i.e. its slopes where every log input is zero. Log capital in dollars instead of thousands is the same data plus a constant, but it moved that start, and with it the local minimum (OP / LP) or the root (ACF) the estimator returned, withstage2_converged=True. On a simulated panel with log capital and materials shifted by 3, LP gave a capital elasticity of −2.1231 (sum of squares 113.33) instead of 0.4296 (109.94); OP with log capital shifted by 8 gave −2.7816 instead of 0.4042; ACF on theprodestparity panel with log capital shifted by 5 gave (2.5532, −0.2448) instead of (0.9833, 0.2157). The start is now the slope of the stage-1 polynomial at the sample means, which a shift does not move. The prodest parity panel, Rprodest's Chilean panel and the test panels give the same estimates as before. - ⚠️
sp.wooldridge_prod:productivity_process['rho']is the slope of the linear projection of productivity on its lag, as for OP / LP / ACF and asProductionResultdocuments. It was the linear coefficient of the Markov polynomial inh, its slope whereh = 0, which moved with the units of the inputs: on Rprodest's Chilean panel 1.0622, and 0.8293 with log capital shifted by −5; now 0.9796 for both.diagnostics['markov_coef']is the polynomial in powers of lagged TFP (as for OP / LP / ACF). Withconvention='prodest'the coefficients are unchanged andrhochanges from the imposed 1.0 to the projection slope. - ⚠️
sp.wooldridge_prod(defaultconvention='statspai') works on standardised inputs. The raw powers of log inputs far from zero are close to collinear: with log capital and materials near 6 to 7 the fit raisedLinAlgError, and near 8 the capital elasticity drifted to 0.43729 from 0.43858. The span is the same, so elasticities and standard errors agree with before to the solver's tolerance on data centred near zero.convention='prodest'keeps the raw design (Stata parity). - ⚠️
sp.cevaerequires PyTorch. Without it the fit fell back to a numpy "linear variational approximation" whose encoder update had a zero gradient, so it regressed the outcome on a fixed random projection ofX: with ten confounders the ATE was 3.45 / 3.86 / 3.43 for seeds 0 / 1 / 2 against a truth of 2.0 (the PyTorch fit gave 2.13), and nothing warned. It now raisesImportError, assp.tarnet,sp.cfrnetandsp.dragonnetdo. - ⚠️
sp.tarnet/sp.cfrnet/sp.dragonnet/sp.cevae/sp.gnn_causalrefuse a treatment not coded 0/1. Codes that truncate to 0 and 1 passed the check: with the treatment coded 0 / 1.5,sp.tarnetweighted its outcome heads by 1.5 and returned an ATE of 1.41 (truth 2.0 in 0/1 units);sp.gnn_causalcast 0.5 to 0. They now raiseMethodIncompatibility. -
⚠️
sp.support_points(weights=)scales the columns by the weighted standard deviation. The unweighted one was used, so the distances were those of a different distribution from the one the points represent. Unweighted calls are unchanged. Option scan round 6 ofdag/,causal_discovery/andcausal_llm/(tests intests/test_option_scan_r6_graph.py). 250 random graphs with latents and bidirected edges agree with Rdagittyon minimal adjustment sets, implied independencies and d-separation; the defects were elsewhere. -
⚠️
sp.dag: two bidirected edges could share one latent. The latent ofa <-> bwas named_L_a_b, soA_B <-> CandA <-> B_Cboth became_L_A_B_C, a common cause of all four nodes.A_BandAwere reported d-connected (d_separatedFalse, truth True), and adjustment sets and identification followed. A clashing name now gets a numeric suffix. Graphs without such a clash are unchanged. - ⚠️
sp.lingam(standardize=False)returned wrong coefficients when a column mean was not zero. The structural regressions have no intercept and the data were not centred. With means (5, -3, 10) and true slopes 0.9 and -0.7,adjacencygave -0.581 and -1.423; it now gives 0.880 and -0.682, the same asstandardize=True. The default path is unchanged. - ⚠️
sp.icptested each subset atalpha / (2**p - 1). ICP tests every set atalpha, and only the test of the true parent set matters for the guarantee, as in RInvariantCausalPrediction::ICP. The division made the answer depend on how many predictors were passed: adding five pure-noise columns to a design with one parent changedparentsfrom{'X1'}to the empty set. Results change wheneverp >= 2; more subsets are rejected, soparentsis larger or equal. - ⚠️
sp.pc_algorithm/sp.fcion a constant column or exactly collinear columns. A constant gave NaN p-values, which PC read as "dependent": it was joined to every variable (a -> k,b -> k, ...). A column equal to2 * amade PC's partial correlation return 0 on the singular matrix, which deleted the true edgea - c. Both now raiseDataInsufficientnaming the column. Categorical tests are unaffected. - ⚠️
sp.pc_algorithm,sp.fciandsp.notearsdropped boolean columns whenvariables=was not given (pandas countsboolas numeric;sp.gesandsp.lingamalready used them). They are now included, so the learned graph can gain a node. - ⚠️
sp.pcmci,sp.lpcmciandsp.dynotearson series with a missing value. One NaN made PCMCI drop two of three true links and DYNOTEARS return an all-zeroW. They now raiseDataInsufficient; deleting the row would shift every later lag. - ⚠️
sp.llm_dag_validatecounted untestable edges as unsupported. An edge touching a node that is not a column of the data, or a node declaredlatent=, gotsupported=Falseand enteredn_unsupported, the verdict for an edge the data rejects. Edges at declared latents are now skipped like the_L_*nodes of<->; the rest are reported in the newn_untestableand thetestablecolumn, with a warning naming the missing columns. A constant conditioning variable is dropped from the conditioning set (conditioning on a constant is conditioning on nothing) instead of making the test NaN. - ⚠️
sp.causal_maswith an LLM client mangled multi-word names and kept invented ones. The last word left of->and the first word right of it were taken, soyears of schooling -> log wagebecame the edgeschooling -> log, andFoo -> Barentered the consensus DAG. Each side is now matched againstvariables; lines that name no variable are dropped with aUserWarning. - Two-sided normal-approximation power counted only the upper rejection
region, so a zero effect had power
alpha / 2instead ofalpha. Fixed insp.power_rct,sp.power_did,sp.power_rd,sp.power_iv,sp.power_cluster_rct,sp.power_ols(and sosp.power/sp.mde),sp.power_two_proportions,sp.power_logrankandsp.power_case_control(test="wald").sp.power_rct(n=400, effect_size=0.01)moves from 0.0314 to 0.0511, which is Statapower onemean, knownsd(0.051146302810811). At conventional power the change is below 1e-6. One-sided power is unchanged. sp.epi.relative_risk/sp.epi.prevalence_ratioapplied the Haldane correction whenever a cell was zero, so the point estimate moved. They now report the plain ratio, as Statacs.(0, 80, 20, 80)gives 0 (was 0.0304) with a NaN interval and a warning;(10, 0, 5, 5)gives 2 with [1.0761, 3.7171], equal to Statacsi(was 1.909, [1.043, 3.496]).correction="haldane"keeps the corrected ratio.sp.epi.attributable_risk: the PAF interval held the exposure prevalence fixed even though it is estimated from the same table. The defaultprevalence="estimated"now uses the multinomial delta method, which is Statapunaf, vce(unconditional)up to the robustN/(N-1)factor.(40, 60, 20, 80)moves from [0.0943, 0.5093] to [0.0911, 0.5110]. In a simulation with a common outcome the old interval covered 91.4% and the new one 94.7%.prevalence="fixed"keeps the old interval.sp.aipw(design=)with PSUs drew the cross-fitting folds row by row, so a PSU's own rows trained the models that predicted it. Folds are now drawn over whole PSUs, ascluster=does since 1.41 (model_info['fold_unit']is'psu'). On the test design the estimate moves from 0.3150 to 0.4099. Designs without PSUs are unchanged.sp.cross_validate(estimand="dml"): the statspai engine ignoredn_foldsandseed, so it split the sample differently from the DoubleML engine (0.9759 againstsp.dml(n_folds=3, random_state=7)= 0.9956). Every engine also reported a 95% interval whateveralphawas; the intervals now have level1 - alpha.sp.dnc_gnn_did(embedding=<DataFrame>)turned the features into NaN (estimate 5.53 against a true 2 in a test panel); a DataFrame is now matched to units by its index and recovers 2.0. An array is read in sorted-id order and is refused when units do not appear in that order.sp.randomize(strata=)assigned only the last row of each duplicated index label. The other rows kept uninitialised values, so every unit could come out as control. Assignment now uses row positions and matches the same frame with a default index.sp.randomize(strata=..., cluster=...)ignoredcluster=and split clusters between arms (8 of 8 in a probe). It now randomizes whole clusters within each stratum, asrandomizr::block_and_cluster_radoes. A cluster that spans strata raisesMethodIncompatibility.sp.switchbackrandomization p-value counted only paths with a strictly larger |estimate|. With few coin flips it could be 0, and the test rejected 12.5% of the time at a nominal 5% (exact enumeration, 16 paths). Paths at least as extreme now count.sp.attrition_boundstreated rows with a missing treatment as control (lower bound 0.6625 instead of 0.7304 in a probe). Treatment codes 1/2 flipped the sign of the bounds. Missing treatment orobservedvalues, and observed rows with a missing outcome, are now dropped with a warning, the sample ofsp.lee_bounds. Codes other than 0/1 raise.sp.balance_checkomnibus F counted collinear covariates in its numerator degrees of freedom (F 0.237 instead of 0.357, the Rlmvalue). The degrees of freedom are now the rank.- ⚠️
CausalResult.tidy(conf_level=)rescales every row (option scan round 6,core/). At a level other than the fit-time one only the headline row was recomputed. The event-study and group-time rows kept their 95% bounds under the requested label. On thesp.dgp_didexample ofsp.callaway_santanna,tidy(conf_level=0.90)gave theevent_+0row a width of 0.4823 (the 95% width); it is now 0.4047 (2 * z_0.95 * se). An event-study table without stored bounds also fell back toatt -/+ 1.96 seat any fit-timealpha; it now uses the fit-time level. At the fit-time level the stored intervals are unchanged. - ⚠️
CausalResult.summary(alpha=)prints the interval it labels.summary(alpha=0.1)printed the stored 95% interval under[90% CI]. An interval of the formestimate -/+ crit * seis now rescaled, with the ruletidy()uses. Any other interval (bootstrap, bias-corrected, test inversion) is printed at its own level with a note. - ⚠️
y ~ x - 1 + 1,y ~ 0 + x + 1andy ~ x - 0keep the intercept (core/utils.py). The fast formula path andparse_formuladropped the intercept whenever-1/0/-0appeared anywhere. Rlmand patsy read the terms left to right, so a later+ 1re-adds the intercept and- 0means+ 1.sp.regress("y ~ x - 1 + 1")fitted a regression through the origin; it now equalssp.regress("y ~ x"). Formulas with a single intercept spelling are unchanged. - ⚠️
predict(what='confidence')uses the coefficients' reference distribution. Aftersp.regress(..., cluster=)the interval of the mean used t(N-K) while the coefficients use t(G-1); aftersp.glmwith the identity link it used t(N-K) while the coefficients use z. Atx = 0the prediction is the intercept with the same SE, and its interval now equalsconf_int()of the intercept (Statamarginsafterregress, vce(cluster)uses t(G-1) too). On a 200-row example with 8 clusters the 95% interval went from [1.1840, 1.6570] to [1.1369, 1.7041].what='prediction'keeps t(N-K), as Rpredict.lm. sp.bayes_dml(random_state=)seeded only the Monte Carlo draws of the posterior. The cross-fitting split ofsp.dmlkept its own default seed (42), so changingrandom_statenever moved the posterior. It now seeds the split too. The default (42) is unchanged. On the n = 400 fixture oftests/test_bayes_option_scan_r6.py,random_state=1gave the DML point 0.97837 (the seed-42 split) and now gives 0.94576, the value ofsp.dml(random_state=1).sp.bayes_iv(per_instrument=True)refitted each instrument ondata, dropping only the rows missing that instrument, so a sub-fit could run on more rows than the pooled fit it is reported next to. The sub-fits now use the rows of the pooled fit. With 10 of 120 rows missingz2, thez1posterior mean was 0.4318 (120 rows) and is 0.4757 (the 110 pooled rows). Without missing values nothing changes.predict,posterior_linpred,posterior_epredandposterior_predictof thesp.bayes_regressfamily (andsp.bayes_mixed,sp.bayes_shrink,sp.bayes_ivreg), andpredictofsp.bma, on new data with a missing regressor: a formula with a categorical term dropped that row, so five rows gave four predictions and each one after the gap sat against the wrong row. A formula of plain columns returned NaN for it. Both now raiseMethodIncompatibility, assp.bartandsp.gp_regressalready did. Complete new data give the same numbers as before.
Coordinator follow-ups to the round-6 scan:
sp.epi.odds_ratio(method="woolf")with a zero cell applied the Haldane correction to the point estimate, although its docstring says it reports the sample OR as Statacci.(0, 10, 5, 20)gave 0.1774; it now gives 0, with NaN interval and p-value and a warning, as Stata 18cci, woolf(r(or) = 0, no interval).correction="haldane"keeps the old numbers. Same rule asrelative_riskabove.
Fixed¶
sp.regtable(eform=[...]): the decimals of a row depended on column order, because every model's precision was read with the first column'seformflag.sp.mean_comparison(weights=)raisesMethodIncompatibility. The argument was "reserved for future use" and silently ignored, so a weighted request printed unweighted means and tests. Agroup=column with other than two distinct values also raises; a third value used to be dropped without notice.sp.ergm(node_attrs=DataFrame)with a defaultRangeIndexand node labels other than 0 .. n-1 reindexed the frame by label, got all-NaN attributes and failed with "SVD did not converge". Such a frame is now read in node order; a frame indexed by label is aligned by label; any other index raises aValueError.sp.het_test,sp.reset_testandsp.diagnosesaid they were equivalent to Stata'sestat hettest/estat ovtest. They are Rlmtestdefaults (Stataestat hettest, rhs iid;reset_test(powers=4)forestat ovtest); the docstrings now say so.sp.blp(alpha=)sets the level of the intervals insummary(); it was ignored and the intervals were always 95% (with z = 1.96, now the exact quantile).BLPResult.ci_alphaholds it.sp.blp:markups(),marginal_costs(),mean_utilityandown_elasticitiesare indexed like the data. They carried positions 0..N-1, so with shuffled or filtered input, assigning them back to the data by label put each value on the wrong product.sp.blprefuses missing values in the shares, prices, characteristics, instruments and identifiers withDataInsufficient. A missing share or price returned all-NaN estimates, a missing instrument value silently dropped the whole instrument, and a missing characteristic was reported as a collinear column.sp.olley_pakes/sp.levinsohn_petrin/sp.ackerberg_caves_frazer/sp.wooldridge_prod:result.samplekeeps the row labels of the data, sodata.loc[res.sample.index, col]brings in a column such as revenue forsp.markup(the example in its docstring did this and picked the wrong rows, since the index was reset).sp.DAG.add_edgerefuses an edge that closes a directed cycle (or a self-loop) withMethodIncompatibility. It used to accept it, after whichadjustment_setsreturned[]andd_separatedanswered as if nothing were wrong. The parser already refused cycles.sp.DAGqueries (adjustment_sets,frontdoor_sets,d_separated,all_pathsand the path methods built on it,path_status,classify_variable,bad_controls,do) andsp.swigraiseMethodIncompatibilityon a name that is not a node, as Rdagittydoes. Before,adjustment_sets('Q', 'Y')returned[set()]("adjust for nothing"),frontdoor_sets('Q', 'Y')returned[{'X'}]andd_separated('Q', 'Y')returnedTrue.sp.DAG.edgesis sorted, andbad_controlsiterates the nodes in sorted order. Both followed set order, which depends on the string hash, soedges,bad_controls,summaryand the row order ofsp.llm_dag_validatechanged between two runs of the same script.sp.DAG.adjustment_sets(minimal=False)documents that it lists backdoor sets. Rdagitty::adjustmentSets(type = "all")uses the adjustment criterion and also lists sets holding descendants of the exposure that lie off every causal path.sp.notearsandsp.dynotearswarn (RuntimeWarning) when the thresholded graph has a directed cycle, which happens when the optimiser stops early (max_iter=3on a six-node graph).sp.notearsalso returnsis_dag. Converged fits are unchanged.sp.lingamdrops rows with a missing value, like the other learners; a NaN used to stop innp.linalg.lstsqwith "SVD did not converge".sp.pc_algorithm,sp.fci,sp.icp,sp.pcmciandsp.lpcmcireject a significance level outside (0, 1);sp.pc_algorithmrejects a negativemax_cond_size.alpha=1.5used to return a complete graph.sp.llm_dag_constrainedwarns when the oracle raises (the result is then plain PC; the error was only stored inprovenance) and when the oracle proposes edges over names that are not invariables(they were dropped silently).sp.icpraisesMethodIncompatibilitywhenyorenvironmentis not aligned withX(it was anassert), and its summary no longer says "no subset accepted" when subsets were accepted with an empty intersection.sp.patehid separation of the experiment from the target sample: the participation probabilities were clipped bytrim=and the odds weights became constant, so the estimate was the unweighted experiment contrast. It now warns (AssumptionWarning) and recordsseparationandn_participation_clippedinmodel_info/diagnostics. Numbers are unchanged.sp.cross_validate(reference=)with a name that matches no engine fell back to"statspai"silently; it now raisesMethodIncompatibility.sp.ipcwread the event column withastype(int), so 2.0 or 0.5 were truncated silently. Values other than 0/1 now raiseMethodIncompatibility.sp.cluster_matched_pairsaid it was a size-weighted estimator with the variance of the cited paper. It is the equally weighted cluster-level difference in means with the matched-pairs t-test variance, which is consistent with a homogeneous effect and conservative otherwise (coverage 96% and 97.6% in a 50-pair simulation). The docstring says so, and the summary labels the interval with its level instead of a fixed 95%.sp.dnc_gnn_diddocstring promised a lagged-outcome stand-in network feature that was never built. Withoutembedding=no network feature enters;diagnostics['network_features']says which did.sp.randomizeraisesDataInsufficienton missing strata or cluster values. It used to return garbage integers or the last cluster's arm. It accepts categorical strata with unused levels, which used to crash.sp.attrition_testrequirestreatmentandobservedcoded 0/1. With 1/2 codes it reported a NaN control rate.sp.attrition_bounds(alpha=)andsp.balance_check(alpha=)are documented as unused.EconometricResults.coef(term)defaults to the fit-time level, astidy()(which it abbreviates) does. Aftersp.logit(..., alpha=0.1)it reported a 95% interval.sp.lpoly(alpha=)outside (0, 1) raisesMethodIncompatibility; it returned NaN intervals.sp.bayes_did,sp.bayes_rd,sp.bayes_fuzzy_rd,sp.bayes_iv,sp.bayes_hte_iv,sp.bayes_its,sp.bayes_synthandsp.bayes_mteraise oneConvergenceWarningper fit when R-hat exceeds 1.01, the effective sample size is below 400 or NUTS reports divergent transitions. The module convention promised this; the diagnostics were only printed bysummary(). Variational fits are not checked.sp.bayes_did(cohort=)warns when rows are dropped only because their cohort is missing. Never-treated units coded with a missing cohort used to vanish, comparison group and all, without a word; the docstring asks for a sentinel such as -1.sp.bayes_synthrefuses a panel with duplicated (unit, time) rows (MethodIncompatibility). It averaged them silently.sp.bayes_dml(mode='full')raisesMethodIncompatibilitybefore fitting anything. It used to fit the whole DML model and then raise a bareRuntimeError, becausesp.dmldoes not return the score arrays the full posterior needs.mode='conjugate'is unaffected.summary()of thesp.epi2x2, IRR, attributable-risk and NNT results printed "95% CI" whateveralphawas. The label now follows the level, stored in a newlevelfield.sp.community_detectionon a directed graph with an asymmetric adjacency ran the undirected algorithms on it silently. It now warns (AssumptionWarning) and suggests symmetrising; igraph refuses such a graph for Louvain and greedy.
Added¶
sp.eigenvector_centrality(mode=). On a directed graph the references disagree:sna::evcentscores a node by the nodes it points to (mode='out', the default and the existing behaviour), igraph'seigen_centrality(directed = TRUE)and networkx by the nodes that point to it (mode='in', equal to igraph to 1e-12). The docstring used to say the function matched networkx, which held only for undirected graphs.sp.power_two_proportions(test=):"chi2"is the Pearson test of Statapower twoproportionsand Rpower.prop.test(strict = TRUE)(0.861470522956074 at n = 400, p = 0.5 / 0.65, equal to both). The default stays"wald"in 1.42; see MIGRATION.md.sp.epi.relative_risk(correction="haldane")andsp.epi.attributable_risk(prevalence=).
Deprecated¶
sp.interference("parmigiani")warns (DeprecationWarning) and will be removed in 1.43. Use"network_hte", the same estimator.
[1.41.0] — 2026-10-27¶
Three pieces of work. The defaults that 1.40.0 and 1.40.1 announced with a
FutureWarning take effect ("Changed" below). Deprecations whose removal
date has passed are removed ("Removed"). And two rounds of option scans of
modules that had never been tested option by option: spatial, bartik,
survival, epi, mendelian, qte, bounds, selection,
postestimation, inference, robustness, survey, multilevel, gmm,
metalearners, tmle, conformal_causal, policy_learning, power,
rlasso, crossval and interference. Each defect was reproduced before
it was fixed, against a reference implementation, a known truth or an
identity; the tests are in tests/test_*_scan_oct2026*.py,
tests/test_option_scan_r5_*.py and tests/test_te_ml_scan_round5.py.
The ⚠️ entries change numbers.
Changed (defaults announced for 1.41)¶
The defaults that 1.40.0 and 1.40.1 announced with a FutureWarning take
effect, and the warnings are gone. Calls that named the argument are
unaffected. See MIGRATION.md, "1.40.2 → 1.41".
- ⚠️
crit_dist='t'is the default ofsp.event_study,sp.sun_abrahamandsp.did_2x2(owner decision 2026-10-21). P-values and intervals use t(G-1) with cluster-robust SEs and t(N-K) otherwise, as Statareghdfe/regress/eventstudyinteractand Rfixestreport. Estimates, SEs and the pre-trend tests do not change. On the castle-doctrine panel thesp.event_studylead at -3 moves from p = 0.0467 to 0.0523 and the headline ATT interval from [0.05067, 0.14474] to [0.04948, 0.14593], equal to Statareghdfe/lincomto 1e-10; the clusteredsp.did_2x2interval moves from [-0.09716, 0.23364] to [-0.10174, 0.23821] (Stataregress, vce(cluster)).crit_dist='normal'(event studies) /'residual'(did_2x2) keeps the 1.40 numbers. Library entry points that pinned the old value now follow the default:sp.did_analysis,sp.did_summary,sp.auto_did,sp.compare_event_study_conventions,sp.causal_question,CausalWorkflow,sp.compare_estimatorsand the calibrated simulation.sp.did(crit_dist='legacy')still selects the pre-1.41 rule. - ⚠️
sp.sun_abrahamreports the cell-weighted ATT by default (aggregation='fixest_att',fixest::summary(agg = "att"); owner decision 2026-10-20, item 3). Onmpdtathe headline moves from -0.0772 (equal-weight mean of the post event-time effects) to -0.0400 (R fixest -0.03995).aggregation='event_time'keeps the 1.40 headline; both numbers stay inmodel_info.sp.did(method='sun_abraham'),sp.did_analysis(method='sa')andsp.auto_didfollow. - ⚠️
sp.hdfe_olsmulti-way clustering usescluster_df='min'by default (item 2): every term of the inclusion-exclusion sum carries G_min/(G_min-1), as Statareghdfeand Rfixestdo, instead of its own G/(G-1) (Rsandwich::vcovCL). On the weighted two-way fixture oftest_panel_absorb_ols_parity.pythe x2 SE moves from 0.038146 to 0.038382, equal to reghdfe to 1e-10. One-way clustering, fits without absorbed effects and the IV path are unchanged;cluster_df='per_term'keeps the 1.40 SEs.sp.psm_did(fixed_effects=, cluster=[...])andsp.panel(method='hdfe'), which callsp.hdfe_ols, follow.sp.absorb_olskeeps'per_term'. sp.bcfissp.bart_causal(method='bcf')(item 4): the Bayesian causal forest with BART priors and an MCMC posterior, withsp.bcf's argument names (n_trees_mu,n_trees_tau,alpha,random_state/seed; other options such asdraws=/chains=pass through). It returns aBayesCausalResult(.cateis a DataFrame; there is nomodel_info['cate']).n_bootstrap=/n_folds=belonged to the old random-forest / boosting approximation and now raiseMethodIncompatibility; that approximation, and its numbers, issp.BayesianCausalForest(...).fit().sp.bcf_ordinal,sp.bcf_factor_exposureandsp.did_bcfkeep it and are unchanged (sp.did_bcfno longer emits thesp.bcfnotice).sp.interference('partial' | 'hudgens_halloran')is the Hudgens-Halloran two-stage estimator (sp.two_stage_interference,group=andsaturation=), the same as'two_stage'. Through 1.40 the two names routed tosp.spillover; a call that still passessp.spillover's arguments under them raisesMethodIncompatibilitypointing to'spillover'.- Removed: the fitted-value keys of
diagnosticsofsp.zip_model,sp.zinbandsp.hurdle(predicted_overall,predicted_count,predicted_structural_zero,predicted_hurdle_prob,predicted_count_mean), deprecated in 1.39.0. Read them fromdata_info. Results pickled by 1.39 / 1.40 still load.
⚠️ Correctness¶
sp.hdfe_ols/sp.absorb_olswithout the Rust extension (STATSPAI_SKIP_RUST=1, or a platform without astatspai-hdfewheel): the pure-Python demean stops each column on step size, so on a slowly converging fixed-effect graphycould stop short whileXwent to the sparse direct solve. The slope was right and the residual variance was not: on the weighted chain design oftests/test_fe_direct_fallback.pythe SE of x was 0.0361215 against 0.0361189 for the dummy-variable regression.yandXare now demeaned together, and when the direct solve has to finish any column it finishes all of them. Fits whose projections converge are unchanged.sp.impactssimulated its standard errors fromdiag(se^2), dropping the covariance between the coefficients andrhothat the indirect and total effects depend on. It now draws from the joint covariance, whichsp.sarandsp.sdmstore inmodel_info['vcov'](soresult.vcov()works for them too) andsp.sacalready stored. On a 150-unit SAR fixture (tests/test_spatial_scan_oct2026.py) the SEs of the direct / indirect / total effect move from 0.124 / 0.332 / 0.428 to 0.084 / 0.203 / 0.197;spatialreg::impacts(R = 20000)gives 0.084 / 0.203 / 0.195. Point impacts are unchanged. Draws that leave the stable range are still discarded, now with a warning and a count inattrs.sp.SpatialModel(...).fit()kept its own fit with the standard-error formulassp.sar/sp.sem/sp.sdmhad replaced (therhoSE withouttr(G'G),betaSEs that treatrhoas known, the oldlambdaSE) and ignoredalpha. It now calls those functions, so the class and the functions give one set of numbers. On an 80-unit fixture the SARrhoSE moves from 0.109 to 0.031 and the SDM intercept SE from 0.113 to 0.201.sp.moran_localpermuted all n values,z_iincluded, so its replicates were centred at 0 instead ofE[I_i] = -z_i^2 w_i. / ((n - 1) m2). It now uses conditional randomisation (Anselin 1995):z_istays put and the neighbours draw from the other n - 1 values. On a 150-unit field the largest gap between the replicate mean andspdep::localmoran_perm'sE.Iifalls from 0.077 to 0.018 (9999 draws each);p_simchanges everywhere (one test unit: 0.818 to 0.621).Isis unchanged.sp.spatial_diddropped rows with a missing outcome before it built the spillover exposureWD, so a unit-period with an observed treatment but a missing outcome counted as untreated for its neighbours (and could move a unit's event time).WDand event time now come from every observed treatment value. With one missing outcome on a 12-unit ring the direct and spillover effects move from 1.188 / 0.314 to 1.162 / 0.420, equal to the dummy-variable regression on the same rows (truth 1.0 / 0.5). Complete panels are unchanged.sp.sar,sp.semandsp.sdmsearchedrho/lambdaonly on 0.99 times the admissible interval, cut at -0.99 and 0.99. The interval is(1 / omega_min, 1 / omega_max)over the eigenvalues ofW, Rspatialreg's default. When the smallest eigenvalue is above -1 (often with row-standardisedW) an estimate below -0.99 came back as -0.99, and with an unstandardisedWthe last 1% below1 / omega_maxwas out of reach. On a row-standardised 120-unit ring with smallest eigenvalue -0.5 (tests/spatial/test_impacts_interval_oct2026.py) the SARrhomoves from -0.990 to -1.368 and the log-likelihood from -219.56 to -210.79, both equal tospatialreg::lagsarlm(rho to 2e-11); SEMlambda-0.990 to -1.330, SDMrho-0.990 to -1.318. On a binary ring withomega_max = 4the SARrhomoves from 0.2475 to 0.2480 (R 0.2479714). The search still starts on the old bracket and widens only when it stops on one of its ends: in a before/after check 42 of 45 fits are identical bit for bit, and the other three had stopped at -0.99. The large-npath without eigenvalues keeps (-0.999, 0.999).sp.impactskept a draw when its spatial parameter was inside (-0.999, 0.999), right only for a row-standardisedWwith smallest eigenvalue -1. It now uses the admissible interval ofW(real parts of the eigenvalues), asspatialreg::impactsdoes. With an unstandardisedW, draws past1 / omega_max, whereI - rho Wis singular or its inverse changes sign, were kept: forsp.sacon a 144-unit binary ring (omega_max = 4, rho -0.003 with SE 0.133) 603 of 20000 draws lay past 0.25 and the SE of the direct effect falls from 3.29 to 0.364. With a row-standardisedW, valid draws below -0.999 were dropped, and a fit withrhobelow -0.999 was refused. Point impacts are unchanged.sp.bartikpairedsharesrows withdatarows by position although its docstring asks for aligned indexes; a reordereddatapaired regions with other regions' shares (estimate -4.90 against 1.55 on the test fixture). Rows are now matched by label when both carry the same labels, a partial overlap is refused, and disjoint labels keep the positional contract.sp.ssaggregatedoes the same forshares, and matches a labelledshocksSeries to the share columns (reversing the Series moved the estimate from 1.55 to 1.98).sp.shift_share_politicalbuilt long differences withgroupby().first()/last(), which skip NaN column by column: a unit whose outcome was missing in the first period was differenced from a later one. A missing endpoint is now refused (the estimate on the fixture was 2.092 against 2.110 without that unit).sp.slxreported a log-likelihood with the df-correctedsigma^2; it is now the Gaussian ML log-likelihood (-82.192 to -82.153 on the fixture), equal to RlogLik(lmSLX)to 1e-4.sp.cohen_kappa(unweighted, the default): the standard error used1 - p_i.where Fleiss, Cohen & Everitt (1969) havep_.i, plus a stray constant. On a 40/10/10/40 table it was 0.060; it is now 0.080, the value of Rvcd::Kappaandpsych::cohen.kappa. The CI and p-value follow. Weighted kappas were right and are unchanged.sp.mantel_haenszel: the continuity-corrected chi-square subtracted 0.5 from|sum a - sum E[a]|even when that was below 0.5. On strata whose exposed-case total equals its expectation the p-value was 0.813; it is now 1, as Rmantelhaen.test. Other tables are unchanged.sp.epi.incidence_rate_ratio:method="exact"(the default) paired the exact interval with the Wald p-value. It now reports the exact conditional binomial test of Rpoisson.test: 40 vs 20 events in equal person-time gives 0.01349 (was 0.01137). A zero count made the Wald statistic infinite or zero: 3 vs 0 events gave p = 0 and 0 vs 3 gave p = 1. Exact now gives 0.25; Wald uses the same 0.5-floored counts as its standard error.sp.aftis now fitted bysp.survreg's exact maximum likelihood. It stopped L-BFGS-B about 1e-5 (relative) from the MLE and took standard errors from a finite-difference Hessian (7e-6 off). Coefficients, standard errors and the log-likelihood now equal Statastregto 1e-6 / 1e-8. It also refused nothing: a zero duration was clamped to 1e-12 and an event coded 2 entered the likelihood as2 log f - log S. Both now raise.sp.mr_multivariable:conditional_f_statswas the F of regressing one exposure's associations on the others, which is large (infinite) exactly when the exposures are collinear and instruments are conditionally weak. It is now the two-sample conditional F of Sanderson, Spiller & Bowden (2021), equal to their R packageMVMR::strength_mvmr(gencov = 0)to 1e-12. It needs the newexposure_se=; without it the entries are NaN.sp.evalue(measure='RR' | 'OR' | 'HR', se=...)withoutci=built the interval asestimate +/- z seon the ratio scale.seis now read as the standard error oflog(estimate), what logit / Poisson / Cox report, and the interval isestimate * exp(+/- z se). For RR = 2, se = 0.15 the lower limit moves from 1.706 to 1.491 and the E-value for the interval from 2.803 to 2.346. Calls that passci=are unchanged.sp.causal(...).sensitivity_panel(): the E-value row passed the workflow's estimate, a difference on the outcome's scale, tosp.evalueas a risk ratio, so it changed with the units ofy: 1.83 on a simulated OLS fit, 15.37 on the same data withymultiplied by 10. It now goes throughsp.evalue_from_resultas a mean difference standardised bysd(y)(2.99 in both units). A result whose scale cannot be established (a logit or Cox coefficient) is skipped and recorded indegradationswith aWorkflowDegradedWarning.sp.mr_f_statistic: withoutn_samplesthe number of variants stood in for the GWAS sample size inR^2 = F / (F + n - 2)(R^2 0.8 for F = 100 and 25 variants).r2_meanis now NaN unlessn_samplesis given. The F-statistics are unchanged.sp.test,sp.lincom,sp.nlcomandsp.hypothesesafter a fit reported as incidence-rate ratios (sp.poisson/sp.nbreg/sp.xtnbregwithirr=True) work on the coefficientsb, as Stata'stest/lincom/nlcomafterpoisson, irrwork one(b). They read the reportedexp(b)as the coefficient:sp.test(fit, "x1 = 0")tested IRR = 0 with the IRR's standard error (Wald 560.9 where the coefficient test gives 64.2 on the test data), and tests of several coefficients refused. Answers now equal those after theirr=Falsefit.result.predict(data)after the sameirr=Truefits used the reportedexp(b)as the coefficients, so predictions on supplied data wereexp(X exp(b))(up to 27 times the stored fitted values on the test data) and their intervals used the IRR-scale covariance. Predictions, link values and intervals now equal those of theirr=Falsefit. Predictions withoutdatawere already right.sp.margins_at,sp.margins(at=),sp.contrast,sp.pwcompareandsp.predictions/comparisons/slopeswithnewdata=refuse a factor level the fit never saw. WithC(g)such a row got zeros in every dummy, so the answer was the base level's, without a word.predict()already refused.sp.predictions(newdata=)andresult.predict(data)evaluate a formula with a stateful transform (center(x),standardize(x),bs(x)) with the values memorised at fit time. They re-memorised them on the new rows, soy ~ center(x)predicted on four rows was centred on those four rows. The design is now memorised on the frame the model was fitted on, when the result still reaches it and that frame reproduces the stored design. Other formulas give bit-identical numbers.sp.twoway_clusterreturns a result whose inference all reads the two-way covariance and t(min(G1, G2) - 1).predict(what='confidence')used the input fit's covariance.predict(what='prediction')divided the residual sum of squares by min(G1, G2) - 1 instead of n - k (error variance 28.75 instead of 0.868 on a 300-row example with 10 years). After a clustered or CR2 input fit the p-values used the input's degrees of freedom while the intervals used min(G1, G2) - 1. Logit, probit, Poisson and GLM fits are refused: the sandwich has the least-squares bread.sp.conleyafter a weighted fit (sp.regress(..., weights=)) uses the weights. The sandwich was built from the unweighted design and residuals. The bread is nowX'WXand the scorew x e, assp.twoway_clusterdoes. On a 300-row example with a 300 km cutoff the SE of x1 moves from 0.1624 to 0.2356; with no neighbour pairs it equals the weighted HC0 sandwich (0.1985; it was 0.1568). A fit whose weights are recorded but not stored row by row is refused.sp.conleyrefuses logit, probit, Poisson, negative binomial and other non-Gaussian likelihood fits, to which it applied the least-squares bread. For Poisson modelssp.fepois(vce='conley')andsp.ppmlhdfe(vce='conley')give the likelihood sandwich.- The result of
sp.conleycarries the Conley covariance and its own degrees of freedom. It kept the input fit'svar_covanddf_inference, so after a clustered fit the p-values used t(G - 1) while the intervals used t(n - k), andpredict(what='confidence')used the pre-Conley matrix. Aftersp.regress(..., cluster=)on 30 clusters the p-value of x2 moves from 0.0786 to 0.0692 (SEs unchanged). After a GMM / LIML IV fit with z inference the Conley p-values are now t(n - k), like its intervals. sp.front_door(covariates=)weights the arms of the inner sum byP(D = d' | X), the conditional front-door formula. It used the marginalP(D = d'), right only when the covariates do not predict the treatment. On a population whose conditional front-door ATE is 1.134 the estimate moves from 0.773 to 1.124 (n = 200,000). Calls without covariates are unchanged to the last bit. A failed treatment logit warns and setsmodel_info['treatment_model_degraded'].sp.suest(cluster=)counts the clusters of the rows some equation uses, as Stata'ssuestcounts those ofe(sample). Clusters whose rows every equation dropped raised G. With 20 used and 5 unused clusters the SE ofy1:xmoves from 0.07840 to 0.07881.sp.spec_curve(alpha=)sets the level ofci_lower/ci_upper. They were 95% intervals whateveralphawas, whilesignificantusedalpha. Withalpha=0.10on 200 rows the interval was [0.1512, 0.4956]; it is now the 90% interval [0.1791, 0.4677].sp.spec_curve(cluster_var=)no longer drops rows with a missing cluster id from thenonrobustandhc1specifications. With 20 of 200 ids missing the nonrobust estimate was 0.2806 on 180 rows; it is now 0.3234 on 200 rows, thesp.regress("y ~ x")number.sp.spec_curveleaves out a specification whose regressors are linearly dependent.np.linalg.invinverted a numerically singular X'X (a control equal toa + bin floating point) and reported 0.3612 (se 0.0757) where the identified coefficient is 0.3698. Such specifications now raise anAssumptionWarningand are listed inmodel_info["dropped_specs"]. Exactly singular ones were already left out, silently.sp.robustness_reportrefers the clustered check to t(G - 1), assp.regress(cluster=)and Stataregress, vce(cluster)do. It used t(n - k). With 5 clusters the p-value of that row was 4.1e-16; it is now 8.9e-4, equal tosp.regress.sp.robustness_reportandsp.subgroup_analysisrefuse a formula with absorbed fixed effects (y ~ x + a | g). The| gpart was parsed away and every check ran without it.sp.copula_sensitivityandsp.survival_sensitivityreport a breakpoint when the adjusted interval crosses zero between two grid values. With a small SE a reversed effect was called robust:sp.copula_sensitivity(0.32, 0.001)gave no breakpoint and now gives 0.35;sp.survival_sensitivity(0.4, 0.001)now gives 1.5 instead of none.sp.ltmle_survivalset only the current interval's treatment to the regime and left earlier treatments at their observed values, so any lagged treatment effect was diluted. It is now the sequential-regression TMLE ofsp.ltmle, run per horizon, with a unit whose event has happened keeping outcome 1. On a two-interval randomised design with a lagged effect (n = 20000) survival at t = 2 under always-treat moves from 0.7211 to 0.7714 and under never-treat from 0.5858 to 0.5296, against true values 0.7800 and 0.5344. Treatment and censoring models are now fitted on the at-risk units.sp.conformal_cate:sewas a bootstrap of the fitted CATEs, which treats the fitted effect function as data (the bugsp.metalearnerfixed in 1.11.4).estimate/seare now the cross-fitted AIPW average and its influence-function SE, equal tosp.metalearner(learner='t')with the same outcome model. On a randomised design with n = 2000 the SE moves from 0.0015 to 0.0498 (difference-in-means SE 0.045). The plug-in mean ismodel_info['cate_mean']. The intervals are unchanged.sp.conformal_fair_itecalibrated one quantile of single-arm residuals, which bounds one potential outcome, not the effect. Each arm now gets a quantile at1 - alpha / 2within each group and the half-width is their sum, the construction ofsp.conformal_cate. Coverage ofY(1) - Y(0)at nominal 90% moves from 0.736 to 0.992 (conservative, as the union bound is) in a 3000-row simulation.sp.focal_cate:se_gridwas the residual SD of the pseudo-outcome regression and did not shrink with n (20 times the sampling SD in a simulation). It is now the HC1 standard error of the linear CATE prediction (ratio to the Monte Carlo SD about 1).sp.policy_treereported the value of the learned policy, and its gain over the best uniform action, on the same scores the tree was chosen to maximise. Under scores with no heterogeneity the gain is positive by construction and its 95% interval covered 0 in 22 of 100 draws. The new defaultvalue_evaluation='cross_fit'grows the tree on the other folds and evaluates each held-out row, unbiased for the value of the procedure (coverage 0.92 to 0.97 with heterogeneity, 0.81 to 0.85 at an exact null).value_evaluation='in_sample'keeps the old numbers with a warning, andvalue_policy_in_sample/value_gain_in_sampleare always returned. The deployedpolicy/treeare unchanged.sp.auto_cate:blp_beta1_pvaluetestedbeta1 = 1. In this regressionbeta1is the ATE, so it now testsbeta1 = 0, assp.blp_testdoes (with ATE = 1 the p-value moves from 0.165 to below 1e-6).sp.ips/sp.snips/sp.doubly_robustwith the behaviour propensity fitted internally and actions not coded0..K-1: the propensity column was indexed by the action label, which raised inside the fit and fell back to uniform propensities. With actions {1, 2} logged at 0.3 / 0.7 the IPS value of always playing 2 moves from 1.392 to 0.999 (0.994 with the true propensities).sp.cate_by_group,sp.blp_testandsp.gate_testtook the firstlen(cate)rows ofdata, so aftersp.metalearnerdropped rows with missing values each effect was paired with another unit's outcome and covariates.sp.metalearnernow records its estimation rows (model_info['estimation_rows']) and the diagnostics use them; a frame that cannot be paired row by row raises.sp.selection_boundsnow runs thesp.lee_boundscode (trimming'quantile'). The unconditional bounds keptfloor((1 - p) n)observations, the rulesp.lee_boundsdropped in 1.29, so each bound was off by one observation's mass. The conditional bounds weighted covariate strata by their share of the whole sample and dropped strata missing an arm; Lee's tightened bounds weight by the always-observed. On a 1,203-row example the unconditional lower bound moves from -0.06854 to -0.06479 and the conditional one from -0.01186 to 0.00072, each now equal tosp.lee_bounds(which matches Stataleebounds) to 1e-12. Rows with a missing treatment or selection indicator, or selected with a missing outcome, are dropped with a warning as insp.lee_bounds.sp.iv_bounds(assumption='monotone_iv')returned[min(OLS, IV), max(OLS, IV)]. When the instrument and the treatment are positively correlated both estimates are biased the same way and the true slope lies outside that interval (OLS 1.50, IV 1.20, truth 1.00 on the test design). It now returns the Nevo-Rosen identified set under their A3 and A4 (working paper Propositions 2 and 3,nevo2012identification): the IV estimates withzand withV(1) = sd(z) x - sd(x) zas endpoints, a closed interval whencov(z, x) < 0after the controls and a half-line otherwise. Newxu_sign=states the assumed sign ofcorr(x, u).model_info['first_stage_f']wasn corr(z, x)^2(1508.5 on a 3,000-row example) and is now the first-stage F (3028.2).sp.balke_pearl: the monotone bounds always tookZ = 1as the arm with more take-up. With an instrument that lowers take-up they came out wider than the bounds without monotonicity ([-0.85, 0.95] against [0.29, 0.49]). The direction is now read from the first stage (detail['z_hi']).sp.qte(method='conditional_qr')solved the quantile regression by an iteratively reweighted least-squares loop that stops near the minimiser, not on it. It now uses the exact solver ofsp.qreg(=quantreg::rq): on a 500-row example the 0.75 effect moves from 1.1121 to 1.0943.sp.best_subsetnever compared the model without candidates, so it selected at least one variable even when none helps (BIC 1133.79 for a noise regressor against 1128.64 for the intercept alone). The empty model now competes; its statistics are inhistory.attrs['null_model'].sp.stepwise(criterion='pvalue')also required each move to lower the BIC, so at n = 2000 a term needed p < 0.006 to enter whateveralpha_insaid. Moves now follow the p-values alone: the least significant term leaves while p >=alpha_out, else the most significant candidate enters while p <alpha_in.horowitz_manski/iv_bounds/oster_delta/selection_bounds(BoundsResult): each bound's interval was centred on the median of the bootstrap draws rather than on the estimate (lower-bound interval of ahorowitz_manskiexample [-8.470, -7.062] becomes [-8.481, -7.073]); fewer than two usable draws gave an SE of 0, now NaN.summary()andplot()printedlower - z se, upper + z sewith the two-sided z under the label Imbens-Manski; they now use the Imbens-Manski critical value (BoundsResult.parameter_ci()), assp.lee_boundsdoes.sp.mixed(group=[outer, inner]): random effects were keyed by the inner label alone. When class ids repeat across schools (class 1, 2, ... in every school) the classes merged and every one got the first school's random effect, sopredict(data)andrandom_effectswere wrong (up to 3.84 on the test panel); estimates were not affected. Keys are now the(outer, inner)pair andrandom_effectshas a two-level index.predict()without data dropped the random part altogether and returnedX b; it now adds it.sp.mixed"LR test vs. linear model" (summary(),_lr_test): with one variance parameter the p-value was the chi2(1) tail, twice Stata's chibar2(01); under REML the statistic compared the ML likelihood at the REML estimates with the OLS likelihood. It now compares REML with REML and ML with ML and uses chibar2(01) for one parameter, chi2(k) otherwise, as Stata 18mixed. On the test fixture: ML p 8.72e-5 to 4.36e-5, REML statistic 15.378 to 15.902 (Stata 15.901683, 4.35977e-5 and 3.33597e-5).sp.xtlsdvc(bootstrap=): the parametric bootstrap regeneratedyasrho y_{t-1} + eover consecutive rows, without the regressors, the unit effects or gaps, contrary to its docstring. It now drawsrho y_{t-1} + x'b + eta_i + eon the cells the estimation used. On a simulated panel (N = 150, T = 8) the SE of rho moves from 0.0339 to 0.0268; the Monte Carlo SD of the estimator is 0.0272. Failed replications are counted (model_info['bootstrap_failed']) and warned about instead of being skipped silently.sp.gmm(vcov='hac')withouthac_bandwidthused bandwidth 1, which weights no lag, so it equalledvcov='mds'. The default is nowfloor(4 (n/100)^(2/9))lags, the rulesp.regress/sp.glmuse (model_info['hac_lags']). On n = 400: SEs 0.07107 / 0.07856 to 0.07653 / 0.07645.sp.gmm(method='onestep')reported Hansen's J with a chi2 p-value at any weight; it is chi2 only at the efficient one, and Stata 18estat overidis "not available after one-step estimation".J_stat/J_pare nowNonewithJ_note.sp.meglm(family='binomial', trials=):n_obswas the total number of trials, so BIC used it (1500 trials on 300 rows: BIC 913.15 to 908.32). It is the number of rows, as Statameglmand lme4 count.MEGLMResult.conf_int(),odds_ratios(),incidence_rate_ratios()ignored the fit'salphaand always gave 95% intervals (summary()did use it). They now default to the fit's level.sp.svymean/sp.svytotal/sp.svyglmsubpop=as an array or Series: a missing value counted as inside the domain (NaNcast toTrue); it is now outside, as for a column name and Statasubpop(). A string column ("yes" / "no") put every row in the domain and is now refused.sp.svydesign(cluster=)with a missing PSU id: withnest=Trueit became the PSU "stratum__nan". It is refused, as a missing stratum already was.sp.distributional_te: the bootstrap SEs of the DTE and QTE (and so the pointwise bands) usedddof=0, while every other bootstrap SE inqte/usesddof=1. They now useddof=1and grow bysqrt(B/(B-1)). On the docstring example (n = 500,n_boot=50,seed=42) the median QTE SE goes from 0.13386 to 0.13522. Point estimates and the KS / CvM p-values are unchanged. A column with fewer than two finite draws now has a NaN SE instead of 0.sp.epi.attributable_risktook its point estimates from the Haldane-corrected RR whenever any cell was zero, even one that leaves RR finite. For the table 10, 0, 5, 5 it gave RR 1.909, AF exposed 0.476 and PAF 0.3125; Statacsigives 2, 0.5 and 1/3, as now. With no unexposed case the PAF is 1 (was 0.852 on 5, 10, 0, 20). The interval uses the raw counts and is NaN, with a warning, when an exposure group has no case; with no case at all the call raises. Tables without a zero cell are unchanged, and their PAF interval equals Statapunafto 3e-9.sp.epi.number_needed_to_treat: when the risk-difference interval contains zero the NNT interval was(inf, inf). It is now the interval for |NNT|,(1 / max(|L|, U), inf): for 30/100 against 20/100 that is (4.56, inf). The newci_signedholds(1/L, 1/U)= (-52.03, 4.56), equal to RepiR::epi.2by2, andci_disjointflags Altman's (1998) disjoint interval, whichsummary()prints as "NNTH 4.56 to infinity to NNTB 52.03". A risk-difference limit of exactly 0 raised ZeroDivisionError; it now gives an infinite NNT limit.sp.ndcgbroke tied scores in row order, so reordering the rows changed the value (1.0 or 0.613 for the same six cases). Tied cases now share their credit, the average over all orderings of the tie, assklearn.metrics.ndcg_score: 0.742 there. Scores without ties are unchanged and still equalfarr::ndcg.sp.cross_validate(weights=)validated the weight column and then fitted every engine unweighted, so the verdict was AGREE on a model nobody asked for (slope 0.788 against the weighted 1.104). The statspai, pyfixest, linearmodels, R fixest and Stata engines now fit the weighted model; engines that cannot (DoubleML, R did, the statspai DML and DiD paths) are reported as skipped. Other fixes in the same module. The statspai engine dropped the fixed effects ofy ~ x | fe | d ~ z(0.434 against 0.733 in pyfixest and fixest, reported as DISAGREE). Estimand"ols"with a| feblock fitted pooled OLS in the statspai and linearmodels engines; it is now"feols". The DoubleML engine fitted the partially linear model whatevermodel=said, so an IRM ATE was compared with a PLR coefficient;model='irm'now runsDoubleMLIRMand other models are skipped. linearmodels clustered on the first of several cluster variables under a multiway label; it now skips multiway clustering. The Stata engine wrote novce()and no weights, read the CSV as floats and wrote results with%10.0g; it now writesvce(robust)/vce(cluster ...),[aweight=],smallforivregress, reads doubles and writes%21.0g. The generated do-file reproduces the statspai engine to 1e-15 in Stata 18 (weighted robust OLS, clustered 2SLS, reghdfe).sp.iv(absorb=)charged the absorbed fixed effects to the SEs but kept the t statistics and p-values of the unscaled SEs, and leftdata_info['var_cov']unscaled. On a 60-row design the t ofdwas 0.861 with p = 0.393 where coef / SE is 0.734 and fixest gives p = 0.467. Coefficients, SEs and intervals are unchanged.sp.rlasso_effect(partialling out),sp.rlasso_effectsandsp.rlasso_iv(select_Z=False, select_X=True)acceptedvce='robust'and returned hdm's homoskedastic SE (0.167 where the robust SE is 0.252 on a heteroskedastic design). A newrobust=argument (set byvce='robust'/vce='iid') gives the robust or classical variance on every path; the defaults stay those of hdm.sp.rlassoand everything built on it (rlasso_effect,rlasso_iv,rlassologit, the DML learners) took a missing value in one column ofXas "nothing selected":rlasso_effectthen dropped every control without a word. Missing or infinite values now raiseDataInsufficient.sp.power_two_proportions,sp.power_logrankandsp.power_case_controlread everyalternativeother than"two-sided"as one-sided, so R's"two.sided"gave one-sided power (0.923 against 0.866). Two-sided and one-sided spellings are recognised; anything else raises.sp.power(..., power=)solving for the sample size never returned less than 11: a cluster RCT that needs 3 clusters (power 0.858 at 3, 0.697 at 2) came back as 11.sp.mdesearched on an absolute scale, sosigma=1000raised andsigma=1e-4kept two digits (5.6e-05 against 5.60318e-05). The search is now on the scale ofsigma; withsigmaother than 1 the MDE can move in the sixth significant digit.sp.cluster_staggered_rolloutcounted event time in calendar units, so periods coded 2000, 2002, ... lost every cohort ("Could not estimate any event-time ATTs") and a single gap dropped the cohorts next to it. Event time is now counted in observed periods (identical on consecutive integers). A NaNfirst_treat, the documented never-treated code, was dropped with its rows: with 5 of 10 controls coded NaN the ATT was 1.666 against 1.529.sp.peer_effectsreturned a minimum-norm estimate with a small SE when the instruments cannot identify the peer effect (complete groups of equal size, the reflection problem): 0.673 (SE 0.029) for a true 0.3. It now raisesIdentificationFailure.sp.network_htereportedsigma^2 (X'X)^{-1}for the orthogonal-score regression; the SEs are now the sandwich (HC0), equal to statsmodelscov_type='HC0'on the same residuals.sp.aipw(cluster=)with cross-fitting drew the folds row by row, so the nuisance models that predicted a cluster were trained on rows of the same cluster and absorbed its shared shock. In a cluster-randomised trial (50 clusters of 12, cluster shock with SD 2, a nearest-neighbouroutcome_learner;tests/test_te_ml_scan_round5.py) the 95% interval covered the true ATE in 85.8% of 1000 replications, with a mean SE of 0.49 against a sampling SD of 0.63. The folds are now drawn over whole clusters (one seeded draw per cluster, in sorted-label order, so the split does not depend on row order): coverage 94.6%, mean SE 0.74 against an SD of 0.76. In the first replication the estimate moves from 2.012 (SE 0.509) to 1.957 (SE 0.717). Withoutcluster=, or withcross_fit=False, nothing changes.model_info['fold_unit']records'row'or'cluster'.sp.aipw: an arm with no training rows in a cross-fitting fold got an outcome model that predicted 0 for every held-out row, silently. It now raisesDataInsufficient. An arm with one or two training rows still falls back to its mean, now with aRuntimeWarningand a record inmodel_info['outcome_arm_mean_fallback']; withweights=that mean is the weighted mean (it was unweighted).sp.metalearner(learner='r', weights=)with a continuous treatment: the residuals behindestimateandsecame from unweighted fits of E[Y|X] and E[D|X], although the docstring promised weighted fits. They are weighted now (the CATE function stays unweighted, as documented). With linear nuisances and fixed folds the estimate equals a hand-written weighted cross-fit to 1e-16; on that fixture it moves from 1.4743 to 1.5455.sp.weighted_conformal_predictiongave the test point weight 1 instead of its own likelihood ratio in the weighted quantile of Tibshirani, Barber, Candès and Ramdas (tibshirani2019conformal). Under covariate shift that breaks the coverage guarantee: training covariates N(0, 1), test covariates N(1.5, 1), 30 calibration points,alpha=0.1, and the intervals covered 81% of test outcomes. A newweights_test=takesw(X_test), and each test point gets its own quantile: 97% (at least 90% is guaranteed). Withoutweights_test=the old weight 1 is kept with anAssumptionWarningwhenweights_calib=is given. Unweighted calls are unchanged.sp.conformal_counterfactualandsp.conformal_ite_intervalnow pass the test points' own weights (1/e(x)for Y(1),1/(1-e(x))for Y(0)); on a confounded fixture the population coverage of the Y(1) intervals moves from 89.7% to 92.3% at a 90% target. A band is now infinite at a test point whose own weight carries more thanalphaof the total (too little overlap for a finite interval with the guarantee); the centres are stored in the newpoint_Y1/point_Y0fields, whichsp.conformal_ite_intervaluses for its point.sp.subgroup_analysis: a subgroup in which a control is constant (or the regressors collinear) disappeared fromresults_dfwhen the inverse failed, and otherwise came back with an SE from a numerically singular inverse (0.0946 where the regression without the constant control gives 0.0881). Such subgroups, and subgroups with too few rows, are now kept with NaN estimates, the reason in a newnotecolumn, and a warning. The other subgroups are unchanged.
Fixed¶
- The provenance wrappers of
sp.spatial_iv,sp.spatial_did,sp.bartikand the legacystatspai.spatial.models._legacyfits caughtException, so a bug in the provenance code vanished without a trace. They now catch only the errors of a result that cannot carry the record (AttributeError,TypeError,KeyError,ValueError). sp.impactson a model without spillovers in the mean (sp.sem) raisesMethodIncompatibility(aValueError) with a recovery hint, not a bareValueError.sp.mixedreturned the modulestatspai.multilevel.mixed("'module' object is not callable") when old code had imported that back-compat path before the first use ofsp.mixed. The lazy export now resolves frommultilevel/lmm.py.sp.gearyandsp.join_countscrashed (operands could not be broadcast) on weights that store a zero, such as adaptive bisquaresp.kernel_weights: they pairedS.nonzero()withS.data.sp.join_countscastyto int before checking it was binary, so 0.5 and 1.7 passed as 0 and 1. Non-binary values are now refused.sp.sem_gmm/sp.sar_gmmread everyrobustvalue except'het'(True,'hc1') as the classical variance.robustis nowNoneor'het'in any case, anything else raises;sar_gmm(w_lags=0)raises.sp.spatial_ivreturned a minimum-normpinvsolution with finite SEs when the instruments could not identify the regressors (no exogenous regressor and no excluded instrument). It now raisesIdentificationFailure.sp.gwrreplaced a singular local design by its pseudo-inverse and returned arbitrary local coefficients. It now raisesNumericalInstability, asGWmodel::gwr.basicerrors;sp.gwr_bandwidthalready scores such bandwidths+inf.sp.spatial_panel(fixed effects) with a regressor the within transform removes (time-invariant undereffects='fe') returned a coefficient fitted to round-off. It now raisesMethodIncompatibilitynaming the regressor.sp.bartikandsp.ssaggregatereturned NaN estimates on missing values in the outcome, regressor, shares or controls; they now raiseDataInsufficient.sp.distributional_tedrops rows with missing values (with a warning); one missing outcome used to turn every output into NaN. It refuses a treatment not coded 0/1 (0-3 formethod='cic').sp.manski_boundsrefuses a treatment not coded 0/1 and an outcome support narrower than the observed range;sp.horowitz_manskirefuses a treatment not coded 0/1.sp.panel_qtetrefuses duplicated (unit, time) rows, which passed the balance check and were counted twice.sp.qtechecks the 0/1 coding before converting to integers (0.6 was read as 0).sp.stepwiseandsp.lasso_selectrefuse an unknownmethod/criterion(an unknownlasso_selectmethod meant AIC, an unknownstepwisemethod stopped at the starting model).stepwisewithcriterion='pvalue'requiresalpha_in <= alpha_out.sp.selection_boundsrefuses an unknownmethod.- Nevo and Rosen (2012) appeared in two docstrings and in the BibTeX of
sp.iv_boundsas Review of Economic Studies 79(3), 1104-1127; it is Review of Economics and Statistics 94(3), 659-671 (refs verified via Crossref, cemmap). sp.cox(robust=): values other than'nonrobust'/'hc0'/'robust'('hc1','HC3','cluster'withoutcluster=) silently gave the model-based variance. They now raise, with a hint (ssc='stata'gives Stata'sN/(N-1)).robust=Trueis R'scoxph(robust=TRUE).sp.cox: when Newton-Raphson does not converge (a monotone partial likelihood, the coefficient running off to infinity) the L-BFGS-B fallback was silent. It now warns (ConvergenceWarning, naming the covariate, as Rcoxphdoes) and recordsdiagnostics['Converged'] = Falseandmodel_info['convergence_warning'].sp.survregandsp.cox_frailtyrefuse event codes other than 0/1, likesp.coxandsp.kaplan_meier.survregread any positive code as an event,cox_frailtyread 2 as censored.sp.mendelian_randomization(methods=)refuses unknown method names; it skipped them.sp.mr_ivwrefuses missing values and unequal lengths: pandas inputs skipped NaN in the sums while the degrees of freedom counted them.sp.aft/sp.survregstate their log-likelihood scale:AFTResult.log_likelihood_scale(log time, Statastreg) andmodel_info['ll_scale'](time, Rsurvreg).sp.margins(method='mem')after any weighted fit raised "shape-mismatch for sum". It returns the effect at the weighted means.sp.marginsaftersp.twoway_clusterkeeps the fit's formula and weights. AnI(x**2)term was refused and averages were unweighted.sp.predictions(newdata=)accepts rows that do not show every level of a factor when the formula also has a transformation. It refused.sp.margins_tableafterologit/mlogitlabels rowsvariable:outcome. The labels were repeated across outcomes.sp.hypothesesrefusesb1when a regressor is also calledb1(it tested the intercept).sp.nlcomrefuses a fractional power of a negative estimate (it raised TypeError).sp.lincomvalidatesalpha.sp.suest(cluster=)no longer refuses a missing cluster value on rows that no equation uses; those rows are dropped, as in Stata.sp.robustness_reportandsp.subgroup_analysisname a formula term they cannot evaluate (C(g),I(x**2),np.log(x),x:z). They raisedColumnNotFoundfor columns nobody wrote, such as'C'.sp.oneway(compare="sidak")keeps full precision for very small p-values. A pair with t = 38.7 on 87 df gets 7.8e-59 instead of 0.sp.event_studywith a single cluster (cluster=a column with one level in the estimation sample) crashed with aZeroDivisionErrorin the cluster-SE correction G/(G-1). It now raisesMethodIncompatibilitywith a recovery hint, whatevercrit_distis.sp.blp_test/sp.gate_testwith the default out-of-fold proxy crashed aftersp.metalearner(cluster=)orfold_indices=.sp.auto_cate_tuned(tune='nuisance')chose the propensity hyperparameters by the R-loss attau = 0, which does not depend on the propensity, so the pick followed the sampler's order. They are now chosen by cross-fitted log loss (best_propensity_log_loss).tune='per_learner'no longer hands the S- or T-learner's (unused) CATE hyperparameters to the other learners.sp.SuperLearner.fitoverwrotelibrarywith the fitted library, so a weighted refit of the same object failed andget_params()changed after fit. The fitted library is nowlibrary_.sp.cluster_catereturned an empty table for a treatment not coded 0/1; it now raises, and warns when clusters are left out.sp.cate_evalnow validatestarget=.sp.svyglm,sp.meologitandsp.mixed/sp.meglmwith a categorical group raisedInvalidIndexErroron data with repeated index labels; rows are now located by position.sp.xtabond/sp.xtdpdsys(cluster=)with string labels said the cluster variable was missing for every unit; labels are now coded. Numeric clusters are unchanged.sp.xtabond(cluster=..., robust=False)returned the classical one-step variance (or the uncorrected two-step one) and ignoredcluster=. The combination is refused: xtabond2's help sayscluster()implies robust.MixedResult.predict()without data is labelled with the estimation rows' index, sodf['yhat'] = res.predict()lines up;plot(kind='residuals')paired the outcome in group order with fitted values in row order.sp.meologitsorted string outcomes alphabetically ("high" < "low" < "medium"), against its docstring. Strings are refused; pass an ordered categorical. Unused categories are dropped.- Refused instead of fitted:
sp.meglm(trials=)outside the binomial family (Poisson ignored it, the others read it as frequency weights); binomial outcomes outside[0, trials](1/2 coding returned the start values), negative counts, non-positive gamma outcomes;sp.svyglmbinomial outcomes outside[0, 1]and negative Poisson outcomes; missing values insp.svymean/sp.svytotalvariables (NaN estimates) and insp.linear_calibrationinputs;sp.lrteston mixed fits with different samples or REML against ML;sp.gmm(vcov='cluster')with missing labels. sp.heckmandid not check thatselectis 0/1. A 1/2 or 0/2 coding died with a division by zero, and one stray code (a 3) entered the two-step probit as is and returned numbers. Codes other than 0/1 now raiseMethodIncompatibilitywith arecovery_hint; missing codes are still dropped. Valid fits are bit-identical.sp.ipcwwith a missing time, event or covariate returned NaN for every weight (the logit / Cox fit took the NaN in). Such rows are now left out of the fit with a warning, assp.coxdoes: they keep their place with a NaN weight and are counted inresult.n_dropped_missing. The other rows get the complete-case weights.treatment_covariatescount only whenstabilize=True.sp.mr_bmarefused more than 14 exposures before looking atmax_model_size, although its message said to use it. The limit is now on the number of models (16,383, all subsets of 14 exposures), so 16 exposures withmax_model_size=2run. A cap of 0 or below (which fitted no model), above the number of exposures, or not an integer is refused; so are duplicated exposures, a zero, negative or infinitese_y(a zero made every posterior NaN) and infinite associations.sp.mr_lapandsp.mr_clustrefuse missing or infinite associations and non-positive standard errors.mr_lapreturned NaN for a missing beta, raised ZeroDivisionError for a zerose_outcomeand squared a negative one away;mr_clustreturned an all-NaN fit.mr_clustalso refuses a zerobeta_exposure(undefined Wald ratio) andmr_lapvalidatesalpha.sp.epi.odds_ratio(method="exact")also returnsconditional_mle, the conditional maximum-likelihood OR that Rfisher.testreports. The estimate stays the sample OR, as Statacci; the docstring now says so. The exact limits sit on the root of the tail equations to 1e-10; R and Stata solve them more loosely and differ by up to 1.2e-3 and 2.2e-4.sp.epi.bradford_hillreadsNonein theevidencedict as "not assessed", as for the keyword form; it raised TypeError.sp.regress(and the other estimators built on the shared formula parser) dropped a fixed-effect block| feor an IV block(d ~ z)when patsy could not read the formula:sp.regress("y ~ d + x | fe")fitted pooled OLS (2.63 against the within estimate 0.71) and"y ~ x + (d ~ z)"leftdout. Both now raiseMethodIncompatibilitypointing tosp.feols/sp.iv.sp.network_htesummary named the method after the wrong authors; it now cites Wu & Yuan (2025), as the docstring andpaper.bibdo.sp.patedid not check its optimisers. The participation logit and the entropy-balancing solve now record whether they converged inmodel_info['converged']/['convergence'], and bootstrap replicates that did not in['n_boot_not_converged']; either failure issues aConvergenceWarning. Entropy balancing counts as converged when the largest remaining covariate imbalance is at most 1e-4 standard deviations. A target sample whose covariate mean lies outside the experiment's range (imbalance 7.3 SD after 836 iterations) used to return an estimate with no sign of failure. Converged fits are unchanged.sp.metalearner(fold_indices=, weights= / cluster=): a missing weight or cluster label madefold_indices(aligned with the input rows) the wrong length and the call was refused. Those rows are now dropped from the vector, as rows with a missing outcome, treatment or covariate already were, and the result equals the call on the pre-dropped data.sp.tmle(fold_indices=, cluster=)did not check that every cluster lies in one fold. Folds that split a cluster are now refused (MethodIncompatibility, with the number of split clusters).sp.policy_tree: the cross-fitted gain interval covers 81-85% at an exact null of no heterogeneity (92-97% with heterogeneity). No valid variance is implemented for that case; the limitation is now in the docstring Notes, andresult['diagnostics']['gain_interval_note']is set when the gain is within one standard error of zero. No number changes.
Added¶
sp.impactsforsp.slx,sp.sdemandsp.gnsfits. For SLX and SDEM the impacts are linear in the estimates (directbeta_k, indirecttheta_ktimes the mean row sum ofW), so their SEs come exactly from the coefficient covariance, asspatialreg'simpacts()forlmSLXanderrorsarlm(Durbin = TRUE): SLX estimates and SEs equal R to 1e-12; SDEM estimates agree to 1e-8 and the SEs differ by the covariance convention (observed information here, as Stata; total effect of x1 0.2751 against R's 0.2725). GNS impacts are simulated as for SDM.sp.slxnow stores its OLS covariance inmodel_info['vcov'].attrs['se_method']says'analytic'or'simulation'.sp.gmm(small=True):Falserefers t statistics, p-values and intervals to the normal, as Statagmmand Rgmm::summary; the default keeps t(n - k), assp.iv(small=True).result.inference_rule()states which.sp.weighted_conformal_prediction(weights_test=): the likelihood-ratio weight of each test point (see ⚠️ Correctness).
Deprecated¶
sp.iv_bounds(assumption='less_than_late'):Wald +/- |OLS - Wald|has no source and can exclude the true slope under the assumption it was described with. It warns and keeps its numbers until removal, no earlier than the next minor release.sp.absorb_ols(cluster_df=): the default is nowNone, which still means'per_term'. With multi-way clustering and nocluster_df=it emits aFutureWarning: the default becomes'min'in 1.42, assp.hdfe_olsdid in 1.41. One-way and unclustered calls do not warn and are unchanged (the two factors agree there).sp.hdfe_olsalways passescluster_df=and never triggers the warning.
Removed¶
Deprecations whose stated removal version had passed.
sp.kan_dlate(deprecated in 1.21, announced for removal in 1.23). It only forwarded tosp.dist_iv; call that.sp.kan_dlatenow raises anAttributeErrorthat namessp.dist_iv.sp.SurrogateResult(deprecated in 1.31, announced for 1.33). No function returned it; the surrogate estimators returnsp.CausalResult.sp.surrogate_index,sp.long_term_from_shortandsp.proximal_surrogate_indexare unchanged.sp.qte(method='quantile_regression')(deprecated in 1.21, announced for 1.23). It raisesMethodIncompatibility; therecovery_hintnamesmethod='conditional_qr'(the same numbers) andmethod='firpo_qte'. An unknownmethod=now raisesMethodIncompatibilityas well (still aValueError).sp.evalue(rare_outcome=)andsp.evalue_from_result(rare_outcome=)(deprecated in 1.18 "for the next minor"). Passrare=.sp.evalue_rrkeeps its ownrare_outcome=argument.
[1.40.2] — 2026-10-27¶
A patch release: one correctness fix to sp.iv GMM and two to the frontier
SEs. The rest makes statistics StatsPAI already computed say how they were
computed, and audits the parity harness row by row. Read the ⚠️ entries
before upgrading: they change numbers.
- Correctness fixes.
sp.iv(method='gmm')under the unadjusted weight matrix now appliesN/(N-K)whensmall=True, as Stata does.sp.frontier/sp.xtfrontierSEs no longer carry the ~1e-5 round-off of a fixed-step Hessian. Thesp.panelmodel test now uses the reported variance. - Parity harness. Every Track A row above 1e-6 now has a registered reason, and every convention has rows that reproduce it. Python now fits the exact doubles Stata reads. SE budgets of 18 modules drop to 1e-6.
- Added.
wmatrix=for GMM,result.inference_rule(), andthreshold=on the pre-trends power functions.
An external audit compared StatsPAI 1.40.1 with Stata 18 on 60 specifications (240 rows, 30 groups) and asked why coefficients and standard errors could match while t / F / p-values did not. One case was a gap and is fixed (⚠️ below); the others were legitimate conventions that the output did not state, and the output now states them.
⚠️ Correctness¶
sp.iv(method='gmm')under the unadjusted weight matrix (the defaultrobust='nonrobust', with eithergmm_vcov) now carries the finite-sample factorN / (N - K)whensmall=True(the default), as Stataivregress gmm ..., wmatrix(unadjusted) smalldoes. Until 1.40.1 that branch carried no factor, sosmall=Truepaired the large-sample variance with t / F inference. Standard errors grow bysqrt(N / (N - K)): on the audit's data (N = 240, K = 4) the SE of x1 moves from 0.067613 to 0.068183 and the joint F from 10.1226 to 9.9539, both now equal to Stata 18 to 1e-15.small=Falseis unchanged and point estimates and Hansen's J are unchanged.sp.from_stata/sp.statanow translateivregress gmm ..., wmatrix(unadjusted) small, which they reported as untranslatable.-
sp.panelwithcluster=, a robust / kernel variance orssc=: the model test indiagnostics(and the summary) was linearmodels' homoskedastic F whatever the variance, so a clustered fit printed F = 12.29 where the clustered test of the same slopes is 29.47 (sp.test) and Stata'sxtreg, fe vce(cluster)28.49 (ssc='stata'). It is now the Wald test of the reported variance on the coefficients' reference distribution:F-statistic/F p-valueunder t inference,Wald chi2/Prob > chi2under z (xtreg, rewithssc='stata', Stata 21.516). Classical-variance fits keep linearmodels' F, which is the same number. -
sp.frontierandsp.xtfrontier(Pitt-Leeti, Battese-Coellitvd) standard errors came from a plain central-difference Hessian with step 1e-5, whose round-off is about 1e-5 relative: on Track A module 28, perturbing the outcome by one ulp moved the intercept SE by 1.7e-5, and the reported SE sat 1.3e-5 from RsfaRand Statafrontier, which agree with each other to 1e-8. Both now use the Richardson-extrapolated Hessian the mixture models already used, and finish the quasi-Newton search with Newton steps. SEs move by up to about 2e-5 relative and estimates by up to about 3e-6; module 28 now agrees with both references to 1e-8 and module 29 with Stataxtfrontier, tito 2e-10 (estimates) and 8e-10 (SEs), from 3e-6.
Announced for 1.42 (owner decisions 2026-10-24)¶
sp.iv(method='gmm')defaults to the robust weight matrix. Withoutwmatrix=,cluster=or a robustrobust=, the weight matrix is the homoskedastic one, so the estimate is 2SLS. From 1.42 the default iswmatrix='robust', the two-step GMM of Stataivregress gmmand linearmodelsIVGMM. Over-identified calls that rely on the old default warn (FutureWarning); just-identified ones do not, the estimator being the same.wmatrix='robust'adopts the new default now,wmatrix='unadjusted'keeps the old one.sp.paneldefaults tossc='stata'. linearmodels' convention has no G/(G-1) cluster factor, refers clustered and RE statistics to t(N-K), and readsrobust='robust'as White SEs, which are inconsistent with fixed effects;xtregclusters on the panel variable instead. On the audit data the clustered FE p-value of x1 is 2.5e-05 by default and 2.1e-04 under Stata's rule. Fits whose SEs or p-values would change warn (FutureWarning): one-way clustering,robust='robust'with fe / twoway / re / CRE, and the RE family. Classical fe / twoway / pooled / fd / be fits are the same number under both and do not warn; kernel, Driscoll-Kraay and two-way clustering have no Stata counterpart and keep linearmodels'.ssc='stata'adopts the new default,ssc='linearmodels'keeps today's.- Not changing:
sp.iv(small=True). RAER::ivreg,fixest,ivreg2, smallandsp.ivregreport small-sample statistics as well; only Stataivregressdefaults to large-sample ones.summary()now says which applies, andsp.statatranslatesivregressexactly.
Fixed¶
- Parity harness (Track A). Every gap above 1e-6 now has a written reason,
row by row (
compare.py::ROW_GAP_NOTES), on both reference sides and for estimates and SEs, and a convention gap must come with rows that reproduce the reference's convention at 1e-6 or better. Building the table found: - The Python side fitted the in-memory frame, while R and Stata parsed the
%.16gCSV, which does not round-trip every double.dump_csvnow replaces the float columns with the correctly rounded parse, which is also what Stata reads, so Python and Stata see identical doubles. R'sread.csvdiffers in the last bit on 10-36% of fixture values; the harness records this and does not hide it. Six results moved beyond the 1e-9 reproducibility floor: the forest's single draw, frontier ×2, ARIMA, CR2 and spatial. - Module 76's 4e-4 gap, put down to
mvtnorm's randomised integrator, was mostlypretrends' fixed 1.96 cutoff, plusuniroot's default tolerance. New*_t196rows join at 1.6e-8; the module moves to the machine tier. - The Oaxaca Stata SE (1.2%) sat under a budget written for R's bootstrap.
It is two conventions: classical vs HC1 group regressions, and
stochastic vs fixed covariate means.
oaxaca, vce(robust) fixednow joins StatsPAI at 2e-14 on all three threefold components. - Convention rows also pin
glm's expected information for probit (againstsampleSelection::probit, 1e-12),glm.nb's conditional-on-θ SEs (3e-13),betareg's expected information (hessian = TRUE, 1.2e-7), and Statanewey's N/(N-K) (hac_small=True, 4e-16). fixestatglm.tol = 1e-12removes module 67's 1e-5 SE gap (1e-15 logit, 1e-7 Poisson).- Stata
synthbuilds its synthetic path from weights rounded to 0.001, so module 07's Stata gap is that rounding. StatsPAI's weights rounded the same way reproduce Stata's gap to 5e-16 (*_w3dprows). - Stata
sdid'se(ATT)keeps 7 significant digits. The do-file now reads the full value and agrees to 1e-15. - SE budgets of 18 modules tightened to 1e-6; the tier census is 82 machine / 5 iterative (was 80 / 7).
sp.recommend(design='did', treat_time=...)with a time-invariant group indicator astreatmenton a panel: the Callaway-Sant'Anna card derived each unit's cohort as its first "treated" period, which for a group indicator is the first observed period. With periods starting at 0 that is the never-treated code, so every unit was coded never treated and the suggested fit raised "No treatment cohorts found". The ever-treated units now adopt attreat_time, and the code shown says so. (The recommend benchmark's repeated-cross-section case had failed CI since 10-19; its data now drop the unit id, as the case intends.)sp.mixlogitmaximises with a central-difference gradient. The forward-difference default stopped about 1e-7 short of the optimum, limited by rounding rather thantol, so the digits depended on the BLAS build (Linux CI missed Stata's correlated Σ by 1.3e-6). Estimates now agree with Statamixlogitto 4e-9 and move by about 1e-7 from 1.40.1; each fit takes about 0.5 s longer.- Tests:
test_period_date_out_of_range_warns_and_keeps_countsused year 0, which pandas 3.1 represents (0000-01-01); it now uses a year beyond every datetime64 resolution. The SCM optimality check intest_scest_multi_parity.pybounds the objective by the rounding of its own evaluation instead of a fixed 1e-14 (Windows CI: 48 eps against a 153-eps bound).
Added¶
sp.pretrends_power(threshold=)andsp.pretrends_slope_for_power(threshold=): the critical |t| of the coefficient-by-coefficient pre-test. The default staysqnorm(1 - alpha/2); Roth'spretrendspackage fixes it at 1.96, andthreshold=1.96reproduces that package (Track A module 76, 1.6e-8 againstpretrendswith an exact integrator).sp.iv(method='gmm', wmatrix=): the weight matrix ('robust','unadjusted','cluster') separately from the variance, asivregress gmm'swmatrix()andvce().wmatrix='robust', cluster='g'iswmatrix(robust) vce(cluster g)andwmatrix='unadjusted', cluster='g'iswmatrix(unadjusted) vce(cluster g), both new and equal to Stata 18 to 1e-15 with and withoutsmall.sp.statanow passeswmatrix=for everyivregress gmmline and translates those two combinations.result.inference_rule()on regression results: the variance label, the reference distribution ('t'with its df, or'z'), the joint-test form (F(q, df)/chi2(q)), clusters, the IVsmallconvention, the panelsscconvention, rows and groups for panel fits, and notes on defaults that differ from Stata.summary()prints it under the coefficient table andto_agent_summary()returns it underinference. For a GMM fit the label names the variance, not the weight matrix: the default reports a sandwich even thoughrobust='nonrobust'.sp.panelresults recorddata_info['n_rows']anddata_info['n_groups']; formethod='be'the summary shows "240 observations in 30 groups" wherenobs(the N of the degrees of freedom) is 30 and Stata'se(N)is 240.tests/reference_parity/test_inference_conventions_stata_parity.py: the audit's data (its DGP, regenerated) and Stata 18 output for GMM under three weight matrices with and withoutsmall, 2SLS, LIML, logit / probit / poissonvce(robust),xtregfe / re / be and a collinear design. All 18 fits agree on b, SE, the joint test and its p-value to 4.4e-14.
Changed¶
sp.testrefuses a restriction on a coefficient omitted for collinearity ("was omitted from the model (collinear with 'x1'), so a restriction on it is not estimable") instead of "Unknown coefficient".- Docstrings of
sp.logit/sp.probit/sp.cloglog/sp.poisson/sp.nbregno longer presentrobust='hc1'and'robust'as synonyms:'robust'is Stata'svce(robust)(N/(N-1)),'hc1'scales by N/(N-K). The default panel convention note (model_info['ssc_reference']) now says that RE is referred to t / F by default and thatssc='stata'gives z / chi2.
[1.40.1] — 2026-10-22¶
A patch release of DiD fixes and conventions, most of them found by replicating five DiD papers in Stata 18 and StatsPAI side by side. Read the ⚠️ entries before upgrading: they change numbers.
- Correctness fixes.
sp.did_multiplegtwith several rows per group and period, and itscontrols=adjustment (both now equal Statadid_multiplegt_old);sp.rlasso_iv(select_Z=True, select_X=False);sp.did_summaryrows now share one estimand;sp.feols/sp.fepoisextended variances no longer crash under pandas 3. - Reproducing R and Stata in one argument.
parity='r' | 'stata'onsp.callaway_santanna/sp.aggtenow also covers repeated cross-sections, unbalanced panels and sampling weights;sp.didandsp.cs_reportforward it. - New options from Stata's dCDH command:
robust_dynamic,firstdiff_placebo,effect_average, controls in placebo and dynamic effects. - Announced for 1.41: event-study p-values and intervals move to t(G-1)
with clustered SEs (
crit_dist='t'), as Stata and fixest report. Calls that would change warn now;crit_dist=adopts or keeps a rule. - Deprecated: the
group=keyword ofsp.drdid(usetreat=).
Added¶
sp.did_multiplegt(robust_dynamic=True): the first-time-switcher DID_l estimators of Statadid_multiplegt_old, robust_dynamic(dCDH, REStat 2024). The estimate becomes DID_0,dynamic=gives DID_1..DID_L against groups with the same initial treatment that have not switched yet, and the placebos are Stata's long differences. Checked against Stata 18 on 16 configurations (binary and count treatments switching on and off, balanced and unbalanced, with and without controls, rows below the cell and cell means) to 3e-15,tests/reference_parity/test_did_multiplegt_dynamic_conventions.py. Conventions found by running Stata as a black box are listed in docs/dev/modules/did.md (the sign of a group is that of its summed deviation from the baseline after the first switch, not of the first change; placebo l uses DID_{l-1}'s groups; a group must be observed throughout the effect's window).model_info['dynamic'][h]['n_switchers']equals Stata'se(N_switchers_effect_h). The default is unchanged: the pair-by-pair dynamic effects of RDIDmultiplegt0.1.4 that Track A module 81 certifies. The two agree on a binary staggered treatment with one row per cell and differ when treatment switches on and off (on the 2026-10-21 test panel: 0.80151 withrobust_dynamic=True, Stata 0.80151, against 0.70159 by default, R 0.70159).sp.did_multiplegt(robust_dynamic=True, firstdiff_placebo=True): Stata'sfirstdiff_placebo. Placebo l is the first differenceY_{F-l} - Y_{F-l-1}of the first-time switchers against the groups not yet switched at F (DID_0's comparison), so it tests parallel trends over two consecutive periods l periods before the switch and does not depend ondynamic=. A group must be observed in every period from F-l-1 to F (found by running Stata as a black box on the unbalanced fixture). Requiresrobust_dynamic=True: the default placebos already are first differences, and Stata ignores the option withoutrobust_dynamic.model_info['placebo'][l]['n_switchers']now reports Stata'se(N_switchers_placebo_l)underrobust_dynamic. Matches Stata 18 on the 16 configurations of the dynamic fixture to 6e-16.sp.did_multiplegt(robust_dynamic=True, dynamic>=1)reports Stata'se(effect_average)inmodel_info['effect_average']: the DID_0..DID_L weighted by the number of switchers each applies to, divided by the same average with the treatment as outcome (the first stage), so an effect per unit of treatment of the first switches. Point estimate andn_switchersmatch Stata 18 on all 16 configurations to 6e-16; the SE is the bootstrap standard deviation of the ratio (Stata's SE comes from its own bootstrap and is not compared). The existingmodel_info['avg_cumulative_effect']is unchanged: it is the unweighted mean of the DID_l, not scaled by the first stage, so it differs fromeffect_averagewhenever horizons have different numbers of switchers or treatment moves after the first switch.sp.did_multiplegt(placebo_controls='refit' | 'main'): withcontrols=the placebos refit the slopes on their own stayers (R, the default) or reuse the DID_M slopes (Statadid_multiplegt_oldwithoutrobust_dynamic).crit_dist=onsp.event_study,sp.sun_abraham('t'/'normal') andsp.did_2x2('t'/'residual'): the critical-value rule for p-values and confidence intervals, named by convention rather than by program.'t'is t(G-1) with cluster-robust SEs and t(N-K) otherwise, the critical values ofparity='stata'/'r'(castle-doctrine panel: equal to Statareghdfe/regressto 1e-10,tests/reference_parity/test_es_crit_default.py). Forsp.sun_abrahamit leavesshare_variancealone. A legacy value together withparity=raisesMethodIncompatibility. Recorded inmodel_info['conventions'](crit_dist,crit_df). The MCPsun_abrahamtool exposes it.sp.did(parity='r' | 'stata')forwardsparity=to the estimator the method resolves to (sp.callaway_santanna, whosesp.aggteinherits it,sp.sun_abraham,sp.did_2x2) and refuses it for the other methods. Underparity=,base_periodreaches the CS fit only when passed, and underparity='stata'the aggregation'sn_boot/random_stateonly when passed (csdid'sestatis analytic).sp.did(crit_dist=)forwards tosp.did_2x2('t'/'residual') andsp.sun_abraham('t'/'normal'), so the 1.41FutureWarningthose emit throughsp.didcan be acted on there; other methods refuse it.crit_dist='legacy'keeps whichever estimator's pre-1.41 rule and is a no-op where there is none, so library callers that go throughsp.did(sp.causal_question,sp.compare_estimators,CausalWorkflow) pin it and do not warn. The warning now points at the user's line when the estimator is reached through anothersp.*function (did/_crit.pywalked past only one frame-input shim).sp.cs_report(parity=)forwards to everysp.callaway_santannafit andsp.aggtecall of the report.-
sp.validation_scope: evidence rows for theparity=switches. Thecallaway_santannamap gains aparitydimension ('none'/'r'/'stata') with rows fortests/reference_parity/test_cs_parity_switch.py(balanced panels) andtest_cs_parity_rc_unbal.py(repeated cross-sections, unbalanced panels, sampling weights), comparing the estimate, its SE and the pre-test (joint_test). Theevent_studyandsun_abrahammaps gain acrit_distdimension ('normal', t(N-K)'t_resid', t(G-1)'t_cluster'), and a new output,interval(the p-values and confidence limits, which pin the critical value), graded bytest_es_crit_parity.py; the critical value is declared not to affect estimates, SEs or the covariance. Newdid_2x2map (variance, weights, covariates, critical value) with its Stata, Rsurveyand fixest rows. Rows run withoutparity=no longer vouch for aparity=fit, whose headline can differ.docs/evidence_inventory.mdgains anintervalcolumn. -
sp.breakdown_mandsp.pretrends_testaccept event-study coefficients withsigma=/event_times=/num_pre_periods=, assp.pretrends_powerdoes: a breakdown value and a joint pre-test from the estimates and SEs a paper prints. On the personal-bankruptcy pilot paper's event study the relative-magnitude breakdown is 0.36793 and the smoothness one 0.00095703; Statahonestdidexcludes zero just below each and includes it just above. sp.bacon_decomposition(balance='observed_rows')reproduces Stataddtimingon unbalanced panels (everye()scalar to 1e-15 on three synthetic panels and the 267-city replication panel): Theorem 1 applied to the observed rows. Off a balanced panel this is not an identity, soweighted_sum(ddtiming's DD) differs frombeta_twfeand the function warns; weights can be negative; 2x2s with no treatment variation are NaN withidentified=False(ddtiming prints 0). Golden fixturetests/reference_parity/_fixtures/bacon_ddtiming_Stata.json.sp.stacked_did(aggregation='event_time' | 'treated_obs'): the overall ATT as the equal-weight mean of the post event-time effects (default, as before) or weighted by treated observations, the estimand of CS / BJS / ETWFE.sp.stacked_did(cluster_stack=True)clusters on unit x stack, the default of Statastackedev, for replication only: a control unit repeats in every stack, so it understates the SEs of time-varying controls (0.62x on the test panel). Both match Stata to 2e-11 (test_stacked_did_stackedev_Stata_parity.py). stackedev builds its cluster id asstack * unit, which can merge two (unit, stack) pairs; ours does not.sp.did_summary(event_study=True, event_window=)puts every method's event-study path inresult.event_study_pathson one relative-time axis, with alead_definitioncolumn (reference_periodfor CS / SA / stacked,untreated_regressionfor BJS leads,post_onlyfor ETWFE);sp.did_summary_plot(kind='event_study')draws them.sp.did_multiplegt_dyn:detailandmodel_info['event_study']gaintwfe_relative_time(effect l at l, placebo l at -(l+1), the axis of a base -1 event study;relative_timeis unchanged, where placebo 1 sits at -1), anddetailgains a publicsecolumn for whicheverse_methodran.
Changed¶
- The code that
sp.recommendand the agent cards suggest forsp.sun_abraham/sp.event_studynow spells out the announced defaults (aggregation='fixest_att',crit_dist='t'), so a copied call does not warn and keeps its numbers when 1.41 flips the defaults. -
sp.from_stata/sp.statatranslatedid_multiplegt_old ..., robust_dynamic dynamic() placebo()tosp.did_multiplegt(robust_dynamic=True, ...)(it used to be refused and pointed atdid_multiplegt_dyn), andcontrols()withplacebo()but withoutrobust_dynamictoplacebo_controls='main'.dynamic()withoutrobust_dynamicis refused, as the command refuses it;firstdiff_placebotranslates tofirstdiff_placebo=Truewithrobust_dynamicand is dropped with a note without it (the command ignores it there; checked in Stata 18). -
sp.validation_scopeforcallaway_santannagains thevariance_conventiondimension. On the repeated-cross-section DR path with covariates, Rdid/DRDID/ csdid evidence ran'reference'and no longer vouches for the default'delta'standard error (its ATT is still covered: identical to 1e-12). sp.aggte(agg_weights='csdid')works for all four aggregation types and on unbalanced panels (balance='pair'), not only for the simple and group aggregates on repeated cross-sections. Its SEs carry the estimation term of the cell counts in every aggregate, and its group average holds its cohort weights fixed, as csdid does; before, with the defaultshare_variance=True, the group-average SE added R's cohort-share term to csdid's weights. A fit that carries no cell counts (a balanced panel, orallow_unbalanced_panel=True) raises.sp.from_stata/sp.statatranslateeventstudyinteracttosp.sun_abraham(..., parity='stata')andsp.from_rtranslates fixestsunab()tosp.sun_abraham(..., parity='r')(wasshare_variance=False). Coefficients and SEs are unchanged; p-values and intervals now use t(G-1) with clustered SEs, as the translated command reports them.- Announced for 1.41 (owner decision 2026-10-21): the default becomes
crit_dist='t', the Stata / fixest convention. Until then the default reproduces today's numbers byte for byte (normal for the two event studies, t(N-K) also under clustering fordid_2x2), and a call that sets neithercrit_distnorparitywarns (FutureWarning, once per call, attributed to the caller's line) wherever the intervals or p-values would change;sp.did_2x2withoutcluster=does not warn. Estimates, standard errors, the pre-trend tests,sp.uniform_bandsandsp.honest_diddo not depend on it. Callaway-Sant'Anna keeps the normal distribution (Rdid, Statacsdid). Library entry points that call these estimators for the user (sp.did_analysis,sp.did_summary,sp.auto_did,sp.compare_event_study_conventions,sp.causal_question,CausalWorkflow.compare_estimators, the calibrated simulation) pass the pre-1.41 value and do not warn. Before / after on the castle-doctrine panel in MIGRATION.md ("1.40.0 → 1.41"). sp.drdidtakestreat=; the keywordgroup=is deprecated. In the rest of the DiD familygroupis the unit id (sp.did_imputation,sp.gardner_did,sp.stacked_did,sp.etwfe,sp.wooldridge_did,sp.did_multiplegt);sp.drdidwas the one place where it was the 0/1 treatment group. The parameter is now namedtreat.group=still works, gives the same result and emits aDeprecationWarning; it will be removed no earlier than 1.42. Positional calls (sp.drdid(df, "y", "d", "post")) andtreat=/dname=are silent. Agent schemas listtreatand no longer advertisegroup. Library examples, the Statadrdidtranslation and Track A module 38 usetreat=. No number changes.-
Swap guards on five more DiD estimators (
did/_arg_roles.py).sp.gardner_did,sp.stacked_did,sp.etwfe(panel fits) andsp.wooldridge_didrefuse a 0/1groupcolumn whose cohort varies within it, with a recovery hint that namesid=; for the first three the call was already refused by the cohort check, with a message that did not say why.sp.wooldridge_didhas no such check and used to return a number. A 0/1groupwith several rows per period runs and warns (ArgumentRoleWarning), since a two-group design looks the same.sp.did_multiplegtrefuses a 0/1groupwith several rows per period when the treatment varies within those cells (its period-to-period pairing would compare rows of different units) and warns when it does not. Correct calls return exactly what they returned before (tested against the same call with the guard switched off; Track A 16, 17, 38, 73, 75, 81, 84 byte-identical to main's own reruns). -
Announced for 1.42:
sp.did_multiplegt_dyndefaults becomeaggregation='switchers'(Stata / RAv_tot_eff) andse_method='analytic'. A call that leaves either unset warns (FutureWarning); numbers are unchanged until then. See MIGRATION.md.
Fixed¶
-
pip install statspai[io]on Python 3.10 tried to build pyreadstat 1.3.7 from source (it ships no cp310 wheels; the build fails on Windows). Theioextra keeps Python 3.10 onpyreadstat<1.3.7. -
sp.feols/sp.fepoiswithvce='wild','CR2'/'CR3'or'conley'raisedValueError: output array is read-onlyunder pandas 3 (copy-on-write): the singleton removal of 1.40.0 narrowed a.to_numpy()mask in place. Numbers are unchanged. -
sp.doctor()read an editable install'sfile:///C:/...URL as a POSIX path on Windows and reported the checkout as shadowed. -
⚠️ Correctness:
sp.did_multiplegton data below the group level (individuals within groups, several rows per group and period). Statadid_multiplegt_oldaverages such data to (group, period) cells and weights each cell by its row count; its results on the rows equal its results on the collapsed cells withweight(n). StatsPAI paired the rows of a group across periods, so a cell weighed n_t x n_{t-1} in DID_M (0.6149 against Stata's 0.5600 on the test panel), and the placebo and dynamic paths kept only the first row of each cell. It now collapses to cells and weights them as Stata does (DID_M and placebo cells by their size in the switching period), matching Stata to 3e-16 on DID_M and placebos, with binary and count treatments. Dynamic cells weigh their size in the switching period t by default, as RDIDmultiplegtdoes (0.66028 on the test panel, R 0.66028), and their size at t + l withrobust_dynamic=True, as Stata does. A cluster variable that varies within a cell is refused.n_obsis the row count;model_infogainscollapsed_to_cellsandn_cells. One row per (group, period), the usual layout, is unchanged. - ⚠️ Correctness:
sp.did_multiplegt(controls=)now nets out the controls as Statadid_multiplegt_olddoes:dY - dX @ theta_d, withtheta_dfrom a regression of dY on dX and period effects over the stayer cells of baseline value d (weighted like the cells). The old code fitted dY on dX within each period's switchers and stayers and returned 0.7015 where Stata returns 0.6425 (group-level test panel); both layouts now match Stata to 2e-16. The placebo and dynamic effects use the controls too (next entry). - ⚠️ Correctness:
sp.did_multiplegt(controls=)withplacebo=ordynamic=. Placebo and dynamic effects ignored the controls. Each now nets outdX @ theta_dwith slopes fitted on that estimate's own control cells: by default a dynamic effect refits them on its robust stayers' long differences and a placebo on its stayers' lagged differences, as RDIDmultiplegt0.1.4 does;placebo_controls='main'reuses the DID_M slopes, as Statadid_multiplegt_olddoes;robust_dynamic=Truefits them on the not-yet-switched cells, as Statarobust_dynamicdoes. On the 2026-10-21 test panel (cell means) dynamic_1 moves from 0.59200 to 0.57119 (R 0.57119) and placebo_1 (placebo_sign='r') from -0.16141 to -0.11499 (R -0.11499; Stata's convention -0.11457). All three match their reference to 3e-15 on the new 16-configuration fixture (balanced panels for R). A control with no variation net of the period effects in a regression sample now gets a slope of 0, as in R, instead of a minimum-norm split; this also applies to DID_M and changes it only in such degenerate samples. The warning andcontrols_scope == 'main'are gone ('all'). -
Not changed, now documented: on unbalanced panels R
DIDmultiplegt0.1.4 differences a group's outcome across a gap (consecutive observed periods), Stata leaves that difference missing. StatsPAI follows Stata for every estimate; R's unbalanced numbers, DID_M included, are not reproduced (docs/dev/modules/did.md). -
parity='r' | 'stata'on repeated cross-sections and unbalanced panels (sp.callaway_santanna,sp.aggte). The 1.40.0 switch was checked on balanced panels only, andparity='stata'warned that csdid's cell weights were verified for the simple and group aggregates on repeated cross-sections only. Checked now against Rdid2.5.1 (panel = FALSE, the default balancing,allow_unbalanced_panel = TRUE) and Statacsdid1.81 (noivar(), andivar()on the unbalanced panel) on a repeated cross-section and an unbalanced panel built frommpdta, with and withoutlpop, never- and not-yet-treated, unit and state clusters: every ATT(g, t), SE, pre-test and the four aggregations (overall and cells) match to 1e-10 without the covariate, and to 1e-7 (R) / 1e-6 (Stata) with it (tests/reference_parity/test_cs_parity_rc_unbal.py, 40 cases). Three conventions were missing and are now set byparity=. (1) Both programs use the reference sign of the doubly robust pre-period regression term, soparity=setsvariance_convention='reference'(SEs with covariates on repeated cross-sections moved by up to 15%). (2) csdid weights every ATT(g, t) by its own treated count in every aggregate (rows of the cohort in the cell's two periods; units of the cohort seen in both on an unbalanced panel) and carries the estimation of those counts into the SEs;parity='stata'now does this for the calendar and event aggregates and on unbalanced panels too, where the aggregates were off by up to 5% (single cells up to 20%), and the fit's own simple ATT isestat simple. (3)parity='r'acceptsallow_unbalanced_panel=True/balance='none', an option R has (it raised). The warning is gone. Default numbers are unchanged (Track A 04 / 38 / 79 and original-data 02 rerun identical to main's code). Sampling weights (weights=, csdid[iw=]; csdid refusespwandaw, and its help file says the iweight is treated as a pweight) were then the one casesp.aggte(agg_weights='csdid')still warned about: it weighted each cell by its treated head count. csdid weights a cell by the weight mass of those treated observations (itse(gtt)still lists the head count) and the group average by the mean weight mass of a cohort's post-treatment cells; head counts missed csdid's weighted aggregates by up to 19% (calendar cells, unbalanced panel) and 2.6% overall. Fixed and the warning removed: on the same two data sets with a sampling weight, and on the balancedmpdtawith each county's weight, 24 weighted cases against csdid and 32 against Rdid(weightsname), every ATT(g, t), SE, pre-test and the four aggregations match to 2.2e-14 (Stata) / 1.3e-12 (R) withoutlpopand 2.4e-7 / 1.7e-8 with it. R's weighted cohort shares already matched. sp.did_multiplegt(n_boot=0)no longer warns that placebo / dynamic effects are not estimable when they were estimated (the check read the missing bootstrap SE).- ⚠️ Correctness:
sp.rlasso_iv(select_Z=True, select_X=False)solves the second stage exactly. It usedMASS::ginv's tolerance onDhat'[d, x], ashdm::rlassoIVselectZ0.3.2 does; that product squares the conditioning of the controls, and on the BCH eminent-domain application (EminentDomain$logGDP, 80 full-rank controls with condition number 7e6, product 5e13) the cutoff discarded 34 of 81 singular values. The 0.2274 that hdm prints and StatsPAI matched is a truncated-SVD value that moves with the tolerance (0.0054 at 1e-10). The controls are now partialled out with a pivoted QR and the IV system solved exactly: 0.0154, HC0 SE 0.0141 (was 0.2274 / 0.1760), equal toAER::ivregwithsandwich::vcovHC(type='HC0')to 6e-13 on the same first stage; the parity fixture now uses that reference. Well-conditioned designs move by rounding only. The double-selection andselect_Xpaths are unchanged. The deprecation message ofiv.bch_post_lasso_ivno longer says it is "~17x off" hdm here: that comparison was against the truncated value (it returns 0.0130). - ⚠️ Correctness:
sp.did_summaryreported the Sun-Abraham and stacked rows as the equal-weight mean of the post event-time effects and the other rows as treated-observation ATTs, then averaged them and called the spread a disagreement between estimators. All rows are now treated-observation ATTs (SAaggregation='fixest_att', stackedaggregation='treated_obs'), anddetailhas anestimandcolumn. On the 267-city personal-bankruptcy replication panel: SA 0.01696 -> 0.01215, stacked 0.01617 -> 0.01187, cross-method SD 0.0025 -> 0.00012. - The design guardrail of
sp.feols/sp.fepois/sp.regress/sp.hdfe_ols/sp.panelreported an event-time dummy that only some cohorts reach (k = 3 when only the earliest cohort is seen three years after adoption) as "assigned at the cohort level" and suggested clustering on the handful of cohorts. A column on which the treatment depends only throughtime - columnis an adoption date, not an assignment unit, and is no longer flagged. State-level assignment still is. Warnings only. sp.bacon_plotdefault colours were keyed by names thatsp.bacon_decompositionnever writes, so every point was grey; a partialcolors=dict now overrides type by type.sp.did_imputation(min_n=0)raised; it is Stata'sminn(0)(report every horizon) and is accepted. Other invalid values raiseMethodIncompatibilitywith a recovery hint.sp.from_stata/sp.statacarrydid_imputation, minn(#)over, and Stata's defaultminn(30)when the option is left out (unweighted event studies, where the effective number of treated observations is the countmin_n=uses). The translation used to report the horizons Stata suppresses.sp.from_r("did_multiplegt_dyn(..., effects = k)")emitteddynamic=k, one effect too many; it emitsdynamic=k-1andaggregation='switchers'.
[1.40.0] — 2026-10-21¶
A minor release. Read the ⚠️ entries before upgrading: several of them change numbers.
- Three announced defaults take effect (MIGRATION.md, "1.39.x → 1.40"):
sp.feols/sp.fepoisdrop fixed-effect singletons, as Rfixestdoes;sp.bayes_regressuses the weakly informative prior when no prior is given;sp.kaplan_meierreports the log-log interval, as Stata does. - Correctness fixes from a library-wide review against Stata and R, among
them:
sp.rdrobustmass-point bandwidths and CR2/CR3 cluster variances, Callaway-Sant'Anna cohorts treated after the sample, dynamic-panel GMM classical SEs,sp.sacSEs,sp.rlasso_ivwithselect_X=False, wild-bootstrap dispatch, FE-awarepredict/ margins aftersp.fepois/sp.feglm, and translated[pweight]SEs insp.from_stata. - Clean-room rewrites: modules whose code had been derived from GPL or
unlicensed reference implementations were re-implemented from the method
papers, documentation and black-box outputs, with parity unchanged
(
docs/dev/2026-10-20-license-provenance-audit.md, specs indocs/dev/cleanroom/).THIRD_PARTY_NOTICES.mdnow lists the permissively licensed references. - New: survey designs (
design=, replicate weights) in causal estimators, survival causal tools (sp.stteffects,sp.rmst, weighted and counting-process Cox / Kaplan-Meier),sp.panel_match, multi-endogenous and cluster-robust weak-IV inference, IV / panel / state-dependent local projections, group-sequential designs and mSPRT,sp.weightit,sp.bart_causal,sp.lmtp,sp.mmqreg,sp.two_stage_interference, a Stata-style data layer (sp.xtset,sp.lag,sp.reshape,sp.collapse,sp.egen) and Stata factor-variable syntax in formulas. - Dependencies: matplotlib is now a core dependency.
Callaway-Sant'Anna: parity='r' | 'stata', both pre-test p-values, always-treated units dropped¶
From the 2026-10 replication of five DiD papers in Stata 18 and StatsPAI (items 1-4 of the improvement report). Every convention below was established by running R did 2.5.1 and Stata csdid 1.81 as black boxes on the same CSV bytes (the real mpdta, and the same panel with 35 always-treated counties).
- Added:
sp.callaway_santanna(parity='r' | 'stata')andsp.aggte(parity=...)(inherited from the fit by default). One argument sets every convention on which StatsPAI's default differs from that program:base_period='varying'; the not-yet-treated cutoff ('period'for R,'cohort'for csdid); the chi2 pre-test p-value; R'sbalance='full'on unbalanced panels; csdid's analytic SEs for every aggregation even afterwboot(itsestatnever bootstraps); csdid's group average with fixed cohort shares (every other aggregate and every cell keeps the share term, as csdid does); and, withbstrap=True, each program's bootstrap defaults (Rademacher / 1000 draws in R, Mammen / 999 in csdid, uniform bands in both). The critical value is the normal one in both programs, clustered or not, so nothing changes there. An explicit argument that contradicts a convention raisesMethodIncompatibility(e.g.parity='stata', base_period='universal'). Withparity=and no other option, every ATT(g, t), SE, pre-test statistic and p-value, unit count and simple / group / calendar / dynamic aggregate matchesatt_gt(bstrap = FALSE)/aggteandcsdid/estatto 1e-10 without covariates and 1e-7 withlpop(each program's logit stops at its own tolerance), never-treated and not-yet-treated, unit and state clustering (tests/reference_parity/test_cs_parity_switch.py, 34 cases). - Added:
model_info['conventions']on everycallaway_santannaandaggteresult:parity,base_period,base_period_explicit,se_method,crit_dist,crit_df,share_variance,pretest_pvalue, plusnotyet_cutoff/balance(fit) oragg_weights(aggregation). - Added:
model_info['pretrend_test']reportspvalue_f(Hotelling F) andpvalue_chi2(R did / csdid) withpvalue_method;pvaluestays the Hotelling-F one by default and is the chi2 one underparity=.summary()prints both, e.g.p (Hotelling F) = 0.1740; p (chi2, R did / csdid) = 0.1681onmpdta. It used to print the Hotelling-F p-value next to achi2(5)label. - Added: when
base_periodis left at its default,summary()prints one line saying that Rdidand Statacsdiddefault to'varying'and that only the pre-period ATT(g, t) differ. The default stays'universal', the basesp.honest_didneeds. - Added:
sp.callaway_santanna(n_boot=)as an alias ofbiters=(sp.aggtealready accepted both). - Changed:
sp.callaway_santanna(always_treated='drop')(default) removes units first treated at or before the first period plusanticipation, with one warning, and recordsmodel_info['n_always_treated_dropped']and['always_treated'](cohorts, rows). This is Rdid's rule (black box:g <= min(t) + anticipation, the first period included; 35 of 500 units on the test panel, 55 withanticipation=1, 175 rows on repeated cross-sections) and csdid's.always_treated='error'raisesIdentificationFailure. The units used to be kept with their cohort skipped. Every ATT(g, t), aggregate and analytic SE is unchanged (checked to 1e-17 on 16 configurations);n_units/n_obsnow match R, the Hotelling-F pre-test p-value counts only the remaining units (0.19888 → 0.19934 on the test panel; the chi2 p-value and clustered tests are unchanged), the few-treated-units count no longer includes them, and a seeded multiplier bootstrap draws for fewer units. - Docs:
docs/dev/modules/did.md,docs/guides/callaway_santanna.md,docs/guides/cs_report.md.
CI repairs for 1.40: mypy baseline, strict docs build, citation audit, pandas 3 / Windows failures¶
- Fixed:
sp.bal_taband themarginsofsp.intreg/sp.heckman/sp.heckprobitraisedValueError: output array is read-onlyunder pandas 3 (copy-on-write makes.to_numpy()read-only, and the sample mask was narrowed in place). Numbers unchanged. - Fixed:
sp.modelsummary(output="table.csv")wrote\r\r\nline ends on Windows (pandas'os.lineseppassed through a text-mode write). pandas now writes the file. - Fixed:
sp.cbps(variant='exact'): near the root the potential changes by less than its rounding error, so the line search could stop with a balance residual of 2e-11 (lalonde ATE on Windows; 1e-16 elsewhere). A full Newton step that halves the moments is now accepted. Coefficients unchanged where the search already converged. - Changed:
sp.policy_treeon a reward matrix (multi-action, depth <= 2) compares split values up to rounding (1e-12 of the total absolute reward) and keeps the first (feature, threshold) among ties, as documented. Exactly tied splits used to be decided by the last bits of the summation, so the root threshold differed between platforms on the policytree fixture (both splits give the same reward in exact arithmetic). Policy values are unchanged; a threshold can move to the first of several exactly tied ones. - Fixed:
structural_breakrefuses a segment with no admissible split (DataInsufficient) andmediate_sensitivityrefusesmax_iter < 1(MethodIncompatibility); both raisedTypeErrorbefore. The MCP HTTP transport leaves its in-flight table before it answers, so a retry with the same id is no longer refused as a duplicate. - Changed:
GARCHResultdeclaresvce,presampleandgradient_normas fields (defaultNone;sp.garchsets them as before). - Internal: the mypy count is back under the 827 baseline (1361 in CI); the 26 griffe / autorefs warnings of
mkdocs build --strictare fixed; the citation auditor resolves DataCite DOIs (Zenodo, Dataverse, arXiv's 10.48550) at DataCite instead of logging Crossref 404s, no longer reads "CGD Working Paper 103" as NBER w103, and repairs Crossref records whose given and family names are swapped; tests reset the specification-search counter before each test.
DiD API: swapped group= caught, one argument vocabulary, result.attgt / .event_study / .aggregations¶
From the 2026-10-21 DiD replication report (items 5, 6, 8). No estimate or standard error changes.
- Changed:
group=is the unit id insp.did_imputationbut the 0/1 treatment group insp.drdid, and swapping the two used to run.sp.did_imputationnow refuses a 0/1groupcolumn whose cohort varies within it (the cohort check refused the call anyway; the message now names the swap and points toid=). It warns withArgumentRoleWarningwhen a 0/1grouphas several rows per period (a two-group repeated cross-section looks the same, so it still runs).sp.drdidrefuses agroupcolumn without exactly two values with arecovery_hintnamingtreat=andid=(it raised before, without the hint). It warns when the two values are not 0/1 and names the value taken as treated (the larger, as before). Docstrings, registry cards and examples now lead with the canonicalid=/cohort=/treat=. - Changed:
sp.drdidraises whenseed=andrandom_state=disagree, and warns thatn_boot/seed/random_statehave no effect (its standard errors are influence-function based; nothing is resampled). - Added:
cohort=(andg=/first_treat=where the parameter has the other name) ondid_imputation,gardner_did,stacked_did,etwfe,wooldridge_did,twfe_decomposition,did_summary,did_report,bjs_pretrend_joint,did_cluster_diagnostics,compare_event_study_conventions,cs_jackknife,staggered_rollout,functional_form_test,distributional_did,cs_report,auto_didandcluster_staggered_rollout;seed=forboot_seedondid_imputation/gardner_did;id=/unit=andcohort=ontwfe_decomposition;unit=/i=andcontrols=onbacon_decomposition. The house-style lint has acohorttheme (canonicalcohort; legacyfirst_treat,g,gname) and listsboot_seedunderseed. Signatures are unchanged. - Added:
CausalResult.attgt(ATT(g,t) cells),.event_study(one row per relative time) and.aggregations(dict of'overall','dynamic','group','calendar') read the tables each DiD estimator stores, includinggardner_did's'D_k+h'dict and theetwfecohort cells, in one column layout. A missing table raisesResultAccessorUnavailable, anAttributeErrorwith arecovery_hint.docs/reference/did_result_contents.mdlists themodel_infokeys of the main DiD estimators; a test checks the CS, Sun-Abraham, BJS and drdid lists against real fits.tidy()already had the same seven leading columns onCausalResultandEconometricResults; a test now pins that.
sp.doctor() / statspai doctor, the MCP server names its interpreter, and a DiD paper gallery with fetched datasets¶
- Added:
sp.doctor()(registered),statspai doctor [--json] [--no-path] [--check-imports]andpython -m statspai --where. The report gives the StatsPAI version, where it is imported from and whether that is an editable install (or code onPYTHONPATHahead of one), the pip metadata version and git revision,sys.executable/sys.prefix/ venv or conda, the optional backends with versions (statspai_hdfewith its API level againstMIN_API_VERSIONandSTATSPAI_SKIP_RUST, pyfixest, numba, jax, torch, pymc, matplotlib, polars, pyarrow), whetherRscriptand Stata are found, and every otherpython/python3/python3.xonPATHwith the StatsPAI version it has. Nothing optional is imported unlesscheck_imports=True(then in a child process); each probe has a timeout and failures become fields.STATSPAI_*variables are listed with secrets redacted. - Added: the MCP server reports its own runtime. The session instructions end with the StatsPAI version, Python version and interpreter path, and the
describe_functionandpreflightreplies carryserver_runtime.statspai mcp doctoralso prints the install kind. - Added: fetch-on-demand datasets (
statspai.datasets._fetch). A loader downloads its file from the canonical public URL on first call, checks a pinned SHA-256 (a mismatching file is never used) and caches it in$STATSPAI_DATA_DIR(default~/.cache/statspai/datasets). Nothing downloads at import or when listing; offline, the loader raisesDataInsufficientwith the URL, hash and target path;download=Falseforbids the network. New loaders:sp.datasets.jel_medicaid(sample='raw' | '2x2' | 'staggered')(Baker et al. JEL county mortality, MIT, 6.3 MB),sp.datasets.bacon_divorce()(Goodman-Bacon 2021 / Statabacondecompexample),sp.datasets.lalonde_panel()(the NSW + CPS + PSID 1975/1978 panel of Sant'Anna and Zhao 2020 / Statadrdid), andsp.datasets.mpdta(simulated=False)(the originalmpdta, from Statacsdid's copy). The barempdta()call is still the simulated replica; its warning now names the real variant. - Changed:
sp.list_datasets()has new columnsdelivery(shipped/fetched),available_offlineandreal_variant;sourceisfetchedfor the new loaders. - Docs:
docs/guides/did_paper_gallery.mdreruns the five papers (JEL Medicaid, Sant'Anna-Zhao LaLonde, Goodman-Bacon divorce, Callaway-Sant'Anna mpdta, Cheng-Hoekstra castle) in a few lines each, with the published, Stata and R numbers. - Tests:
tests/external_parity/test_did_paper_gallery.py(32 tests) checks all 54 cells of Sant'Anna-Zhao Table 3 and 81 Statadrdidestimates, all 156 Bacon 2x2s againstbacondecomp(TWFE -3.08), the 12 ATT(g,t) and fourmpdtaaggregations against Rdid2.5.1 (analytic SEs to 1e-8) and Statacsdid, the castle TWFE 0.0801 / 0.0946 and six Stata specifications, and the JEL 2x2 -2.56, DR -3.76 and TWFE+X -1.37 against the authors' tables and Stata. Reference numbers and provenance are intests/external_parity/data/did_paper_gallery.json. Tests that need a fetched file skip unless it is cached orSTATSPAI_FETCH_DATASETS=1; the nightly full-coverage job sets it.
Defaults announced for 1.40: sp.feols / sp.fepois singletons, sp.bayes_regress prior, sp.kaplan_meier interval¶
- Changed ⚠️:
sp.feols/sp.fepoisdefault tofixef_rm="singleton"(was"none", announced by aFutureWarningsince 1.39.6, now removed): observations in a fixed-effect group of one are dropped, repeatedly until none is left, as Rfixest(0.14:fixef.rm = "perfect_fit"removes them), Statareghdfeand pyfixest do. Coefficients are unchanged;nobs, the degrees of freedom and the standard errors that carry a small-sample factor change on data with singletons. Passfixef_rm="none"for the 1.39 numbers. The extendedvce=menu ("wild","CR2"/"CR3"/"jackknife","conley") now removes the singletons fromdatabefore it fits, so cluster codes and coordinates stay aligned with the estimation sample (withfixef_rm="singleton"these calls used to raise);model_info['n_dropped_singletons']records how many rows went.sp.feglm(nofixef_rm) already dropped singletons and is unchanged. Core API snapshot acknowledged (feols.fixef_rm). - Changed ⚠️:
sp.bayes_regresswithoutpriorusesprior="weakly_informative"(normal priors scaled to the data, Rrstanarm's defaults) for models normal, logit, probit, poisson and negbin whenprior_mean/prior_varare left at their defaults andinference="mcmc"; other calls keep the fixed"vague"prior. The 1.39DeprecationWarningis removed. Posterior summaries change, most where a regressor or the intercept is in large units (the fixed prior pulled those toward zero). Passprior="vague"for the 1.39 numbers.prior_meannow defaults toNone(0 when not given) so that giving it, even as zeros, selects the fixed prior, as the 1.39 announcement said. Cross-validation refits repeat the prior that was used. - Changed ⚠️:
sp.kaplan_meierwithoutconf_typegives the log-log interval ("log-log", Statasts list's default) instead of"plain"; the 1.39DeprecationWarningis removed.ci_lower/ci_upper(and the bands of.plot()) change; the survival estimate, its standard error and the median do not. Passconf_type="plain"for the 1.39 interval. - Tests: Track A module 67 passes
fixef_rm="singleton"tosp.fepoisexplicitly (the setting of itsfixest::fepoisreference); its committed numbers are unchanged.
parity= critical values on the regression event studies; cluster spellings in sp.feols¶
From the 2026 DiD replication series (castle doctrine, divorce reform). Standard errors already matched Stata and R; the reference distribution did not.
- Added:
parity='stata' | 'r'onsp.event_study,sp.sun_abrahamandsp.did_2x2. It switches the critical values of the p-values and confidence intervals to those of Stata (reghdfe/eventstudyinteract/regress) and Rfixest: Student t with G-1 degrees of freedom for cluster-robust SEs, t(N-K) otherwise (both programs agree, measured as black boxes). Forsun_abraham,parity='r'also setsshare_variance=False(fixest's fixed cohort shares) and'stata'keeps the share term ofeventstudyinteract; an explicitshare_variancethat contradicts the chosen program raises. On the castle-doctrine and divorce panels the intervals and p-values then equal Stata's and fixest's to 2e-12 (tests/reference_parity/test_es_crit_parity.py; fixtures_generate_es_crit_Stata.do/_generate_es_crit_R.R). The default output is unchanged: normal critical values for the two event studies, t(N-K) fordid_2x2. Every result recordsmodel_info['conventions'](parity,crit_dist,crit_df;share_varianceforsun_abraham). - Changed:
sp.sun_abraham(share_variance=)defaults toNone, which meansTrue(as before) unlessparity='r'. Numbers unchanged. - Fixed:
sp.feols/sp.fepois/sp.feglmaccept every spelling that clearly means one-way or multiway CRV1 / CRV3 clustering: dict keysCL1/CR1/cluster(CL3/CR3), any case, with a column,'a+b'or a list; strings'cluster:id','cluster(id)','cluster = id','CL1:id','~id', and a bare'cluster'next tocluster=. All give results identical tocluster='id'(including the mark-out of rows with a missing cluster).vcov={'CL1': 'id'}used to stop on pyfixest's internalAssertionError. Unknown spellings raiseMethodIncompatibilitywith a hint pointing tocluster=/vce='cluster id', and anyAssertionErrorfrom pyfixest is re-raised the same way. - Fixed:
sp.hdfe_ols(vce='cluster firm')(and'vce(cluster firm)','cluster:firm','cluster(firm)','~firm') now meanscluster='firm'; it used to be refused as unrecognised. An unknownvce=raisesMethodIncompatibility(aValueErrorsubclass, as before) with a recovery hint. - Not changed (reported): the default critical values differ across the DiD family.
event_studyandsun_abrahamuse the normal,did_2x2uses t(N-K) also with clustering (Rlm+coeftest(vcovCL)),sp.feolsuses t(G-1) (fixest / reghdfe), and theevent_studypre-trend test is already a cluster F(q, G-1) test. Aligning the defaults would move published numbers and is left to the owner (docs/dev/modules/did.md).
Weighted Bacon decomposition, breakdown values for averages, reproducible C-LF, integer overflow in formula terms¶
- Added:
sp.bacon_decomposition(weights=). A positive weight that is constant within each unit gives the decomposition of the weighted TWFE coefficient: group shares become weight shares and every 2x2 estimate and its weight become the weighted ones (derivation in the docstring). Works withcovariates=too. The identitybeta_twfe = sum(weight * estimate)holds againstsp.feols(..., weights=)to 1e-10, integer weights equal replicated units, and the type rows match Statabacondecomp [aw=]to 1e-7 (the ado's own rounding). Weights that vary within a unit, or are zero, negative or missing, raiseMethodIncompatibility. - ⚠️ Correctness (precision):
sp.bacon_decompositionon float32 columns (a Statafloat, e.g. Goodman-Bacon's divorce data) kept means and group sums in single precision:weighted_summissedbeta_twfeby 2e-7 and the 2x2 rows agreed with Stata only to about 1e-7. The outcome, treatment and weight columns are now read in float64 (identity to 1e-15, Stata to 1e-8). float64 input is unchanged (Track A 20 reproduces bit for bit). - Added: the decomposition table has
comparison_class('clean','forbidden', and with covariates'mixed'/'covariates_only') andis_forbidden(True forLater vs Earlier TreatedandLater vs Always Treated); thesummarytable andsummary()show the class. Existing columns and numbers are unchanged. - Added:
sp.breakdown_m(l_vec=, window=)with the meaning they have insp.honest_did(e.g.l_vec='average', the average post-treatment effect), found by the same non-nested scan. On the castle-doctrine data the average-effect C-LF value is 0.406; R HonestDiD brackets it at 0.97 / 1.03 times that value, and the Conditional value at 0.997 / 1.003. - Added:
sp.honest_did/sp.honest_did_from_moments/sp.breakdown_mtakeseed=(default 0, HonestDiD's default; numbers unchanged) for the simulated C-LF critical value, andbackend='r'passes it to HonestDiD. Tables recordattrs['simulated_cv'], and for C-LFattrs['seed']andattrs['lf_draws'](1,000). C-LF was already reproducible run to run; its gap to R / Stata is their different random draws.honestdid_method='Conditional'has an exact critical value. - Added:
sp.honest_did(breakdown=)andattrs['next_steps'](the matchingsp.breakdown_mcall). By default the breakdown value is stored inattrs['breakdown_m']for the FLCI, where it costs about half of the table; for the test-inverted sets passbreakdown=True. - ⚠️ Correctness: integer columns read by a generated formula term (
I(x**3),np.power(x, 2)) are now cast to float64 before patsy evaluates the term. Widening narrow integers to int64 (the earlier fix) still let a largeint32/int64column overflow:sp.regress("y ~ I(pop**3)")with populations of a few million returned a wrong coefficient without warning, and so didsp.glm/sp.logit/sp.poisson/sp.ridge/ ordered models, Callaway-Sant'Anna and DR-DiD covariate formulas, andpredict(newdata=). Columns used byC(...)keep their integer level names. A narrow integer column whose values reach both ends of its type's range (a power computed in int8 before the call) now triggers a warning.sp.feols/sp.fepois/sp.feglmare covered too: the fixest formula is cut at the top-level|, each~part is read on its own,i(f, ...)keeps its integer level names, and the arguments ofsw()/csw()are read one by one (sp.feols("y ~ I(pop**3)")on an int64 population column returned a coefficient of the wrong sign).
sp.rlasso_iv intercept and sandwich, sp.psmatch2 tie warning, neural estimators without bootstrap draws¶
- ⚠️ Correctness:
sp.rlasso_iv(select_Z=True, select_X=False)fitted the first stage with an intercept but solved the second stage without one (a faithful copy ofhdm::rlassoIVselectZ0.3.2). With a nonzero mean inyordthe slope was inconsistent: on a known-truth DGP (beta = -2) it returned -0.64; it now returns -1.969, which is exactly robust 2SLS when every instrument is selected. The intercept is not added when the controls already contain a constant, so EminentDomain's coefficient (0.2274) is unchanged. - ⚠️ Correctness: the same path's robust variance used the transposed bread (
A^+' Ω A^+instead ofA^+ Ω A^+',A = Dhat'[d, x]), again as in hdm. The SE was wrong whenever the first stage left a control out (Monte Carlo: sampling SD 0.239, corrected formula 0.234, old formula 0.363). EminentDomain logGDP SE 0.2466 → 0.1760. The reference is now hdm's own first stage followed by hdm'stsls(homoscedastic=FALSE)(synthetic fixture), or the same sandwich computed in R withMASS::ginvwheretsls'ssolve()fails on EminentDomain's control block (tests/reference_parity/_generate_rlasso.R). - ⚠️ Correctness:
sp.rlasso_iv(select_Z=True, select_X=True): when no control was selected for the instrument, the instrument was centred at the grand mean of the control matrix (hdm'smean(x)) instead of its own mean. The slope was unaffected; the robust SE was inflated (5x on the DGP above).intercept=is now passed to every Lasso step of theselect_Xand double-selection paths, where it used to be ignored. - Fixed:
sp.psmatch2(..., ties=True)no longer showssp.match's tie warning, which told the user to passties='all'(not a psmatch2 option). Withoutties, psmatch2 warns in its own terms and points toties=True. Numbers unchanged. - Fixed:
sp.tarnet/sp.cfrnetwithn_bootstrapbelow 2 returned NaNseandcithrough three numpyRuntimeWarnings and reportedpvalue = 0.0. They now return NaNse,ciandpvaluewithout warnings, withmodel_info['se_method'] = 'none'and ase_notesaying why.sp.dragonnet'sse_notenow describes its AIPW influence-function SE (it used to carry the plug-in-dispersion note of the other two); itsn_bootstrapis documented as unused.
Cleanup: patched-leebounds fixtures retired, kernel/llr caliper warning, drdid RCS replay SE, dead recovery pointers¶
- Changed:
sp.match(method='kernel' | 'llr', caliper=...)now warns that the caliper has no effect (the kernel bandwidth isbwidth), as in Statapsmatch2, and setsmodel_info['caliper_ignored']. Numbers unchanged. - Changed:
sp.from_stata/sp.statatranslatedrdid ..., dripw(with or withoutrc1, anddrdid ..., all) on repeated cross-sections withvariance_convention='reference', so a replayed line prints Stata's SE. A note says thatsp.drdid's default'delta'(Sant'Anna and Zhao 2020, Appendix B) gives the asymptotically correct SE under propensity-score misspecification. The ATT is unchanged. - Fixed:
alternative_functionsrecovery pointers that were not callablesp.<name>paths (sp.binscatter's classic-with-controls warning,sp.panel_matchrefusals,sp.scest(treat=)). - Tests: the teffects / misc_sens Stata do-files no longer patch a copy of
leebounds.ado(no licence stated); the rows that patch produced (leebounds_exact,attr_lee_exact) are retired andtrimming='exact'is checked by known-truth and identity tests (tests/reference_parity/test_lee_bounds_exact_threshold.py). License provenance audit, addendum rows X2 / X3 (the superseded HTZ implementation plan is deleted).
RD follow-ups: CR2 / CR3 cluster variances in sp.rdrobust, NaN density test, rdplot T4 notes¶
- ⚠️ Correctness:
sp.rdrobust/sp.rdbwselectwithcluster=andvce='hc2'/'hc3'used HC2 / HC3-scaled residuals with the CR1 small-sample factor. R rdrobust 4.0.0 (the canonical implementation, used as a black box) maps these to its CR2 / CR3 variances, and so does StatsPAI now: each cluster's residuals times(I - H_gg)^(-1/2)/(I - H_gg)^(-1)withH_ggthe cluster block of the weighted hat matrix of the same fit, no small-sample factor. SEs rise by 7% (CR2) to 18% (CR3) on therd_covs_discriminatingfixture, and the selected bandwidths move with them.vce='cr1' / 'cr2' / 'cr3'are accepted (falling back tohc*with a warning when no cluster is given, as in R). New parity testtests/reference_parity/test_rd_cluster_cr23_R_parity.py(10 designs x {CR1, CR2, CR3}: fixed and selected bandwidths,h > b, covariates, fuzzy, weights, p = 2, uniform kernel) agrees to 1e-9. - ⚠️ Correctness:
sp.rddensityreportedT = 0, p = 1when the test statistic could not be computed (too few distinct values in a window); it now reports NaN with anAssumptionWarningand anot_computablediagnostic, as RrddensityreportsNA. - Docs: two deliberate
rdplotdifferences from R 4.0.0 (the extra boundary bin from floating-point rounding;J_IMSE = J_MV = 1instead ofInf/NaNfor a side with a constant outcome) are recorded as T4 indocs/dev/modules/rd.md. - Removed:
tests/reference_parity/_fixtures/_verify_rdbwselect_cascade.R, which replicated GPL rdrobust internals and was used by nothing (license provenance audit, addendum row X1).
DR-DiD on repeated cross-sections: delta-method SEs by default; more clean-room rewrites (CS grid / RCS path / multiplier bootstrap, AR tests, staggered SDID, score bootstrap)¶
Second clean-room pass over self-declared ports of GPL code (audit rows H25-H27 and three DiD pieces found by the first pass). Specifications and evidence: docs/dev/cleanroom/cs_rcs.md, dynpanel_ar.md, sdid_staggered.md, score_wild.md. In every case the old code was deleted unread and the reference was used only as a black box.
- ⚠️ Correctness (owner's decision):
sp.drdid(est_method='dr', method='trad')on repeated cross-sections andsp.callaway_santanna(estimator='dr')on the repeated-cross-section / unbalanced-panel route now use the influence function of Sant'Anna and Zhao (2020, Appendix B) by default (variance_convention='delta', a new keyword of both, also ofdid._rcs.drdid_rc). RDRDID/didand Statadrdid/csdidcarry the pre-period comparison-regression estimation effect with the opposite sign; with a misspecified propensity score their 95% intervals cover 81% of the time in our Monte Carlo against 95% for the delta method (tests/reference_parity/_fixtures/_drdid_rc_pre_regression_mc.py). Point estimates are unchanged; standard errors move when there are covariates (with a correct propensity model the two agree asymptotically).variance_convention='reference'reproduces R and Stata; the parity tests now pass it explicitly, and the gap is recorded as T4 (reference divergence). No Track A or original-data module uses this path.sp.from_stata/sp.statakeep the default and say so in the translation notes. Upstream report draft:docs/dev/2026-10-20-upstream-drdid-draft.md. - ⚠️ Correctness:
sp.callaway_santannatreats cohorts first treated after the last observed period as never treated (they are untreated throughout the sample). They used to be left out ofcontrol_group='nevertreated'on the panel and the repeated-cross-section paths; Rdidincludes them (black box: our ATT and SE now equal R's to 1e-15 on both paths; the probe panel moved from 1.087 to R's 1.044). - ⚠️ Correctness: dynamic-panel GMM (
sp.xtabond,sp.xtdpdsys) withtransform='fod'orh=1: the classical one-step σ̂² divided by 2 as if the transformed errors were first differences. Classical one-step SEs were about √2 too small and the Sargan statistic twice too large there (abdata FOD: SE of L.n 0.0642 → 0.0908; xtabond2 0.0907). Robust and two-step results are unchanged. - ⚠️ Correctness: the classical (one-step, non-robust) Arellano-Bond AR statistics for system GMM and after orthogonal deviations now match xtabond2 (up to its σ̂² divisor); they did not before (abdata system AR(1) -8.12 → -8.08, FOD -2.76 → -6.44, xtabond2 -8.09 / -6.44). Every robust, two-step and difference-GMM classical statistic is unchanged (2.4e-12 on 676 black-box configurations; every Stata fixture at 1e-9).
- Changed (clean-room):
did/_core.multiplier_bootstrap(Algorithm 1 of Callaway and Sant'Anna 2021), the ATT(g, t) grid and the repeated-cross-section / unbalanced-panel estimator ofcallaway_santanna(newdid/_cs_rcs.py),gmm/_dynpanelAR tests (serial_correlation_testreplacesarellano_bond_ar_test/ar_test_cross_basis), the inference ofsp.sdid(treat=...)(projected covariates, jackknife, bootstrap, placebo, refusals) and the GLM score wild bootstrap (newinference/_score_wild.py, re-exported frominference/jackknife.py). Seeded multiplier-bootstrap draws, SEs and critical values are unchanged (4e-16); the CS repeated-cross-section path reproduces the previous outputs on 49 configurations bit for bit exceptmodel_info['cohort_sizes']on weighted unbalanced panels (now the sum of mean-one unit weights; aggregates unchanged); SDID jackknife SEs match Stata at 1e-15; the score bootstrap reproduces boottest's enumerated p-values exactly.sp.drdid(method='imp')on an unweighted panel now runs the same engine as the weighted one (_dr_imp_att; Track A 38 reproduces to 1e-15). - Changed: on a panel,
sp.drdidrefuses (DataInsufficient) a sample with no treated or no control units, and, for the estimators with a comparison outcome regression, fewer comparison units than regressors; it used to return an infinite or minimum-norm result.callaway_santannaon repeated cross-sections / unbalanced panels refuses negative or all-zero weights, weights that vary within a unit on the unbalanced route, and a row-level cluster that is constant within every cohort x period cell, as before. A non-finite column of influence functions now gives a NaN bootstrap SE instead of 1e-12. Staggered SDID bootstrap / placebo SEs (an S-level screen) move by up to 1.3% withmethod='sdid'/'sc'(replicate weights are solved from scratch;method='did'replicates are identical). - Removed (private):
wooldridge_did._drdid_imp_panel_core,_calibrated_pscore,_logistic_fit,_logistic_coefficients,_weighted_lstsq,callaway_santanna._rcs_residualise_on_controls(unused after the rewrite; theapproachentry ofmodel_infothat named it did not describe the computation and is dropped).
RD power, heterogeneous effects and boundary designs: clean-room rewrites¶
Audit rows H9-H11 of docs/dev/2026-10-20-license-provenance-audit.md.
- Changed:
sp.rdpower/sp.rdsampsi(rd/rdpower.py) are reimplemented from Cattaneo, Titiunik and Vazquez-Bare (2019, section 2), the Rrdpower3.0 help pages and R's outputs (black box; specdocs/dev/cleanroom/rdpower.md). New fixturerdpower_R.json(_generate_rdpower_R.R): data-mode power and robust SE match R at 1e-9 on 10 cells, andrdsampsi(data=)window sample sizes match exactly on 14 cells. Where R's iterative solver does not return (e.g.tau = 0.5on the Senate data), the root is now bracketed and a number is returned. - Changed:
sp.rdhte/sp.rdbwhte/sp.rdhte_lincom(rd/hte.py, newrd/_hte_core.py) are reimplemented from Calonico, Cattaneo, Farrell, Palomba and Titiunik (2025, both arXiv papers), the Rrdhte0.2.0 help pages and R's outputs (black box; specdocs/dev/cleanroom/rdhte.md). Every existing fixture still matches; the new fixturerdhte_R.jsonadds 29 cells (all variance types, kernels, orders, windows, cutoff, selected bandwidths) at 1e-9. Outputs on the previous API are unchanged (old vs new agree to 1e-9 on 19 configurations). New: a categorical (or boolean / string) moderator gives subgroup effects with a bandwidth per level, as R's factors do (it was an error); remappedvcewith or withoutcluster=now warns; invalidalpha,n_evalorhraise; the result's.plotis picklable. - Changed:
sp.rd2d/sp.rd2d_bw/sp.boundary_rd(rd/rd2d.py,rd/_rd2d_location.py,rd/_rd2d_distance.py, newrd/_rd2d_common.pyandrd/_rd2d_inputs.py) are reimplemented from Cattaneo, Titiunik and Yu (location- and distance-based papers, arXiv 2505.05670 / 2510.26051, and the rd2d software article), the Rrd2d1.0.0 help pages and R's outputs, including its$mseconstsbandwidth constants (black box; specdocs/dev/cleanroom/rd2d.md). All 47 cells ofrd_open_R.jsonstill match; the new fixturerd2d_floor_R.json(n = 600, where thebwcheckfloors bind) adds 17 cells at 1e-9 / 1e-8. Outputs of the previous implementation are reproduced to 1.2e-10 on 34 configurations. The private distance helpers_poly_lm/_local_interceptsare now_quantile_poly_fit/_side_jumps. - ⚠️ Correctness: design-mode
sp.rdsampsinow solves the exact two-sided power equation. The previous code used the one-tail approximationtau/se = z_(1-alpha/2) + z_power, which overstates the sample when the target power is close toalpha(e.g.alpha=0.2, target_power=0.3, tau=0.2: 21 + 21 before, 17 + 17 now, the smallest sample reaching power 0.3). At conventional settings (power 0.8, alpha 0.05) the numbers are unchanged. Invalid inputs (non-positive sizes, variances or bandwidths,alphaoutside (0, 1),target_poweroutside (alpha, 1),tau = 0forrdsampsi,ratio <= 0) now raiseMethodIncompatibilityinstead of returning nonsense or crashing.
⚠️ Correctness: sp.match(method='radius', caliper_scale='sd')¶
- ⚠️ Correctness: radius matching read
caliperon the raw score scale whatevercaliper_scalesaid, socaliper_scale='sd'silently returned the estimate at a radius ofcaliperitself (on the reference data, 1.947 atcaliper=0.1instead of 1.767). The radius is nowcaliper * sd(score)over the full sample (ddof 1), the rule nearest-neighbour matching already used. Stata has no SD caliper; the result equalspsmatch2 , radius caliper(c * sd(_pscore))to 1e-9 in estimate and SE (tests/reference_parity/_fixtures/psm_radius_sd_Stata.json, psmatch2 run as a black box).caliper_scale='raw'(the default) is unchanged. Kernel and LLR matching do not readcaliper, as in psmatch2 (black box: its kernel ATT is unchanged bycaliper()).
Gate repairs on main: sp.mmqreg call record, sp.reset_spec_counter(y=), agent cards¶
- Added:
sp.mmqregrecords its call, sosp.vcov(fit, 'robust')/sp.vcov(fit, 'cluster id')refit it (the refit equals Statammqreg'se(V)for thatvceto 1e-10), and it accepts Stata'svce='cluster id'spelling asvce='cluster', cluster='id'. - Changed:
sp.reset_spec_counter's argument is nowy=(the house-style outcome name);outcome=stays accepted as an alias. Neither has been released. - Docs: agent cards (assumptions, pre-conditions) for
sp.reset_spec_counter,sp.xtset,sp.reshape,sp.collapse,sp.egen,sp.msprt_lambda,sp.hpd_interval,sp.gelman_rubinandsp.westfall_young, read from their code and docstrings.
Honest RD (sp.rd_honest, sp.rd_discrete, sp.rd_bias_aware_fuzzy): clean-room rewrite¶
- Changed:
rd/_rdhonest.py,rd/bias_aware.pyandrd/rd_discrete.pyare reimplemented from Armstrong-Kolesár (2018, 2020), Kolesár-Rothe (2018), Imbens-Kalyanaraman, the RRDHonesthelp pages and vignette, andRDHonest's outputs on our data (black box; licence audit row H8). The previous files described themselves as ports of the GPL-3 package. Public signatures and result fields are unchanged and every committedRDHonestfixture still matches at its registered tolerance. Black-box checks over ~40 further designs (279 selected bandwidths for sharp RD, fuzzy RD with clusters and all three kernels) agree to <= 1.3e-7. Specification:docs/dev/cleanroom/rd_honest.md. - Open item: for
rd_discrete(method='bme'), R reports asmaximum.biasthe bias of one of the two binding support-point choices, but which one could not be determined from outputs. We report the one at the farther end of the two-sided interval; it equals R's on the fixtures and can differ elsewhere (onlymaximum_biasand the BME p-values; the interval is unaffected).
Shift-share AKM inference, binned KDE, GWR bandwidth search and the CR2 HTZ test rewritten clean-room; MR diagnostics split¶
Audit rows H19-H21, H23 and H24 of docs/dev/2026-10-20-license-provenance-audit.md: code whose docstrings said it ported GPL R code is rewritten from the method papers, the references' documentation and their outputs on our data (black box), never their source. Specs in docs/dev/cleanroom/.
- Changed:
bartik/_akm.py(the AKM / AKM0 rows ofsp.ssaggregate,sp.shift_share_se,sp.shift_share_political, and the BHJ shock-level view; ShiftShareSE 1.1.0, GPL-3) is reimplemented from Adao, Kolesar and Morales (2019) and the ShiftShareSE vignette; the collinear-share selection is a limited-pivoting Householder QR written from R's?qrdescription in place of a port of R's QR routine (specakm.md). All existing R / Stata fixtures pass at their tolerances; the previous outputs on 40 random designs are reproduced to 1e-12. New black-box fixtureakm_ci_shapes_R.json(six designs, every AKM0 set shape, weights, clusters,beta0,alpha). - Changed: the Gaussian kernel density behind
sp.interflex(estimator='kernel')and thedineq/rifregquantile RIF (regression/interflex.r_density,decomposition/_common._bw_nrd0/kde_at_dineq; Rdensity()/bw.nrd0, GPL) now comes from one new module,core/_binned_kde.py, written from Silverman's rule of thumb and the linear-binning estimator (specbinned_kde.md). New black-box fixturebinned_kde_blackbox_R.json: R's densities to 1e-14,dineq's RIF to 1e-12. Outputs unchanged except for a single observation (R refuses one; the bandwidth now follows the documented fall-backs instead of returning NaN). - Changed:
sp.gwr_bandwidth(GWmodel 2.4-1, GPL >= 2) is reimplemented from the GWR papers and black-box runs ofGWmodel(specgwr_bandwidth.md). New fixturegwr_golden_search_R.json: 64 recorded searches reproduced probe for probe,bw.gwron two data sets for three kernels. Every previous output on 144 valid configurations is bitwise unchanged. An unknownkernel=now raisesMethodIncompatibility; it used to return the search's first lower probe. Probed bandwidths that admit no fit (one neighbour or less) still score as unusable, but aRuntimeWarningnow says how many there were. - Changed:
sp.fast.cluster_wald_htz/cluster_dof_wald_htz(the CR2 Wald test with the approximate Hotelling's T-squared reference; clubSandwich 0.6.2, GPL-3) are reimplemented from Pustejovsky and Tipton (2018, Section 3.2, eq. 12-13) (spechtz.md). New black-box fixturehtz_blackbox_R.json(five designs including a two-way dummy panel where the CR2 adjustment needs the Moore-Penrose inverse; q = 1 to 4; non-zero nulls):eta, F, p andV_Ragree withWald_test(test = "HTZ")to 3e-14. The older fixture generator and the live-R test now call only clubSandwich's public API. A (near-)singular CR2 variance ofR beta(a restriction on a cluster fixed effect) now warns and uses the pseudo-inverse instead of returning rounding noise. - Changed:
statspai.mendelian.diagnostics(1,066 lines) is split: MR-PRESSO moves tostatspai.mendelian.presso. Pure move: outputs are byte-identical andfrom statspai.mendelian.diagnostics import mr_presso(and the private helpers) still works.
sp.interference("partial" | "hudgens_halloran") deprecated; sp.mmqreg SE guidance; mmqreg in sp.from_stata¶
- Deprecated: the dispatcher aliases
partialandhudgens_halloranroute tosp.spillover, which is not the Hudgens-Halloran estimator. They now emit aFutureWarning; in the next minor release they will route tosp.two_stage_interference. Numbers are unchanged. The dispatcher's defaultdesignis now"spillover"(the same estimator), so calls without a design neither warn nor change target later. See MIGRATION.md. - Docs:
sp.mmqregkeepsvce='gls'as the default (Statammqreg's). The docstring now says that the method paper (Rios-Avila, Siles and Canavire-Bacarreza 2024) finds the GLS standard errors biased when fitted scales are near zero or negative and favours'robust', or'cluster'with within-group correlation in larger samples. - Added:
sp.from_stata/sp.statatranslatemmqreg(SSC) tosp.mmqreg:quantile()(a value below 1 is a fraction: Stata 18 gives the samee(qval)forq(.5)andq(50)),absorb(),robust/vce(robust),cluster()/vce(cluster). Refused: several quantiles in one call,denopt(),dfadj,jknife, othervce()types, multi-variable clustering, weights, factor variables. Executed equivalence: the lines run throughsp.statareproduce the frozen Stata 18mmqreg2.6 numbers oftests/test_mmqreg.py(full covariance of the three equations, 1e-10) for everyvcewith one, two and no absorbed sets (tests/test_stata_translation_mmqreg.py).xtqregis refused: its quantile-coefficient standard errors use a GLS form that is not reproduced. The error suggests ansp.mmqregcall, which the test executes and checks againstxtqreg's coefficients,e(q)and location coefficients (1e-10).
E-value, Mendelian randomization, CBPS and mediation sensitivity rewritten clean-room¶
Audit rows H13-H18 and H22 of docs/dev/2026-10-20-license-provenance-audit.md: code whose docstrings said it ported GPL R packages is rewritten from the method papers, the packages' help pages and their outputs on our data (black box), never their source. Each rewrite has a spec under docs/dev/cleanroom/.
- Changed:
sp.evalue_rdand the E-value threshold used bysp.evalue(EValue 4.1.4, GPL-2) are reimplemented from Ding and VanderWeele (2016, Propositions A.8 and A.11, eq. A.5). Every committed R case agrees to 1e-12 (the default-grid confidence-limit E-values are now bit-identical toevalues.RD), and the earlier implementation's outputs on 59 configurations are reproduced except the two rows below. New black-box fixtureevalue_rd_grid_R.jsonpins the grid rule (first grid bias factor whose lower confidence limit reachestrue) on coarse grids. - ⚠️ Correctness: when no grid point below the point-estimate bias factor reached
true(a coarsegrid=on a precise table),sp.evalue_rdreturnedevalue_ci = 1.0, i.e. reported that the interval already containedtrue. It now uses that bias factor as the last grid point (the point E-value; still within one grid step of the exact root). Revalues.RDreturnsNAthere. With no cases among the unexposed (infinite risk ratio) the call now raisesMethodIncompatibilitywith a recovery hint instead of NumPy'sValueError: Maximum allowed size exceeded. - Changed:
sp.mr_raps(mr.raps 0.4.3, GPL-3) is reimplemented from Zhao et al. (2020) (specdocs/dev/cleanroom/mr_raps.md). The sandwich variance evaluated at R's own estimates reproduces R to 1e-15; the new solver goes to machine precision, so fits differ from R by R's stopping rule (simple 8.7e-9, L2 over-dispersed tau2 1.75e-8, robust beta 2-3e-5 / tau2 up to 1e-3; in all 36 robust fits on scratch data our estimating-equation residuals are no larger than R's). One parity tolerance moves from 1e-8 to 1e-7 for that reason. Changes:loglik_robustfor the Huber and Tukey losses is now-sum rho(t)as in the paper's eq. 5.1 (twice the old value; L2 unchanged); when the tau2 equation has no positive root, tau2 is exactly 0 with a warning (it was about 7e-9; beta moves by up to 5e-6 relative); fits that used to stop where R stops reach the root (tau2 up to 7e-6 relative). - Changed:
sp.mr_presso(MRPRESSO 1.0, GPL-3) is reimplemented from Verbanck et al. (2018) (specdocs/dev/cleanroom/mr_presso.md). New black-box fixturemr_presso_R.json(3 data sets x 6 seeds): the raw and outlier-corrected estimates, SEs and p-values, the observed RSS (global and per variant), the outlier set and the distortion coefficient agree with MRPRESSO at 1e-10 (observed about 1e-15); the simulated global and outlier p-values are a stochastic screen (4 Monte Carlo SE), not parity. Against the previous implementation the deterministic outputs agree to 1e-14. Open item (unexplained): the distortion-test p-value does not reproduce MRPRESSO (0.605 vs 0.519 and 0.801 vs 0.926 on two fixtures; the previous implementation did not either); it is not asserted and is documented in the spec. - Changed:
sp.mr_cml(MendelianRandomization 0.10.0, GPL-2 | GPL-3) is reimplemented from Xue, Shen and Pan (2021) (specdocs/dev/cleanroom/mr_cml.md): every K matches Rmr_cMLto 1.6e-15, BIC selection is identical, the model-averaged estimate and SE (weights times the per-K root, as R's outputs settle) to 4e-16; the previous implementation is reproduced to 1.1e-14 on 88 fits. Invalid inputs (NaN, negative SEs, n <= 1, alpha outside (0, 1)) now raiseValueErrorinstead of aKeyError, silent squaring orlog(0);pathgainsconverged, and aRuntimeWarningfires when a K that carries weight hitsmax_iter. - Changed:
sp.grapple(GRAPPLE 0.2.2, GPL >= 2) is reimplemented from Wang et al. (2021) and its S2 Text (specdocs/dev/cleanroom/grapple.md), with a new black-box fixturegrapple_R.json(5 scenarios x 3 losses, tau2 estimated or fixed). The sandwich at GRAPPLE's own estimates reproduces its SEs to 1.6e-15 (L2) and 7e-7 (Huber / Tukey); estimates differ from GRAPPLE only by its optimiser's early stop. The previous implementation is reproduced to 2e-13 (estimates, tau2) and 1e-13 (L2 / Huber SEs). Invalid inputs raiseValueError. - ⚠️ Correctness:
sp.grapple(loss='tukey')(the default) evaluated the variance of rho(Z) with the max-1 biweight but its derivative on the c^2/6 scale, as GRAPPLE does, so the sandwich was not invariant to the scale of the loss and the tau2 SE came out 6/c^2 = 0.273 times the consistent value. One scale is now used throughout:tau2_seandloglikare 3.658 times the old values and the beta SE moves by 2e-5 to 15% (largest on weak-instrument data with tau2 = 0). Evidence: in 400 Monte Carlo replicates the SD of tau2-hat is 1.59e-4, the new meantau2_se1.55e-4, the old 4.2e-5, with beta coverage 0.948; an identity test pins scale invariance, and GRAPPLE's numbers are reproduced from the same formula when its mixed scale is fed in. For the beta SE alone the change rests on the invariance argument (on a weak-instrument design both versions are conservative: coverage 0.987 new, 0.977 old). - Changed:
sp.cbps(CBPS 0.24, GPL >= 2) is reimplemented in full from Imai and Ratkovic (2014) (specdocs/dev/cleanroom/cbps.md). New black-box fixturecbps_R.json(generator_generate_cbps_R.Ron committed simulated data) with 37 parity tests across estimands and exact / over-identified variants. Against the previous implementation on regular inputs the estimates agree to 2e-7, coefficients to 7e-7 and bootstrap SEs to 1.2e-7. Behaviour changes: an all-treated or all-control sample raisesDataInsufficient(it used to return an estimate); non-convergence or propensity scores at the 1e-6 bound emit aConvergenceWarning(on a perfectly separated design, where no finite solution exists, the over-identified estimate moves 1.8%);_interceptinstd_mean_diff_afteris exactly 0;model_infogainsweighting_point_scale; bad arguments raiseMethodIncompatibility/ColumnNotFound(bothValueErrorsubclasses). - Changed:
sp.mediate_sensitivity(mediation 4.5.1medsens, GPL >= 2) is reimplemented from Imai, Keele and Yamamoto (2010) and Imai, Keele and Tingley (2010) (specdocs/dev/cleanroom/mediate_sensitivity.md); new black-box fixturemedsens_R.jsonwith 20 parity tests; the previous implementation's outputs are reproduced to 1.1e-11 on 18 configurations. Typed errors replaceKeyError,LinAlgErrorand NumPyValueError;eps <= 0is refused; hittingmax_itergives oneConvergenceWarninglisting the affected rho values.
sp.mmqreg: quantile regression via moments with absorbed fixed effects¶
- Added:
sp.mmqreg(data, y, x, absorb=, quantile=, vce='gls'|'robust'|'cluster'), Machado and Santos Silva's (2019) location-scale quantile regression (Stataxtqreg/mmqreg) with one or more sets of fixed effects partialled out and the influence-function standard errors of Rios-Avila, Siles and Canavire-Bacarreza (IZA DP 17262, 2024). The density at the quantile is Stataqreg's default (Hall-Sheather "fitted"). Against Stata 18mmqreg2.6 (black box) the coefficients and the full covariance of the location, scale and quantile equations agree to about 1e-14 for everyvce, with one or two absorbed sets and without any (tests/test_mmqreg.py). Againstxtqreg1.3 the coefficients,e(q)and the location and scale coefficients agree to 1e-10 and its location / scale covariances are our cluster ones timesG/(G-1)(N-1)/(N-K); its quantile-coefficient covariance follows another GLS form (scale moment not centred) and is not reproduced, so its standard errors run 0.6% to 31% abovevce='gls'on the fixture (T4, documented indocs/dev/modules/panel_multilevel.md). Observations alone in an absorbed group are dropped with a warning: Stata keeps them, and their 0/0 standardized residual (rounding noise) movedq(tau)by one order statistic on the fixture.
sp.two_stage_interference: Hudgens-Halloran two-stage randomized designs¶
- Added:
sp.two_stage_interference(data, y, treat, group, saturation)(alsosp.interference("two_stage", ...)) estimates the direct, indirect (spillover), total and overall effects of a design in which groups are randomized to saturations and units within groups to treatment with that share treated (Hudgens and Halloran 2008), weighted by group (their estimands) or by individual (Basse and Feller 2018). Standard errors are the conservative group-level variance estimators. Evidence is exact enumeration of every assignment of a small design whose potential outcomes violate stratified interference: every estimate is unbiased (1e-12), the expected variance estimate exceeds the randomization variance by exactly the between-group variance of the group-level effect over the number of groups (1e-12), and equals it when that effect is constant (tests/test_two_stage_interference.py). Signs are treated minus untreated and higher minus reference saturation, the opposite of the paper's.sp.spilloveris unchanged; the dispatcher aliasespartial/hudgens_halloranstill point to it.
Stata translation: xtregar, xttobit, xtintreg¶
- Added:
sp.from_stata/sp.statatranslatextregar(fe/re,rhotype()with its abbreviations,rhof(),twostep,lbi),xttobit(ll(#)/ul(#),intpoints(),tobit) andxtintreg(intpoints(),intreg) tosp.xtregar/sp.xttobit/sp.xtintreg. The panel declaration comes fromxtsetas forxtreg. Refused:ll/ulwithout a number or with a variable,xttobitwithout any limit, factor-variable regressors, weights;intmethod(ghermite),noconstant,offset()andconstraints()are left out of the call and listed inuntranslated_options, sosp.statarefuses to run them. Executed equivalence: the Stata lines run throughsp.statareproduce the frozen Stata 18 numbers oftests/test_xtregar_stata.py(1e-10) andtests/test_xttobit_stata.py(1e-6) (tests/test_stata_translation_xt_ar_censored.py).
DR-DiD engines and CS aggregation weights: clean-room rewrite¶
- Changed:
did/_rcs.py(the eight repeated-cross-section estimators behindsp.drdidandcallaway_santanna(panel=False)), the panel 2x2 engines ofcallaway_santanna(estimator='reg' | 'ipw' | 'stdipw' | 'ipw_abadie' | 'dr' | 'drimp', also used bysp.drdidon panels),did/_core.weight_influenceandsp.aggte's aggregated influence functions are reimplemented from scratch. The earlier code had been written after reading RDRDID/didsource (GPL-3); the new code was written only from Sant'Anna and Zhao (2020), Callaway and Sant'Anna (2021), the DRDID help pages and DRDID 1.3.0's outputs on our data (black box). Shared algebra lives in the newdid/_dr_common.py; specification and evidence indocs/dev/cleanroom/did_drdid.md. Signatures, result fields and published numbers are unchanged (Track A 04 / 79 and original-data 02 reproduce within 1e-9; all CS / drdid / aggte reference tests pass at their tolerances). New black-box fixture: 82 estimator x design x weighting cases (one design with trimmed controls, one with singular cell regressions) with ATT, SE and five influence-function projections (test_drdid_blackbox_R_parity.py). Repeated-cross-section cell regressions that are singular now raiseMethodIncompatibilityfor the traditional DR and OR estimators and drop the collinear column for the improved ones, as the reference does. - Decided 2026-10-20 (see the clean-room section above): on repeated cross-sections the reference
carries the pre-period comparison-regression estimation effect of the
traditional DR estimators (
drdid_rc,drdid_rc1) with the opposite sign to the delta method (paper, Appendix B). With a misspecified propensity score the reference SE is about a third too small in a Monte Carlo (coverage 0.81 vs 0.95). The default keeps the reference sign;_rcs.drdid_rc(..., variance_convention='delta')gives the paper's influence function. Switching the default would be a correctness fix forsp.drdid(method='trad')on repeated cross-sections andcallaway_santanna(panel=False, estimator='dr').
RD core (sp.rdrobust, sp.rdbwselect, sp.rdplot, sp.rddensity, sp.lprobust_at_point / lpbwselect_*): clean-room rewrite¶
- Changed: the modules the 2026-10-20 license audit listed as ports of
GPL code (rows H5, H6, H7, H12) are reimplemented from the papers, the
R / Stata help files and black-box runs of the references, without
reading their source: the CCT bandwidth selector and robust
bias-corrected estimator (
rd/_cct_bandwidth.py, new_cct_inference.py,_cct_side.py,_nn_variance.py), the RD-plot numbers (rd/_rdplot_numbers.py), the CJM density estimator (rd/_lpdensity.py,diagnostics/rddensity.py) andnonparametric/lprobust.py(+_lprobust_bw.py,_lprobust_select.py). Specifications with the black-box evidence for each convention are indocs/dev/cleanroom/(rd_cct.md,rdplot.md,rddensity.md,lpdensity.md,lprobust.md). Signatures and result fields are unchanged. Track A modules 06, 09, 88, 89 and original-data 05 reproduce their committed results within 1.5e-10. New R fixtures: NN residuals, rdplot (70 calls), lpdensity, rdbwdensity regimes, lprobust selectors (600 cases). - ⚠️ Correctness:
sp.rdrobust/sp.rdbwselectwithmasspoints='adjust'(the default) on a running variable with 20% or more tied values floored the finalhandbat thebwcheckdistance (the 10th distinct value from the cutoff). rdrobust 4.0.0 floors only the pilot bandwidth; on the 20-support-point cells oftests/reliability/rd_mass_points.pythe oldhwas 0.475 on every draw against R's 0.15 to 0.35. Now as R (to 1e-12 on those draws). - Changed: a preliminary fit in the bandwidth selector whose window
has fewer distinct running-variable values than coefficients raises
DataInsufficient(sp.rdrobustfalls back to its legacy selector with a recorded warning, as for other degenerate data), and the estimator refuses a rank-deficienth/bwindow withNumericalInstability. Both used to return numbers from a pseudo-inverse (R does too). A constant outcome raisesDataInsufficientin the selector. - Changed (
sp.rddensity):n_effcounted one observation short when the cutoff is not zero and an observation sits exactly at distanceh(now as R); a side with too few distinct points gives NaN densities instead of numbers from an underdetermined fit; estimates atp >= 3move by up to 2e-7 (p = 3, 4) and 1e-4 (p = 6, 7), equally close to R.lpdensity_numbersrefuses a bandwidth <= 0,v > pand unknown kernel names (they fell back to Epanechnikov). - Changed (
lprobustselectors): last-digit changes, up to 2e-9 atp = 1and 1.5e-6 in ill-conditionedp = 3cases; a 50-digit recomputation agrees with the new values (seelprobust.md).bwcheck=0and exactly identified pilot windows now raiseDataInsufficient, as R. - Changed (
sp.rdplotnumbers): duplicated covariates are dropped with a warning andcoef_covslists the kept columns (as R); a side with one observation raisesDataInsufficient; invalidnbins/scale/p/ci/ weights raise typed errors; a non-integerscaleusesceil(scale * J). - Open items for the owner: R 4.0.0 maps
vce='hc2'/'hc3'withcluster=to its CR2 / CR3 estimators; StatsPAI keeps leverage-scaled residuals with the CR1 factor (0.07% to 0.6% of the SE apart on the fixtures tried).sp.rddensitystill reports T = 0, p = 1 when the statistic cannot be computed (R: NA).
sp.scdata / sp.scest / sp.scpi: clean-room rewrite¶
- Changed: the scpi family (
synth/scpi.pyand its private_scpi_inference.py/_scpi_solvers.py, plus the new_scpi_insample.py,_scpi_simulate.pyand_scpi_qp.py) is reimplemented from scratch. The previous code described itself as a port of Rscpi(GPL-3; license-provenance audit row H1). The new code was written from Cattaneo, Feng and Titiunik (2021), the scpi JSS article (2025), the staggered-adoption paper and its supplement, the R help pages, this repository's tests and fixtures, and Rscpi4.0.1 run as a black box; the specification and the evidence for every convention are indocs/dev/cleanroom/scpi.md. Signatures and result fields are unchanged. Against R, fed R's weights and draws, the bounds agree within R's own solver tolerances on 110 of 113 black-box panels (the other three are R solver artefacts, documented). New fixturetests/reference_parity/test_scpi_cleanroom_parity.py(17 configurations on three committed panels) pins the conventions the papers leave open: rho rules, the radii of the simulation constraint set, HC2 / HC4 leverage, residual designs, levels, and constant out-of-sample designs. - ⚠️ Correctness:
sp.scpi(w_constr='L1-L2')in-sample bounds (and the combined and joint bounds built on them) were off R by 1.6e-4 to 6.3e-3 on eight of 26 black-box panels and by 1.4e-2 on the new fixture's short panel; they now agree with R within its solver tolerance (5e-6 here). Other constraints are unchanged (old and new agree to 1e-10 on the Germany fixture and to 1e-13 on seeded runs, same draw stream). - Fixed: in-sample problems on ill-conditioned designs that the previous code left unsolved (NaN draws) or handed to SLSQP are solved exactly (on one test panel: 294 SLSQP fallbacks and 11 failed draws before, none now; the solved values agree to 7e-13).
Licence provenance: permissive notices and the MEDIUM audit rows¶
Follow-up to docs/dev/2026-10-20-license-provenance-audit.md. Clean-room
records are in docs/dev/cleanroom/.
- Added:
THIRD_PARTY_NOTICES.mdnow carries the copyright line and licence text of every permissively licensed implementation StatsPAI code follows (fect, interflex, hdm, TwoWayFEWeights, did2s, DIDmultiplegtDYN, bacondecomp, DiSCos, randomizr, ivDiag, cdgd, mbreaks, didFF, robomit, csdid, synthdid under its BSD-3 option, mgwr, rdd), copied from the packages' licence files. - Changed (no numeric change): re-implemented from published formulas
or documentation, with the previous outputs recorded as a black box and
matched:
sp.oster_bounds/sp.oster_deltaexact solution (Oster 2019 and the MIT R package robomit; 4,000 + 12,000 recorded cases to 6e-13),sp.ridge(MASS used as a black box; 22 recorded calls to 1.4e-13), the sensemakr robustness value and benchmark bounds (Cinelli and Hazlett 2020), the Barnard-Rubin df ofsp.micepooling, grf observation weights, the CR2 symmetric matrix power and the mode-based-estimate bandwidth (Hartwig et al. 2017, eq. 7). Docstrings of the spatial GMM, survey, DiSCos quantile, Yun and Gelbach code now cite the papers instead of naming R / Stata internals. - Changed (clean-room rewrites, A-class rows):
sp.psmatch2(...) .pstest()(pstest help file, Rosenbaum-Rubin 1985, Rubin 2001), the weak-IV-robust AR / K / J / CLR tests behindsp.weakrobust(papers, weakiv help file; Stata parity 1.1e-13), and psmatch2's kernel / radius / llr matching, analytic SE and the lpoly smoother (help file, Stata manuals; Stata parity 3.3e-15). Previous outputs recorded as a black box agree to <= 1.8e-13, except nine local-linear matches with the normal kernel and bandwidths 0.001-0.02 on nearly collinear local designs (ATT moves by up to 4.5e-8; the previous code was itself up to 1.9e-6 from the exact answer). - Changed (clean-room rewrite, row M2):
sp.lee_bounds's Stataleeboundsemulation (trimming='leebounds',covariates=,weights=) was re-implemented from Lee (2009), Tauchmann's documentation and black-box Stata runs. 422 recorded results on 32 data sets agree to 7.7e-14; Stata fixtures as before (bounds 3.3e-15).model_info['leebounds_ntall']is renamedbetween_cell_divisor(the old key is kept as a deprecated alias). - ⚠️ Correctness:
sp.lee_bounds(trimming='leebounds')rounded a trimming threshold below 1e-5 in absolute value to 16 significant digits; Stata stores such numbers with 12 (scientific notation), so on outcomes measured in very small units the emulation missed Stataleeboundsby up to 2.7% on a bound. It now matches (8e-11 on six such outcome sets). No other trimming and no previous fixture changes. - Fixed: grf-style observation weights accept string cluster labels
(a
TypeErrorbefore). - Fixed:
pstest()with a constant or collinear covariate counted it in the LR chi-square degrees of freedom; Stata and now StatsPAI use the covariates the probit keeps (p 1.497e-14 against 7.82e-14 on a 400-row sample). Rubin's B and R now match Stata to ~1e-14 (was 1e-9). - Fixed: the weak-IV LM helpers refuse fewer instruments than
regressors, a malformed
b0and fewer clusters than instruments with typed errors (NaN or NumPy errors before; no public entry point reaches them); the CLR p-value has its correct limits at CLR = inf and rk = inf. - ⚠️ Correctness (approximation table): the Hansen (1997) response
surface behind
sp.structural_break(method='sup-f')p-values was a transcription of Hansen's unlicensedpv_suptable (also in GPLstrucchange). The paper prints only part of it, so StatsPAI now recomputes it by simulation from the paper's description (scripts/generate_hansen_supf_table.py, seed 19970101, 200,000 replications). The p-values move by at most 0.0006 for p < .01, 0.0021 for p <= .10 and 0.0076 above (against Rstrucchange/ Stataestat sbsingle): within twice the approximation error Hansen reports for his own table, and checked against an independent simulation to be as accurate as the original table.hansen_supf_pvaluenow refuseskoutside 1..40 or non-integer (aKeyError/ silent truncation before) and a trimming that is neither api_0in (0, 0.5] nor alambda >= 1(0.5 < pi_0 < 1 returned a negative p-value).
sp.panel_match: clean-room rewrite¶
- Changed:
sp.panel_matchis reimplemented from scratch (did/pmatch.py+did/_pmatch_{prep,sets,refine,infer}.py; the olddid/panelmatch.pyanddid/_panel_match_*.pyare deleted). The first version had been written by an agent who had read R PanelMatch's GPL-3 source; the new code was written only from Imai, Kim and Wang (2023), the PanelMatch help pages and vignette, this repository's tests and fixtures, and PanelMatch 3.1.5's outputs on our data (black box). Signature and result fields are unchanged. On 640 black-box comparisons with the first version (sets, weights, estimates, analytic SEs, balance, placebo) and on seeded bootstrap draws, outputs are identical except the ATC item below. Four new R fixture cases (atc_ps,psm_ties,gaps_exact,art_restrict); the existing cases are byte-identical on regeneration. - ⚠️ Correctness: ATC propensity weighting (
qoi='atc'or the ATC half ofqoi='ate', withrefinement='ps_weight'/'cbps_weight') weighted the treated controls by the odds of being treated, i.e. away from the untreated observations they stand in for. That is R PanelMatch 3.1.5's output, which the first version reproduced; on theatc_psfixture it widens the standardised gap in x1 at t from -0.82 (unrefined) to -1.28. The weights are now the odds of being the matched observation, as for the ATT and ART (-0.35 on the same fixture). R's weights are rebuilt as the reciprocal of ours in a test. Unreleased function, so no migration note. - Added:
model_info['propensity_scores'](score of each unit-period a propensity refinement used).ps_matchkeeps controls whose score ties the treated one, where R drops them (and gives a set whose controls all tie zero weights); pinned by an identity on thepsm_tiesfixture. A covariate that is not a column orlag(x, k)of one raisesMethodIncompatibility, a missing columnColumnNotFound.
sp.scest(treat=): several treated units with staggered adoption¶
- Added:
sp.scest(..., treat='<0/1 column>', effect=, anticipation=)fits one synthetic control per treated unit on its periods before adoption (lessanticipation), with the never-treated units as donors, and reports the gaps by unit and period (effect='unit-time'), each unit's average ('unit') or the average across units at horizons every unit reaches ('time');treated_unit=may list a subset of treated units. Written from the Rscpihelp pages (no source); Rscpi4.0.1 (scdataMulti+scest, GPL-3) was used only as a black box on a committed staggered panel: R's outputs equal this construction applied to R's weights to 1e-12, R's weights lie within its solver's 1e-8 gap of the exact per-unit optimum,ols/lassoweights and the ridge radius agree to 1e-9 / 1e-10 (tests/reference_parity/test_scest_multi_parity.py).treated_unit=andtreatment_time=are now optional (needed withouttreat=); the single-unit path is unchanged.
Schemas: str | list[str] parameters are exported as anyOf¶
- Fixed: the schema generator collapsed any union containing a sequence to
{"type": "array"}, so the 176 parameters (117 functions) annotatedstr | list[str](e.g.sp.weakrobust(endog=),sp.dml(cluster=),sp.predictions(by=),sp.hdfe_ols(cluster=)) told agents and MCP clients that a single column name was invalid. They are nowanyOfa string (with the scalar enum, if any) and an array (with its item type and item enum), plus the other explicit scalar members (int,float,bool) andobjectfor adictmember (registry.py::_union_with_scalars).None, arrays, frames and callables add no branch. The CLI reads the branches;tests/test_schema_union_anyof.py.
sp.breakdown_m default relative-magnitude path scans for the first crossing¶
- ⚠️ Correctness (tolerance level):
sp.breakdown_m(method='relative_magnitude')with the default C-LF set bisected a doubling bracket, which assumes the set is nested in Mbar. The test-inverted set need not be, so a later crossing could be returned. It now uses the log-grid scan plus bisection already used for every other restriction (_first_inclusion) and reports the first Mbar at which the set contains zero, to 1e-3 relative (it was 1e-3 absolute). On the six fixture cases the sets are nested and the values move by at most 9e-4 (e.g. 1.95312 to 1.95277); the call costs about three times as many set evaluations. Evidence: R HonestDiD 0.2.8's C-LF sensitivity table excludes zero at 0.5, 0.9 and 0.97 Mbar* and contains it at 1.03, 1.1 and 1.5 Mbar* on every case (tests/reference_parity/test_honest_breakdown_rm_clf_R.py; C-LF is simulated on both sides, so a stochastic screen).
sp.liml(robust='many_weak'): many-instrument standard errors¶
- Added:
robust='many_weak'onsp.liml(LIML and Fuller): the variance of Hansen, Hausman and Newey (2008, section 2), Bekker's (1994) term plus the third- and fourth-moment terms for non-normal homoskedastic errors, with the leverages of the instrument projection (regression/_many_iv_se.py; bib keyshansen2008estimation,bekker1994alternative, verified via Crossref, doi.org and OpenAlex). Evidence: identity with the partialled-out form; a 1000-replication many-weak design (K = 100, n = 500, skewed errors) covers at 0.953 against 0.864 for the classical SE. Rivmodel1.9.1manyweakSE=TRUEgives a different number; it is reproduced to 1e-10 by the same formula with the leverages of the regressors[D, X]instead of the instruments, which is not the paper's quantity and is recorded as a documented difference (T4) rather than copied (tests/test_liml_many_weak_se.py; ivmodel used as a black box). Existingrobust=values are unchanged.
sp.margins / avg_slopes after sp.fepois: standard errors with the effects as parameters¶
- Changed (unreleased refusal replaced): averages after
sp.fepois(orsp.feglm(family='poisson')) that depend on the absorbed effects (sp.marginsAMEs,sp.margins_atand its contrasts,sp.contrast,sp.pwcompare,sp.avg_slopes/avg_predictions/avg_comparisons,by=averages) now get delta-method standard errors that count the effects as parameters, as Stata'smarginsafterpoisson y x i.gand afterppmlhdfe ..., d(postestimation/_fe_delta.py: Frisch-Waugh partitioning of the Poisson information, model-based / robust / one-way clustered, the fit's own small-sample factor read off its coefficient covariance). Equal to Stata 18 to 1e-8 on a one-way fixture and 1e-6 on a two-way, weighted one after that scalar factor, plus a closed-form identity (tests/test_margins_fe_delta.py). Still refused: unit-level quantities,method='mem', two-way clustering, and logit / probit / OLS-predictive margins with absorbed effects. Point estimates are unchanged.
sp.sac full-information standard errors¶
- ⚠️ Correctness:
sp.sacreported the regression SEs assigma2 (X~'X~)^-1, treating rho and lambda as known, and the rho / lambda SEs from the diagonal of a numerical Hessian of the concentrated likelihood. Both were too small (the constant's SE 0.109 against Stata's 0.344 on the rook-grid fixture; rho 0.103 against 0.189). The fit now uses the same full-information maximum likelihood assp.gns(shared inspatial/models/_fiml.py): standard errors invert the observed information in(b, rho, lambda, sigma2), as Stataspregress, ml dvarlag(W) errorlag(W)(defaultvce(oim)). Estimates, OIM SEs, sigma2 and its SE equal Stata 18 to 1e-6 and the log-likelihood to 1e-12 on two fixtures (tests/reference_parity/test_spatial_sac_Stata_parity.py). The point estimates also move: on the asymmetric-graph fixture the old optimiser stopped 0.024 log-likelihood units below the maximum (lambda 0.6588 against 0.6595).model_infogainsvcov,sigma2,se_sigma2,loglik.
Local-projection extensions and the Dumitrescu-Hurlin test rewritten clean-room¶
The IV, state-dependent and panel local projections of sp.local_projections and the panel test of sp.granger_causality(id=, time=) were first written after reading lpirfs and plm source (both GPL). They are rewritten from the method definitions, the packages' help pages and their outputs only (timeseries/_lpx_*.py, timeseries/_dh_granger.py; the old _lp_*.py / _panel_granger.py are deleted). API, result fields and every fixture row are unchanged: lpirfs 0.2.5, plm 2.6 and Stata 18 lpirf / xtgcause to 1e-11 or better. The original direct and Cholesky paths are the pre-extension code, byte-identical.
- ⚠️ Correctness: the state-dependent projection ignored
dfk=andcommon_sample=with an observed shock, andvce=/dfk=withidentification='lpirfs_cholesky'(Newey-West bands whatever was asked). All are applied now. New fixture rows:lpirfs::lp_nl(use_nw = FALSE)equalsvce='ols'on the Cholesky path,lp_nl_iv(lag_switching = FALSE, use_nw = FALSE, trend = 2)the observed-shock path, andcontemp_datathecontrols=path (one coefficient, not one per regime). - ⚠️ Correctness: an excluded instrument that is also a control (or a combination of the included regressors) made the 2SLS projection either raise numpy's
LinAlgErroror return numbers off by up to 0.4 from an ill-conditioned inverse. It is now dropped with anAssumptionWarning(diagnostics['dropped_instruments']), and the fit equals the one without it to 1e-12; if no excluded instrument is left the call is refused. - Changed: arguments that belong to another design are refused instead of ignored:
gamma=/hp_lambda=/state_transform=/lag_state=Falsewithoutstate=,panel_effect=/diff_shock=withoutid=/time=, and on panel projectionsdfk=,trend=,common_sample=andnw_lags=other than withvce='driscoll_kraay'. A horizon whose regressors reproduce the outcome exactly (e.g. the outcome among the controls ath = 0) is refused; its standard errors were rounding noise. - Added: the Dumitrescu-Hurlin per-unit Wald statistics and p-values are pinned to Stata
xtgcause'sr(Wi)/r(PVi)(the p-value refersW_i / KtoF(K, n_i - 2K - 1);plmreports thechi2(K)value). Panel IV projections reportfirst_stage_fby horizon under the projection's variance, as the single-series ones do.
Owner decisions of 2026-10-20: defaults, deprecations, MCP text block, sp.reset_spec_counter¶
Recorded in docs/dev/2026-10-20-owner-decisions.md; migration notes in MIGRATION.md. Only the first item changes numbers.
- ⚠️ Correctness: the string
'rlasso'(and'rigorous_lasso') in a classifier role (sp.dmlirm/iivmml_mandml_r,sp.dml_did(ml_m=),sp.dml_panelwith a binary treatment) now resolves tosp.RlassologitClassifier, the logistic rigorous Lasso thathdmfits for propensities (rlassologit). It wassp.RlassoClassifier, a linear-probability fit clipped to[clip, 1 - clip], whose clipped rows can carry inverse weights of 1000. Estimates with that alias change; regressor roles keepsp.RlassoRegressor, and'rlasso_lpm'selects the old classifier. Test:ml_m='rlasso'equalsml_m='rlassologit'and'rlasso_lpm'equalssp.RlassoClassifier()exactly (tests/test_rlasso.py). - Changed:
sp.causal_forest(n_jobs=)defaults to-1(all cores; was1), andn_jobsis an explicit parameter ofsp.causal_forestand registered. Outputs are byte-identical for any thread count (pinned bytests/test_treatment_effects_oct19_fixes.pyandtests/test_owner_decisions_oct20.py). Core API snapshot updated with the MIGRATION acknowledgement. - Deprecated:
sp.hdfe_olswith two or more cluster variables and nocluster_df=warns (FutureWarning) that the default multi-way small-sample factor moves from'per_term'(Rsandwich::vcovCL) to'min'(Statareghdfe, Rfixest) in a later minor release. Standard errors are unchanged in this release. - Deprecated:
sp.sun_abrahamwithoutaggregation=warns (FutureWarning) that the headline.estimatemoves from the equal-weight event-time average to the cell-weighted ATT offixest::summary(agg = "att")(aggregation='fixest_att'). Numbers unchanged;sp.did(method='sun_abraham')now passes a missingaggregationthrough, so it warns too. Track A module 05 already passesaggregation='fixest_att'. - Deprecated:
sp.bcfwarns (FutureWarning) that it will route tosp.bart_causal(method='bcf'). Numbers unchanged;sp.bcf_ordinalandsp.bcf_factor_exposurecall the same fit without the notice and are not affected by the planned rerouting. - Changed:
matplotlib>=3.5is a core dependency (still imported only when a figure is drawn;import statspaidoes not load it). seaborn and plotly stay in[plotting]. - Changed:
sp.binscatter(method='classic', controls=...)warns (AssumptionWarning) that residualize-then-bin is not consistent forE[y | x, controls]whenxand the controls are related, suggestsmethod='binsreg', and records it inbin_data.attrs['diagnostics']. The default stays'classic'. - Added:
sp.reset_spec_counter(outcome=None)forgets the session's specification-search count (thespecification_searchviolation) and returns what it cleared; callable as an MCP tool by name or throughcall_function. The violation's recovery hint names it. - Added: the MCP server's text block of a successful
tools/callcan be a few summary lines instead of the JSON ofstructuredContentagain:STATSPAI_MCP_TEXT=summaryorstatspai-mcp --text summary(mcp_server.set_text_mode). The default staysfullfor clients that ignorestructuredContent. Measured savings indocs/dev/modules/agent_mcp.md.
Classifier learners in DML, metalearners, causal forest; sp.RlassoClassifier as a propensity¶
- ⚠️ Correctness:
sp.dynamic_dml(model_t=<classifier>)(ormodel_y=) residualised on the classifier's hard 0/1 label frompredict, so the residual wasT - 1{p > 0.5}, notT - E[T | state]. A classifier on a 0/1 target now gives P(T = 1), and a classifier on any other target is refused, as insp.dml. Estimates with a classifier learner change; the defaultRidgeCVand any regressor are byte-identical. - ⚠️ Correctness:
sp.RlassoRegressor,sp.RlassoClassifierandsp.RlassologitClassifierignoredsample_weightinfit(the rigorous Lasso has no observation weights). They now raiseTypeError, so a weightedsp.dmlfit warns that the nuisance is fitted unweighted (it was silently unweighted before; numbers unchanged),sp.SuperLearner(sample_weight=)refuses the learner, andsp.ipw/sp.aipw(weights=, ps_learner=)with one of them raises instead of returning an unweighted propensity. - Fixed:
sp.dml(irm/iivmml_m,ml_r; a classifier underplr/pliv),sp.dml_did(ml_m=),sp.dml_model_averaging,sp.metalearner/sp.auto_cate(propensity_model=),sp.causal_forest(model_t=)andsp.conformal_counterfactual(propensity_model=)raisedIndexErrorwithsp.SuperLearner, whosepredict_probais the(n,)vector of P(Y = 1), and indexed column 1 regardless ofclasses_. They now read the probability throughcore/_learners.py, which also maps a single-class(n, 1)output to 0 or 1. Test: a SuperLearner with a one-learner library equals that learner in every one of them to rtol 1e-9 (tests/test_learner_proba_routing.py). Outputs with the default learners and with scikit-learn classifiers are byte-identical. - Changed:
sp.RlassoClassifier.predict_probawarns (AssumptionWarning) when its linear-probability prediction is clipped to[clip, 1 - clip]. On a design with ATE 1.2,sp.ipw(ps_learner=sp.RlassoClassifier())returned -1.23 (-2.19 on another draw) while logit, boosting andsp.RlassologitClassifiergave 1.07 to 1.24: about 3% of rows had a linear prediction outside [0, 1], a treated row clipped to 0.001 carried weight 1000, and two or three rows set the estimate. The classifier itself is right: intercept and coefficients equalhdm::rlassoon the 0/1 label to 1e-12 and its columns are [P(0), P(1)] (tests/test_rlasso_classifier_propensity.py, hdm 0.3.2). For a propensity usesp.RlassologitClassifier, as hdm'srlassoLATEusesrlassologit.
Leftovers from the 2026-10-20 probe (xtevent, data layer, translation)¶
Fixed¶
sp.xteventno longer raisesKeyErrorwhen an event-time regressor is collinear with the unit / period effects (typically the binned left endpoint, constant within every unit when the window reaches past the panel). As Stataxtevent3.1.0 does, the term is omitted, reported as 0 with zero variance and enters the averages as 0; aUserWarningnames it andmodel_info['omitted']/ theis_omittedcolumn record it. Both engines match Stata 18 to 1e-10 on a new fixture, with and without clustering and withtrend_method='ols'(tests/reference_parity/test_xtevent_omitted_Stata_parity.py). A static model whose policy is absorbed now raisesMethodIncompatibility.sp.xtevent(proxy_iv=1)(a scalar lead, Stataproxyiv(1)) raisedTypeError; it now equalsproxy_iv=[1]and Stata. A missing external instrument raisesColumnNotFoundinstead ofKeyError.sp.honest_did(backend='r')raisedKeyError('ci_lower')when R HonestDiD accepted no point of its grid (it returnslb = Inf,ub = -Inf, which jsonlite drops). HonestDiD's default grid for the relative-magnitude families is -/+ 20 sd around zero, so any estimate more than about 20 SE from zero hits this. It now raisesConvergenceFailurenaming the cause, with a grid around the estimate inrecovery_hint/diagnostics['suggested_grid']; with that grid the set equals the zero-centred one moved by the estimate (tests/test_honest_did_backend.py).sp.collapse(andcollapseinsp.stata) returned an empty frame silently when no row takes part (every weight zero or missing, orcwdropping every row). Stata 18 stops withno observations,r(2000), and leaves the data alone; so do these now (DataInsufficient;_rcis 2000 undercapture). A group whose weights are all zero is left out, as in Stata (tests/test_data_layer.py).sp.from_r('weightit(...)')was refused with guidance pointing tosp.ps_weights, althoughsp.weightitexists. It now translatesweightit(treat ~ x1 + ..., method = "glm" | "ps" | "cbps" | "ebal" | "energy", estimand, focal, moments, int, d.moments)tosp.weightit; five translated lines reproduce the WeightIt 1.7.0 weights of thesp.weightitparity fixture to 1e-8 (WeightIt is GPL: outputs only). Arguments that change the weights and have no counterpart (stabilize,s.weights,by, ...) are listed as untranslated. Thesynthdid_estimateguidance now namessp.synthdid_estimate. The otherR_GUIDANCEand Stata_UNTRANSLATED_GUIDANCEentries were audited: none has a translation handler or names a missing function.- ⚠️ Correctness:
sp.xtivreg(method='re')refused an excluded instrument that does not vary within panels, which Stata accepts. With no time-varying excluded instrument Stata estimatessigma_efrom the within regression without the endogenous regressors; this now does the same, with a warning that the variance components are then not consistent (the coefficients remain a consistent IV estimator; prefervce='robust') andmodel_info['within_omitted']. Too few time-varying instruments for the endogenous regressors is refused (which one Stata drops is not documented). Checking against Stata 18 found two balanced- panel conventions the unbalanced fixture had not exercised: thesigma_edegrees of freedom count only the regressors the within regression uses, and thesigma_ubetween regression leaves out excluded instruments constant within panels. Balanced panels with a time-invariant regressor or instrument change (sigma_eby ~0.1% per time-invariant regressor). Twelve new Stata 18 cases on a balanced and the unbalanced panel agree to 1e-11 (tests/test_xtivreg_stata.py). One Stata artifact is documented, not copied: withvce(robust)/vce(cluster)itsKcounts a continuous time-invariant regressor (rounding noise in its within transform) but not a dyadic one. sp.granger_causality(id=, time=)(Dumitrescu-Hurlin) returned a NaN headline p-value without a word when a unit has at most 5 + 3K periods. Stataxtgcausestops there (r(459)) and Rplm::pgrangertestrefusesZtilde; this now raisesDataInsufficientnaming the condition, the largest lag order the panel allows and the short units (tests/reference_parity/test_ts_causal_parity.py). The threshold was checked against both at T = 8 / 9 (K = 1) and 11 / 12 (K = 2).- ⚠️ Correctness:
sp.binscatter(method='binsreg')on anxwith few distinct values selected the number of bins as ifxwere continuous; the quantile knots then collapsed (one bin for a ten-valuex). As the binsreg help describes (dfcheck(20 30),masspoints(veryfew)) and Statabinsreg2.2 does, anxwith at most30 + J_rotdistinct values now gets one bin per value, dots and intervals from thep = 0fit; dots and intervals match Stata to 1e-10 on a ten-value fixture (tests/test_binsreg_parity.py; binsreg is GPL, used as a black box). The cutoff uses our continuous-xrule of thumb, which is larger than binsreg's mass-point-adjusted one, so between roughly 42 and 49 values (n = 1000-2000) this gives one bin per value where binsreg selects about ten; above the cutoff its mass-point adjustment is still not implemented (warned, as before). The defaultmethodis unchanged. src/statspai/__init__.pyihad drifted from the runtime namespace by 107 declarations (103 public names missing, e.g.sp.ida,sp.binned_residuals; four pointing to modules that moved). Regenerated;scripts/generate_stub.py --checkand a test intests/test_type_stub.pynow compare the stub's declarations withsp.__all__, so it cannot drift silently again.- ⚠️ Correctness:
sp.wild_cluster_boot(cluster='firm', bootcluster='st')with bootstrap clusters that cut across the one-way error clusters drew the weights perst(2^8 = 256 draws on the probe data, p = 0.047) where Stataboottestdraws them per firm-by-state cell (p = 0.000): itsbootcluster(st)andbootcluster(st firm)give the same p-value. The weights are now drawn for the intersections, with a warning; a bootstrap clustering nested in one of the error clusterings is used as given (the multi-way case was already right), and one nested in none of them that is coarser, where boottest fails with a Mata error, is refused (MethodIncompatibility). Nine enumerated (2^9) p-values match boottest exactly (tests/reference_parity/test_wcb_bootcluster_parity.py).
Evidence notes (no numeric change)¶
sp.panel_match: the standard errors (analytic and bootstrap) grow when a period shock common to every unit is added, while the estimate does not change. R PanelMatch 3.1.5 gives the same analytic SEs on the same data to 1e-12 and its bootstrap SE moves the same way, so this is the reference's behaviour, reproduced and now documented (docstring,docs/dev/modules/did.md, a pinned test). Not changed pending the owner's decision on the clean-room rewrite.
Learners and input checks in the treatment-effect estimators; sp.xttobit / sp.xtintreg fixes¶
No existing number changes.
- Fixed:
sp.aipw(ps_learner=),sp.ipw(ps_learner=),sp.ltmle(g_learner=)raisedIndexErrorandsp.lmtp(ratio_learner=, outcome_learner=)raised scikit-learn's cloneTypeErrorwhen the learner wassp.SuperLearner, whosepredict_probareturns the(n,)vector of P(Y = 1) and which had noget_params. The probability of class 1 is now read in one place (core/_learners.py): an(n,)vector as is, an(n, 2)array by itsclasses_, anything else refused with a typed error.sp.SuperLearnergainsget_params/set_params/classes_, sosklearn.base.cloneworks on it; learners withoutget_paramsare deep-copied in every estimator (lmtp used to refuse them). Test: a SuperLearner with a one-learner library equals that learner in all four estimators (tests/test_learner_types_te.py). scikit-learn'sStackingClassifier, pipelines,sp.HALClassifierandsp.RlassoClassifieralready worked. - Fixed:
sp.bart_causalwith a label-coded treatment ('ctl'/'trt') raised numpy'scould not convert string to float; it now raises the sameMethodIncompatibilityassp.aipw/sp.ipw/sp.dml/sp.tmle. Boolean treatments give the 0/1 result. - Fixed:
GroupSequentialResult.decision([nan])returned'continue'(NaN compares false with every bound) and[inf]returned'stop: efficacy'; a non-finite or non-numeric z is now refused. - Fixed:
sp.xttobit(ll=, ul=)acceptedll >= ul(Stata 18 runsll(2) ul(1)to "convergence not achieved" with a meaningless table and stopsll(2) ul(2)with r(504)); it now refuses. - Fixed:
sp.xttobit/sp.xtintregraisedIndexErroron a perfectly collinear regressor. They now omit the later member of the dependent set with Stata's note (core/_collinear.py, assp.tobit/sp.intreg) and list it inmodel_info['omitted']; estimates, log-likelihood and (for xttobit) standard errors equal Stata 18'sxttobit y x1 x2 x3/xtintreg lo hi x1 x4 x2withx3 = x1 + x2,x4 = 2*x1at 1e-6 (tests/test_xttobit_stata.py, fixture do-file extended). - Changed:
sp.xtintregis about 2.3 times faster (2.8 s to 1.2 s on the 540-row test panel; xttobit 0.4 s): each interval row evaluates only the tail form it keeps instead of both. Outputs are byte-identical to before on the Stata fixture and a second panel.
Stata translation: stteffects, didregress aggregate(), classical wyoung; sp.egen¶
Added¶
sp.from_stata/sp.statatranslatestteffects ra | wra | ipw | ipwratosp.stteffects(equations read by position;lnormal->dist='lognormal',probittreatment model, constant-only censoring model(, weibull),atet,pomeans,level()). Fifteen lines run throughsp.statareproduce Stata 18 (tests/test_stata_translation_stteffects.py).ipwra, atetis translated withatetflagged (complete=False,sp.statarefuses): Stata averages over the whole sample,sp.stteffectsover the treated.noconstant,gammaandhetprobitare refused.didregress/xtdidregress ..., aggregate(dlang | dlang, varying | standard)translate tosp.didregress(aggregate=...), withdlang_df='stata'for the Donald-Lang fits so the interval uses Stata's degrees of freedom; the ten Stata 18 reference lines run throughsp.statato 1e-10 (tests/test_stata_translation_didregress_aggregate.py).aggregate(dlang)withvce(cluster)orwildbootstrapis refused, as Stata refuses it.sp.westfall_young(robust=False)(orvce='ols'): the classical OLS t-statistic, as Stataregresswithout options; the default (HC1 / CR1) is unchanged.wyoung ..., cmd(regress OUTCOMEVAR d x)with no SE option now translates (robust=False) instead of being refused. Coefficients, t and analytic p-values equal Statawyoungto 1e-12 on a stratified and a completely randomized design; the adjusted p-values agree within Monte Carlo error of wyoung's 20,000 draws (tests/reference_parity/test_westfall_young_wyoung_parity.py; wyoung used as a black box).-
sp.egen(data, fcn, x, by=, where=, ...): Stata'segenas a public function (statistics by group,rankwithfield/track,std,seq,group/tag, row functions,anycount/anymatch,cut), returning a double-precision Series. It is the implementation theegenstep ofsp.stataalready used, moved toutils/egen.py;sp.stata's behaviour is unchanged. 42 commands match Stata 18 to 1e-12 (tests/test_egen_public.py). -
sp.statareads the panel declarationsp.xtsetstores on the data (df.attrs), as Stata'suserestores a dataset'sxtset:L.x/F.x/D.xwork without anxtsetline, and the data are sorted by panel and time asxtsetsorts them (_nis Stata's).useof a frame passed infiles=restores its declaration; a declaration naming columns the data no longer have is ignored with a warning. Lags,_nandregress y L.x D.xmatch Stata 18 on an unsorted gapped panel (tests/test_stata_xtset_attrs.py). -
Guide
docs/guides/data_layer.md(in the mkdocs nav):sp.xtset, lags / leads / differences when periods are missing, time-series and factor-variable terms in formulas and insp.stata,sp.reshape,sp.collapse,sp.egen. Every block is executed bytests/test_data_layer_guide.py, which compares the printed output with the guide.
Fixed¶
sp.rankdocstring: Stata'sfieldismethod='min', ascending=Falseandtrackismethod='min'(it said'min'->field,'dense'->track); checked in Stata 18. No numbers change.
sp.ltmle_msm: working marginal structural models¶
Added¶
sp.ltmle_msm(data, y, treatments, covariates_time, regimes, summary_measures, msm_terms=, baseline=, censoring=, msm_weights=, weights=): longitudinal TMLE of a logistic working marginal structural model over a set of static, dynamic (callable) or per-unit regimes -- Rltmle::ltmleMSMwith one final outcome node. Per-regime sequential regressions, one fluctuation per node shared by all regimes (covariates the working-model design, weightsh(d) / g), the projection fitted to the targeted means, influence-curve SEs, and the IPTW estimate alongside. Equals ltmle 1.3-0 (GPL-2, used as a black box) to 1e-8 on nine configurations (static, equal / user / empirical projection weights, a baseline term, censoring, a continuous outcome, observation weights, a dynamic regime, a duplicated regime;tests/reference_parity/test_ltmle_msm_parity.py). Pooling over several outcome nodes (final.Ynodes) andvariance.method='tmle'are not implemented.
Replicate-weight survey designs¶
Added¶
sp.svrepdesign(data, repweights=, weights=, type=)declares a design by its replicate weights (BRR, Fay, JK1, JKn, JK2, bootstrap, ACS / successive difference, other) andsp.as_svrepdesign(design, type=)builds JK1 / JKn / BRR / Fay weights from a strata / PSU design. The statistic is recomputed with every replicate's weights; the variance isscale * sum_r rscales_r (theta_r - c)^2with Rsvrepdesign'sscale/rscales/mseper type and df = rank of the replicate weights minus one. Accepted bysp.svymean/svytotal/svyglmand, throughdesign=, bysp.regress,logit,probit,ipw,aipw,tmle,ltmle,cox,did_2x2,callaway_santannaandsp.did; the otherdesign=estimators refuse a replicate design with a typed error. Equal to R survey 4.5 (svrepdesign+svymean/svytotal/svyglm/svycoxphwith Breslow ties /withReplicatesof IPW and ofdid::att_gt) on seven replicate designs, and to Stata 18svy brr/svy jackknife(tests/reference_parity/test_survey_replicate_parity.py). JKn and JK1 weights built byas_svrepdesignare R'sas.svrepdesignweights exactly; BRR / Fay use a Sylvester Hadamard matrix (fully balanced, not R's half-samples). Documented differences: Stata reports #replicates - 1 as the BRR df; Stata'ssvy bootstrapdivides by R (scale=1/Rreproduces it); on Cox fits with Efron ties R'ssvycoxphkeeps a replicate's deleted rows with a negligible weight, which its tie fractions count (SEs ~3e-4 apart; the test reproduces R's numbers that way). Statasvykeeps replicate estimates in float unlesssaving(, double)is used, which moves its e(V) by ~1e-7.
sp.xtregar: fixed- and random-effects regression with an AR(1) disturbance¶
Added¶
sp.xtregar(data, y=, x=, id=, time=, method='re'|'fe', rhotype=, rhof=, twostep=, lbi=)is Stata'sxtregaron unbalanced panels with gaps in the time index:rhofrom iterated Prais-Winsten on the demeaned data without a constant (everyrhotype:dw,regress,freg,tscorr,theil,nagar,onestep) or fixed; Baltagi and Wu's transformation (rho^dacross a gap ofdperiods); the within estimator or Baltagi and Wu's GLS; the modified Bhargava et al. Durbin-Watson and LBI statistics. Against Stata 18 MP on 29 runs (every rhotype, iterated and two-step, fixed rho, LBI, gap-free subset): coefficients, standard errors and every reported scalar agree to 1e-15 (tests/test_xtregar_stata.py). Conventions pinned along the way:theilandnagarcount the constant inkand divide by the number of consecutive pairs; theFtest ofu_i = 0uses the transformed constant in the pooled model.- Two departures, both documented: the fixed-effects
_consstandard error is divided by1 - rholike the coefficient (Stata leaves it undivided, about 2.5 times too small atrho = 0.6by simulation), and the random-effects Wald test haspdegrees of freedom (Stata labels itchi2(p + 1)).
Evidence notes (no numeric change)¶
sp.xtintreg: the 0.5% gap inse(sigma_e)against Stata is Stata's analytic Hessian under adaptive quadrature, which differs from the curvature of its own likelihood (finite differences of Stata'se(ll)give our number); withintmethod(ghermite) intpoints(100)Stata's standard errors match ours to 1e-10.sp.group_sequential: expected sample sizes match Statagsdesignto 1e-7 for efficacy-only and binding designs. For nonbinding futility Stata adds efficacy crossings computed with futility ignored to futility crossings computed with futility followed, so its weights sum to more than one (its futility-only ESS1 exceedsN_max); ours follows the futility rule. Both are now tested.
design= on sp.ltmle and sp.match¶
Added¶
sp.ltmle(..., design=sp.svydesign(...)): the design weights as observation weights and the survey linearisation of the influence curve (IC / n) over strata / PSUs / fpc, with t intervals on the design df;detailalso carries the design SEs of the two regime means. Equals Rltmle1.3-0 influence curves +survey::svyrecvaron 12 configurations (tests/reference_parity/test_ltmle_design_parity.py).sp.match(..., design=)for nearest-neighbour ATT / ATC: the design weights as MatchIts.weightsand the survey linearisation of the matched-sample regression with the matched rows as a domain of the full design -- RMatchIt4.7.2 +svyglm(y ~ d, subset(design, matched)), equal to 1e-8 on 12 configurations (tests/reference_parity/test_match_design_parity.py). Other matching methods have no influence function and are refused with a typed error pointing tosp.ipw/sp.aipw/sp.tmle(design=).
Survey designs reach sp.did; PH test for weighted Cox fits¶
Added¶
sp.did(..., design=sp.svydesign(...))forwards the design tosp.did_2x2(method='2x2'/'classic'/'twfe') andsp.callaway_santanna(method='cs', panel, analytic SEs); the numbers are those of the two estimators'design=paths (Rsurvey/didparity intests/reference_parity/test_survey_did_parity.py, now also through the dispatcher). Other methods andaggregation=are refused with a typed error (sp.aggtehas no design variance; the CS design fit already reports the simple ATT and the event study).CoxResult.ph_test()on fits withweights=and / orstart=(counting-process data): the score test of Rsurvival::cox.zph, equal to it on 14 weighted, delayed-entry, time-varying and stratified fits under both tie rules and the four time transforms (relative 1e-6;tests/reference_parity/test_cox_zph_weighted_parity.py). These fits used to raise. The time transform ignores the weights and the information is model-based whatever variance the fit reports, ascox.zphdoes (read from its outputs; survival is LGPL, used as a black box).method='approx'stays unweighted / right-censored only; fits withdesign=are refused. Weighted fits now also carrymodel_info['ph_test'], soresult.violations()can flag them.
sp.lmtp(censoring=, estimator='sdr')¶
Added¶
sp.lmtp(..., censoring=[...]): right censoring (indicator after each treatment, 1 = observed, monotone). The policy also prevents censoring; one classifier per time point stacks the natural rows still observed against the policy rows at risk, which gives the censoring-adjusted density ratio; censored units drop out of later regressions. Variables after a unit's censoring may be missing. With censoring the natural-course reference inmodel_infois the same estimator under the identity policy.sp.lmtp(..., estimator='sdr'): the sequentially doubly robust estimator (doubly robust pseudo-outcomes, unbounded predictions, the estimate is the mean of the influence function).- Both equal R
lmtp1.5.4 (lmtp_tmle/lmtp_sdr, AGPL, used as a black box) withSL.glmlearners andfolds = 1: density ratios to 1e-12, estimates and SEs to 2e-7 relative (R glm's tolerance), on point and two-period designs with and without censoring (tests/reference_parity/test_lmtp_parity.py). The uncensored TMLE path is bit-identical to before.
sp.weightit(method='energy')¶
Added¶
- Energy balancing for binary and multi-category treatments
(
sp.weightit(..., method='energy'), Huling and Mak 2024): non-negative weights minimising the energy distance between each weighted group and the full sample (ATE; withimproved=True, the default, also between pairs of groups) or the focal group (ATT / ATC), withlam=,dist=('scaled_euclidean','mahalanobis','euclidean'),min_w=and exact mean balance throughmoments=/interactions=. The quadratic programme is solved exactly by an active-set iteration on the optimality conditions (no new dependency). Weights equal R WeightIt 1.7.0 (GPL, used as a black box) to 1e-9 on 15 specifications; on a flat programme where WeightIt's OSQP stops early, the objective values agree to 1e-11 and ours is the lower (tests/reference_parity/test_weightit_energy_parity.py). Continuous treatments are refused.moments=now defaults toNone(1 forcbps/ebal, as before; 0 forenergy).
sp.ipw(ps_learner=)¶
Added¶
sp.ipw(..., ps_learner=, cross_fit=True, n_folds=5): any scikit-learn style classifier (orsp.SuperLearner(task='classification')) for the propensity score, cross-fitted by default; every bootstrap replicate refits it and keeps each copy of a unit in that unit's fold. The stacked sandwich,design=andpropensity=are refused with a learner, andcross_fit=Falsewarns. An unpenalised logistic learner reproduces the default to 1e-6 (estimate and bootstrap SE); a spline logit recovers a constant effect the linear logit misses (tests/test_ipw_ps_learner.py). Weighting alone is not doubly robust; the docstring points tosp.aipw(ps_learner=).
Honest DiD: sp.breakdown_m for every restriction; module split¶
Added¶
-
sp.breakdown_m(..., honestdid_method=, bias_direction=, monotonicity_direction=)andmethod='smoothness_relative_magnitude': the breakdown value of every restriction setsp.honest_didoffers (sign and shape restrictions,Delta^SDRM, and the test-inverted intervals under smoothness). It is the firstMat which the setsp.honest_didreports contains zero, found by a log-grid scan and bisection, because these sets are not nested inM(RHonestDiD's ownDelta^RMNBset contains zero atMbar = 0.0026, excludes it at0.0052and contains it again at0.01on the test input). RHonestDiDhas no breakdown function; withmethod = "Conditional"its sensitivity grid excludes zero at0.997 M*and contains it at1.003 M*for all ten restrictions tested (tests/reference_parity/test_honest_breakdown_R_parity.py); near-noiseless designs recover the identified-set crossing (tests/test_honest_breakdown_restrictions.py). Where the pre-period coefficients reject the restriction at smallM(empty set), it warns. The default paths (the FLCI under smoothness, C-LF under relative magnitudes) are unchanged. -
sp.xtevent_smpath(result, postwindow=None, maxorder=10, level=0.95): the "least wiggly" confound path that Stataxteventplot, smpath(line)draws afterxtevent(the lowest-degree polynomial through the normalised coefficient inside the Wald region, with the smallest leading coefficient), solved in closed form. The least Wald statistic at each degree and the critical value equal Stata 18's (to the 9 digits it prints), so the degree chosen is the same; the path is a screen against Stata's numerical optimiser (within 1e-7 on four designs, 2e-4 on a fifth, where the closed form has the smaller leading coefficient) (tests/reference_parity/test_xtevent_smpath_Stata_screen.py).
Changed¶
did/honest_did.py(1,944 lines) anddid/_arp.py(895) are split by concern into_honest_common/_honest_r/_honest_breakdown/_honest_momentsand_arp_sets; every name is still importable from its old module. Outputs are byte-identical (Track A 10 / 16 / 21 / 84 and a 375-call probe).
⚠️ Correctness¶
sp.honest_did(method='smoothness', honestdid_method='Conditional' | 'C-LF')(and the sign / shape variants) reported an empty set (NaNbounds,rejects_zero=False) when the estimate lay more than the identified set's half-width plus 20 standard deviations from zero: HonestDiD's default grid is centred at zero and accepted nothing (R returnsNAthere). The grid now moves to the estimate in that case, as the relative-magnitude family already did; every other set is unchanged.
Re-probe of the functions added or changed on 2026-10-19/20¶
Boundary probes (known-truth simulations, missing values, label columns, single clusters, polars and the MCP path) of the ~60 functions from that batch, then a re-run of the 2026-10-19 agent-native audit.
⚠️ Correctness¶
sp.dose_response(method='dr')called without models received the GPS default (gradient boosting) for the treatment mean, and its variance model was fitted on in-sample residuals, so the density collapsed: onE[Y(t)] = sin(t)the curve ran from -71 to 697. The DR curve now uses its documented linear default and cross-fitted (honest) residuals for the variance; every grid point is within 3 SEs of the truth on that design (tests/test_dose_response_dr.py). An unfitted sklearn ensemble passed as a model also crashed on truth testing (dose_response,conformal_ite).sp.ologit/sp.oprobit/sp.gologitsorted the labels of an orderedpd.Categoricaloutcome alphabetically, scrambling it; the declared order is now used (identical to the fit on integer codes).sp.lee_bounds(weights=)kept zero-weight rows in the mean-one rescaling and in N: analytic SEs weresqrt(n_positive / n)too small andn_obscounted them. They are dropped with a warning.sp.sar/sem/sdm/sac/sdem/gns(maximum likelihood) report z statistics and normal intervals, as Stataspregress, ml; they used t(n - k). Estimates and SEs are unchanged; intervals now equal Stata 18's on the same data (tests/test_spatial_ml_z_inference.py).sp.augsynth(progfunc='none', scm=False)returned the average of uniform donor weights (6.878 on the probe panel); Raugsynth(progfunc = "None", scm = FALSE)returns the SCM estimate (3.334), as the docstring says. Without the ridge augmentation the fit is now SCM whateverscm=says.sp.sun_abraham/sp.did_imputationreportedn_obs = len(data)after dropping rows with a missing outcome;n_obsnow counts the rows used.sp.l1_imbalance(weights=)set its bins after dropping zero-weight rows, so an after-matching L1 used other cells than the before-matching one (0.150 instead of 0.104 after k2k). Bins come from every complete row.sp.msprtwith the plug-in variance rejected 9.5% of null experiments at a nominal 5% (the first looks used 2 observations per arm). The defaultmin_per_armis now 10 with the plug-in variance (2.5-3.0% in simulation) and 1 withsigma=.sp.spilloverdropped rows with a missing outcome before computing peer exposure, so their treatment vanished from their cluster-mates' exposure. Exposure now uses every unit; such rows leave only the contrasts (with a warning), and a missing treatment or cluster is refused.
Fixed¶
sp.margins/sp.margins_at(vcov=False)ignored the flag after LDV models (intreg, ologit, gologit, heckprobit).- Typed errors instead of raw numpy / pandas ones, or silent acceptance:
heckprobitwith an outcome constant on the selected rows (used to report convergence with SE 3e30); panellocal_projectionsandxtivreg/iv_wild_bootstrapwith one cluster (SE ~1e-16 or ZeroDivisionError);iv_wild_bootstrapwithdatain another row order than the fit (silently mismatched clusters);xtpcsewith collinear regressors or no consecutive periods forar1; panelgranger_causalitywith a series constant within a unit;wild_cluster_boot(bootcluster=)naming a missing column; spatial ML formulas withC()/ expressions or missing values;causal_impactwith missing values, duplicated periods, collinear covariates or an incomparableintervention_time;bacon_decomposition(covariates=)with a categorical, time-invariant or duplicated covariate (was misdiagnosed as "treatment collinear"); label-valued treatments inmediate,four_way_decomposition,lee_bounds,mediate_paths,stteffects,l1_imbalance; negative durations and event codes other than 0/1 inkaplan_meier/cox/rmst/logrank_test;search_functions(category=)with an unknown category (returned []). - Rows dropped for a missing weight are now announced (
ebalance,match(sampling_weights=),finegray,cox,kaplan_meier,rmst). sp.binscatter(method='binsreg', ci=True)crashed when a bias-corrected interval excluded its dot; intervals are drawn as segments.sp.coefplot(drop='Intercept')(a string) was read letter by letter.sp.idaaccepts a CPDAG sent asDataFrame.to_dict()(the MCP form).match(method='cem', k2k=True): pandas FutureWarning on the cell column.- MCP:
call_functionfolds arguments sent next tofunctioninstead of underarguments; aresult_idcall to a function withoutdata=(slopes, hypotheses, predictions ...) no longer fails when the request also carriesdata_path.
Agent discovery¶
search_functions: "entropy balancing" rankssp.ebalancefirst, the Chinese term for weak instruments reaches the weak-IV diagnostics, andsynthdid(the R package name) resolves tosp.sdid.- Card examples: the 30 functions added in the batch and 31 that gained
parameters now carry examples that build their own data and run as
written (
tests/test_card_examples.pyruns all of them); before, every one failed withNameError.
sp.mediate(design=)¶
Added¶
sp.mediate(..., design=sp.svydesign(...), inference='robust'): the design's weights and the design-based (linearisation) variance of the stacked estimating equations, through the sharedsurvey.linearization.design_vcov, withtintervals on the design degrees of freedom. Statasvydoes not supportmediate; with PSUs and no strata the SEs equal Stata 18mediate [pw=w], vce(cluster psu)timessqrt(G / (G - 1)), the survey factor Stata'smediateomits (tests/reference_parity/test_mediate_design_Stata_parity.py).sp.lee_boundshas nodesign=(its bounds have no influence function here); useweights=with the bootstrap.
Spatial: random-effects panel, SDEM and GNS¶
Added¶
sp.spatial_panel(..., effects='re'): random unit effects by full maximum likelihood (with a constant), formodel='sar' | 'sem' | 'sdm'and the newmodel='sac'(spatial lag and spatial error, random effects only) -- the model of Stataspxtregress, re. Estimates,sigma_u/sigma_e(result.variance_components) and OIM standard errors equal Stata 18 to 1e-6 on all four specifications (tests/reference_parity/test_spatial_panel_re_Stata_parity.py).sp.sdem(W, data, formula)(spatial Durbin error) andsp.gns(...)(general nesting: lags ofy,Xand the error) by maximum likelihood with OIM standard errors; equal Stata 18spregress, mlto 1e-6 on a symmetric and an asymmetric weights matrix (tests/reference_parity/test_spatial_durbin_error_Stata_parity.py).
Instruments from a DAG; sp.ida¶
Added¶
sp.dag(...).instrumental_variables(exposure, outcome, ancestral=True)lists the instruments a causal DAG implies, each with the smallest conditioning set: the ancestral instruments Rdagitty::instrumentalVariableslists (equal instrument sets on 200 random DAGs with latent confounders; both packages' conditioning sets checked against the definition by d-separation), or withancestral=Falsealso those that need a collider in the conditioning set (tests/reference_parity/test_dag_instruments_dagitty_parity.py).sp.ida(data, cpdag, x, y, method='local' | 'global'): IDA, the possible causal effects ofxonyacross the DAGs of a CPDAG (sp.pc_algorithm/sp.gesoutput), linear-Gaussian. The multisets of effects equal Rpcalg::idato 1e-10 on 40 random graphs, both methods (tests/reference_parity/test_ida_pcalg_parity.py). dagitty and pcalg are GPL and were used as black boxes.
sp.mediate(weights=)¶
Added¶
sp.mediate(..., weights='w'): sampling weights in both models (weighted least squares / weighted likelihood) and in the potential-outcome means, with the weighted sandwich forinference='robust'and a weight-carrying case bootstrap for'bootstrap';'delta'refuses. Every outcome / mediator pair the potential-outcome estimator supports takes weights. Effects and SEs equal Stata 18mediate [pw=w]to 1e-9 on linear / logit / probit model pairs (tests/reference_parity/test_mediate_weights_Stata_parity.py).
sp.lee_bounds(weights=)¶
Added¶
sp.lee_bounds(..., weights='w'): sampling weights in the retention rates, the trimming quantile (Stata_pctilewith probability weights), the mass kept at a tied threshold, the means and the analytic variance, as Stataleebounds [pw=w]computes them. Needstrimming='leebounds'(which also stores the weights andy^2in single precision, asleeboundsdoes) or'exact'; refused withcovariates=. On four settings the bounds, the trimming share and thecieffectinterval's inputs equal Stata 18 to 1e-12 and the SEs to 1e-8 (tests/reference_parity/test_lee_bounds_weights_Stata_parity.py). One documented difference: without a tie at the threshold,leeboundsleaves the weights out of one variance term of its upper trimmed mean; we weight it, and the test rebuilds Stata's SE from the unweighted term.model_info['threshold_tie']says which side took the tied branch.sp.from_stata('leebounds y d [pw=w], ...')now translates.
sp.gologit: generalized ordered logit / probit (partial proportional odds)¶
Added¶
sp.gologit(data, y, x, pl=, npl=, link='logit' | 'probit', vce=), the model of Stata's user-writtengologit2:Pr(Y > c_j) = F(a_j + x'b_j)with free or parallel-lines coefficients per variable, gologit2's coefficient layout ('<category>:<variable>'), a warning when the unconstrained model predicts negative probabilities, andsp.marginsper outcome. Against gologit2 3.2.8 (used as a black box) coefficients, standard errors and margins agree to 1e-6, gologit2's own convergence tolerance (tests/test_gologit_stata.py);pl=Truereproducessp.ologit(a_j = -cut_j).
sp.xttobit / sp.xtintreg: random-effects tobit and interval regression¶
Added¶
sp.xttobit(data, y, x, id, ll=, ul=)andsp.xtintreg(data, y_lower, y_upper, x, id), Stata'sxttobit/xtintreg: normal panel effect integrated out by adaptive Gauss-Hermite quadrature (30 nodes by default, centred at each panel's mode),sigma_u,sigma_e,rhoand the likelihood-ratio test against the pooled model. Against Stata 18 on 120 unbalanced panels:xttobitcoefficients, standard errors,sigma_u,sigma_e, log-likelihood and LR statistic to 1e-7;xtintregestimates and log-likelihood to 1e-7, standard errors within 0.5% (ours equal an independent finite-difference Hessian of the likelihood to 1e-7; the remaining gap is unexplained and recorded as an open item indocs/dev/modules/panel_multilevel.md) (tests/test_xttobit_stata.py).
sp.margins after fepois / feglm refuses standard errors that ignore the fixed effects¶
Changed¶
sp.margins/sp.margins_at/sp.contrast/sp.pwcompareafter a fit with absorbed fixed effects reported delta-method standard errors holding the estimated effects fixed whenever the quantity depends on them (any margin aftersp.fepois/sp.feglm, predictive margins aftersp.feols). Ontests/fixtures/margins_fe/fep.csvthat gave 0.121 for the AME afterfepoiswith clustered errors, where Stata reports 0.106 after bothpoisson y x i.g, vce(cluster g)andppmlhdfe ..., d vce(cluster g). They now raiseMethodIncompatibility, as R marginaleffects does on a fixest model;vcov=False(new onmargins/margins_at) returns the estimates without the standard-error columns. Slopes and contrasts of linear models keep their standard errors. Fitting the effects asC(g)terms reproduces Stata's number (tests/test_margins_fe_refusal.py).
Weighting for multi-category and continuous treatments: sp.weightit, sp.bal_tab¶
Added¶
sp.weightit(data, treat, covariates, method=, estimand=): balancing weights for binary, multi-category and continuous treatments in one call (the RWeightItworkflow).method='glm'inverts a (multinomial) logit propensity score, or for a continuous treatment takes the normal density ratio;'cbps'is the just-identified covariate balancing propensity score for categorical treatments;'ebal'is entropy balancing (each arm to the full-sample or focal means; for a dose, zero weighted treatment-covariate covariance withmoments=/interactions=/d_moments=). Estimands ATE, ATT (withfocal=), ATC, ATO, ATM where defined. Weights equal WeightIt 1.7.0 to 1e-9 in 23 configurations; the multinomialglmfits to 2e-7 because WeightIt's optimizer stops at a score of 1e-6 (ours is the MLE to 1e-11). WeightIt's continuous CBPS (a GMM criterion that does not reach exact balance) is not offered.sp.bal_tab(data, treat, covariates, weights=): cobalt-style balance for the same three treatment types: standardized mean differences on unweighted standard deviations, raw differences for binary covariates, variance ratios, KS statistics, pairwise (or each-versus-all) comparisons for multi-category treatments with the maximum as the summary, Pearson and Spearman treatment-covariate correlations for a continuous treatment, and effective sample sizes. Equals cobalt 4.6.2 to 1e-9 on eleven settings.tests/reference_parity/test_weightit_cobalt_parity.py.- WeightIt and cobalt (GPL) were used as black boxes: their documentation and outputs only.
sp.ebalance(weights=): sampling (survey) weights, WeightIt'ss.weights. They are kept separate frombase_weights=(the entropy reference): the balancing targets become the sampling-weighted moments of the target group, the reweighted arm is balanced in the sampling-weighted sense and both outcome means carry the weights. The M-estimation variance weights every estimating function by them. Estimate and standard error equalWeightIt::lm_weightit(vcov = "asympt")for ATT / ATE / ATC with and without base weights (12 settings; estimate 2e-9, SE 1e-7).tests/reference_parity/test_ebalance_sweights_parity.py.sp.match(sampling_weights=): survey weights in nearest-neighbour matching, MatchIt'ss.weights: the propensity score is the logit weighted by them, matching is unchanged, and the effect is the weighted difference in means of the matched sample with weightsmatching weight * sampling weight; standard error clustered on matched sets without replacement and HC3 with replacement (the recipe of MatchIt's vignettes). Score, matched pairs, estimate and SE equal MatchIt 4.7.2 + sandwich in four settings (score 5e-9: R glm's tolerance; estimate and SE 1e-9).tests/reference_parity/test_match_sweights_parity.py.weights=stays the frequency weight it was. k:1 matching without replacement can pick different sets from MatchIt when controls run short (MatchIt assigns the k-th controls in a later round); that is a property of the matching, unchanged here.sp.ltmle(weights=): observation (sampling) weights, R ltmle'sobservation.weights, in every regression, the fluctuations, the plug-in mean and the influence curve. Equals ltmle 1.3-0 to 1e-10 with and without censoring, binary and continuous outcomes.tests/reference_parity/test_ltmle_weights_parity.py.- MatchIt, sandwich and ltmle (GPL) were used as black boxes.
Two-way cluster DML: sp.dml(cluster=[c1, c2])¶
Added¶
sp.dml(cluster=[c1, c2]): two-way cluster-robust DML (Chiang, Kato, Ma and Sasaki 2022, as DoubleML implements it) for PLR, IRM, PLIV and IIVM. Each cluster dimension is split inton_foldsfolds; then_folds**2cells test units in foldkof one dimension andlof the other and train on units sharing no cluster with them; the estimate solves the cell-weighted score and the variance sums the two dimensions' within-cell cluster scores.fold_indicestakes ann x 2array of per-dimension folds. Estimate and SE equal Python DoubleML 0.11.3 to 1e-15 given the same folds for all four models.tests/reference_parity/test_dml_twoway_cluster_doubleml_parity.py. One-waycluster=is unchanged.
Bayesian causal forest: sp.bart_causal¶
Added¶
sp.bart_causal(data, y, treat, covariates, method='bcf' | 'bart'): posterior-sampling Bayesian tree models for a binary treatment on thesp.bartengine.'bcf'is the Bayesian causal forest of Hahn, Murray and Carvalho (2020): a prognostic forest with the propensity score, a separate effect forest with a stronger tree prior, half-Cauchy and half-normal scale priors.'bart'is one BART response surface with effects as prediction differences (bartCause's default response model). Posterior of the ATE / ATT / ATC and of every individual effect, several chains with split R-hat. Evidence: known truth (tests/test_bart_causal.py) and a seed screen against R bcf 2.0.2 and bartCause 1.0.10, eight seeds a side (tier S, not parity): BCF ATE posterior means 0.06 posterior SDs apart, SDs within 1.2%; BART 0.02 posterior SDs apart, SDs within 0.6%, with the prior ofdbarts::bart2that bartCause calls (75 trees, cut points equally spaced over each covariate's range: themethod='bart'defaults,n_trees_control=Noneand the newcut_grid='auto'; 200 trees on quantile cut points left a 0.21 posterior-SD gap).tests/reference_parity/test_bart_causal_seed_screen.py. bcf, bartCause and dbarts (GPL) were used as black boxes.- The
sp.bartbackfitting kernel takes per-observation precision weights;sp.bartpasses ones and its draws are unchanged bit for bit. sp.bcfis unchanged (the ensemble approximation); its warning now points tosp.bart_causal(method='bcf').
CEM k2k and the L1 imbalance¶
Added¶
sp.match(method='cem', k2k=True): 1:1 matching without replacement on the Mahalanobis distance inside each coarsened cell, in data order (R MatchIt'smethod = "cem", k2k = TRUE); unmatched units drop out. Pairs and ATT equal MatchIt 4.7.2 given the same cells.tests/reference_parity/test_cem_k2k_parity.py.sp.l1_imbalance(data, treat, covariates, weights=): the multivariate L1 statistic of Iacus, King and Porro (2011) on a coarsening of the covariates (equal-width bins, Sturges by default), weighted or not; identities (0 for identical arms, 1 for disjoint cells, a hand computation) are tested. R cem'simbalance()coarsens differently by default, so its numbers are comparable only under the same coarsening.
CLAN in sp.gate_test¶
Added¶
sp.gate_test(..., clan=[cols]): classification analysis of the generic-ML framework, the mean of each column in every CATE group and the most-minus-least difference with its Welch standard error (clan_table). Equals RGenericML::CLAN0.2.3 (GPL, black box) given the same groups to 1e-12.tests/reference_parity/test_clan_genericml_parity.py.
Machine-learning nuisances in sp.aipw¶
Added¶
sp.aipw(ps_learner=, outcome_learner=): any scikit-learn style classifier / regressor (includingsp.SuperLearner) for the propensity score and the per-arm outcome regressions, cross-fitted, with the influence-function standard error (se_method='sandwich'is refused;cross_fit=Falsewarns). Logistic / linear learners reproduce the default fit to 1e-6; gradient boosting recovers an effect the linear models miss on a nonlinear design (tests/test_aipw_learners.py).
Modified treatment policies and learners in longitudinal TMLE¶
Added¶
sp.lmtp(data, y, treatments, shift=, covariates_time=, baseline=): the mean outcome under a modified treatment policy (A + delta, or any function of the natural treatment value) by sequential TMLE, with density ratios from the stacked-data classifier and the effect against the natural course (model_info['effect'], Rlmtp_contrast). Equals Rlmtp::lmtp_tmle1.5.4 (SL.glm learners,folds = 1) to 3e-7 for point and two-period designs with continuous and binary outcomes; density ratios to 1e-9. Conventions found on outputs: ratios trimmed at the 0.999 quantile of the whole ratio matrix, a linear regression for every pseudo-outcome except a binary final outcome.tests/reference_parity/test_lmtp_parity.py. lmtp (AGPL) was used as a black box. Censoring and the SDR estimator are not implemented.sp.ltmle(q_learner=, g_learner=): any regressor / classifier (e.g.sp.SuperLearner) for the outcome and treatment / censoring models in place of the main-terms logistic fits. A logisticg_learnerreproduces the default to 1e-8; with gradient boosting the estimate recovers a nonlinear truth that the default misses (tests/test_ltmle_learners.py).ltmleMSMis not implemented.
Doubly robust dose-response curve: sp.dose_response(method='dr')¶
Added¶
sp.dose_response(method='dr'): the doubly robust curve of Kennedy, Ma, McHugh and Small (2017), a local linear regression of the pseudo-outcome(Y - mu(X, T)) / pi(T | X) * mean_j pi(T | X_j) + mean_j mu(X_j, T)on the treatment, with cross-fitted nuisances (n_folds), a normal treatment density (mean and variance regressions), the bandwidth that minimises the smoother's leave-one-out risk (orbandwidth=) and robust local-linear bands on the pseudo-outcomes. Evidence: the curve covers the smoothed truth within its bands when either the outcome model or the treatment density is right (tests/test_dose_response_dr.py), an infinite-bandwidth identity, and a shape screen against Rnpcausal::ctseff(GPL, black box; interior correlation 0.999, not parity: its nuisances, density and smoother differ). The defaultmethod='gps'is unchanged.
Multi-action policy trees: sp.policy_tree on three or more arms¶
Added¶
sp.policy_treegrows a multi-action tree when the treatment has more than two levels (or two levels not coded 0/1), or whenscores=is ann x Areward matrix: each leaf assigns one level, the depth <= 2 search is exact on the reward matrix (policytree::policy_tree(X, Gamma)), and the internal rewards are cross-fitted AIPW scores of every arm. Given the reward matrix, every row's assigned action equals R policytree 1.2.4 in five settings (three and four actions, depth 1 and 2, minimum leaf sizes 1 / 10 / 20, tied covariates).tests/reference_parity/test_policytree_multi_action_parity.py. Before this a three-arm treatment raised a plainValueError.split_step > 1thins the grid by distinct value, not by sample as policytree'ssplit.stepdoes; the docstring no longer calls them the same.
sp.mediate_paths: path-specific effects with ordered mediators¶
Added¶
sp.mediate_paths(data, y=, treat=, mediators=['m1', 'm2', ...], covariates=, interaction=True, outcome_model='linear' | 'logistic', ps=False)splits the total effect of a binary treatment into a direct effect and one effect through each of several causally ordered mediators (a mediator may affect later ones; a block of columns can form one mediator). Both decompositions (Type I and Type II), the pure imputation estimator and, withps=True, the imputation-based weighting estimator of Zhou and Yamamoto [@zhou2023tracing], with a nonparametric bootstrap. Point estimates equal Rpaths0.1.2 (GPL, used as a black box) to 1e-12 with linear outcome models and to Rglm's convergence with logistic ones, on five settings (tests/reference_parity/test_mediate_paths_parity.py); a linear structural model checks the path effects against products of coefficients. Reference verified via Crossref and doi.org.
Stata factor-variable and time-series terms in formulas¶
Added¶
- Formulas read Stata's factor-variable terms:
sp.regress("y ~ i.g##c.x", df)isregress y i.g##c.x(alsoi.g#c.x,ib2.g,c.x##c.x,1.post,i.a##i.b,c.(x z)#i.g), through the same translation assp.from_stata; coefficients equal Stata 18. Before,#failed with patsy's "comments are not allowed". - After
sp.xtset(df, id=, time=),L.x,L2.x,F.x,D.xandL(1/3).xin a formula are Stata's gap-aware operators (columnsx_L1,x_D1...) for every estimator that builds its design from the data (sp.regress,sp.logit,sp.glm...); the other parsers (sp.feols, IV, panel) refuse them with thesp.lagspelling. Terms that mix Stata notation into formula operators (i.g*x) are refused with the right syntax.
Fixed¶
- The translation reached the fixed-effects part of
sp.feols/sp.fepois/sp.feglmformulas and turned reghdfe's absorbed slopey ~ d | county + i.pref#c.yearinto aSyntaxError. The part after|is now left to the fixest layer (varying slope, as before), and the varying-slope route reads regressor-sidei.g#c.xwith its own Stata parser, soy ~ d + i.prov#c.x | county + i.pref#c.yearequals the fit with the slopes written as columns.
⚠️ Correctness: sp.panel(vce='wild') with clusters that cut across entities¶
⚠️ Correctness¶
sp.panel(method='fe', vce='wild', cluster=c)bootstrapped the entity-demeaned design, which is valid only when every entity lies in one cluster. With clusters that cut across entities it now re-absorbs the entity effects in each draw (the enginesp.hdfe_ols(wild=True)uses, fixed in bc97a0fd forsp.wild_cluster_boot). p-values equal Stataboottestafterareg ..., absorb(id) vce(cluster c)exactly (all 4096 Rademacher draws enumerated), the confidence-set bounds to 0.3% of the width (tests/test_panel_wild_boottest.py). Before, an 8-cluster version of the same design gave p = 0.0625 / 0.117 against boottest's 0.078 / 0.180. Point estimates and CR1 standard errors are unchanged; so is the default (clustering on the entity).
weights= where it was missing¶
Added¶
sp.finegray(weights=): sampling weights in the Fine-Gray model. The censoring Kaplan-Meier, the risk sets and the event counts are weighted, and the robust variance uses the weighted score residuals including the censoring term. Withsmall_sample=True, coefficients, covariance and log pseudo-likelihood equal Stata 18stcrregafterstset ... [pw=](both causes, two weight vectors;tests/reference_parity/ test_finegray_weighted_stata_parity.py, Stata used as a black box).vce='model'is refused with weights.sp.gformula_mc(weights=): weighted confounder and outcome models and baseline draws proportional to the weight. On an informative sample whose unweighted g-formula is 0.76 too high, the weighted one recovers the population mean within one bootstrap SE (known-truth test).sp.causal_impact(weights=)andsp.sar(weights=)raiseMethodIncompatibilitywith the reason instead ofTypeError: one time series has no sample of units to weight, and the spatial-lag likelihood is a joint one over all areas.
⚠️ Correctness: sp.mi_estimate complete-data df; binscatter(method='binsreg'); labels in sp.sumstats¶
Fixed¶
- ⚠️
sp.mi_estimatetook the complete-data degrees of freedom of the Barnard-Rubin small-sample df fromdf_resid(n - k) whatever the fit. It now takes the df of the fit's own t reference, as Statami estimatereadse(df_r):G - 1after a clusteredsp.regress(before:n - k, so the df were 140 / 77 / 23 where Stata reports 8.1 / 5.7 / 9.3 on the test data, and p-values and intervals were too narrow), and Rubin's large-sample df aftersp.logit/sp.poissonand other z-based fits (before: small-sample df withn - k). Point estimates and the total variance are unchanged;df,pvalues, the intervals andfmi_barnard_rubinchange for those fits. Matches Stata 18mi estimateforregress,regress, vce(cluster)andlogit(tests/test_mi_estimate_dfcom.py).
Added¶
sp.binscatter(..., method='binsreg'): the binscatter ofcattaneo2024binscatteras Rbinsreg2.2 computes it. Covariates enter the regression with the bin indicators and each dot is the bin's coefficient at the mean of the covariates (not residualized bin means); quantile bins (type-2 quantiles, intervals closed on the right);n_bins=Noneselects the IMSE-optimal number by the direct plug-in rule started from the rule of thumb;ci=Truegives robust bias-corrected intervals from a continuous piecewise-linear fit;cb=Truethe uniform band with a simulated sup-t critical value (nsims=,simsgrid=,seed=). Selected number of bins equal to Rbinsregselecton 40 designs, dots and intervals to 1e-10; the band's critical value is simulated in both (screened, not certified). Rbinsreg(GPL-3) was used as a black box: outputs only. The defaultmethod='classic'is unchanged.
Changed¶
sp.sumstatsshows the variable labels stored on the data (sp.label_var, a labelled.dta) and the value labels ofbyas panel headers;use_labels=Falseshows the column names.labels=andby_labels=still override them.
sp.feglm: fixed-effects negative binomial and bias-corrected logit / probit¶
Added¶
sp.feglm(family='negbin'): NB2 regression with high-dimensional fixed effects estimated as parameters, the model Rfixest::fenegbinfits. The dispersionthetais estimated jointly (IRLS for the slopes and effects alternating with Newton steps onlog theta); it and its standard error are inmodel_info['theta']/['theta_se']. Variance: inverse observed information of the slopes andthetawith the effects profiled out ('iid'), sandwich ('hetero'), one- and two-way clusters, with fixest's small-sample factors. Groups with only zero outcomes and singletons are dropped, as fixest'sperfect_fit. Coefficients,thetaand log likelihood equal fixest 0.14 to 1e-8 andMASS::glm.nbwith the effects as dummies to 1e-9; standard errors equal fixest to 1e-6 in nine settings, including clusters nested in the effects (where fixest countsthetainK); weighted fits equalglm.nb(weights=)(fenegbin takes no weights).tests/reference_parity/test_fenegbin_parity.py.sp.feglm(family='logit' | 'probit', bias_correction=True): the analytical incidental-parameter bias correction for unit (and period) effects with strictly exogenous regressors (Fernandez-Val and Weidner 2016; Ralpaca::biasCorr(L = 0)), written from the published formulas. The variance is evaluated at the corrected slopes with the effects re-estimated there, without small-sample factors, as alpaca reports it ('iid','hetero', one-way clusters). Slopes, uncorrected slopes and standard errors equal alpaca 0.3.5 to 1e-7 for logit and probit with one and two effects.model_info['coef_uncorrected']/['bias_shift']keep the uncorrected fit.tests/reference_parity/test_feglm_biascorr_parity.py.- R fixest, MASS and alpaca (GPL) were used as black boxes: their outputs and documentation only.
⚠️ Correctness: unconverged fixed-effect projections are finished exactly; faster clustering and CSV reading¶
Fixed¶
sp.feols(native engine) andsp.hdfe_olsignored the convergence flag of the alternating projections. On poorly connected fixed-effect graphs (a chain of firms linked by a few movers, with groups of one) they stopped at the sweep limit and reported the slope that far: 0.51932 against the dummy-variable regression's 0.51983 on an 800-firm chain, with no warning (hdfe_olssetconverged=Falseand said nothing). The columns the projections leave unconverged are now residualised by a sparse direct solve of the normal equations (one effect eliminated, the Schur complement factorised;fast/_fe_direct.py), so the fit equals Rlm(y ~ x + factor(id) + factor(firm))to 1e-12;model_info['fe_solver']/absorber.fe_solversay which solver finished. If the direct solve fails too,NumericalInstabilityis raised with a remedy. pyfixest's "Demeaning failed after N iterations" on the delegated path is now aNumericalInstabilitywith a remedy. Fits whose projections converge are unchanged.tests/test_fe_direct_fallback.py.
Changed¶
sp.regress(cluster=[a, b])(and every caller ofinference.jackknife.two_way_vcov_ols) built each cluster's score with a row mask per cluster, O(n G): 10 s for 100k rows and 5,000 clusters, and minutes at a million rows. The meat is now onebincountpass per column (1.2 s at a million rows).core._numba_kernels.cluster_meat(one-waysp.regress) no longer sorts the rows by cluster before summing: labels are factorised and summed in one pass, 0.03 s against 0.29 s on two million rows and five columns, independent of row order and thread count; the Rust sorted-block kernel is no longer on this path because the sort it needs cost more than the sum. Results agree with the previous ones to rounding: 1e-15 on well-conditioned designs; on Track A's14_ols_cluster(ayearregressor in the thousands) the standard errors moved by up to 1.1e-9 relative, two of three closer to R's, and the committed14_ols_cluster_py.jsonwas regenerated (logged indocs/dev/jss_review_changes.md).sp.read_datareads a CSV / TSV file over 4 MB with pyarrow when it is installed and the call passes no reader keywords beyondsep/usecols: about 4x faster (0.5 s against 2.1 s on two million rows), same columns and types (checked against pandas on the first 2,000 rows, pandas taking over on any difference or parse error), dates left as text, missing text cellsNaN. Decimals are now rounded to the nearest double; pandas' default converter was one unit in the last place off for about a third of the values of arepr-written file.tests/test_read_data_csv_fast.py.
Stata data layer: sp.xtset, sp.lag / lead / diff, sp.reshape, sp.collapse¶
Added¶
sp.xtset(df, id=, time=, delta=)declares the panel and time variables (stored indf.attrs) and returns Stata's description: strongly / weakly balanced or unbalanced, gaps, first and last period (r(balanced),r(gaps),r(tmin),r(tmax)of Stata 18 on the same rows).sp.lag/sp.lead/sp.diffare Stata'sL./F./D.: read from the time variable within panel, missing where a period is absent, for any row order;delta=and pandasPeriodtime columns are supported. Checked against Stata 18 on an unsorted, unbalanced panel with gaps.sp.reshape(df, 'wide' | 'long', stubs=, id=, j=, string=)andsp.collapse(df, "(mean) x (sum) t=y", by=, weights=, weight_type=, cw=)follow Stata's row and column order, statistics, weight definitions and refusals (Stata 18 reference numbers intests/test_data_layer.py).sp.stata(..., return_data=True)returnsStataRun(output, data), the dataset as the commands left it; data still preserved at the end are restored first, as at the end of a Stata do-file.
Changed¶
- The
reshape,collapseand time-series-operator code ofsp.statanow lives instatspai.utils(reshape.py,collapse.py,tsops.py); the translator imports it, so there is one implementation.sp.stataoutput is unchanged.
sp.xtpcse: panel-corrected standard errors (Beck-Katz)¶
Added¶
sp.xtpcse(data, y, x, id, time, correlation='independent' | 'ar1' | 'psar1', rhotype=, np1=, hetonly=, independent=, pairwise=, nmk=), Stata'sxtpcse: OLS or Prais-Winsten coefficients with standard errors for disturbances heteroskedastic and contemporaneously correlated across panels, casewise or pairwise covariance on unbalanced panels. All 23 option combinations tested match Stata 18 to 1e-12 on a balanced and an unbalanced panel with gaps (tests/test_xtpcse_stata.py). On unbalanced panels the common AR(1) coefficient weights each panel by its number of consecutive pairs (one more withnp1), which is what Stata computes.
sp.four_way_decomposition: logistic / Poisson outcomes, logistic mediators¶
Added¶
sp.four_way_decomposition(..., yreg='linear' | 'logistic' | 'poisson', mreg='linear' | 'logistic', covariate_values={...}): the four-way decomposition for binary and count outcomes and binary mediators, on the excess-relative-risk scale for log-linear outcomes (detail['total_rr']is the ratio). Standard errors now cover the proportions too (se['prop_cde']...se['prop_eliminated'],med4way'sp_*,op_m,op_ati,op_e), andproportionsgainsmediated,interactionandeliminated. Estimates and SEs equal Statamed4way2.3.2 to 1e-7 on eight settings (vce='ml'), and thevce='ols'estimates equal R CMAverse 0.1.0 to 1e-8 (tests/reference_parity/test_four_way_nonlinear_parity.py; both references GPL, used as black boxes). A Poisson-outcome case is checked against simulated potential outcomes.sp.from_stata/sp.statatranslatemed4way y a m [c...], yreg() mreg() a0() a1() m() [c()]for those models.
Changed¶
- When the total effect is zero up to rounding, the proportions are NaN with a warning (they were reported as 0).
sp.xtivreg: panel IV (random-effects G2SLS / EC2SLS, within, between)¶
Added¶
sp.xtivreg(data, y, x, endog, instruments, id, method='re' | 'fe' | 'be', ec2sls=, nosa=, vce=), Stata'sxtivreg: feasible-GLS 2SLS with Swamy-Arora (default) or Baltagi-Chang (nosa=True) variance components and G2SLS (default) or EC2SLS (ec2sls=True) instruments, within 2SLS with the(N - K) / (N - n - K + 1)correction, between 2SLS; conventional or cluster-robust variance (vce='robust'clusters on the panel, as Stata). Coefficients, standard errors,sigma_u,sigma_e,rhoand the three R-squared match Stata 18 to 1e-12 on an unbalanced panel for all eight variants (tests/test_xtivreg_stata.py). Three conventions were pinned against Stata's output where its manual is ambiguous or says otherwise (unweighted between regression,Kin the within variance, the EC2SLS constant); seedocs/dev/modules/panel_multilevel.md.
⚠️ Correctness: sp.lee_bounds estimation sample; leebounds translated¶
Fixed¶
sp.lee_boundsnow drops the rows Stataleeboundsdrops: a missing treatment or selection indicator, or a selected unit with a missing outcome (with a warning naming the counts). Before, a selected row with a missing outcome made both bounds NaN, a missing selection indicator raised an unrelated error, and a missing treatment entered the analytic standard errors (about 1% too small on the test data). Indicators coded other than 0/1 now raiseMethodIncompatibilityinstead ofIndexError. Bounds, analytic SEs and thecieffectinterval equal Stata 18leeboundsv1.5 to 1e-8 on 21 settings (tests/reference_parity/test_lee_bounds_missing_Stata_parity.py).- The audit's lower-bound mismatch (0.1358 against 0.1304, upper matching)
did not reproduce as an
spbug: Stata'simport delimitedstores the outcome as float32 by default, and withleebounds' 16-digit threshold macro that rounding moves the lower bound by up to 0.01. On the same bytes (asdouble, or float32 on both sides)trimming='leebounds'matches to 1e-15; the float32 case is the committed fixture oftests/reference_parity/test_lee_bounds_leebounds_Stata_parity.py.
Added¶
sp.lee_bounds(selection=None): without a selection column a unit is selected when its outcome is observed (leeboundswithoutselect()).sp.from_stata/sp.statatranslateleebounds y d [, select() tight() cieffect vce(analytic | bootstrap, reps()) level()]tosp.lee_bounds(trimming='leebounds', ...)(it was refused).cieffectsearches the Imbens-Manski critical value on a 0.001 grid andsp.lee_boundssolves for it, so that interval can differ by up to 0.001 standard errors. Withtight(),leeboundskeeps rows whose covariate is missing in the untrimmed arm's mean;sp.lee_boundsdrops them and says so.
Local projections with instruments, regimes and panels; proxy SVAR¶
Added¶
sp.local_projections(..., instruments=[...])fits each horizon by two-stage least squares with the shock endogenous (lpirfs::lp_lin_iv,use_twosls=TRUE), reporting the first-stage F by horizon and warning below 10. The same explicit design without instruments takeslags=(int or list),endog_order=(the lagged system),trend=,vce=('hac','robust','ols'),dfk=andcommon_sample=; withcommon_sample=Trueit is Stata 18lpirf's dynamic multiplier of anexog()variable under everyvce()anddfk. Passing none of these leaves the existing two paths unchanged.state=gives state-dependent projections (lpirfs::lp_nl_ivfor an observed shock,lp_nlwithidentification='lpirfs_cholesky'): logistic or indicator regime weights, optional Hodrick-Prescott cycle, and a per-horizon Wald test that the two regime responses are equal (newStateDependentLPResult).id=/time=give panel projections (lpirfs::lp_lin_panel): unit, period or two-way fixed effects or pooled OLS, differenced shock, instruments, and unit-clustered (default), Driscoll-Kraay or homoskedastic errors.sp.svar(fit, proxy=m, proxy_target=, proxy_scale=, proxy_block_length=)identifies one shock with an external instrument (a proxy SVAR), with a residual-based moving block bootstrap forirf(ci='bootstrap'), FEVD shares of the full forecast-error variance and the robust first-stage F. ⚠️ The first version on main (612557ee, never released) used the Mertens-Ravn wild bootstrap, one Rademacher sign per period on the residual and the proxy. The sign cancels inu_t m_t, so the covariance that identifies the shock never varied across replicates: on the simulated design of the tests its 95% bands covered the true impact response in 17-19% of samples, with standard errors about a ninth of the sampling SD (Jentsch and Lunsford 2019 show the same for this bootstrap). The block bootstrap matches the robust 2SLS standard error of the impact ratio and covers at about the nominal rate.SVARResult.historical_decomposition()splits every variable into a shock-free baseline and accumulated shock contributions (Rsvars::hd).sp.granger_causality(data=, caused=, causing=, lags=, id=, time=)is the Dumitrescu-Hurlin panel test (W_bar,Z_bar,Z_tilde).sp.from_statatranslateslpirf y, exog(x)withlags(),step(),vce(ols | robust | hac nwest [#]),dfk,level(); the otherlpirfforms andivlpirf(absent from Stata 18) are refused with the call to write.- Evidence:
tests/reference_parity/test_ts_causal_parity.py, 1e-12 or better againstlpirfs0.2.5,plm2.6 (GPL packages used as black boxes),svars1.3, Stata 18lpirfandxtgcause; the proxy SVAR against the 2SLS identity, a long simulated sample, the HC0 2SLS standard error and a coverage screen. sp.vcov(lp, vce)/sp.vcov_comparere-report a local projection under anothervce=: the call is recorded, and an impulse-response result is read as one estimate per horizon (per regime forstate=). The refit equalssp.local_projections(..., vce=vce)bit for bit.
Experiments: group sequential designs, always-valid tests, CUPED, simulation power¶
Added¶
sp.group_sequential(n_looks=, information=, alpha=, power=, side=, efficacy=, futility=, binding=, n_fixed=): stopping boundaries and the maximum-information ratio of a group sequential trial, with the boundary families and conventions of Stata 18gsbounds(classical O'Brien-Fleming, Pocock, Wang-Tsiatis with Pampallona-Tsiatis futility; Lan-DeMets error spending of O'Brien-Fleming and Pocock type, Kim-DeMets, Hwang-Shih-DeCani; nonbinding or binding futility; one- or two-sided)..decision(z)applies the bounds. On 117 designs every bound and the information ratio equalgsboundsto 3e-7 when Stata's tolerances are tightened (intpointsscale(60) infotol(1e-12)); at its defaultinfotol(1e-6)Stata's futility bounds sit up to 3e-5 from the exact solution (tests/reference_parity/test_group_sequential_stata_parity.py).expected_info_h0/_h1equal Stata'sESS0/ESS1to 1e-7 for efficacy-only and binding designs; for nonbinding futility Stata's ESS follows a rule we could not reproduce (open item, seedocs/dev/modules/experiments_interference.md).sp.from_statanow translatesgsboundsand answersgsdesignwith the two-line recipe.sp.msprt(data, y, treat, tau=, sigma=, time=)andsp.msprt_lambda: always-valid p-values and confidence sequences of the normal-mixture SPRT (Johari et al. 2022) for continuously monitored A/B tests. No deterministic reference exists; the closed form equals its defining integral to 1e-9 and Monte Carlo checks confirm the type I error and the uniform coverage stay at alpha under continuous monitoring (S-level).sp.cuped(data, y, treat, covariates, method='cuped' | 'cupac' | 'lin'): CUPED variance reduction (Deng et al. 2013), CUPAC with an out-of-fold learner, or Lin's interacted regression; inference issp.difference_in_meanson the adjusted outcome.sp.power("simulation", n=, dgp=, estimator=, n_sims=, seed=, term=): Monte Carlo power, bias, RMSE and coverage (with Monte Carlo standard errors) of any estimator on a user data-generating process.sp.westfall_youngchecked against Statawyoung, permute()(Reif, SSC): coefficients, HC1 / CR1 t-statistics and analytic p-values agree to 1e-12 on a stratified and a cluster-randomized design; the adjusted p-values (exact here, 20,000 draws in wyoung) agree within Monte Carlo error (tests/reference_parity/test_westfall_young_wyoung_parity.py).sp.from_statatranslates the permutation form ofwyoung(robust or clusteredregress, stratum effects exactly whenstrata()is given) and refuses the bootstrap form, classical SEs,subgroup()andcontrols()with the reason.sp.power("ttest" | "twomeans" | "onemean" | "pairedmeans", ...)routes tosp.power_ttest;sp.poweracceptsdelta=foreffect_size=, andsp.power_ttestacceptseffect_size=,power_target=andsigma=fordelta=,power=andsd=.
sp.xtevent: trend adjustment and proxy-IV estimation¶
Added¶
sp.xtevent(..., trend=-3, trend_method='gmm' | 'ols')removes a linear pre-event trend as Stataxtevent, trend()does, andsp.xtevent(..., proxy=['x'], proxy_iv='select' | [leads] | ['w'])is the Freyaldenhoven-Hansen-Shapiro proxy-IV estimator (Stataproxy()/proxyiv(), 2SLS with unit effects asxtivreg, fe). Coefficients, SEs, N and the normalised event times equal Stataxtevent3.1.0 to 1e-9 on eleven settings (tests/reference_parity/test_xtevent_extensions_Stata_parity.py). When the trend starts after the window start, Stata's adjusted covariance attaches the trend weights to the wrong coefficient; ours follows the delta method (the test reproduces Stata's numbers from the misplaced weights). Not done: the smoothest-path plot (xteventplot, smpath).
sp.bacon_decomposition(covariates=)¶
Added¶
sp.bacon_decomposition(..., covariates=[...])decomposes the TWFE coefficient with time-varying controls (Goodman-Bacon 2021, Section 5) intoomega * beta_within + (1 - omega) * sum(weight * estimate)over between-group pairs, as Rbacondecompdoes with controls; the result addsomega,beta_withinandbeta_between. Every pair's weight, estimate and type, omega and the within estimate equalbacondecomp0.1.1 to 1e-10 (tests/reference_parity/test_bacon_controls_R_parity.py) and the identity holds to 1e-12. Withoutcovariatesnothing changes.
⚠️ Correctness: sp.from_stata / sp.stata two-way clustered reghdfe¶
Fixed¶
reghdfe ..., absorb(...) cluster(a b)was translated tosp.hdfe_ols(cluster=['a', 'b']), whose multi-way variance scales each term by its own G/(G - 1); reghdfe scales every term by G_min/(G_min - 1). The translation now passescluster_df='min'and reproduces Stata 18 reghdfe 6 to 1e-8 (x1 standard error 0.06525, previously 0.06151, on the holdout data with a three-level second cluster). One-way clustering is unchanged.tests/test_stata_translation_pweights.py.
sp.didregress(aggregate=): Donald-Lang and standard aggregation¶
Added¶
sp.didregress(..., aggregate='dlang' | 'dlang_varying' | 'standard')fits the DiD on group-period cells as Stata 18didregress/xtdidregressaggregate()does (Donald and Lang 2007): covariates that vary within cells are removed first (one slope, or a regression per cell with'dlang_varying'), then the cell outcomes are regressed on the treatment, cell-level covariates and group and period effects, with the OLS variance (dlang) or group-clustered variance (standard). The ATET and standard error equal Stata to 1e-10 on ten settings (tests/reference_parity/test_didregress_dlang_stata.py). The dlang t reference uses the residual degrees of freedom of the cell regression; Stata leaves the group effects out of that count, anddlang_df='stata'reproduces its intervals.
sp.panel_match: matching on treatment history (R PanelMatch)¶
Added¶
sp.panel_matchestimates the effect of a binary treatment that can switch on and off by matching each switch to the units with the same treatment history over the previouslagperiods (Imai, Kim and Wang 2023), refining the match on covariates (refinement=Mahalanobis, propensity-score or CBPS weighting / matching, or none; lagged covariates as'lag(y, 1:4)'), and averaging a weighted DiD over the matched sets at each lead.qoi=ATT, ATC, ATE or ART; unit bootstrap or the conditional / unconditional analytic standard errors;exact_match=,forbid_treatment_reversal=,restrict_control_period=, standardised covariate balance before and after refinement, and a placebo test (placebo_test=True). Matched sets, weights, estimates, analytic standard errors, balance and placebo estimates equal RPanelMatch3.1.5 to 1e-10 on fifteen settings (tests/reference_parity/test_panel_match_R_parity.py); the places where R's output is wrong are documented, two of them pinned by identities (docs/dev/modules/did.md).
Complex survey designs on the causal estimators (design=)¶
Added¶
design=SurveyDesign(fromsp.svydesign: weights, strata, PSUs, fpc, lonely-PSU rule) onsp.ipw,sp.aipw,sp.tmle,sp.dml,sp.did_2x2,sp.callaway_santanna,sp.msm,sp.cox,sp.kaplan_meier,sp.regress,sp.logitandsp.probit. The fit uses the design weights and every standard error is the Taylor linearisation of the estimator's influence function over strata, PSUs and the finite population correction, through one shared helper (statspai.survey.linearization.design_vcov, Rsurvey::svyrecvarfor a one-stage design, calibration included); intervals use t with the design degrees of freedom (#PSU - #strata), as Statasvy.- Evidence against R survey 4.5 on frozen fixtures
(
tests/reference_parity/test_survey_causal_parity.py,test_survey_did_parity.py): the helper equalssvyrecvaron ten designs and a raked one;ipw/aipw(se_method='sandwich')equal the Binder linearisation of the stacked estimating equations written out in R (closed-form Jacobian checked againstnumDeriv);regress/logitequalsvyglm;coxequalssvycoxph;kaplan_meierequalssvyrecvarofresid(survfit, type = "surv");callaway_santannaequalssvyrecvarof Rdid'sinffunc(cells, simple and dynamic aggregates);msmequals theipw::ipwtm+svyglmrecipe; all to 1e-13 or better.tmle(Rtmleinfluence curves) anddmlwith an IRM logit propensity agree to 1e-8 (glm convergence). sp.dml(design=)forms its cross-fitting folds over whole PSUs.design=is refused with a typedMethodIncompatibilitytogether withweights=/cluster=, with a bootstrap (that needs replicate weights), on the unbalanced / repeated-cross-section CS paths, and when the panel unit's weight, stratum or PSU varies over time.sp.aggteand the event-study covariance behindsp.honest_didrefuse adesign=CS fit rather than rebuild its variance without the design.design=Noneleaves every estimator's output unchanged.
Changed¶
docs/guides/survey_ph.md: section 4 documentsdesign=; the scope note that called the survey estimators "not parity-certified against svyglm" and the "no survey-weighted Cox" gap are removed (both have R parity tests).
Design guardrails on sp.feols / fepois / regress / hdfe_ols / panel¶
Added¶
- A 0/1 treatment that switches on at different dates under unit and time
fixed effects is now flagged:
result.violations()carriesstaggered_twfewith the share of the Goodman-Bacon weight on already-treated controls, anAssumptionWarningis issued, and the MCP payload'snext_callslead with a readydidcall (Callaway-Sant'Anna on the same column) andbacon_decomposition. The share is computed from cohort x period counts, without a regression, and equalssp.bacon_decomposition(...)['already_treated_control_weight_share'](to 1e-12 on random balanced designs, including always-treated units). A single adoption date, a 2x2 DiD and event-study dummies are not flagged. - A treatment constant within each level (or level x period cell) of a
column coarser than the standard errors (HC1 / iid, or clusters nested in
it) is flagged as
treatment_cluster_level, with a ready refit clustered at that level. Clustering at the level or a coarser one containing it, clusters that cross it (units x periods) and treatments that vary within it are not flagged. - Each user-level fit of these five functions and each
sp.spec_curverun records its specifications;model_info['multiplicity'](andsp.audit(...)['multiplicity']) gives the number of distinct specifications of the outcome fitted in the session, and from 5 onviolations()carriesspecification_search(info;warningfrom 20). - Estimates and standard errors are unchanged. Regressions run inside
another StatsPAI function are not checked or counted.
core/_design_guardrails.py,core/_spec_counter.py;tests/test_design_guardrails.py.
sp.fect: placebo, carryover and leave-one-period-out tests¶
Added¶
placebo_period=,carryover_period=andloo=Truerun fect's diagnostic tests (placeboTest,carryoverTest,loo): the chosen untreated cells leave the fit and their out-of-sample effect is tested for zero and for equivalence (TOST, boundtost_threshold, default0.36 * sqrt(sigma2)). Withvce=set,model_info['pretrend_test']carries fect's pre-trend F test, its equivalence version and the TOST p-value (f_threshold=,proportion=,pre_periods=as in fect). Withvce='jackknife'(deterministic) every number matches fect 2.4.1 to 1e-8 or better (tests/reference_parity/test_synth_family_R_parity.py) except one documented departure: fect builds the F statistic fromcov()of the raw leave-one-unit-out replicates, which understates the jackknife covariance by(n - 1)^2 / n, so fect's F is that factor larger (16.0 against 0.73 on the test panel, a spurious rejection of parallel trends). StatsPAI uses the jackknife covariance; the test pins the exact ratio.- The resampling loop moved to
synth/_fect_tests.pyunchanged (same draws for a givenseed); point estimates without the new options are unchanged (Track A module 86 reproduces its committed result).
sp.sdid(treated_unit=...) takes covariates and se_method='noinference'¶
Added¶
- With
treated_unit=/treatment_time=,covariates=(withcovariate_method='projected' | 'optimized') andse_method='noinference'used to raise and send the user totreat=. The block design is now run through thetreat=path with a 0/1 column built from the two arguments; the interfaces agree to 1e-15 without covariates. One treated unit with covariates reproduces Rsynthdid_estimate(X=)(-17.561000491802, 1e-11) and Statasdid'se(tau); tests intest_sdid_optimized_covariates.pyandtest_sdid_staggered_parity.py. The jackknife SE already matched Rsynthdid_se(method = "jackknife")(1e-9,test_did_synth_R_parity.py) and Statavce(jackknife)(1e-10,test_sdid_staggered_parity.py); unchanged.
sp.margins after mlogit / ologit / oprobit / tobit / heckman / ivprobit; sp.heckprobit, sp.intreg¶
Added¶
sp.marginsandsp.margins_atwork aftermlogit,ologit,oprobit,tobit,heckman(method='ml'),ivprobit(ML) and the newsp.heckprobit, with Stata'spredict()statistics through a newpredict=argument and Stata's defaults: one row per variable and outcome after the multiple-outcome models (category=restricts them, Statapredict(outcome(#)));xb,e(a,b),ystar(a,b),pr(a,b)after tobit;xb,ycond,yexpected,psel,xbselafter heckman;xb(Stata 18's default),pr(the average structural function, conditional on the observed reduced-form errors) andpr fixedasfafter ivprobit;pmargin,p11...p00,psel,pcondafter heckprobit. Factor variables are discrete changes,method='mem'is Stata'satmeans,margins_at(contrast=)iscontrast(atcontrast(r)), and fits withweights=average with them. Delta-method standard errors use the full ML covariance on the estimation scale. Before,sp.marginsrefused mlogit / ologit / ivprobit, and failed on tobit (it looked for a data column namedsigma) and heckman. Evidence:tests/test_ldv_margins_stata.pyagainst Stata 18 MP on 25 dy/dx tables and 7 predictive-margin tables, point estimates to ~1e-10. Stata's numerical derivatives leave a few 1e-7 in some of its standard errors (tobitpr(), ivprobitpr, heckprobitp11); there ours are within 1e-8 of a complex-step derivative of the closed form.sp.heckprobit: probit with sample selection by ML (Stataheckprobit),vce=oim / robust / cluster,weights=, Wald and LR tests ofrho = 0. Coefficients and standard errors match Stata 18 to 1e-9.tobitandheckmanresults keep their estimation-scale parameters and full covariance (result._ml_state) for the above.sp.intreg: interval regression by ML (Stataintreg lo hi x), with exact, left-, right- and interval-censored rows,vce=oim / robust / cluster,weights=,formula='lo + hi ~ x + C(g)';sp.marginstakes the tobit statistics after it. Coefficients, standard errors, counts and log-likelihood match Stata 18 to 1e-9 (tests/test_intreg_stata.py); with one censoring point it reproducessp.tobit.
Changed¶
sp.avg_slopes/avg_predictions/ ... andsp.contrast/sp.pwcompareafter these models raiseMethodIncompatibilitypointing tosp.margins(predict=...)/sp.margins_at; on tobit they used to fail with a misleading missing-column error.sp.biprobitevaluates the bivariate normal CDF overarcsin(r)(shared withsp.heckprobit). Results move by less than 1e-10 unless|rho|exceeds 0.95; at|rho|= 0.99 the old rule was off by 9e-7 in the CDF.
sp.augsynth: several treated units, progfunc, scm, fixedeff, covariates¶
Added¶
treated_unit=accepts a list of units that sharetreatment_time; they are fitted as one treated unit whose path is their average, asaugsynth::augsynth(single_augsynth) does. A list used to fail with a raw NumPy broadcast error. Different dates raiseMethodIncompatibilitypointing tosp.staggered_synth(multisynth).progfunc='ridge' | 'none',scm=andfixedeff=, following augsynth. augsynth's other outcome models (EN,RF,GSYN,MCP,CITS,CausalImpact,seq2seq) raiseMethodIncompatibilitylisting the supported values. The R bridge (backend='r') still runs only the single-unit Ridge specification and refuses the new options.- All of it matches augsynth 0.2.0 to 1e-13 (one CV case at lambda = 1e-5:
1e-10) on eight cases with fixed and CV-chosen lambda
(
tests/reference_parity/test_synth_family_R_parity.py).
⚠️ Correctness¶
covariates=was accepted and silently ignored by the native path. It now balances the covariates as augsynth's default (residualize = FALSE): per-unit pre-period means, centred at the donor mean, rescaled tosd(X_c)and appended to the outcome columns. Calls that passedcovariates=get different weights and estimates; calls without it are unchanged (Track A module 18 reproduces its committed result exactly).
Weak-IV-robust tests with several endogenous regressors and clustered errors¶
Added¶
sp.weakrobustreproduces Stataweakivwhen givencluster=,test_vcov=('classic'/'robust'/'cluster'), a list of endogenous regressors,subset=orproject=: AR, K, J, K-J and CLR (with its conditioning rank statistic) in weakiv's LM form, equal to weakiv 2.4.07 to 1e-12 under i.i.d., robust and cluster variances for one and two endogenous regressors, with AR / K / CLR confidence sets (unions of intervals, infinite endpoints when unbounded) for one regressor; Stata's own p-value at every StatsPAI endpoint is 0.05 (tests/reference_parity/test_weakiv_stata.py). With several regressors,subset=gives the subset AR test and set for a coefficient andproject=the projection of the joint AR set, both i.i.d. only as in Stata. Differences from weakiv, documented in the docstring: the CLR p-value is the exact conditional integral (weakiv: a 20-node Simpson rule for one regressor; for several, a simulation that draws AR and K independently although AR = K + J); the sets invert the reported LM tests (weakiv's closed-form i.i.d. AR / K intervals invert the Wald form); projection minimises over the whole real line rather than a user grid. Without these arguments, one-regressor output is unchanged (the Rivmodelconvention).sp.anderson_rubin_test(endog=[...], h0=[...]): joint AR F test of several endogenous coefficients in the classic / HC / cluster / absorb forms; ath0 = 0it equalsivreghdfe'se(arf)to 1e-9. Both functions raisedTypeError: unhashable type: 'list'on a list before.
sp.hdfe_ols IV: rank statistics with several endogenous regressors¶
Fixed¶
- With two or more endogenous regressors
sp.hdfe_ols("y ~ x | fe | d1 + d2 ~ z...")reported the Kleibergen-Paap rk Wald F, the under-identification LM and the Cragg-Donald F as NaN. They are now therankteststatistics on the FE-swept first stage, equal to Stataivreghdfe'se(rkf),e(idstat)ande(cdf)to 1e-9 under cluster, robust and iid variances, over- and just-identified (tests/reference_parity/test_hdfe_iv_ivreghdfe.py). Single-endogenous output is unchanged.
⚠️ Correctness: sp.causal_impact intervals include parameter uncertainty¶
- ⚠️
sp.causal_impact's standard error, interval and p-value treated the regression coefficients and the plug-in variance parameters as known. In 200 null replications per design its 95% interval covered 79.5% (n_pre 40, AR(1) error rho 0.6), 70.5% (n_pre = n_post = 30), 57.5% (rho 0.9), 90.5% (rho 0.2, n_pre 80) and 65% (four covariates). The standard error is now that of the error of the forecast actually made,u_post - L u_pre(the regression coefficients cancel exactly), under an AR(1)-plus-noise error covariance fitted by REML on the pre-period, with a t quantile (model_info['df']): 93.5%, 94%, 89.5%, 92% and 94% on the same draws (tests/reference_parity/test_causal_impact_uncertainty.py). Point estimates and the KFAS-pinned filter are unchanged; the old values are kept asmodel_info['se_avg_conditional']/['se_total_conditional'];detail['effect_se']and the pointwise bands use the new variance;model_info['error_model']holds the REML fit. The separatesynth/bsts.pyimplementation (a local-level model,sp.bsts_synth) is a different model and was not changed. - (2026-10-20, same release) The critical value of the average and
cumulative effect now also carries the uncertainty of the REML error
model: a two-pass parametric bootstrap-t (
n_boot=199draws per pass,seed=0; new parameters) that first corrects the small-sample bias of the AR(1) coefficient and noise share, then simulates the studentised forecast error with the error model re-fitted on every draw (vectorised grid REML,causal_impact/_uncertainty.py). The interval uses the larger of that quantile and the t quantile (model_info['crit'],['crit_method']), the p-value the larger of the two. Coverage on the same five designs: 94.5%, 94.5%, 93% (rho 0.9, was 89.5%), 92.5% and 95%.n_boot=0restores the t quantile; a call takes about one second longer.
sp.staggered_synth solves 300-unit panels in about a second¶
Changed¶
- The multisynth weight QP no longer forms the dense
(sum_j n0_j) x (sum_j n0_j)Hessian. The same primal active-set iteration now works block by block, folds the pooled term (rankn_lags) back in through a small Schur complement, and refactors a block only when its free set changes (synth/_multisynth_qp.py). On a 300-unit, 10-period staggered panel (about 17,000 stacked weights) the point estimate takes about 1.5 s instead of not finishing; 80 units go from 3.6 s to 0.07 s. Weights agree with the former solver to about 1e-11 across 1,960 fits (separate / pooled,nuin {0, 0.4, auto}, ridge on and off,fixedeff); the R multisynth parity tests are unchanged. Where the optimum is not unique (nu=1without ridge) the solver can return a different optimal weight vector with the same objective value.
sp.spillover says which contrasts it computes¶
Fixed¶
sp.spilloverwas labelled "Hudgens & Halloran (2008)" and described as IPW. It computes differences in means between own-treatment x peer-exposure cells split at the median positive exposure; the method label, estimand, docstring and registry card now say so, andmodel_infoadds the threshold,cell_sizesand the estimand formulas. Point estimates are unchanged; standard errors change only where a bootstrap replicate had an empty cell (see below). Three silent behaviours now fail loudly:covariates=(ignored before) and an unknownexposure_fn(fell back to'fraction') raiseMethodIncompatibility; an empty cell (reported as an effect of 0) raisesDataInsufficient, and bootstrap replicates with an empty cell are dropped and counted (n_boot_dropped) instead of entering as 0.
sp.ml_bounds documents what it computes¶
Fixed¶
sp.ml_boundswas described as "ML-enhanced" bounds that tighten as the outcome models improve, citing a counterfactual-density paper and an arXiv placeholder. By iterated expectations its covariate-averaged worst-case bounds are exactly the Manski (1990) interval, widthy_max - y_minfor every learner; the cross-fitted midpoint differs from the plain-means one only by estimation noise. Docstrings, summary andmethodlabel ("Worst-case (Manski) ATE bounds, cross-fitted plug-in") now say so, the unverified references are gone, and a test pins the identity. Numbers are unchanged.
Honest DiD: sign, shape and second-difference restrictions, natively¶
sp.honest_did(bias_direction=, monotonicity_direction=)andmethod='smoothness_relative_magnitude': R HonestDiD's sign restrictions (Δ^SDPB / SDNB, Δ^RMPB / RMNB, Δ^SDRMPB / SDRMNB), shape restrictions (Δ^SDI / SDD, Δ^RMI / RMD, Δ^SDRMI / SDRMD) and relative magnitudes of second differences (Δ^SDRM, "deviation from linear trend"), without R. Smoothness now also runshonestdid_method= 'Conditional','C-F'(conditional-FLCI hybrid, the default once a sign or shape restriction is given) and'C-LF'natively; before they neededbackend='r'.backend='r'passes the new restrictions to HonestDiD.sp.honest_did_from_momentstakes the same arguments; the table'sattrs['delta']names the set as HonestDiD does.- Evidence (
tests/reference_parity/test_honest_restrictions_R_parity.py, HonestDiD 0.2.8):'Conditional'accepts the same grid points as HonestDiD (1e-12) for every restriction on correlated and one-post-period event studies, with the same default grids;'C-F'within one grid step (its grid is the alpha/10 FLCI, whose centre the two FLCI solvers pin to ~1e-5);'C-LF'inside the native bound's spread over ten seeds of its simulated first stage. The C-F grid ends sit on the first-stage boundary, where the verdict was decided by rounding; they are now rejected by the first stage (HonestDiD rejected one, StatsPAI accepted it, on the same inputs). With one pre-period HonestDiD's monotonicity matrix imposes delta_{-2} <= delta_0 instead of delta_{-2} <= 0 (an R1:0loop); the native matrix follows the definition.
sp.mediate(interaction=True) works under the default inference¶
Fixed¶
sp.mediatewithinteraction=True, a logit / probit mediator, a non-linear outcome model ortreat_values=raisedMethodIncompatibilityunder the defaultinference='bootstrap'. The default now bootstraps the potential-outcome estimator (case resampling,n_boot,seed; bootstrap standard errors, percentile intervals,pvalue_methodas before), as Rmediation::mediate(boot = TRUE)does. Point estimates equalinference='robust''s; the ACMEs equal R mediation 4.5.1'sd1/d0to 1e-9 (tests/reference_parity/test_mediate_po_bootstrap.py). The draws are inmodel_info['boot_draws'].inference='delta'still refuses these models.
Survival: case weights, counting-process data, robust-variance conventions¶
Added¶
sp.cox(weights=, start=, ssc=, weights_as=).weights=fits the weighted partial likelihood of Rcoxph(weights=)(each Efron term of an event time carries the mean weight of its deaths);weights_as='sampling'(Stata[pw=]) asks for the robust variance.start=takes counting-process rows(start, stop]for delayed entry and time-varying covariates (RSurv(start, stop, event), Statastset, time0()/enter(time v)).ssc='r'gives coxph's robust / clustered variances (no factor),ssc='stata'stcox's (N/(N-1)robust,G/(G-1)clustered); not given, the old behaviour is kept. With weights or entry times the concordance is computed over the risk sets, as Rconcordance()does. Coefficients, SEs, log partial likelihood and concordance matchcoxphto 1e-10 on 16 configurations, andstcoxto 1e-7 on 8 (tests/reference_parity/test_survival_weighted_parity.py).sp.kaplan_meier(weights=, start=, cluster=, se_method=): weighted and delayed-entry Kaplan-Meier with the infinitesimal-jackknife (robust) variance ofsurvfit(robust=TRUE), R's default with non-integer weights or a cluster. Survival, SE and intervals matchsurvfitto 1e-12 on six configurations.sp.stteffects(method='ra' | 'wra' | 'ipw' | 'ipwra', estimand='ATE' | 'ATET'): treatment effects on mean survival time from Weibull / exponential / log-normal outcome and censoring models and a logit / probit treatment model, solved as stacked estimating equations with the robust sandwich, as Stata'sstteffects. Ten configurations match Stata 18 to 1e-9 (estimates) and 1e-7 (SEs) (tests/reference_parity/test_survival_causal_parity.py). One documented departure: Stata'sipwra, atetaverages the outcome models' predictions over the whole sample (its equations 15e-15f), which estimates the ATE under a correct outcome model;sp.stteffectsaverages over the treated (shown on a known-truth DGP in the same test file).sp.rmst(group=, tau=, weights=, cluster=): restricted mean survival time per group with the difference and the RMST / RMTL ratios. Unweighted it issurvRM2::rmst2(to 1e-12); with IPT weights it is the confounder-adjusted RMST with the infinitesimal-jackknife SE of Rresiduals(survfit, type = "rmst")(to 1e-12).sp.kaplan_meier(se_method='xie_liu'): the varianceadjustedCurvesreports for IPTW Kaplan-Meier curves (Greenwood with the effective number at risk), matchingadjustedsurv(method = "iptw_km")to 1e-12.sp.msm(censor=, censor_covariates=): stabilized inverse-probability-of- censoring weights for person-period data with dropout (pooled logistic models, cumulative product over periods), multiplied into the treatment weights. Weights, coefficients and SEs match Ripw::ipwtm(type = "first")+ipwtm(type = "all")+vcovCLto 1e-6 (tests/reference_parity/test_msm_censoring_parity.py); on a DGP with informative dropout the weighted slope is within 3 SE of the truth where the complete-case slope is 2.8 SE off.sp.statareadsstset t [pw=w], failure(d) time0(t0) id(i)/enter(time t0)forstcox;stcox, vce(robust)andvce(cluster)now translate (ssc='stata'; the robust one used to be refused).sp.from_rtranslatescoxph(cluster =),+ cluster(id),id =withrobust = TRUE,weights =andSurv(start, stop, event)(the cluster used to be dropped).
Changed¶
sp.cox(weights=...)with non-integer weights and the model-based variance warns: that variance reads the weights as frequencies.CoxResult.ph_test()refuses weighted and counting-process fits (MethodIncompatibility) instead of testing them as unweighted right-censored data.
Treatment effects: MatchIt-style matching constraints, entropy-balancing estimands, louder failures¶
Added¶
sp.match(method='nearest', exact=, covariate_calipers=, mahvars=): match only within cells of equal values, within per-variable calipers, or on the Mahalanobis distance ofmahvarsinside a propensity-score caliper -- RMatchIt::matchit(exact =, caliper = c(x = ...), mahvars =). On eight configurations the matched pairs equal MatchIt 4.7.2's row by row and the ATT agrees to 1e-14 (tests/reference_parity/test_matchit_constraints_parity.py).sp.ebalance(estimand='ATE' | 'ATC', base_weights=): each arm balanced to the pooled sample, or the treated to the controls; base weights are the entropy reference (Stataebalance, basewt(), WeightItbase.weights). Estimates and M-estimation standard errors matchWeightIt::lm_weightit(vcov = "asympt")to 1e-9 / 1e-7 on 15 cases (tests/reference_parity/test_ebalance_estimand_parity.py). The ATT numbers are bit-identical to before.sp.optimal_matchwith fewer controls than treated matches every control and leaves the rest of the treated unmatched with a warning, as MatchIt does (it used to raise);n_unmatched_treatedrecords how many, and the summary labels the estimate as the effect on the matched treated.sp.sensemakrreturns aSensemakrResult: still adict(every key andisinstance(r, dict)work as before), with attribute access,summary(),to_dict()/to_latex()/cite(), andtreat,alpha,n_obs,df_residkeys.
Changed¶
sp.matchrefuses a keyword the chosen method does not take with a typed error (MethodIncompatibility, still aTypeError) that names the right spelling:bias_correction=isbias_adjust=formethod='nnmatch', and the reverse for nearest-neighbour matching.sp.tmle(weights=)with the default Super Learner library leaves out the k-nearest-neighbour learners, which cannot take weights, and warns; it used to stop with an error. A library you pass is still used as given.sp.dml(model='irm' | 'iivm')warns with the number of cross-fitted propensity scores it clipped, and recordsn_clipped/clip_shareinmodel_info['diagnostics'](assp.aipwandsp.tmlealready did).sp.rosenbaum_bounds(data=, pair_id=): a pair with two treated or two control rows is aMethodIncompatibilitypointing tosp.weighted_rank(it was a pandas reshape error); a pair that lost a row is dropped with a warning instead of silently.sp.bcfwarns on every call (BCFApproximationWarning) that it is a random-forest + boosting approximation with bootstrap intervals, not Bayesian Causal Forest; its method label now says so. Numbers unchanged.sp.dose_responsewarns when both models are left at their gradient- boosting defaults (noisy, usually non-monotone curves) and reportsmodel_info['curve_monotone']. The default is kept: on a linear confounded design the Hirano-Imbens quadratic was far more biased (+1.27 against -0.13 for the default, truth about 1.9).
⚠️ Correctness¶
sp.cbps,sp.ipw,sp.aipw,sp.tmle,sp.ebalance,sp.optimal_match,sp.cardinality_matchandsp.rosenbaum_boundsrefuse a treatment that is not coded 0/1 withMethodIncompatibility.sp.cbpsused to return a number for a 0/2 or continuous column (6892 and -122.9 in the audit);sp.tmleaccepted a column such as {0.5, 1} because it rounded the codes before checking;sp.optimal_matchread a 0/2 column as having no treated units.
⚠️ Correctness: sp.ges is Greedy Equivalence Search¶
- ⚠️
sp.gesnow searches equivalence classes with Chickering's (2002)Insert/Deleteoperators and re-completes the CPDAG after every step (Meek's rules). It used to add and remove single edges in DAG space and then call every edge outside a v-structure undirected, so compelled edges below a collider (Z -> WinX -> Z <- Y, Z -> W) came back asZ - Wand the search could stop short of the GES optimum. Againstpcalg::ges(phase = c("forward", "backward"))on 30 random linear-Gaussian graphs it matched 11; it now matches all 30, mark for mark (tests/reference_parity/test_ges_pcalg_parity.py). The provenance call is no longer wrapped in a bareexcept Exception: pass.
⚠️ Correctness: sp.msm no longer truncates the weights by default¶
- ⚠️
sp.msm(trim=)defaults to0.0(was0.01), as Ripw::ipwtm(trunc = NULL). Truncating at the 1st / 99th percentiles changes the pseudo-population, so the slope is no longer the MSM parameter, and nothing in the result said so: on a design with strong time-varying confounding (true slope 1.0) the old default averaged 1.15 over 20 replications against 0.99 untruncated. Withtrim > 0the untruncated fit is now computed too and stored inmodel_info['truncation'](estimate_untruncated,se_untruncated,shift,shift_in_se,n_weights_changed), and anAssumptionWarningfires when the two differ by more than 0.25 SE.ipwtm(trunc = 0.01)parity is unchanged withtrim=0.01passed explicitly.
⚠️ Correctness: sp.did_imputation joint lead-and-horizon covariance¶
- ⚠️ With BJS pre-trend leads (
pretrend_method='bjs', the default whenhorizon=has negative values orpretrends=is set), the event-study covariance now carries the lead-horizon cross block. Both blocks are linear in the outcome; the cross block is the sum over clusters of the products of their scores, which is how Statadid_imputationbuildse(V). StatsPAI set it to zero and flagged the matrix block-diagonal, sosp.honest_didfell back to its worst-case-bias interval andsp.uniform_bandsto Sidak on every BJS fit; both now use the joint matrix (sp.event_study_vcov(fit).jointisTrue). - ⚠️ The lead standard errors use
reghdfe's observation and cluster counts: singleton units of the auxiliary regression (one untreated period) are dropped before the dof adjustment, asreghdfe(and so Statadid_imputation) does. Coefficients do not change. On mpdta withpretrends(3)the 125 counties first treated in 2004 are singletons and the lead variances were 0.1% too small. Panels without such units (Track A module 84) are unchanged. - Evidence:
tests/reference_parity/test_did_imputation_pretrend_vcov_Stata_parity.py, Stata 18did_imputation, horizons(0/3) pretrends(3)on mpdta: lead block 1e-13, horizon block 7e-10, cross block 2e-8 (reghdfe's solver tolerance).
Faster relative-magnitudes Honest DiD¶
sp.honest_did(method='relative_magnitude')enumerates the vertices of each piece's dual polytope in one batched solve (a determinant bound settles most bases without an SVD) and shares them with the least-favourable critical value. 7 pre / 7 post periods: 34 s to 6 s; with the post-period average as target, 104 s to 15 s. Identical sets (the HonestDiD parity tests are unchanged at 1e-12).
Discovery: Stata / R command names, paraphrases, limit=¶
sp.search_functionsranks the function a Stata command or R function translates to first:csdid/att_gt->callaway_santanna,reghdfe/ivreghdfe->hdfe_ols,ivreg2->iv,boottest->wild_cluster_boot,bacondecomp,did2s,matchit, andeventstudyinteract/fixest::sunab->sun_abraham. The index is read from thesp.from_stata/sp.from_rtranslator tables (_discovery_commands.py); hits carrymatched_command.sp.csdidnow raises anAttributeErrorthat namessp.callaway_santanna(it suggestedsp.sdid). On 31 common command names the translator's target (or the function of that name) ranked first for 16 and in the top 3 for 17; it now ranks first for all 31.- Paraphrased questions ("treatment adopted at different times", "only one
treated unit ... donors", "first stage F statistic is small", "score
cutoff") reach the design's entry point; a 12-query eval set
(
tests/test_discovery_paraphrase.py) went from 0 to 12 top-3 hits. sp.search_functions(query, limit=10, category=None): like the MCP tool, the Python function returns the top 10 by default;limit=Nonereturns every match as before.- MCP
curatedprofile: listsfeols,iv,match,sun_abrahamandsdid;nbreg/xtnbregare no longer advertised there (still callable by name with their hand-written schema, and listed underfull).tools/listgrows from 42 to 45 tools, 81.6 KB to 87.1 KB (about 20.4k to 21.8k tokens at 4 bytes a token);coreis unchanged. - MCP
describe_functionsends the parameters once, as the JSON schema (it sent the registryparamslist and the schema, whose composed description also repeated the card): 82.4 KB to 51.0 KB overcallaway_santanna/feols/rdrobust/dml/synth. - MCP tools accept
detail="full"as a spelling of"agent"(the fullest payload); it was refused as an invalid argument. - Agent-card examples of the 70 audited entry points run as written
(
statspai/_card_examples.py): each loads a bundled dataset (the real Castle Doctrine, Prop 99, LaLonde, Lee Senate and Card extracts where the design fits) or asp.dgp_*simulator and uses the parameter names the schema lists. Before, most assumed adfwith columns no dataset has.tests/test_card_examples.pyexecutes all 70. next_steps()onsp.feols/sp.regress/ IV fits follows the variance estimator already used: no "re-estimate with robust SEs" on a clustered or robust fit,vcov='hetero'throughsp.feolsfor an iidfeolsfit, and a wild cluster bootstrap when there are fewer than 30 clusters. Everysp.feolsresult was also handed the IV checklist (first-stage F, over-identification, Kitagawa) because"iv"is a substring of"ols (statspai native)"; IV is now matched on whole words.sp.recommendon a panel passed withoutid=/time=reads the layout withsp.detect_design(and refuses when two layouts fit equally well); it used to profile the panel as a cross-section and offer the unit, period and cohort columns as covariates. Columns that encode the unit or the adoption timing (first_treat, a cohort index, another unit code) are never default covariates, also under a declared cross-sectional design. Thesp.regresscode it emits put quoted column names inside the formula ('y ~ d + 'x1', 'x2'') and did not parse.sp.aipwrecords its unclipped fitted propensity, soviolations()reports weak overlap as it does forsp.ipw(it reported nothing).- MCP reproduction bundles hold the standard error and interval as well
as the estimate (headline and every coefficient). The bundle looked for
std_errorwhere the payload saysse, so a re-run with another variance estimator passed as reproduced. - R argument names are accepted:
sp.callaway_santanna(yname=, tname=, idname=, gname=, xformla=, weightsname=, est_method=, alp=),sp.aggte(alp=, biters=),sp.drdid(yname=, tname=, idname=, dname=, estMethod=, weightsname=)(RDRDID::drdid), andsp.event_study(first_treat=).
⚠️ Correctness: sp.rdrobust fuzzy bandwidth with covariates¶
- ⚠️
sp.rdrobust(fuzzy=..., covs=...)with an automatic bandwidth:hnow equals R / Statardrobust's. The parser demeanedcovsbefore the bandwidth cascade, and the fuzzy cascade atnu = 0builds its delta-method weights from each side's levels at the cutoff, which move with the covariates' location.h(and so every estimate) was 4e-3 to 5e-3 (relative) off on covariates with non-zero means;b, all sharp bandwidths and all fixed-hestimates were already exact, and standardised covariates (the earlier fuzzy fixture) were unaffected. New fixturetests/reference_parity/test_rd_fuzzy_covs_level_R_parity.py(p = 1, p = 2, cluster, hc1, cerrd, msetwo; rdrobust 4.0.0, rel 1e-6).
Staggered DiD: one set of input checks, anticipation in sun_abraham and did_imputation¶
The 2026-10-19 audit found the staggered estimators handling the same bad
input in different ways: a cohort column that changes within a unit gave
three different estimates from callaway_santanna, sun_abraham and
did_imputation with no warning, and a cohort treated from the first
period gave sun_abraham zero standard errors. The checks now live in
did/_validate.py and are shared.
Added¶
sp.sun_abraham(anticipation=, ref_p=): the reference period becomes-1 - anticipation(fixestsunab(ref.p = )), the anticipation effects are estimated and left out of the pre-trend test and the post-treatment average. Matchesfixest::sunabto 1e-11 with one or two reference periods and a covariate.sp.sun_abraham(i=None)fits repeated cross sections with cohort fixed effects and HC1 standard errors,feols(y ~ sunab(g, t) | g + t, vcov = "hetero")(1e-11).sp.did_imputation(shift=), Statadid_imputation, shift(): treatment is movedshiftperiods earlier and horizons are counted from the shifted date. Matches Stata 18 to 4e-8 (reghdfe's tolerance).sun_abrahamnames its headline (model_info['headline']) and prints the other aggregation next to it (other_aggregation): the default.estimateis the equal-weight mean of the post-treatment event-time effects, not fixest'ssummary(agg = "att"), which isaggregation='fixest_att'and always inmodel_info['att_fixest_att'].callaway_santanna/aggteresults carrymodel_info['inference_note']when the intervals are analytic or pointwise, saying that Rdid::att_gtdefaults tobstrap = TRUE, cband = TRUEand how to get that. The defaults are unchanged.sun_abraham,did_imputation,gardner_did,stacked_didandetwfewarn (AssumptionWarning) when the treated observations fall in fewer than ten clusters, ascallaway_santannaalready did.
Changed¶
- A cohort (first-treatment) column that takes more than one value within
a unit is refused (
MethodIncompatibility) bycallaway_santanna(panel),sun_abraham,did_imputation,gardner_did,stacked_didandetwfe(panel), as Rdiddoes. NaN, inf and 0 all mean never treated and do not count as different values. - Several rows per (unit, period) are kept by the regression estimators
(the unit may be a group with individuals sampled each period, as in
Stata
did_imputationand fixest) but now raise aRepeatedUnitPeriodWarning, since an accidental many-to-one merge looks the same and understates the standard errors.callaway_santannastill refuses them. sun_abrahamwith no never-treated unit raisesDataInsufficientwhose hint points tocontrol_group='lastcohort'/control_cohort=.sp.fect(n_boot=)defaults toNone(200 when bootstrapping); passing it withoutvce='bootstrap'now warns instead of silently returning no standard errors.
Fixed¶
- ⚠️
sp.sun_abrahamwith a cohort that has no row in the reference period (treated in or before the first period of the panel): the cohort's interaction cells are collinear with its own fixed effects, and every post-period estimate came back with standard error 0 and p = 1, with the unidentified cohort's late relative times reported as effects. Such cohorts are now dropped with a warning (model_info['dropped_unidentified_cohorts']), and the fit equalsfixest::sunabon the panel without them to 1e-11 (tests/reference_parity/test_did_validation_parity.py). - ⚠️
sp.etwfeandsp.stacked_diddrop cohorts treated in or before the first period, with a warning (model_info['dropped_always_treated']). ETWFE's cohort x period cells for such a cohort are collinear with its fixed effect and the dropped cell's treated period became a comparison (on the audit panel the ATT was 2.892 with the cohort and 2.930 without; Retwfegives 2.86 and 2.93); a stacked sub-experiment for such a cohort has no reference period.
Translators: [pweight=] implies vce(robust); one dropped key; new mappings¶
Fixed¶
- ⚠️
sp.from_stata/sp.stata: a command with[pweight=]and novce()ran with the non-robust variance forreghdfe,ivreghdfe,ivregress,ivreg2,xtreg, feandglm(the translator only added a robust variance where the sp function has arobust=argument). Stata gives these commands the variance ofvce(robust)(xtreg, fe:vce(cluster panelvar));reghdfe y x1 x2 [pw=w], absorb(g)had an x1 standard error 7% too large,xtreg y x [pw=w], feone 21% too small. The translator now addsvce(robust)to such commands before translating them, so each command's ownvce(robust)mapping applies; weighted robustreghdfe/ivreghdfego tosp.feols(vcov='hetero', fixef_rm='singleton'), sincesp.hdfe_ols's robust variance is unweighted.glm [pw=]now uses Stata's N/(N-1) (robust='robust', was HC1). Checked against Stata 18 (reghdfe 6, ivreghdfe, ivreg2); three[pweight=]rows added to the translation holdout.newey/prais/rdrobust/xtnbregwith[pw=], which Stata refuses, are refused.qreg [pw=]printed a call without the weights (argumentshad them). sp.from_r:feols(y ~ sunab(cohort, period) | unit + period)produced a call that crashed; it now maps tosp.sun_abraham(share_variance=False), which reproduces fixest 0.14's coefficients and clustered standard errors to 1e-9.rdrobust(covs = cbind(df$z1, df$z2))(anddf[, c(...)]) was dropped; it is translated.cluster = ~a + bis two clustering variables.
Added¶
- Both translators return
dropped(everything the call leaves out: options or arguments, and an unappliedif/in) andcomplete(Trueonly when nothing was dropped).untranslated_options,unapplied_sample(Stata) anduntranslated_arguments(R) stay. sp.from_stata/sp.statatranslatestcox(Breslow ties unlessefron, as Stata),stcrreg(sp.finegray(small_sample=True)),eventstudyinteract(sp.sun_abraham;sp.stataruns it only when the varlist is every relative-period indicator but -1) andpower twomeans | onemean | pairedmeans(sp.power_ttest), each checked against Stata 18.xtabond2,leebounds,lpirf,rwolf,wyoungandspregressare refused with the sp function to call instead.sp.statarefuses a survival fit whose event variable has codes the sp call would read differently fromstset/compete().sp.from_rtranslatesAER::ivreg/ivreg::ivreg(sp.iv),survival::coxph(sp.cox),pwr::pwr.t.test(sp.power_ttest; pwr's per-group n is the total here),survey::svydesign/svyglm(dof='residual', R's reference distribution),lmtest::coeftestwithsandwich::vcovHC/vcovCL(sp.vcov;vcovHC's default is HC3),etwfe::etwfe/emfx,did::aggte,did_multiplegt_dyn(se_method='analytic',effectswritten out) andmodelsummary, each run against the R package on the same data.weightit,synthdid_estimate,mediation::mediate,CausalImpact,lagsarlm,pcalg::pcandmr_ivware refused with the sp call to make (R_GUIDANCE).pkg::fn(...)is accepted.
Output layer: exports with their defaults, and honest table rows¶
Fixed¶
CausalResult.to_word(path)(every causal estimator,sp.qreg,sp.tobit...) crashed with its own defaultdigits=None; it now uses the"auto"precision ofto_latex/to_markdown. A new contract test callssummary/to_latex/to_markdown/to_word/to_excel/cite/to_dict/sp.regtablewith defaults on 49 estimators.sp.regtable/sp.esttab/sp.modelsummaryon a result without coefficients (OaxacaResult,KMResult,VARResult,LPolyResult...) raiseMethodIncompatibilitywith a recovery hint instead ofAttributeError: ... no attribute 'params'.- ⚠️ "Cluster SE" said
Nofor clusteredsp.feols/sp.fepois/sp.feglmcolumns (cluster=orvcov={"CRV1": ...}) and forsp.hdfe_ols(cluster=); it now names the cluster variable(s).sp.hdfe_olscolumns also get their FE rows and R². - ⚠️ "Mean of Y" printed
-0.000for every fixed-effect column (it averaged the demeaned outcome); it is now the outcome mean on the estimation sample. - An unknown
stats=name printed an empty row; it now raisesMethodIncompatibilitywith a did-you-mean.n_obs,r_squared,adj_r_squared,pseudo_r2,loglik,DV meanare recognised, andCluster/FE/First-stage F(rows the indicator block prints) are accepted without adding an empty row. sp.modelsummary(output="table.tex")(or.html/.md/.txt/.csv) wrote nothing and returned text; it writes the file. A misspeltoutput=raises instead of returning text, as doessp.esttab(output="bogus").sp.outreg2(filename="t.html")wrote an Excel workbook under that name;.html/.md/.txtnow write the rendering, an unknown extension orformat=raises, and a bare name still writes Excel.CausalResult.summary()crashed whenmodel_info['pretrend_test']wasNone(sp.sun_abraham(..., pretest='none'), an event window without leads); it now leaves the pre-trend line out.result.plot(type=...)with an unknown type drew a coefplot (CausalResult), or ignoredtype(sp.rkd,sp.cox); it raisesMethodIncompatibilitylisting the valid types.type='synth'is an alias of'both'.sp.rddensity(...).plot()raised "'yerr' must not contain negative values" (its headline is a T statistic, itscia density difference); it now draws the two boundary densities with their intervals.sp.sumstats/sp.balance_tableraised a rawTypeErroron a string or categorical column; each level now gets a row (the statistics of its 0/1 indicator, soMeanis the level's share, Statasummarize i.var). The SMD of a level row insp.balance_tablefollows Rcobalt's binary convention,(p_t - p_c) / sqrt((p_t(1-p_t) + p_c(1-p_c)) / 2)(the whole sample'ssqrt(p(1-p))when both arms are constant), equal tocobalt::bal.tab(binary = "std", s.d.denom = "pooled")to 1e-12 (cobalt 4.6.2 as a black box;tests/test_owner_decisions_oct20.py). Numeric covariates, 0/1 numeric columns included, keep the sample-SD denominator. Both takefilename=(.xlsx / .docx / .tex / .html / .md / .txt / .csv) andoutput='markdown'; an unknownoutput=(orbalance_table(test=)) raises instead of falling back to text.
Added¶
tidy()/glance()on every result class built on the shared result protocol (~360 classes):tidyreads the coefficient vector, the headline estimate or the test statistic, and refuses withMethodIncompatibilitywhen there is none;glanceis one row of the scalar fields.sp.bacon_decomposition,sp.evalue,sp.oster_boundsandsp.mi_estimatereturn result objects that are stilldicts (same keys,isinstance(r, dict), JSON-serialisable) withsummary()/tidy()/glance()/to_latex()/to_word()/cite();sp.regtabletakes themi_estimateresult (Rubin-pooled coefficients and SEs,N).booktabs=TrueonRegtableResult.to_latex,EconometricResults.to_latexandCausalResult.to_latex(\toprule/\midrule/\bottomrule; the default keeps\hline).EconometricResults.plot()(a coefplot);sp.coefplot(drop=[...])(Statacoefplot, drop(_cons); like Stata, the intercept is plotted unless dropped) and a warning when the models' coefficients are on different scales (OLS slopes beside logit log-odds or Poisson log-rates).
Changed¶
- Default summary rows: likelihood models that report a pseudo-R² (logit,
probit ...) show
Pseudo R²andLog-Lik.; default rows that no column can fill (R² / Adj. R² / Funder a logit or a causal estimate) are dropped. OLS tables are unchanged. - The table functions accept each other's argument spellings:
sp.regtable(model_names=, mtitles=, coeflabels=, variable_labels=),sp.esttab(model_labels=, model_names=, mtitles=, coef_labels=),sp.modelsummary(model_labels=),sp.outreg2(model_labels=, coef_labels=, digits=). Passing both spellings raisesTypeError.
Post-estimation after fixed-effects fits; wild bootstrap by estimator¶
Added¶
result.fixef()aftersp.feols/sp.fepois/sp.feglmwith absorbed effects: one Series per effect, normalised as Rfixest'sfixef()reports them (matches it to 1e-6 for one to three effects, IV, Poisson and logit;tests/reference_parity/test_fixest_postestimation_parity.py).predict(newdata=...)(alias ofdata=) on regression results.sp.hypotheses: a list of hypotheses (one row each), andjoint=True/joint=[names]withjoint_test="f" | "chisq"for one Wald test of several restrictions, linear or not (Statatestnl; marginaleffects'joint=to 1e-6).vcov=False(marginaleffects' spelling) onsp.predictions/comparisons/slopesand theiravg_versions (estimates without standard errors).sp.wild_cluster_boottests several coefficients jointly (variable=[...],h0=[...]) and clusters several ways (cluster=[a, b],bootcluster=), matching Stataboottestp-values exactly in the enumerated regime (tests/reference_parity/test_wild_cluster_boot_dispatch_parity.py).sp.iv_wild_bootstrapis public (the WRE bootstrap behindsp.ivreg(vce='wild')).sp.hdfe_ols(cluster_df='min'): the multi-way small-sample factor of Statareghdfeand Rfixest(reghdfe's SEs to 1e-10). The default stays'per_term'(Rsandwich::vcovCL) and is now recorded incluster_info['cluster_df'].
Fixed¶
- ⚠️
predict(newdata)aftersp.feols/sp.fepois/sp.feglmwith absorbed effects left the effects out, and aftersp.fepois/sp.feglmreturned the linear index instead of the mean. It now adds the effects and applies the inverse link, as fixest'spredict; an unseen level gives NaN with a warning. - ⚠️
sp.avg_slopes/avg_predictions/avg_comparisons/sp.marginsaftersp.fepois/sp.feglmread the model as linear (the link was not recorded) and returned the coefficients as marginal effects (0.286 against marginaleffects' 0.275 on the audit data). The link is recorded and the effects enter the index, held at their estimates, as marginaleffects does on a fixest model. Standard errors that would ignore the effects' uncertainty (predictions; any quantity of a non-linear model) are refused unlessvcov=False, as marginaleffects refuses them. - ⚠️
sp.wild_cluster_bootrefit least squares whatever the model: aftersp.ivregit bootstrapped the OLS coefficient, aftersp.logita linear probability model. It now runs the bootstrap the estimator defines (WRE after 2SLS, the score bootstrap after logit / probit / Poisson,sp.did's own wild bootstrap after a 2x2 DiD) and refuses other models. - ⚠️
sp.feols(..., vce='wild')with an IV part reported the OLS cluster standard errors of the structural design (0.024 against the 2SLS 0.059 on the reference panel) with bootstrap p-values of the OLS coefficient. The endogenous coefficients now get the WRE bootstrap and the others keep their CRV1 inference. - ⚠️ The wild bootstrap after a fit with absorbed effects that are not
nested in the clusters (year effects with firm clusters) did not
residualise the bootstrap draws on the effects again, so its p-values
were those of another bootstrap (0.130 against Stata
boottestafterreghdfe's 0.146 on the reference panel).sp.feols(vce='wild')andsp.wild_cluster_bootnow match boottest exactly. - Tables from
sp.avg_slopesand friends kept arrays inDataFrame.attrs, so printing a wide table andpd.concatraised "truth value of an array is ambiguous". The Jacobian is kept in a wrapper that compares by value (np.asarray(tab.attrs["jacobian"])), and a subset of rows is tested with its own rows. sp.vcov(fit, 'DK')/vcov='NW'without the panel ids failed withKeyError: None; they now raise a typed error namingvcov_kwargs={'time_id': ..., 'panel_id': ..., 'lag': ...}(with them, pyfixest's numbers equal fixest'sDK(L)/NW(L)).
Singletons: the default of fixef_rm changes in 1.40¶
sp.feols/sp.fepois:fixef_rmdefaults toNone, which keeps fixed-effect singletons as before and raises aFutureWarningwhen a fit kept any. From 1.40 the default is"singleton"(drop them), the default of Rfixest, Statareghdfeand pyfixest. See MIGRATION.md.sp.crossval's StatsPAI engine now drops singletons, as its fixest and pyfixest engines do, so all three fit the same sample.
Multiple estimation and two-way clustered GLMs run natively¶
Changed¶
sp.feolsmultiple estimation (y1 + y2 ~ ...,sw(),sw0(),csw(),csw0(),mvsw(), on the regressors or the fixed effects) returns the same list in the same order as before, fitted on the native engine when every model is one it fits on complete rows; otherwise the whole call still goes to pyfixest, so a list never mixes engines.sp.fepoisandsp.feglm(family="logit" | "probit")cluster two ways natively ({'CRV1': 'a + b'}).
Fixed¶
- ⚠️
sp.fepois/sp.feglmwith two-way clustering: when the inclusion-exclusion covariance has a negative eigenvalue, fixest sets it to zero (Cameron, Gelbach and Miller 2011); pyfixest does not for GLMs, so these standard errors could differ from fixest's (5% for a logit on the reference panel). The native fit applies the fix and matchesfixest::feglm/fixest::fepoisto 1e-6 (tests/reference_parity/test_feglm_native_fixest_parity.py).
sp.feols: two-way clustering, i() terms and IV with fixed effects run natively¶
The three most common sp.feols calls that still went to pyfixest now run
on the native engine. One million rows, firm and year effects, against R
fixest 0.14 (milliseconds, one thread / eight threads):
| call | before (pyfixest) | now | fixest |
|---|---|---|---|
vcov={'CRV1': 'firm + year'} |
584 | 66 / 66 | 121 / 92 |
y ~ x2 \| firm + year \| x1 ~ z |
500 | 119 / 110 | 214 / 180 |
y ~ i(rel, ref=-1) \| firm + year |
313 | 121 / 96 | 307 / 144 |
Changed¶
- Two-way clustering (
{'CRV1': 'a + b'}, Statacluster a b): Cameron, Gelbach and Miller'sV_a + V_b - V_abwith fixest's factors (smallestG, effects nested in any dimension left out ofK) and its eigenvalue fix; 48 configurations equal pyfixest's to 1e-6, the 24-cell fixest grid passes, and the two-way cell agrees with fixest to 3e-13. i(factor, ref=v)andi(factor, var, ref=v)(integer or string factor, explicit reference) expand to dummies named as before (rel::-3,rel::-3:var), insp.feols,sp.fepoisandsp.feglm.- IV with absorbed effects (
y ~ x | fe | d1 + ... ~ z1 + ...): 2SLS on the jointly residualised columns, with weights and every native variance; several endogenous regressors work (pyfixest refused them). The first-stage F is fixest'sivwald1. 24 cells (just / over identified, two endogenous, iid / hetero / one- and two-way clusters, weights) match R fixest to 1e-6 (tests/reference_parity/test_feols_iv_native_fixest_parity.py).
Fixed¶
- ⚠️
sp.feolsIV: the robust and clustered first-stage F differed from fixest'sivwald1when the panel had singletons (by 1% on the reference panel), because pyfixest's first-stage refit dropped them while the IV model kept them. The native fit uses the IV model's own sample.
Rust kernels installed by default; statspai-hdfe 0.9 runs every pass in parallel¶
pip install statspai now installs statspai-hdfe on every platform it
publishes a wheel for (Linux x86_64 / aarch64 with glibc or musl, macOS
x86_64 / arm64, Windows x64 / ARM64), through an environment marker, so pip
never compiles Rust; other platforms keep the NumPy / Numba paths. The
crate (kernel API 3; 0.9.0 on PyPI since 2026-10-18, 0.9.1 since
2026-10-20 with the same code and the musllinux aarch64 and Windows ARM64
wheels added; StatsPAI requires 0.9.1 from this release)
splits every pass over the rows into fixed chunks that
run in parallel and are added in a fixed order, so fits are bit-identical
at any thread count (checked at 1, 3 and 8 threads for feols, fepois,
logit and probit with two and three fixed effects).
Against R fixest 0.14 on the same CSV, eight threads, milliseconds
(benchmarks/hdfe/fixest_matrix.py, minimum of five runs; one thread is
in the section below):
| call, one million rows | StatsPAI | fixest |
|---|---|---|
sp.fepois, firm + year |
100 | 278 |
sp.fepois, clustered |
125 | 291 |
sp.feglm(family="logit"), clustered |
130 | 325 |
sp.feols, ten regressors, clustered |
110 | 174 |
sp.feols, worker + firm + year |
264 | 340 |
sp.fepois, worker + firm + year |
1316 | 2327 |
sp.feols, firm + year, iid |
59 | 49 |
StatsPAI is faster in 27 of the 30 cells at eight threads and in all 30 at
one thread. The three it loses are plain sp.feols on exactly a million
rows (iid, hetero, weighted): the regression takes 25 ms there, and the
SHA-256 provenance fingerprint of the input frame (56 ms, frames up to a
million rows) bounds the call. The fingerprint is kept as it is, because
replication packs compare it across versions: it now hashes the frame's
own buffer instead of a copy (the same digest), and
STATSPAI_PROVENANCE_HASH=0 skips it for loops of many fits on one frame
(data_hash is then None; explicit compute_data_hash calls are not
affected).
Added¶
statspai_hdfe.feglm_irls(..., family)(API 3): the IRLS loop for Poisson, logit and probit with absorbed effects;sp.feglm's native logit / probit use it when present and fall back to the Python loop on API 2.fepois_irlsis unchanged.rust/statspai_hdfe/src/par.rs: chunked, order-fixed parallel loops (scatter-adds into group sums, elementwise updates, reductions).
Changed¶
statspai-hdfe>=0.9.1,<1is a core dependency on the platforms above; therustextra remains for building it elsewhere. The Windows ARM64 wheel is built on the nativewindows-11-armrunner (cross-compiling from x64 fails without an ARM64 interpreter).- One- and two-fixed-effect demeaning, the cross-tab reweighting, and every per-iteration pass of the IRLS (working response, cross products, deviance, step-halving) run over row chunks in parallel. Rust results move in the last digits relative to 0.8 (association of the sums); the parity harnesses run without the extension, so no committed result changes.
sp.feols / sp.fepois / sp.feglm run on StatsPAI's own engine¶
sp.feols, sp.fepois and sp.feglm reached their numbers through
pyfixest, an optional dependency, so the fast native kernels never served
the most used entry points, and a plain pip install statspai could not
run them. They now fit plain calls natively (sp.fast.feols,
sp.fast.fepois, and a new binomial IRLS for logit / probit) and keep
pyfixest for everything else. Track A module 67 (panel GLM) no longer
calls pyfixest: the parity table counts 87 native modules and 2
third-party ones (was 86 and 3).
One million rows, firm and year effects, one thread, against R fixest
0.14 on the same CSV (benchmarks/hdfe/fixest_matrix.py, minimum of five
runs, Rust kernels installed), in milliseconds:
| call | before (pyfixest) | now | fixest |
|---|---|---|---|
sp.feols, iid |
260 | 62 | 81 |
sp.feols, clustered |
79 | 92 | |
sp.feols, 10 regressors, clustered |
176 | 293 | |
sp.fepois, iid |
487 | 342 | 424 |
sp.fepois, clustered |
367 | 468 |
Changed¶
sp.feols(backend=)defaults to'auto'(was'pyfixest');sp.fepoisgained the argument.'auto'fitsy ~ x1 + ... | fe1 + ...with numeric regressors,vcovin iid / hetero / one-way CRV1, nossc=and no extra pyfixest keyword natively, with the delegate's conventions (singletons kept unlessfixef_rm='singleton', rows with missing values dropped, the same degrees of freedom, p-values and R-squared). On 128 configurations (absorbed effects, vcov, weights, singletons, separation) coefficients agree with pyfixest to 2e-7 relative andsp.feolsstandard errors to 1e-6. Absorbed or collinear regressors, which fixest drops, still go to pyfixest.model_info['backend']says which engine ran. See MIGRATION.md.sp.fepoison the native engine iterates IRLS to a relative deviance change of 1e-10. At pyfixest's and fixest's 1e-8 stop the fixed effects, and with them the standard errors, can sit 1e-5 from the optimum; the native standard errors are within 3e-9 of it on every configuration above (pyfixest's within 1.2e-5, fixest's 5e-8 on the reference panel). It warns when it drops observations for separation, as pyfixest does.sp.feglm(family='logit' | 'probit')gainedbackend=(default'auto') and fits natively under the same conditions (weights=is the one extra keyword it takes natively;family='gaussian'stays with pyfixest). It followsfixest::feglm: observations in a fixed-effect group with only 0 or only 1 outcomes are dropped (with a warning), Fisher-scoring variance, fixest's small-sample factors, normal p-values (pyfixest used a t distribution). Againstfixest::feglmat tight tolerances on 32 configurations (logit / probit, one and two effects, iid / hetero / clustered, weights) coefficients agree to 1.3e-13 and standard errors to 1e-6; pyfixest's defaults sat 6e-5 to 1.4e-4 from fixest's on the same panel.
Fixed¶
- Provenance hashing no longer adds its cost to every fit: the SHA-256 of
the input frame (45 ms of a 110 ms
sp.feolson a million rows) runs on a second thread while the estimator works, with the same digest. sp.fast.feols(vcov='cr1'): the cluster scores are summed withbincountinstead ofnp.add.at, the cluster and fixed-effect codes are reused instead of factorised again, and the nested-in-cluster check is vectorised (it looped over rows in Python). 95 to 61 ms on a million rows; results bit-identical on 66 configurations.sp.fast.demeanno longer re-factorises codes that are already dense when no singleton was dropped, and returns them inDemeanInfo.fe_codes.sp.fast.fepoisno longer recounts each fixed effect's levels withnp.uniqueat the end of the fit (15 ms on a million rows).
Added¶
benchmarks/hdfe/fixest_matrix.py: StatsPAI against Rfixeston a matrix of models (OLS iid / clustered / hetero / weighted / ten regressors, Poisson, logit, a low-mobility three-way panel), sizes and thread counts, on the same CSV bytes, with coefficient and SE agreement.
Rust kernels: shippable, safe, and faster than fixest¶
The Rust kernels in rust/statspai_hdfe had never reached a user: StatsPAI
builds as a pure-Python wheel, no wheel of the extension was published, no
CI job built it or ran a test against it, and even the repository's own
environment did not have it. This release makes it a separate, optional
package and rewrites the kernels.
On one million rows, one thread (benchmarks/hdfe/RUST_KERNELS.md):
sp.fast.feols with firm and year effects takes 0.053 s (NumPy 0.144,
fixest 0.078); with worker, firm and year effects and low mobility 0.63 s
(NumPy 6.2, fixest 0.74); sp.fast.fepois on the latter 3.2 s (NumPy 23.9,
fixest 4.6). Coefficients agree with the NumPy path to 3e-9.
Added¶
pip install "statspai[rust]"installsstatspai-hdfe(crate 0.8.0, kernel API 2), published fromrust/statspai_hdfewith abi3 wheels (one per platform for every CPython >= 3.10). StatsPAI stays pure Python and falls back to NumPy / Numba without it.sp.fast.backend_info()says whether the kernels are in use and why not;sp.fast.set_num_threads()/sp.fast.get_num_threads()size their pool (initial size fromSTATSPAI_NUM_THREADS).STATSPAI_SKIP_RUST=1now actually disables an installed extension (the variable was set across CI but nothing read it).FeolsResult.fe_kernel/FePoisResult.fe_kernel(also into_dict()) andabsorb_ols(...)["fe_kernel"]record which kernel residualised..github/workflows/rust-hdfe.yml:cargo testand the Rust-path Python tests on Linux, macOS and Windows withSTATSPAI_REQUIRE_RUST=1; wheels on demand; publication to PyPI only on anhdfe-v*tag. It replacesbuild-wheels.yml, which had been inert since it was added.benchmarks/hdfe/rust_kernels_bench.py.
Changed¶
- New demean algorithm (
demean_2d,demean_2d_weighted): the projection is solved in the space of the FE coefficients, as fixest does (Berge 2018,berge2018efficient). Two FEs iterate on a sparse cross-tabulation, so a sweep costs the number of distinct FE pairs rather than the number of rows; with three or more, the two largest FEs form an exactly solved block inside an accelerated outer loop. Results agree with the NumPy path to within the stopping tolerance, not bit for bit. sp.fast.fepoisruns its whole IRLS in Rust with the FE structure built once and every column warm-started from its previous FE coefficients.sp.hdfe_ols,sp.Absorber,sp.demeanandsp.absorb_olsuse the Rust kernels for plain FEs (solver="map", no varying slopes) when they are installed.cluster_meatsums fixed blocks of clusters and adds them in order: the cluster-robust meat ofsp.regress(cluster=)is now the same bit for bit at any thread count (it varied in the last digits from run to run).sp.fast.feols,sp.fast.fepoisandsp.fast.event_studyraiseNumericalInstabilitywhen a regressor is absorbed by the fixed effects or collinear with the others. They relied on the inverse of the normal equations failing, which only happens for an exactly singular matrix; an absorbed regressor comes back from any iterative residualisation as rounding noise, and the estimates were then finite garbage.
Fixed¶
- Every Rust entry point validates its inputs and raises
ValueError; the crate was built withpanic = "abort", so an out-of-range FE code, a non-contiguous array orn = 0killed the Python interpreter. Weightedsp.fast.feolson data where singleton pruning drops every row did reach that path; it now raisesDataInsufficient. - The Rust Poisson IRLS added a silent ridge to a singular weighted design
and returned its coefficients; it now reports the design as singular and
sp.fast.fepoisraises. A non-converged inner residualisation warns (ConvergenceWarning). - The sort-plan cache of the weighted Rust dispatcher was keyed on the FE arrays' address, size and end values and could hand back another problem's permutation; the cache is gone with the sorted path.
Absorberreportedconverged=Truewhen any column had converged; it is now True only if every column did.- The Rust kernels ran on Rayon's global pool, which ignores
OMP_NUM_THREADS/NUMBA_NUM_THREADSand hangs in a forked child; they now run on their own pool, rebuilt afterfork. - Track C:
run_when_idle.shpins the Rust pool to one thread, andscripts/trace_perf_path.pybinds the crate's sources only to timings that loaded it (rust_loaded), so editing the crate no longer stales timings measured without it.
Panel R-squared, honest DiD on a varying base, rdrobust docs¶
⚠️ Correctness¶
sp.panellabelled linearmodels' R-squared as if it were Stata's. Aftermethod="fe","re"and"be"the "R-squared (within)" and "R-squared (between)" were linearmodels' 1 - SSR/SST quantities. On the panel ofexamples/notebooks/07-tutorial_panel_fethe between value of the fixed-effects fit was 0.4669 wherextreg, fereports 0.8658, and the within value of the random-effects fit was 0.4258 wherextreg, rereports 0.4624. They are now Stata's squared correlations, with an added "R-squared (overall)", and equal Stata 18e(r2_w)/e(r2_b)/e(r2_o)to 1e-14 on an unbalanced two-regressor panel (tests/test_panel_xtreg_rsquared.py). The old values stay inmodel_info["r2_linearmodels"];model_info["r2_convention"]says which definition a fit reports. Weighted fits,twoway,pooled,fdand the CRE methods keep linearmodels' (Stata'sxtreghas no counterpart to match). Coefficients, standard errors and the headline "R-squared" do not change.
Fixed¶
sp.honest_didreturned intervals for acallaway_santanna(..., base_period="varying")event study without a word. Its pre-treatment coefficients are one-period changes, not the pre-trend path the smoothness and relative-magnitude restrictions bound; on mpdta every M up to 0.0224 rejected zero. It now warns and setsattrs["base_period"] = "varying"on the table.sp.aggtecarriesbase_periodinto itsmodel_infoso the check also sees aggregated event studies.- The
sp.rdrobustdocstring gave the comb bandwidths asmin(mserd, msetwo)andmedian(mserd, mseleft, mseright). The selector computesmin(mserd, msesum)andmedian(msetwo, mserd, msesum)side by side, as R / Statardrobustdo and assp.rdbwselectdocuments.
Example notebooks numbered in reading order¶
Changed¶
- The fourteen notebooks in
examples/notebooks/carry a two-digit prefix giving a suggested reading order:01-statspai_vs_stata_5minfirst, the method tutorials as02-tutorial_regression_sethrough11-tutorial_hte_causal_forest, then12-reproduce_401k_doubleml,13-tutorial_figure_editorand14-statspai_vs_stata_live. Links inexamples/README.mdand between notebooks follow the new names; old links to the unprefixed files no longer resolve.
Evidence maps for dynamic panel GMM¶
Added¶
sp.validation_scopemaps forsp.xtabondandsp.xtdpdsys: model (difference / Anderson-Hsiao / system with its h and IV-equation variants), steps, robust, clustering, transform, collapse, GMM lag window, regressors, lags of y and gapped panels, with 37 evidence rows against Stataxtabond/xtabond2/xtdpd/xtdpdsys, Rplm::pgmmandpdynmc. Unreferenced configurations report as such (three or more lags of y, h(1),iv_equation='level', period dummies in difference GMM, iterated / CUE, two-step with clusters coarser than the unit).logit/probitmaps gained theperfect_predictiondimension: no reference covers'keep'.
Fixed¶
sp.xtabond(steps=2)is the two-step estimator, identical totwostep=True, butmodel_info["twostep"],["windmeijer"]and the method label said one-step. They now read the resolved step count (steps='iterated'is labelled "Iterated GMM").sp.regtable(vcov=)with a spelling no estimator knows (a typo) raises again instead of keeping every column's fit-time standard errors under a warning; a valid spelling a given estimator does not offer still warns per column.
Windows gate in CI¶
⚠️ Correctness¶
sp.ges: the tie margin that keepsi -> jandj -> i(equal scores in exact arithmetic) from being decided by rounding applied only against zero, not between candidates, so the choice still came down to the last bit. On a colliderX -> Z <- Ythe Windows full run (Python 3.12 / 3.13) picked the reverse edge and returned a complete undirected triangle with a spuriousX -- Y. Every comparison now needs the margin; a test that perturbs the score by 1e-13 reproduces the old failure and pins the fix.- Mixed-model standard errors (
sp.melogit/meglm/mepoisson/meologitand the variance components behindsp.icc): the observed information is now a Richardson-extrapolated central-difference Hessian (h = 1e-3) instead of a single stencil ath = 1e-4, whose rounding error (abouteps * |loglik| / h^2) differed by platform. The melogit ICC standard error moves from 7e-7 to 6e-10 of Stata's on macOS (it was 1.3e-6 on Windows, over the 1e-6 budget); fixed-effect standard errors move by 1e-8 to 5e-8 relative. Point estimates are unchanged. sp.testafterrobust='ewc'reports "more restrictions than cosine terms" before solving: with too few terms the restricted covariance is singular in exact arithmetic, and whether the solve noticed depended on the BLAS.sp.zinbandsp.hurdle(count_model="negbin"): when the NB2 dispersion estimate sits at its boundary (alpha * mean(mu) < 1e-4, the Poisson limit), the standard errors no longer come from inverting a Hessian that is singular in ln(alpha). That inversion returned rounding noise whose sign depended on the platform: a NaN standard error for ln_alpha on Linux and macOS, a finite one on Windows. Now ln_alpha's standard error is NaN everywhere (with the usual warning andmodel_info["nonfinite_se_terms"]), the other standard errors are computed with alpha held at its estimate (they match the zero-inflated or hurdle Poisson fit), andmodel_info["dispersion_at_boundary"]records it. Away from the boundary nothing changes.
Fixed¶
- Generated files (
docs/parity.md,docs/evidence_inventory.*, the Tier A fixture lock, the JSS freeze manifest) sortedPathobjects, which compare case-insensitively on Windows, so they came out in a different order there. They now sort by the POSIX path string. - Three tests assumed a POSIX platform or a full R library: the EViews
workbook fixture (Windows'
zipfilerewrites\\in member names, and pandas reads such files natively there), the NUL-truncation demonstration (pandas' Arrow string dtype keeps the NUL), and the agent-bench R oracle (skips without theEValueandjsonliteR packages). scripts/release_gate.pycompared its allowlist against Windows paths (src\...) and failed there.examples/gmethods_timevarying.pyprinted non-ASCII characters, which crash a cp1252 Windows console.
Added¶
.github/workflows/windows.yml: about 700 tests on Windows (Python 3.10 and 3.13) on every code push; a full Windows run on dispatch.
polars and Arrow input everywhere¶
Added¶
- Every
sp.*function accepts a polarsDataFrame/LazyFrame/Series, a pyarrowTable/RecordBatch, or any object with the Arrow PyCapsule stream or DataFrame interchange protocol, wherever it takes a pandas object (data=,newdata=, positional frames). They are converted to pandas at the call; results are unchanged, as a test checks onregress,logit,avg_slopesandcallaway_santanna. pandas arguments reach the function as the same objects. New extra:statspai[polars]. Guide:docs/guides/dataframe_input.md.
Frozen core API¶
Added¶
- The 32 everyday entry points (
sp.list_functions(core=True)) now have a frozen signature:tests/core_api_snapshot.jsonrecords them andtests/test_core_api_freeze.py(also a pre-push hook) fails on a change that breaks an existing call (a removed or renamed parameter without an alias, a changed default, reordered positional parameters, a new required parameter). Intended breaks are acknowledged againstMIGRATION.mdwithscripts/core_api_snapshot.py --write --ack. - Function cards and
x_statspaischemas carryapi_contract(frozen,deprecation-cycle,unstable,deprecated). sp.help()lists the core API first, grouped by task.
Changed¶
- The
stabletier is described as what is enforced (breaking changes only after aDeprecationWarningrelease), not as a locked signature.
Predictions, comparisons and slopes in the marginaleffects style¶
Added¶
sp.predictions,sp.avg_predictions,sp.comparisons,sp.avg_comparisons,sp.slopes,sp.avg_slopesandsp.hypotheses, with Rmarginaleffects's names and defaults (unweighted averages, normal reference;wts=True, df="model"gives Stata'smargins, and thensp.avg_slopesequalssp.margins). They work afterregress,logit,probit,cloglog,poisson,nbregandglm, differentiate through interactions and transformed terms, and take an analytic Jacobian for delta-method standard errors. Each table keeps the Jacobian, sosp.hypotheses(table, "b1 - b2 = 0")tests rows with their joint covariance. Against Rmarginaleffects0.32.0 (black box, lm / logit / probit / Poisson): estimates to 1e-8, standard errors to 2e-5 (R's numerical Jacobian). Conventions followed and pinned by tests:"sd"contrasts mean +/- sd/2 for every row; averaged ratios are ratios of averages; 0/1 variables are contrasts inslopestoo. Guide:docs/guides/marginaleffects.md.
sp.oaxaca threefold decomposition¶
Added¶
sp.oaxaca(...).overallcarries the threefold decomposition (endowments,coefficients,interaction, with SEs), from Group B's viewpoint as Stataoaxaca, threefoldand Roaxacareport it, matching Roaxaca0.1.5 to 1e-10. Before, a threefold had to be assembled from two calls with differentreference=. The gap came up while writing the benchmark's reference solutions.
CausalAgentBench v2: a runnable, pre-registrable agent benchmark¶
Added¶
- The benchmark harness is complete short of the API keys. 50 tasks
with named report fields (v1 asked open questions and was graded against
hand-written ranges computed on no data); frozen data files with a
SHA-256 manifest; gold answers computed by R reference implementations on
those files (
golds/reference.R), with acceptable variants and tolerances; an agent loop that executes the model's code in persistent Python / R sessions (StatsPAI blocked in the Python-stack cells, MCP tools added in the StatsPAI cells); deterministic grading, including a fabrication check (a submitted number that no tool output contains) and an invented-API check; the H1-H5 cluster-bootstrap tests. StatsPAI's reference solutions score 50/50 against the R golds. - The real-model path (Anthropic, OpenAI) is written but builds a client
only when
prompts/preregistration.lock.jsonverifies: OSF DOI, exact model IDs, and the hashes of every design file. The decisions left for the deposit are listed intests/agent_bench/BUDGET_RUNBOOK.md.
Configuration-level evidence for the likelihood models¶
Added¶
sp.validation_scopemaps forlogit,probit,poisson,nbreg,glm,tobit,qregandheckman: which vce x weights (x family / link, censoring, dispersion, exposure) cells a Stata or R reference ran, and which outputs it compared. 49 existing reference tests are now credited cell by cell;logitis covered in all 10 of its cells. Fits are routed to their map by the recorded call, not by the method label.- New references for empty cells: HC0 / HC1 of logit and poisson
(weighted and not) and HC0-HC3 of
sp.glmvs Rsandwich(1e-8);sp.poisson(exposure=)vs Rglm(offset = log(exposure)); tobit two-sided under robust / cluster /[pw]and right-censored under oim / robust / cluster, andqreg [pw=w]at three quantiles, vs Stata 18. -
sp.glmrecordsinformation(observed/expected) inmodel_info. -
Maps for
aipw,ipw,tmle,ebalance,g_computation,feolsandppmlhdfe. New references:sp.feolsover 24 cells (absorbed effects {none, one, two} x {iid, hetero, cluster, two-way} x weights) vs R fixest (1e-6, observed 1.2e-8);sp.aipw's default cross-fitted fit rebuilt by hand from the same fold draw (1e-9).ipwmaps carry a trim dimension (all references ran untrimmed),aipwreads whether trimming bit, andtmlerecordsmodel_info['library'](default/user), since every reference fit used a user-supplied glm library: a fit with the built-in Super Learner is reported as not covered.
Documented¶
sp.probit(robust='hc0' | 'hc1')differs from Rsandwich::vcovHCby 2-3%: R builds the bread fromglm's expected information, this package (like Stata) from the observed one.sp.glm(..., information='expected')reproduces R to 1e-8. Registered as a disclosure (T4).
One standard-error grammar: vce= everywhere, sp.vcov after the fit¶
Added¶
sp.vcov(result, vce)re-reports a fitted result under another standard-error choice without the caller rebuilding the call, andsp.vcov_compare(result, [...])puts several side by side (one row per request and term, with the ratio to the fitted SE). Both re-run the recorded call with onlyvce=changed, so the numbers are exactly whatsp.<estimator>(..., vce=...)returns, small-sample factors and refusals included. Before switching, the recorded call is replayed unchanged and must reproduce the original estimates to 1e-10; edited data, a different frame passed asdata=, and unseeded randomness are refused. Results hold their input frame weakly; when it is gone,data=supplies it. 158 estimators record their call. MCP toolvcov_from_resultexposes both on aresult_id.vce=on 49 estimators that only tookcluster=(the DiD family,tmle/ctmle/aipw/ipw/metalearner, the RD helpers,rlasso_effect(s)/rlasso_iv,xtabond/xtdpdsys,mixlogit,interactive_fe, the spatial GMM models and others).vce="cluster state"sets the estimator's own cluster argument; kinds it implements through other arguments map onto them (ipw:vce="robust"is the sandwich of Statateffects ipw,vce="bootstrap"the bootstrap;tmle:vce="bootstrap"); any other kind raises before anything runs. The schema lists, per estimator, which kinds it accepts.- Stata spellings where an estimator keeps its own
vcevocabulary:sp.feols/sp.fepois/sp.feglmreadvce="robust","cluster a b","cr3 a"(they reached pyfixest verbatim and died on an internalAssertionError);sp.rdrobust/sp.rdbwselectreadvce="cluster v"and"nncluster v"as Stata'srdrobustdoes;did_imputation,gardner_did,did_2x2,lm_lin,cnsreg,nls,series,thresholdandgelbachreadvce="cluster v". vce=onsp.rdbwselect, and passed through tordrobustbyrdbwsensitivity,rdbalance,rdplacebo,rdsummary,rd_robustness_tableandrd_flex. They usedvce="nn"whatever was asked; R'srdbwselect(vce=)changes the bandwidth. The bandwidths equalsp.rdrobust's for the samevceto 1e-13.seed=wherever a function takes a random seed (89 that only tookrandom_state=, andcausal_bandit(rng_seed=)), andcovariates=on the article-API wrappers that tookX=(xlearner,frontdoor,mediation,conformal_ite,partial_identification,rlasso_effects,rlassologit_effects). The house-style lint has aseedtheme and now reports zero legacy spellings in all ten themes; its ratchet is frozen at zero.
⚠️ Correctness¶
sp.regtable(..., vcov=)ignored regression weights and applied the least-squares formula to logit fits. It re-weighteddata_info['X']and the residuals itself: on a weighted fit the HC1 column read 0.120 whereregress(..., weights=, vce='hc1')gives 0.136, and on a logit it read 0.0078 where the robust SE is 0.276. Each column is now re-run throughsp.vcov, which also letsvcov=take any kind the estimator offers ('cluster state').sp.callaway_santanna(clustervars=)on a variable with a single value returned a standard error of about 1e-17. It now raisesDataInsufficient, asgardner_didanddid_imputationalready did.
linearmodels 7.1¶
Fixed¶
sp.panel(method='mundlak' | 'chamberlain'), and random effects with a regressor that does not vary within unit, raisedTypeErrorwith linearmodels 7.1: the private_f_statisticthey call lost itsroot_wargument. The model F statistic now comes from whichever signature is installed, so it is the one linearmodels itself reports in that environment. Under 7.1 its restricted model uses the transformed constant column, so the RE F statistic differs from 7.0's; coefficients, standard errors and the variance components are unchanged.- Two tests assumed linearmodels before 7.1: 7.1 counts the rank when the
design has unit means (the miscount
sp.panelworks around), and it refuses repeated (entity, time) rows.
MCP server: protocol 2026-07-28, a setup doctor, a faster start¶
Added¶
- MCP
2026-07-28clients are served beside the handshake ones. That revision droppedinitialize: every request names its protocol version and client capabilities in_meta, and servers must answerserver/discover. A client speaking only that revision probed the server, got "Method not found" and stopped. Now a request whose_metanames a version, or aserver/discoverprobe, is served statelessly:-32022with the supported list for an unknown version,-32602for a missing required_metafield,resultTypeand the server identity on every result,ttlMs/cacheScopeon list and read results (handles private and immediately stale), resource-not-found as-32602, and nosampling/createMessagewhile such a call runs.subscriptions/listenis acknowledged with an empty set (the lists never change) and closed on cancel or shutdown. Over Streamable HTTP these requests need no session, andMCP-Protocol-Version/Mcp-Method/Mcp-Namemust mirror the body (-32020, base64 sentinel decoded); the defined errors map to 400 / initializestill selects the handshake and now also negotiates2025-11-25;serverInfogainstitle,descriptionandwebsiteUrl.statspai mcp doctorresolves every StatsPAI entry in the Claude Code, Claude Desktop, Cursor, Windsurf, VS Code and Codex configs to the interpreter it runs, reports which StatsPAI each one imports, and runs an in-process round trip. On the maintainer's machine it found Claude Code and Claude Desktop both running 1.38.0 from the system Python.statspai mcp config --client ...prints a client entry that runs this interpreter by absolute path;--writemerges it into the JSON config after a timestamped backup.statspai mcpalone still starts the server.statspai-mcp --versionprints the version, interpreter and protocol revisions.statspai://handleslists the data and result handles the server still holds, so an agent whose context was compacted can recover them.- The packaged skill over MCP (Skills extension,
io.modelcontextprotocol/skills):skills/list/skills/getreturnstatspai-analysiswith its frontmatter and a SHA-256 manifest, andresources/readserves its files underskill://statspai-analysis/. server.jsonfor the MCP Registry (schema 2025-12-11) and themcp-nameownership marker in the README. The release gate keeps its versions on the package version (--fix-censusrewrites them). Nothing is published automatically.
Fixed¶
- The first
tools/listtook about 3 s: filtering by profile built the curated manifest, which loads the whole registry. It now takes 0.04 s; the list is unchanged. import statspaino longer imports the MCP server, andpython -m statspai.agent.mcp_serverno longer prints a runpyRuntimeWarning.sp.spec_curve(controls=['a', 'b'])crashed with a bareTypeError. A flat list of names is ambiguous, so it now raisesMethodIncompatibilitynaming both readings; a string inside the list is a one-column set, asfe=already treats it.call_functionacceptsname=(asdescribe_functiondoes) whenfunction=is absent, andtransform_dataruns the ops agents reach for first (filter/where,keep,mutate,winsorize) asquery,select,assignandwinsor; the lineage records the canonical op.data_provenanceon results fitted from a handle no longer repeats the root file record inside the lineage.
Agent-native review: what an agent reads, calls and is told to do next¶
An agent was given twelve analyses and only the package's self-description; a second drove the MCP server; a third read every result class. Where one of them reached a wrong conclusion or a dead call, the cause is fixed below.
⚠️ Correctness¶
sp.evalue_from_result(measure='SMD')took the raw estimate as a standardised mean difference, so the E-value changed with the units of the outcome (a DiD on log employment got 1.21 where the standardised value is about 1.67). It now divides the estimate, SE and interval bysd=(the outcome's standard deviation) and refuses without it.sp.paper/ the robustness battery pass the SD when they hold the data and otherwise mark the check as not run; the MCPsensitivity_from_resulttool takessd/measure.sp.honest_did(method='relative_magnitude')built its default grid in standard-error units (0 to 3 SE, about 0.02 on a log outcome), although M-bar is a unitless ratio; the table then "rejected zero" everywhere whilesp.breakdown_mwas 0.74. The default is now HonestDiD'sMbarvec, ten points from 0 to 2.sp.sar/sem/sdm/slx/sacacceptedalpha=and ignored it: every interval was 95%.
Fixed¶
result.next_steps()recommended calls that could not run or did the wrong thing:sp.rdbalance(covariates=...)(the argument iscovs),sp.hausman_test(result_fe, result_re),sp.blp_test(result)andsp.gate_test(result)without their data,sp.cate_summaryon a partially linear DML fit,sp.estat(result, 'ic')on an IV fit, an E-value of a DiD or RD estimate, andsp.sensitivity_rrpresented as the Rambachan-Roth interval (it is a linear-extrapolation heuristic; the steps, the audit and the remediation text now point tosp.honest_did/sp.breakdown_m). Steps now carry the fit's own column names and anexecutableflag; every executable step is run verbatim in a test.- MCP
next_callsmarked as ready failed when dispatched (6 of 11 in the audit):rdplot/rddensity/rdsensitivity/bacon_decompositiongot aresult_idthey do not read and no dataset; follow-ups got every alias of every column argument. A follow-up now gets the result handle only if it reads results, the dataset handle if it reads data, and only its own parameter names; readiness is checked by dispatching every ready call through the server in a test. Thenarrativeprinted Python dict reprs and never showed the interval. sp.rateon a cross-sectional causal forest ranked units by the forest's own predictions without warning. With no heterogeneity (n = 800, 400 replications) that rejected a nominal 5% test 36% of the time;sp.rate_splitrejected 0%. It now warns, as the fixed-effects path did (tests/reliability/rate_cross_section.py).sp.matchreported only the raw sample's balance, unlabelled.model_info['balance_after'](weighted matched means, raw pooled SD) andmax_abs_smd_before/_afterare added; the old table is labelledbefore matching.sp.rddensity:estimateis the CJM t statistic andse/ciare on the density-difference scale; the estimand text andmodel_info['scale']now say so (numbers unchanged).- Regression results (
sp.regress,sp.iv,sp.logit, ...) had noestimate/se/ciin the shared envelope. They report the coefficient named infocal_term(single endogenous regressor, recorded treatment, or only regressor) and otherwisenullwith a shortcoef_head. - Strict JSON: hand-written
to_dictoverrides (stochvol,ridge,mswitchand the other domain results that define their own) are sanitised at the standard level;np.bool_no longer serialises as the string "True";NaTisnull; dict-subclass results (PolicyTreeResult) no longer export{}. result_card()crashed on fits without standard errors (ridge,ets,bart,gp_regress,dlm,boxcox,shrinkage);sp.gardner_did(...).summary()without an event window andsp.etwfe(...).tidy()crashed; etwfe's tidy terms were duplicated across cohorts and had no p-values.- Did-you-mean on a misspelled keyword offered
se_methodforseedand nothing behindreject_unknown_kwargs(clustr). Synonyms across Stata / R / Python come first now (seed->random_state,cluster_var->cluster,gvar->g).sp.dml(model=...)with an unknown model names the closest one. -
402 parameters that existed in function signatures were missing from
sp.function_schema()and the MCP tool schemas (synth(covariates=),dml(model_y=),regtable(keep=),rdrobust(vce=), ...). Registry entries now take any signature parameter they do not list from the code and its docstring; 16 deliberate exceptions remain, each with a reason. -
MCP server: an argument the estimator cannot bind is refused with a did-you-mean (
cluster_varwas dropped and the fit returned unclustered SEs withisError: false), and alias spellings (cluster=,id=) on the curated tools bind instead of being dropped;execute_toolandstatspai runrefuse them too (CLI exit 4).preflightchecks the cohort column of staggered methods instead of failing the canonical Callaway-Sant'Anna call on a missingtreat;recommendkeeps its reasoning and gives a ready call for the top pick;pipeline_didpassesid; no data argument is marked required (a call with none names the four sources); thecoreprofile lists the data tools; matplotlib is headless in the server (therdbwsensitivitystage ofpipeline_rdfailed on macOS); unknown function and tool names get suggestions; remediation is phrased in MCP arguments; prompts takedata_idand leave no literal placeholders; private keys are stripped and per-observation arrays capped (arima 87 KB -> 5 KB); dict, dict-subclass and array returns honourdetail(pc_algorithmreturned a serialisation error,policy_tree37 KB atminimal);sensitivity_from_resultwithoster/cinelli_hazlettworks from aregresshandle and says what else it needs otherwise. sp.california_prop99()(top level) is the simulated synthdid-layout panel andsp.datasets.california_prop99()the real ADH data; the top-level helper now carries the simulated flag and warns, naming the real one. The catalogue quoted the simulated panel's SDID parity number (-17.90) for the real data, where SDID is -15.60.- Auto-generated card examples were setup lines when the docstring
called the function under another name (
test_calibration:n = 400); they are now calls of the function. sp.rate_split/sp.forest_policy_treerepeated a data warning once per half of every split (42 times per call); each is now shown once with its count.sp.iv.iv_diaglabels the tF critical value with the effective F it is computed from, not the classical first-stage F.
Added¶
load_data(dataset=...)in the MCP server loads a built-in example as a data handle, flagged when it is simulated.synthanddmlare curated MCP tools in the default profile (not incore, whose byte budget is spent). They were named in the core list but, not being curated, were never served; until now they were reachable only throughcall_function.- Per-observation arrays capped at 20 values in a tool payload are
readable in full from
statspai://result/<id>/arrays/<path>while the handle is live; eacharrays_cappedentry carries that URI (or says to re-run withas_handle=true). Before this nothing in the MCP surface returned the rest of a capped array. sp.list_datasets()andsp.load_dataset(name)at top level (searchable in English and Chinese). Six built-in datasets are simulated replicas named after real ones (mpdta,nsw_dw,basque_terrorism,german_reunification,angrist_krueger_1991,currency_union_panel); every frame now carriesattrs['simulated'], the catalogue lists columns, and the first load of a replica in a process warns that estimates on it are not findings about the real data.
Figure editor (sp.interactive) rebuilt¶
The editor promised that data stay locked, that every edit has
reproducible code and that undo works. A probe on StatsPAI's own event
study, binscatter, RD and coefficient plots found all three broken. Each
edit is now a named operation with typed parameters; one definition gives
the live edit, the generated code and the saved-style replay. The tests
check, pixel for pixel on every operation and on random sequences of
operations, that the generated code reproduces the figure, that undo and
reset restore it, and that a saved style replays identically. Guide:
docs/guides/figure_editor.md.
⚠️ Correctness (data protection)¶
- Errorbar point estimates and their caps were classified as reference
lines (unlocked), and the axis-limit guard read error bars and
fill_betweenbands at their offsets, (0, 0).set_ylimcould hide confidence intervals, error bars and bars without a warning. On thempdtaCallaway-Sant'Anna event study it protected (-0.046, 0.010) while the intervals span (-0.074, 0.031). Roles now come from matplotlib's containers, and the guard uses the data limits of lines, bands, error bars, bars and images, on log and inverted axes too. - A data series whose label contained "treatment" or "baseline" was unlocked. Labels can now only lock an artist; only geometry makes a reference line.
- Locked data could be made fully transparent or invisible. Alpha stops at 0.2 (data, fits) and 0.05 (intervals) with a warning; a style that leaves a locked series with neither line nor markers raises.
Fixed¶
generate_code()reproduced the figure for 1 of 16 edit types tested. It appended afig.tight_layout()the editor never applied, wrote font and theme edits as global settings (which do not change a figure already drawn), kept only the last of several legend edits, placed a repeated edit at its first position (sogrid(False)came out beforegrid(True)), and named subplotsax1,ax2without defining them.undo()andreset()did not restore tick label sizes, fonts, legend changes, themes ortight_layout(8 of 16 edit types).- Font, font-preset and theme edits changed matplotlib's global settings, so every later figure in the session changed too.
- Replaying saved edits copied each record's
codestring into the generated code. It is now regenerated from the operation; incoming code is never used. sp.interactive(fig)on a non-interactive backend (Agg, servers, agents) added seven button axes to the figure and moved its bottom margin from 0.11 to 0.22. It now leaves the figure alone; with a GUI backend the controls open in their own window.set_titlereset the title's size and weight to the defaults.- Jupyter panel: after undo, redo, reset or a subplot switch the controls showed stale values; the tick-size slider always started at 10; warnings from an edit were lost.
- Script editor: selecting a text element recorded an empty edit.
- Running every
sp.*plotdocstring example through the editor (49 figures from 37 functions, plussp.counterfactual_plotandsp.network_plotby hand) found three more cases, now fixed: categorical axes (sp.synth_sensitivity_plot) crashed the classification; recolouring to black turned a histogram's transparent bar edges into visible frames (sp.rdplotdensity,sp.synthdid_rmse_plot); and the period shading ofsp.synthdid_plot, whose transparency is the time weight, was editable. Shading whose alpha varies across same-coloured spans is now locked data. After the fixes every figure passes the three checks. - Recolouring or restyling a series left its legend entry in the old look (matplotlib's legend keeps copies made when it was drawn), so the legend could name the wrong series. Legend entries are now rebuilt after every such edit, and the generated code does the same.
Added¶
FigureEditor.set_series_color/apply_palette/color_groups: recolour a series together with its error bars and band. Palettes includeokabe_itoandgrayscale.set_legend_labels,set_suptitle,set_markersize,set_text,set_text_visible;run(op, **params),operations()(typed schema of every operation) andbatch().check()(text under 6 pt, series indistinguishable in grayscale, series in the background colour),preview(grayscale=True),export(stem, formats=...);save()embeds TrueType fonts in PDF by default (embed_fonts=True).sp.interactive(fig, actions=[...], ui=...)for agents and scripts.- MCP
plot_from_result(edits=[...]): an agent restyles a result's figure with the same actions; the reply addsedit_code,checkand the data-protectionnotices. Its tool description is shorter, so thecoretool list stays inside its byte budget. - MCP
transform_dataopmicenever worked: it read a result attribute thatsp.micedoes not have (so it always reported "no completed datasets"), and withcolumnsit passedvars=, whichsp.micedoes not take. It now returns the first completed dataset; withcolumnsonly those columns are filled. The seed defaults to 0 so a handle's lineage replays to the same data. sp.refresh_legend(ax): rebuild legend entries from the current style of their artists, exactly as a legend drawn now would look.- Jupyter panel, Layout tab: "Text on the plot" lists the axes' texts to reword (annotations) or hide (any text); numeric result labels drawn by the plotting function show their wording greyed out.
examples/notebooks/tutorial_figure_editor.ipynb: a Chinese tutorial on editing a paper figure (locked roles, actions, limit protection, series recolouring, reproducible code checked on a fresh figure, undo, pre-submission check, font-embedded PDF, reusing a style).- Saved styles use a version-2 format (
op+params); version-1 files are translated.
sp.stata: stored results after estimation, checked against Stata 18¶
⚠️ Correctness¶
e(df_m)afterxtreg, re(and the defaultxtreg) was 3 where Stata stores 2, the number of regressors. Afterxtreg, feit is now set to Stata's k + G − 1 (k − 1 with a clustered or robust variance, as Stata does), and afteraregandreghdfeto k.
Fixed¶
e(cmd),e(cmdline),e(depvar),e(model),e(vce)ande(vcetype)are set after 22 estimation commands with the strings Stata 18 stores for each variance option (e(cmd)isregressafterreg). They were either unset or the first word typed. Commands not yet checked against Stata leave them unset.lincomstoresr(estimate),r(se),r(t)orr(z),r(df),r(p),r(lb),r(ub)andr(level)as Stata does.display 1 2concatenates its items, as in Stata;display _N 5no longer reads_Nas_newline."ab"*2repeats a string (count truncated toward zero), andgen str name = expcreates a string variable.sp.bootstrapwith fewer than 10 requested replications said "Only 5/5 bootstrap replications succeeded"; it now says that at least 10 are needed.
Option values checked against R and Stata¶
sp.validation_scope showed which configurations of the core
estimators had been compared with a reference. Counted per option value
rather than per grid cell, most gaps were in options users reach for
after the default: every sp.etwfe standard error, the
caliper / kernel / radius / stratify forms of sp.match, the HC2 / HC3
/ CR2 / jackknife / Conley / wild variances of sp.regress and
sp.iv, and the binned, absorbed and staggered forms of
sp.event_study. These now have reference fixtures produced by running
R 4.5 and Stata 18 on committed data, and comparing them found the
defects below. Default calls on the main path return what they did.
⚠️ Correctness¶
sp.etwfe(xvar=...)on the linear model fit a pooled cohort × post model with unit effects, ignoredcgroup='nevertreated', had no event or calendar aggregation and treated a categorical moderator as continuous. It now fits Retwfe's design (moderator demeaned within cohort × period cells, interacted with every treatment cell and every period; one slope per level of a categorical moderator). On the option fixture the simple ATT moves from 1.4957 (SE 0.0906, both control groups) to 1.5691 (SE 0.1016) with not-yet-treated and 1.5248 (SE 0.1469) with never-treated controls, equal toetwfe::emfxto 1e-14 (SE 1e-10).sp.event_studyremoved the two-way fixed effects with one pass of unit means and one of time means, which is the two-way projection only on a balanced panel with weights constant within a unit. On unbalanced panels the event-time coefficients were off by up to 0.035 againstfixest::feols, and by up to 0.15 with time-varying weights. Those cases now use the exact weighted projection; balanced-panel results are unchanged to the bit.sp.did_imputation(fe=...)imputed a treated row whose fixed-effect level has no untreated observation at the reference level's effect (mpdta,fe=['countyreal', 'state#year']: ATT −1.118, no warning). It now raises, as Statadid_imputationdoes;autosample=Truedrops those rows. A customfe=without a unit effect no longer trips the unit-coverage check.sp.match(bias_correction=True)fitted the bias-adjustment regression unweighted on all controls. It now uses the matched controls weighted by how often each is used (Abadie-Imbens;Matching::Match(BiasAdjust=TRUE)): 1.740 → 1.700 on the fixture.sp.matchwith a caliper: the psmatch2 and Abadie-Imbens standard errors counted treated units left unmatched. At caliper 0.01 the psmatch2 SE goes from 0.3546 to 0.3636, Stata's number.sp.match(se_method='abadie_imbens_2016', caliper=...)now requires, asteffects psmatchdoes, max(m, h) opposite-arm and h same-arm units within the caliper for every unit (ATT and ATE), and raises otherwise; it used to report a standard error where Stata refuses.sp.match/sp.psmdefault standard error with ties in the score. The matches kept every tied control (ties='all'), but the same-arm neighbours that estimate sigma^2(X) were taken in row order, so shuffling the rows moved the standard error (1176 to 1198 onMatchIt::lalonde, estimate fixed). They now keep every tied unit too, which is Statapsmatch2 , ai(1) ties: 1181.665 → 1178.209 on Lalonde and 643.346 → 640.405 on NSW-DW, equal to Stata (set type double) to the last digit and the same in every row order.ties='first'still reproducespsmatch2 , ai(1)withoutties.sp.callaway_santanna(panel=False)withestimator='stdipw'or'ipw_abadie'never reached the DRDID repeated-cross-section estimators and returned the unconditional cell-mean DiD (mpdta withlpop: simple ATT −0.03995, SE 0.0474 for both). They now return −0.04178 (SE 0.1672) and −0.04184 (SE 0.7218), as Rdid.sp.callaway_santanna(panel=False, bstrap=True)bootstrapped the ATT(g, t) cells but kept the analytic SE for the headline and the event study while reportingse_method='multiplier';sp.aggtedid not inherit the bootstrap. Both now use the same multiplier bootstrap, as on panels.sp.honest_did(method='smoothness')atM = 0now returns the closed-form FLCI (the minimum-variance estimator unbiased under a linear trend, ± z·sd). The numerical search reached the half-length but missed the centre by up to 1.4e-3 of it on correlated covariances; Track A module 10 moves by 2.2e-9, now 4e-16 from R's closed form.sp.did_multiplegt_dyn(continuous=)now followsDIDmultiplegtDYNand Statadid_multiplegt_dyn: groups share one baseline and the treatment becomes the switch indicator, the baseline polynomial enters as controls (so the analytic variance carries the slope-estimation term),normalized=and theAv_tot_effdivisor read the original treatment, and Design Restriction 2 applies. Estimates used to match only when every group switched. On option fixture 97 the effects move from 0.6098 / 0.6432 / 0.6496 to 0.6203 / 0.6205 / 0.5966, the SEs from 0.0857 / 0.0934 / 0.0988 to 0.0653 / 0.0732 / 0.0788, andAv_tot_efffrom 0.7202 to 0.7879, equal to Stata and R. Calls withoutcontinuous=are unchanged.sp.ivreg(vce='conley')andsp.ivreg(cluster=[a, b])reported a standard error of 0 (t = inf, p = 0) when the non-positive-definite covariance had a negative variance. They now report NaN with a warning, as Stataacregandivreg2report it missing. Finite standard errors are unchanged.
Changed¶
sp.honest_didraisesDataInsufficientwhen the event study has no pre-treatment coefficient; it returned θ ± z·SE with a warning that named the wrong cause.backend='r'withhonestdid_method='C-F'works (R printed a line before the JSON) and unreadable R output raisesConvergenceFailurewith R's output.sp.spec_curverefuses an unknownse_typesname (it was computed as HC1, or classical withfe=, under the unknown label, sose_types=['classical', 'hc1']reported HC1 twice) and'cluster'withoutcluster_var(dropped silently).'classical'and'iid'now mean the nonrobust variance.sp.feols,sp.fepoisandsp.feglmgive the same numbers in every process. pyfixest orders the fixed effects through a set of names, so the sweep order of its projections followedPYTHONHASHSEEDand clustered standard errors differed between runs of one call (4e-9 relative one-way, up to 1.7e-6 two-way); the written order is now kept.sp.feolsIV fits are about 10× faster on large panels: the first stage uses the model's compiled demeaner and is fitted once (1,000,000 rows, two fixed effects: 21.6 s → 2.1 s). Coefficients, SEs, Sargan and Wu-Hausman are unchanged bit for bit; the first-stage F moves by at most 5e-15 relative.sp.match(distance='mahalanobis' | 'euclidean')finds neighbours with a k-d tree instead of the full distance matrix (100,000 units, five covariates: 173 s → 5 s; RMatching::Match100 s). Every reported number is bit-identical on 1,495 designs with ties, calipers, multiple matches, ATT / ATE and bias correction.- Agent cards: the real-call audit covers 70 entry points and also calls
each by its alternative route. It fixed arguments wrongly marked
required (
xtnbregformula/entity,psmatch2covariates), 13 failure modes naming exceptions that are never raised, staledid_multiplegt/did_multiplegt_dyn/xtnbregtext, and family statements that did not apply; a quoted'none'in a docstring choice set is kept as a choice (gsynth(inference='none')and 14 more). - Every
sp.did_multiplegt_dynoption value is pinned against Stata (option fixture 97: effects, placebos, analytic SEs,e(V), Wald tests,control='never_treated'for the first time), with a newsp.validation_scopemap.sp.sdidplacebo and bootstrap SEs are screened (S, five seeds on each side) against Rsynthdidand Statasdid; thetreated_unit=jackknife SE is registered as T2. sp.validation_scopegains anhonest_didmap and per-value R / Stata references forcallaway_santanna(drimp,ipw_abadie,notyet_cutoffSEs, anticipation, the pairwise unbalanced-panel rule); the map had not matched any repeated-cross-section fit.sp.feols,sp.fepoisandsp.feglmask pyfixest for its compiled demeaner unlessdemeaner_backend=is given. The default numba demeaner took 108 s on 2 million rows with 100,000 firm effects; the compiled one takes 2.6 s, with the coefficient Rfixestgives to 1e-15 (SE 2e-11).- The nearest-neighbour residuals in the
rdrobustbandwidth selector are computed once per tie run, vectorised; results are bit-identical (checked on 1,500 designs with and without ties).sp.rdrobuston 1 million observations takes 5.3 s, from 15 s (Rrdrobust: 6.4 s). sp.match(method='nnmatch', caliper=...)warns when a unit has fewer thann_matchesadmissible matches (teffects nnmatchstops there).
Added (evidence)¶
- Reference fixtures, each read by a test and registered in
sp.validation_scope: sp.etwfe/sp.etwfe_glm: estimate and SE for every option value (both control groups,panel=False, weights under bothagg_weightsrules,xvar, controls, poisson / logit) against Retwfe, andresponse_se='unconditional'/exovar()controls against Statajwdid. The 1e-5 GLM SE gap previously filed as a small-sample convention was reference noise (fixest'sglm.tol, its penultimate-iterate bread, a forward-difference Jacobian); the SEs agree to 1e-9.sp.match/sp.psmatch2: 1 to 10 neighbours, Mahalanobis, kernel (five kernels), radius, stratification, caliper and bias correction against Statapsmatch2,teffects psmatch/nnmatch,attsand RMatching(48 Stata fits at 1e-9).sp.regress/sp.ivvariances: HC0–HC4, CR1–CR3, jackknife, Newey-West, two- and three-way clustering, Conley, enumerated wild bootstrap, LIML / Fuller with and withoutsmall, against sandwich, clubSandwich, fixest, fwildclusterboot and Stataivregress/ivreg2/acreg/newey/boottest.sp.event_study(staggered adoption, other windows and reference periods, bins, intensity, absorbed effects) against fixest;sp.did_imputationcustom FE, mixed covariates and weights against Statadid_imputation;sp.sun_abrahamlast-cohort control andwindow_rule='reference'againsteventstudyinteractand fixest;sp.gardner_did(vce='stage2')against R.sp.rdrobustcomb bandwidth selectors (sharp and fuzzy) against R and Statardrobust;sp.rddensitystandard errors against R;sp.dmlat 2 / 3 / 10 folds, IV-type and ATTE scores, trimming andn_rep=3against DoubleML; classicalsp.synthwith nested and regression V on the identified design.- Documented differences, not changed: weighted CR2 follows Stata,
0.53% from clubSandwich's Pustejovsky-Tipton form; for multiway
clustered IV
sp.ivzeroes negative eigenvalues (as fixest) whilesp.ivregreports ivreg2's raw matrix; undern_rep > 1the aggregated DML standard error is a convention on which R and Python DoubleML disagree with each other; with a caliper, RMatching's standard error (0.2904) is not reproduced by any variant tried (ours 0.3047, psmatch2 0.3186).
README banners¶
Changed¶
docs/logo/readme-1.pngat the top of both READMEs is the full banner again (3344x1882) with the new logo: wordmark, tagline, causal diagram, code sample, six feature tiles, method strip and QR code. The compact banner with the new logo and a "Star on GitHub" QR code is added asdocs/logo/readme-footer.pngabove the License section.- The language switcher reads "English" without "(default)" in both READMEs.
CI¶
- The nightly full test matrix had failed since 2026-10-13 on three
generated tables: the failure-mode table depended on how
ast.unparsewrites comprehension targets (3.10 against 3.11+), a recovery hint named functions withoutsp., and the silent-except baseline was looser than the code.
[1.39.6] — 2026-10-14¶
A review of everything that landed in October, and the work it found
unfinished. About two hundred functions added this month were called on
degenerate, mistyped and reordered input and checked against identities
that must hold; a third round of the option scan covered inference,
iv/, panel and time series; and sp.stata was compared with Stata 18
on 273 snippets. Results that change and should be recomputed include
sp.sun_abraham and sp.stacked_did on integer time with gaps,
sp.gardner_did with unidentified cells, the Kleibergen-Paap and
Sanderson-Windmeijer statistics with several endogenous regressors,
sp.cate_gain_curve with tied scores, the default-prior check of the
Bayesian IV, SUR and mixed models, predict() on unseen factor levels,
and collapse [fw=], [pw=] standard errors and margins, post in
sp.stata. New: weights_as= on thirteen regression functions, a
smaller MCP tool list with an HTTP transport, and Chinese queries in
sp.search_functions. Default calls on ordinary data are unchanged; all
89 Track A modules reproduce their committed results. MIGRATION.md lists
what changes. The JSS manuscript stays anchored to 1.39.1.
README editions¶
Changed¶
README.mdis English again. The English edition moved back fromREADME_EN.mdtoREADME.md, and the Chinese edition moved toREADME_CN.md. GitHub now opens on the English text. The PyPI long description is unchanged in content (pyproject.tomlreadsREADME.md). Links toREADME_EN.mdon GitHub no longer resolve.- New README banner.
docs/logo/readme-1.pngis redrawn as a compact vector layout rendered at 3870x1200: the logo and wordmark, the tagline "Agent-Native Causal Inference for Python", and a QR code on the right that links to the GitHub repository. In the logo, the upper-right and lower-left edges of the hexagon are dimmed so the bright edges trace the S of StatsPAI.
Pre-trend messages that claimed more than the test shows¶
⚠️ Correctness¶
sp.bjs_pretrend_jointaccepted a result from any estimator. It reads the leads from the result's event-study table and tests them against a covariance bootstrapped fromsp.did_imputationrefits. A Callaway-Sant'Anna result has the same table, so the call returned a statistic that pairs one estimator's leads with another's covariance. It now requires asp.did_imputationresult. It also refuses agroup=that does not identify units (the cohort column, the time column); that mistake used to surface only after every refit failed, as "0 / 300 replications succeeded". When refits do fail, the message now carries the reason. Both mistakes were in an example of the packaged skill.
Fixed¶
- The DID diagnostic battery called a non-rejection "Parallel trends
supported".
sp.diagnose_resultprinted that for any pre-trend p-value above 0.05, while the agent summary of the same result warns below 0.10, so a p of 0.098 came back both supported and likely violated. Not rejecting is not evidence of parallel trends. The verdict now says the test did not reject, flags p below 0.10, and points tosp.honest_didandsp.pretrends_power. The same applies to the per-coefficient check. Pass / fail flags are unchanged.
sp.stata checked against Stata 18, snippet by snippet¶
273 short do-file snippets were run in Stata 18 and in sp.stata, and
every number compared. The textbook log replays had covered the common
paths; these are the places they did not reach. Each fix below was
checked in Stata before the code changed, and the tests quote Stata's
output next to each assertion.
⚠️ Correctness¶
collapse ... [fw=]ignored the frequency weights. Means, sums, standard deviations, counts and percentiles were unweighted. The weight clause was stripped beforecollapsesaw it.logit,probit,poisson,nbreg,cloglog,ologitandoprobitwith[pw=]used the small-sample factor N/(N−k) where Stata uses N/(N−1) (logit yb x1 [pw=w]: .0988873 against Stata's .0980456). Only the translation was wrong;sp.logit(weights=, robust='robust')called directly already matched Stata.margins ..., postignoredpost, so_b[],_se[],testandlincomafterwards still read the model. It now posts the margins under Stata's names.margins, predict(xb)after logit returned the mean probability instead of the linear index.marginsafter a weighted logit or probit, which was refused, now works.round()rounded negative halves away from zero and returned missing forround(x, 0); it is Stata'sy*floor(x/y + .5).string(),displayformats and numbers written into macros follow Stata's%g,%f,%e,%0Nand comma rules, checked on a grid of 2,340 values and formats.string(1/3)was.333333333where Stata gives.3333333, and%9.3fdropped the leading zero.- Text inside quotes is kept as written. Double spaces collapsed to
one, and a comma,
//or;inside a string literal was read as an option separator, a comment or a command separator. local mv' = 5` defined the wrong macro; macro names built from macros are expanded first.gen lx = L.xleft a helper columnx_L1in the data, which wildcards such asx*then matched.tsset t, delta(5)ignoreddelta, so every lag was missing.F.D.xandL.D.xwere refused.egen group()andegen tag()treated an empty string as a group.e(df_r)after a clustered regression was N − k; it is G − 1, ande(N_clust)is set.gsort -xput missing values first.gen byte,int,longstored values out of range, andgen strNdid not truncate.xtile, cutpoints()with repeated cutpoints gave other groups.- A scalar no longer shadows an unambiguous abbreviation of a variable.
Fixed¶
- Concatenating two string literals crashed;
capture { ... }blocks now set_rcand continue;date(s, mask, topyear)is supported;tempvarcolumns are dropped when the program ends;levelsofof a string variable feedsforeachwith values that contain spaces. ib(last),ib(first)andib(freq)are supported;i(numlist)ando.are refused with a message instead of a patsy error._b[1.g#c.x1]and_b[1.g]afteribn.resolve.- Input Stata rejects is rejected:
appendof a string onto a numeric variable withoutforce, factor variables with negative or non-integer levels,recodecombiningelsewithmissing. regexs()reading a match made by an earlier command is refused: Stata keeps the state of the last observation that command evaluated, which depends on itsifandin.
Two plots and one note¶
sp.best_subset(...).plot()raisedKeyError: 'step'; it draws the criterion of the best model of each size.sp.synth_ttest(...).plot()raised "Cannot extract trajectory data"; the result now carries the treated and synthetic paths.sp.match: the docstring says that the default standard error, which reproducespsmatch2'sai(J), breaks ties among same-arm neighbours by row order and so depends on it when the matching variable has ties (1174 to 1182 over four orderings of the Lalonde data), and thatse_method='abadie_imbens_2016'does not (1126.26 on all four). The default is unchanged: it is a parity row against Statapsmatch2.
What an agent sees before and after a call¶
Five gaps found by using the registry and the tool layer the way an agent does. No estimate changes.
Added¶
sp.search_functionsanswers a query written in Chinese. It returned[]for any of them: the ranker splits on whitespace and every description is English. About 190 Chinese econometrics terms are now rewritten to the English vocabulary the ranker already knows before it ranks, so the Chinese and English spellings of a query return the same list. A query with no known term still returns nothing.sp.list_functions(kind="function" | "class"). 402 of the 1,566 registered names are result, model and exception classes, which an agent reads about but never calls. The default still lists both.enumon 56 more selector arguments, among themsp.structural_break(method=),sp.survreg(dist=),sp.meglm(family=),sp.adjust_pvalues(method=)and thekernel=of fourteen RD functions. These are the arguments whose accepted values are stated only by the code (a validation in the body, a module constant, a callee the argument is passed to unchanged). Each list is re-read from that place bytests/test_enum_seeds.py.- Failure modes for 87 functions that had none, quoted from the typed
errors each function raises and the
recovery_hintwritten next to them (scripts/gen_source_failure_modes.py). They are taggedsourcein the card provenance and are used only where a function has no hand-written or inherited failure mode. - Tool calls fill an unset seed and say so. 171 functions default to
seed=Noneorrandom_state=None, so the same tool call returned different numbers each time. The dispatcher now sets the argument when the caller did not pass it, reports it asseed_injectedand carries it into thereplaysnippet.STATSPAI_MCP_DEFAULT_SEEDsets the value (default 42) or turns it off. A direct Python call keeps the library default.
Fixed¶
- Bayesian fits cited the probit sampler whatever model was fitted.
BayesRegressResultdeclared the keys of its whole family, and the result payload reports the first of them, sosp.bayes_bootstrap,sp.bayes_regress(model="normal"),sp.bayes_shrinkand the rest all saidcitation_key: albert1993bayesian.cite()was already right. Each fit now carries only the keys of its model (rubin1981bayesianfor the Bayesian bootstrap,gelfand1990samplingfor the normal model, ...). The horseshoe, which had no entry and fell back to the whole family, now citescarvalho2010horseshoeandmakalic2016simple, the two papers its docstring already references. The payload'smethodreadsBayesian <model> regression (<sampler>)instead of the class name.
Changed¶
- An unknown name is answered with the nearest registered ones.
sp.describe_function("callaway_santana")listed the first fifteen names in alphabetical order;sp.function_schema,sp.agent_cardandsp.support_tiergave no hint at all. All four now saydid you mean 'callaway_santanna'?and carry the candidates on the exception as.suggestions.
MCP server: smaller tool list, HTTP transport, handles that survive a restart¶
Added¶
statspai-mcp --transport http. The MCP Streamable HTTP transport on one endpoint (http://127.0.0.1:8765/mcpby default;--host,--port,--http-path), for clients that connect to a URL and for several clients sharing one server. A request is answered with JSON, or with an SSE stream of progress notifications followed by the result when the call carries aprogressTokenand the client acceptstext/event-stream.notifications/cancelledstops a running call, and the queue, orphan and wait limits of the stdio loop apply. The server validatesOrigin(andHoston a loopback bind) and refuses to bind a non-loopback address withoutSTATSPAI_MCP_HTTP_TOKEN. Standard library only. There is no TLS, handles are process-wide rather than per session, andsamplingis off under this transport; the guide says what that means for a shared deployment.STATSPAI_MCP_STATE_DIR. With it set, every result and data handle is also written to disk and read back on a memory miss, with its provenance, lineage and replay string. Aresult_idthen resolves after a server restart and after LRU eviction, where it used to answermissing_result_handleand force a re-fit of the whole chain. Unset (the default), nothing changes. The files are signed pickles in a directory that must be private to the server's user; one that group or others can write is refused with a warning. A TTL still counts from the fit, an entry written by another StatsPAI version is discarded on read, and a result that cannot be pickled stays memory-only.STATSPAI_MCP_STATE_MAX_BYTES(default 4 GiB) bounds each kind.completion/complete. Suggestions for resource-template variables (function names, guide families, live handles) and for theresult_idargument of prompts. MCP defines no completion for tool arguments, so column names are not offered.statspai://arguments. The long-form reference for the arguments every tool accepts.
Changed¶
tools/listis half the size. The ten arguments the server adds to every tool (data_path,data_id, inline tables,result_id,as_handle,max_output_bytes,detail, ...) carried paragraph-long descriptions, repeated in each tool: 97 KiB of the 146 KiB list under the defaultcuratedprofile. They now carry one clause each and the full text is served once, asstatspai://arguments, with a pointer in the session instructions.curatedgoes from 146 to 71 KiB andcorefrom 82 to 39 KiB (roughly 37 to 18 and 20 to 10 thousand tokens). Both pairs were measured before the enum additions described above, which add about 2 KiB to each list.data_columnsanddata_sample_nare still accepted by every tool but are listed only onload_data,describe_data,transform_dataandcall_function; the oversize-file error names them where they matter. No argument was removed and no result changes. The effect on how often a model picks the right tool has not been measured: the real-model benchmark stays off until its pre-registration.statspai-mcp --profile fullprints a warning with the size of the list it is about to advertise.
Fixed¶
- Fits made through the MCP server could record no source revision.
In a source checkout, the first fit reads the StatsPAI git revision by
running
gitonce and caches the answer for the process. Under the server that first fit runs on a worker thread, after numerical code, and on macOS the forkedgitchild can crash beforeexec; the revision was then cached as missing and every later result's provenance lacked it, with a crash dump on stderr as the only sign. Both transports now read the revision when they start, before any worker thread exists. Installed wheels never rangitand are unaffected. Library code that fits in its own threads can still hit this; the fix there belongs in_build_info, whose change would need the parity traces and Track C timing re-recorded, so it is left for a release window.
The packaged skill's examples are now run, not only parsed¶
Fixed¶
- Examples in the
statspai-analysisskill that bound to a real signature and still failed when run. The skill's gate checked that everysp.*name exists and every keyword is one the function takes. It never executed the code. Running the pipeline files on synthetic data turned up these, all in text an agent is told to copy. - The "verified skeleton", the econ pipeline and the method catalog
called
sp.oster_bounds(..., r_max=1.3).r_maxis an R-squared and the function rejects a value above 1. Oster's rule of thumb isr_max="1.3*r2". sp.bjs_pretrend_jointwas shown with a Callaway-Sant'Anna result andgroup=set to the cohort column. It needs asp.did_imputationresult and the unit id, and as written every bootstrap replication failed.- The epidemiology pipeline passed a TMLE risk difference to
sp.evalue(measure="RR"). A negative risk difference is refused and a positive one would have been read as a risk ratio. The example now refits withestimand="RR". - The econ pipeline and the skeleton computed an E-value from an OLS
wage coefficient with
measure="RR". That runs and returns a number that means nothing. They now passmeasure="OLS"with the outcome standard deviation. - In the econ pipeline
resultmeant three different objects. The E-value andsp.estatsnippets read.paramsfrom a result that has none, andsp.unified_sensitivitywas called withoutterm=on a model with several regressors. - Two robustness snippets dropped early cohorts with
first_treat_year > 2008. Never-treated units are coded 0, so that filter deletes the whole comparison group. - The McCrary figure was drawn with
sp.rddensity(...).plot(). That is the generic coefficient plot, and it raises on this result because the reported interval is for the density difference while the estimate is the test statistic. The figure comes fromsp.rdplotdensity. - Smaller ones. A codebook written into a directory that does not
exist, placeholder columns (
X,Z,y) inside otherwise concrete formulas, and synthetic-control calls on a worker panel that has no single treated unit (they now use the bundled Proposition 99 panel).
Added¶
statspai skill validate --runexecutes the quick path, the verified skeleton, the three pipelines and the export recipes, block by block in one namespace per file, on synthetic frames built inside the gate. A block that needs something the gate does not have opens with```python no-run. It is still bound against the signature, and a test caps how many such blocks there are (one today, the torch estimators). The verified skeleton runs in the default test suite. The full pipelines take minutes and are markedslow.statspai skill statussays whether an installed copy matches the skill packaged with the installed version. A skill in~/.claude/skillsis a plain copy, so it keeps describing the API of the release it was copied from.statspai skill installnow leaves a small record next to the copy, which letsstatustell a copy that fell behind from one that was edited by hand before--forceoverwrites it.- The binding check follows
sp.decompose(method, ...)to the function the method name reaches, as it already did forsp.routeandsp.power.
A review of October: options off the main path, and the month's new functions¶
Three pieces of work land together. The first is the third round of the
option scan (inference, the rest of iv/, panel, time series), which
wrote tests for branches no test reached and fixed what they found. The
second is a behavioural review of the roughly 210 functions added this
month: each was called on degenerate, mis-typed and reordered input and
checked against identities that must hold. The third is weights_as=,
finished on 5 October and never merged. Results of default calls on
valid data are unchanged except where an entry below says a number was
wrong. MIGRATION.md lists the calls whose numbers change and the calls
that now raise.
⚠️ Correctness¶
sp.sun_abrahamandsp.stacked_didon integer time with gaps. Both omitted relative time −1 as the reference period. On a panel observed every second year that period never occurs, nothing was omitted, andsp.sun_abrahamreturned 1.011 with a standard error of 0 where the same panel coded with consecutive years gives 2.023 (0.066);sp.stacked_didreturned NaN. Observed periods are now numbered by rank, as the date path already did, and the gapped coding equals the consecutive one exactly. An unevenly spaced grid is renumbered with a warning that event time counts observed periods.sp.gardner_didwithout a comparison for some treated cells. When a period has no untreated observation its time effect is not identified, and the first stage filled it with a minimum-norm solution: 2.537 on noiseless data whose identified cells average 1.8525. Those cells, and units treated in every row, are now dropped with a warning that names the periods, and the estimand is the ATT over the remaining cells. A cohort recorded as NaN is never treated, as in the other staggered estimators; it used to delete those rows. Onsp.dgp_did(staggered=True)data, which codes never-treated units that way, the estimate moves from 0.374 to 0.407, the valuesp.did_imputationgives.sp.etwfereads a cohort ofinf, or one later than the last period, as never treated. The first raisedOverflowError; the second returned a standard error of 9958.sp.did_multiplegtremoves rows with a missing outcome or treatment before differencing (a few of them used to turn the whole result into NaN), and reports NaN, not 0 with p = 1, when no group switches.sp.cate_gain_curvebroke ties in the score by row order. A constant prediction on data sorted by the true effect scored the AUC of the true ranking, outside its own bootstrap interval. Tied units now enter together; a constant score has an AUC of exactly 0.sp.iv.kleibergen_paap_rkandsp.iv.sanderson_windmeijerwith two or more endogenous regressors. The rk statistics were the Wald test of a zero first stage, and the conditional F partialled the other endogenous regressors out by OLS. Both now match Stata 18ivreg2(widstat,idstat, the Sanderson-Windmeijer F) on three specifications under three covariance estimators. TheKP rkandSW conditional Fentries ofsp.iv(...).diagnosticschange with them. One endogenous regressor is unchanged.sp.iv.iv_diag: a cluster passed as an array now reaches the Anderson-Rubin test and the effective F (it clustered only the standard errors), is aligned by position, and withabsorb=the CLR and K sets are computed on the demeaned variables.anderson_rubin_ci,conditional_lr_ci,k_test_cisort the grid; an unsortedbeta_gridreturned wrong endpoints.sp.panel(method='fd')differences consecutive periods of a unit whatever the row order. Unsorted rows, and a sorted panel whose first unit starts late, gave other estimates.sp.panel_logit(method='fe')omits a regressor with no within-unit variation (its coefficient was 4.7e6).sp.fisher_exact(cluster=, stratify=)permutes clusters within strata;stratifywas ignored wheneverclusterwas given.sp.bootstrap(ci_method='bca', block=)computes the acceleration from a delete-one-block jackknife, and by position when index labels repeat.sp.jackknife_serefits on the design the fit stored. Interaction and factor terms got NaN, weighted fits were refitted unweighted, and fits without an intercept were refitted with one.sp.ipwandsp.g_computationleave a bootstrap resample that holds one arm out of the standard error and count it.sp.aipwbuilds the training and prediction designs together, so a covariate constant within a fold no longer shifts the cross-fitted estimate.sp.local_projections:cumulative=Truenow regressesy[t+h] − y[t−1](it used the one-period change), a user-suppliednw_lagsis the truncation lag at every horizon, and the Cholesky band is ordered when a weight is negative.sp.garch(model='egarch', dist='t')forecasts beyond one step with the expected absolute value of the unit-variance t; the term was taken as zero, 13% at ten steps in the test design.sp.dlmcarries a coefficient with zero state variance back exactly in the smoother and holds it fixed within a Gibbs draw.sp.arimaomits regressors that repeat each other, the constant or the drift, and reports them inmodel_info['omitted']; the coefficient used to be split between them.sp.structural_breakandsp.cusum_testomit a redundant regressor (it moved the break date).sp.chow_test(time=)andsp.zivot_andrews(time=)sort by the time column.sp.tscvandDecompositionResult.forecastpassboxcox=andbiasadj=on to the benchmark methods; they were dropped.sp.forecast_accuracyreports MAPE and MPE as missing when an actual value is zero. It averaged over the other periods, so a forecast that missed a zero by 1 scored 0%.sp.factor_importanceon numerically coded discrete factors, the ±1 columnssp.factorial_designproduces, returned shares of 0 and 1 that changed with row order. The neighbourhood now takes every point tied with the k-th nearest, as the categorical case did.sp.mdp_policy_valueraises when the target policy takes an action that never occurs in a state it reaches (it returned 0.351 with an interval of (0.26, 0.45) where the value is 1.075). Its standard error no longer depends on how the states are labelled or on where the features are centred; the value itself was always invariant. With a linear basis the standard error moves by about 2% on a feature centred near zero. With the same feature shifted by 10 it used to average four times the Monte Carlo standard deviation, with 89% coverage of a 95% interval over 200 replications; it is now the same at both origins (92%, the standard error treating the nuisances as known).- Categorical covariates are expanded to indicators by
sp.pscore,sp.marginal_policy_effectandsp.implied_weights, which read acategorycolumn as one number. sp.interference_test(focal=<boolean mask>)read the mask as the indices 0 and 1.predict()on a factor level the fit never saw returned the prediction for the reference level, insp.regress,sp.logit,sp.probit,sp.glm,sp.poissonandsp.nbreg, and so insp.poststratify. It now raises and names the level.sp.write_data(df, 'x.dta')keeps the text of a Categorical column as value labels; only the codes were written..featherkeeps labels with integer keys, andsp.read_datareads.tsvby tabs.sp.influence_measuresrefuses a weighted fit (it mixed the unweighted hat matrix with weighted residuals).sp.itcvuses the weights of a weighted fit throughout.sp.cor_testcounts the rank of the covariates in its degrees of freedom.sp.standardize(formula=)leaves a variable insidelog(),I(),C()orfactor()alone, as its docstring says.sp.gee,sp.gam,sp.mswitch,sp.dlmomit redundant regressors with the usual note; they returned standard errors between 1e5 and 1e14.sp.gamalso omits a parametric term its own smooths reproduce (y ~ x + s(x), ordnext tos(x, by=d)).sp.bootstrap_edges(algorithm='ges')read the edge mark as a node name.
Fixed¶
- The default prior of
sp.bayes_ivreg,sp.bayes_sur,sp.bayes_mixed, the probit systems andsp.bayes_arimais checked after the fit, assp.bayes_regressalready did. With a regressor in thousands the N(0, 1000) prior pulled an IV coefficient to 244 where two-stage least squares gives 1437, without a word. sp.bayes_mixedcomputed its multi-chain R-hat on copies of the last chain;sp.bayes_surreported none.sp.gelman_rubin(fit)reads the chains of a multi-chain fit.sp.bmarefuses a constant outcome and warns on separation; predictions fromsp.bartandsp.gp_regressrefuse missing regressors; a DataFrame with repeated index labels no longer doubles rows insp.bayes_regress.- Complete separation in the shared propensity fit was reported as
converged.
sp.pscore,sp.full_match,sp.matchandsp.psmatch2now warn, andsp.pscoreno longer reports a block 0 orbalanced=Truewhen no block holds both arms. sp.bandit_allocateappliesprob_floorwhile topping arms up tomin_pulls, and warns when it has to assumesigma=1.sp.space_filling(qualitative=)balances each factor's levels when the run count is not a multiple of the level combinations.sp.split_datafalls back to twinning on data with few distinct rows.sp.nlcomparses_b[C(race)[T.2]].sp.ttestand others judge a zero standard error relative to the scale of the data.sp.ridge(lambda_=0)on a rank-deficient design returns the minimum-norm solution with a warning, and its constant-column guard is relative.NLSResult.predictand the result ofsp.thresholdpredict on new data.sp.nlsdetects an unidentified additive pair.- A DataFrame whose index labels repeat no longer breaks
sp.gam,sp.gee,sp.threshold,sp.series,sp.xthtaylor,sp.abnormal_returnsorsp.dlm. sp.geeandsp.gamcheck the outcome against the family.sp.unitroot,sp.arima,sp.garch,sp.var,sp.vec,sp.varsoc,sp.johansen,sp.johansen_lrtest,sp.engle_granger,sp.tvp_var,sp.structural_break,sp.chow_test,sp.ardlandsp.abnormal_returnsrefuse constant, exactly fitted, collinear or too-short input with a named error. Several returned a statistic computed from rounding noise.- The forecasting functions refuse a date index that is not increasing instead of fitting the rows in file order.
sp.hdfe_olsrefuses contradictory variance options (vce='robust'withcluster=reported the HC variance;se_type='iid'withcluster=labelled cluster errors as iid).sp.panelnames a missing entity, time or cluster id, and a one-unit panel, instead of failing inside the linear algebra.sp.panel_unitrootrefuses interior gaps and caps its default lag on short panels.- Many smaller cases across these modules where bad input
met a raw NumPy or pandas error, or
alpha,trim, a lag or a level outside its range was accepted, now raise an error that names the argument.
Added¶
weights_as=: say what the weights are.sp.regress,sp.panel,sp.hdfe_ols,sp.feols,sp.fast.feols,sp.iv/sp.ivreg,sp.glm,sp.logit,sp.probit,sp.cloglog,sp.poisson,sp.nbregandsp.fast.fepoisreport a model-based variance whenweights=is passed alone, which is right for analytic or frequency weights and far too small for sampling weights (14% to 81% coverage of a 95% interval intests/reliability/extreme_weights*.py). Stata settles this with[aw=],[fw=]and[pw=]; until now the only way here was to remember to add a robust option.weights_as='sampling'gives the same coefficients with the robust variance, bit for bit what the robust option gives by hand, and never overrides a variance the call asks for. Each function also accepts its own model-based reading,'analytic'for the linear estimators and'frequency'for the likelihood ones, which changes no number and silences the warning about dispersed weights. The other model-based reading is refused with the reason, andsp.hdfe_ols(weights_as='sampling')asks forcluster=because its weighted fit has no heteroskedasticity-robust variance. The argument is optional and calls without it behave as before.sp.ipw(n_bootstrap=0)returns the point estimate alone, with the standard error, interval and p-value missing.UnitRootResult.rho_leveland.rho_diff;model_info['omitted']onsp.arima,sp.gam,sp.gee,sp.mswitch,sp.dlmandsp.panel_logit;convergedandn_iteronsp.factor;sp.ridge(lambda_='gcv' | 'hkb' | 'lw').
Known and not changed¶
- The standard error of
sp.match(ties='all')depends on row order when the matching variable has ties; see the note onsp.matchabove. - The p-value of
sp.weighted_rankwithphi='quade'or'u868'moves in the third digit with the unit of measurement on rounded data, because block ranges that are equal in exact arithmetic are ranked after floating-point subtraction.
Sample size on results that recorded it as n_obs¶
Fixed¶
result.nobswas None andresult.n_obsraisedAttributeErroron results whose estimator records the sample size underdata_info["n_obs"]. Confirmed onsp.levinsohn_petrin(and with itsp.olley_pakesandsp.acf, which share the result class),sp.limlandsp.truncreg; a scan of the source finds twenty-five constructors that record it this way, in the gmm, panel, frontier, structural and regression modules. The base result class readsdata_info["nobs"], soto_dict()["n_obs"]was None as well. It now fillsnobsfromn_obswhen only the latter is recorded.data_info["n_obs"]is still there. No estimate changes. Reported from a replication of Levinsohn and Petrin (2003).
[1.39.5] — 2026-10-12¶
Five replications of published papers, and what each one turned up.
Three results change and should be recomputed: diversion_ratios() of
sp.blp was wrong by a share ratio, sp.lasso_select(method="cv")
cross-validated on leaked folds, and the rho reported by the
production-function estimators was not a persistence at the default
polynomial degree. Standard errors of sp.blp change when a random
coefficient is estimated at zero, and its outer loop is now gradient
based (20 seconds where it took 44 minutes). New: sp.ctree, the logit
and nested logit of Berry (1994) in sp.blp, sp.blp_instruments, and
sp.grf_calibration. MIGRATION.md has the details. The JSS manuscript
stays anchored to 1.39.1.
The calibration test on predictions made elsewhere¶
Added¶
sp.grf_calibration(Y=, W=, Y_hat=, W_hat=, tau_hat=, weights=, clusters=)is the regression behindsp.calibration_test, taking its inputs directly. It grades CATE predictions that did not come from the forest's own out-of-bag pass: forests fitted on other folds of clusters, a meta-learner, or predictions whose clustering is imposed afterwards (section 3.1 of Athey and Wager's 2019 application does this by overwriting fields of a fittedgrfforest). The function existed as an internal operator and was already tied togrf::test_calibrationat 1e-14; it is now exported, registered, and checks its inputs (lengths, missing predictions, negative weights, the level). Its covariance keyword isvce, as insp.calibration_test;vcov_typeis still accepted. Replicating that paper on its ACIC 2018 data showed the gap: the fold-out test had to be written by hand withsp.regress.
Production functions: units of output, persistence, and what the bootstrap says¶
Found by running sp.olley_pakes, sp.levinsohn_petrin and sp.acf on
the Chilean panel shipped with R prodest. Input coefficients on that
panel are unchanged to seven digits.
⚠️ Correctness¶
sp.olley_pakes/sp.levinsohn_petrin/sp.acf: log output in other units. Adding a constant to log output is a change of units and can only move productivity by that constant. Stage 2 carried the level through an uncentred cubic in lagged productivity, whose condition number was near 1e6 on real data. With 200 added to log output on a simulated panel, the LP capital coefficient moved by 1e-5 and the fit was reported as unconverged. Stage 2 now runs on output less its mean and builds the Markov basis on standardised lagged productivity. The coefficients, the convergence flag andrhono longer depend on the constant.productivity_process["rho"]anddiagnostics["ar_rho"]were the linear coefficient of the Markov polynomial, which is its slope at lagged productivity zero. Productivity carries the constant of the production function, so the data need not come near zero. On the Chilean panel this gave -5.79 for LP and -0.63 for ACF. They are now the slope of the linear projection of productivity on its lag (0.98 and 0.75 there), which equals the old value whenproductivity_degree=1. The polynomial is still indiagnostics["markov_coef"].
Fixed¶
stage2_convergedfor OP and LP was False on the Chilean panel at the default degrees although the estimate was the minimiser to 1e-9; rounding left the first-order condition at 1.01e-8 against a tolerance of 1e-8. A fit that does stop short now raises aConvergenceWarning. It was silent before.- A fit without
boot_repsdrew "Standard errors are not finite: the variance matrix is singular". No variance was computed.diagnostics["se_source"]now says"none (pass boot_reps > 0)"and the warning is kept for standard errors that were requested.
Added¶
- Bootstrap diagnostics for OP, LP and ACF:
boot_se_iqr(the interquartile range over 1.349) andboot_ci_percentile. A standard error more than three timesboot_se_iqrdraws a warning. On the Chilean panel the ACF moment conditions change root in a few resamples; one of 176 replications put the two labour coefficients at -82 and 112, and the reported standard errors were 6.3 and 8.5 against 0.12 and 0.15 for the bulk. The standard errors are not changed. sp.olley_pakesrecords the rows removed bydrop_zero_proxyindiagnostics["n_dropped_nonpositive_proxy"]and warns when some of them are negative, which means the proxy is in logs.- A gross-output call with the proxy among the inputs is still refused; the message now says why. In panels simulated under the Levinsohn-Petrin assumptions the moment conditions do not pin the capital and materials coefficients down (estimates between -42 and 34 for truths 0.2 and 0.5).
sp.blp with random coefficients, after a replication of BLP (1995)¶
A replication of the demand side of Berry, Levinsohn and Pakes (1995) on the public automobile data took 44 minutes to fit and had to compute its own diversion ratios and markups. The logit side of that work is the section below.
⚠️ Correctness¶
BLPResult.diversion_ratios()divided cross-price by own-price elasticities, which leaves out the share ratios_k / s_j. Rows summed to as much as 3.4 on the automobile data. It now divides share derivatives,(ds_k/dp_j) / (-ds_j/dp_j), and agrees with pyblp to 1e-7.include_outside=Trueadds the fraction that leaves the market. A product whose demand slopes up gets NaN and a warning.- Standard errors with a standard deviation estimated at zero. At
zero the derivative of mean utility in that parameter vanishes, and the
sandwich divided by it. On the automobile data three such parameters
had standard errors from 1.7 to 35 and inflated every other standard
error (the mean price coefficient 0.156, now 0.054). The parameter is
now held at its bound: its standard error is NaN, the others are those
of the model without that random coefficient, and
model_info['sigma_at_bound']names it.
Changed¶
- Outer loop. L-BFGS-B with the analytic gradient and
sigma >= 0replaces Nelder-Mead, and the share inversion is accelerated market by market. The main specification of the replication (five random coefficients, 500 draws, 2,217 products) takes 20 seconds, down from 44 minutes. Against pyblp on shared integration nodes the estimates now agree to 1e-12 (1e-8 before).optimizer='nelder-mead'keeps the old search. - Several starting points. The first GMM step runs from four starts
(
n_starts=4) and keeps the lowest objective. On the automobile data the single start at 0.5 ends in a local minimum 7% above the best one.sigma0=sets the first start. result.model_inforeports the starts and the objective each reached, the projected gradient and the number of objective evaluations. With a random coefficient on price it also reports the share of simulated consumers with a positive price coefficient, and the fit warns when products have a positive own-price elasticity. A fit whose outer loop did not converge raises aConvergenceWarning.
Added¶
x_randomaccepts"const", a random coefficient on the intercept. It governs substitution to the outside good. Checked against pyblp (X2 = 1 + x1) to 1e-5.BLPResult.markups()and.marginal_costs()solve the multi-product Bertrand first-order conditions, with firms fromfirm_id=orfirm_ids=. They work for the logit and nested logit as well. Checked against pyblp to 1e-6.
Not done: consumer demographics (the income term of the paper), a jointly estimated supply side, lognormal coefficients.
Demand estimation: the logit and nested logit of Berry (1994)¶
Added¶
- Berry (1994) logit and nested logit in
sp.blp.x_random=[]is the logit, a linear IV regression ofln s_j - ln s_0on characteristics and price;nest=adds the log within-nest share, treats it as endogenous and reports its coefficient asrho, with the closed-form nested-logit elasticities.gmm_steps=1stops at the weighting matrix(Z'Z)^{-1}, so the estimates are exactly two-stage least squares with HC0 standard errors (identical tosp.iv(..., robust='hc0')to 1e-9); the default stays two-step GMM. The result carriesmodel,rho, theinstrumentsused, and Hansen's J (hansen_j,hansen_df,hansen_p). Coefficients, standard errors, objective and elasticities match pyblp 1.2.0 to 1e-7 (observed 1e-9) in all four combinations (tests/reference_parity/test_berry_logit_pyblp_parity.py). A nested logit with random coefficients is refused. sp.blp_instruments: sums of the characteristics, and the count, of the other products in a market; split into the firm's own other products and rival products withfirm_id=(Berry, Levinsohn & Pakes 1995, section 5.1), plus same-nest sums withnest=. Equal topyblp.build_blp_instrumentscolumn by column. The frame can go intosp.blp(instruments=...)or ansp.ivformula (for LIML and the first-stage diagnostics).sp.blp(firm_id=)builds the default instruments with that split.
Fixed¶
sp.blpwith its default instruments (instruments=None) could not run. The excluded instruments repeatedx_linear, which is already in the instrument matrix, so the first weighting matrix was the inverse of a singularZ'Z:LinAlgError: Singular matrix, or an inverse of order 1e16 when the pivots were not exactly zero. The default is now the rival sums ofsp.blp_instruments, and redundant instruments are dropped before the moments are formed (silently when StatsPAI built them, with a warning naming them when the caller supplied them; both are listed inomitted_instruments). Calls that passinstruments=with a full-rank set are unchanged.sp.blp(x_random=[])raised numpy'sneed at least one array to concatenate.sp.blpnow refuses, with a message, three inputs that used to end in a numpy error or a silently wrong inversion: collinearx_linear, fewer independent excluded instruments than parameters that need one, and inside shares that sum to one or more in a market (the outside share used to be floored at 1e-6).
Conditional inference trees, and the cross-validated lasso¶
Prompted by a replication of Varian (2014), "Big Data: New Tricks for
Econometrics". The paper's classification tree is a conditional inference
tree, which had no Python implementation, and its cross-validated lasso
could not be reproduced with sp.lasso_select.
Added¶
sp.ctree: conditional inference trees (Hothorn, Hornik and Zeileis 2006;party::ctreeandpartykit::ctreein R). Each node tests every covariate against the outcome with a permutation test in its asymptotic form, splits on the covariate with the smallest p-value, and stops when no Bonferroni-adjusted p-value is belowalpha. Numeric, two-class and multi-class outcomes; numeric, ordered and unordered covariates. Written from the paper; the R packages are GPL and were used as black boxes. On twelve settings the tree, the cut points and the leaf of every row are identical topartykit::ctree1.3.0, test statistics agree to 1e-9 and adjusted p-values to 1e-7 (tests/reference_parity/test_ctree_partykit_parity.py). On the Boston mortgage data of the paper it returns the published tree: 11 leaves, 228 of 2,380 applications misclassified, predictions unchanged when race is removed. Not implemented: the maximum-type test statistic, Monte Carlo p-values, surrogate splits, case weights. Guide:docs/guides/conditional_inference_trees.md.
⚠️ Correctness¶
sp.lasso_select(method="cv")cross-validated on leaked folds. The folds were cut from data already centred and scaled on the full sample, and a training fold predicted the held-out rows without an intercept of its own. Each training fold is now centred and scaled on its own rows, and the error is pooled over observations. On the same folds and penalties the error curve is that ofsp.glmnet(newlasso_path["cv_mse"], tested to 1e-6). The solver and the BIC / AIC paths are unchanged. In large samples the selected penalty rarely moves. In small ones it can: on the 72-country growth data of Fernandez, Ley and Steel with 41 candidates andseed=0, the old code chose a penalty of 0.007 and kept 36 variables, the new code chooses 0.152 and keeps 14, which is whatsp.glmnetand Varian'scv.glmnetrun select. See MIGRATION.md.
[1.39.4] — 2026-10-11¶
Two passes over the package. The first wrote tests for the options and
inputs around each estimator's main path in matching, regression and the
forests, and found about forty defects. Several returned a wrong number
without saying so: the JIVE methods of sp.iv, sp.glm(robust='hac'),
standard errors of sp.match off the propensity-score ATT path, and the
default dispersion of gamma and inverse-Gaussian GLMs. MIGRATION.md says
which results to recompute. The second timed every public estimator at
three sample sizes and rewrote the ones whose cost grew faster than the
data; those results are bit-identical unless the entry says otherwise.
Results on the main paths are unchanged. The JSS manuscript stays
anchored to 1.39.1.
Speed: estimators whose cost grew faster than the data¶
Every public estimator was timed at three sample sizes
(benchmarks/bench_scaling.py, 124 cases). Those whose time grew like
n^1.5 or more, or that took seconds on two thousand rows, were profiled
and rewritten. No algorithm changed. Each rewrite was compared with the
code it replaces on hundreds to thousands of designs, and results are
bit-identical unless a line below says otherwise.
Changed¶
| Call | Size | Before | After |
|---|---|---|---|
sp.cox |
n = 20,000 | 113 s | 0.07 s |
sp.kaplan_meier |
n = 200,000 | 22 s | 0.06 s |
sp.qreg |
n = 200,000 | 30 s | 0.7 s |
sp.match, sp.psmatch2 |
n = 200,000 | 51 s | 0.5 s |
sp.mixed |
n = 200,000, 4,000 groups | 112 s | 0.12 s |
sp.event_study |
20,000 units x 10 periods | 14 s | 0.12 s |
sp.lp_did |
20,000 units x 10 periods | 74 s | 1.3 s |
sp.did_multiplegt_dyn |
200 units, 500 bootstrap draws | 26 s | 0.4 s |
sp.sdid |
20 donors, 200 placebo refits | 17 s | 0.5 s |
sp.synth(method='sparse') |
20 donors | 54 s | 0.4 s |
sp.logit, sp.probit |
n = 200,000 | 0.55 s | 0.35 s |
Timings are from one laptop under load and are meant as orders of magnitude.
sp.cox,sp.kaplan_meier,sp.logrank_test. Every risk-set sum is a running total over rows sorted by stratum and descending time (survival/_cox_core.py); the previous code rebuilt each risk set from the data at every event time. Harrell's C is counted by sorting, not by a Python loop over pairs. Agreement with the previous code on 216 designs (ties, strata, both tie rules, three variance estimators) is 1e-12 or better. Inside the kernel the regressors are centred and the linear index is scaled within stratum, which the partial likelihood does not see; a regressor with a large mean (a calendar year) used to overflow the first Newton step and raiseLinAlgError, and now fits.sp.qregfrom 5,000 rows solves the dual linear programme by a primal-dual interior point (Portnoy and Koenker 1997) and then solves exactly for thekobservations the solution interpolates. That point is returned only if it passes the subgradient condition strictly, which proves it is the unique minimiser; otherwise HiGHS runs as before. Agreement with the HiGHS vertex on 118 designs: 5e-13 at worst.sp.sqreg,sp.machado_mataandsp.ivqreguse the same solver.sp.match,sp.psmatch2. Propensity-score matching sorts the control scores once and searches the neighbourhood of each treated unit. Mahalanobis and Euclidean matching are processed in blocks and remain quadratic, because the time is in the distance matrix.sp.mixedtakes a thin QR of each group's random-effect design once and evaluates the likelihood from those summaries, a batchedq x qCholesky instead of a densen_j x n_jone per group. The likelihood equals the previous one to 2e-11, except where the residual variance is far below the random-effect variance; there the old value was off by up to 1e-7 and the new one is accurate to 3e-13 (checked against 40-digit arithmetic). Fitted values move below 1e-6 for interior fits. Where the optimum is on the boundary (a variance at zero, a correlation at one) and in the three-level model, which has no Newton polish, the two versions stop at slightly different points of a flat likelihood: variance components differ by up to 2e-4 relative, fixed-effect standard errors by up to 8e-5. The Track A mixed-model module reproduces its committed numbers to 3e-10.sp.event_study,sp.lp_did,sp.did_multiplegt_dyn. Group sums replace per-unit and per-cluster loops;did_multiplegt_dynconverts the panel to unit-by-period matrices once and draws each bootstrap replicate as repeated rows of them, consuming the random generator in the same order. The frame code remains as the fallback for panels the matrices cannot represent (duplicate cells, missing group labels).sp.sdid,sp.synth(method='sparse'). The Frank-Wolfe loop and the coordinate descent are numba kernels, imported on first use, that perform the same operations in the same order. The first call in a fresh environment compiles for one to two seconds; the compiled code is cached on disk.sp.honest_did(method='relative_magnitude')evaluates the truncated-normal quantile of the conditional test on scalars rather than throughscipy.stats.truncnorm(5 against 200 microseconds, a few thousand calls per grid). Output is unchanged on 72 calls.sp.kdensity(Gaussian kernel), the ROC area ofsp.logit/sp.probit, and the Kaplan-Meier steps ofsp.cuminc/sp.fine_grayare vectorised.
Fixed¶
- Seven functions allocated a dense
n x nmatrix and could not run on large samples:sp.fracreg,sp.ivqreg,sp.rdit,sp.rdmc,sp.rd_distributional_design,sp.qte(method='conditional_qr')andsp.robreg(method='m', init='lad'). At n = 200,000 that is 320 GB. The weights of a weighted fit were written asnp.diag(w), a projection was formed instead of applied, and the identity blocks of a linear programme were stored dense. Results are unchanged (ivqreg: 2e-16). sp.nbregon data without overdispersion ran its 100 outer iterations and warned "did not converge". The profile likelihood is flat in the dispersion there and the bounded search returns a slightly different point each round, so the dispersion never settled to the tolerance. The loop now also stops when the likelihood stops moving (relative change below 1e-8). On 160 Poisson and underdispersed samples it stops within 9 rounds where 42 used to exhaust the budget. Fits with real overdispersion are unchanged (1e-14). On samples at the boundary the coefficients move by 1e-7 or less; the standard error of the log dispersion, which is not identified there, can differ.
The Stata comparison notebook¶
Added¶
examples/notebooks/statspai_vs_stata_live.ipynbruns Stata and StatsPAI in the same notebook. Stata is driven through its ownpystatafrom the Python kernel, the estimates are read back frome()/r()and subtracted from StatsPAI's, and a final cell asserts a relative difference below 1e-6 on all thirteen numbers (the largest is 3.7e-10 on Stata 18 MP: OLS,reghdfe, 2SLS,csdid,rdrobust, and a do-file snippet run throughsp.stata). It needs a local Stata; the committed file carries its outputs.
Changed¶
examples/notebooks/statspai_vs_stata_5min.ipynbgains asummarizesection and a section that runs a do-file snippet throughsp.stata, both next to Stata 18 output on the same data, and ends with pointers to the ten method tutorials. It was re-executed on 1.39.3 and stays on the Python kernel, so it runs without Stata.
Options and inputs off the main path: matching, regression, forests¶
The main path of each estimator is tied to a reference implementation or
a known truth. A second pass wrote tests for the combinations around it
(weights with an ATE, a cluster with a likelihood estimator, HAC with a
GLM) and for degenerate inputs. About forty defects came out; the ones
that returned a wrong number are listed first. Results on the main paths
are unchanged, bit for bit: every propensity-score-distance ATT of
sp.match, sp.regress, 2SLS / LIML / GMM, the binary and count models
without the options named below, and every forest average.
⚠️ Correctness¶
sp.iv(method='jive'),'jive1','ujive','ijive','rjive'andsp.iv.jive_mwwere not JIVE. They regressed the outcome on the leave-one-out first-stage fit,(Xh'Xh)^-1 Xh'y, where Angrist, Imbens and Krueger (1999) use that fit as the instrument,(Xh'X)^-1 Xh'y. With 30 weak instruments and a true coefficient of 1 the median over simulated samples was 0.59 (2SLS: 1.27); it is now 0.98, and the 95% interval ofjive_mwcovers the truth 96% of the time where it covered 5%. Standard errors are the sandwich of the IV estimator withXhas instrument.sp.jivewas already correct and is unchanged;sp.iv( method='jive')now equals it.robust='hc2' | 'hc3'withmethod='jive'is refused.sp.glm(robust='hac')standard errors were too small by a factor ofsqrt(n). The score autocovariances were divided bynwhile the bread was not. They now equal statsmodels' HAC at the same lag count (no small-sample factor, thehac_small=Falseconvention ofsp.regress).sp.match(weights=)returned a standard error of 0 and a p-value of 1. Frequency weights are applied by replicating rows, so each unit's nearest neighbour in its own arm was its own copy and the Abadie-Imbens conditional variance vanished. The neighbour is now taken among other original observations; doubling every weight divides the standard error by exactlysqrt(2).sp.match(estimand='ATE')with nearest-neighbour matching reported the standard error of the ATT and ignoredse_method. Relabelling the arms left |ATE| unchanged and moved the standard error (0.206 against 0.234). The default is now the Abadie-Imbens (2006) conditional variance of the ATE, which equalsMatching::Match(estimand = "ATE", sample = TRUE, Var.calc = 1, distance.tolerance = 0)to 12 digits and does not depend on the labelling;'abadie_imbens_pop','ai'and'bootstrap'are honoured, andmodel_info['se_method']records which one was used. The point estimate is unchanged.sp.matchwithdistance='mahalanobis'or'euclidean': the default ATT standard error looked for same-arm neighbours on the propensity score, not in the matching metric. It was too large by a quarter to two fifths (0.2197 whereMatching::Match(estimand = "ATT")gives 0.1678). Sixteen standard errors now agree withMatchingto 12 digits. Estimates are unchanged, and so is everything withdistance='propensity'.sp.match(method='cem', estimand='ATE')returned the ATT under the ATE label. It is now the cell-size-weighted average with the Neyman stratified variance.sp.matchandsp.psmatch2reported an effect of 0 with p = 1 when no unit found a match (a caliper nobody meets, or every treated unit off common support). They raiseDataInsufficient. Bootstrap replicates in which nobody matches count as failed instead of entering the standard error as zeros.sp.propensity_scoregave scores off by up to 0.79 when a covariate was repeated, without a warning; the balance tables and plots built on it inherited that. The fit goes through the package's one logit routine, which drops redundant covariates. One missing covariate value no longer turns every score into NaN.- Mahalanobis matching inverted a numerically singular covariance matrix, so adding a covariate that is a linear combination of the others changed the estimate (2.2485 to 2.2395). A rank-deficient covariance uses the pseudo-inverse; a full-rank one is treated as before.
- A covariate that separates the arms was reported as balanced (standardised difference 0). It is reported as infinite and not balanced.
- A single cluster gave standard errors of about 1e-16 in
sp.logit,sp.probit,sp.cloglog,sp.poisson,sp.nbreg,sp.ppmlhdfe,sp.zip_model,sp.zinb,sp.hurdle,sp.mlogit,sp.ologit,sp.oprobitandsp.clogit. They raiseDataInsufficient, assp.regress,sp.glmandsp.ivalready did. sp.iv(cluster=)merged rows with a missing cluster label into another cluster. Those rows are dropped with a note, assp.regressandsp.ivregdo.sp.poissonandsp.nbregdroppedoffset=whenexposure=was also given. The linear predictor now carries their sum, as insp.glm.- Marginal effects of
sp.logit,sp.probitandsp.cloglogignoredweights=. They are weighted averages now; frequency weights reproduce the expanded data. sp.rate_splitandsp.forest_policy_treedid not refit the forest they were given on each half. The refits dropped the forest's controls and anyY_hat/W_hatthe caller supplied, and estimated the nuisance functions by another method (forfe=forests the treatment "propensity" was a 0/1 class label). With confounding that runs through the controls and a true ATE of 1, the halves estimated 5.2 where the forest estimated 1.0. Each half is now fitted as the forest was. Numbers from both functions change for every forest except one with user-supplied nuisance models and no controls.sp.rateignored the forest's observation weights (equalize_cluster_weights=Truewith clusters of unequal size: AUTOC 0.731 where the weighted value is 0.615). The weighted TOC, AUTOC and QINI agree withgrf::rank_average_treatment_effect.fit(sample.weights =)to 1e-14.sp.glmscaled the covariance of gamma and inverse-Gaussian fits by the deviance over the residual degrees of freedom. Rsummary.glmand Stataglmboth use the Pearson chi-squared. On a gamma fit with a log link (n = 40) the dispersion was 0.4470 where both report 0.3624, and the standard errors were 11% larger than Stata's. The default is now the Pearson estimate: default standard errors equal Stata's (observed information) to 1e-8, andinformation='expected'gives R's.scale='dev'returns the former numbers. Coefficients, the deviance and the log-likelihood are unchanged; the Gaussian, binomial, Poisson and negative binomial families are unchanged.- A two-way fixed effects forest accepted a treatment that the period
effects absorb (everyone treated from the same date) and returned
CATEs for an effect that is not identified. It raises
DataInsufficient.
Fixed¶
sp.match: an undefined standard error (one matched unit, strata of one treated and one control unit) is NaN with a warning, not 0, and a NaN standard error gives a NaN p-value, not 1.se_methodis honoured or refused bymethod='stratify','cem'anddistance='exact'. A nearest-neighbour ATE says how many units of each arm a caliper left unmatched.caliper_scale='sd'with a covariate distance, and an ATE withcommon_support='minmax', are refused. Mahalanobis matching no longer crashes on a missing covariate value. The local linear matching guard against donors with identical scores is relative to the scores.replace=must be a boolean.sp.cbpsrefuses a redundant covariate (its rank check could not fail).sp.overlap_plotaccepts Crump-trimmed scores.sp.glmrefuses a Poisson or negative binomial outcome below zero and a binomial outcome outside [0, 1]; it used to return coefficients of order 1e15.sp.logit,sp.probitandsp.cloglogrefuse missing, negative or all-zero weights (a missing weight made the fit hang; negative weights returned the starting values) and a constant outcome.sp.poissonandsp.nbregvalidate the outcome, weights and offset.sp.hurdlerefuses data with no positive counts.sp.zip_model,sp.zinbandsp.hurdleomit collinear regressors with a note, in both equations.- Unknown values of
dispersion=(sp.nbreg),count_model=(sp.hurdle),marginal_effects=andgmm_vcov=are refused; each used to select a default silently. average_treatment_effect(subset=)inside a single cluster raisesDataInsufficient(it returned an infinite standard error).predict(newdata, estimate_variance=True)of the forest family is refused whenci_group_size=1(it returned a NaN column). The column interface of the forests acceptsC(col).sp.cate_pretrend_testvalidatesalpha.- Several inputs that ended in a
TypeError,KeyErrororAttributeErrorraise an error that says what is wrong:sp.regress(cluster=[a, b, c]),sp.ivreg(vce='cr2', method='liml'),sp.zip_modelwithC(g)in the formula, andsp.regress(hac_panel=)given the same column as panel and time (aTypeErrorunder pandas 2.3 and later).
Changed¶
- The size and power figures quoted for
sp.rate_splitandsp.forest_policy_treewere re-measured after the refit fix, and the study is now in the repository (tests/reliability/ forest_split_evaluation.py, 400 replications). With no heterogeneity,sp.rateon the forest's own ranking rejects a true null 16.0% of the time at a nominal 5% (AUTOC; 12.2% for QINI);sp.rate_splitwith its default 21 splits rejects 0.5% and 0.0%, and the policy tree's gain 0.0%. With real heterogeneity all three reject in every replication. The figures in earlier documentation (7.5% and 3.5%) are superseded.
Known issues¶
- The default standard error of a nearest-neighbour ATE conditions on the
fitted propensity score and is conservative when matching on it (mean
1.3 times the sampling standard deviation in one design);
se_method='abadie_imbens_2016'is correctly sized there. The default is deliberately the same rule for the ATT and the ATE and for every distance; the docstring ofsp.matchsays when to ask for the 2016 variance. sp.match(weights=, se_method='abadie_imbens_2016')still counts a row's own copies among its neighbours.- With heavily tied priorities the influence-function standard error of
sp.rateis too small (0.074 against a bootstrap 0.135 on a 12-level priority); forest predictions are essentially never tied.
[1.39.3] — 2026-10-10¶
Correctness fixes found by three passes over the package: writing ten
teaching notebooks, closing the core coverage gap, and working through
reported defects in the DiD estimators. Several calls returned a wrong
number without saying so (sp.liml with the bracket first in the formula,
balance diagnostics with a missing matching weight, and the inputs listed
below under the DiD and core sections); MIGRATION.md says which results to recompute. The release also
adds ten offline tutorials in Chinese under examples/notebooks/. The JSS
manuscript stays anchored to 1.39.1.
Follow-ups from the teaching notebooks¶
Fixed¶
- Four synthetic-control methods cite their own papers, taken verbatim
from
paper.bib.sp.synth(method="penscm")andmethod="fdid"returned "No citation registered";method="sparse"andmethod="cluster"fell through a substring match to Abadie, Diamond and Hainmueller (2010). They now cite Abadie and L'Hour (2021), Li (2024), Doudchenko and Imbens (2016) and Rho et al. (2025), the keys their module docstrings already carried.method="kernel"cites Hazlett and Xu (2018), "Trajectory Balancing", whose kernel balancing of pre-treatment trajectories is the idea the estimator follows; the module docstring says where the implementation differs from theirtjbal. The entry is new inpaper.bib(refs verified via Crossref, OpenAlex and the authors' package page).method="kernel_ridge"is unchanged. - The docstring of
sp.wild_cluster_bootstrapsays whatci_bootis. It is a percentile-t interval and covers at the nominal rate (0.96 with 8 clusters and 0.95 with 20, 200 replications each), but it is not dual top_bootand with few clusters can contain values the test rejects.sp.wild_cluster_ci_invis the interval that is. No number changes.
Added¶
-
sp.msm(outcome_rows='last')fits the outcome model on each unit's last row, for an outcome measured once at the end of follow-up. Such an outcome arrives repeated on every row of its unit, and the default pooled fit pairs it on the earlier rows with the exposure received so far. In the two-visit design ofexamples/gmethods_timevarying.py, where each visit adds 3, the pooled cumulative-exposure slope is between 2.6 and 2.75 across seeds with a standard error of 0.06; withoutcome_rows='last'it is 2.97 to 3.04 without trimming. The default stays'all', the form in which the Ripwand Stata references are matched, and no number changes; it now raises anAssumptionWarningwhen the outcome is constant within every unit. The example passes the new option; it used to print that the model "recovers the truth" next to 2.77. -
.github/workflows/linux-spot-check.yml: a dispatchable workflow that runs chosen tests on Linux (Python 3.10 and 3.13, and pandas 3) in a few minutes. A failure that only shows on Linux could until now be checked only by a full run of two to five hours per cell. tests/test_example_notebooks.py: the committed tutorials are checked on every run (executed, no error output, standard kernel, no local path, listed in the README, references resolvable inpaper.bib), andpytest -m slow tests/test_example_notebooks.pyexecutes all ten against the tree.
Defects found while closing the core coverage gap¶
Writing tests for the uncovered branches of did, rd, synth and dml
turned up inputs on which a wrong number came back.
⚠️ Correctness¶
sp.fect(method='fe', force='none')without covariates returned the mean outcome of the treated cells, not a treatment effect. The mean of the untreated cells was removed once and then recomputed from the centred matrix, so the fitted counterfactual was zero. On a panel with no unit or period effects and a true effect of 2 it reported 7.31 where Rfect2.4.1 gives 2.387824; it now gives 2.387824.force='unit','time'and'two-way', everymethod='ife'andmethod='mc'call, and any call with covariates were already equal tofectand are unchanged.- Staggered DiD estimators misread a
Periodcohort column that marks never-treated units withNaT. Some treated units were assigned to the wrong cohort (sp.sun_abrahamreported cohorts 2, 3, 4, 6 for a design with two cohorts, and 1.9459 where integer coding gives 1.9776). Every estimator that accepts calendar time was exposed.datetime64columns, andPeriodcolumns that mark never-treated units with a date after the sample, were read correctly and are unchanged. sp.rdrobustrefuses a running variable with fewer thanp + 1distinct values on a side. A local polynomial is not identified there. With one support point a side the bandwidth selector failed, the legacy selector took over with a warning, and the fit returned an arbitrary number (-1.0 with p = 0 on data whose two cell means differ by +1.4). It now raisesDataInsufficientand points tosp.rd_discrete.sp.drdid(id=..., weights=...)ignored the weights. On the panel path the weight column was dropped before the fit, so the weighted and unweighted estimates were the same number; withoutid=the weights were used. All four estimators (method='imp'and'trad',est_method='ipw'and'reg') now agree with RDRDID1.3.0 called withpanel = TRUE, weightsname =(estimates to 1e-9). Unweighted calls are unchanged.sp.did_multiplegtreported a placebo or dynamic effect that cannot be estimated as 0 with standard error 0 and p-value 1. On a panel with too few periods before or after the switches there is no cell to compare. Those rows are now missing, with a warning that names them, and the average cumulative effect is taken over the estimable horizons only. Bootstrap replicates in which a lag has no cell no longer enter its standard error as zeros.sp.callaway_santannareturned a standard error of about 1e-16 for two cluster choices. Withallow_unbalanced_panel=Trueon a balanced panel, a cluster that varies within unit was accepted (the same call without the flag was refused). Withpanel=False, a cluster made of whole cohort-by-period cells (the period, or the cohort itself) gets nothing from the cells that matter: the standard error is exactly zero against never-treated units and a small fraction of the unclustered one otherwise. Both now raiseMethodIncompatibility.- The relative-magnitudes confidence set of
sp.honest_didcame back empty when the estimate was more than 20 standard errors from zero, andsp.breakdown_m(method='relative_magnitude')then reported a breakdown value of 0 for the most significant effects. The default grid is centred at zero, as inHonestDiD; when it accepts nothing because the estimate lies outside it, the grid is now placed around the estimate. Results that were not empty are unchanged. model_info['itt_ci']of a fuzzysp.rdrandinfheld the interval of another quantity. With aci=grid the randomization test is run ony - tau0 * D, so its inversion is the Anderson-Rubin interval for the complier effect. That interval was stored asitt_ci. On a design with an ITT of 1.46 it read (1.78, 2.08).itt_ciis now the large-sample interval of the ITT, and the Anderson-Rubin interval is underanderson_rubin_ci. The headlineciis unchanged.sp.rd_optimized(fuzzy=)refuses a treatment that does not jump at the cutoff. The check wasfirst == 0, and a first stage with no jump is about 1e-17, so the ratio came back near -4e16 (a constant treatment crashed in the root finder instead). It now raisesDataInsufficient.sp.twowayfeweightsreports no standard error on a saturated design. With as many fixed effects and slopes as observations the residuals are zero, and the function reported a standard error near 4e-15 with a p-value of 2e-15. Both are now missing. The estimate is unchanged.sp.dml(fold_indices=...)refuses a missing fold label on a row that is otherwise complete. Such a row was deleted with the incomplete ones, without a message. It now raisesMethodIncompatibility. A row that is incomplete anyway is still dropped, whatever its label.
Fixed¶
sp.rdrobust(bootstrap='rbc')andsp.did_calibrated_simulationno longer decide whether a fit is degenerate by asking if its standard error is exactly zero. An exact fit leaves a standard error of zero up to rounding, and whether that is 0 or 1e-17 depends on the estimator and on the linear algebra library: on Linux a bootstrap replicate with a constant outcome was studentised by 1e-17, and on a noise-free panel the simulation scored every estimator but one as a valid draw. The bootstrap compares the standard error with 1e-10 of the outcome's size and the simulation with 1e-6 (a variance of rounding size, 1e-16, has a square root of 1e-8).sp.rdplotandrdplot_numbersno longer crash when the outcome is constant on one side of the cutoff (cannot convert float NaN to integer). That side gets one bin, which is exact.sp.synth(method='sdid', se_method=...)works. The dispatcher's name for the option isinference; passing sdid's own name raisedTypeError: got multiple values for keyword argument 'se_method'. Passing both with different values raisesMethodIncompatibility.sp.scpi(w_constr='ridge')with fewer than five pre-treatment periods and noQ=raisesDataInsufficientand says to passQ=. The ridge rule cannot estimate its penalty there, and the prediction intervals failed withSVD did not converge.sp.synth.causal_impactskips a missing pre-period outcome (the Kalman filter already did; the regression fit before it did not, and the call failed withSVD did not converge). A missing post-period outcome raisesDataInsufficient, where it used to return an effect of NaN.sp.fectraisesMethodIncompatibilityfor collinear covariates, in place of numpy'sSingular matrix.sp.synth_sensitivityreturnsoriginal_att, the full-donor-pool estimate. The sensitivity plot draws its "Original ATT" line from that key, which was never set, so the line was never drawn.sp.did_multiplegt_dyn(cluster=<a column that is not there>)raisesMethodIncompatibilityand names the column, in place of aKeyError. Withse_method='analytic'it no longer warns that "0/0 bootstrap replicates succeeded".sp.did_calibrated_simulation(control_group='notyettreated')runs Sun-Abraham against the never-treated cohort. It passed the option through,sp.sun_abrahamdoes not take that value, and every draw of that estimator was recorded as a failure.sp.stacked_didlists inmodel_info['cohorts']the cohorts it stacked, and the ones it could not use inskipped_cohorts. The list used to include both whilen_cohortscounted the first kind only.
Track C re-timed¶
- The fixes above touch four files on the paths the Track C benchmarks
time (
did/_core.py,did/callaway_santanna.py,synth/scm.py,dml/_base.py), sopython scripts/trace_perf_path.py --checkreported02_csdid,03_scmand04_dmlas stale. All four modules were re-timed on an idle machine on 2026-10-10 (every step under a load of 1.5) and the check passes again. No ratio moved by more than its run to run noise: HDFE 1.82 / 0.85 / 0.61 (was 1.80 / 0.85 / 0.64), CS-DiD 1.38 / 2.09 / 2.60 (1.39 / 1.94 / 2.60), SCM 1.04 / 1.07 / 3.49 (1.02 / 1.08 / 3.48), DML 1.17 / 1.10 / 1.07 (1.17 / 1.11 / 1.07).
Teaching notebooks, and what writing them turned up¶
Added¶
- Ten offline tutorials in
examples/notebooks/, with prose and code comments in Chinese and their executed outputs committed: staggered DiD, instrumental variables, regression discontinuity, synthetic control, matching and weighting, regression standard errors, panel fixed effects, analysing a randomized experiment, heterogeneous treatment effects, and g-methods. Each works a bundled dataset or a simulation with a known truth and was run against this tree. sp.synth_compare(seed=)fixes the random seed of the methods whose inference resamples (synthetic DID's placebo standard error, for one). Those rows used to change from run to run and could not be pinned, because aseedforwarded to every method was rejected by the deterministic ones.sp.love_plotaccepts ansp.matchresult that carries a matched sample (nearest-neighbour, stratification, CEM). It used to refuse one with a message that asked for exactly that.
⚠️ Correctness¶
sp.limlread the formula"y ~ (d ~ z) + x"wrongly. With the bracket written first, every control after it was taken for an excluded instrument and dropped from the structural equation, and nothing was said. On the Card (1995) data the return to schooling came out as 0.013 wheresp.iv(..., method="liml")and the explicity=, x_endog=, x_exog=, z=call give 0.175. Only the order documented in the docstring,"y ~ x + (d ~ z)", was read correctly. The bracket may now sit anywhere among the exogenous terms, and a formula in neither the bracket nor the"y ~ x | d | z"form is rejected. Calls that passed the variables by name, andsp.iv(method="liml"), are unchanged. See MIGRATION.md.sp.balance_diagnostics,sp.ps_balanceandsp.love_plotreported perfect balance when a weight was missing. A matched frame marks the rows that were not matched with a missing_weight. One missing weight made the weighted control mean NaN, the standardized difference was then returned as 0.0000 for every covariate, andbalancedwasTrue. On the Lalonde data after one-to-one propensity matching the true values are 0.20 for age and 0.16 for marital status, both above the 0.1 threshold. This also affectedsp.love_plot(sp.psmatch2(...)), which takes its weights from that column. A missing weight now counts as zero, which is what it means; negative or infinite weights, and an arm whose weights sum to zero, are errors; and a standardized difference that cannot be computed is NaN, not 0.
Fixed¶
sp.synth_compare: thepre_rmspeof a method that reports no level fit (synthetic DID) is missing, where it wasinf, andn_effective_donorscounts the donors with weight above 0.01 for classic, demeaned, augmented and synthetic DID results. It used to fall back to the size of the donor pool (38 for the Proposition 99 classic fit, which puts weight on 6) or to 0. The column is now a float so that a missing count can be told apart from zero.sp.synth(method="classic")warns when the predictors do not determine the donor weights: the treated unit lies inside the convex hull of the donors' predictors, so infinitely many weight vectors fit them exactly. The behaviour was documented underperfect_fitand reported inmodel_info["in_predictor_hull"], but the call was silent. With one covariate and 38 donors it returned 36 non-zero weights and a pre-treatment RMSPE of 11.5 against 1.7 for the outcome-only fit. Estimates are unchanged. The check is one dictionary lookup on the outcome-only path that Track C times, soscripts/trace_perf_path.pylisted03_scmas stale for this file as well; the module was re-timed on 2026-10-10 with the others (see "Track C re-timed" above).sp.synth(method="augmented").cite()returns Ben-Michael, Feller and Rothstein (2021), not Abadie, Diamond and Hainmueller (2010).
Newer scikit-learn, pandas and rdrobust¶
Fixed¶
sp.RlassoRegressor,sp.RlassoClassifier,sp.RlassologitClassifierand the HAL learners work assp.dmllearners under scikit-learn 1.7 and later. These classes follow the estimator contract without inheritingBaseEstimator, and scikit-learn 1.7 stopped inferring tags for such classes, so cross-fitting raisedAttributeError: ... has no attribute '__sklearn_tags__'. They now define the tags (scikit-learn is still not imported byimport statspai). Estimates are unchanged.sp.twowayfeweightsraisesDataInsufficientwhen the fixed effects absorb the treatment, for example a treatment that never changes within a group. The check compared the residual sum of squares with exactly zero. Rounding leaves about 1e-28 there, so the function returned a coefficient near -4e12 with a standard error near 5e13. The residual variation is now judged against the raw variation of the treatment. Identified designs are unchanged.sp.logit_gof(groups=)andlrocinsp.statano longer depend on the machine when several rows share a covariate pattern. Rows with the same regressors have the same fitted probability but not always the same floating-point number, and both statistics count ties. On one Apple machine the Hosmer-Lemeshow statistic of the Kohler-Kreuter reference model came out as 4.2335 where Stata and every CI runner give 4.5058. Each pattern now carries one value.
Changed¶
- R
rdrobust4.1.1 and the Python port 2.1.1 (both 2026-10-07) changed the bandwidth selector: the stage-1 pilot bandwidth is padded by1 + sqrt(eps)andstdvarsis on by default. The native cascade behindsp.rdrobust(bwselect='mserd')still follows releases 3.0.0 to 4.0.0, which the Track A goldens were generated with. With the newer port installed,bwselect='cct'therefore reports a bandwidth about 4e-8 (relative) away from the native one, where the two used to agree to 1e-15. No StatsPAI number changed.
[1.39.2] — 2026-10-08¶
One correctness fix. sp.mswitch and sp.mswitch_lrtest gave results
that could depend on the unit in which the series was measured. Nothing
else changes. The JSS manuscript stays anchored to 1.39.1: no artifact it
tabulates is touched by this release.
Markov-switching models and the unit of the data¶
⚠️ Correctness¶
sp.mswitchandsp.mswitch_lrtestno longer depend on the unit ofy. The likelihood of a Markov-switching model changes by a constant whenyis rescaled, so the maximiser moves with the unit and nothing else should. The search did depend on it: the quasi-Newton step stops on an absolute gradient and starts from the identity metric, so which local maximum a starting value reached varied with the scale of the series. Insp.mswitch_lrtestthat reached the answer. On white noise with 80 observations,reps=19andstarts=3, multiplyingyby 0.01 sent 4 of the 19 bootstrap replicates to a different local maximum (largest change in a replicate's statistic 5.16) and moved the p-value from 0.20 to 0.15. The search now runs onydivided by its standard deviation and the estimates are mapped back. After the change every replicate agrees to 5e-9 acrossy,100 y,0.01 yand-2 + 0.01 y. Estimates on a series whose standard deviation is near one are unchanged to the stopping rule; on a series far from that scale a start can now end at a different, and no lower, local maximum than before. The Stata parity rows ofsp.mswitchare unchanged. The size and power study of the test was rerun with the new search (tests/reference_parity/_fixtures/mswitch_lrtest_mc.json). SeeMIGRATION.md.
Added¶
sp.mswitch_lrtestnow carries an evidence grade: its statistic, every bootstrap replicate and its p-value are tested for invariance to the unit ofy, its null likelihood against the closed form, and its p-value against the exact value under two clearly separated regimes (tests/reference_parity/test_oct2026_reanchor_known_truth.py).
[1.39.1] — 2026-10-08¶
The release the JSS manuscript is anchored to. It adds collaborative TMLE and closes the evidence gap left by the functions added in 1.39.0. No estimate, standard error or default of an existing function changes, and the two default changes announced for 1.40 are not in it. Every frozen artifact the manuscript tabulates was re-derived on this tree: 89 R modules, 85 Stata modules and the Python side of all 89 reproduce their committed results, as do the original-data ledger, the forest seed studies and Track B, with the one stale row noted below. Track C was re-timed.
Collaborative TMLE¶
Added¶
sp.ctmle: collaborative TMLE (the greedy C-TMLE of van der Laan and Gruber). The propensity model is built one covariate at a time for the outcome fit, and cross-validation decides how far to go, so a covariate that drives treatment but not the outcome stays out of the weights. Under weak overlap with such a covariate the root mean squared error of the ATE was 27% belowsp.tmlewith the full propensity model. The order in which covariates enter and the estimate at every step of the sequence equal Rctmle::ctmleDiscreteto 5e-9 on six fixtures (tests/reference_parity/test_ctmle_R_parity.py). The step that cross-validation selects cannot be compared row by row, becausectmleDiscreteignores itsfoldsargument and draws its own random partition; over many partitions and over 200 data sets the two agree in distribution.penalty='search'is the reference's default criterion.order=gives the pre-ordered ("scalable") variant, linear in the number of candidates; six pre-ordered sequences equal R to 8e-10.result.detaillists every step. Three things to know. The influence-function standard error ignores the selection and was 20% to 40% too small in simulation, in R as well;se_method='bootstrap'reruns the whole procedure and covered 94%. When the outcome model omits a confounder the method brings it into the propensity but is no more accurate thansp.tmle. And it is offered for the ATE only: a collaborative ATT was implemented, was biased by 0.17 to 0.20 when the outcome model missed effect heterogeneity, and was withdrawn.
Evidence for the functions added in the week before the JSS re-anchor¶
No estimate, standard error or default changes in this section.
Added¶
- Known-truth tests for fourteen functions that had no evidence row
(
tests/reference_parity/test_oct2026_reanchor_known_truth.py):sp.mixture_design,sp.sequential_design,sp.switchback_design,sp.bandit_allocate,sp.bandit_experiment,sp.contextual_bandit,sp.adaptive_inference,sp.interference_test,sp.mdp_policy_value,sp.marginal_policy_effect,sp.synth_ttest,sp.loo_predict,sp.invlogitandsp.kfold_split. Each is checked against a closed form, an exhaustive enumeration, an algebraic identity or a planted effect with a stated Monte Carlo tolerance. Estimator callables with evidence go from 921 of 940 to 935 of 938;sp.rdmcplotandsp.binned_residuals_plotare plots and leave the denominator. The three without a grade are deliberate:sp.tvp_var_sv(a stochastic sampler, screened only),sp.text_treatment_effect(experimental) andsp.mswitch_lrtest(below).
Known issue¶
sp.mswitch_lrtestis not exactly invariant to the unit ofy. A shift leaves every bootstrap replicate unchanged; a rescaling sends a few of them to a different local maximum. On white noise with 80 observations,reps=19andstarts=3, multiplyingyby 0.01 moved the p-value from 0.20 to 0.15; with the defaultstarts=10it stayed at 0.20. The observed statistic does not move. Not fixed in this release; seedocs/dev/2026-10-06-neusser-time-series-econometrics-review.md.
Changed¶
- Track B. The thirteen coverage rows, the size and power sweep and the four mechanism experiments were rerun on this tree and reproduce the committed files to the last printed digit, with one exception. The heterogeneous-timing Callaway-Sant'Anna stress row had been recorded before release 1.31.0 and not refreshed: every release from 1.31.0 on gives 954 of 1,000 intervals covering, where the file said 946. The point estimates are identical; the row is inside its documented band either way.
- Track C was re-timed on this tree, and the solver-agreement column
of the synthetic-control row prints in scientific notation (three fixed
decimals showed a gap of order 1e-6 as
0.000).
[1.39.0] — 2026-10-07¶
Three days of textbook passes: the code or the syllabus of more than twenty econometrics, causal-inference, time-series, Bayesian and experimental design texts was run against Stata 18 and R, example by example, and everything that disagreed was traced to its first point of divergence. Each section below is one of those passes, with what was wrong, what was missing and the evidence for the fix.
Read MIGRATION.md before upgrading. It has 71 entries under
1.38.0 → 1.39.0, most of them marked ⚠️ because a number changes:
estimators that returned a wrong value on some class of inputs, and a few
defaults that moved to the convention of the reference implementation.
Among the widest: every likelihood-based regression now omits a perfectly
collinear regressor instead of returning an arbitrary point of a flat
likelihood; sp.arima uses the exact diffuse initialisation, so its
estimates no longer depend on the units of the data; sp.match keeps
every tied match by default.
Two defaults are announced here and change in 1.40, with a
DeprecationWarning until then: sp.kaplan_meier(conf_type=) goes from
'plain' to 'log-log', and sp.bayes_regress(prior=) from a fixed
N(0, 1000) to 'weakly_informative'. Naming the argument keeps the
present behaviour for good.
New subpackages: statspai.mcmc (Bayesian regression, model comparison,
leave-one-out, posterior predictive checks, all without PyMC) and
statspai.doe (design of experiments).
sp.glmnet: the elastic net with glmnet's conventions; IPW standard errors without the bootstrap¶
Second round of the Yuksel and Aydede pass: the items the first round left as decisions.
Added¶
sp.glmnet: lasso, ridge and elastic net for a Gaussian or binomial outcome, on a penalty path, with penalty factors (hence the adaptive lasso) and K-fold cross-validation reportinglambda.minandlambda.1se. The objective, standardisation, path and cross-validation summaries are those of Rglmnet/cv.glmnet. On five designs and three mixing weights the path agrees withglmnet4.1-10 to 1e-10 and stops at the same length, coefficients agree to 1e-6, cross-validated errors to 1e-4, and the selected penalties are the same grid points (tests/reference_parity/test_glmnet_r_parity.py). Written from the paper and the documentation;glmnetis GPL and was run as a black box. Four conventions the documentation leaves open were recovered from its output and are documented and tested one by one.sp.ipw(se_method='sandwich')for Horvitz-Thompson and clipped weights. It used to requirenormalize=Trueandtrim=0and send everything else to the bootstrap. The M-estimation variance now coversnormalize=Falseandtrim > 0(a clipped score is held fixed, as it is in the estimator), for the ATE, ATT and ATC. Equal to a numerically differentiated stacked M-estimator to 1e-10 in all twelve combinations.
Changed¶
CausalForest.variable_importance()defaults to grf's split measure, as itsFutureWarninghas announced since 1.31. It returned the permutation measure.method="permutation"keeps the old numbers; forests grown withsplit_rule="legacy"keep it as their default.
Yuksel and Aydede, Causal Inference and Machine Learning: DML-DiD, full matching, gsynth with several treated units¶
A pass over the causal chapters of the book against the R packages it
uses. Experiments, regression adjustment, nearest-neighbour and caliper
matching, weighting, PLR / PLIV with rlasso, rdrobust, synthetic DiD
and augmented synthetic control already reproduced the references to 1e-9
or better. What follows is what did not, and what was missing. Guide:
docs/guides/yuksel_aydede_causal_ml.md; notes:
docs/dev/2026-10-07-yuksel-aydede-causal-ml-review.md.
⚠️ Correctness¶
sp.gsynth(covariates=...)estimated the covariate coefficients by a pooled regression of the outcome on the covariates alone, with no intercept, unit effects, period effects or factors, on pre-treatment control rows. When a covariate is correlated with the unit effects or the loadings, as in the example data of Rgsynth, the counterfactual was biased: 6.68 for one treated unit of those data, wheregsynthgives 5.70. The coefficients are now estimated inside the interactive fixed effects model of the never-treated units (Xu 2017), and the function reproducesgsynthto 1e-12 with 0 to 3 factors. Calls without covariates are unchanged to the last digit.sp.aipwreportedn_propensity_clipped = 0whatever happened. The scores were clipped to [0.01, 0.99] once inside the propensity fit and counted afterwards, so the count was always zero and the warning attached to it never fired. On the book's design 538 of 20,000 scores were being clipped. The count is now right, a warning is issued when it is not zero, and the bound is the new argumenttrim(default 0.01, so estimates are unchanged).trim=0applies no clipping and reproduces the textbook formula.
Added¶
sp.dml_did: double machine learning difference-in-differences for two periods, on a panel (outcome change, or long form withtimeandid) or on repeated cross-sections. Doubly robust score of Sant'Anna and Zhao (2020);in_sample_normalization=Falseis the score of Chang (2020).score='experimental'for randomised designs. With the same folds and learners, estimates and standard errors equal PythonDoubleML'sDoubleMLDIDandDoubleMLDIDCSto 1e-15 in all eight configurations of each.sp.full_match, andsp.match(method='full'): optimal full matching (Rosenbaum 1991; Hansen 2004). Every unit is used. Solved exactly as a minimum-cost edge cover; on 40 small problems it equals the optimum found by enumeration, and its total distance is never aboveoptmatch's. Weights for the ATT, ATC or ATE, a balance table, and the matched-set-clustered standard error thatMatchItprescribes (equal tolm+sandwich::vcovCLto 1e-9 given the same sets).sp.gsynth(treat=...): a 0/1 treatment column in place oftreated_unit/treatment_time, for several treated units and staggered adoption. A list of treated units is also read. Effects by period since adoption indetail; number of factors by the leave-one-period-out criterion of Xu (2017);inference='parametric'(the bootstrap of the paper) or'nonparametric'.sp.synth(method= 'gsynth', treatment=...)reaches it.CausalForest.average_treatment_effect(subset=)and the same argument ofsp.average_treatment_effect: grf'ssubset, for all four targets, with clusters and withequalize_cluster_weights. Equal to grf to 1e-14 on grf's own forest outputs.sp.metalearner(outcome_model=(control, treated))and, for the X-learner,cate_model=(control, treated): a different learner in each arm.sp.dml(fold_indices=...)reads scikit-learn splits, a list of(train, test)index pairs, when the test sets partition the rows and each training set is its complement.
Fixed¶
sp.causal_forest(data=, y=, d=, x=[...])refused a text orcategorycolumn that the formula interface accepted. Both now expand it to one indicator per level.effect()andpredict()of a forest fitted on a categorical column asked for the indicator columns by name. They are rebuilt from the raw column; a level not seen at fit time is an error.sp.dml(fold_indices=[(train, test), ...])failed inside NumPy with "inhomogeneous shape".sp.gsynthwith a list of treated units is no longer refused.
Interference, adaptive experiments and balancing in high dimensions¶
A pass over Wager, Causal Inference: A Statistical Learning Approach
(draft of September 2026). The book has no code, so each chapter was read
as a syllabus: every estimator it defines was looked up in StatsPAI and
run against a design with a known answer, and against the R package of
the method's authors where one exists. Chapters 1 to 5, 8, 13 and 14 were
already covered and checked out. Review in
docs/dev/2026-10-07-wager-causal-inference-review.md, guide in
docs/guides/wager_causal_inference.md.
⚠️ Correctness¶
sp.network_exposurereturned unusable estimates on networks with uneven degree. Exposure probabilities were simulated (2,000 draws) and floored at 0.001 even though they have a closed form under Bernoulli assignment, and the variance summedY_i^2 (1 - pi_i) / pi_i^2over all units, including those not observed at the exposure level, with no covariance between units that share a neighbour. On a 400-node network with degrees up to 10 the mean outcome with no treated neighbour was 8.60 against a true 7.03 and its reported standard error was 98 against a sampling spread of 6. The function now uses exact probabilities, reports the self-normalised (Hajek) mean by default (estimator='ht'keeps Horvitz-Thompson) and estimates the variance over the dependency graph of the design, with the positive semidefinite adjustment that makes it conservative (variance='hac_psd', default;'hac'is the unadjusted form). Contrasts carry the covariance between the two exposure means. On the same network the Hajek contrast now has a spread of 0.5 instead of 6 and its interval covers at 93 to 97%. Units that cannot receive some exposure level (no neighbours) are excluded with a warning, andmin_prob=restricts to units with adequate overlap. The registry no longer advertises mappings and designs that were never implemented.sp.iv.mteweighted the effect on the treated and on the untreated by the wrong distribution. The weights were built from the propensity scores of the treated (or untreated) units only. The treated are the units whose resistance is below their propensity, so every unit contributes in proportion to its propensity. With a linear marginal effect2 - 2uthe function returned an ATT of 1.22 and an ATU of 0.73 against true values of 1.30 and 0.63. Both are now exact linear functions of the fitted coefficients. The ATE was right.sp.iv.mtestandard errors ignored the covariance between polynomial coefficients, which is large and negative. The ATE standard error was 0.075 where the sampling spread was 0.024. Standard errors are now heteroskedasticity-robust with the full covariance, and are reported for the ATT and ATU as well (extra['att_se'],extra['atu_se']). They treat the fitted propensity score and the covariates as given;bootstrap=includes both.sp.iv.mte(bootstrap=)paired outcomes with other units' instruments whenever trimming removed a row, because the outcome was trimmed and the instrument was not. The bootstrap standard errors were then meaningless (hundreds of thousands on a unit-scale outcome). The bootstrap now resamples the data as supplied and repeats the trimming.
Added¶
sp.interference_test: randomization tests on a network. Tests that treatment affects no one, or that there are no spillovers, by holding the treatments of a set of focal units fixed and permuting the rest. Exact in finite samples for any statistic; all assignments are enumerated when there are few enough.sp.bandit_allocate,sp.bandit_experiment: Thompson sampling, upper confidence bounds and epsilon-greedy allocation for multi-armed experiments, with a probability floor, batching and Gaussian or Bernoulli rewards. Thompson probabilities are computed by quadrature, not by simulation, so the recorded assignment probabilities are exact.sp.adaptive_inference: confidence intervals for arm means and contrasts after an adaptive experiment. The sample mean is biased downwards and not normal under Thompson sampling (its 95% interval covered 87 to 91% in our runs). Weighting by the inverse square root of the assignment probability restores normality; with a 2% probability floor the interval covers 95.5%. An augmented variant with running-mean plug-ins is available asmethod='aipw'.-
sp.residual_balance: approximate residual balancing for average treatment effects with more covariates than a propensity model can carry. Weights minimise the worst covariate imbalance and are applied to the residuals of an elastic-net outcome model. The weights agree with the authors' R packagebalanceHDto 1e-6, which is the accuracy of its quadratic-programming path (our objective value is the lower of the two at every configuration tried). With 150 covariates and 300 units the estimate is unbiased and its interval covers 93%, where pure weighting is biased by a full standard error. -
sp.rd_optimized(alsosp.rd(method='optimized')): sharp regression discontinuity with minimax-optimal linear weights under a bound on the second derivative (Imbens and Wager 2019). The weights come from a least-squares problem with box constraints instead of a kernel, the worst-case bias is computed exactly for the weights used, and the interval is bias-aware as insp.rd_honest. It works with a discrete running variable. Against the authors' R packageoptrddthe estimate is within 0.008 standard errors and the worst-case bias within 0.3% (two discretisations of the same programme). On the Lee election data the interval half-length is 2.996 against 3.002 for local linear regression, the small gain theory predicts for a continuous running variable. -
sp.mdp_policy_value: the long-run average outcome under a policy in a Markov decision process, from one trajectory collected under randomized actions. Doubly robust in the excess-reward function and the ratio of stationary state distributions, so its error does not grow with the horizon. On a four-state process with known tables it is unbiased with 95.5% coverage; with one indicator per state it equals the stationary average of the fitted transition model to 1e-10. sp.marginal_policy_effect: what removing a small share of the treatments that occur would do to the long-run average outcome, identified when part of the state is hidden. Doubly robust with HAC standard errors; 97% coverage on a design with a hidden autoregressive state.sp.contextual_bandit: linear Thompson sampling, LinUCB and epsilon-greedy with covariates. Assignment probabilities are exact, so inverse-probability weighting afterwards is unbiased.sp.interference_test(null='anonymous' | 'no_higher_order'): the two richer hypotheses of the hierarchy. Spillovers depend only on the share of treated neighbours, or only on a unit's own and its neighbours' treatments.sp.network_exposure(design='complete'): a fixed number treated, with hypergeometric exposure probabilities. It used to raiseNotImplementedError.sp.rd_optimized(fuzzy=): fuzzy designs, with an Anderson-Rubin bias-aware interval that stays valid when the jump in treatment is small.sp.doubly_robust(n_folds=): optional cross-fitting of the outcome forest. The default is unchanged.
Changed¶
sp.metalearner(learner='r' | 'dr'): the default final-stage model requires 20 observations per leaf. Both learners regress a heavy-tailed pseudo-outcome on the covariates. The default gradient boosting had no minimum leaf size, so it isolated extreme pseudo-outcomes and returned them as conditional effects. On a 2,000-unit design with true effects between -1.5 and 3.8 the fitted effects reached -24 and +27, and their root-mean-square error was 0.76 (R) and 1.01 (DR) where a constant scores 1.03. With the minimum leaf size it is 0.46 and 0.59. Average effects, their standard errors and every result with an explicitcate_model=are unchanged. The S-, T- and X-learners are unchanged.sp.ltmle(...).summary()labels the two regimes by their treatment values, or as dynamic, in place of a fixedE[Y(1,...,1)].sp.gformula_ice_fn: the documentation said a callable strategy receives the covariate history. It receives the time index. The text now says so and points tosp.ltmlefor history-dependent regimes.
Targeted learning after Schuler and van der Laan's Modern Causal Inference¶
The book has no data and no code. It was audited as a description of what
a plug-in, a one-step estimator and a TMLE must satisfy, and sp.tmle was
checked against those properties, against known truth, and against R
tmle 2.1.1. Review: docs/dev/2026-10-07-schuler-vanderlaan-review.md.
Guide: docs/guides/schuler_vanderlaan_modern_causal_inference.md.
⚠️ Correctness¶
sp.tmle(estimand=...)returned the ATT for every string other than'ATE', and labelled the result with the string it was given.estimand='ate'gave the ATT under the nameate; so did'ATC','RR'or a typo. The argument is now validated and case-insensitive. Results fromestimand='ATE'andestimand='ATT'spelled exactly so were not affected.
Added¶
sp.tmle(estimand='ATC' | 'EY1' | 'EY0' | 'RR' | 'OR'). The effect among the controls (the ATT computation with the arms relabelled), the two treatment-specific means, and the marginal risk ratio and odds ratio, each with influence-function inference; the ratio intervals are built on the log scale.result.detaillists every parameter whenever each arm is targeted (fluctuation='per_arm', the default for these estimands). With shared initial fits the means, the difference, the risk ratio and the odds ratio agree with Rtmleto 1e-11 in estimate, variance, interval and p-value, with and without observation weights and clusters (tests/reference_parity/test_tmle_parameters_R_parity.py). Two documented differences. R's influence curve for the log odds ratio is not centred, which inflates its variance when observation weights vary (0.3% to 2% on the fixture); ours is centred and the test rebuilds R's number from it. R's ATT and ATC are a different TMLE of the same parameter (it also updates the propensity, on a trimmed sample); they are within 0.02 standard errors of ours, and the ATT numbers are unchanged by this release.model_info['influence_function']on everysp.tmleresult: the per-row efficient influence function at the targeted fit. Its mean is zero to Newton tolerance, which is the equation targeting solves.sp.tmle(g1W=0.5),sp.aipw(propensity=)andsp.ipw(propensity=)take a known treatment probability, one number or a column of design probabilities, for randomised trials. No propensity model is fitted and nothing is clipped. Forsp.ipwthe sandwich drops the first-stage term; with one probability for everyone the estimate is the difference in means and the standard error its HC0 one.sp.tmle(se_method='bootstrap', n_boot=). The influence-function standard error treats the nuisance fits as known and is too small when propensities approach 0 or 1. With 9% of the true propensities outside [0.025, 0.975], its 95% interval covered the ATE 91% of the time and the ATT 81%. No truncation level repaired that (0.74 to 0.87 for the ATT across the bounds tried). Refitting everything on resamples of the rows gave 92% and 90%. The bootstrap resamples whole clusters withcluster=, covers every row ofresult.detail, and keeps the analytic values inmodel_info['se_influence']. The weak-overlap warning now points to it.sp.aipw(outcome_model='logit' | 'probit' | 'poisson'). The per-arm outcome regression can be a logit, a probit or a Poisson regression fitted by maximum likelihood, thelogit,probitandpoissonoutcome models of Statateffects aipw. Withcross_fit=False, se_method='sandwich'the ATE, both potential-outcome means and their standard errors agree with Stata 18 to 4e-9, plain, clustered and with sampling weights (tests/reference_parity/test_aipw_outcome_models_stata_parity.py). A linear model for a rare binary outcome predicts negative risks; the logit does not.se_method='bootstrap'also works withfold_indices(CV-TMLE): every copy of a resampled row keeps that row's fold.sp.hal_tmleaccepts every estimand ofsp.tmle.sp.tmle(weights=)withestimand='ATT'/'ATC'. Previously refused. Every empirical mean, the treated share included, becomes a weighted one. Integer weights reproduce row replication to 1e-9.sp.causal_gap: the critical causal gap of the book's section 2.3. Given an estimate and its interval, the smallest difference between the statistical and the causal estimand at which the interval reaches the null or a threshold of practical interest, on the difference or the ratio scale, with the curve of the implied causal estimate over hypothetical gaps.
Design of experiments, second round¶
Review: docs/dev/2026-10-07-joseph-experimental-design-review.md.
Added¶
sp.factor_importance: model-free ranking and selection of regressors by total Sobol' index, estimated from nearest neighbours (FIRST, Huang and Joseph 2025). Equal to Rfirst::firstto 1e-10 on numeric data. For a set of categorical factors only it uses the variance within each cell where the reference takes arbitrary tied neighbours.sp.space_filling(qualitative=): factors without an order, balanced over the runs, with the mixed-factor MaxPro criterion (equal to RMaxPro::MaxProMeasureto 1e-12).sp.factorial_designalso builds Plackett-Burman designs (12, 20, 24 runs), regular fractions at 3, 5 and 7 levels, and the orthogonal array L18. The minimum-aberration search is compiled and covers up to 15 factors in 32 runs and 12 in 64 (it stopped at 9).sp.sequential_design(criterion='alc')forgoal='emulate'.
Changed¶
sp.sobol_indicesdefaults toestimator='saltelli', computed on centred outputs. Released code never had the other default: the function is new in this cycle. With 512 Sobol' points on the borehole function its error for inert inputs is 0.0001 against 0.009 for Jansen's estimator, at the cost of 0.008 against 0.005 for the dominant input.estimator='jansen'is unchanged.
Design of experiments: statspai.doe¶
From a pass over V. Roshan Joseph, Experimental Design for Data Science
and Engineering (2025). Review: docs/dev/
2026-10-07-joseph-experimental-design-review.md. Guide: docs/guides/
design_of_experiments.md.
Added¶
sp.factorial_design: full factorials for factors with any number of levels and two-level fractions2^(k-m), from generators or by a minimum-aberration search, with defining relation, resolution, word length pattern and alias structure. The word length patterns equal those of RFrF2for 17 design sizes from 8 to 64 runs.sp.factorial_effects: effects of a two-level factorial, with Lenth's pseudo standard error, margins of error and p-values for designs without replication, and a half-normal plot. The null distribution is simulated, as in Lenth's R packageunrepx; the t approximation of the 1989 paper (reference='t') flags 2.1% of null effects at a nominal 5% with seven effects. Aliased terms are detected and listed.sp.design_aberration: generalized word length pattern of any factorial design, regular or not, mixed levels included (RDoE.base::GWLP, equal to 1e-10).sp.mixture_design: simplex-lattice, simplex-centroid and space-filling designs for factors that are shares.sp.doe_optimal: D-, A- and I-optimal designs for a linear model given by a formula, a nonlinear model with parameters in braces (locally optimal, or averaged over a prior) and logit / Poisson responses. Approximate designs carry the equivalence-theorem bound on their efficiency; exact designs their efficiency relative to the approximate optimum. The closed-form designs for polynomial, exponential and logistic models are recovered, and two exact designs have the criterion value of RAlgDesign::optFederov.sp.space_filling,sp.design_augment,sp.design_criteria: maximum projection, maximin and uniform Latin hypercubes, random Latin hypercubes, scrambled Sobol' and Halton points; greedy augmentation of an existing design, also on a constrained region; and the measures by which such designs are compared. Criteria equal RSFDesignto 1e-12 and augmentation picks the runs ofMaxPro::MaxProAugment. The search itself is stochastic: its designs score on par with or better thanSFDesign's, which is a screen and not a parity claim.sp.sobol_indices,sp.morris_screening: global sensitivity analysis of a model's inputs. First-order and total Sobol' indices with bootstrap intervals, and elementary-effects screening. Equal to Rsensitivityfrom the same runs (forsoboljansenafter its2n - 1divisor is undone; the paper divides by2n).sp.support_points,sp.split_data: thenpoints that best represent a sample or a distribution in energy distance, and a train / test split in which the test set has the joint distribution of the data (SPlit, or twinning for large data). Twinning returns the rows of Rtwinning::twinfrom the same start.sp.sequential_design: Bayesian optimisation by expected improvement, or active learning of a whole surface, with a Gaussian process surrogate.sp.gp_regress(interpolate=True)for noise-free outcomes,likelihood='ml'for the profile likelihood, andGPResult.expected_improvement().
⚠️ Correctness¶
sp.gp_regresscould return a fit that reverts to the mean between the data points. When the length scale is far below the spacing of the data, the marginal likelihood does not depend on it and has zero gradient. An optimiser that steps into that region stays there, and the default start plus the random restarts could all end in it. On ten sites observed twice with noise the function returned a length scale of 0.0014 with log likelihood -1.2447, where the maximum is at 0.048 with -1.2107 (the value Rrkrigingfinds), and predictions between the sites were off by up to 0.79 on a function of range 1.5. The likelihood is now scanned over a coarse grid of length scales and noise shares first, and the best two grid points are added as starts. Fits that were at the maximum already are unchanged. A fit whose length scale is below a quarter of the smallest gap in the data now says so in a note.
The regression workflow of Gelman, Hill and Vehtari: priors on the scale of the data, predictive checks, leave-one-out¶
A pass over Regression and Other Stories (2020) and its companion
Active Statistics (Gelman and Vehtari, 2024), whose examples are written
around rstanarm::stan_glm, loo and arm. Thirty-three of the book's
classical fits were rerun against R lm / glm / MASS / AER, and the
Bayesian workflow against rstanarm 2.32.2 and loo 2.9.0. Review in
docs/dev/2026-10-07-gelman-vehtari-active-statistics-review.md, guide in
docs/guides/regression_and_other_stories.md.
⚠️ Correctness¶
- Perfectly collinear regressors were handed to the optimiser by every
likelihood-based estimator except
sp.regress.sp.logit,sp.probit,sp.cloglog,sp.glm,sp.poisson,sp.nbreg,sp.ologit,sp.oprobit,sp.mlogit,sp.tobit,sp.qregandsp.svydesign(...).glmreturned one arbitrary point of a flat likelihood: coefficients of order 1e13 fromsp.glmandsp.nbreg, standard errors of exactly zero fromsp.probitand the ordered models, of order 1e6 fromsp.logit,nanfrom the survey regression, in several cases with no warning at all. The trigger is the most common specification mistake there is: every indicator of a category next to the constant. The book's child care example does it twice in one propensity score model. These estimators now omit the later member of each dependent set, as Stata does and as R reports withNA, announce it (note: white omitted because of collinearity) and list it inmodel_info['omitted']. The shared step iscore/_collinear.py. Fits on full-rank designs are unchanged. sp.logitandsp.glm(family='binomial')could stay silent under complete separation. The detector required 99 percent of the fitted probabilities to lie within 0.01 of the boundary. With sixty observations one point near the separating threshold was enough to silence it while the slope reached 1,412. Any fit whose linear index classifies every observation correctly now raises theConvergenceWarning, as does one with fitted probabilities equal to 0 or 1 to machine precision (the event R warns about).sp.glmhad no check at all and now shares it.- A true / false outcome written in the formula failed everywhere.
"(earn > 0) ~ height + male", the book's way of writing the first part of a two-part model, raised anIndexErrorinsp.logitandsp.probitand a shape error insp.regress,sp.glmandsp.bayes_regress, because the formula parser codes a logical term as two columns. It is now one 0 / 1 outcome. factor(y) ~ xinsp.ologit/sp.oprobit/sp.mlogit(the spelling R'spolrrequires) failed withOrdered model requires J >= 3 categories, got 2or aKeyError. The wrapper is now read as the bare outcome.sp.bayes_regress(model='mlogit').predict(new_data)raised "the new data lack the regressors" for a formula of plain columns.
Added¶
sp.bayes_regress(prior='weakly_informative'): independent normal priors scaled to the data, the default ofrstanarm(slope standard deviation2.5 sd(y) / sd(x), interceptN(mean(y), 2.5 sd(y))with the regressors centred,sigma ~ Exponential(1 / sd(y))), for the normal, logit, probit, Poisson and negative binomial models. Results no longer depend on the units of the regressors. The scales matchprior_summary()to nine digits and each sampler matches its exact posterior on a grid. Alsooffset=andexposure=for logit and count models.- Predictive methods on every
sp.bayes_regressfit:posterior_linpred,posterior_epred,posterior_predictandlog_lik, each returning draws by observations, for all eleven likelihoods and forsp.bayes_shrink. sp.loo,sp.waic,sp.kfold,sp.loo_compare,sp.loo_predict,sp.psis,sp.kfold_split: expected log predictive density by Pareto smoothed importance sampling, with the draw-dependent reliability threshold of Vehtari et al. (2024). Given the same log-likelihood matrix the pointwise values, Pareto shapes and comparison match Rloo2.9.0 to 1e-9; on the conjugate normal model they match exact leave-one-out. They accept any draws-by-observations matrix, so a PyMC trace works too.sp.ppc(posterior predictive checks with a test statistic),sp.bayes_r2andsp.loo_r2(Gelman, Goodrich, Gabry and Vehtari 2019; the operator matchesrstanarm::bayes_R2to 1e-9 and extends to count models),sp.mad_sd.sp.bayes_shrink(prior='horseshoe')withglobal_scale=or thep0=calibration of Piironen and Vehtari (2017); Gibbs sampler of Makalic and Schmidt (2016), checked against the exact posterior.sp.retrodesign: power, type S error and exaggeration ratio (Gelman and Carlin 2014) in closed form. Withdof=the test is a t-test and the answer is exact for it;method='shifted'reproduces the published approximation.sp.binned_residuals,sp.binned_residuals_plotandsp.standardize(centre and divide by two standard deviations, Gelman 2008), equal toarm::binned.residsandarm::rescaleto 1e-8 or better;sp.invlogit.sp.poststratify: population-weighted average of cell predictions, draw by draw for a Bayesian fit, overall and by subgroup.sp.glm:link='robit(dof)'(Student-t link, Liu 2004; equal to Rglmwith a user-defined link),family='quasipoisson'/'quasibinomial', and grouped binomial outcomes writtencbind(successes, failures) ~ x.y ~ .in any formula entry point: the dot stands for every other column.sp.bayes_regress(model='ologit'), the ordered logit ofsp.ologitand Rpolr, and grouped binomial data inmodel='logit'throughtrials=or an outcome writtencbind(successes, failures). Both checked against grid posteriors.sp.bayes_shrink(prior='horseshoe', slab_scale=, slab_df=): the regularized horseshoe of Piironen and Vehtari (2017), checked against the exact posterior.sp.binned_residuals(band='model'): a band from the fitted probabilities that does not collapse where the outcome hardly varies.sp.bayes_shrink(prior='horseshoe', family='logit'): the horseshoe, plain or regularized, for a binary outcome, by Polya-Gamma Gibbs sampling (Polson, Scott and Windle 2013). It stays finite where too many regressors separate the outcome and maximum likelihood has no solution. Checked against the exact posterior; the Polya-Gamma draws against their exact moments and series representation.
Changed¶
- The logit, Poisson, negative binomial and multinomial logit models of
sp.bayes_regressmix three to five times better. Four draws in five now come from an independence proposal centred at the posterior mode, the rest from the random walk used before. Effective sample size per 4,000 draws went from about 400 to 1,260 and 1,830 on the book's roaches and wells examples. The posterior is the same; the draws for a given seed are different numbers. - The default prior of
sp.bayes_regressis scheduled to change in 1.40 toprior='weakly_informative'for the normal, logit, probit, Poisson and negative binomial models. Until then an unnamedpriorwith defaultprior_meanandprior_vargives the same numbers as before and aDeprecationWarning. See MIGRATION.
Fixed¶
sp.nbregwith absorbed fixed effects reported one fixed-effect parameter too many when an indicator was omitted for collinearity.
sp.callaway_santanna(balance=): the three rules for an unbalanced panel¶
Added¶
balance='pair' | 'full' | 'none', named after thebal()settings of Statacsdid2.0.0.'full'drops every unit not observed in all periods before estimating and warns with the count. That is the default of Rdidand ofcsdid2.0.0, and it reproduces Rdid2.5.1 to 2e-9 on the unbalanced panels tried (tests/reference_parity/ test_cs_balance_full_did_parity.py).'none'isallow_unbalanced_panel=True.'pair'is what the function has always done on such a panel and stays the default, so no existing number changes: it keeps a unit in every cell where both of its periods are observed, and intests/reliability/unbalanced_panel.pyit covers at the nominal rate where'full'would discard most units.model_info['balance']records the rule, and the unbalanced-panel warning now names all three.
Weighted Callaway-Sant'Anna on repeated cross-sections¶
⚠️ Correctness¶
sp.aggtestandard errors aftersp.callaway_santanna(panel=False, weights=...)left the weights out of the cohort-share term. An aggregate that mixes cohorts carries the influence of the estimated cohort shares (Rdid:::wif), which under weights is built fromw_i * 1{G_i = g}. The repeated-cross-section route, which also servesallow_unbalanced_panel=True, did not pass the weights on, so that term used head counts while the shares used weight mass. Every ATT(g, t), its standard error and every point estimate were right. The standard errors of the simple, event-study, calendar and group-overall aggregates, and the headline standard error of the fit, were off by up to 1.2e-4 in relative terms on the county panel used to find it. They now match Rdid2.5.1 to 1e-10. Balanced panels and unweighted fits did not change. Found in a three-way run against Statacsdid2.0.0 (pre-release) and Rdid2.5.1, more than 13,000 rows on which those two agree to 2e-9; the regression test istests/reference_parity/test_cs_rc_weighted_aggte_did_parity.py.
Matched observational designs: matching on two criteria, sensitivity analysis beyond pairs¶
A pass over Rosenbaum, An Introduction to the Theory of Observational
Studies (2025) and the three R packages it uses (iTOS 1.0.3,
weightedRank 0.7.0, tightenBlock 0.1.7). Before it, StatsPAI could bound a
matched-pair analysis (sp.rosenbaum_bounds) and match one-to-one by total
distance (sp.optimal_match). The book's designs are matched sets with
several controls, built with fine balance, and its analyses put most of the
weight on the sets where an effect can be seen. No existing estimate
changes. The comparisons below were run on the book's data and are pinned
in the test suite on simulated data of the same shape, since the book's
data are not redistributed. Review in docs/dev/2026-10-06-rosenbaum-itos-review.md, guide in
docs/guides/observational_block_designs.md.
Added¶
sp.weighted_rank: sensitivity analysis for block designs (matched sets with one or more treated individuals per set) with weighted rank statistics. Stratified Wilcoxon, Quade, the U-statistic weightsu868/u878/u888/u858/mixedor any(m, m1, m2), custom within-set scores, the gap in place of the range. Returns the bound on the p-value at eachgamma, thegamma_criticalat which it crossesalpha(solved, not read off a grid), and withestimates=Truethe bounds on the Hodges-Lehmann estimate and confidence interval. A list ofphigives the adaptive test that pays for choosing among weights.conditional=Truegives the test on extreme responses. EqualsweightedRank::wgtRank,dwgtRank,gwgtRank,gwgtRankCandwgtRankttto 1e-9 or better on the book's data, andwgtRankCIto the 1e-5 that function solves to.sp.weighted_rank_power: jackknife estimate of the power of such an analysis from pilot data. EqualsweightedRank::estPoweratssratio = 1. For other ratiosestPower0.7.0 divides the bounding standard deviation by the ratio where its square root belongs; a simulation in the tests shows the square root tracks the true power and the ratio does not (estimated power 0 where the truth is 0.2 to 0.9).sp.rosenbaum_stratified: Rosenbaum bounds for strata of any size and composition, with the separable approximation and the Taylor bound that does not depend on it; in a single stratum the worst case is searched directly. Scores can be overall ranks, within-stratum ranks, aligned ranks or the outcome. The moments come from Fisher's noncentral hypergeometric distribution computed exactly. Equalssenstrat(method="RK")andsen2sampleto 1e-9.sp.noether_test,sp.evidence_factors,sp.truncated_product,sp.amplify: the sign test on the largest pair differences with its exact bound, two evidence factors from a design with two control groups, the truncated product of p-values (Fisher's method attrunc=1), and the amplification ofGammainto two odds ratios.sp.two_criteria_match: optimal matching by minimum-cost flow with one distance for pairing and another for balance. Fine and near-fine balance, near-exact matching, symmetric and directional calipers, rank-based Mahalanobis distance, 1:k matching, a cost for using a control, and optimal subset matching. The solver is a compiled successive-shortest-path routine. On the book's largest match (206 treated, 3,919 controls) it takes half a second and reaches the value an LP solver reaches.iTOS::makematchtruncates every cost to an integer before solving (insidercbalance::callrelax); on truncated costs the two optimal values are the same number, and on the costs as given the StatsPAI match is the cheaper one (268.1 against 326.8).sp.tighten_blocks: tightens a block design into smaller or fewer blocks balanced on further covariates, with controls kept in their block.sp.balance_vs_randomization: balance of a matched sample set against simulated completely randomized experiments on the same individuals.- 25 bibliography entries, each checked against the Crossref API and OpenAlex (refs verified via Crossref, OpenAlex and doi.org content negotiation; page ranges that none of the registries carry are left out).
A very small never-treated group is now announced¶
Changed¶
sp.callaway_santannawarns when the never-treated comparison group has fewer than five units plus one per covariate (AssumptionWarning,control_group='nevertreated'only). No estimate changes. That is the size below which Rdid2.5.0 and later stop;did2.3.0 had the same guard but it never fired on its default path. The estimate is defined there, so StatsPAI returns it and says how few units carry every comparison.sp.cs_jackknifewarns once when the full sample passes that size and a delete-one sample does not. The refits are silenced for whatever the full fit already said, and this is something it never said. On such a design (five never-treated clusters, for example) Rdidjackondid2.5.x cannot run; StatsPAI computes every replicate.
Time series econometrics: a pass over Neusser (2016)¶
Ten data sets of Neusser, Time Series Econometrics, run in R, Stata and
sp. Notes: docs/dev/2026-10-06-neusser-time-series-econometrics-review.md.
Guide: docs/guides/time_series_econometrics.md.
⚠️ Correctness¶
sp.garchwith two or more lagged variances could stop at a worse fit than the model it nests. On 3,797 daily returns of the Swiss Market Index,sp.garch(y, p=2, q=1)returned a log-likelihood of -5600.552 withbeta[2] = 0and a standard error of zero. GARCH(1,1) reaches -5599.372 and Stata'sarchreaches -5598.548 for the same GARCH(2,1), with 0.371 and 0.374 on the two lags. A single simplex search walked into the boundary and stayed. The search now starts a bounded quasi-Newton from several splits of the persistence and keeps the best. GARCH(1,1) results are unchanged. An estimate that ends on the boundary now raises aRuntimeWarning. Against Stata 18 on a committed series: coefficients to 5e-5, standard errors to 5e-4, log-likelihood to 1e-8 (tests/reference_parity/test_garch_extensions_stata_parity.py).
Added¶
sp.garch(ar=, dist="t"): autoregressive terms in the mean and Student t innovations with estimated degrees of freedom, as Stata'sarch, ar() distribution(t).GARCHResult.forecast_mean()and.value_at_risk().sp.from_statatranslates both options.sp.garch(model="gjr" | "egarch"): threshold GARCH (Glosten, Jagannathan and Runkle) and Nelson's exponential GARCH, with normal or t innovations and AR terms. Six specifications against Stata 18arch, tarch()andarch, earch() egarch(): log-likelihood to 1e-8, coefficients to 2e-4, standard errors to 2e-3. The threshold term is on negative shocks, sogamma = -tarch;sp.from_statatranslates both commands and says so. On the Swiss Market Index the two models sit more than 50 log-likelihood points above GARCH(1,1).VARResult.fevd(ci="asymptotic" | "bootstrap"): standard errors and bands for the variance decomposition. The delta-method standard errors equal Stata'sirf table fevd, stderr.SVARResult.fevd(ci="bootstrap")does the same for a short- or long-run identification.sp.garch(in_mean=True): the conditional variance in the mean (ARCH in mean, Stata'sarchm), for everymodel, withar=anddist="t". Five specifications against Stata 18: log-likelihood to 1e-8, coefficients to 1e-4, standard errors to 2e-3. Stata holds its pre-sample variance fixed while it climbs the likelihood;sp.garchdoes the same, and a test shows that this, not noise, is what separates the two candidate estimates.threshold=sets fewer threshold terms than ARCH terms (arch(1/2) tarch(1)).in_mean="sd"and"log"put the standard deviation or the log variance in the mean (archmexp()), andin_mean_lags=adds lagged terms (archmlags()); six more specifications against Stata to 1e-8 in the likelihood.boot="kilian"insp.irf,VARResult.fevd,SVARResult.irfandSVARResult.fevd: Kilian's bias-corrected bootstrap. In a bivariate VAR(1) with a root of 0.92 and 60 observations, 90% bands for the own response at horizons 4 to 8 covered 38% of the time with the percentile bootstrap, 56% with Hall's, 62% with the delta method and 86% with the bias correction (300 samples). It is the default bootstrap band. Stata'sirf create, bsand R'svarsreport the percentile band, which stays available asboot="efron"; the default departs from them because their band undercovers and the corrected one does not pay for it elsewhere (root 0.6, T = 60: 77% against 89%; root 0.2, T = 80: 93% against 93%). The uncorrected bootstraps warn when the largest estimated root exceeds 0.9.sp.mswitch: Markov-switching regression (Hamilton filter, Kim smoother). The constant, chosen coefficients, the AR terms or the variance depend on an unobserved regime; dynamic regression or Hamilton's autoregression in deviations from the regime mean. Eighteen models against Stata 18mswitch drandmswitch ar: log-likelihood and regime probabilities at Stata's estimates to 1e-11, covariance to 5e-6, estimates within 5e-5 standard errors, transition probabilities and durations with their standard errors. Hamilton's GNP example gives -181.263386 (Stata, slightly short of convergence, -181.263394). Several starting values are tried and the number of distinct maxima is reported. Three places where a reference disagrees with itself or with another are documented with independent evidence in the test file.sp.mswitch_lrtest: a parametric-bootstrap likelihood-ratio test of the number of regimes. The chi-squared reference does not apply to this test. Under a true single regime the bootstrap test rejects 4.5% of the time at the 5% level (400 samples of white noise, T = 100) and 3.5% for an AR(1); a chi-squared with one degree of freedom would reject 37%. Power against two well-separated regimes is 95%. On Hamilton's GNP series the bootstrap p-value of one regime against two is 0.40. Every replicate uses the same search as the data, and replicates with several maxima or failed fits are counted in the result.sp.mswitch(start_params=)takes extra starting vectors.sp.tvp_var_sv: the TVP-VAR with stochastic volatility of Primiceri. Coefficients, contemporaneous relations and log volatilities follow random walks; Gibbs sampler with the indicator ordering of the Del Negro and Primiceri corrigendum. A random estimator, so no parity is claimed. Each block is tested against an exact computation (the Carter-Kohn draw against the Kalman smoother, the inverse-Wishart updates and the indicator probabilities against closed forms), and a joint-distribution test passes with the corrected ordering and fails with the original one. On simulated data 95% bands cover a drifting coefficient 97% and a stepped volatility 88% of the time. Against the R packagebvarsvvolatility medians differ by 1 to 4% on average, a gap of 1 to 3 Monte Carlo standard errors that is not explained. The default prior on coefficient drift (k_Q = 0.01, the paper's) is tight enough to flatten a coefficient that moves from 0.2 to 0.8.sp.tvp_var: a VAR whose coefficients follow random walks. Equation by equation with the Kalman filter and smoother and maximum-likelihood innovation variances (each equation equal tosp.dlm, and to RKFASto 1e-12 under a proper prior), or by the forgetting-factor filter of Koop and Korobilis, which has no parameters to estimate (equal to discounted least squares to 1e-8). Coefficient paths with bands, impulse responses of the VAR frozen at a date, companion roots over time, forecasts.- Exact diffuse initialisation in
sp.kalman_filterandsp.statespace(init="exact", ordiffuse=[...]to mark some states). Filter, smoother and diffuse likelihood equal RKFASto 1e-12 on six models, including missing observations during the diffuse period and time-varying matrices. The default is unchanged.diffuse=also takes a matrixP0_inffor diffuse directions that are combinations of the states. sp.beveridge_nelson(order=(p, q)): the decomposition from an ARMA model fitted by exact maximum likelihood. The cycle equals the long-horizon forecast of the fitted model to 5e-13. Integer orders keep the OLS autoregression and give the same numbers as before.sp.tsfilter: the one-sided (real-time) Hodrick-Prescott filter (one_sided=True), the fixed-length symmetric Christiano-Fitzgerald filter (sma_order=, Stata'ssmaorder(), to 1e-9), and the stationary variant of the CF filter (stationary=True) as the Stata manual defines it. Stata 18 itself computes something else forcf, stationary: the weight on the first and last observation is the ideal weight of the neighbouring lag. A test rebuilds Stata's numbers from that rule to 1e-9.sp.zivot_andrews:lags="ttest", lag selection at every break date (lag_selection="break"), and options that reproduce Stata'szandrews(statistic to 5e-13, break date and lag order equal, 48 cases).sp.read_datareads workbooks written by EViews, whose archive members are named with backslashes (xl\worksheets\sheet1.xml) and which pandas and openpyxl reject. The file is repaired in memory.sp.irf(ci="asymptotic" | "bootstrap"): standard errors and bands for impulse responses. The delta-method standard errors equal Stata'sirf createfor simple, orthogonalised and cumulative responses, with and withoutdfk. The bootstrap resamples residuals and regenerates the series;boot="hall"reflects the percentile band about the estimate.SVARResult.irf(ci="bootstrap")repeats a short-run or long-run identification on every bootstrap sample.sp.johansen_lrtest: likelihood-ratio tests with the cointegration rank fixed. The same restrictions on every vector (beta=), known vectors (beta_known=), restrictions on the loadings (loading=). Equal tourcablrtest,bh5lrtestandalrtestto 1e-8.sp.zivot_andrews: unit-root test with one break at an unknown date. Equal tourca::ur.zato 1e-7, break date included.sp.xcorr: cross-correlogram with optional AR prewhitening and the Haugh test. Equal to Rccfand Stataxcorrto 1e-10.sp.lrvar: long-run variance with five kernels, Andrews and Newey-West bandwidths and prewhitening. Equal to Rsandwichto 1e-9 over 104 cases.sp.periodogramandsp.cumulative_periodogram_test: raw, smoothed and autoregressive spectral estimates (Rspec.pgram,spec.ar, Statapergram), and Bartlett's white-noise test (Statawntestb).sp.tsfilter: Hodrick-Prescott, Baxter-King, Christiano-Fitzgerald, Butterworth and Hamilton filters, with the gain function. Equal to Statatsfilterto 1e-9.sp.beveridge_nelson: trend and cycle of an integrated series from an autoregression of its difference.sp.kalman_filterandsp.statespace: a linear state space model written by the user, with time-varying matrices and missing observations; filter, smoother, forecasts, and maximum likelihood for a parameter vector. Equal to RKFASto 1e-9. The mixed-frequency model of the book's section 17.4 takes a dozen lines.
Fixed¶
ZivotAndrewsResultandJohansenLRTestlacked the agent contract (to_dict,violations,next_steps,result_card,cite) that every result class carries. Four tests of the full suite failed for it on main between the first round of this pass and this fix; the subset of tests run before each push did not include them.-
sp.johansensummary printed the length of the series where the statistics use the observations left after differencing and lagging, and5% CVatalpha=0.01. The result now carriesn_used,alpha,trendandvar_names. Statistics are unchanged. -
sp.arimatries more starting points for a final fit. One with every ARMA coefficient at zero, where R'sarima(method="ML")begins: on Swiss GDP growth ARMA(5,2) stopped at a log-likelihood of -110.13 where R reaches -107.03, and now reaches R's value. And one from the estimates of each model with one AR or one MA term fewer, the dropped coefficient at zero: on the quarterly change of Swiss log GDP, MA(3) stopped at -249.22 with a root on the unit circle, below the MA(2) it nests (-244.90); R'sarimastops at the same point. It now reaches -243.82. A model no longer fits worse than the models it nests. Fits that were at their maximum are unchanged (all 2,497 ARIMA and forecasting tests of the earlier passes, and Track A module 39, to the last digit).
Found in the book¶
- The MATLAB smoother shipped with section 17.4 computes smoothed
variances with a transpose missing (
Pt*F*inv(Ptp1)forPt*F'*inv(Ptp1), and noFon the right). Smoothed means are unaffected; variances are too large by up to 0.134, so the band in the book's figure is too wide.
Thirty-two references added to paper.bib, each verified against the
Crossref and OpenAlex records of its DOI.
A causal-inference textbook's Stata code, run against Stata¶
The Stata code and data of Qiu Jiaping's Practical Econometric Methods for
Causal Inference (chapters 3 to 12) were run in Stata 18 and replayed
through sp.stata, number by number. 2,007 printed numbers are reproduced
(1,561 at the start), none of the commands is refused (26 at the start),
and one number differs, where Stata's own rounding is the cause. The
findings are in docs/dev/2026-10-06-qiu-jiaping-review.md, the reader
guide in docs/guides/qiu_jiaping.md.
⚠️ Correctness¶
- Propensity score matching with a redundant covariate. The logit
behind
sp.match,sp.psmatch2andsp.overlap_weightssolved its Newton step from a singular Hessian when one covariate was a combination of others. Next to a column of very different size (re74^2) the error reached the fitted scores. On the textbook's specification, which lists the same dummy under two names, the ATT was 1562.29 where Stata'sattnd,psmatch2andteffects psmatchgive 1627.36. On the bundled Lalonde data with a repeated dummy it was 605.80 against 468.10. The fit now runs on the standardised design, leaves out a redundant covariate (as Stata does) and warns when it has not converged. Without a redundant covariate the matches and the estimate do not change. The fit is carried further than before (scaled gradient 2e-13, was 2e-9 on the NSW data), so a standard error that uses the fitted model moves in the ninth digit: the Abadie-Imbens (2016) row of Track A module 11 goes from 621.7932847 to 621.7932873, with Stata at 621.7932451. sp.match(ties='all')could miss a control at exactly the same distance. The cut-off was squared with the C library'spowand the distances with numpy. The two differ in the last place for about one number in 600, and a control tied with the nearest one was then dropped. On one specification the ATET was 1148.68 against Stata's 1170.95.teffects psmatchinsp.stataandsp.psmatch2(ties=True)go through the same comparison.- The score the matching runs on is no longer clipped to [1e-6, 1 - 1e-6]. Every control with a score below 1e-6 was at the same distance from a treated unit. Estimates change only where scores that small or that large exist.
sp.stata:replacereads the rows it has just written. Stata replaces one observation after another, soreplace x = 0.4 * l.x + e in 2/l,replace s = s[_n-1] + xandby id: replace v = v[_n-1] if missing(v)build a recursion. They were evaluated on the whole column at once and returned missing values from the third row on, with no error.replace z = z / z[1]now leaveszunchanged from the second row on, as it does in Stata.sp.stata: a lag is read from the data as they are. The column made forL.xwas reused afterxhad been replaced.sp.stata:clear allclears. It was skipped as a display setting, and a second simulation in the same snippet ran on the data of the first.sp.from_stata: an option written with a blank before its parenthesis.reg y x, cluster (id)was translated without the clustering (sp.statarefused the line).select (z1 z2),vce (cluster id)andtitle ("...")are read as Stata reads them.
Added¶
sp.pscore: the propensity score with blocks and a balancing test (Becker and Ichino 2002; Statapscore). It returns the score, the block of every row, the region of common support and the covariates that are not balanced in a block. The number of blocks, the rejections and the counts per block agree with Stata on six specifications.sp.loneway: one-way analysis of variance with the intraclass correlation, its asymptotic standard error and interval, the standard deviations between and within groups and the reliability of a group mean (Stataloneway). Equal to Stata'sr()to 12 digits for balanced and unbalanced groups, the exact interval and the truncated case.pscore=onsp.matchandsp.psmatch2: match on a column that already holds the score (Statapsmatch2 d, pscore(ps)).sp.psmatch2(common_support='treated'): the region ofpscore/attnd, comsup, where controls outside the range of the treated scores are set aside.se='bootstrap'now works withties=True.sp.heckmantwo-step returns the selection equation (model_info['selection_equation']) and the Wald test of the outcome equation.sp.estat(result, 'firststage')adds the p-value, the degrees of freedom and the partial R-squared.sp.stata/sp.from_stata:pscore,attnd,psmatch2 d, pscore(),pstest,DCdensity,loneway,vif,r(att)/r(seatt)afterpsmatch2(sobootstrap r(att): psmatch2 ...runs),d/des.
Second round, the same day¶
- ⚠️
sp.heckmantwo-step with rho outside [-1, 1]. The two-step estimate of rho is not confined to the unit interval. Beyond it the variance formula has negative weights, and the standard errors were too small (by a third on the case that showed it). rho is now set to +/-1 and sigma to|lambda|, as Stata does, with anAssumptionWarning;model_infokeeps the raw value inrho_two_step. Fits with rho inside the interval are unchanged. The summary prints the selection equation. sp.stata: a recursivereplaceof any length. After one evaluation of the column only the rows that read a changed row are evaluated again, so a chain costs one small evaluation per link (50,000 rows in five seconds; it was declined beyond 20,000).sp.match(method='stratify', strata=)takes the strata from a column (the blocks ofsp.pscore): Becker and Ichino'satts, equal to Stata to 13 digits.common_support='treated'works with any method ofsp.psmatch2whenpscore=is given.sp.estat(result, 'firststage')returns the Stock and Yogo critical values for one endogenous regressor (stock_yogo, withminimum_eigenvalue). The table is the one Stata returns inr(mineigcv), read off by running the command. The threshold of 10 is now called a rule of thumb, which is what it is.sp.stata/sp.from_stata:attkandattsare translated.esttabover models from different estimators has one_consrow, and shows N foretregress.
Third round¶
- ⚠️
sp.stata:generate s = sum(x) if dsummed every row above. Stata's running sum advances on the observations the command selects (. 2 . 6 . 12 . 20for the even rows of 1 to 8).sp.statareturned the sum over all rows (3 10 21 36).if,inandbyare now applied before the sum is taken. Asum()without a qualifier is unchanged. sp.stata: areplacethat keeps a running sum of the variable's own earlier rows (replace r = sum(r[_n-1]) + 1) is run in row order, whatever its length.sp.estat(result, 'firststage')computes the Cragg-Donald minimum eigenvalue statistic for any number of endogenous regressors, equal to Stata'sr(mineig)to 12 digits, and attaches the Stock and Yogo values for that combination.sp.psmatch2(method='radius', radius_weights='pairs'): Becker and Ichino'sattr, which counts every treated-control pair within the radius once. It is there so that a result obtained withattrcan be reproduced (equal to Stata to nine digits); the default,'treated', ispsmatch2's radius matching and gives a different number.attris translated with a note that says so.
Changed¶
scripts/stata_log_replay.pyreads logs as UTF-8 (a comment in Chinese made a line unreadable) and comparespscore,attnd,pstest,heckman,etregress,vif,rdplot,rdbwselect,DCdensityandlonewayoutput.
R reference for Callaway-Sant'Anna moved to did 2.5.1 and DRDID 1.3.0¶
Changed¶
- The R reference environment now uses
did2.5.1 andDRDID1.3.0 (was 2.3.0 and 1.2.3). No StatsPAI estimate changes. Every Track A golden value that runs through the two packages reproduces to the last digit (modules 04, 38, 73, 74, 80), and so do the regeneratedtests/reference_parityfixtures (worst gap 1e-15). The one number that moves is the simulated p-value of the functional-form test in module 79, from 0.99998 to 0.99997. It is a count over 100,000 draws and one draw changed side. The eight implied densities next to it are bit-identical. - Two gaps against R are closed by the reference, not by a code change.
On repeated cross-sections and unbalanced panels the aggregated
Callaway-Sant'Anna standard error differed from
did2.3.0 by 0.003% to 2.2% while everyATT(g, t)cell agreed, and two tests carried a 1% and a 3% tolerance for it.did2.5.0 records thataggte()misattributed the estimated-weight influence term on unbalanced panels.did2.5.1 returns what StatsPAI already computed, to 1e-15 on the twenty synthetic cases and 4e-11 onmpdta, and both tests are now held to 1e-8 (test_cs_no_never_unbalanced_parity.py,test_cs_rcs_parity.py). - Analytic cluster-robust standard errors of
sp.callaway_santannanow have an R reference next to the Statacsdidone.did2.3.0 had no analytic clustered standard error, and its clustered bootstrap averaged the influence function within cluster, which is the cluster-robust variance only for equal cluster sizes.did2.5.0 uses cluster sums, as StatsPAI andcsdiddo. On nine clusters of sizes 150 to 1, for balanced and unbalanced panels, both comparison groups and both base periods, every cell and every aggregation agrees to 7e-13 or better (tests/reference_parity/test_cs_cluster_analytic_parity.py, 50 tests). didFF0.1.0 needscontrol_groupspelled out underdid2.5.x. It forwards a length-two default todid, which the old version read as its first element and the new one rejects. Module 79 and two fixture generators now pass"nevertreated"explicitly. The edited scripts give the same numbers underdid2.3.0.
Missing data in sp.path_analysis¶
Added¶
sp.path_analysis(missing='fiml'): full-information maximum likelihood. Each row contributes the likelihood of the values it has, so a row with one indicator missing still informs the rest of the model. The estimates are consistent when values are missing at random given the observed ones; listwise deletion, the default, needs them missing completely at random and discards information either way. Standard errors come from the observed information, the chi-square compares the model with the saturated moments found by EM, and factor scores are computed from whatever each row observes. Rows missing an exogenous variable are still dropped. On four models (a path model, a two-factor CFA, a structural model with a covariate, a growth curve; up to 16 missing-data patterns) the parameter table, the standardised solution, the fit measures and the factor scores equallavaan'smissing = "ml"to 1e-5.se='robust'is refused with it.
Local and hybrid structure learning¶
Added¶
sp.mmpc: the parents and children of every variable, by max-min parents and children or semi-interleaved HITON-PC (method='hiton'). Each variable keeps the others that no subset of its candidate neighbours separates from it, and an edge stays if both ends agree. Categorical data use the likelihood-ratio test, continuous data the exact t test of a partial correlation. On 24 simulated data sets both skeletons equalbnlearn::mmpcandsi.hiton.pc.sp.mmhc: max-min hill climbing. The tests propose the candidate edges and hill climbing on the BIC searches among them only. On the same 24 data sets the score is never belowbnlearn::mmhc's, equal on 18 and higher on 6.sp.bootstrap_edges(method='mmhc')is accepted.
Event-study rank tests and the lmrob covariance¶
Added¶
sp.abnormal_returnsreports two nonparametric tests.res.tests.loc["grank"]is the generalized rank test of Kolari and Pynnonen (2011): each standardised CAR is ranked among that security's standardised estimation-window abnormal returns, and the mean rank on the event day is compared with its variation over all days.res.tests.loc["sign_rank"]is Wilcoxon's signed-rank test on the CARs and equals Statasignrankto 1e-9. Stataestudy'sGRANKwrites L1 where the test has T = L1 + 1 observations; with its constants the statistic here equals its number to 1e-6, with the paper's it is 1.5 percent smaller.estudy'sWilcoxonrow ranks each CAR within its own security's time series and is not a signed-rank test (it reports 96 for twelve securities); the test rebuilds it to show what it is.sp.robreg(vce='avar1')isrobustbase::lmrob's default covariance of an MM fit, equal tovcov(lmrob(...))to 1e-6 withtuning_s=1.54764. It is the stacked sandwich with one entry changed: the sum of squares of the scale equation's score is taken assum(rho^2) - n b^2, where the default (and Statarobreg) squaresrho - bas it is. Standard errors differ by up to 3e-4 at n = 800.sp.from_r("lmrob(...)")now writes this option.
Changed¶
res.testsofsp.abnormal_returnshas two more rows. Code that indexes the table by label is unaffected; code that asserts on every row should note that a signed-rank p-value is bounded below by the number of events (6.5e-4 for fifteen).
Latent variables in sp.path_analysis¶
Added¶
sp.path_analysisfits structural equation models with latent variables.f =~ a + b + cdefines a latent variable by its indicators, and it can then be regressed on, or be a regressor, like any column. That covers confirmatory factor analysis, regressions that correct for measurement error in a predictor, second-order factors and latent growth curves (growth=True).y ~ 1ormeanstructure=Trueadds a mean structure,std_lv=Truescales a latent variable by a unit variance instead of a unit first loading,NA*afrees a loading. The result carriesfactor_scores,latent_covandimplied_mean. On nine models (two-factor CFA under both scalings, latent and observed predictors together, a cross-loading with a residual covariance, equality and fixed constraints, a mean structure, a growth curve) the parameter table, the standardised solution, the fit measures, the Satorra-Bentler standard errors and scaled test, and the factor scores equallavaan0.6-21 to 5e-6, which is where lavaan's optimiser stops. A model whose information matrix is singular is refused; a negative estimated variance is reported with a warning.
Changed¶
sp.path_analysis(auto_cov_y=False)names a default that was already there.lavaan::semlets the disturbances of terminal outcomes covary without being asked;sp.path_analysisfits the model as written, as Stata'ssemdoes. The earlier docstring said the function reproducessem()without that qualification, which was wrong for a model with two or more terminal outcomes. No estimate changes;auto_cov_y=Truegives lavaan's.
Column names that are not identifiers¶
- Backticks in formulas.
sp.regress("`In-game Purchases` ~ `Side-quest Engagement`", data=df)is how R writes a column whose name has a space or a hyphen. It failed in every formula entry point exceptsp.feols, with "Variable(s) not found: ['quest']". Backticks are now read asQ("...")in the one place every formula passes through, sosp.regress,sp.iv,sp.ivreg,sp.logit,sp.probit,sp.glm,sp.poisson,sp.nbreg,sp.ppmlhdfe,sp.qreg,sp.tobitandsp.gamtake them, and terms are reported asQ("name")either way. - ⚠️
sp.iv/sp.ivregmisread a hyphen inside a quoted name. The rewrite of- 1(drop the intercept) was applied to the whole string, soQ("x-1")becameQ("x+ -1")and the call failed. It now applies outside quotes only. sp.ppmlhdfeaccepts a quoted fixed effect (| Q("firm id")),sp.gama quoted smooth variable (s(Q("x 1"))), and the count models a quoted outcome.- A missing quoted column is reported by its whole name.
The rest of the Das list: mediation, FCI, hill climbing, forest tuning¶
⚠️ Correctness¶
sp.fcinow runs the whole algorithm. It stopped after the PC skeleton and four orientation rules. With latent common causes that is not enough: an edge can survive that no subset of either node's neighbours separates but a subset of Possible-D-SEP does, and the remaining rules orient marks the first four leave as circles. The Possible-D-SEP pass and Zhang's rules R1 to R10 are in. On 36 data sets (24 without latent variables, 12 with two or three hidden common causes) the PAG equalspcalg::fcimark for mark; before, the skeleton differed in 14 of the first 24, each time by edges that should have been removed.possible_dsep=Falsegives the earlier skeleton, which is RFCI's.FCIResult.n_removed_by_possible_dsepcounts what the pass removed.sp.causal_discovery(method='fci')is accepted.
Added¶
sp.mediate(inference='robust', outcome_model='logit' | 'probit' | 'poisson'): natural direct and indirect effects on the scale of the outcome's mean when the outcome model is not linear. A binary mediator is integrated out by its two-point distribution, a continuous one by a normal distribution around its regression line. Effects, proportion mediated and standard errors equal Stata 18'smediateto 1e-8 on ten model pairs (probit, logit and Poisson outcomes; linear, logit and probit mediators; with and without the interaction). A logit outcome with a linear mediator, which Stata refuses, is computed by Gauss-Hermite quadrature and checked against direct integration.treat_values=(a, b)contrasts two levels of a treatment that is not 0/1 (Stata'scontinuous()).sp.mediatewithinference='bootstrap'reports a percentile interval for the proportion mediated.sp.hill_climb: score-based structure learning by greedy search on the BIC, for categorical and for continuous data (bnlearn::hc). The score of a graph equals bnlearn's to 1e-12. The search breaks ties between equally good moves deterministically and walks out of equivalence-class plateaus, so a collider is recovered whatever the column order; on five comparison data sets its final score is bnlearn's on four and higher on one.sp.bootstrap_edges: refit a structure-learning algorithm on bootstrap resamples and report how often each edge, and each direction, comes back (bnlearn::boot.strength). Works withpc,hill_climb,ges,fci,lingam,notearsor a callable.sp.tune_causal_forest: random search over the forest's settings on the out-of-bag R-loss, the criterion of grf'stune.parameters. A tuned setting replaces the defaults only if it beats them by more than twice the noise of the comparison. Measured on four designs: the error of the effects falls by a third when the effect is constant, does not move for a step or a fast-varying effect, and rises 7% for a linear one. It is documented as what it is.
sp.gam: surfaces, random intercepts, thin plate splines, a test for each term¶
te(x, z, k=)fits a surface as the tensor product of two P-spline margins, with one smoothing parameter per direction.s(g, bs='re')adds a random intercept for each level ofg; itslambdais the residual variance over the variance of the effects.s(x, bs='tp')is the thin plate regression spline [@wood2003thin], the basismgcvuses when none is named. All three matchmgcv::gamat given smoothing parameters to 1e-9 (coefficients, standard errors, effective degrees of freedom, criterion, fitted values, predictions) and under REML selection to 1e-5 for the surface and the thin plate curve.fit.smooth_termsgainsref_df,statisticandpvalue: the test that a term is zero [@wood2013pvalues], also printed bysummary(). Reference degrees of freedom matchsummary.gamto 1e-9 and p-values to 5e-6. In 300 simulated samples with no effect the rejection rate of a 5% test is inside 2% to 9%. The test is not reported for random effects.fit.partial(term, simultaneous=True)returns a band that holds the whole curve with the stated probability, by simulation from the posterior of the coefficients. The pointwise band holds the whole curve under 80% of the time at a nominal 95%.fit.plot()draws a contour for a surface and estimates with intervals for a random effect.
Counterfactual identification¶
sp.identify_counterfactual(dag, event, given=): the ID* and IDC* algorithms [@shpitser2008complete]. It decides whether a counterfactual probability, such as the effect of treatment on the treatedP(Y[X=0] = y | X = 1), is a function of interventional distributions on a graph with latent confounding, returns the formula, and says for each interventional term whether observational data can stand in for the experiment..estimate(data)evaluates it on categorical observational data when they can,.evaluate(net)in a specifiedsp.bayes_net. Queries no experiment settles (probabilities of necessity and sufficiency) are reported as such. Every formula returned on 1,092 random queries equals the counterfactual probability computed by enumeration in random structural models; the paper's worked example and the query of Ness's listing 10.8 come out as printed; eight textbook verdicts match the R packagecfid, and on 1,118 random queries the verdict agrees with its development version on 95%. This closes the first open item ofdocs/dev/2026-10-06-ness-causal-ai-review.md, which has the details, including wherecfidand this implementation differ.
Path analysis, and two decisions left open by the Das pass¶
⚠️ Correctness¶
sp.matchkeeps every tied match by default (ties='all'). Under matching with replacement the default wasties='first': of several controls at exactly the same distance, the one that came first in the data. The estimate was then a function of the row order. On the Lalonde sample (614 rows, 24 treated units with exact ties) the ATT was 1967.94, and 2012.47 after shuffling the rows. With every tie kept it is 1968.80 in either order, which is Stata'steffects psmatch (re78) (treat ..., logit), atetto the last digit (1968.799715855857), with the Abadie-Imbens (2016) standard error equal to 1e-6 (1126.3212). On the ECLS sample of the Das book the ATT moves from -0.50 (SE 0.25) to -0.12 (SE 0.03). Data without exact ties are unaffected: the NSW-DW parity row against R and Stata does not move.ties='first'remains, warns as before, and is whatsp.psmatch2uses, sincepsmatch2without itstiesoption does the same.model_inforeportsn_units_with_tied_matchesunder either rule.sp.match(estimand='ATE', se_method='abadie_imbens_2016')now runs withoutties='all'having to be passed.
Added¶
sp.path_analysis: structural equation models among observed variables, in lavaan syntax. Regressions with labelled paths (y ~ b*m + c*x), residual covariances (m1 ~~ m2), equality constraints by repeating a label, fixed coefficients, product terms (x:w) and defined effects (indirect := a1*b1 + a2*b2) with delta-method standard errors. It returns the standardised solution, the chi-square test of the over-identifying restrictions, CFI, TLI, RMSEA with its interval and SRMR.se='robust'gives the Satorra-Bentler sandwich and scaled test. On four models (two mediators with a residual covariance, a rejected chain, a constrained model, a saturated one) the parameter table, the fit measures and the robust results equallavaan::semandestimator = "MLM": to 1e-12 where the solution is closed-form, to lavaan's optimiser tolerance elsewhere. This was the largest gap the Das pass found: chapter 11's multiple-mediator model had no counterpart, andsp.semis the spatial error model. Latent variables and mean structures are not implemented and are refused by name.sp.pc_algorithm(collider_conflict='last')reproducespcalg::pcwhen a sample contradicts itself. Two colliders can claim one edge in opposite directions; the algorithm does not say which wins. The default'first'keeps the earlier one, pcalg lets the later one overwrite. On 24 small-sample reference graphs, 14 of them with such clashes, the skeleton equals pcalg's under either rule, the CPDAG equals it in all 24 under'last', and under'first'in exactly the cases whereorientation_conflictsis empty (tests/reference_parity/test_pc_pcalg_parity.py). The default is unchanged.
Bayesian econometrics without PyMC¶
Ramirez-Hassan's Introduction to Bayesian Econometrics (2026) teaches the
subject with MCMCpack, bayesm, coda and BMA. StatsPAI had Bayesian
estimators for causal designs (PyMC) and a Minnesota-prior VAR, and nothing
for the models that book starts with. This pass adds them as NumPy
samplers, checks every one against an exact posterior, and fixes what the
comparison turned up in the existing PyMC estimators. Notes are in
docs/dev/2026-10-06-ramirez-hassan-bayesian-econometrics-review.md; the
user guide is docs/guides/bayesian_econometrics.md.
⚠️ Correctness¶
sp.bayes_ivandsp.bayes_hte_ivunderstated the uncertainty of the effect when the treatment is endogenous. The docstring described a joint model of the two equations. The code computed first-stage residuals once by OLS and used them as a regressor, as if they were data. The posterior mean was 2SLS, but the posterior standard deviation was 2SLS timessqrt(1 - corr(v, eps)^2). With an error correlation of 0.9 and 400 observations the posterior sd was 0.44 of the 2SLS standard error and the 95% interval covered the true effect in 35 of 60 samples. The residual is nowD - E[D | Z, X]as a function of the first-stage parameters, which is the joint normal model. Same design after the change: ratio 1.02, coverage 58 of 60. Results without endogeneity are unchanged; the stronger the endogeneity, the wider the new interval.sp.bayes_iv(prior_first_stage_sigma=)was accepted and ignored. The first-stage coefficients usedprior_coef_sigma. They now use the argument that is named after them (default 5).sp.bayes_mtewith the defaultmte_method='polynomial'did not estimate the marginal treatment effect. It fittedY = alpha + D * g(p), which has no term for how the untreated outcome varies with the unobserved resistance to treatment. With selection on levels,gis neither the MTE nor a LATE: in a Heckman-Vytlacil normal design with MTE(v) = 1 - 0.8 v, two million observations give an intercept of 0.89 and a slope of -0.14. The mode now fits the local IV regressionE[Y | p] = alpha + sum_k b_k int_0^p a(u)^k du(Heckman and Vytlacil 2005), which returns 1.00 and -0.80 on the same data and needs no normality.mte_method='hv_latent'had the same gap and gains a centred polynomial for the untreated outcome (b_sel). In PyMC, over 12 samples of 1,000 with a true ATE of 1.0, the old default averaged 0.89 and covered in 7; after the change the polynomial mode (plug-in and joint first stage),hv_latentandbivariate_normalaverage 0.98 to 0.99 and each covers in 11. Intervals from the polynomial mode are wider than before, because local IV uses the variation in the propensity only.sp.bayes_synthreported intervals that were too narrow. The effect was the treated unit's post-period mean minus the synthetic control's, with only the donor weights uncertain. The model's own likelihood says the treated unit's untreated outcome is the synthetic control plus noise of variancesigma^2; overT1post periods that noise averages to a term of variancesigma^2 / T1, which is unobserved and was left out. In a factor-model design with a true effect of 2 (30 samples) the posterior sd was 0.057 where the estimates varied by 0.093, and 25 intervals covered; now 0.090 and 30. The post-period counterfactual band is predictive for the same reason.sp.bayes_fuzzy_rdtreated the errors of the outcome and take-up equations as independent. They are correlated whenever the effect is not zero, and that correlation enters the variance of the ratio of the two jumps. The posterior mean was unaffected; the interval was too wide by 10 to 25 percent in typical designs (coverage 97 to 98 percent at a nominal 95) and slightly too narrow when the effect and the selection have opposite signs. The two errors are now jointly normal.
Added¶
sp.bayes_regress: Bayesian regression by MCMC with a normal prior on the coefficients.model=is one of'normal'(Gibbs),'conjugate'(closed form),'t'(Student-t errors),'logit','probit','oprobit','poisson','negbin','tobit'and'quantile'. Returns the draws, credible and HPD intervals, effective sample sizes,prob("x > 0"),predict, convergence diagnostics and the log marginal likelihood ('exact','chib','gelfand-dey','laplace'). It warns when the chain mixes badly and when the default prior is not vague at the scale of a regressor.sp.bayes_mixed: hierarchical models for panel data with random intercepts and slopes; normal outcomes by the blocked Gibbs sampler of Chib and Carlin (1999), logit and Poisson outcomes by Metropolis within Gibbs. The default prior of the random-effects covariance adds 0.02 to each sum of squares and is weak on any ordinary scale; the textbook's identity scale isre_prior=(q + 2, 1.0).sp.bayes_ivreg: Bayesian instrumental variables by Gibbs sampling,"y ~ x + (d ~ z1 + z2)"as insp.ivreg. The joint normal model of both equations, so the posterior of the effect has the 2SLS spread under a strong instrument;rho, the error correlation, is the posterior counterpart of a Hausman test. Warns when the first-stage F is below 10. No PyMC needed.sp.dlm: dynamic linear models, that is regression with coefficients that follow random walks ("y ~ 1"is the local level model). Kalman filter and smoother, variances by maximum likelihood or by Gibbs with forward filtering and backward sampling, coefficient paths with intervals, forecasts,constant=for coefficients that do not move. Filter, smoother and likelihood agree with Rdlmto 1e-9; the Gibbs sampler is checked against an exact posterior.sp.bayes_sur: seemingly unrelated regressions (and multivariate regression) by Gibbs sampling.sp.bayes_shrink: the Bayesian lasso (Park and Casella 2008) and stochastic search variable selection (George and McCulloch 1993) with posterior inclusion probabilities; regressors standardised for the prior, coefficients on the original scale.sp.stochvol: stochastic volatility for returns. The log variance is a stationary AR(1) with its own shock; the whole volatility path is drawn in one block and returned with credible bands. Priors as in Rstochvol, whose long runs it reproduces on a committed series. The sampler passes the joint-distribution test (its sweep leaves the prior invariant when the data are redrawn each iteration).sp.bayes_arima: ARIMA(p, d, q) with the exact Gaussian likelihood and a proper uniform prior over the stationary and invertible region, posterior predictive forecasts, and marginal likelihoods for comparing orders. AR(1) and MA(1) posteriors are checked against grid integration.sp.bayes_mvprobitandsp.bayes_mnprobit: multivariate and multinomial probit by data augmentation on the unrestricted covariance, reporting the identified coefficients and correlations. Checked by the joint-distribution test and against importance sampling from the prior. The multinomial probit covariance is weakly identified with decision-maker regressors only and its chain is as slow asbayesm::rmnpGibbson the same data; the result warns.sp.bayes_mixture: mixtures of normal regressions with a fixed number of components or a Dirichlet process (components='dp'), by a collapsed Gibbs sampler. Number of clusters, posterior similarity matrix, a point partition, and for"y ~ 1"the density with bands. With eight observations the sampler reproduces the posterior obtained by enumerating all 4,140 partitions.sp.gp_regress: Gaussian process regression with RBF and Matern kernels, one length scale per regressor, hyperparameters by maximum marginal likelihood, and a constant mean estimated with its uncertainty in the bands. Agrees with scikit-learn to 1e-10 at fixed hyperparameters.sp.bart: Bayesian additive regression trees for continuous and binary outcomes, on an engine written from the published model (numba, birth and death moves). Posterior bands, prediction at new points, share of splitting rules per regressor. The sampler is checked against the exact posterior of a model small enough to enumerate and by the joint-distribution test for a sum of trees; on a Friedman sample its test error is within the range of Rdbartsacross seeds.sp.abc: inference for models that can only be simulated. Rejection ABC with an optional local linear adjustment, and Bayesian synthetic likelihood. All three are checked against exact posteriors.sp.bayes_regress(model='mlogit'): multinomial logit.sp.bayes_regress(inference='vb'): mean-field variational Bayes for the normal model, with the evidence lower bound.sp.bma: Bayesian model averaging.method='bic'with Occam's window for Gaussian, binomial, Poisson and gamma outcomes, andmethod='gprior'with the benchmark g-priors for Gaussian outcomes, by enumeration or MC3.always=keeps the regressor of interest in every model. The models inside Occam's window are found by an exact branch and bound.sp.bayes_factorandsp.savage_dickey: Bayes factors and posterior model probabilities from marginal likelihoods, and the density-ratio test of a point restriction.sp.bayes_bootstrap: Rubin's Bayesian bootstrap of a regression or of any weighted statistic.- MCMC diagnostics for any chain:
sp.mcmc_summary,sp.mcmc_ess,sp.hpd_interval,sp.geweke_diag,sp.raftery_diag,sp.heidel_diag,sp.gelman_rubin.
Evidence¶
- The diagnostics agree with R
coda0.19-4.1 to 1e-9 on committed chains.sp.bmaagrees withBMS::bmsto 1e-9 and withBMA::bic.glmto 1e-9 in the model probabilities of the logit and Poisson families.BMA::bicregcomputes BIC from an R-squared rounded to five decimals; rounding ours the same way reproduces its BIC to 1e-9 (tests/reference_parity/test_bayes_mcmc_parity.py). - Every sampler is compared with the exact posterior of a small model,
integrated on a grid from
scipy.statsdensities that share no code with the sampler: posterior means within four Monte Carlo standard errors, standard deviations within 5 percent, marginal likelihoods against the exact normalising constant (test_bayes_regress_exact_posterior.py,test_bayes_mixed_exact_posterior.py). - The book's examples on its own data against long runs of
MCMCpackandbayesmare a stochastic screen, not a parity claim (tests/external_parity/test_ramirez_hassan_bayes.py). sp.bayes_ivregis checked against its exact posterior with the error covariance integrated out in closed form, andsp.bayes_iv(PyMC) againstsp.bayes_ivreg.- Known-truth screens of the other PyMC estimators, 40 samples each:
sp.bayes_rdandsp.bayes_itshave posterior standard deviations within 2 percent of the least squares standard errors and coverage of 37 and 39 out of 40.sp.bayes_didhas a posterior standard deviation of 0.107 against the analytic 0.105 and covers in 36 of 40.sp.bayes_dml(60 samples): posterior standard deviation 0.0365, estimates vary by 0.034, 57 cover.
Changed¶
sp.bvar(...).summary()labels the coefficient rows (L1.gdp, ...,_cons);coef_namesandcoef_table()expose them.
Where StatsPAI does not follow the reference packages¶
- Quantile regression estimates the scale of the asymmetric Laplace
likelihood.
MCMCpack::MCMCquantregfixes it at one, which makes the width of the credible intervals depend on the units of the outcome.scale=1.0reproduces it. - The ordered probit always has free cutpoints. The book's example passes
a design without a constant to
bayesm::rordprobitGibbs, whose first cutpoint is fixed at zero; the resulting coefficients are more than three standard errors from maximum likelihood. - The multivariate Gelman-Rubin factor uses the number of chains, as in
Brooks and Gelman (1998).
codahas the number of parameters there. MCMCpack::MCMChregressreports a posterior sd of 0.008 for the intercept of the book's public-capital panel, below the floorsqrt(0.106 / 48) = 0.047that the model's own state-effect variance implies.sp.bayes_mixedgives 0.17 under the same prior, and thelme4standard errors within 3 percent under its default prior.- The book's prior for that variance component,
InvWishart(5, 5), supplies 91 percent of its sum of squares; the posterior mean is 14 times the REML estimate.sp.bayes_mixeddoes not use that prior by default, reports the prior's share of every variance component, and warns above 25 percent.
What a graph-first causal inference book found¶
Ness's Causal AI builds a DAG, tests it, fits a model on it, intervenes,
identifies an estimand and only then estimates, with pgmpy, y0, DoWhy and
Pyro. Each step was redone with StatsPAI on the book's data and compared
with the numbers the notebooks print and with independent R packages
(dagitty, bnlearn, pcalg, causaleffect). The econometrics textbooks probed
estimators. This one probed the layer that says which estimator a graph
licenses, and most of the fixes are there. Notes are in
docs/dev/2026-10-06-ness-causal-ai-review.md; the user-facing map is
docs/guides/ness_causal_ai.md.
⚠️ Correctness¶
sp.dagdropped every arrow written right to left."X <- Z"was skipped, sosp.dag("X <- Z; Z -> Y; X -> Y")had no confounding arrow andadjustment_setsreturned the empty set as valid."A -> B <- C"made a node called"B <- C". Arrows now read either way and chain. A name alone declares a node. An undirected edge, an unreadable statement or a cycle raises; all three used to be skipped.DAG.adjustment_setsreturned no set when the smallest had seven or more variables. The search stopped at six, and an empty list means "not identified by adjustment": with eight observed confounderssp.dag_recommend_estimatoranswered "Not identifiable under the declared DAG". Existence is now decided exactly at any size [@vanderzander2019separators]; larger sets are found by pruning the ancestral one. Same sets as dagitty on the reference graphs.DAG.backdoor_pathslisted paths that do not enter the exposure. It returned every non-causal path, includingX -> M -> C <- Y. As a resultclassify_variablecalled mediators and the parents of an M-bias collider confounders, andsummarydrew every edge as→.backdoor_pathsnow returns backdoor paths,noncausal_pathsthe rest too, andpath_statushas a third type,'noncausal', and an'arrows'field with the path as drawn.sp.identifytreated nodes namedU_...as unobserved. Nothing documented it.adjustment_setsoffered such a node as an adjustment variable whileidentifyreported the same graph as not identifiable.sp.front_doorreturned a shrunken estimate when the mediator did not vary within a treatment arm. The regression of Y on M in that arm has no M coefficient, least squares set it to zero, and the effect was scaled by the other arm's share (153.81 on the book's data, where nobody untreated hasM = 1). It now raisesIdentificationFailure.outcome_model='additive'extrapolates explicitly under no D-by-M interaction and reproduces DoWhy'sfrontdoor.two_stage_regression(170.20560581290403) to 1e-12.sp.pc_algorithmlost edges and searched the wrong conditioning sets. When two colliders claimed one edge in opposite directions the edge was deleted from the CPDAG: 85 of 200 draws of a six-variable design at n = 120 lost at least one. Conditioning sets were drawn from the union of both neighbourhoods, updated within a level, which is neither PC nor PC-stable and depended on column order. The search is now PC-stable [@colombo2014order] and returns the skeleton, separating sets and (absent clashes) CPDAG ofpcalg::pc. Clashes are kept in node order and returned inorientation_conflicts. Meek's third rule was missing and is applied. Graphs learned with earlier versions can differ.sp.fcistopped its skeleton search at the first level that removed no edge. With three common causes of X and Y the spuriousX - Yedge survived at any sample size. It now uses the same search assp.pc_algorithm. Edge labels were also wrong at the left end: a bidirected edge readX >-> Y(nowX <-> Y) andX <-- YreadX >-- Y. The graph now equalspcalg::fcimark for mark on three designs, one with unobserved common causes.
Added¶
sp.bayes_net: a discrete causal Bayesian network. Tables are estimated from categorical data (with an optional Dirichlet prior), written by hand, or given as functions of the parents. Exact inference by variable elimination:query(variables, evidence=, do=),prob,expectation(with a utility per state),do,simulate,cpt, andcounterfactualby a twin network when every non-root node is deterministic. A query that depends on a parent configuration with no data warns that positivity fails. Reproduces the book's kernels, queries, expected utilities and Monty Hall counterfactuals, bnlearn's kernels, and enumeration to 1e-13.sp.identify(...).estimate(data): the ID estimand evaluated on categorical data, using observed columns only, with an optional bootstrap. Treatment values whose formula needs a conditional probability with no data come back missing with a warning.sp.refute: refutation tests for any effect estimator (placebo treatment, dummy outcome, random common cause, data subset), with Monte Carlo p-values that the reruns are centred where they should be.- Categorical conditional-independence tests.
DAG.test_implications(df)uses a chi-square test when the columns are labels (test='g-test'for the likelihood ratio) and reports Cramer's V.sp.pc_algorithm(ci_test='chi-square' | 'g-test')learns from categorical columns. Statistics, degrees of freedom and p-values equaldagitty::localTests(type='cis.chisq')andbnlearn::ci.test(x2-adf,mi-adf). sp.front_door(outcome_model='additive').DAG.noncausal_paths,DAG.format_path.
Changed¶
sp.dagreads quoted names, Graphvizdigraph { ... }and dagittydag { ... }wrappers, and{A B} -> Cgroups. A DOT string used to yield node names with the quotation marks in them.- The estimand
sp.identifyprints no longer conditions on variables the graph d-separates from the factor. For the book's gaming graphP(Purchases | seven variables)becameP(Purchases | four parents). The value is the same. sp.dag_recommend_estimatorlists every strategy the graph licenses (the front door and an instrument next to the backdoor), and writes column names that are not identifiers asQ("...")so the call runs.sp.ipwstoresess_treated,ess_controlandess_ratio_minand raises anAssumptionWarningwhen an arm's effective sample size is under a fifth of the arm;result.violations()reports the same.- A treatment column of labels (
'high'/'low') raises an error that names the column and the recode, insp.ipw,sp.aipw,sp.g_computation,sp.tmle,sp.metalearner,sp.front_door,sp.ebalance,sp.cbpsandsp.dml. It was numpy's "could not convert string to float". sp.pc_algorithm,sp.ges,sp.notears,sp.lingamandsp.fcisay that non-numeric columns are the reason they cannot run and point toci_test='chi-square'.sp.pc_algorithmrefuses aci_testit does not implement (the docstring advertised'hsic').DAG.path_statusandDAG.all_pathsreturn paths in a fixed order.
What a statsmodels time-series textbook found¶
Maitra's A Practical Guide to Static and Dynamic Econometric Modelling
teaches diagnostics, asset-pricing regressions, ARDL, ARIMA, VAR and VECM
with statsmodels on series downloaded from FRED and Yahoo Finance. Each
computation was rerun with StatsPAI and compared with statsmodels, R 4.5.2
and Stata 18. Notes are in
docs/dev/2026-10-06-maitra-static-dynamic-review.md.
⚠️ Correctness¶
sp.arimaon a differenced series depended on the unit of measurement. The level of an integrated series was started from a normal prior of variance 1e6, the statsmodels default. That prior is diffuse only against an innovation variance far below 1e6. For ARIMA(1, 1, 0) on quarterly U.S. GDP the AR coefficient was 0.170 with GDP in billions of dollars, 0.216 in trillions and 0.025 in millions. It is now 0.216 in every unit, the estimate of R'sarima(method="ML")and Stata'sarima, and the log-likelihood agrees with both to seven digits. The filter now uses the exact diffuse initialisation, and thed + sDobservations that identify the integration states are left out of the likelihood. A model withd = 0, or with no AR or MA term, is unaffected.sp.arima(method='innovations_mle')reported the wrong log-likelihood for a differenced model. Its coefficients were right, but the log-likelihood, AIC and BIC came from the same filter. For ARIMA(0, 2, 1) on GDP it reported -408.62 where R gives -400.13.sp.arima(auto=True)never considered a drift, and could select a model fitted on the unit circle. After one difference only models without a constant were searched, so a trending series was fitted with an AR root at one or differenced again. The constant is now part of the search whend + D <= 1, as in R'sforecast::auto.arima, and a candidate with an AR or MA root within 1% of the unit circle is dropped. On thirteen series the order, the differencing, the constant and the AICc matchauto.arima(stepwise=FALSE, approximation=FALSE). Passtrend='n'ortrend='c'to fix the constant yourself.sp.arimacomputes AIC, BIC and AICc from the log-likelihood, the number of estimated parameters and the number of observations after differencing, as R and Stata do.sp.johansen(test='maxeig').summary()headed its column "Trace stat" and wrote the null asr <= k. It now reads "Max-eig stat" andr = k. The statistics were right.
Added¶
sp.chow_test: the Chow test for a break at a date fixed in advance. All coefficients or a subset, one break or several, classical or robust covariance. The break is the number of rows in the first regime, a value of a time column, or an index label. Agrees withstrucchange::sctest(type="Chow")to 12 digits and, in its Wald form, with Stata'sestat sbknown.sp.structural_break(method='chow')is unchanged: it searches for the date and uses the sup-F reference.sp.bds: the BDS test that a series is independent and identically distributed, usually run on residuals. Takes a series or a fitted regression. Reproducesstatsmodels.tsa.stattools.bdsexactly.sp.rolling: rolling or recursive least squares from a formula, with coefficients, standard errors (classical or HC0 to HC3), R-squared and root MSE per window. Agrees with statsmodels'RollingOLSto 1e-13.sp.cusum_test(method='ols'): the CUSUM test on OLS residuals, whose boundary is a constant. Agrees withstrucchangeto 12 digits and with Stata'sestat sbcusum, ols. The default is still the recursive-residual test, and the result gains amethodkey.sp.corrgram(model_df=, boxpierce=): degrees of freedom reduced by the ARMA coefficients behind a residual series, and the Box-Pierce statistic. Agrees with R'sBox.test(fitdf=).sp.ardl(x_lags='bic')or'aic'searches the lag order of every regressor, jointly with the autoregressive order whenlagsnames the same criterion. Selects whatardl_select_orderselects.ARDLResult.forecast(steps=, exog=): dynamic forecasts several periods ahead, with future values of the regressors where the model needs them.ARDLResult.long_run(): long-run effects with delta-method standard errors, equal to statsmodels'UECM.ci_paramsandci_bse.VECResult.forecast,.irfand.fevd: forecasts with intervals, impulse responses and the variance decomposition of the levels. Equal to statsmodels'VECMfor the five deterministic cases and tovars::vec2varin R.sp.engle_grangerreports MacKinnon's p-value (result.pvalue), the one statsmodels'cointgives, and the result has a readablerepr.
Changed¶
sp.arimasearches for the maximum on a standardised copy of a badly scaled series and maps the estimates back. With GDP in billions the drift of ARIMA(1, 1, 0) was 249.09 where the maximum is at 248.90.sp.swilkandsp.sktestaccept a Series or a one-dimensional array, andsp.ttest(series, mu=...)tests it against a value.sp.qreg(quantile=[...])says to usesp.sqreg. It raised aTypeErrorfrom a comparison.result.get_influence()andresult.outlier_test()namesp.influence_measures.
What an applied R causal-inference book found¶
Das, Causal Inference in R (Packt), works through propensity scores,
graphs, instruments, mediation, causal forests and causal discovery with
MatchIt, dagitty, AER, mediation, grf, pcalg and bnlearn. Its 32 scripts
were rerun in R 4.5 and each computation repeated with StatsPAI on the same
data, with Stata 18 as a second reference where it has the command. Most
of it reproduced to the last digit. What did not is below; notes are in
docs/dev/2026-10-06-das-causal-inference-in-r-review.md.
⚠️ Correctness¶
CoxResult.ph_test()was not the Grambsch-Therneau test. It returnedn * rho^2withrhothe Spearman correlation between time and the unscaled Schoenfeld residuals. On the book's maintenance data that gave 0.14 wheresurvival::cox.zphgives 1.03. It is now the score test ofcox.zph(survival 3.0 and later), withtransform='km'by default and'rank','identity','log'or a callable as alternatives, adfcolumn and aGLOBALrow. It agrees withcox.zphto 1e-6 under either tie rule, with and without strata.method='approx'gives the 1994 approximation that Stata'sestat phtestreports, and matches it for all four time functions. Theph_testviolation thatsp.coxstores is computed from the new statistic.- The overdispersion test of
sp.poissoncould not detect overdispersion. The auxiliary regression had a constant in it and was tested on the slope, which measures how the excess variance changes with the mean. Counts with a Pearson dispersion of 12 passed with p = 0.44. It is now the Cameron-Trivedi regression without intercept of((y - mu)^2 - y) / muonmu, the statistic ofAER::dispersiontest(trafo = 2)(3.9747 on those counts, in both).diagnosticsgains'Dispersion (Pearson chi2 / df)'and'Overdispersion alpha (C-T)'.
Added¶
sp.power_ttest: power, sample size or detectable difference of a one-sample, two-sample or paired t test from the noncentral t distribution. Give two ofn,deltaandpower. Agrees with Stata'spower onemean / twomeans / pairedmeansto 1e-10 and withpwr::pwr.t.testto its solver tolerance.sp.power_rctis the normal approximation and is unchanged; with 15 per arm and an effect of one standard deviation it reports 0.78 where the t test has 0.75.sp.esize: Cohen's d, Hedges's g, Glass's Delta and the point-biserial correlation for a difference between two group means, with noncentral-t confidence intervals. Equal to Stata'sesize twosample, allto 1e-9, with and withoutunequal.sp.cor_test: Pearson's correlation, or the partial correlation given covariates, with its t test and Fisher-z interval (Rcor.test,ggm::pcor.test).sp.adjust_pvaluesgains'hochberg','hommel','by'(Benjamini-Yekutieli) and'sidak'. With the existing three these are the methods of R'sp.adjust, to 2e-15.sp.ivandsp.ivregread the two-part formula ofAER::ivreg,"y ~ x + d | x + z". A regressor missing from the instrument list is endogenous.
Changed¶
sp.matchwarns whenties='first'chose among equally close matches. On the ECLS sample the book uses, three covariates give a propensity score with 91 distinct values over 9,289 children. The default kept the first control in data order for each, so 1,357 treated children were matched to 52 controls and the ATT was -0.50 (SE 0.25). With every tied control kept (ties='all') it is -0.12 (SE 0.03). The numbers are unchanged; the warning states how many units had ties and how many controls the estimate rests on, andmodel_infocarriesn_units_with_tied_matchesandn_tied_matches_left_out.- The error for an unknown
sp.adjust_pvaluesmethod lists method names. It listed function objects. docs/guides/migration-from-r.md: theAER::ivregrow showed a call that raises (instruments=), and thepcalg::fcirow a dispatcher method that is refused. Both corrected, and a section added for base-R tests,effsize,pwr,cox.zphanddispersiontest.
The same rerun found that sp.pc_algorithm drops edges pcalg::pc keeps.
A parallel pass (Ness, Causal AI) reached that function first and carries
the fix; this one leaves causal_discovery/ and dag/ alone. The notes
record a 24-case pcalg comparison for whoever closes it.
What an accounting research textbook found¶
Gow and Ding's Empirical Research in Accounting: Tools and Methods
(2024) teaches the field's methods in R, with fixest, plm, sandwich
and robustbase and the companion package farr. Most chapters need CRSP
and Compustat through WRDS. The ones that do not, and the simulated or
packaged-data parts of four others, were rerun in R 4.5.2 and recomputed
with StatsPAI; what changed was then tested on committed synthetic files
against R and Stata 18. Notes are in
docs/dev/2026-10-06-gow-ding-accounting-review.md; the user-facing map is
docs/guides/empirical_accounting_research.md.
⚠️ Correctness¶
- Two-way clustered standard errors when the covariance matrix is not
positive semi-definite.
sp.regress(cluster=[a, b])andsp.panel(..., cluster=[a, b])build the covariance asV_a + V_b - V_ab, which can have negative eigenvalues when one dimension has few clusters. A negative variance was reported as a standard error of 0, with no warning, and the matrix behind the standard errors was not stored, so.vcov()raised. Negative eigenvalues are now set to zero, the adjustment of Cameron, Gelbach and Miller (2011) thatfixestandreghdfeapply, aRuntimeWarningsays so, and the matrix is stored. On the book's accruals regression (8,850 firm-years, 21 years, 85 coefficients) 46 eigenvalues are negative and the reported standard errors rise by 4 to 19 percent; they now equalfixest's to 1e-12 when both use the same base year. Fits whose covariance was already positive semi-definite are unchanged.sp.feolsapplies the same adjustment to pyfixest's matrix, where a negative variance was a missing standard error and the others were 4 to 19 percent too small on that regression; it now equals R'sfixestto 1e-12 there too. sp.poissonfailed on a quasi-separated model, with "SVD did not converge". A regressor that picks out only zero outcomes has no finite coefficient; the iteration tested convergence on the coefficients alone and walked that one towards minus infinity until the weights underflowed. It now also stops when the deviance is flat, reports the other coefficients (equal to R'sglmto 1e-9), warns, and lists the regressor inmodel_info['separated_terms']. The starting values areglm's (mu = y + 0.1); the old ones could overflow on counts in the thousands. Converged fits are unchanged.sp.from_rtranslatedmatchit()to a call that gave another estimate. Without replacement the matched pairs depend on the order in which treated units choose. MatchIt starts from the largest propensity score;sp.matchhas a different default, and the translation left the order out. On a 1,200-unit file the translated call returned 0.533 where MatchIt returns 0.542. The translation now writesm_order='largest'(or them.ordergiven), and the six calls tested reproduce MatchIt 4.7.2 to 1e-15.sp.from_rdropped the clustering of afeolscall without a note.feols(y ~ x | f, ~ firm + year, data = d)andvcov = ~ firm + yeartranslated tosp.feols('y ~ x | f', data=df). Both forms now givevcov={'CRV1': 'firm + year'};vcov = "iid"and"hetero"carry over, and anything else is listed as untranslated.
Added¶
sp.fama_macbeth: one cross-sectional regression per period, the coefficients averaged, standard errors from their variation over time,lags=for the Newey-West correction of the coefficient series.plm::pmgandsandwich::NeweyWestto 1e-15, Stataxtfmbto 1e-6 (it keeps the per-period estimates in single precision).sp.robreg: M, S and MM robust regression. The S step is the fast-S algorithm with a subsampling scheme that also works with indicator regressors. Coefficients, scale, robustness weights and the sandwich covariance equal Statarobregto 1e-8; coefficients, scale and weights equalrobustbase::lmrobto 1e-9;method='m'withvce='huber'equalsMASS::rlmto 1e-10.sp.itcv: Frank's impact threshold for a confounding variable, the impacts of the observed controls as a benchmark, and the percent bias and RIR of Frank et al. (2013). Equal to Statapkonfoundto 1e-15.sp.ndcg: normalised discounted cumulative gain at k, the ranking measure of the fraud-prediction literature, next tosp.auc.sp.abnormal_returns: the event study of finance and accounting. Abnormal returns around each event from a market, market-adjusted, mean-adjusted or factor model fitted on a pre-event window, cumulative abnormal returns with forecast-error standard errors, and five tests of their mean (cross-sectional t, Patell, BMP, and the two Kolari-Pynnonen adjustments for clustered event dates). Against Stataestudyon a committed file: abnormal returns and CARs of all four models to 5e-6 (estudyworks in single precision), the tests to 1e-9 given the same standardised CARs, and end to end to 5e-5 for the two models whose standard deviations follow the same rule.estudy's market-model standard deviation usesn - 1where the forecast-error variance hasn - 2, and its factor-model one leaves out the coefficient-error term; both numbers are rebuilt from ours in the test. Its group CAAR is not the mean of its own security rows and is not compared.sp.feolsIV results carry the first-stage F, the Sargan statistic and the Wu-Hausman test, R fixest'sfitstat(m, ~ ivf1 + sargan + wh), to 1e-13 on the book's example and equal tosp.ivregwith the fixed effects written as dummies.sp.estat(result, "firststage" / "overid" / "endogenous")reads them; it returned empty results for asp.feolsfit.sp.regress(robust='hac', hac_panel=(unit, time)): Newey-West within panels at exact time lags, in any row order.plm::vcovNWto 1e-15 and, withhac_small=True, Statanewey ..., forceto 1e-9, gaps included. Without it a stacked panel is read as one long series.sp.winsor(..., trim=True)sets the tails to missing instead of pulling them in (winsor2, trim;farr::truncate).factor(x)andas.factor(x)in a formula are read asC(x), in every estimator. They raisedNameError.sp.from_rtranslatespmg,lmrob,rlm,rdrobust,binom.testandlinearHypothesis;matchit(!d ~ x)andcaliper=are carried into the call.sp.from_stata/sp.statatranslatextfmb,robregandneweyonxtsetdata.
What a forecasting textbook found¶
Hyndman, Athanasopoulos, Garza, Challu, Mergenthaler and Olivares,
Forecasting: Principles and Practice, the Pythonic Way, runs on
statsforecast, statsmodels and hierarchicalforecast. Its methods come from
the first author's R packages. Chapters 3 to 13 were rerun on the book's
data three ways: with the book's libraries, with R (forecast 9.0.2,
hts 6.0.3) and with StatsPAI. Notes are in
docs/dev/2026-10-06-hyndman-fpp-pythonic-review.md; the user-facing map
is docs/guides/forecasting_fpp.md.
⚠️ Correctness¶
sp.arimacould return an inferior local maximum without a warning. The fit was one quasi-Newton search. Over 32 models on nine of the book's series it ended more than1e-3log-likelihood units below the maximum in 13: an MA(3) for Egyptian exports at -186.84 where the maximum is -183.35, an ARIMA(1,1,1) with drift for egg prices at 58.17 where R reports 59.00, a seasonal model stopped on the invertibility boundary. The search also ran at most 50 iterations, and an ARIMA(3,1,3)(0,1,1)[4] stopped at 323.81 where the maximum is 326.47. Every fit now starts twice, the second time from conditional-sum-of-squares estimates as R'sCSS-MLdoes, and is checked by simplex searches followed by a quasi-Newton polish. A fit that still does not converge raisesConvergenceWarning. Estimates of models where the single search had found the maximum are unchanged.method='innovations_mle'is unchanged.- AICc of a seasonally differenced
sp.arimamodel usedn - dobservations in its small-sample term. It usesn - d - D s, asforecast::Arimadoes. AIC and BIC were right. -
sp.arima(auto=True)ranked candidates fitted with at most 50 iterations and had no seasonal search. It now follows Hyndman and Khandakar (2008); see Changed. -
import statspaifailed on Python 3.11. A dataclass inregression/gam.pyusedslice(0, 0)as a field default; dataclasses on 3.11 refuse an unhashable default, andslicebecame hashable only in 3.12. The default is now a factory. Python 3.10, 3.12 and 3.13 were not affected.
Added¶
sp.ets: exponential smoothing as innovations state space models. Simple exponential smoothing, Holt's linear and damped trend, Holt-Winters additive and multiplicative, each with an additive or a multiplicative error, fitted by maximum likelihood with the initial states estimated.model="ZZZ"chooses the form by AICc. Forecasts carry analytic prediction intervals, and simulated ones for the models that have none. At the parametersforecast::etsestimates, the log likelihood, fitted values and intervals equal R's to1e-13on 15 models. The optimiser is restarted, so the likelihood reached is at least as high as R's on every model tested. The forecast variance of ETS(A,N,A) and ETS(M,N,A) inforecast9.0.2 entersgammaone lag late (fixed in its development version);sp.etsfollows the formula of Hyndman, Koehler, Ord and Snyder (2008), which a simulation confirms.sp.simple_forecast: the mean, naive, seasonal naive and drift methods with normal or bootstrapped intervals. Equal tonaive,snaiveandrwf(drift=TRUE)to1e-14.sp.forecast_accuracy: ME, RMSE, MAE, MPE, MAPE, MASE, RMSSE and the first autocorrelation of the errors for one or several forecasts. Forecast tables with intervals add the Winkler score and coverage, and the CRPS on request. Equal toforecast::accuracyto1e-14.sp.tscv: cross-validation on a rolling forecast origin with an expanding or rolling window, for the built-in methods or any function of the training window. The error matrix equalsforecast::tsCV. Origins where the forecaster fails are reported, not dropped.sp.stlandsp.classical_decompose: STL with R's settings, a periodic seasonal option, robustness iterations and MSTL for several seasonal periods. The result has the seasonally adjusted series, the strength of trend and of seasonality, and forecasts that model the adjusted series. Components equalstats::stlandforecast::mstlto1e-10.sp.ljungbox: Ljung-Box and Box-Pierce tests with the degrees of freedom reduced by the number of fitted ARMA parameters. It accepts residuals or a fittedsp.arima,sp.etsorsp.simple_forecastresult. Equal toBox.test(fitdf=).sp.ndiffs,sp.nsdiffs: the number of regular and seasonal differences, by KPSS tests and by seasonal strength.sp.boxcox_lambda: Guerrero's variance-stabilising Box-Cox parameter.sp.fourier_terms: sine and cosine regressors for long, non-integer or multiple seasonal periods.sp.hierarchyandsp.reconcile: build a hierarchical or grouped structure with its summing matrix, and reconcile base forecasts bottom-up, top-down, by OLS, structural or variance weights, or by minimum trace with a shrunk or sample covariance. Equal tohts::MinTandcombinefto1e-14.boxcox=andbiasadj=insp.ets,sp.arimaandsp.simple_forecast: model a Box-Cox transform of the series (a number or"auto"for Guerrero's choice) and back-transform forecasts and intervals, to the median or, withbiasadj=True, to the mean. Equal toforecast::rwf/snaivewithlambda=to1e-15.sp.etsaccepts missing values between the first and the last observation. A missing period moves the states on with a zero innovation and adds nothing to the likelihood. R'setskeeps the longest stretch without gaps.sp.reconcile(method="middle_out", middle=...)andproportions="forecast"(top-down by forecast proportions). Equal to hierarchicalforecast'sMiddleOutandTopDownto1e-15.sp.ts_features: 29 summary features of a series or of every column of a table of series (autocorrelation sums, strength of trend and seasonality, spike, linearity, curvature, lumpiness, stability, crossing points, flat spots, level and variance shifts, ARCH effect). Equal to Rtsfeaturesto1e-13.sp.bootstrap_seriesandsp.bagged_forecast: bootstrapped versions of a series by Box-Cox, STL and a moving block bootstrap of the remainder, and forecasts averaged over them (bagged ETS).sp.seasonal_dummies: seasonal indicator regressors.sp.reconcile(sd=...): reconciled standard deviations andresult.intervals(level), the normal prediction intervals of the coherent forecasts. Interval widths equal hierarchicalforecast'sNormalitymethod to1e-14for bottom-up, OLS and structural weights.ARIMAResult.forecast(level=(80, 95), exog=..., dof_adjust=...): several coverage levels, future values of the regressors, and the degrees-of-freedom adjusted variance thatforecast::Arimauses. Forecasting a model with regressors used to raise a statsmodels error.ARIMAResult.candidateslists the models an automatic search compared.
Changed¶
sp.arima(auto=True)is the search of Hyndman and Khandakar.period=adds seasonal orders, with the seasonal difference set by seasonal strength. A mean (no differencing) or a drift (one difference) is included when AICc prefers it. The search is stepwise from four standard models;stepwise=Falsefits every model up to total order five. A model with a root of modulus below 1.01 is not returned. On the fifteen series of the book compared, it returns the model R'sauto.arima(approximation=FALSE)returns.sp.arimaestimates a differenced model on the differenced series and then computes fitted values and forecasts from the level model with the exact diffuse start described under the dynamic-modelling audit below. The likelihood is the same one; the search is several times faster. It is the likelihood Stata maximises and the one R'sarimagives for the differenced series. On ten models of the book's series where the old search had converged, log likelihoods move by less than1e-3and coefficients by at most2e-3. Forecasts now agree withforecast::Arimato about1e-6.sp.arimais faster. The likelihood of the differenced series is evaluated by the innovations algorithm, compiled with numba. It is the number the Kalman filter returns, to1e-8, about fifteen times faster.auto=Truewithperiod=12takes 2 to 7 seconds on the book's monthly series; it took one to five minutes, and R takes 5 to 16 seconds withapproximation=FALSE. numba is imported on the first call tosp.arima, not onimport statspai.- Regressors of
sp.arimakeep their names inparams. They werex1,x2. ARIMAResult.forecast()is indexed by the forecast periods when the series had a date or period index.
What a Python econometrics textbook found¶
Dogan's Introduction to Econometrics with Python follows Stock & Watson
with every example written for statsmodels, linearmodels, scikit-learn and
arch. Each computation was rerun with StatsPAI on the book's data and
compared with those libraries and with Stata 18. Notes are in
docs/dev/2026-10-05-dogan-python-econometrics-review.md; the user-facing
map is docs/guides/migration-from-statsmodels.md.
⚠️ Correctness¶
sp.arimanow reports the exact maximum likelihood estimates. The default method started the Kalman filter from a diffuse prior and left the firstmax(p, q + 1)observations out of the likelihood. On 223 quarters of U.S. GDP growth an AR(1) gave a constant of 2.942 and a log-likelihood of -562.07. Stata'sarima, R'sarima(method="ML")and statsmodels'ARIMAgive 2.980 and -565.00, and so doessp.arimanow (coefficients to five digits, log-likelihood to 1e-5 against Stata). An AR(1) check against the likelihood written in closed form is intests/test_arima_exact_likelihood.py.method='innovations_mle'was already exact and is unchanged.sp.arima(auto=True)could not select white noise or a random walk, and favoured large models.(0, d, 0)was skipped by the search, and models with more states were scored on fewer observations, so AICc rewarded them for nothing. On white noise with 100 observations the search returned a nonzero order 150 times out of 150. It also chosedby comparing AICc across differently differenced series, which describe different data. Nowdis chosen first by KPSS tests (the rule of R'sforecast::auto.arima) and(p, q)by AICc on a common sample,(0, d, 0)included. The same experiment returns(0, 0, 0)54% of the time, and(0, 1, 0)56% of the time on a random walk.sp.arima(method='css')andmethod='conditional'raise. They were silently mapped to exact maximum likelihood, which is a different estimator from conditional sum of squares.
Added¶
sp.shrinkage: ridge, lasso and principal-components prediction with the penalty or the number of components chosen by m-fold cross-validation, and the cross-validated root mean squared prediction error.fit.predict(data)andfit.rmspe(holdout)score new data. Means and standard deviations are recomputed on each training fold. Ridge and principal components agree with scikit-learn to 1e-12 on the book's 816-predictor school data; the lasso agrees to solver tolerance. This closes the chapter 14 item left open by the Stock & Watson review.-
sp.test(result, "x1 = 0, x2 = 0"): comma-separated restrictions are a joint test, as in statsmodels'f_test. -
predict()andsp.marginswork on an IV fit whose formula has a transformed term. The design is rebuilt from the structural equation; the(x ~ z)block used to stop the formula engine. tests/reference_parity/test_arima_default_stata_parity.py: the defaultsp.arimaagainst Stata 18 on a committed series, four models, with the log-likelihood to 1e-6.
Changed¶
sp.ivreg/sp.ivreport formula terms under the namessp.regressgives them.np.log(x),I(x ** 2)andC(g)[T.2]were reported asnp.log[x],I[x ** 2]andg[2], the stand-in column names the IV parser estimates them under.sp.regtable(ols, iv)now puts a regressor on one row,sp.test(iv, "np.log(x) = 0")finds it, and Stata's2.g/i.gresolve. Estimates are unchanged. Code that indexes an IV result by the bracket name needs the new name (seeMIGRATION.md). Panel results keep the bracket names for now;sp.testandsp.lincomaccept either spelling there.sp.vecm,sp.IV2SLS,sp.adfullerand other names from statsmodels, linearmodels andarchraise anAttributeErrorthat names the StatsPAI function (sp.vec,sp.ivreg,sp.unitroot); a misspelt name gets "did you mean".sp.testandsp.lincommatch coefficient names without regard to blanks inside them.I(inc**2)names the coefficient the design callsI(inc ** 2); it was "Cannot parse term".- Asking a regression result for a statsmodels attribute (
bse,rsquared,resid,f_test,get_margeff, ...) still raisesAttributeError, and the message now says where the same thing lives (std_errors,r2,residuals(),test(),sp.margins).
What an introductory Stata text found: Kohler, Kreuter and Haensch (2026)¶
Data Analysis Using Stata (4th ed.) teaches what comes before the first
regression: variables, labels, tables, weights, survey data, programs.
Its commands were run in Stata 18 on the book's datasets and replayed
through sp.stata. At the start 1,619 printed numbers were reproduced,
101 differed and 375 commands were declined; now 5,442 are reproduced, 25
differ for stated reasons and 45 are declined. The notes are in
docs/dev/2026-10-05-kohler-kreuter-4e-review.md. Stata 18 reference
numbers on committed synthetic data are in
tests/reference_parity/test_kohler_kreuter_stata_parity.py (196 numbers).
⚠️ Correctness¶
- A formula over a column named like a Python keyword failed.
sp.regress("survived ~ C(class)", ...), and any formula that wrapsclass,return,yieldorlambdain a function, raised a syntax error. These are ordinary variable names in Stata and R data. All estimators that build their design from a formula are affected. sp.estat(result, "reset", rhs=True)could return a negative F when a regressor was in the thousands: its cube reached 1e14 and the unrestricted fit lost it to rounding. The powers are now taken on unit scale. On the rent regression of the book Stata's F(8, 2148) = 59.24 had come out as -214.sp.hausmanleft factor levels out of the comparison when the two results spelled them differently (C(g)[T.2]andg::2.0). Fixed against random effects withi.mar, the test had 2 degrees of freedom where Stata has 6 (chi2 100.26 against 236.26).sp.stata:x == .,x != .andx > .on a variable with extended missing values. The data keep.ato.zas NaN, like., sokeep if x != .dropped rows that Stata keeps (in Stata.a != .is true). The session now knows which variables may hold such values and declines the comparisons that tell the kinds apart, as well asby,tabulate, missingandcollapse, by()on them.missing(x),x < .andx >= .are unaffected. A frame says which variables are coded throughsp.read_data(..., extended_missing='column')ordf.attrs['_ext_missing'].sp.stata:table g, statistic(a) statistic(b)ran with the laststatistic()only. A repeated option was kept once.sp.stata:collapse ..., by(g)dropped the rows with a missingg. Stata keeps them as a group; so does this now.
Added¶
sp.ranksum,sp.signrank,sp.kwallis,sp.spearman,sp.ktau,sp.ksmirnov,sp.median_test,sp.robvar,sp.oneway: the rank, median, variance and one-way tests of a first statistics course, each returning aClassicTestResult.sp.influence_measures(leverage, studentized residuals, Cook's distance, DFFITS, Welsch distance, COVRATIO, DFBETAs aftersp.regress),sp.logit_influence(Pregibon's diagnostics by covariate pattern) andsp.logit_gof(Pearson and Hosmer-Lemeshow).sp.sumstats(weights=)for analytic weights, with Stata's weighted percentiles, andsp.sumstats(by=, total=True)for the Total panel.- Goodman and Kruskal's gamma and Kendall's tau-b with asymptotic standard errors in the association tests of a two-way table.
SurveyResult.params/.std_errors, the names every other result uses.- In
sp.stata: about 100 more functions of the expression language (strings,recode/irecode/autocode, clock times, calendar parts, distributions,c()); string variables;recode,mvencode,xtile,pctile,centile,tostring,destring,order,expand,contract,separate,split,sample,splitsample,mark,markout,levelsof,ds,unab,assert,confirm,isid,joinby,duplicates report/tag;tabulatewith percentages, expected counts, weights,gamma,taubandsummarize();tab1,tab2;tablewith several statistics;mean,proportion,total,ratiowithover(), weights andvce(cluster);svysetandsvy:(the foursingleunit()rules, poststratification, subpopulations,tabulatewith the Rao-Scott correction,regress/logit/poisson,estat effects);predictwith influence statistics afterregressandlogit,dfbeta,estat gof,lroc,lrtest,linktest,logistic,ologit,cloglog;marginsfor predictive margins, factor levels and grids inat();e(sample); thebyprefix before any command; extended macro functions,syntax,marksample,tokenize,gettoken,macro shift,_rc, one-lineif. - In
sp.stata, second round:testandlincomaftermean/proportion/total/ratio;nestreg: regress;anova y g(the one-way layout);ccandcswith exact limits for the odds ratio;predict p1 ... pkaftermlogit/ologit/oprobit;statsby.
Changed¶
- The base category of
C(g)is the lowest level in the estimation sample, as in Stata and R. It was the lowest level among all rows wheregis observed. The two differ only when a level occurs solely in rows that are dropped for a missing value elsewhere; the fit is the same, the coefficients are those of the other coding. sp.stata:displaywith text returns the line as Stata prints it when it holds no number or more than one; a pure string used to return nothing.oprobitrunssp.oprobit(the translation named aglmfamily that does not exist).tabstat, by()andtablereturn the Total panel.by g: regress ...runs the regression in each group; it was refused.predict, residualsafterlogitis the Pearson residual of the covariate pattern, as in Stata; it was refused.levelsofwrites a non-integer level with 16 significant digits, as Stata does.
What the notebooks of Facure's Causal Inference in Python found¶
The eleven chapter notebooks of the book (O'Reilly, 2023) keep their
printed outputs, so each analysis was rerun with StatsPAI on the book's
data and compared with the stored number. The data look like product data:
daily dates, text categories, a treated flag beside a post flag,
several treated units. Most of what follows is about inputs of that shape.
The notes are in docs/dev/2026-10-05-facure-causal-inference-in-python-review.md,
the guide in docs/guides/facure_causal_inference_in_python.md, and the
tests in tests/test_facure_textbook_pass.py (simulated data) and
tests/external_parity/test_facure_causal_inference_in_python.py (the
book's data, skipped without it). References verified via Crossref,
OpenAlex and arXiv.
⚠️ Correctness¶
sp.didon a block design with apostcolumn could return the wrong sign.sp.did(df, y=, treat='treated', time='post', id='unit')on data with several rows per unit and period went to Callaway-Sant'Anna, whose reshape kept the first row of each (unit, period) cell and dropped the rest. On the book's chapter 8 data the result was -0.659; the difference of the four cell means is 0.692.sp.callaway_santannanow refuses repeated (unit, period) rows on a panel, andsp.did(method='auto')estimates a 0/1 group flag with a 0/1 time and repeated rows as the 2×2 design, clustered onid. A 0/1treatthat never changes within a unit, next to a time variable with more than two values, now warns that it is read as "first treated in period 1". SeeMIGRATION.md.- A
categorycovariate with numeric levels was used as a number. Insp.ipw,sp.aipw,sp.match,sp.ebalance,sp.overlap_weights,sp.cbps,sp.tmle,sp.g_computation,sp.dml,sp.metalearner,sp.dose_response,sp.drdid,sp.callaway_santannaand the propensity-score diagnostics, a covariate of dtypecategorywas cast to its level values and entered as one linear term. It is now expanded to indicator columns, as are text columns (which failed in numpy) andC(col)/i.colentries (which failed as missing columns). A plain integer column is still a number. The expansion is recorded inmodel_info['covariate_expansion'], andsp.predict_caterebuilds it on new rows. SeeMIGRATION.md. sigma=had no effect insp.power_rct,power_did,power_rd,power_iv,power_cluster_rctandpower_ols. The effect was multiplied bysigmaand divided by a standard error proportional to it.effect_sizeis now in the units of the outcome and power depends oneffect_size / sigma; with the defaultsigma=1nothing changes. A call that passed a raw effect withsigmagot the power of an effectsigmatimes too small or too large. SeeMIGRATION.md.sp.dag_recommend_estimatornever recommended an instrument. It tested exogeneity by conditioning on the exposure in the original graph, which opens the collider at the exposure and rejects every instrument when there is unobserved confounding. The test is now made in the graph with the arrows into the exposure removed, and a conditional instrument is returned with its conditioning set.- The same function recommended the front door when it does not hold.
A direct edge from the exposure to the outcome, or a confounder declared
with
latent=[...], was not checked. It now usesDAG.frontdoor_sets. sp.cate_evalaccepted a treatment that is not 0/1 and returned a RATE computed from AIPW scores that do not exist for a dose. It raises.sp.balance_tableandsp.balance_checkreturned a table of NaN for a treatment with other codes (three arms, text labels). They raise, andsp.balance_tableno longer skips a covariate that is not in the data.sp.synth(inference=)ignored values it did not know.inference='jackknife'on the classic method returned placebo inference. Unknown values, and'bootstrap'/'jackknife'outsidemethod='sdid', are errors.
Added¶
sp.switchback_designandsp.switchback: regular switchback experiments after Bojinov, Simchi-Levi and Zhao (2023). The design gives the minimax-optimal randomization points for a carryover of orderm. The analysis is the Horvitz-Thompson estimator of the average lag-meffect, the exact randomization test of the sharp null, and under the optimal design the paper's conservative standard error. Evidence: enumeration of every assignment path of a 12-period design reproduces unbiasedness and the paper's variance formula to machine precision; the book's two examples agree to 1e-12.sp.synth_ttest, andsp.synth(..., inference='ttest'): the debiased K-fold synthetic control estimator and t-test of Chernozhukov, Wüthrich and Zhu (2026). Equal to the authors' R packagescinferenceto 1e-15 on the estimate and the standard error forK= 2, 3, 4.sp.metalearner(learner='r')takes a continuous treatment. The CATE is the effect of one more unit of the treatment;estimateis the partially linear coefficient with its influence-function standard error (estimand='APE'), equal tosp.dml(model='plr')on the same folds and learners. The CATE equalseconml.dml.NonParamDML's exactly under the same folds and learners. The other learners still require 0/1.sp.cate_gain_curve: effect by quantile of a predicted effect, cumulative effect and gain curves and their area, for a randomized treatment of any type.- Dates as time and cohort columns in
sp.callaway_santanna,sp.sun_abraham,sp.did_imputation,sp.etwfeand throughsp.did.datetime64andPeriodcolumns are numbered in the order observed; a missing cohort date or one after the last period means never treated. The numbering is inmodel_info['calendar_time'], and unevenly spaced dates warn. -
sp.ivandsp.ivregread the linearmodels blocky ~ 1 + [endog ~ instruments] + exog. -
sp.gardner_didwith a date-typed time column returned a wrong estimate (3.13 for 1.93 on a simulated daily panel) and said nothing.sp.gardner_did,sp.stacked_did,sp.wooldridge_did,sp.twfe_decomposition,sp.bacon_decompositionandsp.did_forestnow number dates the same way as the estimators above; the last five failed withTypeError.
Added in the second round¶
sp.cate_gain_curve(n_boot=, cluster=, seed=): bootstrap standard error and percentile interval of the area (auc_se,auc_ci). On rankings with no information the 95% interval covers zero in 95% of 60 draws on the book's test set.sp.synth_experimental_design(criterion='population'): the design question of the book's chapter 10. A random search over treated sets for the one whose weighted average, and the weighted average of the remaining units, both track the population average over the pre-period (population_weights=for a size-weighted average). The default criterion is unchanged.- Text,
categoryandC(col)covariates or controls are also expanded insp.sbw,sp.genmatch,sp.optimal_match,sp.cardinality_match,sp.psmatch2,sp.auto_cate,sp.policy_tree,sp.event_study,sp.sun_abraham,sp.did_imputation,sp.etwfe,sp.wooldridge_did,sp.gardner_did,sp.stacked_did,sp.lp_did,sp.did_multiplegt,sp.did_few_treated,sp.overlap_weighted_didandsp.continuous_did.
Added in the third round¶
sp.switchback(outcome_bound=B): a standard error and an interval under any regular design. With every outcome bounded byBin absolute value, the variance of the estimator is largest when all potential outcomes equalB(Lemma 1 of the paper). That variance has a closed form for any set of randomization points and any coin, checked here against enumeration of every assignment path on 24 designs.seis its square root andcithe Chebyshev intervalestimate +/- se / sqrt(alpha): valid in finite samples, assumption-free and wide. For T = 120, m = 2, B = 10 it gives 26.66, 33.67 and 27.81 for the optimal, every-period and every-three-periods designs; the paper's simulations give 26.78, 33.67 and 27.85. The optimal design keeps the paper's tighter estimator.- Categorical covariates in the forests.
sp.causal_forest(formula interface),sp.regression_forest,sp.iv_forest,sp.multi_arm_forest,sp.lm_forest,sp.causal_survival_forestand the other forests of the family turn a text orcategorycovariate into one 0/1 column per level, namedcol[level]. Every level gets a column, sopredicton a frame with the original column can refuse a level it never saw. sp.synth_experimental_design(criterion='population')enumerates every treated set when there are at mostn_searchof them and returns the optimum (diagnostics['global_optimum']); otherwise the best random set is improved by single exchanges until none helps.
Changed¶
- Every
sp_callstring ofsp.dag_recommend_estimatornow runs as written (sp.ipw,sp.front_door,sp.identifyandsp.ivwere called with arguments they do not have). sp.synthwith a list of treated units on a single-unit method says which functions take several (sp.geolift,method='sdid'); it failed in numpy with a shape error. A list of one is unwrapped.sp.marginson a fit with factor or transformed terms and nodata=says to pass the data.repr()of the result ofsp.synth_experimental_designis one line; it printed every weight vector.
What Causal Inference in R found¶
Barrett, D'Agostino McGowan and Gerke's book (https://www.r-causal.org)
teaches one workflow with the propensity, halfmoon and tipr packages.
Every analysis in its chapters 2 and 6 to 16 was rerun in R and in StatsPAI
on the same rows. Notes are in
docs/dev/2026-10-05-barrett-causal-inference-in-r-review.md, the user
guide is docs/guides/causal_inference_in_r.md.
⚠️ Correctness¶
sp.trimming(method='crump')andsp.propensity_score(trimming='crump')trimmed too little. The cutoff solvedalpha = 1 / (2 E[g])where the rule of Crump, Hotz, Imbens and Mitnik (2009) isalpha (1 - alpha) = 1 / (2 E[g]), and it searched a 500-point grid. On the book's data the cutoff was 0.0638 and 329 of 354 days were kept. The rule gives 0.0716 and keeps 318, which is whatpropensity::ps_trim(method = "adaptive")returns (cutoff equal to 1e-15, the same 36 days dropped). The cutoff is now solved exactly.- Weighted variances in
sp.balance_diagnosticsandsp.ps_balance. The weighted group variance had divisorsum(w)while the unweighted one hadn - 1, soweights=1did not reproduce the raw columns. It is nowsum(w (x - m)^2) / (sum(w) - sum(w^2) / sum(w)), the form in Austin and Stuart (2015). Weighted variance ratios now equalcobaltandhalfmoonto 1e-10 (0.7794 before, 0.7938 after on the book's temperature variable). Weighted standardized differences move in the third decimal. sp.from_rtranslated four common calls of the book to something else and reportedok.lm(..., weights = w)kept the weights inargumentsand dropped them frompython_code, so the code ran an unweighted regression.glm(..., family = binomial()), the usual way to write it, becamesp.glm(family='binomial()'), which raises.matchit(treat ~ x)passed the treatment as the outcome as well.subset =andoffset =disappeared without a note. Now the weights reach the code, families are read in every spelling R accepts (binomial,binomial(),binomial("probit"),binomial(link = "probit"),quasibinomial()with a note on its standard errors),matchitleavesyto the caller undermissing_argumentsand writes out MatchIt's defaults (replace=False,estimand='ATT'), and any argument that was not carried over is listed inuntranslated_arguments. The same now holds for every R functionsp.from_rknows: an argument a handler never read is reported (feols(subset =),plm(effect = "twoways"),lmer(weights =),glmer(nAGQ =)).feols(weights = ~w)passed the column name with its tilde.did::att_gtlostcontrol_group,xformla,anticipation,weightsnameandclustervars, and its translation ran withbase_period='universal'where R's default is'varying'; all are carried now and the base period is written out.lmer(REML = FALSE)becomesmethod='ml'.DAG.adjustment_sets(minimal=True)could miss minimal sets. The search stopped at the smallest size that had a valid set. ForW -> X; P -> W; Q -> W; P -> Y; Q -> Y; X -> Yit returned{W}and not{P, Q}. It now returns every valid set with no valid proper subset, asdagitty::adjustmentSetsdoes.
Added¶
sp.ps_weights(ps, treat, estimand=). Propensity-score weights for the ATE, ATT, ATC, the overlap population (ATO) and the evenly matchable (ATM), withstabilize=,truncate=and a continuous exposure through normal densities. Equal topropensity::wt_*to 1e-13.sp.ess(weights, by=). Effective sample size, overall or by group.sp.energy_distance(data, treat, covariates, weights=). One number for joint covariate imbalance. Equal tohalfmoon::bal_energyto 1e-15.sp.implied_weights(data, treat, covariates, interactions=, estimand=). The weights a linear regression adjustment puts on each row. Equal tolmw::lmwto 1e-8.sp.confounder_adjustandsp.confounder_tip. What an estimate becomes under a stated unmeasured confounder, and how strong the confounder must be to reach the null. Differences, risk, odds and hazard ratios, binary or normal confounder. Equal totiprto 1e-12 on 19 cases.sp.ipw(estimand='ATO' | 'ATM'), and'ATU'as another name for'ATC'. The sandwich standard error covers all five estimands and equalspropensity::ipwto 1e-13 once itsn / (n - 1)factor is applied.sp.contrast(effect='ratio' | 'odds_ratio')gives marginal risk and odds ratios with delta-method intervals on the log scale.sp.contrast(subset=)andsp.margins(subset=)average over a subpopulation while keeping the model's factor levels, which gives the effect on the treated or the untreated after any outcome model. Both equalmarginaleffects::avg_comparisons.sp.g_computation(estimand='ATC').sp.margins_at(contrast='first' | 'adjacent')returns the difference between grid points with the standard error that uses their covariance. Withat={'dose': [30, 60]}it is g-computation for a continuous exposure under any functional form. Equal tomarginaleffects::avg_comparisons(variables = list(x = c(a, b)))after a spline regression.sp.margins_at(subset=)as insp.margins.sp.bootstrap(ci_method='studentized'), the bootstrap-t interval (rsample::int_t). The statistic returns(estimate, se).sp.roc_curve(weights=)andsp.auc(weights=), the weighted Mann-Whitney probability. No standard error is reported with weights.DAG.equivalent_dags()andDAG.equivalence_class()list the graphs the data cannot tell apart and the edges that flip among them. Counts and undirected edges equaldagitty.sp.balance_diagnostics(sd_denom='unweighted')is thecobaltconvention. Its summary now also has the effective sample size of each group and the energy distance before and after weighting.
Changed¶
sp.contrastraises when only one level of the variable is available. It used to return an empty table.
What Ding's Linear Model and Extensions found¶
A pass over the 24 R programs of Peng Ding's regression course
(arXiv:2401.00649): least squares and robust inference, selection and
shrinkage, GLMs, clustered data, quantiles, survival. Every real-data
result was recomputed with sp.* and compared with R; everything that
changed was then tested on a committed synthetic file against R and Stata
18. Notes are in docs/dev/2026-10-05-ding-linear-model-review.md, the
chapter map in docs/guides/ding_linear_model.md.
⚠️ Correctness¶
sp.coxrobust and clustered standard errors were wrong when event times are tied. Under the defaultties='efron'the sandwich was built from Breslow's score residuals. With no ties the two are identical; on the COMBINE trial of chapter 27, where relapse is recorded in days, the robust standard errors were off by up to 2%. They are Efron's now and matchsurvival::coxph(robust = TRUE)to 1e-14 withrobust=,cluster=andstrata=. Coefficients, model-based standard errors andties='breslow'fits are unchanged.sp.regressrejected full-rank regressions through the origin."t ~ 0 + x + I(1 - x)"(Goodman's ecological regression) raised "perfectly collinear": the check used the correlation, which is the right test only when the model has a constant. It also refused a constant column in a model without an intercept. Models with an intercept are unchanged.sp.mlogit,sp.ologitandsp.oprobitdropped a category when the outcome was a string and the formula had aC(),I()or interaction term. They stopped with "requires J >= 3 categories, got 2".
Added¶
sp.gee: generalized estimating equations (Liang and Zeger) with independence, exchangeable, AR(1) and unstructured working correlations for Gaussian, binomial, Poisson and gamma margins. Reports the sandwich and the model-based covariance. Matches Rgeeto 1e-12 and Stataxtgeeto 1e-7 (dof_correction=False,scale=1for Stata's conventions). The AR(1) parameter comes in both references' versions:corstr='ar1'is Stata's pooled moment,corstr='ar-m'is Rgee's per-cluster one; they differ when cluster sizes do.sp.ridge: ridge regression with the scaling, GCV score and HKB / LW penalties ofMASS::lm.ridge(1e-13).sp.boxcox: Box-Cox power for the outcome, with a profile-likelihood interval and likelihood-ratio tests of -1, 0 and 1. Profile asMASS::boxcox, estimate and tests as Stataboxcox, model(lhsonly).sp.best_subset: exact best-subset selection by branch and bound, asleaps::regsubsets. Regressors can be forced in.sp.regress(robust='hc4')(Cribari-Neto 2004).sp.glm(link='cauchit').sp.qreg(weights=)andsp.qreg(vce='ker'), followingquantreg.sp.kaplan_meier(conf_type='log' | 'log-log'), the intervals of Rsurvfitand Statasts. The default is unchanged..predict()onsp.mlogit/sp.ologit/sp.oprobitresults returns the predicted probabilities of every category, in sample or on new data.-
sp.estat(result, "leverage")returnsdffits,loo_residuals,pressandloo_interval_halfwidth. -
sp.stata/sp.from_statatranslatextgee(tosp.gee, with Stata's defaults written into the call) andboxcox(tosp.boxcox). .predict(data, what='confidence')onsp.logit,sp.probitandsp.cloglogresults: the probability, its delta-method standard error (R'spredict(se.fit = TRUE)) and an interval that stays inside (0, 1). The same call already worked onsp.glmresults.sp.coxreports the Wald and score tests of all coefficients next to the likelihood-ratio test (diagnostics["Wald chi2"],["Score chi2"]), assurvival::coxphprints them.-
sp.zip_model,sp.zinbandsp.hurdlealso store their log likelihood and information criteria asLog-Likelihood,AICandBIC, the names the other count models use. -
sp.gam: generalized additive models,"y ~ s(x1) + s(x2, k=15) + z", with penalised cubic B-spline smooths for Gaussian, binomial, Poisson and gamma responses. Smoothing parameters by REML (default) or GCV / UBRE. Returns parametric coefficients with standard errors, the effective degrees of freedom of each curve,partial(term)with a band,predictandplot. Same basis, penalty and criteria asmgcv::gamwiths(x, bs = "ps"): 1e-9 at given smoothing parameters in three families, 1e-4 on the parameters it selects.s(x, by=d)fits a curve that multipliesd(the effect of a 0/1 treatment as a function ofx) or, for a factord, one curve per level, andvce=/cluster=give sandwich standard errors. sp.conformal_regression: distribution-free prediction intervals for least squares by split conformal, jackknife+ or exact full conformal, also assp.conformal("regression", ...).sp.stepwise(start='full'): bidirectional search from the full model, as R'sstep().
Deprecated¶
diagnosticsofsp.zip_model,sp.zinbandsp.hurdleno longer lists the fitted-value vectors (predicted_structural_zero,predicted_count,predicted_overall,predicted_hurdle_prob,predicted_count_mean). They are indata_info. Reading them fromdiagnosticsstill works and warns; the keys go in 1.41.- The default interval of
sp.kaplan_meierwill change from'plain'to'log-log'in 1.40. The plain interval is the only one of the three that leaves [0, 1] and has to be cut off, and it is the default of neither R ('log') nor Stata ('log-log'). Until then a call withoutconf_type=returns what it always did and raises aDeprecationWarning. SeeMIGRATION.md.
Changed¶
I(x^2)is read as a power in every formula. It used to fail with a message aboutxor.sp.coxformulas acceptC(), interactions andI().sp.estat(result, "leverage")no longer forms the n by n hat matrix.
refs verified via Crossref REST and doi.org content negotiation: hoerl1970ridge, golub1979generalized, box1964analysis, furnival1974regressions, cribari2004asymptotic, lin1989robust, cook1977detection, lei2018distribution, barber2021predictive, eilers1996flexible, wood2017generalized.
What the programs of Hansen's Econometrics found¶
The sixteen Stata do-files that come with Hansen's Econometrics (2022)
were run in Stata 18 and replayed through sp.stata, every printed number
compared. The notes are in docs/dev/2026-10-05-hansen-econometrics-review.md.
The Stata evidence on committed data is in
tests/reference_parity/test_hansen_methods_stata_parity.py.
⚠️ Correctness¶
- Dynamic panel GMM with
time_dummies=Truekept dummies it could not identify.sp.xtabondandsp.xtdpdsysmade a dummy for every period of the panel but the first. Withlags=2the differenced equation starts three periods in, so the dummies of the periods before it were zero or identified from a handful of level rows, and the rest summed to the constant. The fit went through a pseudo-inverse with a warning. Onabdataa system GMM with two lags gave 1.1548 for the first lag where Stata'sxtdpdprints 1.1665. Now the dummies of periods without a transformed row are left out, and with a constant one more is omitted from the regressors and kept as an instrument, which is whatxtdpddoes. One-step, robust and two-step estimates, standard errors, the instrument count and Sargan's statistic agree with Stata 18 to 1e-9 onabdata. Fits without period dummies are unchanged. SeeMIGRATION.md. - A GMM weight matrix that is singular to rounding was inverted as if it
were not.
numpy.linalg.invraises only on an exactly zero pivot. On the investment panel of chapter 17 the two-step weight has rank 191 of 199 (the first years hold six to thirteen firms and more moments than that),invreturned noise, and the standard errors came out of order 1e14 with no warning. The rank is now checked first, and a singular weight gets a generalized inverse with the existing warning. - Which generalized inverse: the one Stata takes. With a singular
weight the two-step estimate depends on the generalized inverse. The
Moore-Penrose inverse used so far gives 0.3178 for the first lag in
Table 17.3 where Stata prints 0.3191.
sp.xtabond/sp.xtdpdsysnow sweep on the largest remaining diagonal and set the collinear moments to zero, which is Mata'sinvsym, and leave those moments out of the one-step covariance in Windmeijer's correction. Table 17.3 is reproduced to every printed digit, coefficients and corrected standard errors, and a case on committed data agrees withxtdpdto 1e-9. Fits whose weight has full rank are unchanged. - Random effects with a regressor that does not vary within unit used the
wrong degrees of freedom.
sp.panel(method='re')divided the within residual variance byN - G - KwithKthe number of regressors. A regressor that is constant within unit (an industry dummy, a listing indicator) is swept out of the within regression and costs it nothing, so the divisor isN - G - rwithrthe within rank, as in Stata'sxtreg, reand R'splm.sigma_e,sigma_u,thetaand through them every coefficient were slightly off. On the investment panel of chapter 17 the coefficient on lagged debt moves from -0.009192 to -0.009196, Stata's value. Designs in which every regressor varies within unit are unchanged. The same count is used bymethod='mundlak'/'chamberlain'and by thesigma_u/sigma_ethatsp.statareports afterxtreg. SeeMIGRATION.md. - Dickey-Fuller p-values of explosive series were too small. MacKinnon's
approximation is a cubic fitted up to a statistic of 2.74 (constant) or
0.70 (constant and trend). Beyond that the cubic turns down. With a
trend, a statistic of +3.0 was given p = 0.25, +3.25 p = 0.06 and +4.0
p = 0.00, so a series growing faster than a random walk could be
reported as stationary. The p-value is now 1 above the fitted range, as
in Stata and statsmodels. This affects
sp.unitroot(test='adf'and'pp') and the Fisher-type panel tests. Statistics inside the range are unchanged. SeeMIGRATION.md. - 2SLS lost digits on ill-conditioned designs. The projection on the
instruments went through
(W'W)^-1and the second stage through(X'PX)^-1, which squares the condition number twice. With a cubic in class size instrumented by a cubic in predicted class size (chapter 20) the coefficients agreed with Stata to seven digits only. Both steps now use orthogonal factorisations. Against a 50-digit solution of the same system the error falls from 8e-7 to 3e-13. Well-conditioned fits move by rounding error.
Added¶
sp.mprobit: multinomial probit for choice data in long form, with alternative-specific and case-specific regressors and an independent or unstructured error covariance, parameterised as Stata'scmmprobit. The choice probabilities are integrated by Gauss-Legendre quadrature (up to four alternatives), so the likelihood has no simulation noise. Against Stata'smprobit, which also integrates, coefficients agree to 1e-9 and standard errors to 1e-7. Againstcmmprobitwith 3,000 simulation points on the book's travel data the unstructured model agrees to three or four digits (log likelihood -2017.437 against -2017.433).sp.series: series regression with a polynomial or spline basis and the order chosen by leave-one-out cross-validation. Returns the fitted function and its derivative with pointwise standard errors. The cross-validation criteria of the book'sfigure20_6.Rare reproduced to 3e-14 on both of its samples.sp.factor(...).rotate('varimax' | 'promax'): rotated loadings, the rotation matrix, the factor correlation and the variance each factor accounts for, with Stata's conventions. Stata'srotateto seven digits, with and without Kaiser normalisation.sp.xthtaylor(method='amacurdy', time=): the Amemiya-MaCurdy estimator for balanced panels. Stata'sxthtaylor, amacurdyto 1e-10.sp.nlogit(fixed_lambda=, base=): dissimilarity parameters held at a value, and a choice of base alternative. The book's constrained nested logit is reproduced (log likelihood -2044.4153).sp.mixlogitkeeps its covariance matrix indata_info['var_cov'], sosp.testandsp.lincomwork after it.sp.statarunsnlogitgen/nlogitwith a constraint,cmmprobitwithestat covariance/estat correlation,cmmixlogit,margins, dydx() outcome() alternative()after either,rotate,xthtaylor, amacurdy,estat bootstrap(normal, percentile, BC and BCa intervals),matrix list e(Sigma)aftervar,display a, b, and a product of two factors when one of their main effects is in the model (i.b i.a#i.b), withtestparm i.a#i.b. All 190 numbers of chapter 26 are reproduced.sp.threshold: threshold regression with an unknown threshold (the intercept and the coefficients named inregime=change when a variable crosses a value estimated by least squares), and withkink=Truethe continuous model in which only a slope changes. The threshold model reports Hansen's (2000) likelihood-ratio interval for the threshold and standard errors of the other coefficients given it;n_boot=adds a multiplier-bootstrap test of the linear model, whose F statistic has no standard distribution because the threshold is not identified under the null. In the kink model the threshold is jointly normal with the slopes and gets a standard error.absorb=removes fixed effects at every candidate. Evidence: Stata'sthresholdto 1e-11 (threshold, sum of squares, coefficients, classical and robust variances), Stata'snlto 1e-7 for the kink model, and the book's ownfigure23_3.Ron the Card, Mas and Rothstein data to 1e-9 (threshold, 99% interval, coefficients, clustered standard errors).sp.statatranslatesthresholdwith one threshold.-
sp.statarunsxtdpdwithdgmmiv(),lgmmiv()andiv()throughsp.xtdpdsys: thedgmmiv()variables are instrumented GMM-style, theiv()variables are their own instruments,i.<time>in both is the set of period dummies. Both columns of Hansen's Table 17.3 are reproduced (136 printed coefficients and standard errors).xtdpdwithoutlgmmiv(),div(),liv(),noconstantandfodeviationare declined. -
sp.cnsreg: linear regression under linear equality constraints.method='cls'is constrained least squares and reproduces Stata'scnsregwith classical, robust and cluster covariances.method='emd'is the efficient minimum distance estimator of chapter 8. sp.nls: nonlinear least squares. The regression function is a formula with the parameters in braces ("y ~ {a} + {b} * x^{c}", Stata'snlsyntax) or a Python function. Classical, HC1 to HC3 and cluster covariances. Reproducesnlto 1e-6.sp.pcaandsp.factor: principal components, and factor analysis by principal factors, iterated principal factors, principal-component factors or maximum likelihood, with scores. They reproduce Stata'spcaandfactor, sign convention and likelihood-ratio statistics included.sp.jackknife: the delete-one or delete-one-cluster jackknife for any statistic, the counterpart ofsp.bootstrap. It reproduces Stata'sjackknifeprefix andvce(jackknife).sp.xthtaylor: the Hausman-Taylor estimator for panels with time-invariant regressors, some of them correlated with the unit effect. It reproduces Stata'sxthtaylorto 1e-12, conventional and cluster-robust standard errors, andsp.stataruns the command. One departure: with period dummies in an unbalanced panel Stata's estimates change with the period it omits (a coefficient of -0.248 or +0.015 on the test panel, depending on the year); here the fit is the same for every base period.sp.mlogit,sp.ologit,sp.oprobitandsp.clogitkeep their covariance matrix (data_info['var_cov']). They kept the standard errors only, sosp.testandsp.lincomrefused any hypothesis on more than one coefficient after these fits. The matrix agrees with statsmodels'MNLogitto 1e-11.sp.statarunscmsetandcmclogit(alternative-specific and case-specific regressors,basealternative(),casevars()) andmargins, dydx() outcome() alternative()after it. All 35 numbers of the conditional logit of chapter 26 are reproduced.sp.model_average: selection criteria (AIC, BIC, leave-one-out cross-validation) and averaging weights (Mallows, jackknife, smoothed AIC and BIC) for candidate regressions, with averaged coefficients, fit and predictions. Weights agree with R'squadprogto 1e-8, and the jackknife weights with the book's program for chapter 28 digit for digit.sp.var(exog=)andsp.varsoc(exog=): exogenous variables in every equation of a VAR, as in Stata'svar ..., exog(). A structural VAR fitted on such a model works unchanged.sp.estat(result, 'overid')after 2SLS and LIML also returns the statistics that assume i.i.d. errors (Sargan and Basmann, or Anderson-Rubin and Basmann's F) underiid_errors, and names the robust statisticscoreafter a heteroskedasticity-robust 2SLS fit.sp.read_datareads Stata 7 files (format 110), which pandas before 3.0 refuses.sp.statarunscnsregwithconstraint define,nl,pca,factor,estimates stats,irf create/irf table(irf,cirf,oirf,coirf,fevd,sirf,sfevd), thejackknifeandbootstrapprefixes andvce(jackknife)/vce(bootstrap),var/varsoc/svarwithexog(),L(1/3).D.x,ivregress, perfect,estat overid, forcenonrobustwithr(sargan)and its companions,e(rank), interacted factors among the excluded instruments, and square brackets used as parentheses (gen n = [_N]).
Changed¶
sp.ivdrops excluded instruments that are linear combinations of the exogenous regressors and the other instruments, warns, and lists them inmodel_info['omitted_instruments']. It used to stop with numpy'sSingular matrix. An interaction of two factors among the instruments no longer duplicates dummies that are already regressors. The quarter-of-birth specification of chapter 12 with 180 instruments now runs.sp.clogitevaluates its likelihood without a Python loop over choice sets and finishes with Newton steps. On 2,779 choice sets of four alternatives it takes 0.13 seconds where it took 22. Estimates agree with the earlier ones to 1e-8 or better.sp.lincomandsp.testaccept division by a number ("exp + exp2/5").sp.from_stata("nlcom (a + b) / (1 - c)")is read as one expression.
rdlocrand 3.0¶
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.rdwinselectandsp.rdsensitivitywithp > 0use HC3 for the large-sample test; they used HC2.rdlocrand3.0 addedvce = "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, fortermshousewithp = 1in [-1, 1], the large-sample p-value moves from 0.666 to 0.694.vce='hc2'returns the earlier numbers. Calls withp = 0, the default, are unchanged, and so is the randomization p-value.
Changed¶
sp.from_stata/sp.statareadvce()onrdrandinfandrdwinselect. Withp() > 0and novce()the translation notes that releases up to 2.0 used HC2.tests/reference_parity/test_rdlocrand_v1_parity.pyruns 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.rdrandinfkeeps 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.pvalueis the rank of the treated post/pre RMSPE ratio among the placebos;ciwas 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.ciis 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 whenpvalue < 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 than1 / alpha - 1donors no effect can be rejected and the interval is(-inf, inf); it used to be finite. The estimate,seandpvalueare unchanged, the old interval is inmodel_info['ci_normal'], andmodel_info['ci_method']says which oneciis.summary()now prints an infinite end as-inf/infinstead of leaving it blank, for every estimator. SeeMIGRATION.md.- DFBETAS from
sp.estat(result, 'leverage')were on the wrong scale. The division by the square root of the diagonal of(X'X)^-1was 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. The2/sqrt(n)rule was then applied to that quantity, anddfbetas_flagged_obslisted the wrong observations. The values now agree with Stata'sdfbetaand with statsmodels to 1e-8. Leverage and Cook's distance were right and are unchanged. SeeMIGRATION.md. predict(new_data)aftersp.glmreturned 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. Aftersp.poissonandsp.nbregthe call used to fail on the_conscolumn and now works, with theoffset=orexposure=of the new rows applied. SeeMIGRATION.md.sp.from_statatranslatedheckmanto the two-step estimator. Stata'sheckmanis maximum likelihood unlesstwostepis given, andtwostepitself was reported as an untranslated option. The translation now writesmethod='ml'ormethod='twostep'. On themrozdata the coefficient on education is 0.10835 by maximum likelihood and 0.10907 by the two-step estimator; both now match Stata.sp.heckmanitself is unchanged. SeeMIGRATION.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,expandsqrtcan be written without thenp.prefix in every formula (log(wage) ~ educ). A transformed outcome is accepted bysp.ivregandsp.panel, a transformed endogenous regressor bysp.ivreg(hours ~ educ + (log(wage) ~ exper + I(exper**2))), andsp.qregtakes transformed, factor and interaction terms. A name the data do not have is reported by name; it used to end in aKeyError. result.predict(data, what=, alpha=)for the results ofsp.regress,sp.ivreg,sp.glm,sp.poissonand 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 ofsp.logitandsp.probittake a DataFrame.sp.estat:'archlm'(Engle's LM test),'durbinalt'(Durbin's alternative test),'bgodfrey'withversion='fstat', and'white'withvariables='fitted'(the form of the test on the fitted values and their squares).'leverage'also returnsrstudentandrstandard.'beta'returns the standardized coefficients of Stata'sregress, beta.sp.tobit(formula=)andsp.truncreg(formula=), with transformed, factor and interaction terms; the formula may also come first.sp.survregaccepts 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 Stataglm, scale(x2).sp.testtakes a list of restrictions and coefficient names that hold operators or blanks (I(exper ** 2),np.log(sales)); so doessp.lincom.sp.lrtestaccepts 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()), andvce(robust)/vce(cluster)onheckman.
Fixed¶
sp.panel(method='fd')with the time variable among the regressors (a linear trend) raisedKeyError.sp.panel(method='fe')and'twoway'raised when the effects absorbed a regressor. The regressor is now omitted with a warning and listed inmodel_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 inmodel_info['cre_means_omitted'].
⚠️ Correctness¶
sp.stata/sp.from_statatranslatedivregress gmmto a different estimator. Stata's two-step GMM uses the heteroskedasticity -robust weight matrix unless told otherwise; the translation calledsp.ivreg(method='gmm'), which uses the unadjusted one, and droppedwmatrix(). On Card's schooling data the schooling coefficient came out 0.1608 against Stata's 0.1588. The translation now followswmatrix()andvce()as Stata 18 resolves them (avce(cluster c)alone sets the weight matrix too), and nine option combinations reproduceivregress gmmto 5e-9 throughsp.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 andsp.statarefuses them. Calls tosp.iv/sp.ivregthemselves 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 bynfor every estimand, so the ATT came back multiplied byP(T=1)and the ATC byP(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. SeeMIGRATION.md.- The confidence interval of
sp.fisher_exactwas too short. It was read off a 101-point grid six outcome standard deviations wide, each point tested on its own 500 draws, and withcontrolsthe 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 atalpha. p-values are unchanged. SeeMIGRATION.md. sp.gformula_ice_fnraisedLinAlgErroron 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_effectandsp.attrition_boundsnow say so and point totrimming='exact'. The default and its numbers are unchanged. - For
statistic='ks'and'rank_sum'the "confidence interval" ofsp.fisher_exactwas 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 onY - tau_0 * Dfor 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 forstatistic='t'. p-values are unchanged. SeeMIGRATION.md. - Arithmetic inside a formula wrapped around on small integer
columns.
pd.read_statareturns a Stataintasint16and abyteasint8, and numpy keeps arithmetic in that type, soI(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 affectedsp.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 toint64before a formula is evaluated (tests/test_narrow_integer_formula.py). Frames whose integers are alreadyint64or float are unaffected. SeeMIGRATION.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 aConvergenceWarning. 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 thecempackages. 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 Statacemon the NSW data (matched sample and estimate). Estimates from the default change, and so do estimates with integer covariates under an explicitn_bins. SeeMIGRATION.md.sp.sensemakrskipped 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.a##b##cwas 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.a#bwithout 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.p##c.x##c.xlost the square. All four now follow Stata and are pinned against Stata 18 (tests/reference_parity/test_the_effect_stata_parity.py).-
sp.stata:collapsestores 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.survregignoredrobust=andcluster=. Both were accepted and written intomodel_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 18streg ..., time vce(robust)andvce(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 underrobust='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. SeeMIGRATION.md. - The Vuong statistic reported by
sp.zip_modelandsp.zinbwas 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 Rpscl::vuongand 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). SeeMIGRATION.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 (Statatargets(2 2 1)), anddof_adjust=Truematches the sample variance and skewness as Stataebalancedoes. With it the weights agree withebalance, 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 Statasensemakrto 1e-12.sp.rdrobustnames 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 (thet-hatandCIcolumns of Statarbounds). The significance levels and the confidence bounds agree with Rosenbaum'sDOS2::senWilcox, the point-estimate bounds with Statarbounds.sp.statarunsrbounds diff, gamma()andbstat, stat().sp.statarunsxi,ebalance,cem,sensemakrandtable g, statistic(mean x);encode, g();date();egen cut(), group();reghdfewithd##ib3.tand squares;_b[1.d#3.t]; abbreviated variable names; weights that are missing or zero (the observation is dropped, as in Stata);[iw = w]inregresswhen the weights sum to the number of observations;i.covariates inpsmatch2andteffects;program def,return scalarof a local macro and nested programs. Translatedtabstat, by()andtablehead 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.ivprobitandsp.ivtobitfit a probit and a tobit with continuous endogenous regressors. Maximum likelihood is the default andmethod='twostep'gives Newey's minimum chi-squared estimator, as in Stata'sivprobitandivtobit. Both report the Wald test of exogeneity and takevce='robust'orcluster=. 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.ivpoissonfits an exponential-mean model with endogenous regressors by GMM, as Stata'sivpoisson 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.cmtestruns the conditional moment tests of normality, heteroskedasticity, skewness and kurtosis on a fit fromsp.probitorsp.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 Rmicsr::cmtestto 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.nlogitfits 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 18nlogitto 1e-6, and coefficients and likelihoods with Rmlogit.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.etpoissonfits a Poisson regression with an endogenous binary treatment by maximum likelihood, as Stata'setpoisson. 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 withmargins r.treatto 1e-9 (tests/reference_parity/test_etpoisson_stata_parity.py).sp.from_statatranslates 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 whensp.cmtestrejects. 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 withmicsr::tobit1(method = "trimmed")to 1e-8 (tests/reference_parity/test_binary_r2_and_scls_parity.py).sp.probitandsp.logitreport a family of goodness-of-fit measures inmodel_info['r2']: McFadden, Cox-Snell, Nagelkerke, Efron, Tjur, Estrella and McKelvey-Zavoina. They agree with RDescTools::PseudoR2andmicsr::rsqto 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'sstreg, frailty(gamma). It reportslog(theta), the frailty variance and the likelihood-ratio test oftheta = 0with its boundary p-value. Coefficients, standard errors,thetaand 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.survregresults also carrydata_info['llobs'], sosp.vuongcan compare two duration distributions, andmodel_info['ll_log_time'], the log-likelihood in Stata's convention.sp.vuongcompares two non-nested maximum likelihood fits on the same observations and returns the Vuong statistic with its AIC and BIC corrections. It takes fits fromsp.poisson,sp.nbreg,sp.zip_model,sp.zinb,sp.hurdle,sp.logitandsp.probit, which now carry their per-observation log-likelihood indata_info['llobs']. Statistics agree with Rpscl::vuongto 1e-6 on five model pairs.psclleaves 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.statareadsstset timevar, failure(eventvar)and runsstreg ..., distribution() time [frailty(gamma)] [vce()]throughsp.survreg.stregfor the Weibull or exponential withouttimeis the proportional-hazards metric and is refused, as areshared(), other frailty distributions, andstsetwithid(),enter()ororigin().sp.ologit,sp.oprobit,sp.mlogit,sp.tobitandsp.nlogitfits carry their per-observation log-likelihood, sosp.vuongtakes them.sp.from_stataandsp.statatranslateivprobit,ivtobitandivpoisson gmm.ivtobitwithoutll()is written asll=None, becausesp.ivtobitcensors at zero by default and Stata does not.ivpoisson cfunctionis 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.arimafitted 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.arimanow hastrend=. The default estimates a constant when the series is not differenced and none when it is, as R'sstats::arimaand statsmodels'ARIMAdo;trend='c'on a differenced series estimates the drift Stata'sarimaalways includes;trend='n'gives the old fit.method='innovations_mle'already estimated the constant and is unchanged. SeeMIGRATION.md.- Two-step standard errors of
sp.heckmannow 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'swomenwk), 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 RsampleSelectionand 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 leftsp.tobitat its own default ofll=0, so every outcome at or below zero was treated as censored too.tobit y xwith no limit (an uncensored normal model in Stata) became a tobit at zero, andllwithout a value or with a variable name fell back to zero silently. Both limits are now written out,sp.tobitacceptsll=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. SeeMIGRATION.md.sp.tabreported a Yates-corrected statistic as "Pearson chi2" on 2 x 2 tables. The test came fromscipy.stats.chi2_contingencyat its default, which applies the continuity correction when the table has one degree of freedom. The label, Stata'stabulate, chi2and R'schisq.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. SeeMIGRATION.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. (pcounts lagged variances andqlagged squared innovations; ARCH(1) isp=0, q=1.)
Added
sp.sdtestandsp.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'ssdtest/sdtestiandztest/ztesti.sp.prtest,sp.sktest,sp.swilkandsp.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 18prtest,sktest,swilkandcito 1e-8 or better (the Shapiro-Wilk p-value to 1e-5).sp.stataruns those commands andprtesti.svar ..., aeq(A) beq(B)andsvar ..., lreq(C)run insp.stata, with the matrices defined bymatrix A = (...)before them.tabulate a b, chi2 exact lrchi2 Vruns insp.stata. The Pearson and likelihood-ratio statistics, Cramer's V and Fisher's exact p-values are in.attrs['test']of the table and inr();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 effectb / (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 toeval.sp.svar. Structural VAR on a fittedsp.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 18svarto 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-PerronZ(t)andZ(rho)and the KPSS statistic, equal to Stata'spperronandkpssto 1e-9. KPSS has stationarity as its null; the result says so innull.- Stata's factor-variable names in
sp.testandsp.lincom."2.race = 3.race"and"x + 1.union#c.x"find the coefficients the formula engine callsC(race)[T.2]andx:C(union)[T.1]."i.race"on its own tests every level jointly. sp.heckman(select=None)selects where the outcome is observed (Stata'sheckman y x, select(z1 z2), whichsp.statanow runs), andmethod='mle'is accepted for'ml'.sp.arimaandsp.garchtakedata=with a column name.sp.hdfe_ols(absorb_dof='areg')(andsp.absorb_ols) charges every absorbed level in a clustered variance, where the default followsreghdfeand does not charge an effect nested in the cluster variable. Withdrop_singletons=Falsethe fit is then the dummy-variable regression, slopes and standard errors equal tosp.regress('y ~ x + C(g)')to 1e-10 under the classical, robust and clustered variances, with and without weights, without building the dummies.sp.statasendsaregthere: on the NLS panel (4,134 groups) the command goes from about three minutes to under a second, and its_consis 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'sttesti).- Abbreviated variable names in
sp.stataare spelled out inheckman,truncreg,etregressandprais, and inside the options that hold a varlist:select(),treat(),absorb(),by(),cluster()andvce(cluster ...). An abbreviation that fits two variables is refused, as in Stata. sp.statarunssdtest,sdtesti,ztest,ztesti,ttesti,etregress,pperron,kpss,arima,arch,testparmandnlcom.
Fixed
xtreg y x i.year, fethroughsp.statafailed 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.regresswith 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=onsp.rlasso,sp.rlasso_effect,sp.rlasso_effectsandsp.rlasso_iv. The penalty loadings of Belloni, Chernozhukov, Hansen and Kozbur (2016) sum the scores within cluster before squaring,gammadefaults to0.1 / log(G), and the final standard error is cluster-robust. hdm has no such option and the book sources an unpublishedrlasso_cluster.R. Against Stata's lassopack on the same bytes: the penalty level, the selected variables and the post-Lasso coefficients ofrlasso, cluster()agree to 1e-10, and the point estimates and variances ofpdslasso, cluster()(double selection and partialling out) andivlasso, 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 inpdslasso. 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 withoutcluster=keeps 6.1 controls on average and its 95% interval covers 78.6% of the time; withcluster=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 isstatspai.synth._core.placebo_inversion_ci, checked against a brute-force grid. UnderH0: effect = Cthe weights do not move, the treated gap becomesgap - Cand the gap of a placebo whose donor pool holds the treated unit with weightwbecomesgap + w C, so each comparison is a quadratic inequality inC. 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 ofre78is 5,508; in the real file it is 6,349). The docstring now opens with that and the frame carriesattrs['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.survregraisesMethodIncompatibilitywhen a duration is zero or negative. It used to add1e-15to every duration and fit.
Fixed¶
sp.zip_modelandsp.zinbreturned 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 thetripsdata of the book, Rpscl::zeroinflreports standard errors for the other 19 coefficients and StatsPAI's are now within 0.5% of them.sp.hurdlewas 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 withsensitivitymw::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 unlesscluster=was given, andvcovonly ever chose the effective F reported beside it. Agrees withsandwich::vcovHCon the reduced form to 1e-9 for one and two instruments. The default stays'classic'.sp.ri_test(stat='rank_sum' | 'lin' | 'lin_t')andsp.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 byn1 n0 / n, is below the chi-square quantile of the acceptance probability;result.rerandomizationrecords it. Then_rerandsearch 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 ofstatistic='ks'/'rank_sum'/'t'a confidence interval.FisherResulthas analphaattribute.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_multiplegtwith 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 Statadid_multiplegt_oldreturns (1e-8), as are the two placebos. Binary treatments give the same numbers as before. - ⚠️
sp.sun_abrahamon 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 underweights=), as in Sun and Abraham (2021) and Stataeventstudyinteract. 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 foreventstudyinteract;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 ofAv_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 ignoredweights=andtrends_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, withintrends_nonparamcells, normalized, one switch direction, and on an unbalanced panel. - ⚠️
regress y x, vce(hc2 clustvar, dfadjust)throughsp.stata/sp.from_statacame back assp.regress(robust='hc2'): the cluster variable and the degrees-of-freedom adjustment were dropped without a note. It is nowvce='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 Statatwowayfeweights.sp.did_multiplegt_dyntakes 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 andAv_tot_effagree with Statadid_multiplegt_dynon 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 newjoint_effects_test,joint_overall_testandeffects_equal_testare 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=andnormalized_weights=insp.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 forrobust='hc2'and forvce='cr2'withcluster=, as Stata 18vce(hc2 [clustvar], dfadjust).sp.testnow works aftervce='cr2'/'cr3', whose full covariance is stored.- Translations:
twowayfeweights,did_multiplegt_dyn,did_hadanddid_multiplegt_oldrun throughsp.stata. - A sup-t test in
sp.uniform_bands, andterms=. The result'sattrsnow carrymax_tandsupt_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 Statasotable. sp.fecton 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 formethod='fe'. Smaller panels take the same path as before.sp.fectwarns when the iterations stop atmax_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 attol=1e-3, 0.131 at1e-4and 0.051 at1e-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')andmodel='pliv'with a classifier nuisance used the predicted label, not the predicted probability. Passingml_m=RandomForestClassifier()for a 0/1 treatment residualised it asD - 1{p > 0.5}. On the book's 401(k) example the estimate was 6,044 whereDoubleMLand the book give 8,754. A classifier now contributespredict_probafor a 0/1 outcome, treatment or instrument, and is refused for any other target. Regressors,model='irm'andmodel='iivm'are unchanged.sp.dml_model_averaginghad the same defect and the same fix. See MIGRATIONoct2026-causalml-textbook-fixes.sp.dml_sensitivityon an IRM fit used the PLR scaling. The bound for the average effect needssigma^2 = E[(Y - g(D, X))^2]and the Riesz representerD/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 toDoubleML's to 1e-10, for the ATE and the ATTE scores.sp.dml_sensitivity'srv_qanow 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 subtract1.96 * seof the unadjusted estimate, which ignores thatSis estimated. On the 401(k) PLR fitrv_qamoves 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 Restimatr::lm_lin2.0.0 to 1e-9 for HC0 to HC3, classical, CR2 (with Bell-McCaffrey degrees of freedom) and Stata clustering.superpopulation=Trueadds 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_curvegainsse,ci_lower/ci_upperandband_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_hatis no longer required whenmu1_hatandmu0_hatare 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(...)returnsse_low,se_high,ci_lowandci_highfor PLR and IRM fits, with clusters and with repeated cross-fitting.DoubleML0.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')storesmodel_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_projectionacceptssp.dml(model='irm')results. It projects the cross-fitted doubly-robust scores on covariates or group indicators, which isDoubleML'scate()/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 adict, now with.vcov()(equal to hdm's to 1e-14) and.conf_int(joint=True), a sup-t band as inconfint(<rlassoEffects>, joint = TRUE). sp.sensemakrtakes string and categorical controls (they enter as indicators and are benchmarked as a group), takeskd=/ky=multiples, and reports the adjusted estimate, standard error, t and interval for each benchmark, equal to Rsensemakr0.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.dagaccepts named unobserved nodes (latent=[...]or"F [latent]"): they are never offered as adjustment variables andsp.identifyprojects them out (DAG.latent_projection()).DAG.implied_independencies()lists the testable implications of a graph andDAG.test_implications(data)tests them; both equaldagitty's output.
Fixed¶
sp.proximal_regressionrefuses a treatment that is not 0/1. It used to truncate a continuous treatment to integers and return a number.sp.dml_sensitivityrefuses 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_decompositionnow 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 ofsp.bacon_decompositionand 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 toxtreg, fe vce(cluster)to 1e-10). The comparison with Rbacondecompandtwowayfeweightsonmpdta, a strictxfailuntil 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', whatteffects psmatchdoes) 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 covariatesteffects psmatch, atetgives 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 ipwwas translated tosp.ipwwith its default bootstrap standard error. It now asks for the stacked M-estimation standard errorteffects ipwreports (se_method='sandwich').teffects aipwwas translated tosp.aipwwith its default five-fold cross-fitting, a different (and random) estimator. It now runscross_fit=False, se_method='sandwich', andatet, whichteffects aipwdoes not have, is refused.boottestwas translated to a call ofsp.wild_cluster_bootstrapwith arguments that function does not take, so it raised aTypeError. It now runssp.wild_cluster_booton the regression before it, withweight(),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). Abootcluster()other than the error cluster, a joint hypothesis and an unknown weight are refused.
Added¶
- A probit propensity score.
sp.ipw,sp.matchandsp.psmatch2takeps_model='probit'(default'logit', as before). The sandwich standard error ofsp.ipwand the Abadie-Imbens (2016) standard error ofsp.matchuse the probit's score, density and observed Hessian. Against Stata 18 on the Lalonde data, to the seven digitsteffectsprints:teffects ipw (..., probit)ATET and ATE with their standard errors,teffects psmatch (..., probit), atetwith its standard error, andpsmatch2(whose default is a probit) to 1e-7.tests/reference_parity/test_teffects_probit_stata.pyholds the numbers.teffects ... , probitandpsmatch2withoutlogitare 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=Trueaddsci_inverted, the set of null values the test does not reject, which is whatboottestprints; the translatedboottestasks for it.- The Abadie-Imbens (2016) standard error for the ATE.
sp.match(estimand='ATE', se_method='abadie_imbens_2016')reports whatteffects psmatch, atereports, 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, ateis translated with it; it used to be flagged as not carrying Stata's standard error. egeninsp.stata:count,mean,median,sd,min,max,total,pctile,iqr,std,group,tagand therow*functions, withif,in,by()and behindby 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.stataruns more of an ordinary do-file.quietly { ... },capture { ... }andnoisily { ... }blocks;capture cmd, which swallows what Stata would raise (a variable that is not there,restorewithoutpreserve) and nothing else;by g:/bysort g (t):in front ofgenerateandreplace, with_n,_Nand subscripts counted within the group;count [if] [in];_pctileand the percentiles, skewness and kurtosissummarize, detailleaves inr();scalar(name);_b["x"];duplicates drop [varlist, force];bsample [, cluster()](the data are then marked as random);predictafter a regression withi.variables; the variablespsmatch2leaves behind (_pscore,_weight,_support...); ascalardefined with no data in memory. Loops, frames and matrices are still refused.scripts/stata_log_replay.pyreadsimport delimited(Stata's delimiter detection and lower-cased names) andinputblocks, and comparessdid,boottestandpsmatch2output. 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 acrossuse.sp.stataruns loops.forvalues,foreach,while,if { } else { }andcontinue, 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,syntaxandmataare still refused.sp.stataholds more than one dataset.savekeeps a copy in the session (no file is written);use,append using,merge 1:1 | m:1 | 1:m ... usingwork on those copies and on the frames passed in the newfiles=argument. Frames (frame create / copy / change / put / post,frlink,frget) are run.merge,append, andfrlink/frgetwere 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 andinv(). - 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_forestacceptstreat=andcovariates=ford=andx=.display "text" expevaluates 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 matchesrdlocrandto 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:wlandwrare the window's endpoints, as inrdlocrand, not offsets from the cutoff. The two agree only atc = 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.rdwinselectdid 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 matchedrdmulti::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 asresult.normalized. -
sp.rdrobust's automatic manipulation check is nowsp.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 wheresp.rddensitygives 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 inmodel_info['mccrary']['pvalue'](the key keeps its name;'test'beside it says'rddensity').sp.mccrary_testitself 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, inmodel_info['power']),evall=/evalr=,fuzzy_stat=.sp.rdwinselect:obsmin=,wobs=,wasymmetric=,approx=,n_perms=,kernel=,dropmissing=; columnsvariableandbinom_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 aresp.rdplot's on each cutoff's units and are returned infig.rdmcplot_data.sp.bitest: the exact binomial test of Stata'sbitest/bitestiand R'sbinom.test, from a 0/1 column or from counts. Agrees with Stata 18 to 1e-12, includingr(k_opp).sp.stataruns both commands and stores theirr()results.sp.rdrandinf(interfci=): Rosenbaum's (2007) confidence interval under interference between units, inmodel_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 underapprox=True; agrees withrdlocrandto 1e-9.sp.rdwinselect(wmasspoints=True): for a discrete score, windowkruns from thek-th support point below the cutoff to thek-th at or above it, as inrdlocrand1.0 (same windows, counts and p-values). Releases 1.1 and 2.0 pair thek-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 thecvar()variables bysp.stata),bitest,bitesti, and theinterfci()andwmasspointsoptions.sp.rdmswithoutx2: one score with cumulative cutoffs (rdmulti::rdms(Y, X, C)), a sharp RD at each cutoff on the whole sample or, withranges=, on the units in each cutoff's interval. Agrees withrdmultito 1e-9.sp.statarunsrdms y x, cvar(c) range(lo hi)andrdmcplot y x, cvar(c) pvar() nbinsvar() ..., reading the per-cutoff values from the named variables.sp.rdms: lists forcutoff1=/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 withrdmulti::rdmsto 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.rdlocrandre-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 andp = 1, that test rejects a true null 37% of the time at the 5% level.sp.rdrandinfpermutes outcomes against (score, assignment) pairs and rejects 4.7% (3,000 replications) (tests/reference_parity/test_rdlocrand_extensions_parity.py). Forp = 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.rdwinselectare those ofrdlocrand1.0, to 1e-9 in every column (test_rdlocrand_v1_parity.py). Releases 1.1 and 2.0 start one observation short on the left of the cutoff (9 withobsmin = 10on the Senate data); the documentation and the book's printed output describe what 1.0 does. Four releases were run side by side: this and the mass-point shift appear in 1.1, and a Kolmogorov-Smirnov randomization p-value stuck at 1 on binary variables appears in 2.0. - Kolmogorov-Smirnov p-value with ties.
sp.rdrandinf(statistic= 'ksmirnov')now reports the exact conditional p-value, which is what R does below 10,000 pairs. With tied outcomes the previous value assumed a continuous distribution: 0.965 where R gives 0.356 on a binary covariate. The randomization p-value was not affected.
What the labs of Cunningham's Causal Inference: The Remix found¶
The labs of the 2026 workshop tour were run in Stata 18 and R did and
again through StatsPAI. Three results were wrong (⚠️ below) and four
commands could not be translated. Details and what is still open are in
docs/dev/2026-10-04-mixtape-remix-review.md.
- ⚠️
sp.callaway_santannawith a covariate that varies over time. Each ATT(g, t) now conditions on the covariate's value in the earlier of the cell's two periods, as Statacsdidand Rdiddo. The value in the unit's first row of the data used to be taken for every cell, so the estimates matched neither package and depended on the row order. On the Remix's Brazilian mental-health panel (14 time-varying controls,method(drimp) notyet long2) the simple ATT was 0.3985 (0.0613) and is now 0.4028 (0.0598),csdid's number, with all 196 cells within 6e-6. Covariates that are constant within unit give the same numbers as before. Reference rows againstcsdidand againstdid::att_gtare intests/reference_parity/test_stata_did_commands_parity.py. - ⚠️
sp.etwfe_emfx(type='group' | 'calendar' | 'event')on asp.jwdid/sp.etwfefit: the standard error of the headline. The headline estimate is the unweighted mean of the reported rows, and the standard error printed beside it was the one of the'simple'aggregate, a different quantity. It is now the delta-method standard error of that mean through the rows' joint covariance, which is what Stata gives for the mean of the rowsestat event, postleaves ine(b)(0.1168 against the 0.1064 reported before, on the test fixture). Withinclude_leads=Truethe headline no longer averages the pre-treatment rows in. The rows themselves, their standard errors andtype='simple'are unchanged. - ⚠️
sp.did_imputation(pretrends=k)when some of thekleads are not identified. Without never-treated units the far leads are observed for the last cohort only, and their indicators are spanned by the fixed effects and the nearer leads. StatsPAI inverted the singular matrix anyway and returned every lead, the identified ones included, with standard errors in the thousands. It now refuses, names the unidentified leads and the longest run that is identified. On Baker's simulated panelpretrends=17reproduces Stata'spre1topre17, and the leads refused are the ones Stata reports as omitted.
Added on the way:
sp.callaway_santanna(estimator='drimp'), the improved doubly robust estimator of Sant'Anna and Zhao (inverse probability tilting for the propensity score, odds-weighted least squares for the outcome model), on panels and on repeated cross-sections. It is Statacsdid'smethod(drimp)and agrees with it to 1e-12 on the estimate and 1e-9 on the standard error on the Remix's TVA panel.sp.estat(result, 'simple' | 'group' | 'calendar' | 'event')aftersp.callaway_santannaorsp.jwdid/sp.etwfe, the aggregations Stata'scsdidandjwdidreport throughestat, withwindow=(a, b)forestat event, window(a b). Aftersp.callaway_santannathe group average holds the cohort shares fixed, ascsdiddoes;sp.aggtekeeps Rdid's convention.sp.stata/sp.from_statatranslatedrdid,jwdid,csdid_estatandestat simple | group | calendar | event, andcsdid ..., method(drimp). Every line is checked against what Stata 18 printed for it, 46 comparisons in the file named above. Two things the translation says out loud.drdidwith no estimator named isdrimp.csdid ..., ipwis notmethod(ipw), becausecsdidaccepts and ignores an estimator written as a bare option, so the line runs the default as Stata does.did_imputation, minn(#)other thanminn(0)is reported instead of approximated.sp.gardner_did(treat=, fe=, second_stage=), the general form of the two-stage estimator that Rdid2sand Statadid2simplement. The untreated rows are the ones a treatment dummy marks, the first stage is any list of fixed effects ('region#year'for a cell, as insp.did_imputation(fe=)) with covariates, and the second stage any list of columns or'ib0.x'factors.sp.gardner_didcould fit unit and period effects only, so region-by-year effects, as in the Remix's Medicaid lab, were out of reach. Eight forms agree with Statadid2sto 1e-8 (1e-6 where Stata demeans withinunit()in single precision), andsp.statanow runsdid2slines. Calls withfirst_treat=are unchanged.sp.drdid(est_method='all'), the table Stata'sdrdid, allprints: the doubly robust, outcome-regression and weighting estimators on one sample, one row each in.detail(five on a panel, seven on repeated cross-sections), every row equal to Stata's to 1e-13. The result itself is the improved doubly robust fit.sp.statarunsdrdid ..., all, the form the labs use most.sp.g_computation(by_arm=True), regression adjustment with an outcome regression in each treatment arm, and withps_covariates=its inverse-probability-weighted, doubly robust form.se_method='analytic'is the sandwich of the stacked estimating equations. These are Stata'steffects raandteffects ipwra: the effect, the untreated potential-outcome mean and both standard errors agree to 1e-14, for the ATE and the ATT.sp.stataruns both commands, which it refused. The default call is unchanged.-
sp.statareads more of a do-file.`r(mean)'andlocal m = r(mean)are expanded from the results the session holds, in the text Stata writes into a macro (.41178860375for a mean of 0.41178860374999998, so a line that uses the macro computes with Stata's number).keepanddroptake varlist ranges and wildcards.reshape wideandreshape longgive Stata's columns and rows.collapsehasfirstnmandlastnm.reghdfetakesib<k>.x, for whichsp.hdfe_olsgained the same spelling among its regressors. With these the Lalonde and Medicaid labs of the Remix run from the first line to the last. Macros that read the data, and loops, are still refused. -
⚠️
sp.synth(method='classic')without predictors now fits the raw pre-treatment path. The default used to rescale each pre-treatment period by its range across units before solving for the weights, a step meant for predictors in different units, so the periods were weighted unequally and the weights did not minimise the pre-treatment squared error.standardize_predictorsnow defaults toNone: covariates and special predictors are rescaled as before, the pre-treatment outcomes are not. The result is the simplex least-squares fit thataugsynth(progfunc = "None")andsynthdid'sscreturn and that a nested V on the outcome lags converges to. Numbers that move: the README's Proposition 99 example from -19.76 (11.23) to -19.51 (10.80), same placebo rank 3 of 39, pre-treatment RMSE 1.72 to 1.66; the outcome-only Texas fit of the Mixtape replication from 21,482 to 21,013, on the same three donors. Fits withcovariates=orspecial_predictors=do not move, nor do Track A modules 07 and 52.standardize_predictors=Truegives the earlier numbers, and the RSynthreferences for the sensitivity tools, which were built for that convention, now ask for it by name.sp.synth_loo,sp.synth_time_placebo,sp.synth_donor_sensitivity,sp.synth_rmspe_filterandsp.synth_sensitivitytake the same argument and follow the same default. - The Remix workshop's worked project as a reference. The tour
includes a complete staggered-adoption analysis with its R outputs
committed (16 countries, 1994 to 2010). StatsPAI reproduces them: Bacon
decomposition, BJS with a covariate, Callaway-Sant'Anna with a
time-varying covariate in four configurations (a second confirmation
of the fix above), Sun-Abraham under
aggregation='fixest_att', synthetic DiD, ridge-augmented SCM, and, with the change above, outcome-only SCM (weights within 1e-5 ofaugsynth's for the four treated countries; before it one weight was off by 0.13).
Three more ⚠️ from the same replay, all in sp.stata / sp.from_stata:
- ⚠️
csdidwas translated with StatsPAI's propensity-score trimming, notcsdid's.csdid1.81 defaults topscoretrim(1), no trimming;sp.callaway_santannadefaults to 0.995, as Rdid. The translated call now carriespscore_trim=1.0. On a design with four controls above 0.995 the first post-treatment cell is 1.4400 (0.7104) without trimming and 1.3164 (0.3862) with it; both are reproduced. The guide said the two defaults were the same.sp.callaway_santannaitself is unchanged. - ⚠️
reghdfe y i.x, absorb()was translated to a call that could not run. The factor was written asC(x), whichsp.hdfe_olsdoes not read, and the translation was reported as faithful. It is nowi.x. A factor inside an interaction among the regressors is refused.noconstantis recognised as changing only whatreghdfeprints. - ⚠️
collapse (first)and(last)skipped missing values, which is what Stata'sfirstnmandlastnmdo. They now take the first and last row of the group whatever it holds.
Agent cards¶
Fixed¶
- 69 agent cards listed an alternative twice and 12 listed the
function itself. A family card and a curated card could name the
same function under two spellings (
did_imputationandsp.did_imputation), and a family card names its own members, sosp.agent_card('callaway_santanna')recommendedcallaway_santannainstead. Alternatives are now distinct and never the function itself, across all registered functions. - The card audit against real calls covers 60 entry points, up from 40
(
heckman,mediate,oaxaca,ebalance,cbps,lee_bounds,rdrandinf,rd_honest,wooldridge_did,lp_did, thendid_multiplegt_dyn,fect,interflex,xtabond,sensemakr,oster_bounds,rdbwselect,rd_discrete,mixed,liml). The twenty new cards had no defect in required arguments, result class or enum values. - Arguments agents could not see.
sp.regress(weights=, vce=),sp.ivreg(cluster=, vce=)and six arguments ofsp.nbreg(weights,y,x,alpha,maxiter,tol) were accepted by the functions and absent fromsp.function_schema(). They are registered now, and theweightsdescriptions say which kind of weight the default variance assumes. The card ofsp.rdrobustdescribedweights=as reserved and unimplemented; it has been implemented and reference-checked for some time, and the card says so. sp.fect(method='ife' | 'mc')withoutr=/lam=, andsp.interflex(estimator='kernel')withoutbw=orvce='bootstrap'with another estimator, raised a bareValueError. They now raiseMethodIncompatibility(aValueError) with a recovery hint naming the argument to add.
Reliability¶
- Weight diagnostics in
sp.logit,sp.probit,sp.glmandsp.iv, which completes the set. A fourth weight study (tests/reliability/extreme_weights_binary_iv.py, 24 designs, 2,000 replications each) finds the default variance covering 15% to 79% under dispersed sampling weights and the robust one 81% to 90% in small effective samples. Every regression entry point that takesweights=and reports a model-based variance by default now recordsn_effective_weights(andn_clusters_effective_weightswhen clustered) and warns. No estimate changes.docs/dev/2026-10-05-weights-declaration-options.mdrecords the decision behind this: keepweights=as it is and warn now; add an optionalweights_as=after the JSS submission; do not make it required. sp.event_studyis checked againstfixestacross its common options. A 32-cell grid on one panel (single-date and staggered adoption, two windows, two reference periods, with and without a time-varying covariate and unit-level weights) agrees withfixest::feolson every coefficient and standard error to 2e-12. Three cells joinsp.validation_scope('event_study')(covariate, weights and both, at the default window and reference period); the others stay as regression checks and are not credited as evidence, the staggered ones because a reference match there certifies the specification and not the estimand. No code changed.sp.iv(method='gmm')now has reference rows against Stataivregress gmm, and acceptssmall=False. Coefficients, standard errors and Hansen's J agree with Stata 18 to 5e-9 on Card's data under the robust, cluster and unadjusted weight matrices, with and withoutsmall.small=False(Stata's default forivregress gmm) was refused for GMM before; the difference is the finite-sample factor,N / (N - K)orG/(G-1) * (N-1)/(N-K), and nothing else. The docstring ofsp.ivlists which options give whichivregress gmmvariant.sp.validation_scopealso failed to recognise a GMM fit (the label carries its step count) and reported every one as not covered; fixed.- Weight diagnostics in
sp.fast.feols,sp.fast.fepoisandsp.nbreg. A third weight study (tests/reliability/extreme_weights_fast.py, 18 designs, 2,000 replications each) finds the same three failures there as elsewhere: the default variance under sampling weights (22% to 81% coverage), a robust variance in a small effective sample (84% to 90%), and a clustered one when the weights leave few clusters in effect (79% at 7). The three entry points now recordn_effective_weightsandn_clusters_effective_weightsand warn; the fast result objects carry them in a newweight_infoattribute. No estimate changes. - Few treated clusters in fixed-effects regressions. A fourth block
of
tests/reliability/few_clusters.py(40 units, 10 periods, a true effect of zero, 2,000 replications) gives the rejection rate of the 5% test as the number of treated units grows: unit-clustered two-way fixed effects 75% / 31% / 10% / 6% at 1 / 2 / 5 / 10 treated units,sp.did_few_treated3% / 7% / 10% / 14%.sp.panel,sp.hdfe_ols,sp.feolsandsp.regressnow record a 0/1 regressor that is ever 1 in fewer than 10 clusters infew_treated_clustersand warn; the hint offerssp.did_few_treatedonly for one or two treated clusters.sp.did_few_treatednow warns when the treated groups are more than a tenth of the controls (it used to wait until they were more than half). No estimate changes. - New guide, "When the interval can be trusted"
(
docs/guides/when_intervals_hold.md): the findings of the reliability studies as rules for clusters, weights, unbalanced panels, a discrete running variable and the DML learner, each with the diagnostic to read on the result. The DiD estimator guide's section on unbalanced panels now says whatallow_unbalanced_panel=Trueassumes. - The reliability studies are in the evidence view.
docs/evidence_inventory.jsonlists every design and method of the six studies undertests/reliability/with its rate, Monte Carlo standard error and the number of refused fits (which count in the denominator);docs/evidence_inventory.mdsummarises, per study and method, how many designs sit at the nominal rate and which is furthest from it. - A coverage study for DML across first-stage learners
(
tests/reliability/dml_learners.py, 20 designs, 300 replications each). Under linear confounding OLS, Lasso andsp.dml_model_averagingcover 94% to 95% and the tree learners 90% to 93%. Under nonlinear confounding the linear learners are biased by 0.72 on a coefficient of 0.5 and never cover, a random forest covers 43% to 67%, gradient boosting (thesp.dmldefault) 87% to 90%, and model averaging 73% to 87%. The reported standard error matches the sampling spread in every design, so the shortfall is nuisance bias, which the interval does not carry. No code changed; the existing note on a defaultsp.dmlfit already points to learner choice and model averaging. sp.rdrobustat polynomial orders 0, 3 and 4 now has reference rows. 54 cells (six bandwidth selectors, three kernels, three orders) against Rrdrobust4.0.0, eight outputs each: agreement to 1e-13 atp=0, 1e-10 atp=3and 1.4e-8 atp=4. The existing grid coveredp=1andp=2only;sp.validation_scope('rdrobust')lists the new cells. No code changed.- A coverage study for staggered-adoption DiD on an unbalanced panel
(
tests/reliability/unbalanced_panel.py, 8 designs, 1,000 replications each).sp.callaway_santanna,sp.did_imputationand two-way fixed effects hold 94% to 97% when cells are missing at random and when units leave according to their level.sp.callaway_santanna(allow_unbalanced_panel=True)compares group means, and is biased by 40% of the effect (coverage 50% with 100 units, 3% with 400) when treated units with a high level leave. That is what the estimator computes, in Rdidas here; what changed is that the docstring and the unbalanced-panel warning, which recommended the option without qualification, now state the assumption, and the result records it inmodel_info['unbalanced_assumption']. No estimate changes. - The
sp.rdrobustwarning for a running variable with fewer than 30 distinct values now quotes the coverage found intests/reliability/rd_mass_points.py.
[1.38.0] — 2026-10-04¶
A .dta file that is read and written back is now the same file. The names
of value-label sets, the extended missing values .a to .z, notes,
characteristics (xtset declarations among them) and display formats all
survive sp.read_data followed by sp.write_data, and Stata 18 confirms
it. Files from Stata 18 with alias variables (format 120 / 121) can be
read. The release also carries weight diagnostics beyond sp.regress and
a sp.rdrobust that refuses an undefined bandwidth on a discrete running
variable in place of returning a NaN interval.
One default changes: sp.write_data folds a <var>__miss column back
into <var>. See MIGRATION.md under 1.37.0 → 1.38.0.
A .dta file read and written back is the same file¶
sp.read_data followed by sp.write_data used to lose four things Stata
stores in a .dta file. All four now survive, and Stata 18 confirms it: the
files written here pass dtaverify in formats 117, 118 and 119, and
cf _all using <original>, all matches every variable.
Added¶
- Value-label sets keep their names.
sp.read_datarecords the name of each variable's set indf.attrs['_value_label_names'], andsp.write_datawrites one set under that name for all the variables that share it. Before, pandas named each set after its variable, so a set shared by twenty items became twenty sets. A variable whose labels were changed gets a set of its own. - Extended missing values are written back. A
<var>__misscolumn fromread_data(extended_missing='column')is folded into<var>as.a....zon write;write_data(extended_missing='nan')keeps the old behaviour. A code on a row where<var>holds a value is refused. - Notes and characteristics are read into
df.attrs['_notes']anddf.attrs['_characteristics'](keyed by variable, the dataset's own under'_dta') and written back for the columns still in the frame.xtset/tssetdeclarations are characteristics, so they survive. - Display formats are written, not only the unit of a date: a
%12.2fccolumn comes back as%12.2fc. A format Stata would refuse for the column is left out with a warning. - Format 120 / 121 files are read (Stata 18, alias variables), which
neither pandas nor pyreadstat opens. The alias variables hold no data
in the file; they are left out with a warning and listed in
df.attrs['_alias_variables']. sp.stataknows the label sets a file came with:label define yesno 2 "Maybe", addafter loading a .dta now changes every variable attached toyesno.
Changed¶
sp.write_datato .dta is atomic. The file is written beside the target and moved into place when complete; a write that fails leaves an existing file as it was.
Fixed¶
sp.read_data(extended_missing='column')failed on a file with a date variable that had a missing value, on both readers: pandas stopped withTypeError: ufunc 'isnan' not supportedand pyreadstat withValueError: Given date string "a" not likely a datetime. The dates are now converted after the codes are taken out.
sp.rdrobust with a few support points¶
Fixed¶
sp.rdrobust(masspoints='off')crashed with a complex number, or returned a NaN interval, on a discrete running variable. With five support points a side the bandwidth selector took a fractional power of a negative variance-to-bias ratio and stopped withTypeError: float() argument must be ... not 'complex'in half the replications of a simulation; in a few others a negative variance came back as a NaN standard error and a NaN interval, with no error. Both now raiseNumericalInstabilitywith a recovery hint (masspoints='adjust', a manualh=, orsp.rd_discrete/sp.rdrandinf). The defaultmasspoints='adjust'was not affected.- A fall-back to the legacy bandwidth selector or estimator is no
longer silent. When the CCT selector or the bias-corrected fit fails
on degenerate data and
sp.rdrobustuses its older implementation instead, it now warns and lists the step inmodel_info['legacy_fallbacks']; an undefined bandwidth is raised rather than replaced by another selector's number.
Reliability¶
- Weight diagnostics in
sp.panel,sp.hdfe_ols,sp.feols,sp.poissonandsp.ppmlhdfe. A second coverage study (tests/reliability/extreme_weights_models.py, 12 designs, 2,000 replications each) repeats the weight question for fixed effects and Poisson. The default variance covers 45% to 80% (fixed effects) and 14% to 68% (Poisson) of the time under dispersed sampling weights; the robust variance drops to 84% at a Kish effective size of 17; and a cluster-robust interval with unit-level weights follows the number of clusters the weights leave in effect (78% at 7, 87% at 17, 91% at 23), whatever the count of clusters. These entry points now recordn_effective_weightsand, for one-way clustering,n_clusters_effective_weights(inmodel_info, orweight_infoon thesp.hdfe_olsresult), and warn in those three cases. No estimate or standard error changes. - A coverage study for
sp.rdrobuston a discrete running variable (tests/reliability/rd_mass_points.py, 28 designs, 1,000 replications each). The robust interval holds 94% to 96% down to 20 support points a side and falls to 69% to 89% at 10 and 5, where the mass-point bandwidth floor reaches the whole side; the existing warning for fewer than 30 distinct values covers that range. Clustering on the support points undercovers at every level (86% at 50 points a side, 22% at 5), andsp.rdrobustnow warns when each cluster holds a single value of the running variable. sp.regressreports the Kish effective sample size of the weights and warns in two cases a new coverage study found. Withweights=alone the standard errors are the classical weighted ones, which assume precision weights. Under sampling weights the 95% interval of a slope covered 78% of the time at a Kish ratio of 0.4 and 34% to 47% at 0.05 to 0.09 (tests/reliability/extreme_weights.py, 2,000 replications a cell); the fit now warns when the ratio is under 0.5 and says which reading each option corresponds to. HC1 covers 88% at a Kish size of 53 and 84% at 17 where HC3 stays at 93% to 94%; HC0 / HC1 / HC2 fits warn when the Kish size is under 100 and under half of n.model_info['n_effective_weights']is recorded on every weighted fit. No estimate or standard error changes.
[1.37.0] — 2026-10-04¶
Value labels now do something. A .dta file's value labels were read and
written back but used by nothing; they now reach tabulations, regression
tables and do-files run through sp.stata, and the labels Stata attaches
to its extended missing values (.a "Refused") are read correctly on both
reader paths. The rest of the release is small-sample inference:
cluster-robust p-values and intervals under CR2, CR3 and two-way
clustering refer to t(G - 1), the sp.fast estimators count degrees of
freedom as fixest does, and weighted Poisson standard errors use the
weights.
Python 3.10 is now the oldest supported version. Numbers that change are
listed in MIGRATION.md under the 1.36.0 → 1.37.0 headings.
Python 3.10 is the floor; Windows is untested for now¶
Changed¶
- Python 3.9 is no longer supported (
requires-python = ">=3.10"). 3.9 reached end of life in October 2025, andpyfixestand current SciPy no longer install on it, sosp.xtevent(default engine),sp.sscand the TWFE arm of the DiD calibrated simulation did not run there. A full test run on 3.9 failed 47 tests, 45 of them for that reason. Thefixestextra loses itspython_version >= '3.10'marker. - Windows is out of the test matrix for now. A full run on Windows
failed about 18 tests that pass on Linux and macOS, most of them the MCP
server's stdio tests. The project has no Windows machine to debug them.
The package still installs on Windows and the Rust backend's wheels are
still built for it, but nothing is verified there until the list in
docs/dev/2026-10-04-windows-test-gaps.mdis cleared.
Value labels, in use¶
Variable labels already reached df.attrs and went back out to .dta.
Value labels were stored and then used by nothing: sp.tab printed 1 / 2 /
3, sp.regtable printed C(edu)[T.2], and a do-file's label commands
were skipped. See the new guide docs/guides/stata_labels.md.
⚠️ Correctness¶
- A label on an extended missing value is no longer attached to a
number. For a file with
label define regionlbl ... .a "Refused",sp.read_datareturned{..., 2147483622: 'Refused'}inattrs['_value_labels']withoutpyreadstat(the raw code of.ain a label table, a value no row holds) and{..., 'a': 'Refused'}with it. Both now return the real codes in_value_labelsand{'region': {'.a': 'Refused'}}in the newattrs['_missing_labels']. AUserWarningsays that the rows are read asNaNand can no longer be told apart from.. - With
pyreadstatinstalled, a numeric variable holding.a....zcame back as anobjectcolumn. It isfloat64withNaN, as on the pandas path. A regression on such a column failed or, worse, treated it as categorical.
Added¶
sp.read_data(path, extended_missing='column')keeps which missing value each row held, in a<var>__misscolumn ('.a'...'.z') next to the variable. The variable itself stays numeric withNaN.sp.label_values(df, var, {code: text}): Stata'slabel define+label values.'.a'...'.z'label extended missing values.sp.decode(df, columns=None, missing=False): Stata'sdecode. Ordered categoricals whose categories follow the codes.sp.regtable(..., labels=df)labels the rows from the data's variable and value labels (esttab, label):C(edu)[T.2]andedu::2becomeEducation: secondary, interactions are joined by×.sp.label_vars(df, other_frame, rename=None)copies variable labels, value labels and formats from a labelled frame, for after amerge,get_dummiesorrename, where pandas drops or misplaces them.sp.write_data(value_labels=)takes'.a'...'.z'codes, and writesattrs['_missing_labels']back to .dta and Parquet.sp.read_dataon an SPSS .sav file keeps value labels (for numeric variables with whole-number codes) inattrs['_value_labels']; it used to keep variable labels only.sp.statarunslabel variable / define / values / data / dropanddecodeinstead of skipping or refusing them;encodelabels the new variable;renametakes the labels along;preserve/restorecover labels;codebookandlabelbookare skipped as display commands.
Changed¶
sp.tabprints value labels in place of codes when the data carry them, in the order of the codes, as Stata'stabulatedoes.labels=Falsegives the codes (nolabel). Counts and tests are unchanged, and so is the output for data without value labels.tabulateinsidesp.statadoes the same and honoursnolabel.sp.describehas a sixth column,value_labels(0=No, 1=Yes).
sp.ssc without pyfixest¶
Fixed¶
sp.ssc(...)raised a bareModuleNotFoundErrorwhen the optionalpyfixestwas not installed. It now raisesMissingDependencyError(still anImportError) naming thefixestextra, assp.feolsdoes.
What the full test matrix found after five days without running¶
The count gates in front of the test job had been red since 2026-09-28, so nothing below was tested on CI in that time. A manual full run across three operating systems and Python 3.9 to 3.13 turned these up.
Fixed¶
sp.causalkeeps its signature after the deprecatedstatspai.causalshim is imported. Importing the shim (from statspai.causal import CausalForest) rebindssp.causalto a callable module, andinspect.signature(sp.causal)then read(*args, **kwargs). Anything that introspects the signature saw a different function depending on import order. The call itself always worked.- On Python 3.13 the four regression forests document their shared
options again.
sp.regression_forest,sp.multi_regression_forest,sp.probability_forestandsp.quantile_forestsplice a common block of parameter documentation into their docstrings at import, by matching a placeholder line with its indentation. Python 3.13 strips docstring indentation at compile time, so the match failed there:help()showedyand nothing else, andsp.function_schemafell back to generic parameter descriptions. The docstrings are now identical on every supported Python.
Stata dates through sp.read_data¶
⚠️ Correctness¶
- A Stata date is the same value with and without
pyreadstat.sp.read_datatakes the pyreadstat reader when that optional package is installed and the pandas reader otherwise, and the two disagreed on dates. With pyreadstat a%tm,%tq,%th,%twor%tyvariable came back as Stata's raw period count (720.0for 2020m1) and a%tdvariable as anobjectcolumn ofdatetime.date; without it every one of them wasdatetime64. Both paths now returndatetime64, set to the first day of the period aspd.read_statadoes. Found by running one notebook under two interpreters. The pandas path had its own gap: a%tycolumn with a missing value came back asobject, and is nowdatetime64too. sp.read_datathensp.write_datakeeps the unit of a date. Adatetime64column whose entry inattrs['_formats']is a Stata date format is written back with that unit (%tdstays%td). pandas wrote every datetime column as%tc, and on the pyreadstat path the write failed outright on theobjectdates.convert_dates=still overrides.- A Stata
byteno longer overflows in Python. Without pyreadstat,sp.read_dataand the MCP data loader returned Stata's storage types as numpy dtypes:int8for abyte,int16for anint,float32for afloat. Stata computes in double precision whatever the storage type; numpy does not, sodf["age"] ** 2on anint8column wrapped past 127 and returned negative numbers without a warning. Numeric columns are nowint64/float64, which is also what the pyreadstat path returns, so the two readers give the same frame. The MCP streamed-sample path widens too.
Changed¶
sp.write_datastores each numeric column in the smallest Stata type that holds it exactly, as Stata'scompressdoes for integers; afloat64column goes tofloatonly when every value is already a float32. Nothing is rounded. A .dta read and written back keeps its storage types and its size, where pandas alone would have written every widenedbyteas along.
Chen Qiang's do-files: synth2 and rcm covariates¶
The replay of the 18 chapter do-files (see 1.36.0) now reproduces 2,958
numbers and refuses no command. 324 numbers differ for documented reasons,
320 of them downstream of the non-convex nested search of synth and
synth2 in chapter 18, where the placebo p-values agree all the same.
Added¶
sp.rcm(covariates=...).covariates=adds every unit's covariates to the candidate predictors (Hsiao and Zhou 2019; Statarcm y x), checked against Stata along the forward and the best-subset path. One deliberate difference: with as many candidates as pre-treatment periods less one, Stata'srcmselects the model that interpolates the data (R-squared 1, a BIC that is the logarithm of rounding error); StatsPAI stops at one residual degree of freedom. The command's lasso option is not implemented, because its path and cross-validation folds are Stata's own and could not be checked.sp.synth(method='classic')returns the reports of Stata'ssynth2. Withplacebo=Truethe result hasmodel_info['placebo_table'](pre- and post-treatment MSPE and their ratio per unit),model_info['placebo_effects'](two-, right- and left-sided placebo p-values per period) andmodel_info['placebo_weights']. New keywords:placebo_cutoff=leaves badly fitted pretend units out of those p-values,placebo_time=refits at a pretend treatment date,loo=Truedrops each weighted donor in turn and reports the range of the synthetic path,post_periods=restricts the periods the effect is measured on.model_info['pre_r2']is the pre-treatment R-squared.sp.from_stata/sp.statatranslatesynth2to this call. Checked againstsynth22.1.0 on the regression-based predictor weights (tests/reference_parity/test_synth2_stata_parity.py): pre-treatment MSPE to 1e-8 and every p-value exactly; paths and post-treatment MSPE after rounding our weights to three decimals assynthstores them.synth2's R-squared divides by the variation of the synthetic path, ours by that of the treated unit's outcome.placebo_units=(also inmethod='rcm') andpre_periods=coverplacebo(unit(numlist))andpreperiod().sp.synth(v_method='regression')no longer refuses a specification with as many predictors as units. The regression behind V is not identified there; Stata'ssynthsweeps the cross-product matrix with the largest remaining diagonal first and gives the columns it cannot sweep a zero coefficient. The same rule now applies and reproducese(V_matrix)to 5e-11 ande(RMSPE)to 1e-8, with 12 units and 12 predictors and with 9 units and 12 predictors (test_synth_regression_v_stata.py).
sp.fast.feols_jax: the same standard errors as sp.fast.feols¶
⚠️ Correctness¶
sp.fast.feols_jaxreturned different standard errors fromsp.fast.feolson the same call. When the default ofsp.fast.feolsmoved to fixest's small-sample convention in 1.31, the JAX backend kept the old count, and its result still reportedssc='fixest'. Clustered standard errors were 8% above fixest's (and above the NumPy backend's) when the absorbed effects were nested in the clusters;iidandhc1were 5.7e-4 low. It now takesssc=(default'fixest'), agrees with fixest 0.14 on the same 21 configurations to 6.3e-10, and reports the convention it used.ssc='statspai'returns the old numbers. Coefficients are unchanged.sp.fast.feols_jax_bootstrapdoes not use a small-sample factor and is unchanged. SeeMIGRATION.md.
sp.fast.fepois: fixest's small-sample factors, and weights in the HC1 score¶
⚠️ Correctness¶
sp.fast.fepoisclustered standard errors were 8% too large when the absorbed effects were nested in the clusters. The source said the small-sample factors were fixest's; they were the pre-1.31sp.fast.feolsconvention.cr1charged every absorbed level against the degrees of freedom, where Rfixest::fepoisleaves out dimensions nested in the clusters;iidusedn/(n - p - Σ(G_k - 1))where fixest uses(n - 1)/(n - K);hc1was one degree of freedom short. A newssc=argument defaults to'fixest', which reproduces fixest 0.14 on 20 configurations to 6.5e-8 (its own stopping rule);ssc='statspai'returns the old numbers. Coefficients are unchanged.sp.fepoisandsp.ppmlhdfewere not affected. SeeMIGRATION.md.- A weighted
sp.fast.fepois(vcov='hc1')left the weights out of the score. The bread was weighted and the meat was not: standard errors 43% to 46% off fixest's on the reference panel. Unweighted fits and the weightediid/cr1variances were not affected.
Changed¶
- Two tests compared
sp.fast.fepoiswith fixest at 1% and 2% (the second against a non-defaultssc(fixef.K='full')), which is how the factors above went unnoticed. They now compare with fixest's defaults at 1e-5.
sp.fast.feols: degrees of freedom with one absorbed dimension¶
⚠️ Correctness¶
sp.fast.feolscharged one degree of freedom too few for a single absorbed dimension. Withy ~ x | firmthe residual degrees of freedom weren - p - (G - 1); Rfixestand Statareghdfeusen - p - G, because the effects span the intercept. IID and HC1 standard errors weresqrt((n - p - G)/(n - p - G + 1))of fixest's (5.6e-4 low on a 1,042-row panel with 150 firms). Clustered standard errors weresqrt((n - p - 1)/(n - p))of fixest's when that dimension was nested in the clusters, and with two absorbed dimensions neither nested in the clusters the count was one too many. All of these now agree with fixest to 1e-9 across 21 configurations, with and without weights (tests/reference_parity/test_fast_feols_weights_fixest_parity.py). Coefficients are unchanged. Two-way models clustered on a key that nests one of the effects were exact before and are unchanged;sp.feolsandsp.hdfe_olswere exact throughout.ssc='statspai'is unchanged. SeeMIGRATION.md.
sp.regress: weights and dropped rows under CR2, CR3 and two-way clustering¶
Found while generating a Stata reference for the configurations the evidence map listed as having none.
⚠️ Correctness¶
sp.regress(weights=)was ignored undervce='cr2',vce='cr3'/'jackknife'and two-way clustering. Those three paths refit the model without the weights and returned the unweighted coefficients and standard errors, with no warning. They now fit the weighted model, and the variance is that of the weighted design:vce='cr2'with weights reproduces Stata'sregress [aw=w], vce(hc2 g)to 5e-16. Withvce='wild'andvce='conley', which do not implement weights, the call now raises instead of dropping them. Unweighted calls are unchanged. SeeMIGRATION.md.- Rows dropped for missing values shifted the cluster keys under
vce='cr2'/'cr3'and two-way clustering insp.regress. The keys were read from the firstnrows of the data, not from the rows that were fitted. With four of 400 rows missing a regressor the CR2 standard errors were off by 17% and the two-way ones by 66%. The keys are now read on the estimation sample; the result equals the fit on the pre-cleaned data to 1e-12.vce='wild'raised an unrelatedValueErrorin the same situation and now works. Data with no missing values in the formula variables are unchanged. sp.twoway_clusterandsp.cr2_seignored the weights of a weighted fit.sp.twoway_clusterbuilt its scores from the unweighted design: standard errors 11% to 17% below Stata'sregress [aw=w], vce(cluster a b)on the test fixture.sp.cr2_serecomputed the unweighted fit from the stored design. Both now agree with Stata to 1e-15, and both follow the fitted rows when the fit dropped missing values (sp.twoway_clusterraised;sp.cr2_sefell back to a re-parse of the formula).
CR2, CR3 and two-way clustering: t(G - 1), not the normal¶
⚠️ Correctness¶
- p-values and intervals of the bias-reduced and two-way cluster
variances came from the normal.
sp.regress,sp.feolsandsp.ivregwithvce='cr2'/'cr3'/'jackknife', andsp.regress(cluster=[a, b]), replaced the standard errors of a fitted model and then referred the t ratio to N(0, 1), while the one-way cluster variance of the same functions used t(G - 1). Stata reports t(G - 1) forvce(hc2 clustvar)andvce(jackknife, cluster()), and t(G_min - 1) forvce(cluster a b). The standard errors agreed with Stata and the p-values did not. They do now (1e-8 on the test fixture). The size study shows what it was worth: with six clusters of equal size CR2 rejected a true null 12% of the time on the normal reference and 6% on t(G - 1); CR3 7% and 3%. Coefficients and standard errors are unchanged; p-values rise and intervals widen, slightly at 40 clusters and substantially below 10.sp.hdfe_olsandsp.panelalready used t(G - 1). SeeMIGRATION.md.
sp.poisson and sp.ppmlhdfe: weights in the covariance¶
⚠️ Correctness¶
sp.poisson(weights=)andsp.ppmlhdfe(weights=)computed the covariance without the weights. The coefficients were the weighted ones; the information, the scores and the cluster sums were not weighted. Against Stata 18 the Poisson model-based standard errors were 10% high (42% high with integer frequency weights), the robust ones 7% low, andppmlhdfe's robust and clustered ones 8% and 12% off. All now agree to 6e-10. The column is read as Stata reads it for the variance asked for:weights=walone is[iw=w]/[fw=w](model-based, scales with the weights), withrobust=it is[pw=w], withcluster=it is[pw=w], vce(cluster). That is whatsp.logit,sp.probitandsp.glmalready did; they are pinned alongside. Unweighted fits are unchanged. SeeMIGRATION.md.- The log-likelihood reported by a weighted
sp.poisson,sp.nbregorsp.ppmlhdfewas the unweighted sum.ll,ll_null, the LR statistic and the pseudo R-squared now use the weighted sums, as Stata does (Poissone(ll)ande(ll_0)to 1e-10).sp.nbreg's coefficients and standard errors under weights were already correct.
Count models with missing values¶
Fixed¶
sp.poisson,sp.nbregandsp.ppmlhdfefailed on a missing regressor or outcome. A formula of plain columns passed the missing values into the IRLS, which stopped withLinAlgError: SVD did not converge. Rows with a missing outcome or regressor are now dropped, as Stata does and as these functions already did for formulas withC(),I()or interactions; weights, exposure, offset and cluster keys are read on the rows that remain. The fit equals the fit on the cleaned frame to 1e-12. Nothing that ran before changes. Twenty other regression-family estimators were put through the same comparison and already handled missing rows.
Agent surface¶
- Evidence maps credit reference tests that already existed, and gain
new ones.
sp.validation_scope('rdrobust')listed 12 configurations with a reference; the R tests for the variance kinds, clustering, covariates, fuzzy and kink designs and the 36-cell bandwidth x order x kernel grid were never entered. They are now (75), and the grid test asserts all eight outputs at 1e-9 where it asserted two at 1e-6 (the worst measured gap is 8e-12).regressgains a Stata 18 reference for analytic weights under six variances, CR2, two-way clustering and the cluster jackknife (6 to 15 configurations).ivgains asmalldimension, so asmall=Falsefit is no longer counted under thesmall=Trueevidence, and the existingivregress/ivreghdferows (7 to 22). A weightedsp.regressfit was read as unweighted by the map and reported as covered; it is now its own cell.sp.fast.feolsgains the fixest rows above (2 to 6). Forsp.etwfe, the Statajwdidreferences for unit effects, everyhettype, a moderator (xvar) and never-treated controls, linear and Poisson, and the weighted references on two real panels were entered: a linearfe='unit'fit was reported as having no evidence, partly because it recorded the control group asneverwhere the map saysnevertreated, so it could match nothing.sp.etwfe(controls=, fe='unit')is now pinned against Statajwdid, exovar()(1e-10), and the docstring says what the default cohort-effects design does with a time-varying control: it is a different regression from the unit-effects one and has no reference.sp.match(method='nnmatch')gets a map of its own: its fits were routed to the propensity-score map, where none of its options exist, and read as unverified although nineteffects nnmatchcommands pin estimate and standard error to 1e-10. The MatchIt comparisons made throughsp.match(method='nearest'), which is the same fit assp.psm, are entered in thepsmmap. - The few-cluster warning no longer says the wild bootstrap "keeps
correct size". A size study (
tests/reliability/, 16 designs, 2,000 replications each) shows where that holds and where it does not. With clusters of similar size and half of them treated, the wild cluster bootstrap rejects a true null 5% to 7% of the time from 6 clusters up. With two treated clusters it almost never rejects (0.0% at 40 clusters), and with one cluster holding half the sample it over-rejects (13%) while CR3 stays at 3% to 5%. The hint insp.regress,sp.paneland the agent summary now says this. sp.regressreports the effective number of clusters and warns when it is small. The few-cluster warning was keyed on the count (fewer than 30) and stayed silent with 40 clusters of which one holds half the sample, where CR1 rejects a true null 36% of the time. The fit now recordsmodel_info['n_clusters_effective'], the inverse Herfindahl index of the cluster sizes, and warns when there are 30 or more clusters but fewer than 30 in effect. In the study CR1 is at 6% above 30 effective clusters and at 8% to 10% at 26 and 18. No estimate or standard error changes.- The effective-cluster warning also covers
sp.panel,sp.hdfe_olsandsp.feols. In a fixed-effects panel of 40 units clustered by unit, the cluster-robust t-test rejects a true null 5.5% of the time when every unit has 8 periods and 21.8% when one unit has 312, so the three entry points now recordn_clusters_effectiveand raise the same warning assp.regresson one-way clustered fits. sp.regresswarns about few treated clusters. A regressor that is constant within clusters and takes the values 0 and 1 is a cluster-level treatment. When fewer than 10 clusters, and under a quarter of them, sit on one side, the fit warns and recordsmodel_info['few_treated_clusters']. In the study, two treated clusters out of 40 make CR1 reject a true null 31% of the time while the wild cluster bootstrap never rejects. Level dummies of a categorical term are not counted, and more than three such columns are recorded without a warning (a set of cluster indicators is not a treatment).- Isolated MCP calls can return a result handle. With
STATSPAI_MCP_ISOLATION=process, a call that asked for a handle (as_handle=true) used to run on the thread runner, because a handle minted in a child process died with it; those are the long fits one most wants to be able to kill. The worker now pickles the result it cached into a private directory and the server adopts it under the same id, so theresult_idin the response works in follow-up calls (isolation.adopted_handles). The file is signed with a one-time key and unpickled only if the signature verifies. A result that cannot be pickled is dropped from the response with the reason. The channel runs the other way too: a call that reads a result (audit_result,honest_did_from_result,sensitivity_from_result,estat, ...) is isolated, with the cached entry handed to the worker. What still runs in the server: calls that read a data handle (data_id), the data tools, thepipeline_*composites, andinterpret_result, which may ask the client for a completion while it runs. sp.fepoisandsp.feglmcards declared the result as a list. The annotation isUnion[EconometricResults, List[EconometricResults]](the list is the multiple-estimation syntax); a plain formula returns one result, and the card now says so. Found by extending the real-call card audit from 30 to 40 entry points.sp.panelnames its default small-sample convention. With nossc=the result now carriesmodel_info['ssc'] = 'linearmodels'and a one-line description (N/(N-k)on the covariance, noG/(G-1)cluster factor, tests onN - Kdegrees of freedom); before, the field was absent and a reader had to know what its absence meant. The few-cluster warning now adds thatssc='stata'givesxtreg's factor andt(G-1). The default itself is unchanged, and so is every number: it is a documented convention,ssc='stata'andssc='fixest'reproducextregandfixest, and the joint test that showed the difference (p = 0.0017 against Stata's 0.0040 on 60 clusters) is pinned intests/reference_parity/test_joint_wald_stata_parity.py.
[1.36.0] — 2026-10-03¶
Three textbooks' do-files were run in Stata 18 and replayed through
StatsPAI: Chen Qiang's Econometrics and Stata Applications, Zhao
Xiliang's Design-Based Econometrics, and Stock and Watson's
Introduction to Econometrics. Most of what they exercise already agreed.
What did not is fixed here, and some of it changes numbers. The affected
calls are listed in MIGRATION.md under the 1.35.0 → 1.36.0 headings.
Numbers that change: the model F statistic of sp.regress under robust or
clustered errors, fit statistics of a regression without a constant, AIC
/ BIC, several sp.estat defaults, sp.etwfe when every unit is
eventually treated, data-driven RD bandwidths when the interquartile range
sets the pilot scale, the LIML kappa, CR2 degrees of freedom,
synthetic-control weights when the minimiser is not unique, and the
standard errors sp.stata asks for on robust ML commands and large-sample
IV. sp.panel(vce=...) now raises on a value it does not implement, where
it returned classical standard errors.
Stata labels now make the whole trip. sp.write_data writes variable,
value and dataset labels back to .dta, the MCP data loader keeps them, and
an agent sees them, with informative display formats, in the data
description and as role hints from sp.detect_design.
Stata labels on the way out, and through the MCP server¶
sp.read_data keeps a .dta file's labels; nothing wrote them
back, and the MCP data path did not use it.
Added¶
sp.write_data(data, path), the counterpart ofsp.read_data. For .dta it writes the variable labels (attrs['_labels']), value labels (attrs['_value_labels']) and dataset label (attrs['_data_label']) into the file;labels=/value_labels=/data_label=add to or override them. The default format is 118, so non-ASCII labels survive. A label longer than Stata's 80 characters, value labels on a string column or on non-integer codes raise, naming the variable. Parquet carries the labels too; .csv / .xlsx / .feather / .json cannot, and warn when the frame has any. Checked by opening the written file in Stata 18 (describe,label list,tabulate).sp.read_datanow also returns the dataset label, inattrs['_data_label'], and restores integer value-label codes after a Parquet round trip (JSON metadata had turned them into strings).- The MCP data description (
describe_frame, used by the data-handle tools and the data resource) reportsvariable_labels,value_labelsanddata_labelwhen the frame carries them. - Display formats that say something are kept.
sp.read_dataand the MCP loader put a .dta file's non-default display formats inattrs['_formats'], and the data description reports them asdisplay_formats.%td,%tmand%tqall load asdatetime64, so the format is the only place the time unit of a date survives;%12.2fcmarks money. Stata's defaults (%9.0g,%9s, ...) are not reported. sp.detect_designreportsrole_hints: columns whose name or variable label points to a role (time,unit,treatment,weight,cluster), with the matched word and the label. In a survey file the names are often opaque (v12) and the label is where the meaning lives. English words match whole words only (iddoes not fire onpaid); common Chinese terms match too. Hints are advisory. They never changedesign,confidence,identifiedorcandidates, and the key is absent when nothing matches.- Labels follow columns through
transform_data. pandas dropsattrsin some operations, so a loaded .dta lost its labels partway along a transform chain. Each step now rebuilds them: a surviving column keeps its labels,renamemoves them, and a columnassignoverwrote gets none.
Changed¶
- The label part of the MCP data description is bounded. A value label
shared by many variables (one 1 to 5 scale on 300 survey items) is spelled
out once and later variables carry
{"same_as": <first variable>}; a label with more than 50 codes is cut, with the full count invalue_labels_truncated; Stata's missing-value codes print as.arather than2147483622; and strings inheadlonger than 200 characters are clipped (a strL can run to megabytes). - Value labels and formats survive a column projection. pandas narrows
its per-variable lists after a
columns=read; the reader fell back to "label set named after its variable", which loseslabel values region regionlbl. Both layouts are now resolved by name. - The MCP data loader reads .dta the way
sp.read_datadoes. It called barepd.read_stata, which dropped variable labels and turned every value-labelled column into a string categorical, soforeign(0 "Domestic" / 1 "Foreign") reached an estimator as text where Stata's own commands use the codes. Value-labelled columns now keep their numeric codes and the labels travel indf.attrs, on the whole-file, streamed sample and remote paths alike. A tool call that relied on receiving the label text as the column's values needs to map the codes throughattrs['_value_labels'].
An undergraduate textbook's do-files, replayed against Stata¶
The 18 chapter do-files of Chen Qiang's Econometrics and Stata
Applications (2nd edition) were run in Stata 18 with a log, and every
logged command was replayed through one sp.stata session
(scripts/stata_log_replay.py). At the start 607 printed numbers were
reproduced and about 230 commands were refused. Now 2,911 numbers are
reproduced, 17 differ for documented reasons and three synth2 commands are
declined. The findings are in
docs/dev/2026-10-03-chen-qiang-2e-review.md, the reader's guide in
docs/guides/chen_qiang_2e.md. The textbook's programs and data are not
redistributed. Everything below is pinned against real Stata 18 output on
four committed synthetic datasets
(tests/reference_parity/test_textbook_methods_stata_parity.py, 40 blocks).
⚠️ Correctness¶
sp.regressreported the classical F statistic under a robust, clustered or HAC covariance.diagnostics['F-statistic']was the ratio of sums of squares whatever the covariance. On the Nerlove cost function withrobust='hc1'it was 437.9 where the Wald F on the robust covariance is 177.19 (Stataregress, robust). It is now that Wald statistic, withN - Kdenominator degrees of freedom,G - 1under clustering and the rescaledF(q, B - q + 1)underrobust='ewc', and equalssp.testof all slopes. Classical fits are unchanged.- Fit statistics of a regression without a constant.
y ~ x - 1reported the centred R-squared (negative when the line through the origin fits worse than the mean), an F statistic that assumed an intercept anddf_model = K - 1. They are now the uncentred R-squared, the F test of allKcoefficients anddf_model = K, as Stataregress, noconstantand Rlm(y ~ x - 1). AIC/BICofsp.regressandsp.estat(..., 'ic')wereN log(RSS / N) + 2K, without the constantN (log 2 pi + 1), so they disagreed with the log likelihood reported next to them and with Stata and R. They are now-2 log L + 2Kand-2 log L + K log N. Differences between models, and therefore every model choice, are unchanged.sp.estat(..., 'ic')also returnslland uses the model's own likelihood aftersp.logit/sp.probit.sp.estat(..., 'white')degrees of freedom. Squares and cross-products that duplicate another term (the square of a dummy) were counted as restrictions. The statistic was right, the p-value too large.sp.estat(..., 'bgodfrey')default. The firstlagsobservations were dropped. Stata'sestat bgodfreyand R'slmtest::bgtestboth keep them with the missing lagged residuals set to zero. That is now the default (fill='zero');fill='drop'is the old behaviour and Stata'snomiss0.sp.estat(..., 'vif')returned the factors rounded to two decimals and1/VIFto four. They are returned at full precision.- Breusch-Pagan LM test for random effects on an unbalanced panel
(
result.bp_lm_test()). The scale factor used the average panel length. It is now the Baltagi-Li form withsum T_i^2, which is Stata'sxttest0(274.68 where the old formula gave 277.21 on the test panel). Balanced panels are unchanged. sp.from_stata("synth ...")now passesplacebo=False. Stata'ssynthfits the treated unit only; the translation ran the in-space placebo as well, minutes per donor withnested.
Added¶
sp.prais: Prais-Winsten and Cochrane-Orcutt regression with AR(1) errors, iterated or two-step, six estimators ofrho, robust covariances. All 24 combinations agree with Statapraisto 1e-9 (two-step) or to Stata's own convergence tolerance (iterated).sp.corrgram: autocorrelations, partial autocorrelations (regression or Yule-Walker) and Ljung-Box Q statistics; Statacorrgramandwntestq.sp.varsocand, on the result ofsp.var,lag_exclusion(),lm_test(),stability(),granger_table(),equation_table(),forecast()andfpe: Stata'svarsoc,varwle,varlmar,varstable,vargrangerandfcast compute. Also reachable assp.estat(fit, 'varlmar' | 'varwle' | 'varstable' | 'vargranger').sp.vec: vector error-correction model by Johansen maximum likelihood with all five trend specifications, standard errors of the cointegrating vectors,lm_test()andstability(). Agrees with Statavec,veclmarandvecstableto 1e-8.sp.hausman(consistent, efficient): the Hausman test between two fitted models, withconstant=,sigmamore=andsigmaless=, a generalised inverse and rank degrees of freedom, as Statahausman.sp.panel(method='mle'): random effects by Gaussian maximum likelihood with observed-information standard errors (Stataxtreg, mle).sp.xtsum,sp.xtserial,sp.xtoverid: within and between summary statistics, Wooldridge's test for serial correlation, and the cluster-robust test of random against fixed effects.sp.synth(method='rcm'): the regression control method (the panel data approach of Hsiao, Ching and Wan) with exact best-subset, forward or backward selection, AICc / AIC / BIC / MBIC, placebo runs in space with period-by-period p-values and a placebo in time. The best subset is found by branch and bound: the 24-donor Hong Kong example with a placebo run per donor takes about a second and reproduces every digit the Stata commandrcmprints.sp.estat:'imtest'(White's test with Cameron and Trivedi's skewness and kurtosis parts),'classification'aftersp.logit/sp.probit, the Durbin score form in'endogenous', and the optionsvariables=/version=('hettest'),rhs=('reset'),fill=('bgodfrey'),threshold=.sp.synth_loo,sp.synth_time_placebo,sp.synth_rmspe_filter,sp.synth_donor_sensitivitytakecovariates=,special_predictors=andv_method=, so a robustness check runs on the specification of the estimate it checks. They matched on pre-treatment outcomes only.sp.stataruns much more of a do-file.regress, noconstant; weight expressions ([aw=1/v]), frequency weights (expanded rows) and a weight clause afterif;gsort,rename,tabulatewithgenerate(),collapse,ipolate,set obs,set seed,clear;asinh(), runningsum(), date functions, densities and quantiles, random draws and time-series operators insidegenerate;e(rss),e(mss),e(rmse),e(df_r),e(df_m),e(F),e(ll);predict, leverage,predictafterlogit/probit,predict ... if;estimates store / restore / table,esttab,hausman;bysort: summarize;estatand its subcommands;prais,corrgram,wntestq, thevarandvecfamilies,fcast compute;xtreg, re | be | mlewithsigma_u,sigma_e,rho, the three R-squared and the constant ofxtreg, fe;xttest0,xtoverid,xtserial,xtsum; the_consthatreghdfeprints;teffects psmatchwithgenerate()andosample(),predict, ps,tebalance summarize;c.x#(c.z1-z9)andtest c.x#c.z;rcm; andprogram ... endwithsimulate.tsset/xtsetnow sort the data, as in Stata. Random numbers come from numpy, so a simulation has Stata's design and another sample; a warning says so.scripts/stata_log_replay.pycompares regression headers,estat,margins,hausman,corrgram, thevar/vecsuites, thextcommands,teffects,tebalance,rdrobust,rddensity,synthandrcmoutput, reads thetssetdeclaration and value labels stored in a.dtafile, and marks numbers computed on simulated data.
refs verified via Crossref and OpenAlex: cochrane1949application,
ljung1978measure, hsiao2012panel, drukker2003testing,
arellano1993testing, baltagi1990lagrange, yan2022rcm,
yan2023synth2.
A design-based textbook's do-files, run against Stata¶
The do-files of Zhao Xiliang's Design-Based Econometrics (2nd edition,
13 files covering randomized experiments, matching, IV, DiD, imputation and
synthetic control, RD and mediation) were run line by line in Stata 18 and
in StatsPAI on the same data. Regression, clustered and HC2/HC3 standard
errors, 2SLS, Kleibergen-Paap and effective F statistics, Bacon
decomposition, Callaway-Sant'Anna, BJS imputation, SDID, rddensity and
fixed-bandwidth rdrobust already agreed to 1e-8 or better. What did not is
below. The textbook's data are not redistributed; every fix is pinned on a
bundled dataset or a deterministic design, with the Stata or R value in the
test.
⚠️ Correctness¶
sp.etwfereturned arbitrary numbers when every unit is eventually treated. With no never-treated unit, the periods from the last cohort's adoption on have no untreated comparison. The saturated design was solved bypinvanyway. On the Baker-Larcker-Wang simulated panel the overall ATT came back as 4.7e11 with a rank-deficiency warning (the truth is 68). The last cohort is now the reference and those periods are dropped, as Retwfedoes, with aUserWarningandmodel_info['last_cohort_reference']. Retwfe0.6.2 gives 68.3290597638 and cohort effects 94.9877509885 / 52.0066074672 / 20.9978906826, reproduced to 1e-11. A single adoption date with no untreated group raisesDataInsufficient.- Data-driven RD bandwidths were off whenever the interquartile range set
the pilot bandwidth.
rdbwselectstarts fromC * min(sd, IQR / 1.349) * n^(-1/5)with quartiles by Hyndman-Fan definition 2. StatsPAI usedmethod="lower", a different order statistic at the upper quartile for most sample sizes. It showed only when the IQR was the smaller scale. On the Lee (2008) House datahwas 0.1356186677 against 0.1356263072 in Rrdrobust4.0.0 and Statardrobust10.0.0, and estimates moved by 2e-5 to 2e-4. Now within 6e-12 across selectors, kernels and polynomial orders. Affectssp.rdrobust,sp.rdbwselectand everything that calls the CCT selector. The three Track A RD modules are unchanged (the standard deviation binds there). - The LIML
kappalost as many digits as it is close to 1. It was the generalized eigenvalue of two matrices each formed as a cross product minus a projection. On the Angrist-Krueger samplekappawas 1.00000662005 whereivregressandivreg2both give 1.0000066192058, which moved the coefficient by 1e-6. Adding 1e6 to the outcome, which the intercept absorbs, turnedkappa - 1negative. The part the excluded instruments explain is now formed directly. Track A59_limlmoves at 1e-15. sp.cr2_seused approximate degrees of freedom, and one of them for every coefficient. The Satterthwaite df came from a formula that is not Bell and McCaffrey's, and all coefficients were then referred to a t with the smallest df among them. On a 110-cluster design with a treatment, a covariate and ten block dummies the treatment's df was 83.6 where the exact value is 79.1, and its interval used the intercept's 3.5, a critical value of 2.93 in place of 1.99. The df is now the exact one and each coefficient uses its own. Df and confidence limits agree with Restimatr::lm_robust(se_type = "CR2")andclubSandwich::coef_testto 1e-10. Standard errors are unchanged.EconometricResultsreadsdata_info['df_by_coefficient']when a fit records it.
Added¶
sp.difference_in_means, the Neyman estimator with the variance of the design. The textbook opens with randomized experiments, and its stratified estimates had to be assembled by hand. One call now covers complete randomization (Satterthwaite df), blocks (N - 2Jdf), matched pairs, clusters (CR2 with Bell-McCaffrey df), blocks of clusters and matched pairs of clusters, chosen fromblocks=andcluster=.estimand='ATT'weights blocks by their treated units. All six designs agree with Restimatr::difference_in_means2.0.0 to 1e-11 in the estimate, the standard error, the df and the interval. A block with a single treated or control unit, a cluster in both arms and a cluster in two blocks raise.sp.didregress, Stata'sdidregressandxtdidregress. The two-way fixed-effects DID with the variance of each command: group dummies counted as regressors fordidregress, thextreg, feconvention withid=forxtdidregress, clustering on the group, and t(G - 1).sp.estat(result, 'ptrends')andsp.estat(result, 'granger')areestat ptrendsandestat granger. Onsp.datasets.mpdta()the ATET, its standard error and the Granger test agree with Stata 18 to 1e-11, with and without a covariate, for both commands and under staggered adoption. The linear-trend test agrees withestat ptrendsto 3e-6, and the gap is on Stata's side: the same model fitted in Stata byaregwith time centred gives the value here to 1e-12, and with raw years it differs from both. Like Stata, the two tests are refused when treatment dates vary.sp.sdid(covariate_method='optimized'), the covariate method of Rsynthdid_estimate(X=)and the default of Statasdid. Single adoption date, point estimate only. The estimate,betaand the weights agree with Rsynthdid0.0.9 to 1e-11. Stata lands 3e-4 away from R on the same data. Both run a gradient iteration to its cap of 10,000 steps, so neither returns a converged optimum, and the result depends on the units of the covariate. The test pins both facts.'projected'remains the method with standard errors.sp.rddensityreturns the rest of Stata's output.model_info['conventional']is the statistic without bias correction thatrddensity, allprints, andmodel_info['binomial_tests']is the table of exact binomial tests in ten windows around the cutoff. Both match Stata on the Lee senate data to 1e-9 and to the printed digits. The robust statistic is still the test.sp.match(method='nnmatch'), Abadie-Imbens covariate matching as Statateffects nnmatch. Every tie kept,metric=Mahalanobis / inverse variance / Euclidean,exact=(ematch),caliper=,bias_adjust=on a chosen set of covariates, ATE or ATT, robust or homoskedastic variance (vce=,vce_nn=).detailistebalance summarize. Nine configurations onsp.datasets.nsw_dw()agree with Stata 18 to 5e-13 in the estimate and the standard error. Two details of Stata's variance that its manual does not state were found by rebuildinge(V)term by term. The same-arm set behindvce(robust, nn(h))holds the unit plushneighbours, and those neighbours must satisfyematch()too. The existingsp.match(distance='mahalanobis')is a different estimator (one match unlessties='all', within-group covariance, sample-ATT variance) and is unchanged.sp.mediate(inference='robust'), potential-outcome mediation as Stata 18mediate. New optionsinteraction=(treatment by mediator) andmediator_model='linear' | 'logit' | 'probit'. Reports NIE, NDE, PNIE, TNDE, TE and the proportion mediated with a standard error, from the sandwich of the stacked estimating equations. Estimates agree with Stata to 1e-9 and the coefficient covariance to 1e-9. Effect standard errors agree to 2e-4, which is the numerical-derivative noise in Stata's own variance. Its standard errors move by 1e-4 when a covariate is centred, an equivalent model, and the gradient it implies is 0.0065 where the exact one is zero. The analytic ones here do not move. The default behaviour ofsp.mediateis unchanged.sp.synth(v_method='regression'), the default of Statasynth. Without itsnestedoption Stata does not search for the predictor weights. It regresses the pre-treatment outcomes on the predictors and takes the diagonal of the outer product of the slopes. That is deterministic and takes a least-squares fit, where the nested search took 79 seconds for one fit on the Proposition 99 data (39 fits for the placebo test). Onsp.california_prop99()the predictor weights agree withe(V_matrix)to 1e-10 and the pre-treatment RMSPE to 2e-10. Stata stores donor weights rounded to three decimals and buildse(Y_synthetic)from the rounded weights, which there sum to 0.999. Its post-treatment average is -18.70 where the exact weights give -18.81. Rounding our weights reproduces its path to 1e-9. The donor weights in this path are polished to the exact optimum.sp.rdrobust(scalepar=), the rescaling of a regression kink estimate by the kink in the policy rule. Matches Stata to 1e-11.sp.mediate(inference='robust')takesC(g)incovariates, one indicator per level but the lowest.sp.effective_f_testreports the Montiel Olea-Pflueger critical values. It compared the effective F with 23.1, which is the critical value for one instrument at a 10% bias tolerance. With several instruments the critical value depends on the data and on the estimator. Passingy=now returnscritical_valuesfor TSLS and LIML at tolerances of 5, 10, 20 and 30%, with the effective degrees of freedom and the worst-case bias behind them. Withoutythe simplified, estimator-free values are returned. Onsp.datasets.card_1995()with two and with four instruments every value agrees with Stataweakivtestto its seven printed digits. With four instruments the 10% TSLS value is 12.0, not 23.1.weakivtestevaluates its 30% column at 3.33 where the definition is 1/0.3. The exact value is returned here.alpha=sets the level.sp.estat(result, 'endogenous')after a robust or clustered IV fit returns the robust tests. It returned the Durbin and Wu-Hausman statistics, which assume homoskedastic errors, whatever the VCE of the fit. Afterrobust=orcluster=the result now carries the robust regression-based F and Wooldridge's robust score chi-squared, as Stata'sestat endogenousdoes. They agree with Stata to 1e-9 with one and with two endogenous regressors, robust and clustered. The classical fit is unchanged.sp.pretrends_powerandsp.pretrends_slope_for_powertake coefficients and a covariance. Stata'spretrendsreadse(b)ande(V)of any event-study regression. These two functions took only a StatsPAI event-study result, so a hand-built lead and lag regression had no way in.sigma=withnum_pre_periods=orevent_times=now does that, a fitted regression withevent_times={name: time}does too, andslope=is shorthand for a linear violation. The likelihood ratio agrees with Statapretrendsto 1e-13. The power is a numerically integrated probability on both sides and agrees to 2e-4. The integration tolerance here went from SciPy's default 1e-5 to 1e-7, so the power no longer moves in the fifth digit between two calls.- Guide: a design-based econometrics textbook in StatsPAI
(
docs/guides/design_based_econometrics.md). A chapter map from the Stata commands of the do-files to the StatsPAI call, the same analyses on bundled datasets with the numbers Stata gives, and the places where two correct programs print different numbers. sp.bacon_decompositionreturnssummary, the total weight and the weight-averaged estimate of each comparison type. These are the summary rows of Stataestat bdecomp, matched on the castle-doctrine panel to the digits Stata prints.sp.panelformulas acceptC(g), interactions andI().sp.panel(df, "y ~ d + C(year)", method="fe"), the textbook way to write a two-way model, failed with "Column 'C(year)' not found in data".method='twfe'is accepted as a name for'twoway'.- IV formulas accept
C(g),C(g, Treatment(k)), interactions andI()among the exogenous regressors and the instruments, insp.iv,sp.ivregand the absorbed-effects path. They used to fail with "Variables not found in data: ['C', ...]". Expanded columns are namedg[2].
Fixed¶
sp.from_stata/sp.stataon equation syntax.(y x)(treat)and(d=z)without blanks were refused.(d = z1 - z3), a varlist range written with blanks, became the formulad ~ z1 + - + z3and was reported as faithful.(d = i.q)passedi.qthrough untranslated.- Grouped factor notation.
c.(x x2),d##c.(x x2 x3)andc.(a - b)#1.postare expanded. A level term such as1.dis the indicator of that level (it used to be pasted into the formula).treat#1.preis refused with the reason. ivregress/ivreg2withoutsmallnow request large-sample statistics for every VCE throughsp.iv(..., small=False), not only forrobust. The chapter's IV commands reproduce Stata to 1e-9.teffects.nnmatchandpsmatchemittedmethod='nn'/'ps', whichsp.matchdoes not have, so the translation never ran. They now map to calls that reproduce the Stata estimate (psmatch, atetwith the Abadie-Imbens 2016 standard error), withnn(),metric(),ematch(),biasadj(),caliper()andvce()carried over.teffects raandipwrawere mapped tosp.regressandsp.ipw, a different estimator, and are now refused.teffects aipwwith different covariates in the two equations is refused.did_imputationreadsh()andpre(), the abbreviations ofhorizons()andpretrends().- New translations.
synthpredictors with periods (y(1988),y(1980(1)1988),y(1975&1980)) andxperiod()becomespecial_predictors, and the call asks for the V method Stata used.sdidmaps tosp.sdid,mediatetosp.mediate(inference='robust'),bacondecomptosp.bacon_decomposition, with the panel taken from an earliertsset/xtset.rdplotcarriesp(),nbins(),ci(),shade,kernel()andh();rddensitycarriesp()andh();rdrobustcarriesscalepar(). A command name may be followed by its first equation without a blank (mediate(y x)(m x)(d)). - Commands with no line-by-line translation name what to call.
ritest,honestdid,pretrends,fect,xthdidregress,hdidregress,synth_runner,ddml,qddml,poregress,dsregress,xporegress,telasso,tebalance,teoverlap,weakivtest,panelviewandevent_plotused to come back as "unknown command". - On the textbook's 13 do-files the share of estimation commands that
sp.statacan run as written went from 59% to 88%. The rest are loops over macros,xthdidregress,synth_runnerand one factor term, all refused with a reason. didregressandxtdidregresswere translated to a different estimator.sp.statasent them tosp.did(method='twfe'), which collapses a panel with more than two periods to a 2x2 at the median period. On a five-period panel that returned -0.0149 where Stata gives -0.0300. They now go tosp.didregress.wildbootstrap(),aggregate()andnogteffectsare reported as untranslated, andestat <test>after an estimation command runssp.estat.- Synthetic-control weights that are not unique are now the minimum-norm
ones, and a nested fit with its placebos takes seconds. A treated unit
inside the donors' hull, or a predictor the V search has weighted to
zero, is fitted equally well by a whole face of weight vectors. The
weights returned were whatever SLSQP reached from a uniform start: a
choice that depends on the solver, and one that took 20 to 350 seconds
per placebo unit. Every such minimiser has the same fitted values, so the
rule is now a property of the problem: among them, the weights with the
smallest norm (the most evenly spread). It is computed exactly in two
steps, the optimal fit and then the least-norm weights that reproduce
it, and it does not depend on the starting point or the order of the
donors. On Proposition 99 a nested fit with 38 placebo fits, which had
not finished after ten minutes, takes 41 seconds, with no fallback to
SLSQP in 120,000 inner problems. Where the true weights are unique but the fit is exact
(Track A
52_scm_unique) the error falls from 1.5e-7 to 2e-14. Fits whose solution was already unique do not change, which includes Track A07_scmand the README's Proposition 99 fit. Placebo fits of units inside the hull do change, and with them a placebo p-value can. - A nested synthetic-control fit took a minute. With fewer predictors
than donors every evaluation of the outer search solved its inner
problem with SLSQP, a few thousand times. When the treated unit lies
outside the donors' hull that problem has one solution, which is now
found exactly by a small active-set method and returned with a
certificate of optimality and uniqueness. One fit on the Proposition 99
data goes from 55 seconds to 2. Where the certificate fails (a treated
unit inside the hull, a predictor weighted to zero) SLSQP's choice is
kept, so those fits are as slow as before and their answers do not
move. Weights change by up to 2e-5, towards the exact minimiser. The
README's Proposition 99 fit goes from -19.760529 to -19.760541 (it
prints -19.76). Track A
07_scm,18_augsynthand52_scm_uniquemove by at most 4e-7. sp.iv_diagwith several instruments printednanfor the tF critical value and interval. The report now says the tF adjustment is defined for one instrument and points to the Anderson-Rubin interval.sp.panel(robust=...)with an unknown value such as'hc1'or'cluster'returned classical standard errors without a word. It raises.
Stata translation, checked against a textbook's own logs¶
The replication files of Stock and Watson's Introduction to Econometrics
(4th edition) ship the Stata logs that produced the book's tables. Replaying
those 13 logs through sp.stata compares 1,300 printed numbers. The
estimators agreed; the translation layer did not always ask them for the
convention Stata uses, and it could not run a do-file that prepared its own
data. Both are addressed here. docs/dev/2026-10-02-stock-watson-4e-review.md
has the findings and what is still open.
⚠️ Correctness¶
sp.sumstatsignored an unknown statistic.stats=['mean', 'p99']returned the mean and nop99column, without a word. An unknown name now raises and lists the available ones.sp.stata/sp.from_stata:probit,logit,poissonandnbregwithvce(robust)were run with HC1. Stata's robust variance for a maximum-likelihood command carriesN/(N-1); the translation asked forrobust='hc1', which isN/(N-K). Standard errors were too large bysqrt((N-1)/(N-K)), 0.4% on the textbook's mortgage data with 21 regressors, and every Wald test after such a fit was off by the square. The translation now emitsrobust='robust'. Callingsp.probitand the others directly is unchanged.ivreg2andivregresswithrobustand nosmallwere run with HC1. WithoutsmallStata applies no degrees-of-freedom factor; the translation now emitsrobust='hc0', which matches exactly.ivreg2'ssmalloption was also reported as untranslated; it is now read.- A descriptive command between a fit and
testreplaced the fit.regress ...; summarize x; test xappliedtestto the summary table.summarize,correlate,pwcorrandttestnow leave the last estimation result in place, as r-class commands do in Stata.
Added¶
sp.stataruns the data steps of a do-file and appliesif/in. A qualifier used to make the runner refuse the line. It is now applied with Stata's rules for missing values: a missing value is larger than any number, soif x > 0keeps the rows wherexis missing, which a pandas filter drops.generate,replace,keep,drop,sort,mvdecode,encode,preserveandrestorerun on a private copy of the data;generatestores single precision unless the line saysdouble, as Stata does, so regressions on generated variables reproduce Stata's digits.predictgives fitted values and residuals after a linear fit.scalaranddisplayevaluate an expression that may use_b[x],_se[x],e(N),e(r2),e(r2_a)and ther()results ofsummarize,testandttest, and named scalars are written intotestrestrictions. Expressions go through a parser with a closed set of operators and functions; nothing is passed toeval. What is outside the set (e(sample), string functions,egen,use) is refused with the reason.- Time-series operators in
sp.stata. Aftertsset timeorxtset id time,L.x,L2.x,F.x,D.x,D2.x,LD.xand lag lists such asL(1/4).xare resolved against the time variable inside each panel: a lag is missing where the earlier period is absent, as in Stata, which a row shift does not reproduce on gapped or panel data.testandlincomaccept the operator spelling of a coefficient. summarize, detailandtabstatreproduce Stata.sp.sumstatsgainspercentile_method='stata'(Stata's rule for a percentile between two order statistics; the default stays linear interpolation) and the statisticsp1,p5,p95,p99,variance,skewness,kurtosis,sum,range,semean,cv,iqr.summarize, detailtranslates to exactly what Stata prints except the four smallest and largest values, andtabstatis translated. Checked on the fivesummarize, detailblocks of the textbook's earnings data.tin(d1, d2)works inifaftertsset.- Guide: Stock & Watson, 4th edition (
docs/guides/stock_watson_4e.md). How to run the book's do-files withsp.stata, how to check the numbers against its logs, and, chapter by chapter, what a paper written today adds to the book's analysis and the call for it. sp.statastops atexit, as a do-file does.sp.from_statano longer suggests a pandas filter for anifqualifier without saying that pandas and Stata treat missing values differently; it points tosp.stata.scripts/stata_log_replay.py: replays a Stata log throughsp.stataand compares every printed number to the precision Stata printed it.tests/external_parity/test_stock_watson_4e_logs.pyruns it on the textbook's logs whenSTATSPAI_SW4E_DIRpoints at them.sp.unitroot: augmented Dickey-Fuller and DF-GLS tests for one series, with a constant or a trend and a lag order that is fixed or chosen by AIC / BIC. The ADF statistic, p-value, critical values and chosen lag agree withstatsmodels.adfuller, and the regression reproduces the RATS output in the textbook's chapter 15. The DF-GLS statistic agrees with thearchpackage to 1e-14. Its critical values depend on the sample size and the lag order, from a response surface fitted to 200,000 simulated null draws per grid point (scripts/simulate_dfgls_critical_values.py); the asymptotic values reject too often in short samples.sp.ardl: AR(p) and ADL(p, q) forecasting regressions, with the lag order fixed or chosen by BIC / AIC on a common sample, the one-step forecast and its interval, a Granger-causality F test (.granger()) and pseudo out-of-sample forecasts with their RMSFE and bias (.poos()). Coefficients agree withstatsmodelsARDL. On the textbook's GDP-growth application it reproduces the RATS output digit for digit: four regressions with robust standard errors, their forecasts, the Granger statistic, both BIC / AIC tables and the out-of-sample forecast errors (tests/external_parity/test_stock_watson_4e_ch15.py).sp.structural_break(method='sup-f', break_vars=, vce=): a break in a subset of the coefficients, with a heteroskedasticity-robust statistic. This is the QLR test as applied to a forecasting regression, where the lags ofyare held fixed. Reproduces the textbook's QLR of 6.4746 at 1980:Q4. Under heteroskedasticity and no break the classical sup-F rejected in more than 15% of simulated samples and the robust one in under 10%. The defaults are unchanged.sp.regress(robust='ewc'): the equal-weighted cosine estimator of the long-run variance (Lazarus, Lewis, Stock and Watson 2018), withewc_dfcosine terms (defaultfloor(0.4 * T ** (2/3))). P-values and intervals use the t distribution with that many degrees of freedom, andsp.testuses the rescaled F of the paper's equation (14). It is what the textbook's authors recommend in place of Newey-West with a short lag length and normal critical values. With every cosine term it equals HC0 timesT / (T - 1), exactly. On an AR(1) design with a true null (T = 200, both autocorrelations 0.7) a 5% t test rejected 26% of the time with HC1, 11% with Newey-West at the textbook's truncation and 7% with EWC.sp.regress(robust='hac', hac_lags=, hac_small=). The lag length of the Newey-West estimator could not be set, and Stata'sneweycould only be matched to 0.5% because it scales the covariance byN/(N-K). Withhac_small=Truethe Stata golden of Track A module 51 is matched to 3e-16; the default still matchessandwich::NeweyWest(adjust = FALSE). The default lag rule is unchanged.sp.ttest: one-sample, paired and two-sample t tests (Statattest, Rt.test), with pooled, Satterthwaite or Welch degrees of freedom and both one-sided p-values. Checked againstscipy.statsand Stata's output for the textbook's class-size comparison.- Stata commands now translated: the pre-Stata-10
ivreg(small-sample standard errors by construction),ttest,correlate(casewise),pwcorr(pairwise),neweyanddfuller.tobitcarriesvce(robust)andvce(cluster).dfglsis answered with the equivalentsp.unitrootcall and why its numbers differ. - Command abbreviations:
regr,summ,cor,prob,logi,poi,tob,teresolve to the full command, down to the minimal abbreviation in Stata's syntax diagrams and no further. xtreg, feandsummarize, detailnow state insemanticswhat Stata prints that the call does not return (_cons; Stata's percentile rule, skewness, kurtosis).scripts/stata_corpus_scan.pycountsivreg,ttest,dfullerand other commands the translator does not know. They were left out of the denominator, so a corpus that used them looked fully covered.
Agent surface¶
Agent-surface hardening from the 2026-10-02 repository review
(docs/dev/2026-10-02-review-status.md tracks every item). No estimator
changes its numbers.
⚠️ Correctness¶
sp.panel(vce=...)silently ignored any value outside its extended menu.vce='robust',vce='cluster'andvce='nonsense'all returned the classical standard errors with no warning, on every panel method. They now raiseMethodIncompatibility, and the message names the working spelling (robust='robust',cluster='<column>'). The supported values ('CR2','CR3','jackknife','conley','wild') and calls that omitvce=are unchanged. A result obtained with one of the ignored values carried classical SEs: refit withrobust=orcluster=. SeeMIGRATION.md.
Fixed¶
- A tight MCP output budget could hide most of a result's risk flags.
violations,runtime_warnings,degradationsandwarningswere cut like any other list: under a 1,500-byte budget 100 violations came back as one, and the only trace was atruncatedentry the agent had to decode. These lists are now cut last, after every table. When they are cut the result carriesrisk_details_complete: falseand arisk_summarywithtotal/shown/omitted, counts by severity and by category per field. The configuration-level evidence block ofresult_cardis never cut. - More than 20 distinct runtime warnings were dropped without a count.
The server keeps the first 20;
risk_summary.runtime_warningsnow reports how many were raised. max_output_bytesfailed silently when it could not be met. A result whose never-cut fields alone exceed the budget (a vectorestimate, a longreplay) was returned over budget with an empty ledger. Every result that did not fit untouched now carriesoutput_budgetwithstatus(truncatedorunavoidable_overflow),max_bytes,actual_bytes,scopeand, on overflow,oversized_fields. The ledger is counted in the size, sotruncatedguaranteesactual_bytes <= max_bytes. The documentation now says the budget coversstructuredContentonly.- A second
tools/callreusing an in-flight request id took over its cancel handle. It is refused with-32600. - Agent cards that told an agent the wrong thing (found by the card
audit).
sp.match's schema markedtreatment=andoutcome=as required; the function accepts onlytreat=andy=, so a call built from the schema failed.sp.paneladvertisedmethod='cre',sp.sun_abrahamadvertisedcontrol_group='notyettreated'(it takes'lastcohort'),sp.aipwadvertisedestimand='ATC': none is accepted.sp.feolsandsp.matchnamed the wrong result class. - Agent cards carried statements of their family that are false of the
variant. Binary
sp.logit/sp.probitlisted the multinomial model's IIA and the ordered models' proportional odds among their assumptions.sp.callaway_santanna,sp.sun_abraham,sp.did_imputationandsp.etwfeadvised "use CS or SA" and carried the failure mode "staggered timing with TWFE".sp.rdrobustdescribed the sharp design only and called the continuity framework local randomization; it now states the fuzzy, kink and clustered conditions. Point-treatmentsp.ipwwas described in the longitudinal g-methods' terms.sp.dmlpromised a "√n CATE". Fourteen cards corrected through a per-variant override table (statspai._family_cards.VARIANT_OVERRIDES) after reading the thirty audited cards. - Family cards copied member-specific assumptions to every member.
All 30 family cards were read. 52 statements in 24 families that
name the members they are about ("Cox: ...", "Frailty models: ...",
"Romano-Wolf ...") are now scoped to those members (
STATEMENT_SCOPE), shortening 220 cards:sp.kaplan_meierno longer lists the Cox model's proportional hazards,sp.bonferronino longer lists Romano-Wolf's bootstrap,sp.iccno longer lists stochastic-frontier assumptions.sp.interflex,sp.negdandsp.iccget assumptions of their own; utilities that have none (sp.W,sp.scdata, exporters) now state none. - Family cards copied member-specific failure modes to every member.
The same reading for
failure_modes: 28 of the 41 family failure modes are about named members (FAILURE_SCOPE), and 142 cards carried one that was not theirs.sp.kaplan_meierwas told what to do when a proportional-hazards test rejects,sp.lincomwhen a Hausman statistic is negative,sp.micewhen one subgroup of many is significant. The 55 methods this left with no failure mode get ones written for them (MEMBER_FAILURE_MODES, 31 entries): competing events treated as censoring for Kaplan-Meier, crossing curves for the log-rank test, an imputation model that omits the outcome forsp.mice, a direct path around the mediator forsp.frontdoor. The two bundled network datasets andsp.validation_scopestate none. sp.match(method='llr')raisedZeroDivisionErrorat the default bandwidth. In a bootstrap replicate whose local linear weights sum to zero for some treated unit, the matched-outcome bookkeeping divided by that sum and took the whole call down. The unit's matched outcome is now left missing. Point estimates are unchanged.- Tools reached through the registry returned no result card. Only the hand-curated MCP tools attached it, so the long tail (the forest family among them) answered with no configuration-level evidence and no diagnostic status.
sp.from_stata("areg ...")did not say that the constant differs. Slopes and SEs equalareg's;Interceptis the first group's level, notareg's_cons. The translation note now says so.- Six seed spellings were invisible to the result card.
sp.result_cardreportsseed/reproducible/seed_sourceby argument name and knew onlyseedandrandom_state.boot_seed(did_imputation,gardner_didand aliases),bootstrap_seed,rng_seed,wild_seed,halton_seedandrngare now read, the two imputation-family results record the bootstrap seed they used, andtests/test_seed_contract.pyfails on a new spelling.python scripts/seed_inventory.pylists all 259 seeded functions and their defaults (None125,4273,056,123457,12342); the defaults are deliberately left as they are. - Loading the data was outside the tool timeout.
data_pathwas read before the supervised runner started, so a read that never returns (a FIFO, a stalled network mount, a slow URL) held the worker forever and every later call queued behind it. The load now runs under the same timeout and cancel as the estimator and reportsstage: data_load. - A computation that outlived its timeout could still commit a handle.
The result and data caches now refuse a
putfrom a call that has already timed out or been cancelled, so a late handle cannot evict live ones.
Added¶
- Admission limits on the MCP stdio loop.
STATSPAI_MCP_MAX_QUEUED_CALLS(default 32) andSTATSPAI_MCP_MAX_ORPHANED_CALLS(default 4) answer a call that cannot be admitted with anisErrorresult of kindserver_busyinstead of queueing without bound. A timed-out computation cannot be killed, so before this each timeout left one more estimator running beside the next call.STATSPAI_MCP_MAX_REQUEST_BYTES(default 64 MiB) refuses an oversized request line unparsed. - Killable workers:
STATSPAI_MCP_ISOLATION=process. Opt-in. A call that touches no server-side state runs in a child process and is killed on timeout or client cancel. Measured on a regression whose read blocks forever: five consecutive timeouts leave no orphaned thread and no child process, and the next call runs; the default thread runner reports an orphan on the same call. Calls that use or request handles stay on the thread runner. Each isolated call pays a fresh interpreter start. STATSPAI_MCP_MAX_QUEUE_SECONDS(default 900): a call that waited longer than this in the queue is answeredserver_busywhen its turn comes instead of being run.- A tested client matrix (
tests/test_mcp_client_matrix.py): each supported protocol revision, an unknown revision, a text-only client, a client without sampling, a client that sends cursors, a server restart with stale handles, and a client that skipsinitialize. - Joint Wald tests are pinned against Stata by variance option
(
tests/reference_parity/test_joint_wald_stata_parity.py, 33 tests run on Stata 18 MP).sp.testmatchestest/testparmin statistic, both degrees of freedom and p-value aftersp.regress(classical, robust, HC2, HC3, cluster),sp.ivreg(Stata'sivregress, smallconvention),sp.panelfixed effects,sp.logitandsp.poisson. Cells of the evidence inventory with a joint-test reference go from 3 to 16. One convention is recorded as a disclosure rather than a match:sp.panel(cluster=)with the default small-sample convention refers a 1.7% larger statistic toF(q, N - K), giving p = 0.0017 wherextreg, fe vce(cluster)gives 0.0040 on 60 clusters;ssc='stata'reproduces Stata exactly. - Reproduction bundles:
statspai://result/<id>/bundle. A cached result can be exported as the data file and its SHA-256, thetransform_datasteps in order, thesp.<fn>(...)call, the headline numbers, and a script that re-runs all of it in a new process and checks the numbers. Tested by running load, transform and fit in one server and executing the exported script in a separate interpreter. The script refuses a data file whose hash changed. Inline or remote data gets no script, and the bundle says why. - Agent card audit of the 30 most-used entry points
(
scripts/agent_card_audit.py,docs/dev/agent_card_audit.md). Each card is checked against a real call: required arguments, result class, all 289 schema enum values, recommended alternatives. See Fixed for what it found. - Stata translation holdout (
tests/stata_translation_holdout/): 39 commands written against Stata's documented grammar, with the numbers Stata 18 MP gives for the 33 that run. Scored on five layers (recognised, translated, executed, same sample, same numbers). First run: 29 of 33 reproduce Stata, 4 are refused out loud ([fweight=],noconstant,qreg,xtreg, re), none is silently wrong, and all 6 commands that must be refused are. All four gaps are now closed and all 33 reproduce Stata.[fweight=]andxtreg, rewere translated the same night by a separate line of work that had not seen the corpus, so for those two the holdout was a blind test, and they pass: coefficients, classical and clustered SEs, and the normal-based interval ofxtreg, reequal Stata's. sp.from_stata/sp.statatranslateqreg.qreg y x [, quantile(#)]maps tosp.qreg, whose default variance is Stata's (agreement to 1e-14 at three quantiles); a percentagequantile(75)and the abbreviationq()are read.qreg, vce()is not translated and is refused rather than dropped.- Red-line scenarios (
tests/agent_eval/test_red_line_scenarios.py): for each way an agent can produce a confidently wrong report (weak instrument, few clusters, diagnostics not run, an unvalidated SE, a lost handle, a truncated risk list, an untranslated Stata option) the real MCP output must carry an unambiguous signal a reference policy can read. No model is called. docs/reproduction_manifest.json: per module and side, the status of the last re-derivation, the reference version and platform, the hashes of the bytes compared, whether CI re-derives it, and the measured reason where a Stata side is missing.- Evidence inventory additions: a scope map for nonlinear
sp.etwfe(family='poisson'/'logit'); the Track B coverage, stress-design and size/power results with Monte Carlo standard errors, failed replications kept in the denominator; hashes of the option-fixture inputs and of the translation holdout.scripts/evidence_track_manifest.pyreports, for all six evidence tracks, which hash lock covers each file and whether anything reads it. - Skill short path (
references/quick-path.md): route, describe, fit, inspect, export for a single estimate, and the three points at which to stop. Its code blocks are executed by the test suite. Calls to functions that take**kwargsare now checked against the agent schema, the routing questions (sp.route) or the wrapper signature (sp.power), so all 275 calls in the skill are verified. sp.result_card: which expected diagnostics ran. Theassumptionssection gainschecksandchecks_summary: every diagnostic the estimator family expects, with statuspassed/failed/not_run/not_applicable, why the check exists, and the function that runs it. An emptyviolations()no longer reads the same whether every check passed or none ran. Read fromsp.audit; nothing is re-estimated. A result whose estimator cannot be identified getschecks_available: falserather than a guessed checklist.replay_completenesson every MCP result that has areplay.levelisstandalone(a new process can re-run the string given the file named inneeds),session_replayable(it depends on adata_idorresult_idheld by the server) orcall_only(inline or remote data, or an argument without a literal form).-
The packaged skill's code blocks are bound to real signatures.
statspai skill validateparses every Python block inSKILL.mdandreferences/and checks eachsp.*(...)call for unknown keywords and excess positional arguments (275 calls; the 81 whose target takes**kwargsare checked for positional count only). Before this a resolvable name with a wrong argument passed the gate. The skill's "verified" note now states this scope instead of claiming every signature and attribute. -
docs/evidence_inventory.md/.json: the entry x configuration x output evidence view, generated.scripts/build_evidence_inventory.pywalks every cell of eachsp.validation_scopegrid and counts, per output, the cells with reference evidence. It replaces the hand-kept 1.22.0 table as the current view and is drift-gated on push. The counts say what a function tier cannot:rdrobusthas reference evidence in 12 of 25,920 cells,dmlin 4 of 9,120, and no estimator is covered on its whole grid. - Sun-Abraham joint covariance evidence is now in the scope map. The
full event-study matrix was already pinned against
fixest::sunab(8.9e-11) but not registered.sp.validation_scopegains ashare_variancedimension forsun_abraham:vcovisreferencewithshare_variance=Falseandnot_coveredunder the default, where no reference pins the off-diagonal blocks. The coverage simulation is now credited to the default setting only, which is what it ran. did_imputationstandard errors under the Y(0)-model options are compared with Stata. The seven SEs were recorded in the option fixture and asserted by no test; they agree to 1.3e-9 or better.sp.validation_scopecoversdid_imputationandgardner_did. Both results now record the configuration the map reads (y0_fe,y0_covariates,horizon_requested;covariates). The joint event-study covariance of each, already pinned against Statadid_imputationanddid2s::did2s, is registered.gardner_did(vce= 'stage2')reads asestimate_only; covariates, weights and bootstrap SEs read as not covered.sp.validation_scopecoversevent_studyandetwfe. The TWFE event study is covered where Track A module 85 ran it (one adoption date, window(-4, 4), unit clustering); a staggered panel or another window reads as not covered. Foretwfethe estimate and the event-study covariance arereference, and the headline SE is adisclosure: it sits 2e-6 frometwfe::emfx(forward-difference Jacobian in the reference) and 6e-4 from Statajwdid(K convention). The default call,panel=Truewith not-yet-treated controls, had no evidence row because module 17 runspanel=False; it is now attached through a test asserting the two are bit-identical. Both results record the configuration the map reads (n_adoption_dates,covariates,intensity,absorb,cluster_level;family).
⚠️ Correctness (reference fixtures, not estimators)¶
- The six option-level Stata fixtures were generated in single
precision. Their do-files do not source Track A's
_common.do, soimport delimitedstored the decimal columns as float and Stata estimated on data rounded in the 8th digit. The resulting gaps had been explained as optimizer and absorption tolerance and absorbed by loose test tolerances. All six were regenerated on Stata 18 MP withset type doubleandasdouble. No StatsPAI number changes; what changes is how close the references are:
| reference | before | after | test tolerance |
|---|---|---|---|
csdid ATT(g,t) |
2e-5 rel | 4.9e-13 | abs 5e-7 -> rel 1e-9 |
eventstudyinteract ATT / SE |
2e-5 / 3e-7 | 1.9e-10 / 2.0e-11 | abs 1e-6, rel 2e-3 -> rel 1e-9 |
did_imputation ATT, fe(t) / fe(.) |
5e-7 | 5e-14 | abs 1e-6 -> rel 1e-6 |
did_imputation ATT, unit effects absorbed |
2e-6 to 3.4e-6 | 3e-8 to 4e-7 | abs 1e-6 -> rel 1e-6 |
did_imputation SE |
1.8e-7 | 1.3e-9 | none -> rel 1e-8 |
did_multiplegt_dyn, lprobust, did_had (217 assertions) |
within 1e-6 / 1e-7 | within 1e-9 | abs 1e-6 / 1e-7 -> abs 1e-9 |
One row does not reach 1e-6 and is recorded as a reference-precision
disclosure, not a match: did_imputation, unitcontrols(year).
StatsPAI equals a dense exact least-squares fit to 7.5e-12; Stata sits
1.4e-6 from it, and its ATT moves by 6e-7 across tol() settings
without converging. Track A is not affected: _common.do has always
forced double precision there.
Changed¶
CrossValidationResult.next_steps()returns{"action", "reason", "priority", "category"}dicts like every other result class; it was the one class returning plain strings. SeeMIGRATION.md.- The option-level Stata fixtures are bound to their tests. Three of the six were opened by no test (the numbers are literals in the test source) and none is in the Track A hash lock. Every consumer now opens its fixture, each fixture's SHA-256 is recorded in the evidence inventory, and a fixture no test reads fails the build.
- The R parity workflow says what it does, and runs weekly. Its
header claimed the R closed-form parity "refreshes on every push". It
runs on pushes that touch
tests/r_parity/**, re-derives 17 of the 89 R modules, installs current CRAN binaries rather thanrenv.lock, and never importsstatspai. The header and a new "What CI re-derives" section oftests/r_parity/R_ENVIRONMENT.mdnow state this, with the other 72 R modules and all 85 Stata modules named as frozen artefacts re-derived locally. A Monday schedule was added so that an upstream package release that moves a number surfaces without waiting for a push.tests/test_r_parity_ci_scope.pykeeps the document, the workflow and the artefacts on disk in step. - CI fast gate runs the whole agent chain: a full analysis over a real
stdio subprocess (route, load, transform, fit under two covariance
options, audit, follow-up, resource reads, tight budget, stale handle),
the hardening and handle suites, the metadata ratchet, validation scope
and the packaged skill. The packaged skill's smoke fits are now run by
pytest; previously only the archived JOSS-era copy had its full gate
wired in, behind the
slowmarker. - MCP subprocess tests start the server with
posix_spawn. On macOS aforktaken after an estimator ran in the same pytest process segfaulted in the child beforeexec.
[1.35.0] — 2026-10-02¶
A correctness release. Estimators that had no reference or known-truth
evidence were run against simulated designs with a planted answer, and
the ones that missed it were fixed: 33 functions change their numbers,
listed in MIGRATION.md under oct2026-known-truth-fixes. No
Track A module moves (89 reproduce, 0 drift). Estimator callables with
evidence go from 572 of 822 to 735 of 736, the denominator now excluding
84 plots, datasets and exporters that cannot carry a grade.
Defaults that change: sp.deepiv uses the unbiased paired-sample loss,
sp.event_study drops event times with no treated observation,
sp.did_few_treated reports the exact inversion interval,
sp.llm_annotator_correct reports the delta-method standard error, and
sp.rd_compare no longer includes the local-randomisation row.
⚠️ Correctness¶
sp.sensitivity_rrmade no allowance for a linear pre-trend. The interval is the ATT plus or minus the bias implied by extrapolating the fitted pre-trend, plusMbarof slack. The extrapolation was multiplied by the first post-period's relative time measured from zero, and relative time 0 was left out of the treated periods. Coefficients are measured against the reference period, so a drift ofbper period biases the coefficient at relative timekbyb (k - ref). With a drift of 0.2 and four treated periods the average ATT is off by 0.5 and theMbar = 0interval covered the truth in 2% of replications. The bias term is now|b|times the mean distance of the treated periods from the reference period,Mbarscales by the same distance, and the standard error of the fitted slope is carried into the width. Coverage is 100% in the same design, with or without a trend: the interval is an upper bound. Intervals are wider than before;breakdown_mbarmoves accordingly.sp.iv_comparereportedfirst_stage_Fas NaN in every row. The lookup used a diagnostics key without the endogenous regressor's name. The column now carries the first-stage F of each fit.sp.llm_annotator_correctused the wrong attenuation factor for a binary label. It divided the naive coefficient by1 - p_01 - p_10. That is the attenuation for a misclassified outcome. For a misclassified regressor the factor is the gap in predictive values,P(T=1 | T_obs=1) - P(T=1 | T_obs=0), which equals(1 - p_01 - p_10) Var(T) / Var(T_obs). The two agree only for a balanced label with symmetric errors. At a prevalence of 0.2 with 10% errors each way a true effect of 1.0 came back as 0.835 (12% coverage); with asymmetric errors, 1.07 to 1.09. All four tested configurations now average 1.00. The multi-class and continuous paths were already built on the right quantity and are unchanged in their estimates.sp.llm_annotator_correctstandard errors ignored the validation sample. The reportedsewas the first-order one, which treats the estimated error rates as known. It is now the delta-method SE (the first-order SE timesmodel_info['se_inflation_factor']), for all three paths: 0.077 against an across-seed SD of 0.074, where the first-order SE is 0.059. The first-order numbers stay inmodel_info['first_order_se']/['first_order_ci'], andbootstrap=Truestill replaces both.sp.discos_test(test='ks')was not a test. It ran a two-sample Kolmogorov-Smirnov test on the points of the two quantile functions as if they were observations. Their number is the grid size, whatever the data, and neighbours are almost perfectly dependent: a true null was rejected in 57% of replications at the 5% level, and one treated unit could reportp = 2e-59. The statistic is now the largest absolute gap between the quantile functions, ranked among the placebo units' (3.3% rejection under the null, 60% power at a 0.8 SD shift with 29 placebos). Without placebo fits'ks','cvm'and the dominance test return a NaN p-value instead of one from a chi-squared or KS approximation that treated grid nodes as independent.sp.vcnetandsp.sciganstretched the dose-response curve on a user-supplied grid. The spline basis for the evaluation grid was built on the grid's own range, not on the range of the observed doses the coefficients were fitted on. The first grid point therefore returned the curve at the smallest observed dose and the last at the largest: with doses on[0, 1]andt_grid=[0.2, 0.5, 0.8]the result was 1.01 and 1.99 at the ends, the truth at 0 and 1, where the truth at 0.2 and 0.8 is 1.36 and 1.96. The default grid, which spans the observed range, was not affected. The grid is now evaluated on the fitted basis (1.354, 1.752, 1.963 over 60 seeds). Grid points outside the observed range report NaN with a warning instead of a silent zero basis row.sp.vcnetstandard errors left out the average over covariates. The bootstrap refitted the coefficients and evaluated them at the full-sample covariate mean. Each draw now averages over its own rows: at mid-range the SE goes from 0.012 to 0.016 against an across-seed SD of 0.016, and coverage from 78% to 93%. Failed bootstrap fits are counted and reported instead of being filled with the point estimate.sp.sciganappliedpropensity_weightsby drawing a weighted resample of the rows. Unit weights therefore returned a noisiersp.vcnet, different for eachrandom_state. The weights now enter the ridge fit directly: unit weights reproducesp.vcnetexactly, and integer weights equal row duplication to 1e-13.sp.deepiv's default loss was attenuated toward zero. The defaultn_gradient_samples=0minimised the mean over first-stage draws of(y - h(t, x))^2, an upper bound on the DeepIV loss whose solution is the regression ofyon a draw from the first stage. With a linear response that converges to the true slope times the share of treatment variance the instruments explain. Ony = d + 0.5 x + uwith a share of 0.56 it returned 0.50 to 0.71 per unit ofd, with a reported SE of 0.003, and raisingn_sampleschanged nothing. The default is nown_gradient_samples=1, the unbiased paired-sample loss of the original paper: 0.90 to 1.14 over eight seeds, mean 1.04.n_gradient_samples=0reproduces the old numbers and warns. The reportedseholds both networks fixed and is now flagged withmodel_info['se_valid_for_ate'] = False.sp.conformal_ite_multidpintervals covered a potential outcome, not the individual effect. The half-width at a stage was one quantile of the pooled single-outcome residuals. An individual effect is a difference of two potential outcomes, and with independent noise the stage intervals covered 83% where the Bonferroni split promises 95%. The half-width is now the sum of two arm-specific split-conformal quantiles, each at1 - alpha_k / 2, which holds for any dependence between the potential outcomes (99.8% with independent noise, about 97.5% in the worst case). Intervals are wider by roughly a factor of 2.3. A calibration set too small for the requested level now warns instead of silently using the largest residual, and an empty arm raises.sp.dgp_rd(fuzzy=True)did not generate a fuzzy RD. Treatment was drawn from a logistic curve that is smooth through the cutoff, so there was no jump in the probability of treatment and nothing for a fuzzy estimator to identify: over 60 seedssp.rdrobust(fuzzy=)had a median of 0.34 for a declared 0.3 and no finite mean. The probability now jumps by 0.6 at the cutoff (recorded inattrs['first_stage_jump']) and the same estimator averages 0.301.cutoff=now centres the running variable (it used to stay on(-1, 1), socutoff=5produced no treated units) andbandwidth_relevant=, which was ignored, sets its spread. The default sharp design is unchanged draw for draw.sp.dgp_rditdeclared an effect of 2.0 and generated 2.86. The AR(1) recursion ran on the outcome, so the level shift fed back into itself and settled ateffect / (1 - 0.3). The recursion now runs on the errors only and the shift iseffectat every horizon. Data from this simulator change for every seed.sp.kernel_ivreturned a function that was not the structural function. Ony = sin(d) + uwithdcorrelated withu, the estimate sat 0.25 (RMSE over the grid) fromsin(d), about where the plain regression ofyondsits, and the band excluded the truth. The estimator is now a control-function kernel IV: a local-linear first stage, a bivariate local-linear surface in(d, v), and increments of that surface averaged over the residuals with support at both grid points. RMSE on the same designs is 0.05 to 0.06 atn = 3000and the uniform band (multiplier bootstrap) covered in every replication. The signature and the result fields are unchanged. The band conditions on the first stage and says so in the docstring.sp.bcf_longitudinaloverstated the effect by about a quarter. With treatment assigned by a coin flip each period and a constant effect of 6.0 it returned 7.44, 7.77 and 7.61 on three seeds; a difference in means gives 6.0. Two things combined. The propensity score was fitted and predicted on the same rows, so it was higher for treated rows than for controls. And the residual that feeds the unit effects subtractedtau * Dfrom a mean that already containede * tau, which left-e * tauin it. The unit effects then carried each unit's treatment share and the second pass moved the arms apart. The propensity score is now out-of-fold and the residual isY - mu - tau * (D - e). Same three seeds: 5.995, 5.996, 5.993.sp.bcf_longitudinalaveraged a zero for periods with no treatment variation. A period where everyone (or no one) is treated has no identified effect. It was reported as 0.0 and entered the average, and its raw outcome level went into the unit effects. Such periods now report NaN, are left out of the average, and raise anAssumptionWarning; a panel with no identified period raisesValueError. The per-period seed no longer depends onhash(time), which varied between interpreter runs for string time labels.sp.bcf_ordinalreported standard errors about thirty times too small.ate_sewas the root of the mean CATE variance divided byn, about 0.001, against an across-seed standard deviation of 0.03 to 0.05, soate_cialmost never covered. It is now the root of the summed variances of the adjacent-level effects up to each level: 0.024, 0.034 and 0.042 on the same design. The point estimates are unchanged.sp.event_studyaveraged a non-existent event time into the ATT.window=(-4, 4)on data whose exposure reaches +3 left an all-zero dummy in the regression. It came back as coefficient 0 with SE 0, was listed in the table, and entered the headline average as an estimated zero: 1.56 for a true 2.0. Event times with no treated observation are now left out of the table and of the average, with a warning, and listed inmodel_info['empty_event_times']. A window that fits the data is unchanged.sp.event_studytreated some never-treated codings as treated. The documented contract is "NaN or a value outside the data range". A value far below the range happened to work; withinmax_lagof the first period it did not, so the common0for never-treated with periods numbered from 1 spread the control group over the post-treatment dummies. Non-finite values and values before the first period are now never-treated outright. A value equal to the first period is taken as treated throughout, as before, with anAssumptionWarningthat says to recode it to NaN if it is a never-treated code: with periods numbered from 0 and0for the controls the estimate is 0.20 for a true 2.0. The docstring examples ofsp.pretrends_test,sp.pretrends_power,sp.pretrends_slope_for_powerandsp.sensitivity_rrwere written that way and are corrected.sp.power_did,sp.power_rdandsp.power_ivdid not match the power of the tests they describe. Checked against simulated rejection rates:power_ivmultiplied OLS power byF / (F + 1), which hardly moves for any F above 10. 400 observations, an effect of 0.2 and a first-stage F of 37 returned 0.95; simulation gives 0.21. It now uses the 2SLS variancesigma^2 / (n R2_z)withR2_z = F / (F + n).power_rdused the variance of a difference between two local means. 2,000 observations and an effect of 0.25 returned 0.93; the local-linear test has 0.74. It now uses the boundary local-linear variance2 C_K sigma^2 / (n h f)(C_K= 4.8 for the triangular kernel).power_didapplied a cluster design effect1 + (T - 1) rhoto a within-unit change and omitted the1 / (p (1 - p))of a two-group comparison. 200 units, ten periods andrho = 0.5returned 0.48; simulation with AR(1) errors gives 0.38. It now uses the exact variance of the DiD estimator under AR(1) errors, and takesprop_treat(default 0.5).sp.power("did" | "rd" | "iv", ...)andsp.mdefollow. The tests for these functions restated the old formulas, so they could not fail.sp.identifyprinted the wrong estimand for queries that need the full recursion. The identifiability verdict was right; the formula was not, because the ID algorithm's line 7 replaces the distribution and the implementation carried only the graph. The front-door graph returnedsum_{M} [P(Y) * P(M | X)], which isP(Y). It now returnssum_{M} [P(M | X) * sum_{X'} [P(X') * P(Y | M, X')]]. Seven graphs are checked numerically against the interventional distribution of random structural models (gap 2e-16), and the printed formula no longer depends on the hash seed.sp.rd_forestandsp.rd_boostdid not adjust for the running variable. The learners see covariates only and were fitted to the raw outcome inside the bandwidth, so the result was the difference of within-window means: off by the slope of the regression function times the distance from the cutoff, with a standard error that ignored it. Ony = D + 0.5 x + ...they returned 1.29 with a reported SE near 0.01 and covered the truth 0% of the time. Now a side-specific linear trend in the running variable is removed first, and the headlineestimate/se/ciare the average effect at the cutoff from a local-linear RD (sp.rdrobust, robust bias-corrected) on the outcome residualised on the covariates with the same learner, cross-fitted, as insp.rd_flex. Estimates average 1.03 and the interval covers.detailstill holds the CATE by covariate profile; its mean ismodel_info['mean_cate'], andestimandreads'ATE at cutoff'.sp.rd_distributionreturned a change in probability under the nameqte. The field held the jump inP(Y <= y_q)at the pooled quantiles, which is negative where the treatment raises the outcome: a design that shifts the outcome up by 2.0 reported -0.48 at the median.qteis now the quantile treatment effect at the cutoff, from inverting the local-linear CDFs on the two sides, with bootstrapse,ci_lowerandci_upper(coverage 92% to 97% across quantiles in simulation). The old numbers are unchanged undercdf_effect/cdf_se, with the thresholds iny_at_quantile. New argumentsn_boot=200,seed=0.sp.did_few_treated's interval did not match its own p-value and under-covered. The interval was the estimate minus the interpolatedalpha/2and1 - alpha/2quantiles of the placebo draws, which with 29 control groups sit between the two most extreme draws. It covered 88% at a nominal 95% (91% with 60 groups), and a null could lie outside it while the reported p-value was abovealpha. The interval is now the exact inversion of the reported test: the estimate plus or minus them-th largest|W|,m = floor(alpha (n_draws + 1)). Coverage is 96.6% and 94.4% in the same simulations. With fewer than1/alpha - 1draws the interval is unbounded and says so. The old interval is kept inmodel_info['quantile_interval']; the estimate and the p-value are unchanged.- Clustered CER bandwidths were too narrow in
sp.rdbwselectandsp.rdrobust. The coverage-error-rate bandwidths (cerrd,certwo,cersum,cercomb1,cercomb2) shrink the MSE-optimal bandwidth bym ** (-p / ((3 + p)(3 + 2p))). Withcluster=, rdrobust takesmto be the number of clusters, counted on each side of the cutoff and added; StatsPAI used the number of observations. On 1,200 observations in 60 clustersh_cerrdwas 0.2054 where rdrobust 4.0.0 (R) and Stata 18 both give 0.2305. Bandwidths, the bias-corrected estimate and the robust SE now match R to 1e-8 (tests/reference_parity/test_rd_cluster_cer_parity.py). Unclustered fits and every MSE bandwidth are unchanged, and all 89 Track A results re-derive. - Six estimators did not recover a known truth. None had numerical
evidence attached. Each was run on a simulated design with a planted
answer, missed it, and the cause was traced to the code. The anchors are
in
tests/reference_parity/test_oct2026_correctness_fixes.py. sp.balke_pearlreported bounds that are not the Balke-Pearl bounds. The lower and upper bound each took the extremum of five expressions, three of which are not among the eight the sharp bounds need. On 300 random valid IV models the interval differed from the sharp one in 260 and excluded the true ATE in 16. The bounds now equal the optimum of the response-type linear program to machine precision. Thelower_monotone/upper_monotonefields were already correct.sp.rd_multi_score/sp.multi_score_rdcoded a unit as treated when any score crossed its cutoff, where the documented rule is all of them (maxformin). A design with a 0.8 jump returned 0.14; it now returns 0.80.sp.design_robust_event_studyused already-treated rows as comparisons. Treated observations later thanlagshad no dummy and fell into the omitted category. With a constant effect of 2.0 and exposure up to four periods,lags=2returned 1.21 and a lead of -0.45. They now get one unreported dummy. The count and pooled coefficient are inmodel_info['diagnostics'].sp.cluster_staggered_rolloutcompared each cohort with every other cluster, including cohorts that switched on between the reference period and the event time. The never-treated clusters were computed and not used. A constant effect of 1.5 came out at 0.8 to 0.9 at the later event times and 1.22 overall; it is now 1.51 with 95% coverage. Each event time carries its own bootstrap SE, where every row used to repeat the SE of the post-period average.sp.rd_extrapolateunderstated the SE of the averaged effect by aboutsqrt(n_eval). The evaluation points are estimated from one sample and move together, and their variances were averaged as if independent. Coverage of the 95% interval was 40%; it is 92%. The point estimate and the per-point SEs are unchanged.sp.kitagawa_testcould not reject. Bootstrap replicates were drawn from the sample with its instrument labels, so their distribution was centred on the observed statistic and the p-value sat near 0.5. Replicates are now drawn from the pooled sample, the least-favourable null. Power against a gross exclusion violation went from 0 of 40 to about 70%, with no false rejections of a valid instrument in 60 samples. The p-value is(1 + #{T* >= T}) / (B + 1).sp.from_statadropped abbreviated options without a trace. Handlers read options by full name, so the abbreviations in Stata's own syntax diagrams translated to a different model withok=Trueand empty notes:reg y x, randvce(r)lost the robust SEs,cl(id)andvce(cl id)the clustering, andreghdfe y x, a(id year)(alsoivreghdfe,ppmlhdfe) every fixed effect.noconsand any option a handler did not know (keepsingletons,small) vanished the same way, andrdrobustcarried over onlyc(),fuzzy()and a spelled-outkernel(). Abbreviations are now expanded before the handler runs, and every option is carried into the call, listed in the newuntranslated_options(with a note), or listed inignored_display_optionswhen it only changes what Stata prints (noheader,or,irr,beta).rdrobustmapsp q deriv h b rho kernel bwselect covs vce masspoints weights level.- Macros were pasted into the call.
reg y x $controlsreturnedsp.regress('y ~ x + $controls')withok=True. A$globalor`local'in the varlist,if/inor an estimation option is now refused. by g:was dropped.by g: reg y xtranslated to the pooled regression. Prefixes that change the estimate (by,bysort,bootstrap,jackknife,permute,svy,rolling,statsby) are refused;quietly/capture/noisily(with or without the colon),eststo [name]:andxi:are peeled, wherequi reg y xused to fail as an unknown command.xtregwithoutfewas translated as the fixed-effects model. It is Stata's random-effects estimator and is now refused, asre,beandfdare.xtreg, fe vce(robust)clusters on the panel id, as Stata does; it used to translate with default SEs.csdidwas translated with StatsPAI's defaults, not csdid's. The call now follows the table indocs/guides/callaway_santanna.md: covariates in the varlist go tox=(they were dropped),method(ipw)isestimator='ipw_abadie'(it was mapped to the stabilised'ipw', a different estimator),base_period='varying'unlesslong2, and withnotyetthe cutoff is'cohort'unlessasinr.method(drimp)is refused.sp.stataon thecsdid ... method(reg) long2line of parity module 04 reproduces the Stata golden file to 1e-9 (simple ATT, SE and event-study cells).did_imputation, horizons(0/3)lost the horizons: the numlist failed anint()and was swallowed, andhorizon(3)was passed as an integer wheresp.did_imputationtakes a list. Stata numlists (0/3,-4(2)4,0 1 2) are parsed;pretrends(),autosample,controls()andcluster()are carried over.- The printed
python_codedisagreed withargumentsforxtabond/xtdpdsys(lags()missing;robustomitted althoughsp.xtabonddefaults torobust=Trueand Stata does not) and fordidregress, wboot(se_methodmissing). Running the printed code gave another model than the tool call. -
sp.stataran a partial translation with a warning.sp.stata("reg y x if z > 0", data=df)fitted all rows, and an untranslated option only warned. Both now raiseMethodIncompatibilitywith the call to run by hand. See MIGRATION. -
A factor interaction in an IV varlist produced a broken formula.
ivreghdfe y c.x1##c.x2 (d = z), absorb(id)returned'y ~ x1 + + + x2 + + + x1:x2 | id | d ~ z'withok=True: the translated term was split on its blanks again. Same forivregress/ivreg2. psmatch2withoutlogitis a probit score in Stata, a logit insp.psmatch2; the translation now listsprobitinuntranslated_optionsinstead of passing as exact.tiesandateare carried over (they were dropped / described as unsupported).
Added¶
refit_bootstrap=onsp.tarnet,sp.cfrnetandsp.deepiv. The defaultseof these estimators holds the trained network fixed and is flaggedmodel_info['se_valid_for_ate'] = False. Withrefit_bootstrap=Bthe networks are retrained onBresamples, each from its own initialisation, andse,ciandpvaluecome from the spread of the refits; the fixed-network number moves tomodel_info['se_plugin']. The run takesB + 1times as long. Over 40 replications atn = 600with 20 refits, the refit interval covered the true ATE 95% of the time fortarnet(SE 0.068 against an across-seed SD of 0.056) and 97.5% forcfrnet; the fixed-network interval covered 57.5% and 47.5%. Fordeepiv(40 replications atn = 1500, 20 refits) the refit SE averaged 0.30 against an across-seed SD of 0.20: conservative by about 45%, every interval covering. The fixed-network SE was 0.009 and covered 2.5% of the time. The default is unchanged (refit_bootstrap=0).sp.synthdid_placebo(kind='time')is R'ssynthdid::synthdid_placebo: the estimator on the pre-treatment periods with the treatment date moved earlier and the original fit's regularisation. On the real Proposition 99 panel it gives -1.6674165 against -1.6674164 fromsynthdid0.0.9 (and -11.00774 formethod='did', the same in both). The defaultkind='unit'is the in-space table the function always returned.- Release gate (
scripts/release_gate.py, pre-push hookrelease-gate). Three things reached PyPI in 1.33.0 / 1.34.0 and were found only when a paper's replication archive was built from the tag, each costing a patch release. They are now checked where a release is made.--fix-censusrewrites the registry census quoted indocs/guides/stability.mdanddocs/jss_source_audit_dossier.mdfrom the live registry, and the check enforces it between the version bump and the tag (never on an ordinary push, since other lines add functions all day). Patterns passed toreand raw string literals must survive the JSS archive's ASCII transliteration (scripts/ascii_source.py): write\u2026, not the character. Text shipped in the wheel must not carry promotional wording the archive verifier rejects. -
Track C timings are bound to the code they timed (
scripts/trace_perf_path.py,tests/perf/results/_timed_path.json). The record holds the hash of every source file that ran inside the timed calls, the harness and the Rust backend, with the__version__line masked.--checksays whether the committed timings still describe the tree and names the files if not. The 1.34.0 timings are valid for 1.34.1 and 1.34.2 (no timed-path file changed); a documentation-only release no longer calls for a two-hour rerun on a quiet machine. -
sp.overlap_weighted_did(id=). With a unit identifier the bootstrap resamples units and keeps a unit's two observations together, so a unit effect differences out of the standard error as it does out of the estimate. On a two-period panel with a unit effect as large as the noise the SE is 0.119 against an across-seed SD of 0.110; the row-level bootstrap gives 0.172. Withoutidnothing changes. - Known-truth anchors for the Bayesian estimators.
tests/reference_parity/test_oct2026_fourth_pass.pycheckssp.bayes_did,sp.bayes_fuzzy_rd,sp.bayes_hte_iv,sp.bayes_itsand both modes ofsp.bayes_mteagainst planted parameters (skipped without thebayesextra), andsp.bayes_dml, the proxy-variable production-function estimators andsp.did_calibrated_simulationwithout it. sp.from_stata/sp.statatranslatearegandrdbwselect.areg y x, absorb(g)becomessp.regress('y ~ x + C(g)'), the dummy-variable regression, becausearegcounts the absorbed groups in the degrees of freedom of every variance estimator and keeps singleton groups, wherereghdfe(andsp.hdfe_ols) does neither.rdbwselectmapsc p q deriv kernel bwselect covs fuzzy masspoints allandvce(cluster v); any othervce(),weights()andscaleregul()are listed inuntranslated_options. Both are pinned to Stata 18 output at 1e-7 across eightaregand fourrdbwselectspecifications (tests/test_stata_translation_areg_rdbwselect.py).- Known-truth evidence for 60 more estimators. Three new files under
tests/reference_parity/hold 140 tests: closed-form identities checked to 1e-9 or tighter, recoveries of a planted parameter within four standard errors, and the six anchors above. Estimator callables with numerical evidence go from 572 to 632 of 822 (docs/parity.md). All of it isanalytical-only; none is a comparison with R or Stata. scripts/silent_except_audit.py, a ratchet on silent broadexcepthandlers across the package (pre-push hooksilent-except,tests/test_silent_except_audit.py). A handler counts when it catchesExceptionor wider and neither re-raises, warns, logs, nor reads the exception. The per-file baseline may only go down. It starts at 165 sites; wrappers aroundattach_provenance, which guards itself, are exempt. The count before this release was 227.sp.statareads a do-file snippet.///continuations,/* */and//comments and#delimit ;are resolved;global/localmacros whose value is written out are expanded (also when defined from other macros);xtset id yearsupplies the panel id and time of a laterxtreg, fe/xtabond. A macro set by an expression or extended function, an undefined macro, andforeach/forvalues/programblocks are refused with the reason.scripts/stata_corpus_scan.py: translation coverage of a folder of do-files, with the commands and options behind the losses ranked by the number of projects they appear in. No do-file ships with StatsPAI.sp.from_statapayload keysuntranslated_options,ignored_display_optionsandunapplied_sample: a translation with the first empty and the lastNonefits the same model.
Found by running the estimation commands of twelve published replication
packages through the translator, then checked on do-files that played no
part in the fix (no exception, and no option without a trace). The fixes
follow Stata's documented grammar and are tested on synthetic commands
(tests/test_stata_translation_grammar.py), including two invariants:
adding an option changes the translation or is reported, and
python_code spells out the same call as arguments.
Changed¶
- The estimator denominator of the parity index no longer counts
functions that cannot have a reference value. The registry files a
function under the module it lives in, so 84 plots, bundled datasets,
exporters, language-model helpers and catalogue listings in estimator
modules were counted as estimators with no evidence. They are now
counted with the infrastructure, by name and with the reason, in
statspai._parity_taxonomy.NON_NUMERIC_CALLABLES(tests/test_parity_taxonomy_non_numeric.pyholds the list to its admission rule, and fails if a listed function acquires evidence). Indocs/parity.mdandsp.parity_summary()["denominators"]the estimator row goes from 822 to 738 callables. The cross-language count is unchanged at 417, so its share reads 56.5% where it read 50.7%; a function that computes anything of its own, such asdid_summary,rd_compareorlisa_cluster_map, stays an estimator. - Measured limitations are now stated where they apply.
sp.rditwarns below 100 effective observations (the Newey-West SE is biased downward in short windows: nominal 95% intervals cover about 83% at 30 and 89% at 60).sp.dynotearswarns when the series are far from unit variance, since its L1 penalties are on the scale of the data.sp.q_learningdocuments that a working model without the needed history interaction biases the earlier-stage rule (sp.a_learningandsp.snmmdo not have the problem), andsp.overlap_weighted_didthat its SE treats the two periods as independent samples and is conservative on a panel. - Fallbacks inside estimators now warn. Sixty-two handlers caught an
exception and substituted something else without a trace: a marginal
mean for a propensity score (
focal_cate,conformal_debiased_ml,gnn_causal,ivdml), NaN for a standard error or test statistic (spatialML,auto_cateBLP, Bayesian R-hat and ESS), a dropped cohort, pair or placebo unit (did_misclassified,cohort_anchored_event_study,cluster_matched_pair,gsynth,sdid,did_sc_bridge), an uncalibrated score for a calibrated one (dr_calib_bridge), a heuristic for an LLM proposal (llm_dag_proposeand the two LLM sensitivity helpers), or an untransformed confidence interval beside a transformed coefficient (regtable(apply_coef=)). Each now emits aRuntimeWarningnaming the step, the error and the substitute (ConvergenceWarningfor the Bayesian diagnostics);sp.rdsummaryrecords failed side analyses inresult['degradations']. Numbers on a run where nothing fails are unchanged: the full suite passes and every Track A Python result re-derives to 1e-9. - The repository's default README is now the Chinese edition.
README.mdholds the Chinese text (formerlyREADME_CN.md) and the English text moved toREADME_EN.md. The PyPI long description stays English (pyproject.tomlnow readsREADME_EN.md). Both editions open with the Stanford REAP affiliation and the published JOSS article (https://doi.org/10.21105/joss.10604). No code changes. - Bibliography: software citations and title case.
paper.bibgains verified entries for R, Stata, the Python reference manual,linearmodels,pyfixest,differences, eight CRAN packages cited at the version the parity harness pins (quantreg,clubSandwich,WeightIt,ShiftShareSE,DIDmultiplegt,data.table,wooldridge,renv) and the Stata Journal articles forrdmultiandddml. Twenty-three article titles that were stored in sentence case are now in title case, sosp.bibtex()and.cite()print them that way. The maintainer remark on the CBPS entry moved fromnotetoannote, sincenoteis printed in a reference list. Refs verified via Crossref, DataCite, CRAN metadata, Zenodo, Open Library and the projects' own citation files; each entry'sannoterecords its sources. sp.dml(model="plr")with the default learners says what its coverage evidence is. The result carriesmodel_info["learner_note"], andsummary()prints it: the default gradient boosting covered 0.88 at a nominal 0.95 on the one design tested. No estimate, standard error or default changes, and no warning is raised. A fit with supplied learners, and the other DML models, carry no note.
Fixed¶
sp.llm_dag_validateread an edge list as an empty graph. Passing[("a", "b"), ("b", "c")]or a{parent: [children]}dict returned "0 supported, 0 unsupported", which reads like a clean validation. Both forms are now accepted and give the same answer assp.dag(...); an object edges cannot be read from raises.sp.rd_compare's default table had a row that always failed. The default methods included local randomisation, which needs a window (wl,wr) and reportedstatus = errorwithout one. The default is now('rdrobust', 'honest'). Request the third row withmethods=[..., 'randinf']andmethod_kwargs={'randinf': {'wl': ..., 'wr': ...}}.sp.synth_mdereturned a bareinfwhen no effect size reached the power target. It now warns with the reason. The usual one is the placebo test's floor: withJplacebo units the smallest p-value is1 / (J + 1), so with fewer than 19 donors the test cannot reject at 5% whatever the effect.- Parity index.
sp.dag_simulateandsp.rd_dashboardare counted as a dataset and a plot (_parity_taxonomy.NON_NUMERIC_CALLABLES), andtests/reference_parity/test_oct2026_fifth_pass.pyanchors the remaining workflow, comparison-table, dispatcher and diagnostic functions to the estimators they call. Estimator callables with evidence: 735 of 736. sp.notearsdocuments that edge directions are not identified. The data are standardised before fitting, so on linear Gaussian data the orientation within a Markov equivalence class depends on the sample. The adjacencies are recovered; the docstring now says to read the output as a skeleton.sp.california_prop99()described simulated data as the real panel. The top-level loader generates its rows from a seeded design laid out like the Proposition 99 data, and its docstring called it "the canonicalsynthdidexample dataset". Estimates on it do not reproduce published ones:sp.sdidgives -17.9, against the well-known -15.60. The numbers are unchanged, since reference fixtures are built on them; the docstring now says the data are simulated and points tosp.datasets.california_prop99()for the real panel, on whichsp.sdidagrees with Rsynthdid0.0.9 to 1e-9 (-15.6038279). Every simulated example loader (california_prop99,california_tobacco,basque_terrorism,german_reunification,cps_wage,chilean_households,mincer_wage_panel,disparity_panel) now setsdf.attrs['simulated'] = Trueand says so in its docstring.sp.synthdid_placeboclaimed to replicatesynthdid::synthdid_placebo. It returns one refit per control unit (an in-space placebo table). The R function is an in-time placebo: one estimate on the pre-treatment periods with the treatment date moved earlier. The docstring now describes what the function does. The output is unchanged.-
statspai.agent.remediation: the DML orthogonality rule spelled its not-equal sign as a literal character inside a pattern; it is now\u2260, so the pattern is the same in the ASCII archive. Behaviour is unchanged. -
Docstring examples of
sp.olley_pakes,sp.levinsohn_petrinandsp.ackerberg_caves_frazerdrew the proxy with noise, which breaks the scalar-unobservable condition the estimators rest on. The proxy in the examples is now an exact function of capital and productivity; on that design the three estimators recover the elasticities (0.60 / 0.30 for OP and LP, 0.58 / 0.32 for ACF, against 0.6 / 0.3). - 51 curated registry entries advertised no example.
sp.describe_function(name)["example"], the agent cards and the MCP tool descriptions were blank for hand-written specs that left the field empty, although each function's docstring has anExamplesblock. The registry now takes the first docstring statement that calls the function (sp.<name>(or through a sub-namespace such assp.epi.<name>(), and never overwrites a curated example. Entries without an example go from 58 to 9. ivreghdfewithout an(endog = instruments)block isreghdfe; it was a parse error.reghdfe, keepsingletonsbecomesdrop_singletons=False.ivregress/ivreg2with[aw=w]or[pw=w]was refused ("sp.ivreg takes no weights= argument"): the signature check did not see thatsp.ivregtakesweights=through**kwargs. The translated call is checked against the closed-form weighted IV estimate.level(#)becomesalpha=where the sp function takes it.
[1.34.2] — 2026-10-01¶
No estimator, default, or numerical output changes from 1.34.1. Two fixes found by building the JSS replication archive.
Fixed¶
- Schema enums were lost when the source was transliterated to ASCII.
statspai._schema_enrich._OPEN_SET_REheld a literal ellipsis (U+2026). The JSS archive rewrites non-ASCII source to ASCII (scripts/ascii_source.py), which turned it into an unescaped...that matches any three characters, so every documented choice list read as an open set andfunction_schema()/schemas/regenerated from the archive had no enums (e.g.causal_survival_forest(target=)). The pattern now spells it…; behaviour in the package is unchanged andschemas/is byte-identical. - The packaged matching guide (
docs/guides/choosing_matching_estimator.mdand its copy understatspai/agent/_guides/) called DML "the state of the art"; it now says what DML is designed for.
[1.34.1] — 2026-10-01¶
Documentation-only release. No code, default, or numerical output changes from 1.34.0; the estimators are byte-identical.
Fixed¶
docs/guides/stability.mdanddocs/jss_source_audit_dossier.mdstill quoted the 1.32.0 registry census, anddocs/index.mdcited the software as version 1.30.1. 1.33.0 and 1.34.0 shipped them unchanged; the 1.34 registry has 1,274 registered functions and 416 / 161 / 694 / 3 certified / validated / api_stable / experimental symbols, with 577 certified-or-validated symbols backed by 578 registry-evidence source files. The JSS replication archive is built from a tagged release and its claim linter checks these figures, so the correction ships as a release (as 1.30.1 did).- The Track C timings on
mainsince 1.34.0 (5e02ae0a) were measured on the 1.34.0 source and are carried unchanged.
[1.34.0] — 2026-09-30¶
Closes the remaining items of the top-5 replication list (eight QJE / AER papers). Each item is pinned by a reference fixture from Stata 18 or R.
Added¶
sp.xtevent: event study of a policy that is continuous or changes several times per unit (Stataxtevent, Freyaldenhoven et al.). Leads and lags of the policy's first difference inside the window, endpoint regressors for the tails, unit and period effects,norm=,static=,engine='areg'|'reghdfe',absorb=, and Stata'sdiffavgcontrast. Against Stataxtevent3.1.0 on five specifications (default, clustered asymmetric window, another normalisation with robust SEs, thereghdfeengine, the static model): coefficients, SEs, N anddiffavgto 1e-12 (test_xtevent_Stata_parity.py).sp.event_studycovers binary adoption dates only.sp.rotemberg_summary: the Goldsmith-Pinkham, Sorkin & Swift Rotemberg-weight table of a Bartik instrument: weights and just-identified estimates per industry x period, industry aggregates, first-stage F, negative / positive weight panels, correlations, the by-period split, the top industries with the clustered Anderson-Rubin interval of theirch_weak, and beta by weight sign. Against GPSS's ownbartik_weight+ch_weakcode on a synthetic panel: every number to 1e-12 (test_rotemberg_summary_Stata_parity.py); on the ADH data it reproduces every number of their published ADH table.- Panel Conley in
sp.hdfe_ols:conley_time=,conley_unit=,conley_lag=(andconley_lag_cross=,conley_kernel=,conley_time_kernel=): Stataacreg's spatial + time HAC on the FE-absorbed design, in O(neighbour pairs) memory. Equal toacreg ..., pfe1(id) pfe2(t)to 1e-12 on three specifications (test_hdfe_conley_panel_acreg_parity.py). Spatial-only Conley on rows that share coordinates now warns: its kernel treats a unit's own rows in different periods as correlated. sp.feols(backend='native')fits on StatsPAI's HDFE kernel and reports asfeols: same coefficients and SEs (within ~1e-9) for iid / hetero / CRV1, 10-60x faster on millions of rows (2M rows, two effects: 1.9 s vs 110 s). A pyfixest fit of at least a million rows that the native kernel can take warns with the suggestion.sp.cdlz_bunching: the minimum-wage bunching estimator's summaries (Cengiz, Dube, Lindner & Zipperer 2019). From a bin x event-year regression it reports missing jobs below and excess jobs above the new minimum, the percentage change in affected employment and wage, and the two elasticities, with analytic delta-method SEs, plus the event-year path and the bin profile. From the coefficients of the paper's Table 1 column 1 it reproduces every statistic and SE of that column (tests/external_parity/test_cdlz_bunching_table1.py).sp.bunchingremains the Saez / Kleven kink and notch estimator.sp.stacked_did(events=): repeated (non-absorbing) events. A 0/1 event column replacesfirst_treat; every event is a sub-experiment over the window, its controls are the units with no event inside that window, and an event whose own unit has another event in the window is dropped by default (own_overlap=). Equal to the same stack built independently in Stata and fitted byreghdfe, unweighted and[aw=], to 1e-12 (test_stacked_did_events_Stata_parity.py).sp.regress(collinear='omit')omits collinear regressors as Stata does. A rank-deficient design used to raise. The later member of each collinear set is now dropped with a note and listed inmodel_info['omitted'], scanning terms in the order the formula writes them. With factor variables this is the set Stata'sregressomits (x i.g d1dropsd1,d1 i.g xdrops1.g, an event study withi.unit i.perioddrops the last period dummies), with coefficients and SEs to 1e-10. With plain variables only,regresspivots on the data inside its solver; the kept set is then another normalisation of the same model (same R-squared, root MSE and coefficients outside the set).collinear='raise'keeps the old error (test_regress_collinear_Stata_parity.py).sp.iv(small=False)gives Stataivregress's default large-sample statistics:Ndivisor, HC0, no cluster factor, z / chi2.small=True(the default, unchanged) isivregress ..., small. 2SLS and LIML match Stata to 1e-12 in all six combinations (test_iv_small_Stata_parity.py).sp.did_imputation(autosample=True)drops treated observations whose unit or period has no untreated observation, as Statadid_imputation, autosample. The default still refuses (Stata rc 198). Overall ATT and event study with pre-trends agree with Stata to 1e-7; the remaining 1e-8 is Stata's iterative solver, since ours equals the exact dummy-variable solution to 1e-12 (test_did_imputation_autosample_Stata_parity.py).- Covariate-tightened Lee bounds:
sp.lee_bounds(covariates=[...]). Each covariate cell is trimmed by its own share and the trimmed means are averaged over the covariates of the always-observed (Lee 2009). Withtrimming='leebounds'the bounds equal Stataleebounds, tight()to 1e-14 and its analytic variances to 1e-8, including that command's between-cell divisor. The bootstrap covers every trimming (test_lee_bounds_tight_Stata_parity.py).covariatesused to be ignored with a warning. sp.feols("... i(rel, ref=[-1, -5]) ...")accepts several reference levels (fixestref = c(-1, -5)). Names, coefficients and clustered SEs equal R fixest 0.14.0 to 1e-11 (test_feols_multiref_R_parity.py).C()terms insp.oprobit/sp.ologit/sp.mlogitformulas.oprobit("y ~ x + C(g)")equals Stataoprobit y x i.gto 1e-13.sp.bootstrapof a coefficient vector. A statistic returning a vector or a fitted result (lambda d: sp.regress("y ~ x", data=d)) returns anEconometricResultswith bootstrap SEs, the bootstrap covariance, z inference and normal-based intervals, as Statabootstrap _b: regress. Percentile intervals, bias and replicates are inmodel_info.idcluster=gives each resampled cluster its own id (Stataidcluster()). Failed replications are now counted and reported.sp.sqreg(reps=)bootstraps all quantiles jointly (Statasqreg), sosp.test(r, "q25:x = q75:x")compares coefficients across quantiles. Withoutrepsthe table is unchanged.- Result accessors.
r2_adjon regression results (sp.feolsnow records adjusted R-squared, equal tosp.hdfe_ols),nobsand a labelledcov_params()onsp.hdfe_olsresults,delta_starinsp.oster_bounds(alias ofdelta_for_zero), and the full coefficient table insp.qreg'sparams/std_errors/vcov()/conf_int().
Changed¶
sp.oprobit/sp.ologit/sp.mlogitfit by analytic Newton-Raphson. Closed-form scores and Hessians replace BFGS on numerical gradients plus a complex-step Hessian. With 100 dummiesoprobittook 260 s and now takes 0.03 s;mlogitwith 40 dummies went from 11 s to 0.1 s. Estimates are unchanged (Track A 44 / 45 / 49 move by 1e-12). Against Stata 18 with 119 dummies and an unscaled regressor: coefficients 7e-12, clustered SEs 3e-10 (test_oprobit_dummies_Stata_parity.py).sp.sun_abrahamnever forms the dense interacted design. Unit and period effects are eliminated exactly (a Schur complement over periods), so 100 cohorts on 180,000 rows take 0.9 s instead of 36 s and a fraction of the memory. Results are unchanged to 2e-12, and exact rather than iterative on unbalanced panels.sp.qregsolves the dual linear program. Same vertex as before (to 1e-15, ties included), 5-8x faster; each SE refits attau +/- h, so a 20,000-row, 10-regressor fit takes 1.4 s instead of 8.3 s.statspai.rd.rdrobustis the module again, callable as the function, soimport statspai.rd.rdrobust as m; m.rdplotworks.sp.rdrobustis the plain function as before.- One event-study location.
sp.aggte(type='dynamic')also publishes its table asmodel_info['event_study'], like Sun-Abraham, stacked DiD and BJS, andsp.event_study_tablereads every one of them.
⚠️ Correctness¶
sp.event_study_tablereturned NaN estimates for every estimator that names the point estimateatt(Sun-Abraham, stacked DiD, BJS, Callaway-Sant'Anna), with no error. It now readsestimate,attordy/dxand refuses a table of NaNs.- Cutpoint SEs of
sp.oprobit/sp.ologitignoredrobust/cluster. They came from the model-based block of the covariance. They now use the requested VCE, as Stata does. Coefficient SEs were already right. sp.mlogitIIA test. The restricted model kept the dropped category in the softmax as a zero-utility option, and its variance was BFGS'shess_invapproximation. It is nowmlogit ... if y != j, with Statahausman's generalised inverse and df (rank). Chi-squared statistics agree with Stata to 2e-5, and categories that were skipped as non-PSD are now reported.- Brant test standard errors in
sp.oprobit/sp.ologituse the exact information of each binary logit instead of BFGS'shess_inv.
[1.33.0] — 2026-09-30¶
Added¶
- Intensity event studies:
sp.event_study(intensity=, absorb=). The event-time regressors becomeintensity x 1[t - T0 = k](a common shock with heterogeneous exposure;treat_timemay now be a number, the common date),absorb=adds fixed effects insp.hdfe_olssyntax, and either routes the fit throughsp.hdfe_ols. The result carries the joint covariance, sosp.honest_did(result)runs on it directly. On the replication of Zheng, Huang and Zhu (2026, Figure 3) it equals the hand-builtexposure x quarterregression to 7e-9 (coefficients) / 6e-8 (SEs) (test_event_study_intensity.py). Without either argument the historical unit + time path is unchanged. sp.ri_test(absorb=)reuses its fixed-effect sweep.stat='ols'followed bystat='ols_t'on the same design sweeps theGcluster columns once, the sweep runs athdfe_ols's tolerance, and cluster permutations skip the row-level expansion: 9,999 + 9,999 permutations on 1.5 million rows take 4.6 minutes instead of 37, with the same p-values.-
Equivalence gate for the two absorbed-IV paths (
test_hdfe_iv_paths_agree.py):sp.iv(absorb=)andsp.hdfe_ols("y ~ x | fe | d ~ z")must agree to 1e-8 in coefficients, SEs, KP rk LM / Wald F and Hansen J, clustered, robust and iid, with and without missing interaction components. -
sp.jwdid(data, y, ivar=, tvar=, gvar=, x=, method=, never=, hettype=, exovar=, cluster=, predict=). Wooldridge ETWFE under Statajwdid's option names:sp.etwfe(fe='unit')withmethod='ppmlhdfe'-> Poisson,never->cgroup='nevertreated',exovar->controls,x->xvar, andpredict='xb'for theestat simple, predict(xb)headline (Noneisestat simple, counts, withresponse_se='margins'by default so the SE isestat's too).model_info['stata_equivalent']spells out the command. Against Statajwdidv2.2 on the 13 hettype / never / covariate specifications and three newexovar()specifications: link-scale ATT to 1e-9, SEs to 1e-7 (test_jwdid_Stata_parity.py). - Stata factor terms in
sp.etwfe(controls=, xvar=)/sp.jwdid(exovar=, x=)."i.year#i.nodecity","c.x#i.f","i.a##i.b","i.region"build their columns (statspai.did._factor_terms): a lonei.vomits its base level, a#product keeps every cell and the estimator omits the collinear ones, as Stata does --exovar(c.xc#i.xcat)needs all three slopes, and equals Stata to 2e-14 only with them. A term that is already a column name is left alone. On the sjjj-2026 panelexovar="i.year#i.nodecity"reproduces the appendix column (0.1488 / 0.0676) without the 20 hand-built dummies. - One result API.
CausalResultgains.nobs(alias of.n_obs),.vcov()and.conf_int(alpha=None)(the reportedciat the fit-time level);EconometricResultsgains.n_obs(absent, as before, when no sample size was recorded) and.vcov()-- the matrix behind the reported SEs, raising rather than returning a diagonal when none is stored. sp.version_info()and the git revision in provenance. Many commits share one__version__(tensrc/commits of the 1.32 line did), and an editable install keeps stale pip metadata (1.11.4 / 1.16.1 reported for a 1.32.0 tree).sp.version_info()returns the version, the git revision of the imported source (+dirtywith uncommitted changes,Nonefor a wheel), the pip metadata and a warning when they disagree. EveryProvenancerecord,format_provenance, the table footer of_reproand the replication-pack manifest carrystatspai_revision.sp.stacked_did(family='poisson', spec='pooled', absorb=, control_group='notyettreated_rows'). Stacked DID as applied papers run it: PPML throughsp.ppmlhdfe(Statappmlhdferules), a single treated x post coefficient, extra fixed effects on top of unit x event and period x event, and later-treated units as controls only before their own treatment. A hand-built Stata stack withppmlhdfe(pooled, with extraid city#yeareffects, and the event study withlincomof the post coefficients) agrees to 1e-9 (test_stacked_ppml_Stata_parity.py); one call reproduces the replicated paper's stacked estimates (0.321 / z 4.223 / N 501,400; 0.285 / 3.792).- Callaway-Sant'Anna analytic SEs honour
clustervars, and on repeated cross-sections (panel=False) the cluster may vary within unit (e.g. city x year, ascsdid ..., cluster(cy)withoutivar()). The influence functions are summed within cluster, csdid's analytic variance;sp.aggte's analytic path uses the fit's clusters too (it ignored them).sp.aggte(agg_weights='csdid')on repeated cross-sections now carries the influence of csdid's estimated treated-count cell weights. Every ATT(g,t), group and simple aggregate and SE equals Statacsdid(v1) to 1e-9, on a panel with a time-invariant cluster and on repeated cross-sections with a row-level one (test_cs_rc_cluster_csdid_parity.py); on the replicated paper StatsPAI returnscsdid's GAverage 0.104033392 with SE 0.033423408.csdid2agrees on every point estimate but reports smaller SEs on repeated cross-sections (0.0328111 in the paper): its control-row influence function is scaled by the 2x2 subsample share instead of the control cell's, which RDRDIDandcsdiddo not do. StatsPAI follows the latter two. sp.psmatch2(ties=True, ate=True)-- Statapsmatch2'sties(every control at the minimal distance is a match,1/meach) andate(controls matched to treated too, two-sided common support, treated rows weighted only by their use as a match; ATU / ATE inmodel_info). PSM-DID papers regress on_weight != ., and without these options the matched sample was half the size. With the propensity score supplied,_weight,_support, ATT / ATU / ATE equal Statapsmatch24.0.12 row for row (test_psmatch2_ties_ate_Stata_parity.py); on the replication's 99,878 firm-years, Stata fed StatsPAI's score returns the identical 28,602-row matched sample. The paper's 28,628 differs only through the logit score (1e-7 apart, within Stata's convergence tolerance), which moves a few hundred nearest neighbours.sp.oster_bounds(absorb=, absorb_controls=, moments=, cluster=)andr_max="1.3*r2_a". The exact Oster solution after a fixed-effects regression, aspsacalccomputes it afterxtreg y d x i.ind#i.year, fe: within coefficients and R-squared, andxtreg'se(r2_a)with the control dummies counted at their exact rank given the panel effect (and the panel means not charged undercluster=when the panel is nested in it).moments=exposes the exact solution from summary statistics. Equal to Stataxtreg, fe+psacalcto 1e-12 (delta 1e-8) (test_oster_absorb_Stata_parity.py). On the replicated paper the inputs are exact and R_max is 0.18268 against Stata's 0.18265: Stata'se(r2_a)implies three more regressors than the dummies' exact rank (the three extra eigenvalues are 1e-15, the next 2e-2), so delta is 3.480 against the paper's 3.484; with Stata's R_max it is 3.4857.sp.hdfe_olsresults carrydf_aanddf_a_nested(reghdfe'se(df_a)/e(df_a_nested)). A second Stata fixture pins the R-squared family with no cluster, a nested unit cluster and an absorbedcity#yearcluster: degrees of freedom exact, statistics to 1e-12 (test_hdfe_fitstats_Stata_parity.py); it reproduces the adjusted R-squared of all seven OLS columns of the replicated fundtown paper.sp.winsor(method=, subset=, by=)--winsor2'sifqualifier andby()option (see Changed for the percentile definition). Equal to Statawinsor2element for element (test_winsor_winsor2_Stata_parity.py).sp.logit/sp.probit/sp.cloglog(perfect_prediction='drop'). An indicator regressor whose non-zero rows share one outcome is omitted with those rows, iterated, as Stata does ("x != 0 predicts failure perfectly"); a warning names them andmodel_inforecords them.N, log-likelihood, pseudo R-squared and estimates equal Statalogit/probit y x i.g(test_logit_perfect_prediction_Stata_parity.py).'keep'restores the old fit (whose MLE does not exist).sp.ppmlhdfematches Statappmlhdfe's sample and handles absorbed regressors. Found by replicating a five-fixed-effect PPML paper whose everyNand pseudo R-squared differed from Stata. (1) Singletons of any absorbed effect are dropped by default (drop_singletons=True), iterated jointly with the separation rules to a fixed point;model_info['n_singletons']. Slopes and robust / clustered SEs of the remaining rows are unchanged;N, the cluster count and henceG/(G-1), and the pseudo R-squared now equal Stata's. (2) A regressor collinear with the fixed effects (a time-invariant variable under a unit effect) is omitted with a warning and listed inmodel_info['omitted']; it used to overflow and end inLinAlgError. (3)absorb="id + ind^year"/"ind#year"andcluster="city^year"build the interacted groups. (4) Rows missing an estimation variable are marked out, as Stata does. (5) With several absorbed effects the within-transform reuses the accelerated kernel ofsp.fast.fepois(see Changed for the speed); the old 500-sweep cap had stopped short of the projection (estimates moved in the 8th digit). Against Stata 18ppmlhdfeon a fixture with singletons, all-zero units, missing values, two omitted regressors,ind#yearand acity#yearcluster:N, clusters and the dropped count exact, slopes / SEs / pseudo R-squared to 2e-9 (test_ppmlhdfe_singletons_Stata_parity.py).sp.etwfe(family='poisson', fe='unit', response_se='margins'). Response-scale (count) standard errors as Statajwdid ..., method(ppmlhdfe)+estatreports them. Until now they could only be rebuilt by hand from Stata'se(V)(0.0262 vs 0.0318 on the replication panel).marginsholds the absorbed unit and period effects fixed and differentiates throughppmlhdfe's_cons, which is normalised around the mu-weighted sample mean of the regressors, so the gradient ismu (x - xbar)plus the constant. A categoricalxvaris mapped to jwdid's level coding first, since with the periods held fixed the parametrisation matters. Everyestat simple / event / group / calendar / over()SE equals Stata to 7e-7, including clusters that nest neither units nor periods, where the constant's covariance with the slopes enters (test_etwfe_poisson_jwdid_parity.py, new fixtureetwfe_poisson_jwdid_margins_Stata.json). The default stays'profile'. A Monte Carlo study (benchmarks/etwfe_poisson_response_se_coverage.py, 6 designs x 1,000 replications) finds the profiled interval covering the sample ATT at 94-95% with 400 units and 91-93% with 100 units or strong unit heterogeneity. The margins SE is 14-15% larger in every design, so it over-covers in the first case and is near 95% in the second. Point estimates, link-scale SEs and every default output are unchanged.-
sp.etwfe(family='poisson' | 'logit', response_se='unconditional'). A response-scale SE for the population ATT, as Statamargins, vce(unconditional). The aggregate is a ratio of sample means over treated rows, so its influence function per cluster adds the composition termsum (m_r - ATT) / nto the delta-method term, with their covariance; event-time aggregates keep a joint covariance. The profiled SE conditions on each unit's observed level, so with heterogeneous units it is far below the unit-sampling SE (0.300 against 0.646 on a panel whose unit-effect sd is 1.2). Against the delete-one-unit jackknife of the full refit the new SE agrees to 1% (fe='unit' and fe='cohort', simple and event time;test_etwfe_nonlinear.py). With cohort dummies (fe='cohort', Retwfe) the ordinary delta-method SE is already close to it, because the cohort intercepts carry the between-unit variance. In the Monte Carlo study it covers the population ATT at 94.0-95.6% in five designs where the profiled SE reaches 90.6-94.5%, and at 88.7% when unit effects are lognormal with sd 1.5 (profile 54%, margins 63%). -
2SLS in
sp.hdfe_ols:"y ~ exog | fe1 + fe2 | endog ~ inst"(Stataivreghdfe). Every variable is swept once by the reghdfe absorber, then 2SLS runs on the residuals; SEs followivreg2's small-sample rules (cluster, robust, iid) andresult.iv_diagnosticscarries the first-stage F tests, Kleibergen-Paap rk LM and Wald F, Cragg-Donald F, Anderson-Rubin test and Hansen J / Sargan. Againstivreghdfe1.1.4 every coefficient, SE and statistic agrees to <= 5e-9 (test_hdfe_iv_ivreghdfe.py). On the 1.5-million-row replication of Zheng, Huang and Zhu (2026, Table 6) it gives -0.1381572 (0.0305366), KP F 509.244 and KP LM 30.714 as published, in 25 s where the pyfixest route took about 4 minutes. - R² family and reference df on
sp.hdfe_olsresults:r2,r2_a,r2_a_within,rss,tss,rmse,df_inference, asreghdfe'se(r2),e(r2_a),e(r2_a_within),e(rmse),e(df_r)(FEs nested in the cluster are charged in full for the adjusted statistics,df_a_nested); equal to reghdfe 6.13.1 to 1e-16 (test_hdfe_fit_stats_reghdfe.py).summary()shows them. sp.hdfe_ols(df_inference=):'resid','normal'or a number, for the reference distribution of p-values and CIs (see ⚠️ below).- Non-ASCII column names in
sp.hdfe_olsformulas (工资 ~ 暴露度 | 企业). sp.honest_did(grid_lb=, grid_ub=, grid_points=, grid_expand=)(andhonest_did_from_moments), HonestDiD'sgrid.lb/grid.ub/gridPoints, passed to R withbackend='r'too (see ⚠️ below).sp.gelbach(absorb=, cluster=, shapley=): absorbed fixed effects (swept by the reghdfe absorber, i.e.b1x2with the FE dummies inx1all()),b1x2 ..., cluster()covariance, and Shapley shares averaged over entry orders. Equal tob1x24.1.0 to 1e-10 (test_gelbach_absorb_cluster_b1x2.py).sp.ri_test(absorb=, interact=)and continuous treatments forstat='ols'/'ols_t': absorbed FEs, and an intensity design (treat='exposure', interact='post') with exposure permuted across clusters. Under clustering onlyGcolumns are swept once, so 9,999 permutations on 1.5 million rows take about a minute.sp.sumstats(output='numeric'): the unformatted values (Nas int, the rest float) instead of display strings.-
sp.from_stata:reghdfe/ivreghdfenow map tosp.hdfe_ols(reghdfe's singleton, dof andt(G-1)rules) with Stata'sa#babsorbed interactions writtena^bandc.x#i.gslopes asi.g#c.x(1.32 emittedcity#quarterinto the formula: aSyntaxError);absorb(..., savefe),vce(robust)and multi-way clusters are handled;summarize/sum2docxtranslate tosp.sumstats;permute,esttab,estout,outreg2andcoefplotreturn an error naming the StatsPAI function to use instead of "unknown command". -
sp.honest_did_from_moments(betahat, sigma, event_times=...)andsp.honest_did_from_result. Rambachan-Roth sensitivity for an event study from any estimator --reghdfe, a stacked regression, a published table -- given its coefficients and joint covariance, as RHonestDiD::createSensitivityResults(betahat, sigma, ...). On HonestDiD's ownBCdata_EventStudythe relative-magnitudes (Conditional) sets equal R exactly and the FLCI agrees within R's simulation noise (test_honest_did_moments_R_parity.py).honest_did_from_resultis the MCP tool's name on the Python side (design_auditalready pointed to it). sp.stacked_did(weights=, event_id=, treated=, event_time=). Population (or any analytic) weights, as Cengiz et al. use, and a pre-built stack -- one row per (sub-experiment, unit, period), e.g. built with a clean-control rule -- used as is instead of being stacked again (which reused the never-treated controls in every sub-experiment). The regression now runs on the HDFE kernel, so its SEs followreghdfe(they differed by a constant degrees-of-freedom factor). Againstreghdfe [aw=]on a CDLZ stack the coefficients, SEs and ATT agree to 1e-9 (test_stacked_did_prebuilt_weights_Stata_parity.py).weights=forsp.ssaggregate,sp.bartik/sp.BartikIVand (through the fit)sp.shift_share_se. Weighted 2SLS / OLS with the AKM, AKM0, EHW and Homoscedastic rows ofShiftShareSE'sw, weighted Rotemberg weights, and the BHJ shock-level view withl_weights. Equal to RShiftShareSE1.1.0 and Stataivregress [aw=], vce(robust) smallto 1e-12 (test_shiftshare_weights_parity.py).sp.anderson_rubin_ci(cluster=). The cluster-robust Anderson-Rubin set, invertingivreg2's Wald-form AR statistic (F(k, G-1)); equal toivreg2'se(arf)/e(arfp)to 1e-12 on a grid of nulls (test_ar_ci_cluster_Stata_parity.py).sp.ri_test(strata=, stat='ols' | 'ols_t', covariates=). Re-randomize within strata (and by cluster within strata), with a regression-adjusted statistic including stratum effects; enumerated designs equal Rri20.5.0 exactly (test_ri_strata_R_parity.py).sp.westfall_young. Westfall-Young (1993) stepdown maxT from the design's own re-randomizations (within strata, by cluster, each outcome on its own rows), as Statawyoung, permute(); the MHT module advertised it but only shipped the bootstrap Romano-Wolf. Checked against a from-scratch enumeration of the textbook algorithm and for FWER under the null (test_westfall_young.py).sp.suest. Seemingly unrelated estimation across separately fitted OLS equations (own samples), robust or clustered, withwald/test_equal/test_zerofor the cross-equation "joint test" rows of experimental tables; equal to Statasuestafterregressto 1e-12 (test_suest_Stata_parity.py).sp.rd_diff_in_disc. Difference-in-discontinuities (Grembi, Nannicini and Troiano 2016): one pooled local polynomial, fully interacted in side and period, whosepost x abovecoefficient is the change in the discontinuity; clustered by unit or site it carries the covariance between the two periods that differencing twordrobustruns ignores. Equal to Stataregress [aw=kernel], vce(robust | cluster)to 1e-14 (test_rd_diff_in_disc_Stata_parity.py).sp.ssc(preset). Small-sample-correction presets forsp.feols(ssc=)named after the Stata command they reproduce ('regress','areg','reghdfe','xtreg','ivregress','ivregress_small','fixest') --aregcharges firm effects nested in the cluster,reghdfe/xtreg, fedo not,ivregressapplies no factor withoutsmall. Each reproduces its command's SE to 1e-10 (test_ssc_presets_Stata_parity.py).sp.rdrobust(masspoints=)andsp.rdbwselect(masspoints=)('adjust','check','off', as R). The adjustment for tied running variables was always on;'off'and'check'now reproduce R's unadjusted bandwidths and estimates, and the tied shares are reported (model_info['masspoints'],.attrs). Equal to R rdrobust 4.0.0 to 1e-8 on a 0.02-grid running variable (test_rd_masspoints_R_parity.py).cluster=forsp.rd_honest,sp.rdbwsensitivity,sp.rdplacebo,sp.rdbalance,sp.rdsummaryandsp.rdplot.rd_honestfollowsRDHonest(clusterid=)-- cluster-robust SE and the Moulton-corrected bandwidth search -- and equals it to 1e-9 withM,hfixed and 1e-6 with them selected (test_rdhonest_cluster_R_parity.py); the diagnostics pass the cluster to everyrdrobustfit;rdplot's per-bin intervals become Statamean, vce(cluster)within the bin and its band CR1 (test_rd_cluster_diagnostics.py).sp.cgs_continuous_did(cluster=). The overall ACRT SE sums the unit influence functions within a time-invariant cluster; with cluster-level doses and shocks it tracks the Monte Carlo spread (0.63 vs 0.74) where the unclustered SE gives 0.22 (test_cgs_continuous_cluster.py).sp.bacon_decomposition(balance='drop_units'). Decomposes the balanced subpanel of an unbalanced panel (Stataxtbalancefirst) and lists the dropped units; the default still refuses, as R/Statabacondecompdo (test_bacon_unbalanced.py).-
sp.spec_curve(fe=). Fixed-effect sets as a choice dimension of the specification curve, estimated asreghdfe(singletons dropped, absorbed levels charged, those nested in the cluster not); equal to Statareghdfe/regressunder all three VCEs to 1e-10 (test_spec_curve_fe_Stata_parity.py). -
Skill:
references/modern-methods.mdcovers the 1.29–1.32 estimators. The packagedstatspai-analysisskill had no entry for about thirty functions added since 1.29. The new reference file covers the robust staggered-DID family, joint event-study covariance and sup-t bands, pre-trend equivalence and power, few-treated inference, thesp.iv_diagbundle, the GRF forest family (fe forests,rate_split,forest_policy_tree, IV / multi-arm / survival forests),sp.dynamic_dml,fect/rd_honest/rdhte, andsp.validation_scope.SKILL.mdgets five new hard rules. Every call shape was run before writing it down; the validation gate gains 30 signature claims and a smoke fit that checks the fe-forest ATT equalssp.did_imputationand thatforest_policy_tree/compare_event_study_conventionsreject pooled forests and staggered timing. -
sp.etwfe(family='poisson')reproduces Statajwdid ..., method(ppmlhdfe)in one call. Found by redoing a published jwdid table (21 regressions) with StatsPAI (sjjj2026_replication-StatsPAI复现程度.ipynb): the cell coefficients matched, but the paper's number could only be rebuilt by hand. New:scale='link'reports the treated-observation-weighted mean of the cohort x period coefficients (estat simple, predict(xb)/emfx(predict='link'); log points for Poisson) — the default stays'response', the average marginal effect. Both scales, and the simple / group / event / calendar aggregations of each with their own delta-method SEs, are stored inmodel_info['aggregations'], with the per-cell counts, coefficients and SEs inmodel_info['cells'];sp.etwfe_emfx(res, type=..., scale=...)serves either scale.fe='unit'absorbs unit fixed effects as jwdid does (R etwfe's cohort-dummy design stays the default,fe='cohort'); the two agree on balanced panels and differ on unbalanced ones, wherefe='cohort'now warns. Withfe='unit'all-zero units are separated out of IRLS but kept inNand the cluster count, as jwdid reports them (model_info['n_separated']); on a 923k-row, 44k-unit panel the fit takes about 5 s.cgroup='nevertreated'is now supported for Poisson and logit (pre-period cells relative tog - 1, i.e. event-study leads, asjwdid, never). Thefe='unit'cells equal an explicitsp.ppmlhdfefit of the same design to 1e-8 (tests/test_etwfe_nonlinear.py). The clustered small-sample factor underfe='unit'isG/(G-1),ppmlhdfe's convention; on the replication panel every link-scale estimate and SE (21 regressions, group / calendar / event aggregates,neverleads, a 26-cluster city panel,exovar-stylecontrols, re-timed cohorts, dropped years) matchesjwdidto 7 significant digits. Response-scale SEs follow the profiled delta method and differ fromjwdid'smargins, which holds the absorbed effects fixed and differentiates throughppmlhdfe's_consnormalisation (0.0262 vs 0.0318 there; point estimates agree). - Nonlinear ETWFE covers the rest of a jwdid table:
hettype=,xvar=,separated=.sp.etwfe(family='poisson' | 'logit', hettype='event' | 'cohort' | 'time' | 'twfe')pools the cohort x period cells asjwdid, hettype();xvar=followsjwdid y x(each effect moderated by the covariate demeaned within the hettype cells, plus covariate-by-period and, for time-varying covariates, covariate-by-cohort terms), a categorical column entering asi.xlevel dummies, andsp.etwfe_emfx(res, ..., by_xvar=True)reports every aggregation per level (estat ..., over(), Remfx(by_xvar = TRUE)). Linearly dependent columns are omitted asppmlhdfedoes (a covariate level missing from one cohort used to stall IRLS for 1,000 iterations and return NaN).separated='keep' | 'drop'(fe='unit') decides whether separated all-zero rows stay inN, the cluster count and the aggregation weights: jwdid drops them exactly whenppmlhdfeflags the separation, which it does on some panels and not on others, so the published number depends on it. Against Statajwdidv2.201 /ppmlhdfe2.3.0 on a new fixture (13 specifications: every hettype,never, categorical and time-varying continuous covariates) link-scale estimates agree to 2e-14, SEs to 1e-10 relative, the response-scale point estimate to 2e-7 (test_etwfe_poisson_jwdid_parity.py); on the replication panel, Appendix Table 3's hettype columns and Figure 4'sover()ATTs match to 6 digits. The nonlinear design moved todid/_etwfe_glm_design.py(with_etwfe_glm_fit.py,_etwfe_glm_emfx.py). Withfe='cohort'(R etwfe's design)xvar=follows Retwfeinstead -- the covariate demeaned within cohort x period cells, interacted with the cells and with the periods: a continuous covariate matchesetwfe0.6.2 /fixest0.14.0 to 1e-7 (emfxsimple and event, both scales). For a factor covariateetwfe0.6.2 writes the period term asi(year, (x2_dm + x3_dm)), whichfixestreads as one summed slope; StatsPAI keeps a slope per level and matches that per-level design fitted withfixest::feglmto 5e-8 overall and per level (test_etwfe_poisson_xvar_R_parity.py, which also pins thatetwfe's own numbers differ). - Linear
sp.etwfe(hettype=)andfe='unit'reproduce Statajwdidwithoutmethod(). Anyhettype, orfe='unit', fitsjwdid'sreghdfedesign: unit and period effects, SEs clustered on the unit withreghdfe's(N-1)/(N-K) * G/(G-1)(K = regressors + constant),jwdid'sxvardesign and the aggregations (estat simple / event / over()) of the nonlinear branch. Againstjwdidv2.201 /reghdfe6.12.3 on an unbalanced fixture (13 specifications: every hettype,never, categorical and time-varying covariates) estimates agree to 1e-13 and SEs to 3e-12 (test_etwfe_linear_jwdid_parity.py). The default linear design (R etwfe, cohort dummies) is unchanged;fe='cohort'with a non-default hettype,panel=Falseorweights=raise. sp.honest_did(l_vec=, window=).l_vectargetsl' tau_post(HonestDiD'sl_vec;'average'for the mean post-treatment effect) instead of a single period;window=(lo, hi)restricts the event study first, as an analysis run onestat event, window(). Against Statahonestdid1.3.0 the FLCI agrees to 3e-6 and the Conditional relative-magnitudes bounds to within one grid step (test_honest_did_lvec_window_Stata_parity.py).backend='r'takes both: it hands RHonestDiDthe windowed event study andl_vec, and its Conditional relative-magnitudes sets equal the native ones to 1e-10 (average,e=1, a customl_vec).- Poisson / logit ETWFE event studies carry their joint covariance.
model_info['event_study_vcov']isG V G'over the event-time aggregates (leads and horizons share one regression), on the fit's scale;sp.etwfe_emfxswitches table and matrix together. Sosp.event_study_vcov,sp.pretrends_test,sp.honest_didandsp.uniform_bandsuse the full covariance instead of falling back to the diagonal / worst-case approximation. sp.pretrends_test(window=(lo, hi))tests only the leads inside an inclusive event-time window, taking the sub-block of the joint covariance — Stataestat event, window(-5 5) pretrend. The result reports thepre_periodstested.- Every result class speaks the agent contract.
to_dict(detail= "minimal"|"standard"|"agent"),violations(),next_steps()andresult_card()used to exist only on theCausalResult/EconometricResultstrees (16 of ~300 result classes);sp.oaxaca(...).to_dict(detail="agent")raisedTypeError. They now live onResultProtocolMixin(statspai._result_contract), legacyto_dict()overrides are wrapped so they acceptdetail=("standard"returns the old dict byte for byte), and NamedTuple / plain result types get the same surface throughattach_result_protocolwithout losing tuple unpacking. 298 of 300 result classes carry the contract; the two exceptions (the JAX-absent placeholder andHelpResult) are listed inscripts/result_protocol_audit.py::AGENT_CONTRACT_GAPS, and the audit fails on any other gap.result_card()["provenance"]now names theresult_classand, for stochastic estimators, recordsseed/reproducible/seed_source(reproducible: falsewhen the call ran unseeded,nullwhen the estimator did not record its seed). No estimator's numbers or default seed changed. - MCP protocol completeness.
pingis answered;tools/callruns on a worker pool (STATSPAI_MCP_WORKERS, default 1, so estimation stays serialised) while the main loop keeps answeringping,tools/listand resources;notifications/cancelledstops a running tool at its next progress checkpoint. Results carryreplay— the equivalentsp.<fn>(...)call — so an MCP analysis can be re-run in Python. Payloads overmax_output_bytes(argument orSTATSPAI_MCP_MAX_OUTPUT_BYTES, default 256 KiB) are cut largest-table first with every cut listed undertruncated(headline estimate / se / ci / p-value are never cut); non-finite values are listed under_nonfinitesonullno longer conflates "missing" with NaN / inf. - MCP data access is bounded.
STATSPAI_MCP_DATA_ROOTSconfinesdata_path(symlinks resolved,file://included) to the listed directories; the data-handle cache has a byte cap (STATSPAI_MCP_DATA_CACHE_BYTES, default 2 GiB) next to its count cap; tools with an output-path parameter (e.g.synth_report(output=...)) are derived from their schemas and advertisedreadOnlyHint: false. - Errors an agent can act on.
ColumnNotFound(kindcolumn_not_found, still aMethodIncompatibility/DataInsufficient/ValueError) carriesdiagnostics["did_you_mean"]—sp.did(df, y="yy", ...)now says "did you mean 'y'?".MissingDependencyError(kindmissing_dependency, also anImportError) carries the install command (pip install "statspai[fixest]"forsp.feols). Remediation recognisesmissing_arguments(lists the schema's required parameters) andunknown_argument(suggests the closest parameter or alias).pipeline_*stages keep the structured error payload instead of a string, and their fallback serialisers record degradations. - Discovery that understands research questions.
sp.search_functionsmaps estimand / design vocabulary (LATE / compliers, ATT, CATE, RKD, synthetic control, 2SLS, TWFE, IPW, AIPW, TMLE, …) to the functions that estimate it, ranks dispatchers first and canonical names above aliases, scores agent-card text at low weight, and returns the best partial matches (match: "partial") instead of[]. "minimum wage employment difference in differences" used to return nothing. -
Schemas say what comes back and what a string means.
FunctionSpec.result_class(98 % of callables) is exported asx_statspai.returns = {class, fields, …}in the agent-native schema, asreturnsin the agent card andresult_schemaindescribe_function. Parameters carryx-statspai-role(dataframe/formula/column/columns),x-aliases/x-canonicalfor legacy spellings, enums derived fromLiteralannotations, dispatcher method tables and docstring choice lists (option-parameter enum coverage 57 % → 67 %), and array item types inferred from defaults.FunctionSpec.alias_ofmarks 30 aliases (statspai.registry.is_alias); aliases stay callable but are listed once in the MCP tool manifest. Merged agent cards record per-fieldprovenance(curated/family/baseline/docstring) so family boilerplate is distinguishable from method-specific statements. 28 new estimator cards (e.g.kitagawa_test,evalue_rd,ppi_ols,bayes_its, the conformal family,stochastic_dominance,cluster_staggered_rollout); placeholder parameter descriptions fall from 53 % to 39 %. Ratchets: card / curated-card floors re-frozen at current values, enum andresult_classcoverage pinned. -
MCP tool-list profiles and discovery meta-tools.
tools/listused to return every auto-generated tool (579 entries, ~2 MB, roughly half a million tokens — more than any client context window).statspai-mcpnow takes--profile core|curated|full(alsoSTATSPAI_MCP_PROFILE); the CLI default iscurated: the hand-written estimator / workflow / pipeline tools plus three new meta-tools —search_functions,describe_function,call_function— through which every registered function stays reachable (discover → describe → call).tools/callaccepts any tool name under every profile. (In-processhandle_requestnow defaults tocuratedtoo — see Changed.) - MCP data handles, inline tables and transform chains.
load_data(fromdata_path,data_recordsordata_csv) returns adata_idthat every tool accepts in place ofdata_path;transform_dataapplies an ordered list of operations (query,select,drop,rename,dropna,fillna,assign,sort,sample,winsor,wide_to_long,long_to_wide,mice, or any DataFrame-returningsp.<function>) and returns a new handle with the parent and every step recorded;describe_dataprofiles a handle;statspai://data/{id}reads it back. Results fitted from a handle carry the whole lineage (and the root file's SHA-256) indata_provenance. Inline tables are capped by the same byte budget as file loads and hashed. A failing transform step aborts the chain with the step index and aWorkflowDegradedWarning; a missing handle explains whether it was evicted or never existed. Before this, a frame derived in one call could not reach the next, and any hand-made table needed a temp file. - Registry metadata harvested from docstrings; family inheritance;
consistent schemas. The 982 auto-registered entries now carry
returns(first sentence of theReturnsblock),example(the shortestsp.<name>(...)statement fromExamplesthat parses and binds to the signature — the same checkscripts/registry_example_audit.pyapplies) andreference(paper.bib keys when theReferencesblock cites them, else the author's own first entry). Coverage moves from example 34 % / returns 22 % / reference 22 % to 96 % / 83 % / 33 %. Family variants without a card (rd_* → rdrobust,did_* → did,synth_* → synth,dml_* → dml,iv_* → iv,forest_* → causal_forest, …) inherit their dispatcher's assumptions, pre-conditions, failure modes and alternatives;describe_functionnow merges inherited fields exactly as the agent card does and says so (inherited_from,inheritance), and reportsauto_generatedand, for result / exception / model classes,kind: "class"(320 exported classes are no longer offered as tools). Exported JSON schemas are reconciled: numeric enums become numbers, an enum on an array moves toitems.enum, a default of another JSON type widenstypeto the union, a default outside its enum is stated in the description instead of contradicting the schema, andList[float]/Sequence[int]parameters get typeditems.tests/test_registry_metadata_ratchet.pypins the coverage shares and the schema consistency so they can only move up.sp.rd(the RD dispatcher) is registered and exported. A per-family card layer (statspai._family_cards, 28 families, 272 members) states the shared assumptions, data pre-conditions, failure modes with remedies and alternatives for the parity-ledger entry points no earlier card reached — limited-dependent-variable and count regressions, survival, clustered / spatial-HAC inference, weak-IV tools, RD bandwidth helpers, multiple testing, spatial statistics and models, networks, power, decompositions, mediation, selection / attrition bounds, survey estimation, time series, dynamic panels, post-estimation, rlasso, DiD diagnostics, ML-causal helpers, the g-formula / transport / target-trial toolkit, missing data and randomization inference. Agent-card coverage on callables moves from 46 % to 75 %; the ratchet floor is raised to 70 %. A second, per-function card layer (statspai._function_cards, 80 entries) refines the highest-traffic DiD, IV, RD, synthetic-control and DML entry points: the failure the agent will actually see from this estimator, the situation in which this variant is the wrong pick even though the call succeeds (not_recommended_when), and the scaling hazard of this implementation (cost_profile). Precedence is now explicit and tested (tests/test_function_cards.py): a family card or family template is the floor, a per-function card overrides it field by field and inherits the fields it does not state, and a hand-written registry entry keeps its own content first (per-function cards only append to it). Before this the family-template copies in the registry's in-file seed table silently shadowed any later per-name card, andrdrandinfcarried the RD template's continuity-at-the-cutoff assumption although it is the local-randomisation estimator; it now states its own. Analternativesentry can no longer point at the function itself, andevalue_*/dgp_*/plot_*/sim_*helpers no longer inherit an estimator card through the family name rule. Every discovery view (describe_function,agent_card,function_schema(..., agent_native=True)['x_statspai']) now carries a structuredevidencerecord read from the committed parity index —status(bit-exact / aligned / analytical-only / external-replication / unverified),grade(T1–T4 / S / external) withgrade_basis(recorded by the index builder, or derived from status and sides per CLAUDE.md §5.1),sides,reference,reference_versions,tolerance,tests,module_id— so an agent no longer parses evidence tiers out of prose notes or needs a separatesp.parity_statuscall. - Machine-readable estimator routing:
sp.route,sp.decision_guide, MCProute_estimator,statspai://guide/{family}. The decision logic of the sevendocs/guides/choosing_*_estimator.mdguides (DiD, IV, RD, matching, ML-causal, QTE, dynamic panel) is restated as data instatspai._routing: each family has questions with allowed answers and routes that name a registered function, an example call, why it is right, the assumptions it adds and the guide heading to read.sp.route(family, **answers)returns the matching routes, the unanswered questions and the one that narrows the choice most;sp.decision_guide(family)returns the whole table. Tests pin every route target to the registry and everyread_moreto a heading in the guide, and the guides themselves now ship in the wheel (statspai/agent/_guides/, byte-identical todocs/guidesviascripts/sync_guides.py --check, a pre-push hook) so an installed package can serve them over MCP. - CLI runs estimators.
statspai run <function> --data FILE --arg key=value ...executes any registered function through the same dispatch layer as the MCP server and prints the agent payload (--detail,--out,--format summary); schema-driven family shortcuts (statspai did|callaway_santanna|event_study|regress|feols| ivreg|rdrobust|synth|dml|match|ipw|aipw --data FILE --y ...) build their flags from the registry so they cannot drift;statspai route <family> --answer key=valueroutes a question without data;statspai mcp [--profile]starts the server. Unknown arguments are reported under_unsupported_args, estimator errors are the structured payload on stderr with exit code 3, usage errors exit 2. - Packaged Claude Code skill
statspai-analysis,AGENTS.md,llms.txt. The JOSS-eraStatsPAI_full_data_analysis_skill/(2,263-lineSKILL.md, invalid skill name, description over the 1,024-character limit, stamped 1.19.0, not shipped) is superseded by a packaged skill understatspai/agent/_skill/: a valid frontmatter, a 130-lineSKILL.md(operating loop, pipeline map, hard rules) and ninereferences/files split from the original playbook plus a newmcp-and-cli.md(discover → describe → call over MCP and the shell, data handles, routing, the result contract). No version stamp: the bundledvalidate_api_claims.pygate, now run over every skill file, is the stamp (statspai skill validate [--quick]).statspai skill install [--target DIR] [--force]copies it into~/.claude/skills;statspai skill pathprints the packaged location.AGENTS.md(how an agent discovers, calls and reads StatsPAI) andllms.txt(an index of the agent-facing docs and machine-readable schemas) are added at the repository root. The original directory is kept unchanged as the JOSS archive artifact. sp.search_functionsunderstands task phrases. Stopwords are ignored, common econometrics spellings are expanded (did↔ difference-in-differences,rd↔ discontinuity,iv↔ instrument,staggered↔ Callaway / Sun-Abraham, …), a function matches when at least half of the content words hit, name / tag hits outrank description hits, and result / exception classes are no longer listed. "effect of a policy with panel data" and "two-way fixed effects bias" returned nothing before; they now return the DiD family andbacon_decomposition.sp.didacceptsoutcome=/treatment=(aliases fory/treat, alongside the existingunit=/controls=), andsp.did(method='auto', id=...)with a 0/1 treatment indicator on a multi-period panel derives the first-treatment cohort (0 = never treated) and runs Callaway-Sant'Anna instead of failing with "No treatment cohorts found". A non-absorbing indicator raisesMethodIncompatibility. The registry / MCP schema fordidnow listscovariates,cluster,weights,aggregation,control_group,estimatorandanticipation, which the live signature always had.CausalResult.to_json(detail=...)andResultProtocolMixin.to_json()so every result type serialises through the same entry point; the agent payload gains adegradationslist (see Fixed).
Changed¶
sp.etwfeestimand labels are short:"ATT (link scale)"and"ATT (linear ETWFE)"(they are regression-table row labels, and the old sentence-long label was truncated bysp.etable). The full definition moved tomodel_info['estimand_description']; the response-scale label is unchanged.- Regression tables leave FE / cluster cells blank for a
CausalResultthat does not declare them, instead of printingNo: the table does not know that CS-DiD or an imputation estimator absorbs nothing. A causal result that writesmodel_info['fixed_effects']/cluster_var(nowsp.etwfe) getsYes/Noand the cluster variable like any regression. CausalResult.pretrend_test(window=None, type='auto', alpha=None)falls back tosp.pretrends_test(result, ...)when the estimator stored no test (sp.etwfe), so the method and the function agree; with no arguments a stored test is returned as before.sp.ppmlhdfeis 10x faster on multi-way fixed effects. The IRLS step now updates the linear predictor as the working variable minus the within residual (theppmlhdfealgorithm) instead of solving the effects' score equations by Gauss-Seidel, which needed thousands of sweeps per iteration with nested or many crossed effects; the inner within-transform follows a tolerance schedule and is warm-started from the previous iteration (exact: the projection removes the fixed-effect span under any weights); fixed-effect dimensions nested in another are dropped from the projection. A five-way PPML on 10^5 rows: 24 s -> 2.4 s (Stata 2.9 s); a seven-way stacked PPML on 6.9x10^5 rows: 347 s -> 45 s. Estimates move by at most ~1e-9 (relative), inside the IRLS tolerance; all Statappmlhdfeparity fixtures are unchanged.sp.callaway_santanna(clustervars=...)no longer requiresbstrap=True; analytic SEs are clustered. Withbstrap=Truethe pre-trend Wald test now clusters as well (it used unit-level influence functions).sp.fepoisnamessp.ppmlhdfewhen pyfixest cannot absorb the fixed effects (NumericalInstability, still aValueError), instead of surfacing pyfixest's "Demeaning failed after 100_000 iterations"; a five-way PPML that fails there runs insp.ppmlhdfe.sp.winsoruses Stata's percentile definition (_pctile, numpyaveraged_inverted_cdf) by default, as thewinsor2equivalence in its docstring promised; it used linear interpolation, which moved every cutoff whosen p / 100is not an integer (a replication's 99th percentile: 51,003.45 against Stata's 51,015).method='linear'gives the old cutoffs.sp.logit/sp.probit/sp.cloglogdrop perfectly predicting indicators by default (see Added);Nand the pseudo R-squared change on data that has them.sp.aggteinheritsbstrap/cband/bitersfrom the fit (Rdid::aggte(bstrap = NULL)), instead of defaulting to an unseeded 1000-draw bootstrap:sp.aggte(fit)on a defaultcallaway_santannafit is now analytic and returns the same SE on every call.cband=Trueor an explicitn_bootstill requests the bootstrap; an unseeded bootstrap now draws a seed and records it inmodel_info['random_state']. See MIGRATION.md.- ⚠️ MCP tool failures are
isErrorresults, not protocol errors. An unknown or expireddata_id/result_id, a data-load failure, an invalid argument value or a timeout used to come back as a JSON-RPC error (-32602 / -32000), which many clients never show to the model. They are now{"isError": true, "structuredContent": {error_kind, message, hint, miss_reason, …}}. Malformed requests, unknown methods and unknown tool names stay JSON-RPC errors (an unknown tool name is now -32602). A staleresult_idis an error on every path instead of being ignored. Text blocks are compact JSON. See MIGRATION.md. - ⚠️ MCP network data is opt-in.
data_pathURLs (http(s)://,s3://,gs://) needSTATSPAI_MCP_ALLOW_REMOTE=1and obeySTATSPAI_MCP_MAX_DATA_BYTESlike local files;openWorldHintistrueonly while remote loading is enabled.transform_dataexpressions (query/assign) pass an AST allowlist first (unsafe_expressionotherwise). - ⚠️ In-process
handle_requestdefaults to thecuratedprofile, like thestatspai-mcpCLI; pass the profile explicitly (or setSTATSPAI_MCP_PROFILE=full) for the full manifest.statspai://functionsalways serves the full index. CausalResult.next_steps()/EconometricResults.next_steps()no longer print. They returned the checklist and printed a ~1.2 KB banner to stdout, which polluted MCP stdio and piped CLI output; passprint_result=Trueto print.- CLI output contract. Stdout is strict JSON (
allow_nan=False, numpy values serialised, notrepr), carriesruntime_warnings, and estimator prints go to stderr. Exit codes byerror_kind: 2 usage, 4 input errors (column_not_found/missing_arguments/unknown_argument; were 3), 5missing_dependency(was 3), 3 other estimator errors. - Remediation category
bad_argumentis nowunknown_argument. sp.all_schemas()omits classes (passinclude_classes=True); thedataparameter is typedobjectwithx-statspai-role: dataframeinstead ofstring.-
sp.cluster_staggered_rolloutreports skipped cells and failed bootstrap draws with aRuntimeWarninginstead of dropping them silently, and warns when its standard error is the 1e-6 placeholder (fewer than two usable draws). Estimates are unchanged. -
MCP stdio loop reads on a thread; server-side sampling no longer deadlocks.
serve_stdioused to read stdin on the same thread that rantools/call, so a tool that asked the client's LLM a question (interpret_result→sampling/createMessage) could never receive the reply and fell back after the 60 s timeout. stdin is now consumed on a reader thread that routes JSON-RPC replies to the sampling matcher immediately and queues requests for the loop; responses, progress notifications and sampling requests share one locked stdout sink so lines cannot interleave. Covered by a real-subprocess test (tests/test_mcp_stdio_subprocess.py: initialize → tools/list → fit → interpret_result with a fake sampling client), which also pins thecoretools/list under 100 KB on the wire and that stdout stays pure JSON-RPC. (The CI change adding these suites to the push-time gate andstatspai-mcp --helpto the wheel smoke test waits inplans/pending-workflow-patches/— workflow files cannot be pushed by the GitHub App.) - MCP payloads say what did not happen.
tools/callresults carryruntime_warnings(deduplicated Python warnings raised during the estimator call —ConvergenceWarning,AssumptionWarning, weak-instrument / few-cluster notices — which previously went to stderr where no MCP client reads them). Registry-dispatched tools report arguments they could not bind under_unsupported_argsinstead of dropping them silently (a misspeltcluster=used to change the standard errors with no trace); the accepted set is now the live signature ∪ registry params ∪@accepts_aliasesspellings, so a registry entry that lags the signature never rejects a real argument. Estimatorprint()output is redirected to stderr during a tool call so it cannot corrupt the JSON-RPC stream. Report / export tools that write files (cs_report,did_report,rd_dashboard,synth_to_excel,synth_report_to_file,influence_functions) are annotatedreadOnlyHint=false. to_dict(detail=...)keeps one level of nested diagnostics whose values are scalars (diagnostics['mccrary']['pvalue'],diagnostics['pretrend_test']); previously every nested test result was dropped, so an RD result carried a McCrary p-value that the agent payload could not see.scripts/error_taxonomy_audit.py: the structured-raise floor moves from 42 (never ratcheted since the taxonomy was introduced) to 2,600.
Changed¶
sp.bartik/sp.BartikIV/sp.shift_share_political:leave_one_outdefaults toNone. Leave-one-out needsregional_shocks; the old defaultTrueonly ever warned and fell back on the basic API.Noneuses leave-one-out exactly whenregional_shocksis given; an explicitTruewithout it now raisesMethodIncompatibility.EconometricResults.pvaluesis a labelled Series likeparamsandstd_errors(it was a bare ndarray onsp.regress), and results expose.nobsand.r2.
Fixed¶
- Event-study plots of
sp.etwferesults raisedKeyError: 'ci_lower'.result.plot(),result.event_study_plot()andsp.enhanced_event_study_plot(...)indexed the interval columns, whichsp.etwfe/sp.etwfe_emfxleave out ofmodel_info['event_study']; the plots now rebuild a missing interval asatt -/+ z * se(the intervaletwfe_emfxreports) and leave an existing one untouched. Found by the sjjj-2026 replication, whose Figure 3 had to be drawn by hand. sp.regtableprinted "No FE, no clustering" forsp.etwfe, which absorbs unit (or cohort) and period effects and clusters on the unit: the nonlinear / jwdid branch recorded neither. It now writesmodel_info['fixed_effects']andcluster_var.sp.stacked_did(controls=...)crashed in the event-study ATT: the delta method multiplied event-time weights by the full covariance, which also carried the controls.- Original-data parity results refreshed; module 02 made deterministic.
tests/orig_paritymodules 04, 04b, 08 and 11 had result files older than the library fixes they exercise (the matched ATT covering every treated unit and the Abadie-Imbens default SE forsp.psm, the logit propensity default ofsp.g_estimation, the E-value CI rule), so a rerun changed them. Regenerated: g-estimation now equals R to 6e-10 (was 4.3e-4), the SMD E-value to 1.5e-11 (was 4.9e-3), and the IPW E-value's CI bound is 1 because its RR interval contains 1. The Lalonde PSM row differs from MatchIt by 2.2% because 15 treated units have tied nearest controls:sp.psmtakes the first in data order, MatchIt breaks ties its own way. Module 02 compared a multiplier-bootstrap SE (unseeded, different on every run) with R's analytic one; it now usesaggte(bstrap=False)like the R side and equals R to 1e-15. - Evidence attachment:
sp.feolsinherited Track A modules03_hdfe/15_hdfe_clusterby name resolution. Those modules run the nativesp.fast.feols; the index builder credited the leaf namefeols, which is the top-levelsp.feols-- a pyfixest delegate the modules never called. The two agree on the module bytes (coefficients and iid SEs ~1e-14, clustered SEs 2e-10 to 3e-9 under pyfixest'sfixef_tol=1e-6), so the grade stands, but it is now an explicit measured proof (_parity_taxonomy.SUBNAMESPACE_LEAF_PROOFS,test_track_a_alias_equivalence.py) whose note discloses the delegation, andbuild_parity_index.pyrefuses any dotted call whose leaf name is a different, unproven object. Grades and estimates unchanged. -
sp.fectregistry limitation was stale. It said fect's cross-validated choice ofr/lamwas not supported;cv=Truehas existed since 1.29.0. The limitation now states what is actually limited: the folds are random, and cross-language parity covers fits at a user-suppliedr/lam. -
sp.cgs_continuous_did: the overall ACRT standard error was about half its true value. The cell influence function is a treated-sample quantity (the ACRT is a mean over dosed units) but was aggregated as if it were on the whole cell, dropping a factorn / n_treated. With half the units dosed the SE was 0.059 against a Monte Carlo spread of 0.123; it is now 0.123 against 0.121 (test_cgs_continuous_cluster.py). sp.spec_curve: clustered p-values and CIs used t(n - k) where Stataregress, vce(cluster)uses t(G - 1), overstating significance with few clusters; the SE was already right (test_spec_curve_fe_Stata_parity.py).- Stata reference fixtures read the data in double precision. The
fixtures added for
hdfe_ols, the wild bootstraps and the weak-IV statistics usedimport delimitedwithoutasdouble, so Stata worked on float-rounded data and the comparisons stopped at ~1e-7 -- attributed at the time to the demeaning tolerance. Regenerated; they now agree to ~1e-14 and the tolerances are tightened accordingly. sp.rdrobustaccepts asymmetric bandwidthsh=(left, right),b=(left, right). The signature and estimator supported them, but the input check calledfloat()and rejected every pair (Watering Down, QJE 2020). Matches Rrdrobust4.0.0 to 1e-9 with and without clusters (test_rdrobust_asym_bandwidth_R_parity.py).sp.oprobit/sp.ologit/sp.mlogitreport convergence at the polished optimum. The flag came from the BFGS start, which often stops on a line-search failure; after the exact Newton polish the estimate was right butconvergedreadFalse(Princelings, QJE 2019). It is now Stata'sg' (-H)^{-1} g < 1e-5.- Schema bundle: result-field types use the public pandas path. The
returns.fieldsof agent cards renderedpandas.core.frame.DataFrameunder pandas < 3, so the bundle changed with the pandas version andtest_schema_bundle_has_no_internal_pandas_pathsfailed; they now go through the same canonicalisation as signatures. -
sp.gsynthnames the problem whentreated_unitis a list or missing. It supports one treated unit. A list, tuple or array used to fail deep in the factor fit with numpy'sLinAlgError: Incompatible dimensions; it now raisesMethodIncompatibilitypointing tosp.fect. An identifier not in the unit column raised a bareKeyError; it now raisesDataInsufficientnaming the column. Estimates are unchanged (the Track A 19_gsynth result file reproduces byte for byte; trace re-recorded). -
Nonlinear ETWFE aggregation after 027813fb (unreleased regression). That commit counted only non-separated rows in each cell's
n_treated, which is also every aggregate's weight: the one-call reproduction of the jwdid table fell from 21/21 to 1/21 (link-scale headline off in the third decimal wherever two cohorts overlap) and the response-scale ATT inflated about sixfold (0.0944 to 0.548). Separated rows are treated observations with a marginal effect of exactly zero, so they belong in the denominator; weights are back to all treated rows. Itsevent_study_vcovweighted the cell covariance by1/(n1 n2), shrinking variances by about1/n^2, sosp.honest_didreturned NaN or near-zero-width sets; replaced byG V G'(above).pretrends_testnever reached that matrix and still used the diagonal (chi2 29.9 against jwdid's 52.65); it now does, and the loosenedrtol=1e-3/atol=1e-6diagonal check it added is back to the strict one. Regression tests intests/test_etwfe_nonlinear.pyfail on 027813fb. scripts/stability_audit.py --checkis green again.sp.rd,sp.routeandsp.decision_guidewere registered as stable without an evidence record; they now cite their unit-contract tests (tests/test_rd_dispatcher.py,tests/test_routing.py).sp.rd(..., cutoff=0)raisedTypeError: rdrobust() received both 'cutoff' and its canonical target 'c'because the dispatcher forwarded the alias inside**kwargsalongsidec=c.cutoff=/running=are now folded intoc/xbefore dispatch.@accepts_aliaseson a**kwargsfunction (sp.did) let an unknown spelling fall through to Python's "missing 2 required positional arguments: 'y' and 'treat'"; it now raises the did-you-meanTypeError('outcom' (did you mean 'outcome'?)).to_dict(detail='agent')wrappedviolations()/next_steps()in a bareexcept Exceptionthat returned an empty list, so a crashing diagnostic read as "no violations". Failures now emitWorkflowDegradedWarningand land indegradations({section, error_type, message}).- The MCP
didtool advertisedpost=(whichsp.didrejects) and omittedid/method, so staggered designs could not run through it; the schema now mirrors the dispatcher. - Remediation hints pointed at
sp.weak_iv_ci(...)andsp.fixest(..., fe=[...]), neither of which exists; they now namesp.anderson_rubin_ciandsp.feols. A test pins everysp.<name>(inremediation.pyto the registry. -
Stale tool counts in
docs/guides/agent_api.md("~100 tools", "three prompts"), the MCP server and dispatcher docstrings. -
sp.sdid(treat=...): staggered adoption, projected covariates and Stata's inference rules.treat=takes a 0/1 indicator, as Stata'ssdid Y unit time W. Each adoption cohort is fitted against the never-treated units with the block estimator's own weight solver and the cohort effects are averaged by treated units x post periods (clarke2024synthetic, eq. 7); per-cohort effects, weights and SEs are inmodel_info["tau_by_cohort"], andunit_weights/time_weightsbecome unit x cohort and period x cohort tables.covariates=withcovariate_method="projected"removesX betafitted by a two-way fixed-effects regression on the never-treated units (Stata'sprojected;optimized, Stata's default, is not implemented and is never chosen silently).se_methodgains"noinference"; the jackknife holds the cohort weights fixed and re-projects covariates on every leave-one-out sample; bootstrap and placebo re-solve from the original weights. Stata's refusals are reproduced (jackknife needs two treated units in every cohort, bootstrap more than one treated unit with a single cohort, placebo more controls than treated units). Pinned against Statasdid2.0.2 on the paper's two datasets: ATT, every cohort effect, the projected-covariate beta and the jackknife SEs (sdid / did / sc, with and without covariates) to 1e-13 or better (tests/reference_parity/test_sdid_staggered_parity.py, T2); bootstrap / placebo are a stochastic screen only. Thetreated_unit=/treatment_time=interface is unchanged and stays pinned to Rsynthdid. -
sp.did_had— heterogeneous-adoption DiD (de Chaisemartin, Ciccia, D'Haultfœuille & Knau;dechaisemartin2024nounit). The design where every group adopts at the same period F with a heterogeneous dose and no group stays untreated, so the counterfactual cannot come from a control group: it is the local-polynomial intercept at dose zero, supplied by the quasi-untreated. Ports Stata'sdid_hadincludingeffects=/placebo=/trends_lin=/dynamic=, the quasi-untreated-group diagnostic, and a Yatchew differencing test of linearity (yatchew1999elementary— note Stata's owndid_hadhelp dates this to 1997 while citing the 1999 paper's volume and pages; StatsPAI carries the verified 1999 record). Pinned against Stata 18 MP (87_did_had.do) to 1.6e-8 at supplied bandwidths and 9.8e-8 end-to-end with selection.
Note the interval is deliberately not symmetric around the
estimate: did_had pairs a conventional point estimate with a
bias-corrected interval centred at β̂ − B̂, and StatsPAI follows it.
sp.lprobust_at_pointand the bandwidth-selection family. Local polynomial regression with robust bias correction — the engine underdid_had— matching Stata to 9.2e-9 across three kernels × three bandwidths, plus ports of five of the sixnprobustselectors:
sp. function |
vs R | |
|---|---|---|
mse-dpi |
lpbwselect_mse_dpi |
5.6e-13 |
mse-rot |
lpbwselect_mse_rot |
4.3e-12 |
imse-dpi |
lpbwselect_imse_dpi |
4.5e-13 |
imse-rot |
lpbwselect_imse_rot |
1.6e-12 |
ce-rot |
lpbwselect_ce_rot |
4.2e-13 |
Stata agrees with R on all five (worst 1.1e-7); its residual is its
own — lpbwselect.ado integrates the kernel moment constants
numerically where these evaluate them in closed form. All five are
also pinned end-to-end through sp.did_had against Stata's
bw_method(), which is what catches a wiring bug that leaves the
selectors themselves correct.
Three details the reference forces and that are followed rather than
harmonized: imse-rot grids on x-quantiles while imse-dpi grids
on equal spacing; the IMSE B_h uses rB·B1² with no
regularization term, unlike the pointwise mse-dpi bandwidth, which
carries bwregul·R; and ce-dpi shares ce-rot's b exactly,
differing only in h.
mse-rot and imse-rot deliberately report no b: R derives one by
re-entering the selector on the even (p − deriv) branch, which is not
ported, and neither consumer needs it — lprobust and did_had both
run at rho = 1, so their bias bandwidth is h.
ce-dpi is not ported (it needs nprobust's ~150-line
coverage-error plug-in). Passing it raises NotImplementedError
naming the gap, so a valid Stata spelling does not read as a typo and
is never silently swapped for a different selector.
Mass points matter here and are handled: with tied doses at the evaluation point — the normal case in this design, since true stayers all sit at dose 0 — a plain k-NN variance picked an arbitrary subset of the tie group and got the standard error wrong by 3–6% while the point estimate stayed exact to 2e-9. Tie groups are consumed whole.
-
ScalarEffect's docstring example could not run. It calledScalarEffect(...)bare, with no import, so the example-execution gate failed on it. Nowimport statspai as sp+sp.ScalarEffect(...), matching the house rule that every example is runnable as written. -
sigmamore=Trueonsp.hausman_test/PanelResults.hausman_test(Stata'shausman fe re, sigmamore, pinned to Stata 18). sp.test/sp.lincomuse the full covariance ofsp.panelresults, so joint restrictions -- e.g. the Mundlak test of the unit-mean terms -- work.
⚠️ Correctness¶
-
sp.stacked_didstandard errors charge the absorbed stack-by-period effects, asreghdfedoes. The stacked regression now runs on the HDFE kernel (seesp.stacked_did(weights=, ...)under Added). Its CR1 factor(N-1)/(N-K)used to count only the event-time dummies inK; the stack-by-period effects are not nested in the unit cluster and belong inK(the stack-by-unit effects are nested and still do not). Every SE and interval widens by the constantsqrt((N - k_event)/(N - K)): 1.5% on the castle-doctrine panel (window=(-5, 5), never-treated controls, 50 states) and 7.0% on the unilateral-divorce panel (41 states), wheresenow equalsreghdfeon the same stack to 1e-13. Point estimates are unchanged. See MIGRATION#stacked-did-reghdfe-k. -
sp.from_statano longer passes Stata varlist wildcards / ranges through.reghdfe y t2pre* t2current t2post*came backok=Truewitht2pre*left in the formula, where*reads as an interaction: a confidently wrong model. Wildcards (x*,x?) and ranges (a-b) name dataset columns, sofrom_stata(line, columns=list(df.columns))now expands them in dataset order (sp.statapasses its data's columns) and withoutcolumnsthe command is refused with that hint. Found by the AIGC-wage replication's do file (TestVarlistAbbreviations). -
sp.iv(absorb="... + a^b")reported no Olea-Pflueger effective F. The weak-IV helpers looked upa^bas a column, raisedKeyError, and the diagnostic was replaced by an error string. Interacted terms are now materialised as insp.ivitself; with one instrument the effective F equals the KP rk Wald F, as it should. -
sp.hdfe_olsconverges further when a varying slope is absorbed.tolnow defaults to1e-12with ani.f#c.x/f[x]term (1e-8otherwise). Slope sweeps converge slowly, and the per-sweep stopping rule stopped early: on the replication of Zheng, Huang and Zhu (2026, Table 5B col. 3, four FE groups plus a quarter-specific slope) the clustered SE was 1.05e-5 (relative) from reghdfe's at the old default and 3.8e-8 now; the coefficient agreed either way. reghdfe's collinearity screen still uses its1e-8-based threshold (newsp.absorb_ols(collinear_tol=)). The fit takes roughly 1.5x as long. Old numbers:tol=1e-8(test_hdfe_slope_default_tol.py). -
sp.iv(absorb="a^b")dropped no rows with a missingaorb. The interacted FE was built from strings, so a missing component became the level"nan"and the row stayed in the sample, whereivreghdfedrops it. On the 1.5-million-row replication of Zheng, Huang and Zhu (2026, Table 6) the 2SLS coefficient was -0.136797 instead of -0.1381572. Such rows are now dropped (test_hdfe_iv_ivreghdfe.py). The absorbed sweep also warns (ConvergenceWarning) if it stops atfe_maxiterwithout converging; it used to return the unconverged residuals silently. sp.hdfe_olsclustered p-values and CIs uset(G - 1), reghdfe'se(df_r)(min(G) - 1, capped at the residual df); they usedt(N - K - df_a), practically the normal with many observations. SEs are unchanged. With 100 occupation clusters in the replication of Zheng, Huang and Zhu (2026), p = 0.091 and CI [-0.1423, 0.0107] as Stata, where 1.32 gave 0.0878 and [-0.1414, 0.0098]. The same for the no-FE path (regress, vce(cluster)). Old numbers:df_inference='resid'.sp.honest_did(method='relative_magnitude')no longer reports the grid edge as the bound. When the confidence set reached the end of the +/-20 sd test-inversion grid, the grid end was returned (with a warning): the interval was understated, e.g. [-0.325, 0.325] where Statahonestdidgives [-0.531, 0.390]. The grid now extends at the same step until the set closes (attrs['grid_extended_at']), matching Stata to the grid step and the C-LF simulation (test_honest_did_grid.py). HonestDiD (R) truncates like 1.32;grid_expand=Falsereproduces it.-
sp.ri_test(cluster=)refuses a treatment that varies within clusters. It used to give every cluster the treatment of its first row and permute those values, a null distribution of a design nobody ran (the HIV-results incentive replication, randomized to individuals, withcluster='villnum'). Pass the cluster-level variable withinteract=forexposure x post, or permute rows.'diff_means'/'t'/'ks'also refuse a non-binary treatment. -
sp.callaway_santannawithout never-treated units no longer averages in zeros. Found by the QJE 2019 (Princelings) and QJE 2023 (AI-tocracy) replications. Withcontrol_group='notyettreated'and every unit eventually treated, the late ATT(g, t) cells have no comparison units; they came back asatt = 0, se = infand entered every aggregate as genuine zero effects (simple ATT 0.55 against Rdid's 0.92 on a design with true effect 1). StatsPAI now applies Rdid's rule: periods from the last cohort's treatment date (minus anticipation) on are dropped and that cohort serves as the not-yet-treated comparison only, with a warning;model_info['comparison_only_cohorts']records it. Any cell that still has no comparison units is left out of the estimates and aggregates with a warning (model_info['dropped_gt_cells']), instead of counting as zero. Against Rdid2.3.0 the cells and the simple aggregate agree to 1e-8 on panel and repeated cross-section data, both base periods, anticipation 0/1,reganddr(test_cs_no_never_unbalanced_parity.py). sp.callaway_santanna(allow_unbalanced_panel=True, estimator='reg')honourscontrol_group='notyettreated'. Without covariates this path used a cell-mean estimator whose comparison arm was hard-wired to the never-treated units, so the not-yet-treated request returned the never-treated numbers whilemodel_inforeportednotyettreated. Cells now match Rdidto 1e-14 under both control groups. The same fix applies topanel=False. Covariates that are constant or collinear within a cell on the repeated-cross-section path are now dropped (as R'slmaliases them) instead of making every cell fail and the fit return zeros.sp.hdfe_ols(wild=True)re-absorbs the fixed effects in the bootstrap. The wild cluster bootstrap ran on already-demeaned data, which is right only when every absorbed effect is nested in the cluster; with a year effect and county clusters the bootstrap t distribution was too narrow (Web of Power, QJE 2023, Table 5 col. 1: p = 0.045 againstboottest's 0.218). It is nowboottest's WCR bootstrap: null imposed, each bootstrap outcome re-projected on the FE space (reduced to G x G algebra, so a draw is cheap), the confidence set by test inversion, Rademacher sign vectors enumerated when2^G <= wild_n_boot, and|t*| = |t|counted as a tie. Against Stataboottestwith full enumeration the p-values agree exactly and the bounds toboottest's ownptol(test_hdfe_wild_boottest_parity.py).wild=Truewithweights=now raises instead of returning an unweighted bootstrap.sp.hdfe_ols/sp.absorb_olsomit collinear regressors and count nested fixed effects asreghdfedoes. A regressor spanned by the fixed effects made the solve raise (exact) or, with float noise, returned a coefficient of order 1e5 that moved the others (Web of Power Huai column: 0.0196 against 0.0214); it is now omitted with a warning and reported as NaN, usingreghdfe'scollinear_tol. Absorbed degrees of freedom followreghdfe's defaultdofadjustments(pairwise clusters continuous): a FE nested in another (yearinsideregion x year,countyinsideprefecture) is no longer charged in full, which made SEs 0.5-0.8% too large. Againstreghdfe6.12.3 estimates, SEs ande(df_a)agree (test_hdfe_nested_collinear_parity.py); the documentedabsorb(county i.pref##c.year)gap (df_a 41 against 36) is closed.sp.ivreg(vce='wild')reports the test-inversion confidence set. The interval was the null-imposed bootstrap-t quantiles placed around the estimate, valid only at the null value (ADH, AER 2013: [-0.496, -0.078] againstboottest's [-0.580, -0.112]). It is nowboottest's: the{b0 : p(b0) > alpha}set over the same draws. With one endogenous regressor the draws are vectorised (Frisch-Waugh-Lovell per draw), so the inversion takes seconds; several endogenous regressors use the per-draw loop. Against Stataboottestafterivregresswith enumerated Rademacher weights the p-value is exact and the bounds withinboottest'sptol(test_iv_wild_ci_inversion_parity.py).sp.romano_wolfuses each outcome's own sample and has no p-value floor. Rows missing any outcome were dropped from every regression (Kinship, QJE 2019: six samples of 66-79 countries shrank to 15); each outcome now uses its own non-missing rows, as Statarwolf, reported intable['n_obs']. A bootstrap draw in which a control dummy was all zero made the fit singular and was scored ast* = 0, i.e. as exceeding every observed statistic -- a floor underp_rw(UCT, QJE 2016: 0.027 at|t| = 10); aliased controls are now omitted per draw and only draws with an unidentified treatment coefficient are discarded, with a warning. The bootstrap statistic is the studentised|b* - b| / se*of Clarke, Romano & Wolf (2020) rather than|b*/se* - b/se|.sp.rlasso_ivreturns NaN when no instrument is selected. The coefficient is then not identified; it used to come back with an SE of order 1e14 (AI-tocracy, QJE 2023). A warning names the regressor.- Weak-IV statistics with absorbed fixed effects match
ivreghdfe.sp.anderson_rubin_test(absorb=, cluster=)computed the cluster-robust AR statistic in score form -- the cluster meat built fromy - b0 dinstead of the reduced-form residual -- which disagrees with the reduced-form test whenever the instrument matters (Web of Power Table 4 col. 4: p = 0.068 againstivreghdfe's 0.0246). It is now the Wald formivreg2reports. The absorbed-FE charge shared byanderson_rubin_test,effective_f_test,iv_diagandsp.iv(absorb=)also appliesreghdfe's pairwise mobility-group rule, so a FE nested in another (yearinsideprefecture x year) is no longer charged (223 againstivreghdfe's 209 there; AR F, effective F and the IV SE were ~1.6% off). Againstivreghdfe ..., ffirstthe AR F and p-value, the Kleibergen-Paap F, the coefficient and its SE agree to 1e-10 (test_weakiv_nested_fe_Stata_parity.py). - AKM shift-share inference drops collinear shares exactly as
ShiftShareSE.sp.shift_share_se/sp.ssaggregatedropped collinear share columns with a one-pass Gram-Schmidt test; R'sqr()(LINPACKdqrdc2, limited pivoting with downdated norms) keeps a different set on nearly collinear matrices (ADH's raw 794 industries: 781 columns against R's 776, AKM SE 4.6e4 against R's 1.5e4). The selection is now a port ofdqrdc2, and the kept columns and AKM SE equalShiftShareSE1.1.0 (test_akm_collinear_shares_R_parity.py). A share matrix that stays numerically singular after the drop (condition number above 1e8) now warns that the AKM / AKM0 SEs are unreliable -- R returns them silently; BHJ's cleaned 773-industry file gives R's 0.1103 on both sides. sp.lee_bounds(trimming='leebounds')reproduces Stataleebounds.leeboundsstores the trimming percentage and the quantile threshold in 16-digit local macros; the rounded threshold can land one unit in the last place beyond the data and drop the quantile observation, so its bounds differ from Lee's rule on six of eight float-stored test samples (UCT, QJE 2016, Table III assets: lower bound -3.38 in Stata, -2.51 under Lee's rule). The new option performs the same rounding and tie branch; bounds agree to 1e-12 and analytic SEs to 1e-8 (test_lee_bounds_leebounds_Stata_parity.py). The default stays Lee's rule; the docstring no longer claims it equalsleeboundsfor continuous outcomes.-
sp.ppmlhdfedrops separated observations by default. The defaultseparation=Trueused to warn and keep them: fixed effects of all-zero groups were chased towards minus infinity, IRLS crawled (a 923k-row panel did not finish in 86 minutes), and on a subsample the slopes differed from Statappmlhdfe/sp.fepoisat 1e-3 relative with no error, withNand the cluster count including the separated rows. It now removes them with thefeand single-regressorsimplexrules Stata applies by default (correia2020fast), reportsmodel_info['n_separated']and warns; regressors left identically zero are omitted, as in Stata.separation='warn'restores the old behaviour. The fixed-effect projections are also vectorised (np.bincountinstead of a per-group mask loop): the 46k-row, 2.2k-unit subsample that took 299 s now takes well under a second. The Track A fixtures that run through this code (37, 42, 47, 58) have no separated rows and reproduce their committed results to within 6e-15 relative. -
sp.did(method="sdid")andsp.did_analysis(method="sdid")estimated staggered panels as a block design, silently. Both read each unit's first treated period fromtreatand took the earliest one as every treated unit's adoption period, so a later cohort's pre-adoption periods counted as treated: 1.54 against a true effect of 2 on a two-cohort test panel, with no warning. Several adoption periods are now estimated cohort by cohort throughsp.sdid(treat=...)(below); a single-cohort panel returns exactly what it did before.sp.did(method="sdid")also passesseed=/random_state=on -- they were accepted and dropped, so its placebo / bootstrap SE changed on every run -- anddid_analysis(method="sdid")requiresid=. See MIGRATION.md#sdid-staggered. -
sp.optimal_designreported every sample size twice as large as needed. The individual and stratified branches returned the total sample of the two-sample power formula asn_per_arm(and doubled it again forn_total); the cluster branch doubled the cluster count the same way; themde=Nonebranch ignored the sample size; the cost-optimal cluster size was never used. Sizes now follow the formula (documented in the docstring) and agree withsp.power/sp.power_cluster_rct; solving for the MDE needsn=orn_clusters=. See MIGRATION.md#design-and-diagnostic-fixes. - Hausman test.
sp.hausman_testestimated the FE / RE variances itself and disagreed with Stata by up to 50x on the statistic; it now shares thePanelResults.hausman_testimplementation, which matches Stata 18hausman fe reto 1e-6 (tests/reference_parity/test_hausman_stata_parity.py). Both clamped a negative statistic to 0 and recommended RE with p = 1 -- on panels where the unit effect loads on the regressors, i.e. exactly where FE is needed; a negative statistic is nowrecommendation="inconclusive",pvalue=nan, with anAssumptionWarning. sp.compare_estimatorsdropped methods silently.matching(in the default list) anddmlcalled their estimators with keywords they do not take; the per-methodtryturned the error into a warning and the row vanished. All seven methods now run, and the IPW bootstrap is seeded so the table is reproducible.sp.estatIV tests never ran.endogenous/overidlooked upmodel_infokeys no IV estimator writes, andestat(result, "all")did not recognisesp.ivreg's"IV-2SLS"label; they now read the fit's Wu-Hausman F and Sargan / Hansen J statistics.
Changed¶
-
sp.event_study_vcovis now pinned against R and Stata, off-diagonal blocks included. Track A modules 05 / 17 / 73 / 85 compared each event-time coefficient and its SE, i.e. only the diagonal of the covariance thatsp.uniform_bandsand the HonestDiD FLCI consume.tests/reference_parity/test_event_study_vcov_R_parity.pyandtest_event_study_vcov_Stata_parity.pynow compare the full matrix, read throughsp.event_study_vcov, for six estimators on the Track A CSV bytes:sp.callaway_santannaagainstdid::aggte's influence-function covariance (3.4e-15),sp.event_studyagainstfixest(5.1e-13),sp.sun_abraham(share_variance=False)againstfixest::sunab(8.9e-11),sp.gardner_did(event_study=True)againstdid2s(6.7e-11),sp.did_imputationagainst Statadid_imputation(1.2e-9) andsp.etwfeagainstetwfe::emfx(type="event")(3.4e-9 not-yet, 8.1e-8 never-treated). Each reference is run with its iterative or numerical step tightened so that it is the exact solution. At their defaults,did2ssits ~1e-7 away (fixestfixef.tol1e-6),did_imputation2e-5 (imputation-weighttol1e-6) andetwfe1e-6 to 3e-5 (marginaleffects' forward-difference Jacobian; StatsPAI's is analytic).did_imputation's own point estimates stay 3.6e-7 away becausereghdfe's solver tolerance is not exposed; an exact denseregressin the same Stata run matches StatsPAI to 1.0e-11. No numbers moved. The parity grade goes from unverified to bit-exact.sp.uniform_bandsdraws its sup-t critical value by Monte Carlo, so it is graded analytical-only. It passes an equivalence test againstmvtnorm::qmvnorm(20 seeds, margin +/-0.005). Its docstring understated the Monte Carlo error: the seed-to-seed SD at the default 100,000 draws is about 5e-3, not 1e-3. -
sp.parity_status(...).summary()no longer contradicts itself on analytical-only and external-replication rows that name a reference. It used to printno external software reference vs <reference>even when the reference was software (e.g.wooldridge_prodagainst Stataprodest). Such rows now readknown-value check against <reference>; not graded as cross-language parity. Grades are unchanged. -
CI: the pandas-3 lane runs nightly instead of on every push.
test-pandas3(~45 min, the full suite under pandas 3.x) now runs on a nightly schedule, on pull requests, on releases and on manualfull/ publish dispatches. Every push to main still gets the fast gate, the quality ratchets,import-budget,securityand theParity guardsworkflow, which is unchanged. The concurrency group now includes the event name so a push never cancels the nightly run. -
scripts/signature_house_style.pycounts a legacy-named parameter as converged when the function accepts the canonical spelling through@accepts_aliases, and the ratchet baseline was lowered to the observed counts (legacy sites 376 -> 69). Signatures keep the reference package's parameter names by decision -- seedocs/guides/grammar.md. sp.regtable/sp.esttab/sp.modelsummarytabulate Tobit and other limited-dependent-variable fits term by term. They collapsed theseCausalResults to the headline estimand: one row namedbeta_x1, with the intercept (const) andsigmamissing.sp.etableon non-pyfixest results now renders throughsp.regtable(same"coef*** (se)"layout), so every table entry point reports identical numbers from one formatter; results whose standard errors are bare arrays no longer breaksp.regtable.sp.dgp_bunchinggenerated no bunching (it scaled incomes above the kink without moving anyone onto it), sosp.bunchingfound nothing on the package's own demonstration data. It now follows the iso-elastic kink model (newt0=/t1=marginal tax rates;df.attrsrecordsdt, the bunching interval and the number of bunchers).- About 120 docstring examples across 91 modules failed under
pytest --doctest-modules(undefinedsp/df, unchecked or stale output, references to names that do not exist such assp.synth.bsts_synthandstatspai.compat.SklearnDID); they are now self-contained and checked. - One precision system for every exit of a result object.
CausalResult.to_latex()was pinned at%.4fwith no precision argument and.summary()printed six decimals, while the other exporters used the adaptive default -- the same ATT read0.3326,0.333and0.332635. Both now acceptfmt=/digits=and default to"auto";.summary()floors p-values at<0.001. The genericField | Valuetable used%.4gin LaTeX and%.6gelsewhere; all three display surfaces now share the adaptive formatter (538,582, not5.386e+05).to_dict()/to_excel()keep full precision. Display only -- no estimate changes. See MIGRATION.md#result-display-precision. regtable(stats_fmt=...)defaults to"stat"instead of"%.3f": three decimals, given up only as the integer part grows past six figures, so R2 still reads0.090and an F of 538582.398 reads538,582. Passstats_fmt="%.3f"for the old rendering.
Fixed¶
- Printing a large
sp.forest_group_effectsorbest_linear_projectiontable from a fixed-effects forest no longer raises. Both put their covariance matrix inDataFrame.attrs["vcov"]; pandas compares attrs with==when it concatenates or truncates a frame for display, and a raw array made thatValueError: The truth value of an array ... is ambiguous. The matrix is now a read-only array whose==returns one bool; indexing,@and ufuncs are unchanged (np.equalstill compares elementwise). No numbers change. CausalResult.to_latex()emitted LaTeX that does not compile. Cells, headers and notes went out unescaped: a matching result'spropensity_scorerow andmean_treated/mean_controlheaders haltpdflatexon the bare_, and the star legend'sp<0.1typesets asp¡0.1under the default OT1 encoding. Escaping now runs over cells, headers and notes (a caller-suppliedcaption=still passes through verbatim).tests/test_latex_escaping.pycompiles the output with a real TeX engine when one is installed.- Five copies of the LaTeX escaper had drifted (
output/estimates.py,synth/exports.py,synth/report.py,did/report.py,_result_serialize.py); two omitted\, none covered</>. All now delegate tooutput._format.latex_escape. - The generic
Field | Valuetable (ResultProtocolMixin.to_latex) had three LaTeX faults: escaping covered only_ % &; booleans rendered as the amsmath macro\text{True}(undefined without amsmath); and the generated caption (the class name) was raw. Multi-line values are flattened and truncated to 120 characters. - The star legend printed
p<0.00forstar_levels=(0.05, 0.01, 0.001); thresholds now widen only as far as they need (p<0.001). sp.etable([m1, m2])returned an empty table, silently. A lone list is unwrapped foretable/modelsummary/esttab/outreg2, and theTypeErrorfor an argument that cannot be tabulated now names its position and type and says how to recover.- The registry example for
sp.matchcould not run (treatment=/outcome=instead oftreat=/y=). A new audit,scripts/registry_example_audit.py(gated at zero findings bytests/test_registry_example_audit.py), parses every registryexample=and checks its keywords against the real signature, through@accepts_aliases.
[1.32.0] — 2026-09-27¶
Added¶
-
sp.mi_test(pooled, terms, method="equal_fmi"|"unrestricted", small=True)-- joint Wald test aftersp.mi_estimate, Stata'smi test(equal fractions of missing information, the default) andmi test, ufmitest, with Stata's default small-sample denominator df: Reiter (2007) for the equal-FMI test, Barnard & Rubin (1999) / Marchenko & Reiter (2009) otherwise;small=Falseismi test, nosmall. Matches Stata 18 to 1e-9 in F, df and p on 326 configurations crossing complete-data df (396 / 26 / 11 / 3), imputations (2--8), both tests, 1--4 tested terms and both df modes (tests/reference_parity/test_mi_test_parity.py). Two places where Stata departs from the Marchenko & Reiter paper are followed and documented: the large-sample part of the small-sample df is built on the within-imputation RVI, and the equal-FMI branch switches onk(M-1) <= 4. Reiter's df outside its range (complete-data df tiny relative to the missing information) reproduces Stata but warns (AssumptionWarning). The result carriesdfcomanddf_adjustment.mi_estimate/MICEResult.combinenow also return the full within and between covariances (ubar_matrix,b_matrix). -
sp.result_card(result)/result.result_card()-- one auditable, JSON-safe card per fit: estimand (label, scale, control group / aggregation), sample (rows used vs rows passed, recorded exclusions such as the missing-cluster markout, clusters / units / periods), specification (formula, weights, offsets, seed, exact call arguments), inference (covariance convention, t(df) or normal, CI level, whether the full covariance is available), provenance (versions, data hash, run id, backend), evidence (the configuration x output map ofsp.validation_scopewhere one exists; otherwise the function-level tier, explicitly labelled as not covering this configuration), declared identifying assumptions with the diagnostics actually run, and limitations. MCP tool responses now carry the same object asresult_card(omitted atdetail="minimal"). SurveyDesign.calibrate(margins= | totals=, variance="greg"|"stata")-- a calibrated design whose standard errors account for the calibration: every estimator's linearisation scores are residualised on the calibration variables before the design variance.variance="greg"(default, the g-weighted residual variance of Särndal-Swensson-Wretman) matches Rsurvey::calibrateforsvymean/svytotal/svyglm(gaussian and quasibinomial) to 1e-15;variance="stata"matches Stata 18svyset, rake()/regress(). Estimates of the calibration totals have zero variance.sp.rake/sp.linear_calibrationstill return weights only (test_survey_calibrated_design_parity.py).- Survey domain estimation:
subpop=onsp.svymean/sp.svytotal/sp.svyglmand theSurveyDesignmethods (Statasvy, subpop(), Rsubset(design, ...)): estimates on the domain rows, variance over the whole design with zero scores outside it. Estimates and SEs match Rsurveyand Stata 18 to 3e-14, including a domain with no members in whole strata and a domain of a calibrated design. The design df follows Stata by default (strata without members dropped, every PSU of the rest counted);subpop_df="r"counts only PSUs with members, as Rdegf.svyglmtreats rows with a missing formula variable the same way, which also fixes a misalignment of weights and scores when patsy dropped rows (test_survey_domain_parity.py). sp.rdrobust(weights=)-- observation weights as Rrdrobust(weights=): multiplied into the kernel weights of every local regression (all three bandwidth-selector stages, conventional and bias-corrected fits, leverages, sandwich). Conventional and robust estimates, both SEs and h agree with R rdrobust 4.0.0 to <= 1e-12 across sharp / fuzzy, covariates, clustering, hc1 / hc3, msetwo / cerrd / msecomb2, p = 2, uniform kernel and a fixed bandwidth (test_rd_weights_parity.py);bwselect="cct"forwards them to the official port. The weightedrbcbootstrap is refused.sp.validation_scopegains aweightsdimension forrdrobust.sp.marginshandles factor variables and formula transformations.C(g)gets Stata's discrete change of each level against the base level (rows"2.g","3.g", included in the defaultdydx(*));I(x**2),np.log(x)and other patsy transforms are differentiated through by rebuilding the design from the fitted formula.margins,margins_at,contrastandpwcompareshare one context: averages over the estimation sample (Statae(sample)) with the fit's weights, offsets / exposure in the prediction,atmeansas products of component means, t(df) or z as the fit.margins_at/contrast/pwcomparenow work after logit / probit / poisson on the response scale (Pr(y=1), expected count) instead of refusing. New Stata 18 fixture (test_margins_ext_parity.py): factor / quadratic / weighted / exposure margins after regress, logit, probit, poisson and glm agree to 1e-8 (linear) / 1e-7 (ML). The five documented Stata defects oftest_r2_postest_parity.py(D1-D5) are fixed and assert Stata's numbers.sp.mice: proper'logreg'and new'polyreg'for categorical variables. Two-level variables get logistic imputation with the parameters drawn from their asymptotic posterior (Rmice.impute.logreg); unordered categoricals get a multinomial logit with posterior draws; categorical columns enter the other equations as dummies. A model that cannot be fitted is reported (ConvergenceWarning,result.fit_failures) instead of silently falling back. On a MAR design the new default recovers a categorical effect of 1.0 (mean 0.999, 95 % CI coverage 98 %); the old'sample'default returned 0.37 with 5 % coverage (test_mice_categorical.py).sp.recommendcards say whether they can run. Every card carriesready/missing_arguments(required parameters of the target function the data did not determine) and, when the design is not identified from the inputs, ablockedreason with a fix;.run()refuses a non-ready card with that message instead of an estimatorTypeError. Newrecommend(subgroup=, treat_time=, shares=, shocks=). Repeated cross-sections with more than two periods buildpost = 1[time >= treat_time]explicitly for a pooled 2x2; a treated x post switch indicator without a unit id is reported as not identified.sp.bartikaccepts array shares / shocks;sp.dgp_bartik(endogenous=True)andsp.dgp_did(ddd=True)provide structural shift-share and triple-difference DGPs.sp.recommend_benchmark()reports end-to-end success (acceptable top-1 and fitted and audited, over all cases) and the stage at which each case failed; the CI gate now also fails on frontier fit / audit errors. Corpus1.1.0-fifty: 50/50 end to end (was 46: four frontier cases recommended the right estimator and then could not run). The corpus is now shipped in the wheel, so the benchmark works afterpip install.sp.feolsabsorbs varying slopes (f[x],f[[x]],f[x1, x2], Statai.f#c.x). pyfixest parses the syntax but drops the slope, so these formulas now run on StatsPAI's own HDFE kernel and say so (model_info["backend"] = "statspai-native"). 24 configurations (one and two absorbed dimensions, slope-only, two slopes, iid / hetero / cluster / weighted cluster) agree with R fixest 0.14.0 to <= 1.6e-9 (test_feols_varying_slopes_parity.py). With only slopes absorbed the intercept is kept and reported, as fixest (Statareghdfe absorb(i.f#c.x)omits it;sp.hdfe_olskeeps that convention). Pyfixest-backed fits now recordbackend,backend_versionandimplementationinmodel_info.sp.verify_replication_pack(path)unpacks an archive into a fresh directory, checks every file against its SHA-256, rerunscode/script.pyin a subprocess and compares every recorded estimate and SE (results/results.json, new in each pack) with the rerun's -- statusverified/mismatch/rerun_failed/integrity_failed/incomplete_pack.sp.replication_pack(strict=True)refuses to write a pack that a third party could not rerun (no code, data or environment, or any failed step).MANIFEST.jsonnow also records aruntimeblock: optional backends (Rust HDFE kernel, numba, JAX, pyfixest, ...), thread environment variables and the BLAS numpy links -- things that change the code path or summation order without changing the code.RecommendationResult.identification: the assumptions the recommended design needs that no data can verify, the checks that bear on them (labelled as not proofs), the questions to ask before a causal claim, whether the design was declared or only inferred from the data's shape, and the level of claim it supports ("descriptive_only"when no treatment is given). Shown insummary()and returned by MCP.sp.causal_question(...).estimate()keeps the declaration binding. The result recordsdeclared_estimandanddeviations(declared vs delivered estimand when the design falls back, estimation rows vs declared rows, estimator keywords overridden atestimate()), warns on an estimand fallback, andestimate(strict=True)refuses one.EstimationResult.nis the estimator's own sample size (it waslen(data), over-reporting n whenever rows were dropped).sp.support_tier(name)(also indescribe_function, the result card and the capability table): core (stable, numerical evidence), extension (stable, contract-tested, no numerical evidence) or research (experimental, or a frontier module whose output is heuristic) -- derived fromstabilityandvalidation_status, never stored.docs/capabilities.mdis generated from signatures and the registry (scripts/build_capability_table.py, drift-tested): weights, clustering, variance options, backend, support tier, evidence tier and whether a configuration-level evidence map exists, for the flagship estimators.- Method cards for frontier modules. TARNet / CFRNet / DragonNet /
CEVAE / GNN / DeepIV and the LLM tools (
llm_dag_*,llm_unobserved_confounders,llm_sensitivity_priors,llm_causal_assess,causal_mas) now state in their registry limitations -- and hence in every tool description and result card -- that they have no numerical evidence (and, for LLM output, that it is a proposal, not evidence). - End-to-end workflow tests (
tests/test_flagship_workflows.py): six flagship workflows from a CSV on disk to a publication table -- panel / HDFE (weights, clusters with missing values, varying slopes, strict replication pack verified by rerun), DiD (event study, uniform bands, HonestDiD), IV (weak-IV-robust CI), weighted RD with a density test, DML with the identification brief, and survey + MI (categorical imputation, calibration). sp.validation_scopegrades joint outputs. Two output types join estimate / se / coverage / diagnostic:vcov(every entry of a reported covariance matrix -- what joint tests, sup-t bands and HonestDiD consume) andjoint_test. Rows exist only where an artifact compared them: the Callaway-Sant'Anna dynamic event-study covariance vs Rdid(dr / never-treated / analytic), and the regress joint Wald F vs Statacontrast(classical / HC1 / CR1). Anserow never vouches for either.- Workflow benchmark (
benchmarks/workflow_bench/): HDFE OLS (unbalanced), IV-HDFE, PPML with separation, staggered DiD, DML with repeated folds and the wild cluster bootstrap at 100k / 1M rows against pyfixest, DoubleML and R fixest / did, each in its own process with cold start, warm median / IQR, peak RSS and failures, estimates compared before times. Slower rows are reported (PPML and IV-HDFE at 1M rows: R fixest is fastest). docs/guides/research_tasks.md: ten task-oriented entry points (Stata migration, HDFE, staggered DiD, weak IV, RD, selection on observables, survey + MI, margins, tables / replication, agents), each with the options that change the answer and how to check evidence for the configuration used.sp.dml(treat=[d1, d2, ...]): several treatments with their joint covariance (PLR). Each treatment is fitted with the others as controls on one cross-fitting split, as DoubleML's multi-dPLR; the result is anEconometricResultswith one coefficient per treatment and the covariance across treatments from the stacked orthogonal scores, sosp.test/lincomgive joint inference. Coefficients, SEs and the full covariance equal Python DoubleML's (from its ownpsiarrays) to 3e-15 on shared folds (test_dml_multi_treatment_parity.py). IRM / IIVM, clusters, weights andn_rep > 1are refused for now.sp.poisson/sp.nbregacceptC()/I()/ interaction formulas (patsy design, intercept kept as_cons); plain formulas take the old path unchanged.- MCP data loading samples huge files in one streamed pass.
data_sample_non a file overSTATSPAI_MCP_MAX_DATA_BYTESnow draws the sample chunk by chunk (.csv/.tsv/.txt/.parquet/.jsonl/.dta) instead of being rejected with advice that could not work; Parquet is checked against its uncompressed (projected) size. sp.read_datareads.dtawithout pyreadstat, keeping variable and value labels via pandas; newioextra (pip install statspai[io]) for pyreadstat.sp.pwcorr(listwise=, obs=)and per-pair N / p-values inoutput="dataframe"(.attrs["nobs"],.attrs["pvalues"]).- Provenance (
result._provenance) is now recorded bysp.logit,sp.probit,sp.cloglog,sp.poisson,sp.nbreg,sp.glm,sp.feols,sp.fepois,sp.feglm(new decoratorrecords_provenance). sp.panel(ssc="stata" | "fixest"): standard errors on Stata's or fixest's small-sample convention. The default keeps linearmodels' own scaling, which matches neither package once SEs are robust or clustered (a factor of up to 1.40 in the variance on the test panel)."stata"reproduces the command each method corresponds to --xtreg, fe,xtreg ... i.t, fe,regress,regress D.y D.x, nocons,xtreg, re,xtreg, be-- includingG/(G-1)*(N-1)/(N-K)with the unit effects uncounted when the cluster nests the unit (andareg's full count when it does not), t(G-1) inference (z for RE), and Stata's rule thatvce(robust)afterxtregclusters on the panel variable."fixest"reproduces R fixest's defaultssc()forfe/twoway/pooled/fd. Coefficients, SEs and the reference distribution match Stata 18 and fixest 0.14 to 1e-14 on an unbalanced panel with unit, state and period clusters and analytic weights (tests/reference_parity/test_panel_ssc_stata_parity.py). The full covariance is now stored on everysp.panelresult, sosp.lincom/sp.testwork after it.-
sp.qreg(cluster=): cluster-robust quantile-regression SEs (Parente and Santos Silva 2016 [@parente2016quantile]; refs verified via Crossref and the Essex research repository). Alsovce="cluster <var>"andvce="kernel"(the heteroskedasticity-robust Powell sandwich), withkernel_scale="mad" | "silverman". Official Stataqreghas no cluster option; the reference isqreg2, the authors' own module, and the full covariance matrix matches it to 1e-13 at quantiles 0.25-0.9, with string and partly missing cluster ids, together with its t(N-k) p-values and intervals (tests/reference_parity/test_qreg_cluster_stata_parity.py). -
sp.aipw(weights=, cluster=). Sampling weights (weighted logit and per-arm WLS, weighted mean of the AIPW scores) and cluster-summed standard errors, on both the cross-fitted influence path and the parametricse_method="sandwich"path. Against Stata 18teffects aipwthe ATE, both potential-outcome means and all their SEs agree to 1e-14 with clusters, weights and both.teffects aipwrefuses[pw=]; its[iw=], vce(cluster)sandwich is the sampling-weight one and pinsweights=, while[iw=], vce(robust)treats the weights as frequencies (effective N = sum of w) -- a different quantity, reconstructed exactly in the test so the gap is documented (tests/reference_parity/test_teffects_design_stata_parity.py). Unweighted, unclustered calls are unchanged to the last bit. sp.ipw(weights=, cluster=, se_method="sandwich"). Sampling weights, a cluster bootstrap, and the deterministic M-estimation sandwich ofteffects ipw; ATE and ATET agree with Stata 18 to 1e-13 across unweighted /[pw=]/vce(cluster)/ both. The default bootstrap is unchanged.sp.heckman(method="ml"): full-information maximum likelihood, the estimator Stata'sheckmanfits by default (the existing two-step stays the default here). Parameterised as Stata does (athrho,lnsigma), withvce="robust",cluster=andweights=([pw=], which imply robust SEs). Outcome and selection coefficients,athrho,lnsigma,lambdaand every SE agree with Stata 18 to 1e-15 / 1e-11 across all five variance / weight combinations. The two-step estimator refuses robust SEs and weights, as Stata'stwostepdoes.sp.tobit(vce=, cluster=, weights=). Robust and cluster-robust standard errors (Stata'sN/(N-1)andG/(G-1)) and sampling weights, matching Stata 18tobitto 1e-10 for left- and two-sided censoring.sp.dml(cluster=)for all four models (PLR / IRM / PLIV / IIVM): cross-fitting folds over whole clusters and the Chiang-Kato-Ma-Sasaki (2022) one-way cluster variance, as DoubleML computes it onDoubleMLClusterData; estimate and SE agree with DoubleML to 1e-15 on shared folds (test_dml_cluster_doubleml_parity.py).weights=is now an alias ofsample_weight=.sp.tmle(weights=, cluster=)-- Rtmle'sobsWeights=(weighted Super Learner fits, fluctuation, plug-in and influence function) and a centred cluster-sum influence-function variance withG/(G-1), equal totmle'sid=variance for equal-size clusters; binary and continuous outcomes agree with Rtmle2.1.1 to 1e-9 across all four combinations (test_tmle_design_R_parity.py).SuperLearner.fittakessample_weight(RSuperLearner'sobsWeights).sp.match/sp.psm(weights=)are frequency weights, Stata's[fw=]-- the only weightteffects nnmatch/psmatchaccept -- and agree withteffects psmatch [fw=]to the existing module-11 gap (2e-7). Sampling weights andcluster=are refused with the weighting estimators that support them as alternatives: no matching estimator has a checked variance for either.sp.metalearner(weights=, cluster=)for the AIPW average behindestimate/se(weighted cross-fit nuisances, cluster-level folds, centred cluster-sum variance); the CATE fit stays unweighted. Checked analytically (unit weights and singleton clusters reproduce the old numbers; the estimate equals a hand-built weighted cross-fit AIPW).- Formula-first calls on data-first estimators.
sp.qreg,sp.panel,sp.panel_compare,sp.robustness_report,sp.subgroup_analysisandsp.spatial_paneltake(data, formula), unlikesp.regress/sp.iv/sp.feols;sp.qreg("y ~ x", data=df)died with "multiple values for argument 'data'". A string in the first position is now read as the formula; existing calls and signatures are unchanged. - House-style aliases:
sp.callaway_santanna(cluster=)(forclustervars=) andsp.lp_did(id=, treat=, covariates=).
⚠️ Correctness¶
sp.qregp-values and intervals use t(N - k). They were referred to the normal distribution for everyvce; Stataqreg/qreg2and Rquantreg::summary.rqall use t with the residual degrees of freedom. Coefficients and SEs are unchanged; p-values and 95% intervals widen slightly (by the t/z ratio, 0.2% at N = 1,200). Now pinned against Stata 18qreg(vce(iid)/vce(robust), three quantiles) and quantregse = "nid"(test_ldv_design_stata_parity.py). The results table gains atcolumn;zis kept with the same values for compatibility.sp.panel(method="mundlak" | "chamberlain")random-effects variance components follow Stata. linearmodels computes sigma_e and sigma_u with degrees of freedom from the column count, and the unit-mean columns the CRE designs add are swept out by the within transformation, so they inflated it (N - G - 4instead ofN - G - 2with two regressors). theta moved, and with it the coefficients on the means and the constant (4e-4 relative on the test panel); the regressors' own coefficients, which equal the FE estimates, did not. theta now comes from the fit without the means, whose rank is its column count; both CRE designs match Stataxtreg, rewith the terms added to 1e-12. The corrected fit reproduces linearmodels'RandomEffectsattribute for attribute on a design without mean columns (tests/test_panel_cre_theta.py). The Mundlak Wald test now uses the reported covariance, so it followsssc=too.sp.panelhonoursweights=and a namedcluster=column. The linearmodels path (method='fe' | 'twoway' | 'pooled' | 're' | 'be' | 'fd') droppedweights=without a word, andcluster=naming any column other than'entity'/'time'/'twoway'clustered on the entity whilemodel_info['cluster']reported the requested column (a state-clustered SE of 0.157 came back where the correct value is 0.214). Weightedfe/twoway/pooledfits now matchsp.feols(weights=)(coefficients 1e-15, robust SE 1e-10); a named column is clustered on. Weights withre/be/fd/ CRE / GMM, and a non-entity cluster with GMM, raiseMethodIncompatibilitybecause no reference defines them (Stataxtreg, rerejects weights).sp.did_summary(cluster=)reaches the Callaway--Sant'Anna row. Every other method receivedcluster=; CS kept unit-clustered analytic SEs, which understated the state-clustered SE by 2.3x in the test design. Clustering on a variable other than the unit now uses CS's multiplier bootstrap withclustervars=[unit, cluster](seed 0), the same restriction as Rdid::att_gt(clustervars=).sp.pwcorrdeletes missing values pairwise, as Stata'spwcorr. It dropped every row with a missing value in any listed variable, so a third variable's gaps changed the correlation of two complete variables (0.0 instead of Stata's 0.7505 on the review's example). Matches Stata 18pwcorr, sig obs;listwise=Truegives the old sample.sp.marginsafterpoisson/nbreg/glmwithexposure=/offset=now includes them in the prediction (Stata's defaultpredict()is the number of events). The offset was read frommodel_infoonly, where these fits do not store it, so the AME was off by the mean exposure (0.387 vs Stata 1.189 on a test design).sp.marginsfamily averages as Stata does: over the estimation sample (rows ofdata=outside it -- missing outcome, marked-out cluster -- used to be averaged in), with the fit's weights (ignored before), andmethod="mem"with factor variables at their shares (it evaluated every dummy at the base level).margins_at/contrast/pwcompareuse t(df) afterregress(was N(0, 1)); the Sidak adjustment no longer cancels to 0 for p < 1e-17; anat=variable outside the model raises (Stata r(322)) instead of being ignored.- The
rdrobustregistry limitation claiming the defaultmserdselector "can differ from rdrobust::rdrobust" was out of date (the native CCT cascade matches R on the default path, Track A 06); it now points to the configurationssp.validation_scopeenumerates. sp.rdrobust(fuzzy=..., bwselect="msecomb1"|"msecomb2"|"cercomb*")selected the sharp bandwidth: the comb recursion did not forward the first stage. On the test design h was 0.18 vs R's 0.32 and the robust estimate 0.29 vs 0.56. Now equal to R rdrobust to 1e-12.sp.from_stata/sp.statatranslate factor notation and weights instead of pasting them into the formula.reg y x i.g [aw=w]used to come backok=Trueasy ~ x + i.g + [aw=w]. Nowi.g->C(g),ib3.g->C(g, Treatment(3)),c.x#c.x->I(x**2),a##b->a + b + a:b;[aw=]->weights=,[pw=]->weights=plus robust SEs (as Stata),[fw=]refused with the exact row-expansion code,[iw=]refused;poisson/nbregkeepexposure()/offset(); time-series operators (L.x) are refused instead of mistranslated. Each translation lists the conventions it relies on undersemantics. Translatedregress(pweights, factor interaction; aweights, quadratic) andpoisson, exposure()reproduce Stata 18 (test_stata_translation_semantics.py).sp.feolsintervals and joint tests use fixest's degrees of freedom. The pyfixest adapter set the residual df to N - k with k the regressors only (fixest: N - K counting the absorbed fixed-effect levels) and ignored clustering, so every interval was too narrow -- by a small amount without clusters, by more with them (t(N - K) instead of t(G - 1)). The coefficient covariance was not stored, sosp.test/lincom/marginsrefused multi-coefficient restrictions; it is now kept and the joint test matches Rfixest::wald(F(2, 19), p = 1.96e-13, where the old df would have given 5.6e-55). Coefficients, SEs and coefficient p-values are unchanged (test_feols_inference_df.py).-
sp.micedefault for non-numeric variables is'logreg'/'polyreg'instead of'sample', and'logreg'is now a proper imputation. Imputed values -- and pooled estimates -- change for any data with categorical or binary variables; see MIGRATION.md. -
sp.iv/sp.ivreghonourweights=. Both accepted the keyword and dropped it, returning the unweighted estimate (4.238 weighted vs 3.547 reported on a design where the effect varies with the weight). Weights are now analytic weights (Stata[aw=]) on 2SLS / LIML / Fuller / GMM / JIVE; the coefficients, classical / robust / cluster SEs and weighted R² match Stata 18ivregress ... [aw=w], smallto 1e-14 (tests/reference_parity/test_iv_weights_stata_parity.py, fixture_generate_iv_weights_stata.do).absorb=withweights=and the refit-based IV SEs (CR2 / CR3, two-way, Conley, wild bootstrap) refuse a weighted fit rather than report an unchecked number. alpha=sets the reported interval on 31 estimators.logit,probit,poisson,nbreg,zip_model,zinb,hurdle,ppmlhdfe,glm,fracreg,betareg,mlogit,ologit/oprobit,clogit,truncreg,biprobit,etregress,iv,liml,jive,gmm,panel_fgls,panel_logit/panel_probit,interactive_fe,frontier,zisf,lcsf,cox,survreg,ivqreg,twoway_cluster,conley,jackknife_se,cr2_se,shift_share_setookalphaand still stored, returned fromconf_int()and printed 95% intervals. The fit-time level is now the default ofconf_int(),tidy()andsummary();alpha=0.05output is unchanged.EconometricResults.summary(alpha=)printed the wrong interval. It relabelled the columns ([0.050 0.950]) but printed the 95% bounds.sp.synth(method="mc", covariates=...)used the covariates. The dispatcher dropped them (ATT 0.82 without vs 2.73 with on a design whose covariate confounds; true effect 2).sp.synth(method="augmented", placebo=False)no longer runs the placebo loop; the dispatcher did not forwardplacebo. ASCM inference is that loop, so the SE / p-value / interval are now NaN, with a warning.sp.synth(method="classic", standardize=False)stops standardizing. The parameter isstandardize_predictors;standardize=was dropped, so predictors were standardized regardless.standardize=is now an alias.sp.synth_report(n_donor_samples=, sensitivity_seed=)reach only the sensitivity analysis instead of also being passed to (and dropped by)sp.synth.sp.qregstandard errors are Stata's by default. The docstring said "equivalent to Stata'sqreg", but the SE was a Silverman-bandwidth Gaussian-kernel iid sandwich that matched neither Stata nor quantreg (2-8% off on the new fixture, 3.0% / 7.3% on Track A). The default is now Stata'svce(iid)(fitted-quantile sparsity, Hall-Sheather bandwidth);vce="robust"is Stata'svce(robust)andvce="nid"is quantreg'ssummary(se="nid"). All three agree with their reference to 1e-14 at tau = 0.25 / 0.5 / 0.75. Coefficients are unchanged;vce="powell"reproduces the old SEs. Track A40_qregnow runsvce="nid": 1e-15 against quantreg and 2e-8 against Stataqreg, vce(robust), and its SE budget goes from 1e-1 to 1e-6.sp.tobitreaches the optimum. BFGS stopped atgtol=1e-6and the SEs came from a second-difference Hessian; a Newton polish on complex-step scores (thetruncreg/biprobitpath) moves coefficients by ~1e-6 and SEs by up to ~5e-5 (relative). Track A module41_tobitgoes from 2.0e-6 to 3.4e-11 (RcensReg) / 1.1e-11 (Stata); its registered SE budget is tightened from 1e-5 to 1e-6.
Performance¶
- Wild cluster bootstrap no longer scales memory with n x B. The
restricted wild bootstrap engine behind
sp.wild_cluster_bootstrap,sp.wild_cluster_bootand the subcluster bootstrap now uses the boottest algebra (every draw is linear in its cluster weights, so the loop touches only B x (G_boot + G_err) numbers): at n = 100k, 40 clusters, B = 999 it takes 0.02 s / 195 MB instead of 0.64 s / 2.7 GB, with the same p-values and intervals (to 1e-14). -
sp.fast.fepoiswithout the Rust extension is 1.6x faster: the IRLS fallback now uses the shared fused numba weighted sweep of the HDFE absorber (it re-implemented it withbincounttemporaries) and warm-starts each iteration's alternating projections from the previous fixed-effect component (the fixed point is unchanged). 1M rows, two FEs: 1.42 s -> 0.89 s, same estimates and SEs to 1e-15. -
IV, LIML, quantile regression and RD no longer need O(n^2) memory. Several kernels formed
n x nprojections ordiag()matrices:sp.iv(every method) used 2.9 GB at n = 8,000 and ran out of memory near 15,000;sp.liml10 GB at 20,000;sp.rdrobust7 GB at 80,000. Projections are now applied to vectors and sandwiches weight rows, sosp.iv(..., cluster=)fits n = 1,000,000 in 0.5 s andsp.limln = 200,000 in 0.03 s. The sameX' diag(v) Xpattern was removed fromsp.did(2x2),ddd,its,bartik,spec_curve,robustness_report, the JIVE variants, many-weak IV, peer effects,rkd, the honest RD CI and the OLS HC2 / HC3 path. Results change only in the last floating-point digits; all Stata / R parity tests pass unchanged. sp.qreguses HiGHS interior point with crossover. It returns the same vertex solution as the simplex (Stata / R parity unchanged at 1e-14) about 45x faster at n = 100,000. The oldmaxiter=5000simplex cap failed from n ~ 20,000 and silently dropped every fit to an IRLS fallback that itself formed ann x nmatrix (35 s / 6.6 GB at 20,000; now 1.8 s / 0.25 GB).- Kleibergen-Paap robust statistic vectorised (a per-row Python loop
took 1.6 s at n = 100,000 inside every robust
sp.ivfit). sp.gardner_did(did2s) fits large panels. The first stage built a dense unit x time dummy matrix and solved it by SVD, which failed to converge at 20,000 rows (2,000 units). The design is now sparse and both the first stage and the GMM correction term are solved through sparse normal equations: 200,000 rows take 0.15 s. Rdid2sparity is unchanged.- Nearest-neighbour matching with replacement no longer builds the full
treated x control distance matrix. Distances are computed in blocks
sized to about 16M cells, and each target's k nearest are preselected
with a partial sort before the tie-aware ordering, which picks the same
matches.
sp.psmat n = 100,000 went from 167 s / 1.9 GB to 9.7 s / 0.6 GB;sp.matchwith Mahalanobis distance benefits the same way.
Changed¶
sp.panel(method="fd", cluster="time")fails with an explanation. linearmodels cannot cluster first differences on a variable that changes within a unit and raised a rawValueError; it now raisesMethodIncompatibilitypointing tossc="stata", which computes it (Stataregress D.y D.x, vce(cluster t)).- Canonical keyword spellings on 174 more functions. Functions whose
parameters used
unit/entity/i/panel_id,outcome,treatment,controls/covs,t/time_col,cluster_var,sample_weight/weightordfnow also accept the house-styleid,y,treat,covariates,time,cluster,weightsanddata(all the synth, RD-covariate, matching diagnostics, bounds, DiD helper, target-trial, transport, LLM-causal, production-function and robustness entry points). The old spellings keep working; passing both raisesTypeError.tests/test_house_style_alias_contract.pykeeps it that way, with four documented false friends (esttab(t=),dyadic_regression(i=)and the spatialunit='km'helpers). - MCP
data_sample_nreturns different rows. One rule for every path: row i gets the i-th draw ofdefault_rng(0).random, the smallestdata_sample_nkeys are kept, in file order -- so the rows no longer depend on whether the file happened to fit under the byte cap. Recorded assample_methodindata_provenance. sp.marginsdefaultvariablesincludes factor variables (Statadydx(*)); rows ofdata=with a missing model variable are dropped (e(sample)) with a warning when the count differs from the fit'snobs, instead of raising.sp.did_analysisand the robustness report no longer describe a non-rejected pre-trend test as "no evidence of pre-trend violation"; they say it does not reject and that a non-rejection is not evidence that parallel trends hold, pointing tosp.pretrends_power/sp.honest_did.-
did_multiplegt_dynresult labels anddocs/guides/stability.mdnow say what is implemented (controls=,trends_nonparam=,normalized=,continuous=) and what is not (trends_lin,predict_het); the repeated-cross-section entry forcallaway_santannalists dr / ipw / reg, both control groups and the bootstrap.docs/recommend_benchmark.mdreports the 50-case corpus with denominators. Thebit-exactparity grade is described as a <= 1e-6 tolerance grade, not bitwise equality. -
Unknown keywords raise instead of vanishing on
sp.iv(k-class path),IVRegression.fit,sp.synth(method="classic"|"penalized"),sp.synth(backend="r")andsp.augsynth.sp.ivreg(small=, iv_diag=)were whitelisted and then ignored; they now raise until implemented. sp.synth_comparereports the methods it skips. A failing method used to be dropped with no trace; it now emitsWorkflowDegradedWarningand is listed incomparison.degradations. A misspelled option raises instead of silently emptying the table, and the call raises if every method fails.- Listwise deletion is announced. Estimators that drop rows with a
missing value in a variable they use now emit one
AssumptionWarningnaming the count per column and recordmodel_info["listwise_deletion"], as R prints "observations deleted due to missingness". It fires only when the reported N equals the input rows minus the incomplete ones, so imputation, bandwidth or subsample estimators stay silent.sp.ivnow attaches provenance. -
Non-finite standard errors warn.
EconometricResultsemits aConvergenceWarningand recordsmodel_info["nonfinite_se_terms"]when an SE is NaN / inf (a parameter at its boundary, separation, an unidentified term) instead of printing NaN silently. -
The partially linear DML estimate is labelled
theta, notATE.sp.dml(model="plr"),sp.DoubleMLPLRandsp.dml_model_averagingreportestimand == "theta", sosp.regtableprints athetarow. The PLR parameter equals the ATE only under a constant effect: with heterogeneous effects of a binary treatment it is a variance-weighted average, and with a continuous treatment (the JSS article's years of schooling) a partial coefficient. Estimates are unchanged. See MIGRATION#plr-theta-label. sp.cate_summarynames its first rowMean, notMean (ATE). It is the plain average of the fitted conditional effects, which is neither the estimator's doubly-robust average nor, for a continuous treatment, an ATE.
Parity¶
- The original-data RD row runs StatsPAI's own selector.
tests/orig_parity/05_lee_original.pycalledsp.rdrobust(bwselect="cct"), which delegates to the official rdrobust Python port, so the row printed in the JSS article (agreement 3.5e-16) compared the method authors' code with itself. It now runs the native default and agrees with Rrdrobustto 3e-14. Found by extending the call-trace audit to the ledger:scripts/trace_parity_provenance.py --ledger origwritestests/orig_parity/results/_implementation_trace.json, andtests/test_orig_parity_native_contract.pyholds the twelve modules to it (seven native; five NHEFS modules fit their propensity or outcome regression with statsmodels, registered incompare_orig.ORIG_IMPLEMENTATION_PROVENANCEand marked in the table). tests/r_parity/renv.lockrecords every reference package. Fifteen packages that modules 72-89 load (did2s,interflex,staggered,DIDmultiplegt0.1.4,DIDmultiplegtDYN, ...) were missing from the lock generator's list, andrdrobustwas pinned at 3.0.0 while the goldens were produced by 4.0.0. The lock is regenerated from the reference library (354 packages), all 89 R modules re-verified at 1e-9 in it, andtests/test_r_lock_covers_references.pyscans every R parity script so the list cannot fall behind again.- The Track A forest row's verdict reads "one draw (S); T3 by seed study": the single draw is a stochastic screen, the module's T3 grade rests on the seed-replicated study.
Fixed¶
tests/r_parity/verify_reproduce.pyused a backslash inside an f-string expression, which needs Python 3.12; the Tier 2 reproduction path failed with aSyntaxErrorunder the 3.10 lock environment.- The JSS release gate also requires
MIGRATION.mdto carry no## Unreleasedsections (1.31.0 shipped eleven). -
The Track B caption quotes the exact binomial 99% acceptance region for a 95% interval at B = 1,000, [0.931, 0.967], computed by the table generator, instead of a hand-typed "Wilson band" [0.935, 0.967] that no interval formula produces.
-
sp.read_data(".dta")raisedModuleNotFoundErrorwithout pyreadstat: the import sat outside thetrythat guarded the pandas fallback. sp.rdrobust(cluster=)crashed with anIndexErrorwhenever a row had a missing y or x (the cluster ids were not filtered with the data; the donut filter was misaligned the same way, also in thebwselect="cct"path). Rows with a missing cluster or weight are now incomplete cases, as in R.sp.feols/sp.fepois/sp.feglmmark out rows with a missing cluster value (warning,model_info["n_missing_cluster_dropped"]) like every other estimator and Statavce(cluster); pyfixest raised.sp.logit/sp.probit/sp.cloglogresults can be pickled (a closure attribute made them unpicklable, breaking the MCP result cache and replication packs).- MCP data loader: the over-cap error advised
data_sample_n, which could not help because the cap was checked before sampling. sp.marginsafterirr=Truecount fits uses the index-scale covariance.
[1.31.0] — 2026-09-26¶
Added¶
sp.rate_splitandsp.forest_policy_treenow aggregate many splits (n_splits=21) instead of reporting one. A literature check turned up the procedure for this rather than a gap: [chernozhukov2025generic] define variational estimation and inference (VEIN) precisely for the problem these two functions had, and warn that "with a single splitting practice, empiricists may unintentionally look for a 'good' data split, which supports their prior beliefs about the likely results, thereby invalidating inference" -- which is what shipping a single split withrandom_statein reach invites.- The estimate is the median over splits, the interval is the median of
conditional intervals built at
1 - alpha/2(whose median covers at1 - alpha), and the p-value is twice the median conditional p-value.estimate_min/estimate_max/estimate_iqrreport how far the split was moving the answer.n_splits=1reproduces the old behaviour and warns; 21 costs about twelve seconds on a 150-unit panel, 100 (as they use) about a minute. - On the guide's 15-country trade panel this changes the conclusion. Single splits ranged over -0.075 to +0.051 and seed 0 alone read as a significant negative AUTOC, 95% CI [-0.133, -0.017]. The VEIN default reports +0.0001, 95% CI [-0.0566, +0.0492], p = 0.54 -- nothing, which is what is there.
- A rule cannot be averaged, so
forest_policy_treereports the tree from the median-gain split plusdiagnostics['split_stability']: how often each covariate was chosen at the root, and the spread of thresholds and treated shares. A tight interval on the value of a rule whose root covariate changes between splits is not evidence for that rule. - Each feature is aggregated on its own, as they do, so with
n_splits > 1the three policy-tree medians do not satisfygain = value - value_treat_all. That identity holds within a split;n_splits=1reports it directly. Stated rather than left to be discovered. -
Two failure modes that only appear once many splits are taken, both found by running it: a partition that leaves the evaluation half with no imputable treated cell is inadmissible rather than a draw, and would otherwise have destroyed the whole call (one of 21 does on the trade panel) -- those are skipped, counted in
n_splits_skippedand warned about; and a split whose dyadic variance goes non-positive contributes an estimate but no interval, so the medians are taken nan-aware andn_splits_without_intervalreports how many (7 of 20 there). Failing only when fewer survive than there are splits to take a median of, or more than half fail. -
sp.forest_policy_tree: the treatment rule a fixed-effects forest implies, and what it was worth.sp.policy_treemaximises a doubly-robust objective and so needs a propensity; a within-unit design has none. The imputation scores that give the ATT serve instead, which fixes the population to the treated cells: the rule answers "of the cells that were treated, which should have been", not whether an untreated unit should be treated -- that is the extrapolationaverage_treatment_effectalready refuses here, andsp.forest_supportis the check before carrying a rule to units the design never switched. - Depth
<= 2is searched exactly and deeper is greedy, reusingpolicy_learning'sexact_policy_treeandPolicyTreerather than growing a second policy tree inside the forest module.costis usually what makes targeting a real question: withcost=0and positive effects, treating everyone is optimal. - Fitted and priced on disjoint units, with no flag to turn that off.
A tree chosen to maximise the value of the scores it is then priced
against is priced on its own noise. On a design where every rule is
worth exactly the same -- no heterogeneity,
costequal to the constant effect, so the true gain over treating everyone is exactly 0 (200 replications, N = 200, T = 8) -- the same-sample gain averaged +0.048, larger than its own standard error of 0.037, and 13.0% of runs reported a significant benefit from targeting. Split: +0.005 and 3.5%. With real heterogeneity the split costs almost nothing: oracle gain0.8 phi(0) = 0.3191, split-sample 0.3189 at 99.5% power against a same-sample 0.3316. value,value_treat_allandgain_over_treat_allare three linear functionals of oneyfrom one design, so the gain is reportable on its own rather than as two overlapping intervals. Its estimate is exactly the difference; its variance is not the difference of theirs, because the BJS centring weights the cohort x event-time mean by each functional's ownv^2. Both readings are conservative (0.063 and 0.096 against a Monte Carlo 0.047) and the narrower is reported.- A rule that treats every cell now reports a gain of exactly 0 with a
standard error of exactly 0 and
p = 1, rather thanNaNfrom the dyadic-variance guard: a structurally zero functional is not a variance that could not be estimated. sp.rate_splitand this share_refit_halves, which also now carries the parent forest's feature names onto the refitted halves -- diagnostics used to come back asX2instead ofgdp.-
sp.aggte(agg_weights='csdid'): the other implementation's aggregation weights, selectable rather than merely documented. Rdidgives a cohort one weight — its share of the treated, constant acrosst. Statacsdidweights each cell by its own treated-observation count, and its group average enters each cohort at that cohort's mean cell weight, so being observed for fewer post-treatment periods does not shrink it. The two coincide on a balanced panel and disagree on repeated cross-sections. 1.30.0 rebuilt csdid's numbers from our own cells to show the gap was a convention rather than a defect; this release makes the convention an argument.sp.aggte(..., agg_weights='csdid')reproduces Stata'sestat simpleto 0.0e+00 and itsestat groupaverage to 2.2e-16 on the committed fixture, cohort by cohort. The default is unchanged. It is defined fortype='simple'andtype='group'— the two aggregates the rule was read offcsdid_estat.adofor — and raises for the others rather than guessing, and needs apanel=Falsefit, which is the only case where the conventions differ.sp.callaway_santanna(panel=False)now recordsn_treated_obsper (g, t) cell, the count csdid weights by. -
sp.did_multiplegt_dyngainscontrols=,trends_nonparam=andnormalized=, the three options of the authors' ownDIDmultiplegtDYNthat the module had listed as not implemented, each reproducing the reference to machine precision on the same CSV bytes (2e-16 to 4e-16 relative on every effect and every placebo;tests/reference_parity/test_dcdh_options_parity.py). controls=is not a regression adjustment: it replaces the outcome's first difference with the residual from a regression of that difference on the covariates' first differences and time fixed effects, fitted on the (g, t)s whose treatment has not changed yet and separately for each baseline treatment value. On the test panel, whose differential trend is covariate-driven by construction, it moves the lag-1 placebo from 0.417 to 0.030.trends_nonparam=makes a control match the switcher on time-invariant variables as well as on baseline treatment.normalized=divides each horizon's effect by the average cumulative treatment its switchers received, so the series is an effect per unit of treatment rather than of a path whose length grows with the horizon.
⚠️ A design where no switcher can be matched with any control now raises
DataInsufficient instead of returning estimate = nan with a "mean of
empty slice" warning. trends_nonparam= makes that design easy to ask
for by accident, and a silent NaN reads as a missing estimate rather
than as a design that cannot be estimated.
continuous=k joins them (same precision: 8.9e-16 and 5.6e-16 for
degrees 1 and 2). It is the escape hatch for Design Restriction 1(i):
when every group has a different period-one treatment there is no group
to match a switcher against, so the status-quo outcome evolution is
modelled as a degree-k polynomial in the period-one treatment, fitted
per period on the not-yet-switched cells and residualised out. Per
period is the load-bearing word — a single pooled polynomial with time
effects, the other reading of the documentation, lands 8 to 16 percent
away. With continuous= the treatment need not be binary, which is the
one case where that check is relaxed.
model_info['group_effects'] now carries each switching group's own
estimated effect at each horizon. They average to the reported delta_l
exactly and their counts match what the reference reports, which makes
them the dependent variable a heterogeneity regression needs.
trends_lin and predict_het remain unimplemented.
trends_lin was written as the reference documents it — an event study
on the outcome's first difference, summed over horizons — and does not
reproduce the reference, so it is absent rather than wrong; the module
docstring records both series and what has been ruled out, so the next
attempt starts from evidence. predict_het is absent for the same
reason: the group effects it would regress are now exposed and verified,
but the reference's coefficients
([0.5416409, 1.1172626, 1.2236984, 2.2393611] on the options fixture)
are reproduced neither by regressing them pooled (0.475 at horizon 0) nor
with cohort fixed effects (0.562), and the reference does not expose the
dependent variable it used.
sp.dynamic_dml: heterogeneous effects of a treatment sequence (Lewis & Syrgkanis 2021). The gap this closes:sp.dml_panelassumes one homogeneous effect and a treatment that does not move the future state, andsp.msm/sp.gformula/sp.ltmlegive population-average regime contrasts without saying which period did the work or for whom. When the treatment moves the state that drives later treatment, the static estimator is not merely inefficient but wrong: controlling for the later state blocks the indirect path, not controlling for it leaves the confounding in.- Returns the effect of intervening on each period's treatment on the
final outcome -- indirect paths through later states included, which is
what setting a whole sequence delivers -- plus the total,
.contrast(w)for any linear combination,.cumulative()for the treat-from-here-on profile, and.coeffor linear heterogeneity in baseline modifiers. - Parity. Given the same folds and first-stage learners it reproduces
econml.panel.dml.DynamicDML-- the reference implementation, by the authors of the method -- to 1e-15 relative on both the per-period estimates and their standard errors (T2;tests/reference_parity/test_dynamic_dml_econml_parity.py). Known-truth recovery on a linear dynamic system is unbiased at nominal coverage (T1, 40 replications). - Joint covariance. The triangular moment conditions are solved as one
stacked system rather than by back-substitution, so the covariance
across periods comes with the estimate. On the test design the total
sequence effect's standard error is 0.032 against 0.062 from adding the
per-period variances -- the periods are negatively correlated, and
diagnostics['independent_sum_se']reports the wrong number beside the right one. lags=1by default, and it matters. Sequential ignorability needs the treatment history in the state. Omitting the one lag on the test design moved the three period effects by -15%, -15% and +37% with no interval covering the truth: an incomplete state does not make the estimator noisier, it makes it confidently wrong.lags=0warns.docs/guides/g_methods_ph.mdsection 6 has the worked version.- Panel handling that the array APIs leave to the user: long-format
data/unit/time, incomplete units dropped and counted with a warning about what that does to the population, duplicated unit-periods refused by name, andperiods=to pick a balanced window. sp.ddd(method='dr'|'reg'|'ipw', id=...): covariates that actually identify the triple-difference ATT. Passingcovariates=to the triple-interaction regression has never identified the covariate-adjusted DDD ATT (Ortiz-Villavicencio & Sant'Anna 2025, [ortiz2025better]); 1.30.0 already warned about it. The warning is now anAssumptionWarningand, more usefully, there is somewhere to go:method='dr'(or'reg'/'ipw') withid=<unit column>hands the two-period design tosp.ddd_heterogeneous, whose conditional estimators carry Rtriplediffparity (Track A 77). Without covariates the point estimate is identical either way (1e-9 on the test design) because the cell means are the same object; the standard error is not, because the panel route differences within unit while'3wfe'treats the two periods as independent cross sections.
How bad the additive term is, measured: on a design whose covariate is
distributed differently across the four (treat, subgroup) cells and whose
untreated trend loads on it, the true effect is 2.0, the unadjusted DDD
returns 2.968 — and adding the covariate additively returns 2.968 as
well, to twelve digits. A time-invariant covariate has no triple
difference to contribute, so the adjustment is arithmetically a no-op
while the bias it is meant to remove lives in the covariate's effect on
the trend. With a time-varying covariate the additive term does move the
coefficient (2.569), just not to the truth. method='dr' returns 2.054
and 'reg' 1.982. tests/test_ddd_covariates.py measures all of it.
-
sp.did_timevarying_covariatesgains the doubly robust, IPW and not-yet-treated paths, an analytic standard error, and the covariate pre-tests — approach 1 of Caetano, Callaway, Payne and Sant'Anna (2026) [caetano2026difference], the paper that supersedes the 2022 preprint this module was built from.est_method={'reg','ipw','dr'}selects the cell estimator; these are the same functionssp.callaway_santannadispatches to, applied to the long difference with the covariates frozen atg-1, so a'dr'cell is consistent if either the outcome model or the propensity score is right.control_group='notyettreated'widens the comparison set with theG > max(t, g-1)rule Rdiduses.vce='analytic'is the influence-function plug-in (no resampling) andvce='multiplier'the multiplier bootstrap on the same influence functions;'bootstrap'stays the default, so every number this module already printed is unchanged. All three cell estimators and the not-yet-treated comparison are pinned againstdid::att_gt(xformla = ~x1 + x2, base_period = "universal")on the same panel —didtakes each 2x2's covariates from its base period, which under a universal base isg-1, so it is the same estimand and the two must agree to machine precision rather than in expectation. Every cell's ATT and its standard error match: 3.6e-15 for regression with not-yet-treated comparisons, 1.0e-11 for doubly robust and 3.8e-11 for IPW (the two that solve a logit, hence the convergence-tolerance floor).covariate_pretest=Truereports two statistics per covariate, both the module's own comparison run with the covariate in place of the outcome: the post-treatment one measures how much the treatment moves the covariate (the evidence that it is a genuine bad control), and the pre one is a placebo with every cohort shifted a period earlier, so it sits entirely before treatment. On a panel where the treatment raises the covariate by 1.5 and the outcome loads on it,reg,ipwanddrall recover the planted ATT of 1.0 (0.996, 0.997, 1.030 at SE 0.065), the pre-test reads the covariate shift back as 1.51 while its placebo is 0.03 (p = 0.52), and a two-way fixed-effects regression that controls for the contemporaneous covariate — the practice the paper argues against — returns -0.18.tests/test_did_bad_controls.pymeasures every number quoted here. -
The rest of the GRF family, on the StatsPAI engine. Six new tree kinds (instrumental, multi-causal, multi-regression, quantile, survival, causal-survival) reuse the engine's sampling, honesty, pruning and little-bag variances, so every forest below is honest, cluster-aware and reports out-of-bag predictions:
sp.lm_forest-- varying coefficientsh_k(x)inY = c(x) + W'h(x), with vector-gradient splits (grflm_forest).sp.regression_forest,sp.multi_regression_forest,sp.probability_forest,sp.quantile_forest(class-count splitting on the node's quantile bins, forest-weighted quantiles), andsp.survival_forest(log-rank splits evaluated exactly with Fenwick trees in O(log T) per candidate, forest-weighted Kaplan-Meier or Nelson-Aalen curves).sp.variable_importance,sp.best_linear_projection,sp.get_scores-- grf's post-estimation for any forest that supports it, includingsp.causal_forest(best_linear_projectionthere follows grf: no propensity clipping,vce="HC0".."HC3",vcov_type=accepted).- Evidence. Fed grf 2.6.1's own forest weights and inputs, every
operator after the forest reproduces grf to <= 7e-14: the IV, multi-arm,
lm and causal-survival local solves, the doubly-robust scores, averages,
standard errors and best linear projections, the Kaplan-Meier and
Nelson-Aalen curves, weighted quantiles, class frequencies and the
variable importance of eight forest types
(
tests/reference_parity/test_grf_family_operator_parity.py). The causal-survival score map -- nuisance curves to the censoring-adjusted numerator -- matches grf to 1.2e-15 on a fixture with tied event and censoring times and rows past the horizon (test_csf_psi_operator_parity.py). Forests themselves are T3: on known-truth designs against three grf seeds the CATE RMSE ratio is 0.97-1.02, pointwise coverage within 0.013, median variance ratio 0.89-1.13, average effects within 0.30 grf standard errors and SE ratios 0.98-1.03 over five StatsPAI seeds (test_grf_family_statistical_parity.py). tests/test_lazy_exports_in_all.py: a name reachable assp.<name>but missing fromstatspai.__all__is invisible tosp.list_functions(); this ratchet stops new ones (55 outside the GRF family are frozen inscripts/lazy_export_all_baseline.json).
⚠️ Correctness¶
sp.pretrends_teston ansp.event_studyresult now uses the joint pre-period covariance by default.sp.event_studywithheldmodel_info['vcv_pre']unless called withexpose_pre_vcov=True(an opt-in kept during the JOSS review), sopretrends_test,pretrends_powerandsensitivity_rrfell back to a diagonal covariance, warning but still returning a number. On the castle-doctrine panel (window=(-5, 5), clustered by state) that number was p = 0.603, while the event study's ownpretrend_testand Stata 18 (reghdfe ..., vce(cluster sid)+test) both give F(4, 49) = 1.2746, p = 0.2927. A third-party Stata-versus-StatsPAI walkthrough caught it.expose_pre_vcovnow defaults toTrue;Falserestores the old diagonal path and still warns.- When a result carries no
vcv_pre(checked onsun_abraham,stacked_did,did_imputation(pretrends=...)), the pre-trend tools take the pre-period block fromsp.event_study_vcovinstead of the diagonal, provided its diagonal reproduces the table's standard errors (to 1e-6); otherwise they keep the warned diagonal fallback. pretrends_test(type="f")usesG - 1denominator degrees of freedom when the result recordsn_clusters(Stata'stestafter a clustered regression), instead ofn_obs - K. On the castle panel it now returns F(4, 49), p = 0.29272676, against Stata's 0.29272676.- The default
typeis now"auto": the estimator's own convention. That is"f"whenmodel_info['pretrend_test']is an F test (it recordsdf_denom, assp.event_studydoes), sosp.pretrends_test(es)returns the same F(4, 49), p = 0.2927 as the event study and Stata; it is"wald"(chi2) for influence-function estimators such ascallaway_santanna/aggteanddid_imputation, whose references (Rdid, Statadid_imputation) report chi2. The old default istype="wald"(p = 0.277 on the castle panel). See MIGRATION#pretrends-joint-covariance. -
sp.callaway_santanna(...).model_info['event_study']standard errors now include the cohort-share estimation term. The convenience event study stored on a raw Callaway--Sant'Anna fit weighted each event time's ATT(g,t) by estimated cohort shares but treated the shares as fixed, dropping Rdid'swifterm thatsp.aggte(type="dynamic")carries. Every event time that mixes cohorts had too small a standard error: on the castle-doctrine panel by up to 47% (52% with population weights), onmpdtaby up to 0.9%. The table now equalssp.aggte(cs, type="dynamic", bstrap=False)to ~1e-15 on panel and repeated-cross-section fits, weighted or not, never- or not-yet-treated controls, with covariates. Point estimates do not change; withbstrap=Truethe multiplier bootstrap now resamples the same influence functions. See MIGRATION#cs-event-study-share-term. -
sp.iv_forestwas not an instrumental forest. Its neighbourhood forest was a scikit-learn random forest trained onY, not honest and not split on the IV gradient; residuals came from in-sample predictions, and the reported "LATE" was a global Robinson-Wald ratio whose bootstrap SE resampled fixed residuals. It is now the GRF instrumental forest (ATW 2019, Sec. 5) with out-of-bag nuisances, little-bag variances and the doubly-robust average conditional LATE with a compliance-score Riesz representer.latenow means that average,cateis out-of-bag. sp.multi_arm_forestscored in-sample. Outcome regressions and the multinomial propensity were fitted and evaluated on the same rows, so the AIPW scores were overfitted and the SEs too small; a failed propensity fit fell back silently to the marginal shares. It is now the multi-arm causal forest (grfmulti_arm_causal_forest): one forest for all contrasts, out-of-bag probability-forest propensities, no silent fallback, no propensity clipping by default (propensity_bounds=opts in).sp.causal_survival_forestdid not implement Cui et al. (2023). The docstring promised honest trees split on doubly-robust pseudo-outcomes; the code fitted one random forest on[X, W]to an IPCW pseudo-outcome (marginal Kaplan-Meier censoring per arm) and read the CATE off it, which a forest that never splits onWshrinks to zero. It is now the causal survival forest: survival and censoring forests on[X, W](out-of-bag, counterfactual arm read from the same trees), the censoring-robust estimating equation discretised as grf does, trees split on eq. (14) with failure-count constraints, and doubly-robust averages.target="survival_probability"is new;ate_rmstis kept. Over 40 replications the RMST average has bias 0.0007 (MC se 0.0038) and 100% coverage, the survival-probability average -0.0024 (0.0039) and 97.5%.-
GRF engine seeding (affects
sp.causal_forestand every GRF forest). Little-bag groupgwas seeded withseed + g, sorandom_state=5andrandom_state=6shared all but one group of trees -- a seed-robustness check compared a forest with itself -- and two forests grown from one seed drew identical subsamples, correlating the nuisance forests' errors with each other. Group seeds now come fromnumpy.random.SeedSequence(seed)and every nuisance forest has its own stream. Numbers for a givenrandom_statechange within Monte Carlo error. On the instrumental forest the coupling had cost 4.6% CATE RMSE against grf (0.485 vs 0.463; now 0.464); on Track A module 13 the causal forest's gaps to grf fell from 0.24% / 0.38% (ATE / ATT) to 0.03% / 0.26%. -
55 more public names reach the registry. 30 estimators and helpers (among them
sp.q_learning,sp.a_learning,sp.snmm,sp.balke_pearl,sp.gnn_causal,sp.bayes_dml,sp.causal_bandit,sp.mr_bma,sp.mr_multivariable,sp.fairness_audit,sp.conformal_continuous,sp.long_term_from_short) and 24 result classes were reachable assp.<name>but missing from__all__, sosp.list_functions()/sp.function_schema()never showed them. All are registered, every one carries a runnable example, andtests/test_lazy_export_results.pychecks each producer returns the class it names and serializes. The ratchet baseline is now empty.
Parity¶
- Track A module 17 (ETWFE) reproduces again. 1.30.0's ETWFE work moved
sp.etwfe(cgroup='nevertreated')from unit fixed effects onto the cohort - period basis the not-yet-treated branch uses (R
etwfe's default), whose CR1 factor counts the cohort levels; the R reference still setivar=countyreal, so the committed Python golden no longer reproduced and the simple never-treated SE sat 6.0e-4 from R. The reference now usesetwfe's default design for that row (2.0e-6 from R) and keepsivarfor the per-cohort rows, which validatesp.wooldridge_did. The 6.0e-4 gap to Statajwdid, ivar()on the simple rows is reconstructed exactly --sqrt((2500-17)/(2500-20)), unit effects nested in the cluster left out of K -- and regraded A;jwdidwithoutivar()reproduces our SEs to 3e-7 but moves its point estimates by 1e-6.
Changed¶
sp.q_learning,sp.a_learningandsp.snmmname their outcomey=(the house-style spelling);outcome=keeps working as an alias.iv_forest,multi_arm_forestandcausal_survival_foresttake the forest options ofsp.causal_forest(n_estimators,min_samples_leaf,max_samples,honest, ... ;n_trees/min_leafstill accepted) plussplit_alphafor grf'salpha--alphakeeps its old meaning, the significance level. They accept arrays as well as a DataFrame, andclusters=/weights=. See MIGRATION.md.sp.iv_forest,sp.multi_arm_forestandsp.causal_survival_forestare now registered (they were reachable but absent from__all__).
Deprecated¶
sp.SurrogateResult: no function ever returned it (the surrogate estimators returnCausalResult); constructing it warns, removal in 1.33. It is not offered to agents.iv_forest(n_bootstrap=)is ignored with aDeprecationWarning.CausalForest.variable_importance()withoutmethod=warns: the default changes from"permutation"(in-sample effect changes) to grf's"split"importance in 1.33.
⚠️ Correctness (validation-evidence audit)¶
sp.iv(..., vce=...)silently ignored the option.vce=(andvcov=) fell through**kwargsinto the k-class fit, which dropped them:sp.iv(f, data, vce="cr2", cluster="g")returned CR1 standard errors labelled as nothing in particular, andvce="cr3"/"wild"did the same.sp.ivnow honours every covariancesp.ivregcomputes -- HC0-HC3 and CR1 in the k-class fit; CR2 / CR3 / wild cluster bootstrap / Conley / jackknife through theivregpaths, bit-identical tosp.ivreg-- and raisesMethodIncompatibilityfor a request itsmethod=cannot compute (e.g. LIML with CR2) instead of returning some other SE.sp.fast.feolsnow defaults tossc="fixest". The old default ("statspai") charged every absorbed effect in the CR1 small-sample factor, including effects nested in the cluster variable, and under-charged the two-way FE rank by one for iid / HC1. Track A pinnedssc="fixest", so the default configuration users ran had no reference row. On the Track B panel (50 units x 6 periods, unit + period effects, CR1 by unit) the old default's SE was 8% above the Monte Carlo SD and covered 0.979; the new default covers 0.955 with SE/SD 0.987 (tests/coverage_monte_carlo/mechanisms/ feols_ssc.py). Point estimates are unchanged;ssc="statspai"reproduces old SEs. See MIGRATION.md.
Changed (validation-evidence audit)¶
- Evidence notes are the same in a pip install as in a checkout. The
registry used to scan
tests/at import time when a source tree sat next to the package, and fall back to a bare "Track A parity seed" note when it did not. Grades agreed either way (both come from the packaged_parity_index.json), but 557 functions returned differentvalidation_notes-- and the JSS manuscript printed the checkout version, which no installed release returns. Notes are now built only from the packaged index record: oneR parity module NN/Stata parity module NNline per reference actually joined, the self-describing evidence note, and up to four further evidence files. The bare seed note is gone. Version strings no longer print as "R R version 4.5.2".tests/test_registry_install_independence.pyimports a copy of the package with no source tree around it and requires everydescribe_functionrecord to equal the in-tree one. - The Track A hash gates hold on the JSS archive. The archive ships
source files ASCII-transliterated (JSS requires ASCII source), and the
Tier A fixture lock and the implementation trace hashed raw bytes, so both
reported every module stale on the extracted archive although no code had
changed. All gates now hash through
scripts/ascii_source.py, the function the packager uses; the lock (26 source entries; no data or golden entry moved) and the 89-module trace (hashes only; every classification and traced path unchanged) were regenerated. Output string literals in the parity drivers are written as\uescapes so the archive's scripts print byte-identical tables. The two.gitignorefiles left the lock scope.
Changed (validation-evidence audit)¶
sp.validation_scopegrades outputs, not only configurations. Each evidence row now lists the values it actually ran on every dimension (no wildcards) and the outputs it compared (estimate,se,coverage,diagnostic). A point-estimate row no longer vouches for a standard error it did not compare:sp.iv(..., robust="hc3")on Card is nowestimate_only(the Card reference pins classical / HC1 / CR1), where it wascovered. Statuses arecovered/estimate_only/stochastic_only/disclosure_only/not_covered; T4 non-uniqueness disclosures are no longer folded intostochastic_only. Explicit queries with a value outside a dimension's domain raise. New dimensions record what changes the computation: IV estimator and absorption; the feolsssc; CS covariates, anticipation and clustering; RD covariance and covariates; DML score, learners (linear / default / other), folds, repetitions and IPW trimming; forest tree count (exact), tuning (grf defaults or custom) and design; PSM distance, bias correction and SE method.sp.causal_questionresults are unwrapped to the estimator they ran. Dimensions a fit did not record are listed underuncheckedand never match.- New reference rows behind the map:
sp.sun_abraham's default event-time aggregate against the same linear combination offixest::sunabcoefficients (8e-12;tests/reference_parity/test_sunab_event_time_aggregate_parity.py);sp.iv(method="liml")againstivmodelon Track A module 59's bytes (module 59 itself runssp.liml, a separate code path);sp.ivandsp.ivregasserted bit-identical on the configurations whose rows they share (test_validation_entry_points.py); and a Track B row that runssp.fast.feolsitself rather thansp.panel. - The implementation-provenance trace is bound to the implementation. It
records the SHA-256 of every StatsPAI file whose code ran while each Track A
module estimated, of the committed result, and the dependency versions; a
change to any of them makes the trace stale, so an internal delegation
change behind an unchanged entry script can no longer pass. A package
outside the reviewed lists classifies a module as
unclassifiedrather than native. - "T3" is reserved for seed-replicated equivalence. Stochastic checks that
are not TOST equivalence tests -- the CS multiplier bootstrap against
did, thefect/interflexcross-validation selectors, the GRF-family forests' known-truth screens, the HonestDiD C-LF grid -- are now described as stochastic screens, in docstrings, registry notes and the evidence map (kindS). The forest equivalence test adds a Welch-t version of its TOST (20 seeds at 8,000 trees). - Track C compares like with like. Both sides of every benchmark read
one input file written by
tests/perf/_data.py(SHA-256 recorded on each side), run on one thread, under one run id;compare_perf.pyrefuses rows whose sides differ in input or run. The CS panel now uses the same calendar on both sides (periods 1-5; it was 0-4 in Python and 1-5 in R with the same cohorts), both sides compute the pre-test and the simple and dynamic aggregations, and the SCM row times the same ADH specification (special predictors, nested V, no placebos) on both sides, with the package-default workflow (V = I plus donor placebos) reported separately.
Added (validation-evidence audit)¶
- Agent cards for four newly exported causal functions.
sp.q_learning,sp.a_learning,sp.snmmandsp.balke_pearlbecame reachable assp.<name>in this release but carried no assumptions / failure-mode card, which pulled causal-category agent-native coverage below its 55% floor.SurrogateResultis recorded as a non-function__all__export. sp.validation_scope(result): which artifacts cover the configuration you actually ran. A registry tier is attached to a function; the evidence behind it is attached to configurations. For the twelve validation-suite estimators the map records, per artifact, the method variant, inference option, data condition and code path it exercises, and the call reportscovered(a T1/T2 row matches exactly),stochastic_only(only T3 / T4 / coverage rows match) ornot_covered, plus the rows that differ in one dimension. It already found a gap:sp.iv's default classical 2SLS covariance -- the one printed in the JSS Card listing -- had no deterministic reference row; it is now pinned againstAER::ivreg+sandwichon the original Card extract (classical / HC1 / CR1, just- and over-identified, Sargan) at <= 8e-11 (tests/reference_parity/test_iv_card_aer_parity.py).- Native relative-magnitudes Honest DiD.
sp.honest_did(method= "relative_magnitude")now inverts the Andrews-Roth-Pakes conditional test (honestdid_method="Conditional") or the conditional least-favourable hybrid ("C-LF", the default, as in HonestDiD) over the union ofDelta^RMpieces, in Python ([andrews2023inference], a port of the MIT-licensed R HonestDiD 0.2.8; notice inTHIRD_PARTY_NOTICES.md). On sixteen cases (diagonal / correlated covariance, a non-basis target, a single post period) the Conditional set returns the same accepted grid points asHonestDiD::createSensitivityResults_relativeMagnitudes; C-LF, whose first stage is simulated, agrees within one grid step (tests/reference_parity/test_honest_rm_R_parity.py).sp.breakdown_m(..., method="relative_magnitude")bisects the same set. A vertex enumeration of the dual polytope, which does not depend on theta, makes the grid inversion about 20x faster than one LP per evaluation. - Seed-replicated forest comparison.
sp.causal_forestandgrfare refitted under 50 seeds on fixed data at 500 / 2,000 / 8,000 trees on two datasets (tests/reference_parity/test_grf_seed_mc_equivalence.py). They are equivalent within 0.1 sampling SE (TOST) at every tree count on both datasets, and their seed-to-seed SDs agree within a factor of two (about 6-8% of the sampling SE at the default 2,000 trees). Seeds are spaced 1e5 apart on both sides: grf forests from consecutive seeds share most of their draws, and an intermediate version of this comparison that used consecutive R seeds wrongly reported StatsPAI's forest as several times noisier than grf's. T3 now means this, not "within combined Monte Carlo error" computed from sampling SEs. - Implementation provenance of every Track A row, verified by call trace.
scripts/trace_parity_provenance.pyruns each module under a profiler and records every call from StatsPAI into a non-substrate package and every subprocess;compare.py::IMPLEMENTATION_PROVENANCEregisters the exceptions andtests/test_parity_implementation_provenance.pyholds the two together (with a source-hash freshness check). 86 of 89 modules are native; 35 (panel FE/RE via linearmodels), 39 (ARIMA via statsmodels) and 67 (panel GLM via pyfixest) are marked as third-party wrappers; none call a reference backend. The parity index andsp.describe_functionnotes carry the kind. sp.auditgains anot_applicablestatus (see Fixed) andAuditReport.not_applicable.
Changed (validation-evidence audit)¶
- Track A modules 10 and 21 no longer compare R with R. Both called
sp.honest_did(..., backend="honestdid"), which runs the R package, so their rows were wrapper checks. Module 10 now runs the native FLCI and is compared with HonestDiD after its simulated folded-normal quantile is replaced by the exact one (<= 1.4e-8 for M > 0) and, at M = 0, with the closed form (GLS linear extrapolation; native 2.2e-9, HonestDiD's cone solver 5.7e-6). Its budget tightened from 5e-4 to 1e-6. Module 21 runs the native ARP set; its numbers are byte-identical to the backend's. - Track A module 06 headline is the native CCT selector, not the
official rdrobust Python port (
bwselect="cct"), which is kept as an unjoined convergence check (agreement 1.7e-13). Its SE budget tightened from 0.10 to 1e-6: the forced-bandwidth rows that bound it have agreed to 2e-15 since 1.24. result.next_steps()on an IV fit no longer recommendssp.estat(result, 'overid')for a just-identified model, and suggests re-estimating robust SEs withsp.iv, notsp.regress(which would drop the instruments).
⚠️ Correctness (validation-evidence audit)¶
sp.ebalancestandard error. The SE was a weighted two-sample variance with the weights held fixed. It ignores that entropy balancing equalises the covariate moments exactly, so outcome variation the covariates explain cancels out of the ATT; on the Track B design it was twice the estimator's Monte Carlo SD (coverage 1.000, size 0.000). The default is now the M-estimation sandwich of the stacked estimating equations (vce="mestimation"), which matchesWeightIt::lm_weightit(vcov = "asympt")to <= 3e-9 on simulated designs and onMatchIt::lalonde(tests/reference_parity/test_ebalance_weightit_parity.py). Point estimates are unchanged;vce="naive"reproduces the old SE. See MIGRATION.md.sp.causal_question(design="causal_forest").estimate()reported an estimate that did not come from the forest. The branch fittedsp.causal_forestand then returned the ATE, SE and interval of a separate cross-fit AIPW with its own gradient-boosting nuisances, so the number of trees (or anything else about the forest) had no effect on the answer, and the Track B "causal forest" coverage row measured that other estimator. It now returns the fitted forest's own doubly-robust ATE,cf.average_treatment_effect(target_sample="all")-- the estimandgrf::average_treatment_effectreports -- exactly.aipw_n_folds=is accepted and ignored. Forests too small to give every training row an out-of-bag prediction now raiseDataInsufficientinstead of silently returning an unrelated estimate; the Track B rows use the default 2,000 trees.sp.auditasked for an over-identification test on just-identified IV fits, and did not read the Sargan / Hansen J statistic an over-identified fit already carried (it looked only under nested keys, so it reported "missing" either way). The check is nownot_applicable, with a reason and no suggested function, when excluded instruments equal endogenous regressors, and reads the flatSargan p-value/Hansen J p-valuekeys otherwise.summary["n_total"]counts applicable checks only, sopassed + failed + missing == n_totalstill holds;summary ["not_applicable"]is new. The same semantics reach the MCPaudit_resulttool, whose description had also advertised statuses the function never returned.
[1.30.1] — 2026-09-24¶
Documentation-only release. No code, default, or numerical output changes from 1.30.0; the estimators are byte-identical.
Fixed¶
docs/guides/stability.mdanddocs/jss_source_audit_dossier.mdstill quoted the 1.29.0 registry census. 1.30.0 shipped them with 1,189 registered functions and 414 / 129 / 643 / 3 certified / validated / api_stable / experimental symbols; the 1.30.0 registry has 1,195 and 414 / 130 / 648 / 3, with 544 certified-or-validated symbols backed by 483 registry-evidence source files. The JSS replication archive is built from a tagged release and its claim linter checks these figures, so the correction ships as a release rather than only onmain.
[1.30.0] — 2026-09-23¶
Inference, mostly. Two lines of work land together: fixed-effect causal forests gain the imputation scores that let them report averages at all, and the DiD family gains the joint event-study covariance — which turns out to be the object three separate things were missing. Simultaneous (sup-t) bands become available for every event-study estimator rather than only Callaway--Sant'Anna; the Rambachan--Roth fixed-length interval follows; and the covariance a raw Callaway--Sant'Anna fit had been feeding HonestDiD was wrong off the diagonal, which made designs look more robust to violations of parallel trends than they are. Few-treated-cluster inference (Conley--Taber, Ferman--Pinto) arrives for the designs where the cluster-robust standard error is not merely conservative but wrong.
The fixed-effect forest also gains sp.rate and the sp.rate_split
that grades a targeting rule honestly, and a continuous treatment now
refuses att() outright instead of inventing a treated group from
T == 1.
Estimator choice itself becomes measurable: sp.did_calibrated_simulation
strips the estimated effect out of your own panel, redraws the adoption
pattern, injects an effect you specify, and refits every candidate, so the
answer to "which DiD estimator" comes from bias, RMSE and coverage on your
data rather than from a citation.
This release is also the one the DiD reconciliation study is pinned to:
seventeen estimator fixes that study reported, and sp.cs_jackknife, had
been sitting in uncommitted worktrees since August while main moved on.
⚠️ Correctness¶
sp.causal_forestwith a continuous treatment:cf.att()andaverage_treatment_effect(target_sample="treated" | "control")now raiseMethodIncompatibility. A continuous treatment defines no treated group, yetT == 1was used to select one. On the Card returns-to-schooling design witheducas the treatment,cf.att()averaged the CATE over the rows with exactly one year of schooling and printed the result as "ATT" with a zero-width interval. grf refuses these targets for a non-binary treatment, and both StatsPAI engines now do too.target_sample="all"(the average partial effect) and"overlap"are unchanged. See MIGRATION.md.-
Record correction for 1.29.0: the switch to grf defaults in
sp.causal_forestchanged its output. The 1.29.0 entry lists the new defaults (2,000 honest subsampled trees, out-of-bag forest nuisances) under Changed, but they moved the fitted effects materially. On the bundled Card data (educas a continuous treatment;exper,expersq,black,south,smsaas covariates;random_state=42) the CATE standard deviation went from 0.081 in 1.28.0 to 0.016, and the interquartile range went from 0.058–0.103 to 0.063–0.087. R grf 2.6.1 on the same bytes gives 0.017 and 0.063–0.087. Refit forest-based heterogeneity results produced with 1.28.0 or earlier. The 1.29.0 MIGRATION entry also said continuous treatments still used the plug-in average. Since 1.29.0 they have used grf's continuous-treatment debiased score (method="aipw_continuous"). See MIGRATION.md. -
sp.honest_did/sp.breakdown_mon a raw Callaway--Sant'Anna fit used an event-study covariance whose off-diagonal blocks were wrong.sp.aggtedid not expose the joint covariance of its cells, so the fixed-length-CI route rebuilt one from the per-cell influence functions with the estimated cohort shares held fixed -- dropping, off the diagonal, the same weight-estimation term whose omission from the diagonal was fixed in 1.22. The diagonal therefore agreed with the reported standard errors while the cross-cell entries were up to 8 percent from Rdid's, and the FLCI built on them was wrong in a direction that depends on the panel: on thesp.cs_reportdemo it made the breakdownM*about three times too large, i.e. the design looked far more robust to violations of parallel trends than it is.sp.aggtenow reports the covariance behind its own standard errors inmodel_info['vcov'], every caller reads it, and it reproduces Rdid'st(IF) %*% IF / n^2on the castle-doctrine panel to 2.8e-13 on all 196 entries (tests/reference_parity/test_aggte_vcov_r_parity.py). Re-run any HonestDiD sensitivity taken from a rawcallaway_santannafit; results routed throughsp.aggte(type='dynamic')were already correct. -
sp.etwfe_emfx(type='event' | 'calendar')headline. The returned.estimatewas the mean of every reported row -- withinclude_leads=True, pre-treatment placebo coefficients included -- and.se,.pvalueand.ciwereNaN. The headline is now the mean of the post-treatment rows (all rows for'calendar') with its delta-method SE through the joint covariance of the rows, which is now exposed. The per-rowdetailtable is unchanged. sp.stacked_didreturnedatt = 0.0,se = 0.0when the window had no post-treatment relative time with data. It now raises.- The Callaway--Sant'Anna joint pre-trend test on ATT(g, t) cells is
rank-aware. Under a universal base period the pre-treatment cells are
linear combinations of the same control-group long differences, and a
single-unit cohort adds cells with no treated-side variation, so their
covariance is singular by construction (rank 17 of 30 on the
castle-doctrine panel). The old code added a
1e-10ridge and inverted, returning a Wald statistic of order1e9on that panel. It now uses the spectral pseudo-inverse withdf = rank(what Stata'stestdoes when it drops redundant constraints) and reportsn_cells,rankandrank_deficient. Full-rank panels are unchanged. sp.ddd(covariates=...)now warns. Covariates entered additively in the triple-interaction regression identify the covariate-adjusted DDD ATT only if their outcome effect is common to all eight cells and their distribution does not shift across cells (Ortiz-Villavicencio and Sant'Anna 2025). The number is unchanged; the warning andmodel_info['diagnostics']point tosp.ddd_heterogeneous(x=..., est_method='dr'), the doubly robust estimator of that paper.-
Two functions no longer claim to implement papers they only take inspiration from.
sp.did_misclassifiedtakes the misclassification rate and anticipation window as inputs and applies a mechanical adjustment; Augustin, Gutknecht and Liu (2025) identify both from the data.sp.bridge(kind='did_sc')fits DiD and SC separately and reports an inverse-variance combination, which has none of the double robustness of Sun, Xie and Zhang (2025). Docstrings and the registry entry now say so; numbers are unchanged. -
sp.lp_didleads used a stricter clean-control window than Statalpdid. At leadh < 0StatsPAI required a control's treatment to be zero over[t+h-1, t-1], one period more than the outcomes entering the long differenceY_{t+h} - Y_{t-1};lpdid(the author implementation) applies the horizon-zero condition at every lead (CCS_m<h> = CCS_0), i.e.[t+h, t-1]. The extra period dropped the earliest observable calendar year at every lead: pure-control observations at shallow leads (coefficient unchanged,(N-1)/(N-K)shifted by one year and one year effect) and early switchers at the deepest lead (point estimate moved). Now[t+h, t-1]. On the castle-doctrine and no-fault-divorce panels every lead's sample size matcheslpdid(376/326/276/226 and 464/423/382/341) and estimates and SEs agree to 1e-7 / 1e-8; Track A module 83's lead rows moved from a registered 3.9e-4 SE gap to 2e-16, and its R transcription was aligned to the same rule. Post-treatment horizons and the pooled post window are unchanged. -
sp.did_multiplegt_dynplacebo lags 2 and deeper used the wrong sample. The reference computes placebolon the switchers and controls behind effectl— the mirror image aboutF-1: a switcher must be observed atF-1+l(so a cohort that cannot support effectldrops from placeboltoo) and the controls are the units not yet switched atF-1+l. StatsPAI took the lag-1 rule for every lag (controls not yet switched atF, no anchor-period requirement). On the castle-doctrine panel (dynamic=4, placebo=3)placebo_2moves from 0.062241 to 0.050966 andplacebo_3from 0.015389 (21 switchers) to 0.023244 (20 switchers); both are now pinned to RDIDmultiplegtDYN/ Statadid_multiplegt_dynto 1e-6 relative (tests/test_did_multiplegt_dyn_castle_reference.py).placebo_1is the same object under both rules and is unchanged, as are all effects. Track A module 78's fixtures were not affected (every cohort there supports every requested lag), which is why the module passed while the rule was wrong. -
sp.did_multiplegt_dyn(se_method='analytic')is now the authors' variance and is pinned to the reference. It used to be the plain two-sample influence function: no small-sample cell factor, a zero residual for any single-switcher cohort, no summation withincluster. That ran 4–18 percent below the reference on the castle-doctrine panel (e.g.dyn_e00.04106 vs 0.04389,placebo_10.03921 vs 0.04786) and about 1 percent below on the Track A fixture. The analytic path now builds the reference'sU_Gg_var: each (g, t) contribution is the long difference minus its cell mean — the cohort for a switcher, the (baseline, t) not-yet-switched set for a control — timessqrt(n_cell/(n_cell-1))withn_cellthe number of distinct clusters in the cell; a cell with a single cluster is centred on and scaled by the pooled switcher+control cell at that period instead (this is what a lone switcher such as castle's 2005 cohort needs); contributions are summed withinclusterbefore squaring and the variance isΣ_c (Σ_{g∈c} U_g)² / G². Every per-horizon SE and theAv_tot_effSE now match RDIDmultiplegtDYN2.3.4 to 4e-15 relative and Statadid_multiplegt_dynto 2e-7 on the castle-doctrine panel (weighted and unweighted) and on both Track A module 78 fixtures including the switch-off design (tests/reference_parity/test_multiplegt_dyn_parity.py, which used to assert a bounded 1.5 percent gap and now asserts 1e-6 parity). Thecluster=argument is now honoured by the analytic path (it was bootstrap-only);groupmust be nested incluster, and a missing cluster value is an error. Point estimates on the analytic path are unchanged, and the defaultse_method='bootstrap'is unchanged. -
⚠️ correctness fix —
sp.etwfe_emfx(type='event' | 'group' | 'calendar')did not reproduce Retwfe::emfx/ Statajwdid, estat, andcgroup='nevertreated'was not honoured by those aggregations. Three defects compounded: (i) the event and calendar views weighted each cohort's cell by the cohort's total number of treated post-period observations instead of the cell's own count (R weights every treated observationN = 1), so cohorts with longer post-windows were over- weighted; (ii) the group view reported the pooled cohort × post coefficient of a separate regression rather than the cohort's average post-treatment cell; (iii)cgroup='nevertreated'was served by the historicalsp.wooldridge_didunit-demeaned regression, whose cells never reached the group view and whose small-sampleK(51) is not the one R's design implies (66), so the group rows were identical under both control groups and the never-treated headline SE matched neither R nor Stata. The linearsp.etwfefit now uses R's design for both control groups (cohort + period fixed effects,ivar = NULL; never-treated: a cell for every period of every cohort exceptg - 1), every aggregation averages the cohort-by-period cells over their own treated observations with delta-method SEs through the cluster-robust cell covariance, andsp.etwfe(...).detailis now the Remfx(type = 'group')table (theweighting='cohort'headline therefore moves with it;type='simple'point estimates are unchanged). On the castle-doctrine panel (sp.datasets.castle_doctrine):dyn_e0moved 0.05927 → 0.0710706 (not-yet) and 0.09527 → 0.0972154 (never);group_20090.19798 → 0.2110805 (not-yet) and 0.1980 → −0.0028081 (never); the never-treatedattSE 0.0409008 → 0.0415298. All 36 rows (simple,e = 0..5, five cohorts, six calendar years, both control groups) are pinned against Retwfe0.6.2 intests/test_etwfe_emfx_reference.pyto 1e-9 relative on the estimates and 1e-6 on the SEs (the residual ismarginaleffects' finite-difference Jacobian; R's ownsqrt(w' vcov(e) w)agrees to 1e-13), and reproduce Statajwdidv2.2 to 1e-9 on the SEs after the documented degrees-of-freedom factorsqrt((n - K) / (n - K_Stata)),K_Stata = K - n_cohorts(fixest counts the cohort-FE parameters, reghdfe drops the unit FE as nested in the cluster;model_info['ssc']recordsn,K,G). Without covariates the never-treated aggregates now coincide with Callaway–Sant'Anna's never-treatedaggteto 1e-9, as they should. Surfaced by the DiD reconciliation study (Paper-DiD-JAE). -
sp.etwfe_emfx(type='event', include_leads=True)weights pre-treatment cells by their own observation counts (event_study['n_cell_obs'], also added tosp.wooldridge_didresults) instead of the cohort's post-period total. -
sp.sun_abraham(weights=)standard errors at multi-cohort relative times moved by 0.8-2.9 percent. The cohort-share variance term of Sun and Abraham (2021, Prop. 3) was built from the unweighted multinomial covariance(diag(w) - ww')/N_leven when observation weights were supplied. Stataeventstudyinteract [aw=]builds it from the weighted share regression's robust sandwich,sum omega_i^2 u_i u_i' / (sum omega_i)^2withomegarescaled to mean one over the estimation sample; that is what is computed now. On the castle-doctrine panel every weighted event-time SE reproduceseventstudyinteractto 2.3e-7 (was up to 2.9 percent away, in both directions); unweighted SEs and all point estimates are unchanged (6.7e-10). Single-cohort relative times, where the term vanishes, were never affected. Surfaced by the DiD reconciliation study's weighted castle-doctrine panel. -
sp.did_imputationgainsweights=(Statadid_imputation [aw=], Rdidimputation(wname=)): weighted least squares in the untreated Y(0) model, omega-weighted averages over treated cells, and the exact variance with the weighted projection-W Z0 (Z0' W Z0)^-1 Z1' u. Pinned to Statadid_imputation(Borusyak's implementation) on the castle-doctrine panel: headline ATT, every horizon and every lead agree on estimate and standard error to 1e-6 (tests/test_did_imputation_weights.py). Rdidimputation0.5.1 agrees on the weighted point estimates but not on the weighted standard errors (16 percent on the headline, up to 57 percent by horizon): it row-scales the design byomegaon both sides of the normal equations (Z0' W^2 Z0), so its weight vector no longer reproduces its own estimate (v'y= 0.0428 against an estimate of 0.0659 on castle). StatsPAI follows the author implementation.
Added¶
sp.ratenow works on causal forests with fixed effects, andsp.rate_splitgrades a targeting rule honestly. RATE was the last thing anfe=forest could not do: the doubly-robust score it averages needs a propensity, which a within-unit design has none of. The imputation scores that already give the ATT [borusyak2024revisiting] give the curve too, read on the population they identify -- the treated cells -- soTOC(q) = ATT(top q by S) - ATT, a retrospective targeting curve rather than the population RATE a randomised design gives.- The standard error is exact, not assumed. Conditional on the
ranking, AUTOC and QINI are linear in the scores, and the imputation
scores are linear in
y; composing the two rank and imputation weights makes RATE one more functionalv'y, with the same cluster- and dyad-robust variance as the ATT. The composed weights annihilate the unit and period dummies to 1e-15 and put exactly zero net weight on the treatment, RATE being a contrast; both are asserted.se_methodgains'imputation'(what the new default'auto'selects for these forests) alongside the rank-corrected'influence'. variancedefaults to'bjs'here, not to the'forest'thataverage_treatment_effectuses.'forest'nets the fitted heterogeneity out of the residual, which is calibrated for the RATE of the realised sample; RATE loads on the tail of the effect distribution, so the distinction shows where it does not for the ATT. Over 120 replications (N = 150, T = 8,tau = 0.3 + 0.5 z, held-out ranking) 95% intervals covered the population AUTOC 97.5% of the time with'bjs'and 90.8% with'forest'; against the sample's own RATE, 99.2% and 95.8%. Bias was -0.006 on 0.452.sp.rate_split(new).sp.rateranks the rows it also scores. For these forests out-of-bag predictions are not enough, because every imputation score carries-gamma_hat_tfrom the periods the forest trained on: with no heterogeneity at all, AUTOC averaged -0.025 instead of 0 and a nominal 5% test rejected 17.5% of the time (QINI 13.5%) over 200 replications.rate_splitsplits the units -- or, withmembers=, the nodes of a dyadic panel, dropping and counting the rows that straddle the halves -- refits a forest on each, and has the training half's rule ranked against scores built on the evaluation half alone. On the same null it averaged +0.0008 and rejected 7.5% (QINI 4.0%), keeping 99.5% and 100% power againsttau = 0.3 + 0.5 z.sp.ratenow warns on the reused path instead of silently inviting it.-
Below roughly 30 units a side
rate_splitwarns that the split is measuring itself: on the guide's 15-country trade panel it ranged over +/-0.08 across six splits (two with a non-positive dyadic variance) while the true-tauranking on the whole sample gave 0.081 (se 0.031).docs/guides/heterogeneity_panel_forests.mdsection 5 runs that as a cautionary example. -
Inference for causal forests with fixed effects (
fe="twoway"/"unit") through imputation scores. A within-unit design has no propensity, so 1.29.0 refused every average. Following the model the FE forest already assumes (fe="twoway"), unit and period effects are now fitted on the untreated cells [borusyak2024revisiting] and every treated cell getsGamma = Y - alpha_hat_i - gamma_hat_t, unbiased for its own effect whatever the heterogeneity. The forest is the proxy,Gammathe signal [chernozhukov2025generic].covariates="auto"(or a list of names) adds time-varying covariates to the untreated model linearly; the default"none"is the pure two-way model.controls=is accepted as the spellingsp.did_imputationuses: cf.average_treatment_effect("treated")returns the imputation ATT. With the defaultcovariates="none"it issp.did_imputation: identical estimate onmpdta(1e-13) and, withvariance="bjs", identical standard error (5e-15 relative), so it inherits that estimator's Stata/R parity. The defaultvariance="forest"centres treated residuals on the out-of-bag forest prediction before the cohort x event-time blocks: in 200 replications (N = 300, T = 8, staggered adoption selected on the unit effect) it covered the ATT 97.5% of the time with heterogeneous dynamic effects (BJS convention: 100%, standard error 34% larger) and 94.5% with a constant effect (Monte Carlo evidence, tier T1). The mean forest prediction over the same cells is reported asforest_plug_in; with heterogeneous effects it was biased by -0.18 against -0.003 for the imputation ATT.cf.best_linear_projection()regresses the scores on covariates over treated cells.sp.calibration_test(cf)/sp.calibrate_cate(cf)regress the scores on the OOB prediction (method="imputation", the new default for binary FE forests). The heterogeneity test kept its size (3.5-4.0% at 5%) and the slope now de-attenuates: calibrated predictions cut RMSE from 0.634 to 0.570 (heterogeneous) and 0.213 to 0.118 (constant effect).sp.forest_group_effects(alsocf.group_effects): group average effects with valid standard errors for any grouping — labels, quantiles of the OOB CATE (GATES), or membership in dyadic data (each country over all of its pairs) — with pair-clustered or dyadic-robust [aronow2015cluster] variances, an equality Wald test and optional percent scale for log outcomes. FE forests use imputation scores (group ATTs), pooled forests AIPW scores (group ATEs).sp.forest_support: support diagnostics for counterfactual CATE predictions (units that were never treated): range and k-nearest-neighbour checks against the rows that identify the effect, benchmarked on distances to other units.sp.cate_pretrend_test: pre-trend test by predicted-effect group for FE forests (untreated cells, group x lead indicators, cluster-robust Wald tests that the leads are zero and equal across groups). In 100 replications the equality test rejected 3% of the time without a pre-trend and 71% with a group-specific one.sp.causal_forest(..., split_rule="cffe"): forfe=forests, the tau-heterogeneity split criterionn_L n_R / n^2 (tau_L - tau_R)^2of [kattenberg2023causal], the rule thecausalfepackage implements, in place of the GRF gradient criterion. Everything downstream is unchanged. The criterion needs the shallow, large-leaf trees it was designed with: on the causalfe package's own simulation its CATE RMSE was 0.74 with StatsPAI's defaults and 0.53 withmin_samples_leaf=20, max_depth=4(GRF criterion: 0.50 either way), and a warning fires on the deep default.sp.identify(...).summary(): the identification result now renders the verdict, the do-free estimand or the hedge that witnesses non-identifiability, and the c-components ofG[An(Y)], like every other StatsPAI result object.- Stata parity for
sp.truncreg's covariance options (tests/reference_parity/test_truncreg_vce_parity.py). Track A module 62 pinned only the default estimates;robust=andcluster=were accepted and silently ignored before 1.29 and nothing pinned them afterwards. The new fixture freezes the full covariance matrix Stata 18 reports undervce(oim),vce(robust)andvce(cluster cl)on module 62's data. StatsPAI matches it entrywise to 9.3e-7 in correlation units (standard errors to 4.6e-7 relative), against the 1e-3 / 2e-2 a missing finite-sample factor would cost. sp.datasets.currency_union_panel: a simulated dyadic trade panel with staggered currency-union adoption, known pair-level effects, and an optional late-adopter wave during a common downturn; its layout follows [aytug2026euro]. The worked example is indocs/guides/heterogeneity_panel_forests.md.paper.bib:aytug2026euro(arXiv:2601.19664; verified via the arXiv API and DataCite, SSRN posting via Crossref).
Changed¶
- ⚠️ FE forests:
sp.calibration_testandsp.calibrate_catedefault to the imputation regression forfe="twoway"with a binary treatment (see Added). The globally within-transformed regression of 1.29.0 ismethod="within"; continuous treatments andfe="unit"keep it. See MIGRATION.md. cf.average_treatment_effect("treated")accepts the target as the first positional argument, as the panel-forest guide already wrote it (the call used to fail because the first parameter isX).
Three nuisance-control additions motivated by the audit of the ML4CI companion paper. All are opt-in or read-only: existing calls return bit-identical numbers (pinned regression tests in the new test files).
sp.tmle(fold_indices=...): cross-validated TMLE. One integer label0..K-1(K >= 2) per row ofdata, the conventionsp.dml(fold_indices=)uses; rows dropped for missing values are dropped from the vector. When given, the initialQ(Y | A, W)andg(A | W)Super Learners are fitted outside each fold and predicted inside it, the fluctuation is fitted once on the pooled out-of-fold predictions, and the SE is the efficient influence function at those targeted fits.model_info["cross_fitted"]andmodel_info["n_cv_folds"]record the path; per-fold ensemble weights are inmodel_info["sl_outcome_weights_by_fold"]/["sl_propensity_weights_by_fold"](entryk= the Super Learner fitted on rows with fold != k); a continuous outcome's[0, 1]rescaling uses full-sample bounds; a Super Learner failure in a training complement is re-raised naming the fold;result.methodreadsCV-TMLE (...). The docstring now also states that the ATT path targets onlyQand reports the estimating-equation form. Cannot be combined withQ=/g1W=. The docstring now states that the default path fits both nuisances on the full sample and evaluates them in sample (it is not cross-fitted);n_foldsonly drives the Super Learner's internal weight selection.sp.metalearner(fold_indices=...)(andsp.RLearner/sp.DRLearnerfold_indices=): an explicit cross-fitting partition with labels0..n_folds-1, used for every internal split on that path (the R/DR nuisance cross-fit and the AIPW cross-fit behindestimate/sefor the S/T/X/R learners). The default still drawsKFold(n_folds, shuffle=True, random_state=42); passing that split's labels reproduces the default exactly.model_info["cross_fit_partition"]names the partition used.CausalForest.get_nuisances()returns read-only copies of the forest's internal nuisances (Y_hat,W_hat) that the forest was grown on and its doubly robust averages use, with their source. At fit time, for a binary 0/1 treatment withoutfe=,diagnostics["nuisance_overlap"]records how many internal propensities fall outside[0.01, 0.99](CausalForest.NUISANCE_OVERLAP_BOUNDS), and anAssumptionWarningfires when that share exceeds 5% (NUISANCE_OVERLAP_MAX_SHARE). No estimate changes. The warning is attributed to the first caller frame outsidestatspai, so it is not deduplicated across fits.- Shared validator
statspai.core._validate.validate_fold_indicesfor the twofold_indices=arguments above. -
The repeated-cross-section aggregation gap against Stata
csdidis now a documented convention rather than an open question. With repeated cross-sections theATT(g, t)cells agree three ways to machine precision but the aggregates do not: StatsPAI and Rdidweight a cohort by its share of treated observations, whilecsdidweights each cell by its own treated count (base period plus comparison period) and weights a cohort into the group average by its mean cell weight rather than its total.tests/reference_parity/test_rcs_aggregation_conventions.pypins our aggregates to R at 1e-12 and rebuilds Stata's simple ATT, its three cohort ATTs and its group average from our own cells at 1e-12, so the 0.02 to 1.4 percent gap on that fixture can be priced instead of reported as unexplained. The rule was read offcsdid_estat.ado(csdid_group) and is written out in the test's docstring and indocs/guides/repeated_cross_sections.md. -
sp.did_calibrated_simulation: choose a DiD estimator on your own panel instead of by citation. The comparative literature's recommendation is to settle estimator choice empirically [ulloaperez2025comparative]: inject a known effect into the researcher's own data under a randomised adoption pattern and read off bias, RMSE and coverage against a truth you control. The harness does exactly that in four steps -- strip the estimated dynamic effect out of the observed outcome with the imputation fit [borusyak2024revisiting], redraw the adoption pattern (permute the cohort labels, or draw them i.i.d.), inject a known effect, refit every candidate -- and returns a table of bias, RMSE, coverage, rejection rate and the ratio of the mean reported standard error to the actual dispersion, each with its Monte Carlo standard error, plus.best('rmse'). Nine estimators are scorable:twfe,callaway_santanna,sun_abraham,did_imputation,gardner_did,etwfe,did_multiplegt_dyn,stacked_didandlp_did. Because the redrawn assignment is random, parallel trends holds by construction, so what the table measures is the estimators rather than the design. Onmpdtawith 100 replications all four default estimators came back within 1.3 Monte Carlo standard errors of the injected effect, with mean standard errors 0.95 to 0.99 times the realised dispersion and coverage of 0.91 to 0.96. A draw where an estimator raises, or returns a point estimate without a usable standard error, is recorded in.failuresand warned about rather than quietly scored. -
sp.did_few_treated: Conley--Taber (2011) and Ferman--Pinto (2019) inference for designs with one or a handful of treated clusters. The cluster-robust variance estimates the treated side's contribution from as many draws as there are treated clusters, so with one it over-rejects regardless of the total: on a 30-group panel with AR(1) errors it rejects a true null 74 percent of the time at a nominal 5 percent. Both methods here read the placebo distribution of the coefficient off the control groups -- applying the treated groups' residualised treatment path to each control group's residual path -- and invert it, which allows arbitrary within-group serial correlation.method='conley_taber'rejects 3 percent on that same design.method='ferman_pinto'additionally rescales each control draw for the heteroskedasticity that unequal group sizes generate, fittingVar(W) = A + B / Mon the control draws (non-negative least squares when the unrestricted fit goes negative, with a warning): with a small, noisy treated group Conley--Taber rejects 18 percent at a nominal 10 percent and Ferman--Pinto 13.5 percent. The point estimate is the usual two-way fixed-effects coefficient, which these methods do not claim is consistent with a fixed number of treated groups; what they deliver is the test and the interval.tests/test_did_few_treated.pymeasures every number quoted here.sp.auditgains afew_treated_clusterscheck that routes a design with fewer than ten treated clusters here (and tosp.cs_jackknife), andsp.callaway_santannareportsmodel_info['n_treated_units'], the count that check reads — treated clusters, which is what the few-treated literature counts, not cohorts. -
sp.event_study_vcov(result)andsp.uniform_bands(result): joint event-study covariance and sup-t simultaneous bands for every DiD event study, not only Callaway--Sant'Anna.event_study_vcovreads the joint covariance ofcallaway_santanna/aggte,event_study,sun_abraham,gardner_did,did_imputation,stacked_did,lp_did,did_multiplegt_dynandetwfe, and flagsjoint=Falsewhen only a diagonal or block-diagonal matrix exists.uniform_bandsreplaces the pointwise critical value by the1 - alphaquantile ofmax|Z|,Z ~ N(0, R)(Montiel Olea and Plagborg-Moller 2019), over all, post or pre event times; with no joint covariance it uses the Sidak value, which is conservative. To support this the estimators now expose the matrix:did_imputationmodel_info['event_study_vcov'](the BJS cluster scores cross-multiplied; the horizon block reproduces Statadid_imputation'se(V)on mpdta to 1.3e-6, the size of Stata's own point-estimate gap to the exact imputation),stacked_did(the stacked regression's sandwich),lp_did(per-horizon cluster scores stacked,suest-style),did_multiplegt_dyn(clustered influence sums underse_method='analytic', replicate covariance under the bootstrap) andetwfe_emfx(type='event' / 'calendar')(model_info['vcov']). Every diagonal reproduces the estimator's reported SEs to 1e-12 (tests/test_es_inference.py). -
sp.enhanced_event_study_plot(..., uniform_band=True)overlays that band on the event-study figure, pre and post windows computed separately; it warns and falls back to the pointwise interval when the estimator exposes no usable covariance. -
sp.honest_did(method='smoothness')solves the Rambachan--Roth FLCI for every estimator above, throughevent_study_vcov. It previously did so only for Callaway--Sant'Anna fits and fell back to a worst-case-bias interval, with a warning, for Sun--Abraham, dynamic TWFE, Gardner, stacked, LP-DiD, dCDH and ETWFE fits. The output frame now records which interval it holds in.attrs['interval']('flci'or'worst_case_bias'). Rawsp.etwfefits are accepted directly. The one remaining fallback isdid_imputation(pretrend_method='bjs'), whose leads come from an auxiliary regression with no cross-covariance to the imputation horizons. -
sp.sun_abrahamexposes the aggregate under both variance conventions and the joint event-time covariance.model_info['se_fixest_att_share']is the standard error of the fixestagg='att'aggregate when the cohort-share estimation term (Sun and Abraham 2021, Prop. 3) is carried into the aggregate, which is what alincomon Stataeventstudyinteract'se(V_iw)returns;model_info['se_fixest_att']remains the fixed-share valuefixest::sunabreports.model_info['vcov_event_time'](withmodel_info['event_times']) is the joint covariance of the interaction-weighted event-time coefficients, regression cross terms plus the share term on the diagonal, i.e. the matrixe(V_iw)itself. On the castle-doctrine and no-fault-divorce panels the share-term aggregate reproduces Stata'slincomto <= 1.3e-9 relative (unweighted and population-weighted) and the fixed-share aggregate reproduces R to <= 1.3e-9 (tests/test_sun_abraham_share_aggregate.py). Surfaced by the DiD reconciliation study, where the headline Sun-Abraham standard error differed between the two references by 1.4 to 5.5 percent for this reason. -
sp.etwfe(weights=...): observation weights for the linear ETWFE. The cohort-by-period regression becomes weighted least squares with Rfixestweights=/ Statareghdfe [pw=]semantics (zero-weight rows dropped with a warning; NaN or negative weights raiseMethodIncompatibility), keeping the unweighted fit's cluster-robust small-sample conventionG/(G-1) * (n-1)/(n-K)withncounting observations. Threaded through the dispatcher into the not-yet-treated, never-treated and repeated cross-section branches (weightswithxvaror withfamily='poisson'/'logit'raises rather than being silently ignored). sp.etwfe(agg_weights=...)/sp.etwfe_emfx(agg_weights=...): how the estimated cells enter thesimple/event/group/calendaraggregates of a weighted fit.'estimation'(default) is Statajwdid, estat: cell(g, t)carries the sum of the estimation weights over its treated observations,W_{g,t} = sum_{i in g} w_{i,t}; under never-treated controls this makes thesimpleaggregate identical to the weighted Callaway--Sant'Anna simple ATT (an estimand-level identity, hence the default).'unit'is Retwfe::emfx: one unit weight per treated observation,W_{g,t} = n_{g,t}, with the estimation weights entering the regression only.etwfe_emfx(agg_weights=None)inherits the fit's rule. Withoutweightsthe two rules coincide and every number is unchanged. Event-study cells (model_info['event_study']) and the cohortdetailnow carry both totals (n_cell_obs/w_cell_obs,n_treated_obs/w_treated_obs,w_obs);model_info['ssc']['sum_weights']records the weight total.- Reference parity (
tests/test_etwfe_weights_reference.py, castle-doctrine and no-fault-divorce panels, both control groups): underagg_weights='estimation'everyatt/dyn_e*/group_*/cal_*estimate reproduces Statajwdid [pw=]+estatto <= 1.1e-12 relative and the SEs reproduce Stata's after the documentedsqrt((n - K_R)/(n - K_Stata))degrees-of-freedom factor to <= 1.9e-12 (castle K = 66/61 never, 36/31 not-yet; divorce 429/417 and 303/291); underagg_weights='unit'the estimates reproduce Retwfe(weights=)+emfxto <= 4.9e-12 and the SEs to <= 3.3e-6 (marginaleffects' numerical Jacobian). The never-treated weightedsimpleaggregate equals Statacsdid [pw=], estat simpleto <= 9.1e-15 on both panels. The divorce panel is shipped astests/fixtures/divorce_no_fault_panel.csv(byte-identical to the reconciliation study's locked CSV). -
Registry:
etwfegains theweightsandagg_weightsparameter specs, a limitation entry for the unsupportedxvar/ nonlinear-family combinations, and a validation note pointing at the new parity test; agent schemas regenerated. -
sp.did_multiplegt_dyn(weights=)— observation weights, Statadid_multiplegt_dyn, weight()/ Rweight=, whose spelling is accepted as the aliasweight=(the canonical parameter name across the package isweights). The weight is read at the row a unit contributes from (periodF+lfor an effect,F-1+|l|for a placebo, exactly as the reference'sN_gt), so time-varying weights behave identically; switcher means, control means, cohort weights and theaggregation='switchers'weights all become weighted.detailkeeps the raw switcher count inn_switchersand adds the weighted one inw_switchers;model_infogainsweightandn_groups. On the castle-doctrine panel withweight='popwt'every effect, placebo,Av_tot_effand analytic SE matches RDIDmultiplegtDYN2.3.4 to 4e-15 and Statadid_multiplegt_dynto 2e-7 (which is R's own gap to Stata). -
sp.gardner_did(weights=), the counterpart of Rdid2s(weights=)and Statadid2s [aw=]: strictly positive estimation weights applied to both stages, with the corrected variance built on the weighted objects exactly asdid2sdoes. Pinned against the population-weighted castle-doctrine rows (popwt) at rel < 1.2e-7 on estimate and SE. -
sp.aggteexposes the joint covariance of its cells. Every aggregation (simple,dynamic,group,calendar) now carriesmodel_info['vcov'](the covariance the reported standard errors are the square-root diagonal of),model_info['influence_functions'](the corrected per-cell functions, cohort-share estimation term included),model_info['cell_labels'], and fortype='dynamic'model_info['vcv_pre']/['pre_event_times']for the pre-treatment block -- the object Rdidreturns asaggte(...)$inf.function. Downstream consumers read it:sp.honest_did(method='smoothness')on a dynamicaggteresult now runs the exact Rambachan--Roth FLCI on that covariance instead of the worst-case-bias fallback, andsp.pretrends_power/sp.pretrends_slope_for_poweruse the full pre-period covariance instead of the diagonal. Surfaced by the DiD reconciliation study (Paper-DiD-JAE), where both fell back silently on every Callaway--Sant'Anna event study. -
sp.lp_didreports the pooled estimates Statalpdidcallspooled_results:model_info['pooled']['post']regresses the average long difference over[0, h_max]on the clean-control sample ofh_max, and['pre']does the same over[h_min, -2]. On a design where every horizon shares one sample the pooled coefficient equals the mean of the per-horizon coefficients exactly (tests/test_lp_did_pooled.py); on the castle-doctrine panel it is pinned againstlpdidin the DiD reconciliation study. -
Canonical parameter names on three DiD entry points, with the old spellings kept as aliases so no call breaks:
sp.cs_jackknife(t=, i=)is now(time=, id=),sp.did_multiplegt_dyn(weight=)is now(weights=), and the newsp.did_few_treatedusesid=/covariates=. The signature house-style ratchet (scripts/signature_house_style.py) is five sites below its baseline as a result.
Fixed¶
sp.synth_compare(...).plot()raisedKeyError: 'counterfactual'on the default method pool. The ~20 estimators behindsp.synth(method=...)spell their trajectory columns differently (treated+synthetic,observed+counterfactual,treated+counterfactual) and the plotting layer hard-coded one spelling per branch. The extraction helper now resolves the aliases; no estimator's output columns were renamed.sp.harvest_did(...).summary()raisedKeyError: 'statistic'. The shared DiD machinery readspretrend_test["statistic"], whichharvest_didnever emitted. It now emits bothstatisticand the originalchi2alias.- ⚠️ Correctness:
sp.ri_test/sp.fisher_exactp-values depended on the SciPy version. Permutations tying with the observed statistic count as extreme (the ri2 / randomizr convention), but the count used an exact floating-point>=.stat="ks"is where that breaks: on n = 12 the KS statistic takes six exact values j/6, yetscipy.stats.ks_2sampbuilds them as a max over two ECDFs, and since SciPy 1.18 those six values come back as eleven distinct floats. 54 of the 924 enumerated assignments then landed under the observed value by less than 1e-9, the extreme count fell 438 -> 384 and the two-sided p-value moved 0.4740 -> 0.4156, losing parity with R'sri2on a dependency upgrade alone. Ties are now counted within 1e-12 relative to the statistic's magnitude (sp.rdrboundsalready guarded its permutation count the same way), so both SciPy 1.15 and 1.18 give 438/924. Continuous statistics are unaffected: 1e-12 is below anything they resolve. - A causal-forest regression test pinned a macOS/arm64 number as if it were
a cross-platform contract, and had been failing on the Linux CI leg since
it landed. The GRF engine is bit-deterministic for a given
random_stateon a given machine (verified for 1-8 numba threads) but a split is an argmax over criteria, so a different summation order flips one and the subtree below it: jittering the test's design matrix by a relative 1e-15 moves its ATE by up to 4.7e-3, twenty times the 2.9e-4 that was being called a failure. The pins are now coarse canaries, and the invariant they were reaching for -- two fits of the same data agree bit for bit -- is asserted directly, where it holds everywhere. sp.continuous_did(...)raisedValueError: output array is read-onlyunder pandas 3. Copy-on-write hands.to_numpy()a read-only view of the frame's own buffer, and the missing-value mask was built up with an in-place&=. The mask is now materialised withcopy=True.-
sp.causal_forest(data=, y=, d=, x=[...])dropped the feature names, socf.effect(df[x]),cf.predict(df)and the BLP / importance tables sawX0, X1, ..., and a DataFrame with the right columns raised "missing effect-modifier column(s)". The names are now kept. -
CI mypy gate was not type-checking anything. With
python_version = "3.9"in[tool.mypy], mypy followed imports intocoverage/debug.py(which usesmatch), hit a blocking[syntax]error and stopped before checkingsrc/statspai.scripts/quality_gate.pytreated that aborted run as valid, so the gate reportedobserved=1 baseline=25and passed. The mypy target is now 3.10, the interpreter the gate runs on (3.9 compatibility is still covered by the 3.9 test leg). The gate now trusts a run only when mypy's summary says it finished checking;errors prevented further checkingfails it.DEFAULT_MYPY_MAXis reset from 25 to 828, the first real measurement (lean CI env, py3.10, mypy 1.20). The ratchet still only goes down.
[1.29.0] — 2026-09-22¶
⚠️ Cross-language parity campaign, phase 3 (StatsPAI vs R / Stata)¶
Two rounds held 14 estimator families against their canonical R or Stata
reference on identical data bytes. Estimator callables with a cross-language
grade went from 224 to 415 of 773 (29.0% → 53.7%); callables with any
numerical evidence from 429 to 540. 174 defects were fixed (round 1: 125,
round 2: 49), most of them changing default output — see
MIGRATION.md before rerunning an
analysis. Per-family findings, before/after numbers and open items are in
docs/dev/campaign_phase3/.
Round 1. Cross-language parity campaign, phase 3. Estimator callables with a cross-language (R / Stata) grade went from 224 to 362 of 773 (29.0% → 46.8%). The campaign fixed 125 defects: survival / epidemiology 11, time series 7, inference / sensitivity 7, panel / GLMM 12, treatment effects 12, DiD / synthetic control 37, RD / IV 17, spatial / survey / structural 22 (each numbered item in the family reports counted once; docstring-only and label-only corrections not counted). Most were found by running the named R or Stata reference on the same CSV bytes; the rest turned up while building those comparisons, from a known-truth DGP or from reading the code, where no package computes the quantity. None was caught by the existing unit tests. See MIGRATION.md.
Round 2. Cross-language parity campaign, phase 3, round 2. Estimator callables with a cross-language (R / Stata) grade went from 362 to 415 of 773 (46.8% → 53.7%). The round fixed 49 defects: time series 1, spatial / structural (frontier, MGWR) 4, treatment effects 1, RD 2, decomposition and QTE 8, ML causal inference (grf operators, CDDF, off-policy evaluation, DML) 13, sensitivity / Mendelian randomization / transport / trial design 13, synthetic-control tools and shift-share 7 (counted as in round 1: each numbered item once; convention changes and docstring-, label- and citation-only corrections not counted). Each was found by running the named R, Stata or Python reference on the same data, or while building that comparison. None was caught by the existing unit tests. See MIGRATION.md.
⚠️ Correctness (round 1)¶
Survival and epidemiology
sp.cuminc: the delta-method variance used the wrong increment, and Gray's test was a different test. The Marubini–Valsecchi variance terms useF(t) - F(t_j); the code usedF(t) - F(t_{j-1}). At the first event time, where the variance is the binomialp(1-p)/n, the old value was 0.000228 against Statastcompet's 0.000282. Cause-1 SE at t = 0.25 / 2.0 / 7.0 goes from 0.015107 / 0.027944 / 0.032854 to 0.016795 / 0.030489 / 0.035325 (stcompetto 2e-16); CIs move with it. Gray's test was a log-rank on unweighted subdistribution risk sets with a hypergeometric variance. The risk set agrees with Gray's only without censoring, and the variance never does. It is now Gray's score and asymptotic covariance, matching Rcmprsk::cumincto 1e-14: cause 1 chi2 2.3950 (p 0.1217) → 1.8499 (p 0.1738), cause 2 0.5354 → 0.7835.sp.finegray: censoring weights at the wrong limit; SEs were the inverse information. The weights wereĜ(t)/Ĝ(T_i);cmprsk::crrand Statastcrreguse left limits,Ĝ(t-)/Ĝ(T_i-). The two differ whenever a censoring ties an event time. Coefficients (0.440740, 0.270487, 0.282621) → (0.440603, 0.270658, 0.281948) (crrto 6e-15). The default SE is now Fine and Gray's sandwich with the censoring-estimation term (vce="robust",crrvar): (0.084511, 0.168711, 0.168777) → (0.081090, 0.159197, 0.157572).vce="model"returns the old inverse information (crrinvinf);small_sample=Trueappliesstcrreg's N/(N-1).sp.cox_frailtyreturned the ordinary Cox fit. The beta step ignored the frailty offset, theta was searched on [0.5, 100] of a likelihood that was not the gamma marginal, and the SEs came from the plain Cox information. Rewritten: Newton fit of the gamma-penalised partial likelihood in (beta, w) for fixed theta, theta maximising the integrated likelihood (Rc.loglik, Statae(ll)), SEs from the full penalised information..thetais now the frailty variance, as in R and Stata; it was documented as a precision. Fixture: beta (0.480666, -0.506041) → (0.528795, -0.504217); SE (0.099537, 0.176958) → (0.105516, 0.187037); theta 0.500004 (precision, at its bound) → 0.240201 (variance; Stata 0.240199, chibar2 6.30 on both sides). Matches Rcoxph(frailty(sparse=FALSE))and Statastcox, shared().sp.roc_curve/sp.aucwere wrong with tied scores. The curve stepped one observation at a time, so a case–control tie counted 0 or 1 depending on an unstable argsort (0.6694 against the Mann–Whitney 0.6672 on one simulated draw). The AUC is now the Mann–Whitney placement value with ties counted one half: 0.68376068 → 0.68389966 on the fixture's tied score (pROC and Stataroctabto 2e-16).thresholds,tprandfprnow have one entry per distinct score, not per observation.sp.kdensity:bw_method="sheather-jones"returned the Silverman rule, and the default Silverman width matched neither reference. The Sheather–Jones branch was literally0.9*a*n^(-1/5); it now solves the equation without binning: 0.5222 → 0.5592 (2e-7 from Rbw.SJ(nb=1e7, tol=1e-14), 1.8e-3 from R's binned default). The default combined R's quantile type with Stata's 1.349 constant; it is now exactly Rbw.nrd0(1.34): 0.522176 → 0.525683 (+0.67%). This second change is a choice between two constants, made because the old value matched no reference;bw_method="stata"reproduces Statakdensity's width.sp.cox(ties="breslow")ran Efron's approximation. The code comment said "Breslow currently reuses the Efron helper". On tied data x1 goes from 0.355720 to 0.335603 (coxphandstcoxBreslow to 1e-9).ties="efron"is what the old call ran.
Time series
sp.panel_unitroot: three of the four tests were not the named test. LLC wassqrt(N)·mean(t_i) + sqrt(N), with no long-run variance, pooled regression or adjustment table. IPS used rounded moments that did not depend on T or lags, and invented moments fortrend='n'. Fisher combined normal-CDF p-values instead of MacKinnon (1994) p-values. Hadri treatedtrend='n'as a trend. Rewritten after Stata_xturllc/_xturips/_xturfisher/_xturhadriandplm::purtest, withconvention='stata'(default) or'plm',dfcor=androbust=; matches both to 1e-10. Onts_panel.csv(lags=1): LLC −7.80 → −4.656, IPS −6.238 → −6.109, Fisher P 239.1 → 117.09 (each =xtunitroot). On a random-walk panel LLC went from −0.833 (p 0.20) to −0.0016 (p 0.50).sp.engle_grangercompared its statistic with critical values for a different regression. The residual ADF regression included a constant; the MacKinnon Engle–Granger critical values are tabulated for step 2 without deterministic terms.trend('c' / 'ct' / 'ctt') now enters step 1, as inegranger;trend='ct'andalphaused to be ignored, NaNs were not dropped, and the asymptotic three-decimal critical values were wrong for N ≥ 3 (N=3 5%: −3.78 against −3.74066). Critical values are now the MacKinnon (2010) response surface at T = n−1,alpha∈ {0.01, 0.05, 0.10}. y1~y2, L=0: Z −9.6167 → −9.6364 (Stataegranger−9.6364), 5% critical value −3.34 → −3.3608; withtrend='ct': Z −8.6168 → −8.4234, critical value −3.34 → −3.8190. Matchesegrangerandurca::ur.df.sp.johansencritical values. Max-eigenvalue 5% values for k−r = 3 and 4 were 21.12 and 27.42 (Stata and Osterwald-Lenum: 20.97, 27.07);trend='n'and'ct'used the unrestricted-constant table (for 'n', trace k−r=3 should be 24.31, not 29.68); k=1 trace used 3.84 instead of 3.76;alphaand an invalidtestwere ignored. Now Stata's full 5-case table, with the restricted casestrend='rc'/'rt'and Stata's trend names;alpha∈ {0.05, 0.01}. The statistics were already right; only the rank decisions change.sp.bvarbuilt every equation's prior from the last equation. One prior-variance vector was overwritten in a loop, so the own-lag variance went to the wrong variable and results depended on column order (gdp own-lag 0.901 with columns (gdp, infl, rate), 0.637 after reordering). The prior variances were also scaled by s_k² a second time. Now the Minnesota prior with fixed Σ₀ of Statabayes, minnfixedcovprior: var, solved in closed form; newlambda3,lambda4andsigma='ar'|'var'. gdp own-lag 0.901 → 0.6382 (Stata MCMC 0.6383 ± 0.00017).sp.garch: pre-sample variance and optimiser. The pre-sample variance wasvar(eps); Stataarchandrugarchusemean(eps²)at the current μ. Nelder–Mead stopped short; the fit now uses an analytic score, BFGS and a Newton polish.forecast()ignored every ARCH / GARCH lag beyond the first. Log-likelihood −2144.43397943 → −2144.43398728 (Statae(ll)−2144.43398728); μ 0.0629375 → 0.0629347 (Stata 0.0629347).sp.cusum_testboundary.alphavalues other than .01 / .05 / .10 silently used 0.948. The boundary coefficient is now the exact root of the crossing probability (0.9478982 at 5%); the default boundary array moves by 1e-4 relative.sp.structural_breaksup-F grid stopped one candidate short ofstrucchange::Fstats(from=0.15). The sup-F changes only when the maximum sits at the last point.
Inference and sensitivity
sp.ivreg(vce="wild")/ the WRE IV wild bootstrap counted ties and never enumerated. It now uses every Rademacher sign vector when2**G <= n_bootand a strict-inequality p-value, as Stataboottestdoes. On the 16-cluster weak-instrument fixture the p-value is 22361 / 65536 = 0.3412017822, equal toivreg2+boottest(0.34120178) instead of agreeing within simulation error; results reportenumeratedandn_boot_requested.sp.wild_cluster_bootstrap,sp.wild_cluster_boot,sp.subcluster_wild_bootstrap(andsp.panelfeolswildp-values) never enumerated and counted ties. Stataboottestand Rfwildclusterbootenumerate the Rademacher grid when2^G <= repsand count|t*| > |t|strictly; StatsPAI always sampledn_bootdraws and counted>=, which under enumeration adds 2/2^G. G = 12,n_boot=9999:dp 0.92569 → 0.9306640625 (= 3812/4096, both references);x20.054505 → 0.0546875; subcluster 0.90999 → 0.91259765625 (Stata). The change applies whenever2^G <= n_boot(G ≤ 9 at the default 999) and by at most 2/B otherwise.n_bootnow reports the draws used.sp.wild_cluster_ci_invinterpolated the CI endpoints. p(h0) is a step function; the endpoints were linear interpolations on a 41-point grid. Each bracket is now bisected to the jump.dCI (−1.114049, 1.103695) → (−1.121498336078, 1.103681178126), equal tofwildclusterbootto 4.8e-12. It warns when the grid does not bracket an endpoint (it returned the grid edge).sp.fisher_exact/sp.ri_testsampled designs they could enumerate. With no more assignments thann_perm(s)the distribution is now exact (Rri2): simple design p 0.1443 → 132/924 = 0.142857…, stratified 0.0315 → 148/4900 = 0.0302040816.sp.rosenbaum_bounds/sp.rosenbaum_gamma: continuity correction, zeros, and the two-sided bound in the wrong tail. A 0.5 continuity correction that no reference applies is gone; zero differences are ranked with weight 0 (zero_method="pratt", RDOS2and Statarbounds) instead of dropped; "less" and two-sided bounds use the correct tail. Upper bound, 40 pairs, Γ = 1, 1.5, 2, 2.5, 3: (0.0022, 0.0302, 0.1077, 0.2232, 0.3525) → (0.002835, 0.041828, 0.149128, 0.301256, 0.459566). Two-sided on the negated data: (4.2e-3, 7.7e-5, 1e-6, 0, 0) → (0.00567, 0.0837, 0.2983, 0.6025, 0.9191); the old bound called a negative effect insensitive to any hidden bias.sp.oster_boundshad the two R² gains swapped. It computed β* = β̃ − δ(β̊−β̃)(R̃²−R̊²)/(R_max−R̃²) instead of … (R_max−R̃²)/(R̃²−R̊²), and only the approximation. From data it now solves Oster's exact problem as Statapsacalcdoes (method="exact"); from summary statistics it uses the corrected approximation. R_max = 1.3 R̃² = 0.61269: δ* 1.20492 → 0.904199433 (psacalc0.9041994327987157); β*(δ=1) 0.078546 → −0.071462629.sp.oster_deltaread the defaultr_max=1.3as an R² of 1.3. Values above 1 are now a multiplier of R_full (default min(1, 1.3 R_full), as documented), and the solution is exact. Defaults: δ* 0.16189 → 0.90420; β*(δ=1) −2.39096 → −0.07146.sp.pate(method="aipw")was not doubly robust. The augmentation was normalised by the sum of all odds weights and rescaled by n_exp / n_tgt. With a correct participation model and a misspecified outcome model the bias was −0.168 (MC SE 0.013); with per-arm Hájek normalisation it is −0.001 (MC SE 0.016). No package computes this estimator; the check is on known truth.
Panel and GLMM
sp.meglm(family="gaussian")fixed the residual variance at 1. SE of x1 was 0.0428 against Stata /lmer0.0335, and point estimates were off in the third digit. σ² is now estimated; the fit equalssp.mixed(method="ml")and Statameglm(4.2e-9 / 2.8e-7).- GLMM and
sp.mixedoptimisers stopped short.meglmstopped on L-BFGS-B's relative function change, 2.7e-4 from the optimum:mepoissonLaplace_cons0.3451384 → 0.3450937699 (Stata 0.34509377003).sp.mixedREML / ML stopped ~1e-6 short (balanced-design REML ICC 1.04e-6 from the ANOVA value, now 7e-11). Both now finish with Newton steps. - GLMMs with non-canonical families (NB-2, gamma, ordinal logit) used the
Fisher curvature in the Laplace approximation and AGHQ scaling (lme4's
PIRLS convention). Stata, glmmTMB and
clmmuse the observed curvature, now the defaultcurvature="observed". Gamma_cons0.6196 → 0.6372 (Stata 0.63715030, glmmTMB 0.63715031); NB_cons0.4480 → 0.4728; ologit x1 0.6816 → 0.6865.curvature="expected"gives the old approximation. sp.meologitSEs held the variance components fixed, the formulameglmdropped in 1.24.0, and there were no threshold SEs. Now the inverse observed information over (β, thresholds, covariance), with delta-methodthresholds_se.sp.iccreturned NaN for every GLMM, used a heuristic SE (var(log σ²_u) ≈ 2/n_groups) and failed on three-level fits. It now gives the latent ICC formelogit/meologit, raises for count / gamma GLMMs and random-slope models (Stataestat iccrefuses them too), uses the delta method and a logit-scale CI asestat icc(mixed ML SE → 0.0533785 = Stata), and returns Stata's level ICCs for three-level fits (SE NaN, warned).MixedResult.n_paramscounted the residual variance twice: AIC was +2 and BIC +log n against Statae(k)/ RAIC().sp.xtnbreg(model="fe" / "re")computed different estimators."fe"was an unconditional NB-2 with entity dummies,"re"a normal random-intercept NB-2 GLMM. Both now compute Stata's (andpglm's) Hausman–Hall–Griliches models: conditional FE and beta-dispersion RE. z1 0.418 → 0.3783114 (Stata 0.3783114). The old estimators aremodel="ufe"andmodel="normal_re".sp.interactive_feSEs were not Bai's. Regressors were projected with M_F only; Bai's D0 uses M_Λ X M_F. xa SE 0.03194 → 0.03304 (default dof) / 0.0331716 (dof="regife", = Stataregife). Cluster SEs now carry the CR1 factor, final residuals use the final factors, and the defaulttolis 1e-10 (was 1e-6).sp.gmmwith nonlinear moments stopped ~1e-8 short and reportedconverged=False. A Gauss–Newton finish (Statagmm's algorithm) puts the estimates 1.2e-12 from Stata.sp.mixlogitreported negative SDs, perturbed the model with hidden+1e-6/+1e-8terms on the Cholesky diagonal and SDs, and aH + 1e-8 Iridge on the Hessian, and its robust variance lacked Stata's N/(N−1). All removed; with Stata's Halton draws the log-likelihood agrees with Statamixlogitto 5e-13.
Treatment effects
sp.ltmlewas a different algorithm, and the binary ATE came out doubled. Pseudo-outcomes were fit by linear regression, only the current treatment was set to the regime, and binary outcomes were updated onlogit(Y)withYclipped to 1e-6. Re-implemented as Rltmle's sequential-regression TMLE. Binary ATE / SE 0.75744 / 0.03477 → 0.37280 / 0.04434; continuous 1.95265 / 0.09166 → 2.04340 / 0.10320 (both = R).propensity_boundsnow bounds the cumulative g, default(0.01, 1.0)(was(0.01, 0.99)per step).sp.gformula_ice_fn(bootstrap=0)reportedsd(Y)/sqrt(n)as the SE. It is now the sandwich of the stacked sequential OLS equations: 0.05909 → 0.06830 (geex0.06830). The point estimate does not change.sp.aipw(estimand="ATT")SE omitted the-tau D / pterm of the ratio estimator's influence function: 0.10180 → 0.08987 with the default cross-fit (checked against DoubleML's ATTE score).sp.multi_treatment: the multinomial-logit GPS was sklearn's L2-penalisedLogisticRegression(), the bootstrap refit a different outcome model (50 trees) from the estimate (100 trees), and arms with ≤ 2 units silently got a zero outcome model (now an error). ATE(1 vs 0) 1.163538 → 1.163558; bootstrap SE (B=50) 0.08920 → 0.09146.sp.lee_boundstrimmed one observation too few (floor((1−p) n)). The default is now Lee's sample-quantile rule (trimming="quantile"): (0.71763, 2.61867) → (0.72453, 2.61202).sp.survivor_average_causal_effectand thesp.principal_stratSACE collapsed the bounds. Outcomes missing for non-survivors were dropped with their rows, which set P(S=1|D) = 1: (1.68442, 1.68442) → (0.72453, 2.61202), now equal tosp.lee_bounds.sp.principal_strat"Complier (LATE)" reported E[Y(1)|complier]. It now reports the Wald LATE: 6.39543 → 7.35517 (AER::ivreg7.35517).sp.manski_bounds(assumption="mts")also imposed MTR and swapped the endpoints when the naive difference was negative."mts"is MTS alone; the joint assumption is"mts_mtr": [0, 0.32812] → [−0.336, 0.32812] (Statatebounds).sp.horowitz_manskidropped strata missing a treatment arm, and with them their probability mass: [−0.332, 0.629] → [−0.337, 0.663] on a constructed case, equal to the unconditional bounds.sp.mediate_interventional(tv_confounders=)plugged in the marginal mean of L for both arms, deleting the D→L→Y path. IDE 0.57441 → 1.10616, total 0.81801 → 1.34976 (CMAverserpnde/te).sp.g_estimationignoredpropensity_covariatesand always fitted a linear-probability propensity. The default is now DTRreg's logistic propensity: psi (1.05636, 0.94856) → (1.05524, 0.94731) = DTRreg; on NHEFS 3.4626 → 3.4611485591 (What If Program 14.2's logistic g-estimate).propensity_model="linear"gives the old numbers.sp.ipcwgave censored rows the same weight as observed rows (np.where(d == 1, w, w)); they now get 0, as documented (was ≥ 0.77).method="cox_ph"crashed with more than one covariate and accumulated the Breslow hazard in data order.
DiD and synthetic control
sp.sdid/sp.synthdid_estimate/sp.sc_estimate/sp.did_estimate: none of the three SEs was synthdid's. The placebo SE refit each control from scratch (one placebo unit,ddof=1,n_repsandseedignored); the bootstrap resampled controls only; the jackknife dropped controls and refit the weights. They now followsynthdid::vcov: random permutations warm-started from the full-sample weights with frozen regularisation and divisor r, all-unit bootstrap, leave-one-unit-out jackknife with fixed weights. Per draw they agree with R to 2.1e-11. Prop. 99 placebo (seed 42): sdid 2.6041 → 2.5630, sc 3.4510 → 3.4350, did 4.9324 → 5.0013. Five-treated panel jackknife: sdid 0.1248 → 0.4382, sc 0.2378 → 1.5116, did 0.3189 → 0.7810 (= R to 1e-14). An SE synthdid leaves undefined is NaN with a warning; Prop. 99 jackknife (one treated unit) 0.7476 → NaN. Point estimates are unchanged.sp.breakdown_mignoredmethodand did not invert the set it names. It returned(|θ̂| − z·SE)/(e+1)for every input. With smoothness and a covariance it now root-finds on the FLCI thatsp.honest_didreports (R HonestDiD with an exact quantile to 6e-11): e=0 / 1 / 2 on a hand-crafted event study 0.1512 / 0.0967 / 0.0772 → 0.15345 / 0.06460 / 0.03829; mpdta CS e=0 0.019253 → 0.007990.relative_magnitudeinverts the native RM interval (mpdta 0.019253 → 1.948) and warns; without a covariance the closed form is kept and warns.sp.honest_did(method="smoothness"), native: false SLSQP convergence widened the FLCI. The worst-case bias b(h) froze at its start value whenever the variance constraint was slack there (12% off a bound at M = 0.02, 123% at M = 0.05). Fixture e=1, M=0.02 lower bound 0.10497 → 0.11913 (HonestDiD 0.11913); mpdta CS e=1 lower bounds −0.081896 / −0.121360 → −0.078258 / −0.107401 at M = 0.01 / 0.02. The best h is refined past the grid, andci_lower,ci_upperandMare no longer rounded to 6 decimals.sp.continuous_did(method="twfe"): SE degrees of freedom and unbalanced panels. The iid SE divided byn - 1although the unit and period effects were absorbed (0.042912363 → 0.047212137, fixest 0.0472121368); the clustered factor omitted period effects not nested in the cluster (0.049641066 → 0.049849497); and the one-pass double demeaning gave a wrong slope on unbalanced panels (0.403496461 → 0.402987816, SE 0.044333346 → 0.049373238, both = fixest). Matchesfixest::feolsto 1e-15.sp.continuous_did(method="att_gt")bootstrap. Each drawn unit was kept once however often it was drawn (a subsample), and the pooled SE assumed independent dose bins although they share one comparison arm. Pooled SE 0.10639 → 0.18500 (unbalanced 0.11190 → 0.18991); point estimates unchanged. With no zero-dose units the SE was NaN; it is now finite andmodel_info["control_arm"]names the arm used.sp.continuous_did(method="dose_response")reported the average pointwise SE of the fitted level as the SE of the average slope. It is now a unit bootstrap of the average slope: 0.243084 → 0.154392 (unbalanced 0.255191 → 0.146908). Thelinregressfallback warns and is recorded inmodel_info["fallback"]instead of switching silently.sp.did_timevarying_covariateswas a different estimator. Comparison units' covariates were frozen at their median period instead of the cohort's g−1, and the adjustment was a pooleddY ~ D + Xregression. It is now the outcome-regression ATT(g,t) ofptetools::pte_default/did::att_gt(est_method="reg")(to 1e-14), and the headline is the group aggregation: 1.273390 → 1.257705 (ptetools 1.25770523749721); ATT(4,4) 1.242550 → 1.823768.sp.mc_panel/sp.mc_synth/sp.matrix_completion/sp.synth(method="mc")never estimated the fixed effects. Both references (MCPanel,fect) default to unpenalised unit and time effects; StatsPAI fitted soft-impute with no intercept, so the nuclear norm shrank the outcome level towards zero. Newfixed_effects="two-way"default. True ATT 2.344:mc_panel2.64068 → 2.37710, at lambda 8 2.93426 → 2.38462 (MCPanel 2.38462);mc_synth(seed=0)1.66626 → 1.32783; Prop. 99mc_synth(placebo=False, seed=0)−13.119 → −17.980. The stopping rules (tol1e-5 / 1e-6,max_iter1000 / 500) left about 8e-5 relative error in the ATT; nowtol=1e-10,max_iter=5000, and hitting the cap warns.mc_panelreturns p = NaN, not 0.0, when the SE is 0.sp.spillover_didignored ring exposure timing under staggered adoption. Every ring unit entered every cohort's comparison. Each ring is now measured from its exposure onset: ring 1 0.79123 → 1.23765, ring 2 0.25243 → 0.39661 (Rdid1.2376 / 0.3966). SEs now include the cohort-share weight term: direct SE 0.077918 → 0.091366 (did). Single- cohort output is unchanged.sp.harvest_did: pre-period placebo cells used the treated cohort as its own control (cohort 5, e=−4: −0.3550 → −0.5104,did−0.5104); the event-study, aggregate and pre-trend inference assumed independent cells, understating the headline SE by about 40% (0.06207 → 0.10148; estimate 1.79243 → 1.78531; pre-trend p 0.303 → 0.170); and cell SEs switch from Welch (ddof=1) todid's influence-function convention (divisor n).sp.causal_impact: the SE of the average and cumulative effect treated post-period forecast errors as independent although they share the AR(1) state. SE 0.23480 → 0.28516,se_total7.0439 → 8.5547; point estimate unchanged. Pinned by a dense-Gaussian identity, not by RCausalImpact, which fits a different model.sp.scpiwas not the Cattaneo–Feng–Titiunik procedure. It subsampled pre-periods for the in-sample part, used a residual variance for the out-of-sample part, builteffect ± z·sqrt(var_in + var_out)and reported an SE and p-value R does not define. It is now a port of Rscpi4.0.1's single-treated-unit path. Germany, simplex: 1991 effect interval [0.230, 0.774] → [−0.731, 1.316] (R, same weights: [−0.700, 1.302]); 2003 [−4.32, −2.61] → [−5.25, −1.11]; average-effect CI (−2.29, −1.05) → (−3.29, −0.22). The old intervals were 2–4× too narrow.se/pvalueare NaN; nominal coverage is1 - (u_alpha + e_alpha).sp.scest(w_constr="lasso" | "ridge")were penalised fits on standardised data. Rscpiconstrains||w||_1 ≤ Q(Q = 1) and||w||_2 ≤ Q(Q fromshrinkage.EST). Germany max |w − w_R| 0.227 (lasso) / 0.194 (ridge) → 1.8e-12 / 1.4e-5."simplex"and"ols"are unchanged.sp.ssaggregateAKM SE was the wrong formula. It summeds_ik Z_i e_iinstead of AKM'shX_k s_k'eand was anti-conservative. SE ofx: IV + controls 0.06646 → 0.29044, IV intercept only 0.04967 → 0.27941, reduced form 0.22565 → 0.96533 (= RShiftShareSE= Stataivreg_ss). TheSE (HC1)diagnostic dropped the first stage: 0.17750 → 0.27585. p-values and CIs use z, as both references do. Point estimates unchanged.sp.shift_share_seused the second-stage fitted values as the instrument: 0.017595 → 0.290442. It now reruns AKM on the inputs thatsp.bartik/sp.ssaggregaterecord, and raisesValueErrorfor other results instead of returning a number.sp.staggered_synthused contaminated donors and was not Ben-Michael, Feller & Rothstein's estimator. Donors for cohort g included units adopting after g while the effect window ran to the panel end, so later adopters' treated outcomes entered earlier cohorts' counterfactuals; the ATT was weighted by post-period count. Rewritten asaugsynth::multisynth's QP (ATT to 5.6e-12, jackknife SE to 1.8e-11):method="separate"2.664881 → 2.597125,"pooled"2.597259 → 2.526639.penalizationis now multisynth'slambdaon the normalised objective.sp.discosaveraged individual-level data away. It pivoted to unit-period means and treated each unit's time series as its distribution. On individual-level data it now runs the Gunsilius estimator of RDiSCos::DiSCo0.1.4 (defaultmethod="quantile"): truth 0.9,"quantile"1.249059 → 0.924513,"mixture"0.921300 → 0.967711. Aggregate panels keep the old heuristic with a warning; its weights are now solved exactly (Prop. 99 −23.0638600 → −23.0638480, 30 s → 0.7 s).sp.demeaned_synth/sp.robust_synthplacebo p-values compared different statistics: the treated unit's mean squared post gap against each placebo's squared mean post gap, which is anti-conservative. Null DGP: p 0.111 → 0.222 (demeaned) and 0.111 → 0.444 (robust). Point estimates unchanged up to solver noise (4e-10).sp.robust_synth(variant="elastic_net", l1_penalty>0)penalised the intercept and thresholded atl1instead ofl1/2. Atl2=5, l1_penalty=1e-12the intercept jumped from 20.7388 to 0.5346 and the ATT from 3.0336 to 1.9978; now 20.7388 / 3.0336, matching glmnet to 1e-11. The defaultl1_penalty=0is unaffected.
RD and IV
sp.tF_critical_value/sp.tF_adjustment: the table was not Lee, McCrary, Moreira & Porter's. c(10) was 3.16 (LMMP / ivDiag: 3.4353), c(15) 2.54 (2.8662), c(50) 1.98 (2.1529), and it returned 1.96 from F = 75 on instead of from 106.09. Every value was too small. Now RivDiag::tF's table and interpolation, 0 error on 26 F values. Below F = 4 it returnsinfwhere ivDiag clamps to 18.66.sp.iv_diag/sp.anderson_rubin_test/sp.weakrobustlooked tF up at the homoskedastic F although the t-ratio uses HC1 / cluster SEs. Onrd_iv_ivw: 18.66 at F=3.96 → 12.238 (HC1, F_eff 4.293) / 16.475 (cluster). With more than one instrument tF is NaN (None inanderson_rubin_test), as in ivDiag.sp.iv_diagse_olsignoredvcovandcluster: 0.030699 → 0.035494 (HC1) / 0.039353 (cluster), = ivDiag.sp.weakrobust,sp.conditional_lr_ci,sp.k_test_ci,conditional_lr_test: CLR and K statistics. With two or more instrumentsZ L^-1was not orthonormal, so every statistic was mis-scaled: CLR 0.0094457 → 0.0090722 (ivmodel0.00907221605522), K 0.0090481 → 0.0086880 (Stata 0.0086879820). K "at h0" was read off the nearest grid point (31.889 vs 32.144 on the docstring example); it is now evaluated at h0. The CLR critical value was simulated (the set's lower end moved −4.43 / −3.79 between seeds 0 and 1);method="exact"(default) integrates it: set −3.7680547 (ivmodel−3.7680086)."simulate"/weakrobust(clr_method="simulate")keep the simulation.sp.jive:variant="jive2"kept observation i (fitted/(1−h)): 0.18519 → −3.38682 (Stataujive2−3.386819). The default SE wass²(X̂'X)⁻¹without the sandwich: 3.3019 → 7.0217 (Stata 7.021736).sp.rdrobustraised an explicitp ≤ derivtoderiv+1:rdrobust(deriv=1, p=1)returned the p=2 estimate (1.3843 against R 0.9933).pdefaults to R's rule (1, or deriv+1); an explicitpis honoured;deriv > praises. A window with too few observations (h=1e-4, estimate 5.4e-6, SE 6e-22, p 0.0) now raisesDataInsufficient, as R errors.sp.rkd: the default bandwidth was a rule of thumb and the fuzzy SE left out the covariance of the two kinks. Nowrdrobust(deriv=1, vce="hc1"): h 0.21760 → 0.21801, estimate 1.03151 → 1.03463; fuzzy SE at h=0.4 0.225889 → 0.217965 (R 0.217964991849126).sp.rdplot: an "IMSE" rule with a 0.7 fudge factor chose the bins (esmv J = (17, 16) → (38, 41) = R), andkernelnever reached the fit. Bins, bin CIs (R's t intervals) and the polynomial are now Rrdplot's; the default kernel is R's"uniform", which keeps the old curve.sp.rdplotdensityused a per-side ECDF, a rule-of-thumb bandwidth and a heuristic SE. Left density at the cutoff 0.6045 → 0.2325 (R); the old curves were about 2× too high.sp.mccrary_testwas a different estimator under McCrary's name, with silent fallbacks (density 0.01, SE 0.1). Now Rrdd::DCdensity: θ 1.3036 (SE 0.2756) → 1.2707 (0.1910). The automatic check insp.rdrobust(...).model_info["mccrary"]changes with it.sp.rdhte/sp.rdbwhte/sp.rdhte_lincom: SEs were the order-p HC1 (0.0697) instead of Rrdhte's robust bias-corrected HC3 (0.0993); default h 0.1506 → 0.3893 (Rrdbwselect);bwas ignored; binary moderators were not treated as subgroups;bandwidth_hwas rounded to 6 decimals; degenerate fits returned zeros with a1e10·Icovariance.sp.rd_bias_aware_fuzzy: the bias boundh²M/12is below the local-linear worst case. Rebuilt on the RDHonest construction (bias from the realised weights, MROT forM_y/M_d, RDHonest's bandwidth, nearest-neighbour variance). Fixed h=0.4, M=(2, 0.5): (1.1549, 2.3799) → (1.1327, 2.3635); defaults (0.62, 3.92), h 0.141, M_y 89.7 → (0.941, 3.143), h 0.1713, M_y 35.4 (= RDHonest).sp.rdrboundsused one median split with fixed-n assignment instead of the extremum over all thresholds with Bernoulli assignment. Upper bounds at Γ = 1.2 / 1.5 / 2 were 0.012 / 0.056 / 0.159 against R 0.021 / 0.094 / 0.346. Now R's algorithm; agreement is within Monte Carlo error (T3).
Spatial, survey and structural
W.transformdiscarded the constructed weights. Every style was rebuilt from binary weights, so kernel and inverse-distance W became binary under"R"(Georgia row 146 was uniform 0.023256; now the kernel / row-sum values 3.09e-5, 7.19e-5, …)."V"lacked the globaln / Qrescale: Columbus queen weights summed to 105.2985, now 49 (= n, spdep style S).sp.gwrkernels differed from GWmodel and mgwr. The exponential kernel was truncated at u ≥ 1, adaptive Gaussian and exponential used only the k nearest points, fractional adaptive bw was rounded up and bw > n capped. AICc: Gaussian adaptive 40 896.3499 → 894.1283726, exponential adaptive 40 896.7510 → 891.2529635, exponential fixed 60000 1105.0634 → 897.9314442 (= GWmodelgwr.basic).sp.gwr_bandwidth(criterion="CV")minimised in-sample RSS, which is monotone in the bandwidth, so it always chose the smallest candidate. Now the leave-one-out score: adaptive bisquare 7 → 147, fixed 48990 → 316468.4969 (GWmodel 316468.4968965). The golden-section search is now GWmodel'sbw.gwr(bounds, floor / round probes, stop;tol1e-3 → 1e-4): fixed AICc bandwidth 211025.27 → 210996.3347.sp.mgwrrecovered β as f/x (0 wherever x = 0) and silently reused the previous bandwidth when a search raised. β is kept directly, the exception propagates, and non-convergence warns. Other numbers are unchanged.sp.sarar_gmmwas not GS2SLS. Stage 3 filtered the instruments, stage 1 used only [X, WX], the β and ρ SEs were stage-1 SEs printed next to stage-3 estimates, and λ came from Nelder–Mead. Rewritten asspatialreg::gstsls: Columbus const 43.97302 → 43.54044357 (= gstsls), ρ 0.453550 → 0.46178656, λ −0.006961 → −0.01698126, SE(const) 11.2365 → 10.6284221.sp.spatial_panel: SAR / SDM β SEs were conditional on ρ; they now come from the joint information matrix, assplm: SE(lpcap) 0.02535161 → 0.02544250 (splm), SE(ρ) 0.02108514 → 0.02351640. ρ was found by bounded Brent with the default tolerance and bounds clipped to ±0.99: 0.27468821 → 0.2746887114326. SDM witheffects="twoways"lagged the demeaned X, W·(QX), instead of Q·(WX): ρ 0.367330749 → 0.368846212 (splm 0.3688462096).sp.svydesign: fpc given as population counts was divided by the number of elements instead of PSUs; the SE was NaN, now 0.49514353151367907 (R 0.4951435315136789).sp.svydesign: design df collapsed to 1 when PSU ids repeat across strata. CI [6.7114, 20.2148] → [12.34201491229616, 14.58418673639966] with df 17, equal to R and Stata.sp.svyglmbinomial and Poisson used the Gaussian bread. Logit SEs [0.1179, 0.0385] against R [0.5098, 0.1731]; Poisson [0.4900, 0.1605] against [0.2138, 0.0502]. Now to 3.9e-14.- The design effect of a survey mean depended on the weight scale: DEFF 204.877 → 3.9032222795694005 (R 3.9032).
sp.rakestopped on an absolute change in O(1/n) weights, leaving a 4.3e-4 margin error at n = 100 000. The rule is now relative margin error,tol=1e-10; unknown or missing categories raise.sp.lcsf/sp.zisfstopped about 1e-3 short, with a fixed-step finite-difference Hessian. Newton polish plus a Richardson Hessian: distance from sfaR 1.15e-3 → 1.1e-8 (estimates), 1.44e-3 → 8.6e-8 (SEs).sp.metafrontier(cost=True)returned TGR ≡ 1: the sign of the gap was wrong. TGR min / mean / max 1 / 1 / 1 → 0.170 / 0.445 / 1.0.sp.blpSEs and elasticities. SEs were too small by √N (linear) and N (σ): the Jacobian was not scaled and the joint (β, σ) sandwich was missing. Linear SEs (0.00434, 0.00070, 0.00187) → (0.1075, 0.0201, 0.0474); σ SE 4.99e-5 → 0.0304. Elasticities ignored the random price coefficient (sigma_pricewas unused); the error against pyblp was over 1e-2, now about 1e-8.
Fixed (round 1)¶
Survival and epidemiology
sp.breslow_day_testwarns when it drops a stratum with a zero fitted cell (it dropped them silently).sp.power_case_control's sample-size search also steps downwards, so it finds the minimum from above (it only stepped up from the Wald closed form).docs/reference/survival.mdno longer advertisesties="exact", which was never supported.
Inference and sensitivity
sp.oster_bounds/sp.oster_deltawarn when they fall back becauser_max ≤ R̃²(the fallback was silent).
Panel and GLMM
sp.panel_comparerecords a failing method withWorkflowDegradedWarninginstead of swallowing the exception into an "error" cell.sp.lrtestwarns only when the χ̄² mixture is a bound; the old warning also fired in the single-added-effect case, where the formula is exact.sp.mixlogitreportsconvergedby the score;sp.gmmno longer reportsconverged=Falseat the optimum.- The GLMM module docstring no longer calls Laplace "Stata meglm default"
(Stata's default is
mvaghermite, 7 points); a Notes section maps each StataintmethodtonAGQ. The gamma docstring's claim that the observed information "would lose definiteness when y < μ" is removed. sp.interactive_fewarns when it drops units with missing cells (it dropped them silently).
Treatment effects
sp.lee_bounds(covariates=)is still ignored, but now with aUserWarning.
DiD and synthetic control
sp.sdid(covariates=...)raisesMethodIncompatibility; the covariates were ignored.sp.continuous_did(method="att_gt" | "dose_response")warns thatcontrolsare ignored.sp.mc_synth: the swallowedexcept Exceptionin the placebo and CV loops is removed; NaN cells of unbalanced panels are treated as unobserved instead of reaching the SVD. The citation string gives the correct author (Khashayar Khosravi).sp.mc_panel's docstring states the default-lambda heuristic the code uses.sp.harvest_didno longer claims to implement Abadie, Angrist, Frandsen & Pischke (2025), a survey chapter that defines no such estimator; the docstring describes the Callaway–Sant'Anna building blocks and the StatsPAI-specific precision aggregation.sp.causal_impactwarns thatn_seasonsis ignored. Its docstrings no longer describe a local-level Bayesian model "equivalent to" RCausalImpact; it is a regression plus AR(1) state-space model with frequentist prediction intervals.sp.scdataraises on duplicate (unit, time) rows (it averaged them), warns when it drops donors with missing pre-period data, and raises on missing post-period donor values.sp.ssaggregate(cluster=, alpha=)were ignored.clusternow gives the region-cluster row andalphasets the AKM / AKM0 CI level.sp.bartik(robust=...)used HC1 for any value other than"nonrobust"(e.g."hc3"); unimplemented values now raise. A zero Rotemberg denominator warns and reports NaN instead of all-zero weights.sp.robust_synth/sp.demeaned_synth(covariates=...)raiseNotImplementedError; the argument was ignored.model_infopre-fit MSPE / RMSE (robust_synth,demeaned_synth) and RMSQE (discos) are no longer rounded to 6 decimals.sp.robust_synth(variant="penalized")is relabelled "Ridge-penalized simplex SCM"; it was labelled Abadie & L'Hour (2021).- Placebo loops in
robust_synth,demeaned_synthanddiscoscatch onlyValueError/LinAlgError; the dose-response and time-varying-covariate bootstraps no longer use blanketexcept Exception.
RD and IV
sp.rlasso_effect(s)warns when a target is spanned by the selected controls (cps2012female:hsd08: hdm −4.7e13, StatsPAI 1.9e-36); no number changes.sp.lasso_ivis no longer attributed to BCCH (2012) in the docstring andmodel_info["method"], and no longer forms an n×n matrix.sp.ivqreg's docstring no longer claims agreement with Stataivqreg2or "R quantreg::ivqreg".
Spatial, survey and structural
sp.spatial_iv:alphawas ignored and the docstring said "Conley-style spatial HAC" for what is HC0 (sphethet=TRUE). Estimates and SEs are unchanged.sp.svydesign: a single-PSU stratum was handled silently and the docstring misstated R's behaviour; it now warns, andlonely_psu=offers R's rules. Default numbers are unchanged.sp.linear_calibration: the docstring stated the wrong objective; collinear calibration variables now warn. Numbers are unchanged.
Added (round 1)¶
Survival and epidemiology
sp.cuminc(variance="gray", conf_type="log-log", rho=): cmprsk's variance,stcompet's bounds, Gray's weight.sp.finegray(vce="robust" | "model", small_sample=).sp.cox_frailty(theta=, ties=); results carrylr_theta0(chibar2),log_frailtiesandloglik_cox.sp.direct_standardize(ci_method=, variance=)and.se;sp.indirect_standardize(ci_method=).sp.roc_curve(se_method="delong" | "hanley-empirical");sp.sensitivity_specificity(ci_method="exact").sp.kdensity(bw_method="stata" | "nrd0");sp.lpoly(se_method="stata", pwidth=);sp.power_case_control(test="chi2").- Parity file
tests/reference_parity/test_survival_epi_R_parity.pyagainst survival, cmprsk, epitools, DescTools, pROC, epiR and Stata.
Time series
sp.irf(cumulative=, sigma_df=);sp.its(hac_small_sample=).sp.granger_causalityreturnschi2/chi2_p_value;causingmay be a list (joint test).sp.structural_break(method="global")(strucchangebreakpointswith BIC), andsup_wald/sup_breakon sup-F results.sp.cusum_testreturnsstatistic,p_valueandboundary_coef.sp.garch(presample="stata" | "rugarch", vce="oim" | "opg" | "robust")andgarch_loglik().sp.panel_unitroot(convention="stata" | "plm", dfcor=, robust=).- Parity file
tests/reference_parity/test_timeseries_R_parity.pyagainst urca, aTSA, vars, strucchange, sandwich, rugarch, plm and Stata.
Inference and sensitivity
sp.rosenbaum_bounds(zero_method="pratt" | "wilcox"):"wilcox"isrbounds::psens's zero handling.- Wild-bootstrap results carry
enumeratedandn_boot_requested;sp.ri_testcarriesexact;sp.oster_boundscarriesmethodandbeta_adjusted_alternatives. - Parity files
test_inference_sens_R_parity.py/test_inference_sens_stata_parity.pyagainst sandwich, clubSandwich, summclust, fwildclusterboot, ri2, DOS2, rbounds, EValue, robomit and Stataregress/jackknife/boottest/ritest/rbounds/psacalc.
Panel and GLMM
curvature="observed" | "expected"onsp.meglm,sp.melogit,sp.mepoisson,sp.menbreg,sp.megamma,sp.meologit;MEGLMResult.thresholds_se.sp.xtnbreg(model="ufe" | "normal_re")for the previous estimators.sp.gmm(sandwich_weight="estimation")(Stata's two-step weight),sp.absorb_ols(cluster_df="min")(reghdfe),sp.interactive_fe(dof=),sp.mixlogit(halton_burn=, halton_shift=, small_sample=).- Seven parity files
tests/reference_parity/test_panel_*_parity.pyagainst lme4, glmmTMB, ordinal, performance, psych, pglm, phtt, gmm and Stata.
Treatment effects
sp.aipw(cross_fit=, se_method=):cross_fit=Falsefits the nuisances on the full sample andse_method="sandwich"reproduces Statateffects aipw;model_info["potential_outcome_means"]and_se.sp.multi_treatment(outcome_model="linear", se_method="influence" | "sandwich"): Statateffects aipwwith a multivalued treatment.sp.stabilized_weights(density_sd=),sp.msm(density_sd=):"ml"reproducesipw::ipwtm(family="gaussian").sp.tmle(q_bound=): 5e-4 reproducestmle::tmle.sp.lee_bounds(se_method="analytic", trimming="quantile" | "exact");trimming=onsp.principal_stratandsp.survivor_average_causal_effect.sp.g_estimation(propensity_model="logit" | "linear").sp.four_way_decomposition(vce="ols" | "ml")with delta-method SEs inresult.se(CMAverse /med4way).sp.manski_bounds(assumption="mts_mtr").sp.ltmlesupports censoring nodes as Rltmledoes.- Parity file
tests/reference_parity/test_teffects_R_parity.py.
DiD and synthetic control
- Private
statspai.did._twfe_weights.dcdh_fe_weights, bit-exact withTwoWayFEWeights::twowayfeweights(type="feTR").sp.twfe_decompositiondoes not use it yet (see below). - Alias proofs for
sp.bjs,sp.borusyak_jaravel_spiess,sp.did_2stageandsp.synthdid_estimate. sp.did_timevarying_covariates(aggregation="group" | "simple");model_infocarriesatt_groupandatt_simple.continuous_didresults carrymodel_info["dof_K"],["control_arm"],["dose_response_pointwise_se"]and["se_method"].sp.mc_panel/sp.mc_synth(fixed_effects="two-way" | "unit" | "time" | "none");model_infokeyslambda_mcpanel,lambda_fect,low_rank_matrix,fixed_effects_matrix,converged,n_iter. Both share the solver instatspai.matrix_completion._core.sp.scest(Q=, Q2=)andw_constr="L1-L2";sp.scpi(sims, u_missp, u_sigma, u_order, u_alpha, e_order, e_alpha, rho, rho_max, Q, Q2, draws)withmodel_infokeysbounds,CI,rho,Sigma,u_var,e_mean,e_var,df,vsig,failed_sims;drawstakes R's standard normals.sp.scdataalso returns R-namedA,B,C,P,J,KM,T0,T1.sp.ssaggregatereturns the Borusyak–Hull–Jaravel shock-level data set indata_info["shock_data"]and the shock-level IV coefficient and HC0 SE (= Stata / Rssaggregatethenivreg2, robust), plus everyShiftShareSErow (Homoscedastic, EHW, region cluster, AKM, AKM0 with the inverted CI).sp.bartikexposes Rotemberg weights inmodel_info["rotemberg_weights"]with the per-industry just-identifiedbeta_k.sp.staggered_synth(nu=, fixedeff=, n_leads=, n_lags=, se_method="jackknife")andmodel_info["event_study"],["weights"],["nu"],["global_l2"],["ind_l2"],["jackknife_atts"].sp.discos(M=, simplex=, q_nodes=, cdf_grid=)and DiSCo's permutation test inmodel_info["permutation"].model_info["mspe_ratio"]/["placebo_mspe_ratios"]indemeaned_synth/robust_synth.- Parity files
test_did_synth_R_parity.pyandtest_did_synth_{didvar,mc,misc,scpi,shiftshare,synthvar}_parity.py.
RD and IV
sp.weakrobust(clr_method=),conditional_lr_test(method=),sp.conditional_lr_ci(method=).sp.mccrary_test(bin_width=);sp.rdhte(q=, vce=);sp.rdbwhte(q=, bwselect=, vce=, cluster=);sp.rdhte_lincom(linfct=).sp.rkdadds the robust bias-corrected row inmodel_info["robust"];sp.rd_bias_aware_fuzzyreturns RDHonest's own interval inmodel_info["bias_aware"]["rdhonest"].fig.rdplot_data/fig.rdplotdensity_datacarry the plotted numbers.- Parity files
test_rd_iv_R_parity.pyandtest_rd_iv_rd_R_parity.py.
Spatial, survey and structural
sp.mgwr(bws=);sp.sarar_gmm(sig2n_k=, w_lags=)(w_lags=1is PySALGM_Combo);sp.spatial_panel(vce="information" | "oim")("oim"= Stataxsmle).sp.gwrresults carrycv,influenceandtr_StS; thesp.spatial_ivcoefficient table gainsz,p,ci_lowerandci_upper.sp.svydesign(lonely_psu=),svymean/svytotaldeff=,sp.svyglm(dof=).sp.metafrontier(envelope="own" | "all"); the default"all"is unchanged and"own"equals Rmetafrontier.- Parity fixtures against spdep, GWmodel, spatialreg, sphet, splm, Stata
xsmle, Rsurveyand Statasvy, sfaR,markupest,metafrontierand pyblp.
Changed (round 1)¶
sp.panel_unitroot: IPS and Hadri withtrend="n"raise.sp.oster_bounds/sp.oster_delta:r_max > 1raises inoster_bounds; inoster_deltaa value above 1 is a multiplier.sp.xtnbregacceptsmodel="fe" | "re" | "pooled" | "ufe" | "normal_re".sp.interactive_fe(method="pca")issues aDeprecationWarning; it was identical to"iterative".sp.manski_bounds: a refuted"mts_mtr"raises instead of reporting swapped endpoints.sp.scest/sp.scpi:lasso_lambdaandridge_lambdaare ignored with aDeprecationWarning; useQ=/Q2=.sp.scpi'speriod_resultsdropsin_sample_var/out_sample_varand addsjoint_lower/joint_upper.sp.staggered_synthraises on non-absorbing treatment, unbalanced panels, first-period adopters and cohorts without an eligible donor. Its QP is solved by an exact active set instead of SLSQP.sp.discoson aggregate panels (one row per unit-period) warns that it is not the Gunsilius estimator and labelsmodel_info["estimator"] = "time_series_quantiles_fallback".sp.rdrobust(p=None);sp.rdplot(kernel="uniform");sp.rdplotwith fewer than 20 observations andsp.rdplotdensitywith a side too sparse for rddensity raise, as R does;sp.rdbwhtereturns a DataFrame for subgroups;sp.mccrary_test'smodel_info["n_bins"]is the number of histogram cells.sp.gwr_bandwidth: default search bounds are GWmodel's ([20, n]/[D/5000, D]) andtoldefaults to 1e-4.
Not fixed in this release. sp.twfe_decomposition is not the
decomposition it names: its headline (−0.02718 on mpdta) is not the TWFE β
(−0.03751), its Bacon "earlier vs later" rows use the wrong window (2004 vs
2006: −0.0148 against −0.0327), all weights are 1/9, and its "dCDH" weights
are 7 positive cohort×period rows where TwoWayFEWeights gives 875 cell
weights, 125 of them negative. Its treated-vs-never rows and
model_info["twfe_beta"] are correct. A strict xfail pins the gaps.
Reference-implementation findings (round 1)¶
Places where the reference is wrong or not unique. StatsPAI does not copy them; each is reconstructed or asserted in the parity test.
- statsmodels
tau_2010s(Engle–Granger critical values) has two typos against the paper: N=2 1% β₂ −33.527 (paper −22.527) and N=3 5% β₁ −8.5632 (paper −8.5631). StatsPAI transcribes the paper. - Stata
_xturllc.ado, model 3: σ* = .971 at T = 40, where plm has .871 and the column is otherwise monotone (.906, .871, .842). The test rebuilds Stata's t* from StatsPAI's quantities with Stata's value._xturips.ado's trend-mean at lag 8, T = 60 is −2.204 where plm has −2.024; that cell is not exercised. - rugarch SEs differ by about 0.7%: at identical parameters StatsPAI's Hessian agrees with Stata's to 4e-6 and with rugarch's numerical Hessian only to about 0.7%.
- Stata
boottestCI: its Chandrupatla search returns early on the step function; both reported endpoints are values its own test rejects (p = 204/4096, 202/4096). - Stata
leebounds1.5 keeps its trimming threshold in a 15-digit local macro, so its tie branch never runs and its lower bound drops the quantile observation. With the threshold held exactly (%21x) it matchestrimming="exact"on both bounds. - R
AIPW0.6.9.3 ATT divides its control term by P(A = 0); the test rebuilds that number from the same nuisances, and the DR ATT is cross-checked against DoubleML's ATTE score. - Stata
me*withmcaghermite:mepoisson/menbreg/meglmgamma estimates sit 3e-5 / 1e-6 / 1e-5 from the optimum of Stata's own objective. Stata's reportede(V)for non-canonical Laplace GLMMs is not the Hessian of its own objective at its own estimates (NB-2 4.5e-4, gamma 5.5e-3, ordinal 5e-5; grade C, not reconstructed); for AGHQ it is reproduced by a fixed-node Hessian (grade B). SEs are certified against glmmTMB's AD Hessian instead. - Stata
regife: without therequirepackage,reghdfeerrors with r(9) andregifesilently posts the pooled-OLS starting values as its estimate (the IFE estimate only ine(bend)). - phtt
sig2.hatdemeans residuals unit by unit although the model has no unit effect; reconstructed in the test. - DiSCos 0.1.4
DiSCo_per_iterfills each placebo's quantile matrix from column 2, so column 1 stays zero and every placebo counterfactual in quantile mode drops a weighted donor. Patched and unpatched distances differ by up to 99.6%; the p-value (1/6) happens to agree on the fixture. - ptetools 1.0.1 loses
control_groupwhen the cohort column is namedg(non-standard evaluation); did 2.3.0 drops never-treated units when the cohort column is an integer; didFF 0.1.0distDDcrashes when a bin's influence function is dropped (balance_e = 1). - HonestDiD uses a Monte-Carlo
.qfoldednormalquantile, and its derivative bisection over h stops at a step of (h0 − hMin)/100: FLCI bounds agree with the exact-quantile rerun only to about 2e-5 while the half-length agrees to 1e-8.breakdown_magainst shipped HonestDiD is aligned at 4.3e-4. - R
scpi4.0.1, simplex: CLARABEL leaves Norway at 2.0e-6, above the 1e-6 threshold used to count active donors, so R gets df = 6 where the exact optimum gives 5 and R's rho is 8% larger. R's own L1-L2 fit reaches the same optimum and reproduces StatsPAI's rho to 3e-9. - ivDiag 1.0.6 effective F with more than one instrument uses the
un-partialled Z'Z and differs by 13–16% from Stata
weakivtestand StatsPAI. - GWmodel local R² under an adaptive kernel applies the transposed
weight matrix (reproduced exactly in the test). Its default C++ path
silently ignores
bw.seled(noted for themgwrbandwidth search, which stays unpinned).
⚠️ Correctness (round 2)¶
Time series
sp.structural_break(method="bai-perron"), the default, was not the Bai-Perron sequential procedure. It was a binary segmentation: each segment used its own relative trimmingint(seg_len·min_segment)instead of the global h = floor(ε·T), the candidate range dropped the last admissible date, and every step was referred to the full-sample sup-F(1|0) law (the Monte-Carlo p-value atalpha) instead of Bai-Perron's sup F(l+1|l) critical values, which grow with l. It is now a transcription ofmbreaks::dosequa(prewhit=0, robust=0, hetdat=1, hetvar=0), with the critical-value tables asserted equal tombreaks::supF_next_cv1..5. Break dates are identical todosequain 6 series × 4 levels, and every step statistic equals the tracedmbreaks:::pftestvalue (observed ~1e-14). On the fixtures the dates did not move; only the reported statistics change (F scale → Wald scale, p-values → critical values). On other data the old per-segment trimming and full-sample null can select different dates.p_valuesis nowNone; results carryf_stats,critical_valuesandsequential_tests. The old procedure is not reachable.sp.structural_breaksup-F p-value: Hansen (1997) by default. The default was a fixed-seed Monte-Carlo approximation of the Andrews law (1/5001 resolution, grid-dependent). It is now Hansen's (1997) approximation, the one strucchange, Stataestat sbsingleand the method's author use: p againststrucchange::sctest(type="supF")3.7e-13 on a 54-point grid, against Statapvalsup()≤ 2.7e-11. Onr2_wnthe default p is 0.2297329322069956 (R 0.22973293220700686).pvalue_method="simulate"returns the old Monte-Carlo value. This is a convention change, not counted as a defect.
Spatial, survey and structural
sp.malmquist: the efficiency change included noise. EC was exp(ε_{t+1} − ε_t) with ε = y − x'β, so it contained v_{t+1} − v_t, and M inherited it; the docstring claimed D = 1 on the frontier, but 6–19% of firm-periods on the fixture have D > 1. EC is now the ratio of predicted technical efficiencies, TE_{t+1}/TE_t (efficiency="bc", the default;"jlms"also available); TC is unchanged. Components match RsfaR1.0.1 (sfacrossper period plusefficiencies()) to 2.7e-7. Fixture: mean EC t→2 / t→3 1.0653 / 0.9759 → 1.0389 / 0.9456; mean M 1.0997 / 1.1170 → 1.0730 / 1.0824; row 1 (M, EC, TC) (1.26878, 1.15400, 1.09947) → (1.11709, 1.01603, 1.09947).efficiency="residual"returns the old index.sp.zisf: the JLMS efficiency was the Battese–Coelli mixture.efficiency(method="jlms")andmodel_info["mean_efficiency_jlms"]now return exp(−E[u|ε]) with E[u|ε] = (1 − P(eff|ε))·E[u|ε, ineff]. Fixture mean 0.81702 → 0.81279. The default (BC) efficiency is unchanged.zisfnow has references: Statachks1.1 (8.6e-8 / 9.4e-8, estimate / SE) and Rsfa::zsfm1.2.0 (production, cost andzprob=).sp.mgwr: the bandwidth search was not MGWR's algorithm. Every stage differed from the authors' PySALmgwr: GWmodel'sgold()instead of mgwr's golden section (δ = 0.38197, rounded probes, memo, stop at |Δcriterion| ≤ 1e-6, 2-decimal result); bounds [20, n] instead of [40 + 2p, n] (adaptive) / [min d / 2, 2·max d] (fixed); no 1.0000001 factor on the adaptive kernel; CV not divided by n; a max|Δf| stop instead of the score of change (SOC); no bandwidth freeze after 5 unchanged sweeps; no SE / ENP_j / AICc. Rewritten as a transcription of mgwr 2.2.1: bandwidths, initial bandwidth, bandwidth history and iteration count are identical in 4 configurations, betas 3.0e-12, SE / ENP_j / AICc ≤ 1e-15. Georgia: bandwidths [93, 101, 156, 158] → [101, 101, 117, 157]; RSS 51.2691 → 50.803541834793; AICc 297.0704149757866 (new, = mgwr); runtime 6.2 s → 0.42 s.tolis now the SOC threshold. The old search matched no reference and is not kept;kernel_eps=1.0gives GWmodel's kernel.
RD and IV
sp.rd2d/sp.rd2d_bw/sp.boundary_rdcomputed a different estimator. Through 1.28.0 the defaultapproach="distance"was a local linear fit on the signed distance to the boundary line, with a Silverman / curvature rule-of-thumb bandwidth, conventional (not bias-corrected) inference, one pooled number andmodel_info['bandwidth']rounded to 6 decimals;approach="location"pooled pointwise fits by inverse-variance weights. Rrd2destimates pointwise effects τ(b) with therdbw2dMSE / CER bandwidths and robust bias-corrected inference. Rebuilt as a port of Rrd2d1.0.0 (sharp and fuzzy, product / radial kernels, HC0–3, clusters, all 8bwselectrules, mass points, kinks, WBATE headline with the cross-point covariance): 1.6e-11 / 3.8e-12 (estimate / SE) againstrd2d::rd2d, 1.5e-12 / 1.1e-12 againstrd2d.distance. Breaking: the defaultapproachis now"location"(R'srd2d), andsp.rd2d_bwreturns a DataFrame of per-point bandwidths instead of a float. No like-for-like old number exists: onrd_open_bd.csvR reports five pointwise effects (0.446 / 0.929 / 1.020 / 0.704 / 1.357, bias-corrected) and WBATE 0.891 (SE 0.0903). A single one-score effect isapproach="pooled", nowsp.rdrobuston the signed distance to the boundary curve, not the old fit. Not ported:covs.effand the simulated uniform bands / LBATE.sp.rd_discretewas not the Kolesár–Rothe procedure. The local-linear-on-bin-means variance usedvar(bin)/n_bininside a sandwich already weighted byn_bin, so SEs were understated by ~√(bin size);Mcame from maximum second differences; the'bm'boundK·Σ|w|is not the BME interval.'bsd'now runs the RDHonest engine behindsp.rd_honest, and'bme'(alias'bm') portsRDHonestBME; both match RRDHonest1.0.1.9000 to 1e-9 at fixed h (1e-6 when RDHonest selects h and M). Fixture (estimate / SE / CI): bsd M = .05, h = 5 0.7208 / 0.0113 / [0.105, 1.337] → 0.7619 / 0.1121 / [0.445, 1.079]; bsd default 0.6636 / 0.0083 / [−34.5, 35.8] → 0.8401 / 0.1466 / [0.464, 1.216]; bm h = 4 0.7271 / 0.0130 → 1.4953 / 0.0549 / [0.640, 2.355].model_info['discrete']losesn_left,bin_means,honest_ciand gainsmaximum_bias,bandwidth,eff_obs. The headline p-value uses bias/SE; R's own BME p-value is kept asp_value_rdhonest(see Reference-implementation findings).
Decomposition and QTE
sp.melly_decompose/sp.machado_mata: the quantile regressions were not quantile regressions, and the process grid and inversion were not cdeco's. A 50-iteration IRLS with a 1e-6 step stop left coefficients 2–5% off the LP optimum at the median and up to 379% off on a small tail slope (τ = .9 tenure 0.00227 against 0.00047). The QR process now uses thesp.qregLP solver (= Stataqrprocessto 7.5e-15), the midpoint grid (j − 0.5)/J with defaultn_tau_qr99 → 100 (was 99 points on [0.01, 0.99]), and Melly's quantiles use the averaged inverse CDF (mm_quantiledefinition 2) instead ofnp.quantiletype 7.melly_decomposenow matches Statacdeco, method(qr)1.0.2 to 6.2e-16.dq_wage.csv, counterfactual quantile: τ ≈ .1 1.49961 → 1.48117, τ ≈ .5 2.30641 → 2.30639, τ ≈ .9 3.11162 → 3.12503 (cdeco 1.48117 / 3.12503).machado_matashares the fix; it stays Monte Carlo and converges tomelly_decompose. The old numbers are not reachable.sp.fairliewas not Fairlie's decomposition. It drew independent random subsamples of both groups (no ranking by predicted probability, so no matching), rescaled the contributions to sum to the explained gap and reported no SEs. It is now Jann's algorithm (rank both groups, subsample the larger in rank order, pair rank to rank, switch covariates in order) with delta-method SEs indetailed['se']; with equal group sizes it is seed-free. Matches Statafairlie1.0.7 to 4.5e-15 (contributions) and 5.4e-14 (SE) with a tightly converged logit. Logit, reference 0: educ 0.010457 → 0.009463 (Stata 0.009463), exper 0.015056 → 0.015866, tenure 0.000835 → 0.001020.sp.mediation_decompose: NDE at the wrong covariate profile, and no SEs by default. NDE used θ3·mean(M | A = 0), covariates at the unexposed mean, while NIE, Stataparamed,CMAverseandsp.four_way_decompositionhold covariates at the overall mean. With confounded exposure NDE 0.717658 → 0.770783 (paramed0.770783);cde + int_ref == ndeagainstfour_way_decompositionto 4e-16;cdeis unchanged, and nothing changes without covariates. The defaultinference='analytical'returnedse/ci=None; delta-method SEs now matchparamedto 9.5e-15.- QTE-family propensity scores were penalised and under-converged
(
sp.dist_iv,sp.distributional_tepropensity and DR distribution-regression logits,sp.qte(method="firpo_*")with covariates). sklearnLogisticRegression(C=1e6, lbfgs)stops at gradient tolerance 1e-4, 2.5e-5 from the MLE. A Newton fit to 1e-10 now equals Statalogitto 1.7e-13. Estimates move in the 5th–6th digit on covariate paths only.
ML causal inference (grf operators, CDDF, off-policy evaluation, DML)
sp.calibration_test/sp.test_calibrationwere not grf's test. The old version regressed a Horvitz–Thompson pseudo-outcome on[tau, tau - mean(tau)](HC1, two-sided normal p); because the columns sharetau, the "differential" coefficient estimated b2 − b1, so its test of 0 was a test of equal calibration. It now regressesY - Y.haton(W - W.hat)·mean(tau)and(W - W.hat)(tau - mean(tau))with HC3 and reports grf's t against 0 with a one-sided p from t(n − 2), matchinggrf::test_calibration2.6.1 to 1.1e-15 (coefficient, SE) and 1.6e-13 (p). Fixture: differential 0.180 → 1.187 (grf 1.18726); mean 0.985 → 1.00297. The output no longer has anullcolumn. Rows that are not the training sample used mean-of-Y / mean-of-T stand-ins for the nuisances; they now raise. Newvce=(HC0–HC3, default HC3); the result dict keeps the keyvcov_type. HC0 carries the n/(n − 1) factor of thesandwich::vcovCLcall grf makes;t_vs_zero/p_one_sidedare kept as synonyms oft/p. (Thedml_diagnosticsfix of the same campaign is superseded by the upstream redesign already on main.)sp.ratewas not grf's RATE. Scores were a Horvitz–Thompson signal, not grf's AIPW scores; tied priorities were not averaged; QINI used weights1 - uinstead of grf'sk/n, off by exactly mean(score)/(2n) on the fixture (0.25814 vs 0.25869); the influence-function SE omitted the rank-estimation term (0.0949 against grf's half-sample bootstrap 0.0744, +28%). The point estimate and TOC curve now equalgrf::rank_average_treatment_effect/.fitto 4.6e-15, including tied priorities; the analytic rank-corrected SE is 0.0725, within grf's Monte Carlo error (the SE stays T3). Non-trainingXnow raises.sp.cate_evalhad its own copy of the old RATE with the same tie, QINI-weight and SE defects; it now sharessp.rate's operator.sp.average_treatment_effect(target_sample="overlap")used an e(1 − e)-weighted AIPW mean; grf uses the R-learner OLS with intercept and HC3. Fixture: 1.12386 / 0.07687 → 1.12514 / 0.07968 (grf exact). Other target samples were already exact. Out-of-sample rows that fall back to the plug-in now warn (only amethodfield recorded it).sp.honest_variance: the SE was the Monte-Carlo error of the average,sd(half-means)/sqrt(n_splits), which shrinks to 0 with more splits (0.0038 at 25 splits against a sampling SE of 0.024). It is now the half-sample spread (≈ sd(tau)/sqrt(n)); the docstring calls it descriptive.sp.blp_testran unweighted OLS on in-sample CATE predictions, while its docstring claimed equivalence togrf::test_calibration. On a constant-effect DGP (n = 400, T-learner) it reported β2 ≈ 1.43, p ≈ 3e-52. It is now the CDDF weighted regression (weights 1/(p(1 − p))) on an out-of-fold proxy by default (proxy="cross_fit"), equal toGenericML::BLP0.2.3 to 2.3e-15 / 1.2e-15 given the same proxy, propensity and baseline. Null DGP: β2 0.137, p 0.29. Newvce=(default HC1;"const"is GenericML's default); the result dict keeps the keyvcov_type.proxy="in_sample"restores the in-sample proxy (the regression stays weighted).sp.gate_testhad no outcome regression. It ran ANOVA / t-tests on the model's own predictions grouped by the same predictions, so its p-values had no meaning. Withy/treat/covariatesit now runs the GATES weighted regression, equal toGenericML::GATES(monotonize=FALSE) withquantile_groupmembership to 1.9e-15.vce=as inblp_test;vce="const"on the weighted regression reproducessandwich::vcovHC(type="const"), which is notvcov(lm). Without outcomes the descriptive path remains and warns.sp.forest_diagnosticsfilled propensities withmean(T)on non-training rows and so reported perfect overlap; it now reports NaN with a warning.sp.ips/sp.snips/sp.doubly_robust:clipalso floored the behaviour propensity at1/clip. Withclip=2IPS gave 0.8496 against Open Bandit Pipeline (obp) 0.5.7lambda_=21.1904.clipnow caps the weight only, asobp'slambda_;pi_b <= 0raises. The defaultclip=50is inert wheneverpi_b >= 0.02.sp.snipsSE divided bysum(w)instead ofmean(w): 0.00039 against a bootstrap SE of 0.0174 (n = 2000). It is now the delta-method SE, equal tosp.ope.snips.sp.doubly_robustwith a 1-D (deterministic-action) policy read the action vector as target probabilities in the correction term. Fixture, always action 2: 1.3054 → 0.8555 (arm-2 sample mean 0.896). Values now equalobpDoublyRobustto 1.8e-16 with the same reward model (q_hat=).sp.dml_panel(include_time_fe=True): the two-way within transform stopped after one sweep, exact only for balanced unweighted panels. Unbalanced fixture: max abs error 0.19 in y; estimate 0.82050 → 0.81951 (PythonDoubleML0.11.3 0.81951). Now alternating projections to 1e-13, equal tofixest::demean(2.6e-14). Balanced unweighted panels are unchanged.sp.dml_model_averaging/sp.model_averaging_dml: the CLS weights came from SLSQP (ftol 1e-10); for K ≤ 12 learners they now come from an exact support-enumeration QP (short-stacking weights equalddml::ddml_plm(ensemble_type="nnls1")to 4.4e-14). Output moves by about 1e-8. The SLSQP fallback for K > 12 now warns.
Sensitivity, Mendelian randomization, transport and trial design
sp.mi_estimate: degrees of freedom and covariance. It used Rubin's large-sample df(m-1)(1+1/r)^2everywhere (the comment said Barnard–Rubin) and pooled only the diagonal of the within covariance. It now uses Barnard–Rubin df withdfcom = data_info['df_resid'], the fullvar_cov, and p / CI from t(df), matching Rmice::pool3.19.0 (df 1.4e-14, p 3e-13) and Statami estimate: regress. Example x2: df 31.25 → 24.99; p now equals mice's 1.5656e-06. Results addubar,b,t,riv,lambda,fmi,fmi_barnard_rubin,df,dfcom,ci_lower,ci_upper. Passing estimates withoutdf_residtoMICEResult.combinegives the large-sample df.sp.mediate_sensitivityused a heuristic bias, not Imai–Keele– Yamamoto. It subtractedα_T ρ σ_Y/σ_M. It now fixes ρ in themedsensSUR / FGLS, with delta-method SE / CI and the exact crossing ρ̃ = corr(resid M ~ T + X, resid Y ~ T + X); matches Rmediation::medsens4.5.1 (ACME 8e-14). Crossing 0.558 → 0.487; ACME(−0.9) 0.852 → 1.533.eps=sqrt(machine eps)reproduces medsens' stopping rule.sp.calibrate_confounding_strength: the bias bound was off by √dof. It used√(k r_y · k r_d/(1 − k r_d)) · se, with no √dof and linear scaling of both R². It now computes the Cinelli–Hazlett benchmark bounds exactly assensemakr::ovb_bounds0.1.6 (1.3e-15).dof=is now required. At k = 5 the bias bound 0.0199 → 0.4640; an estimate of 0.10 went from "robust" to a breakpoint at k = 1.5.sp.survival_sensitivitynever overturned protective effects: the worst case waslog_hr − log Γregardless of sign. It now moves toward the null. log HR = −0.4, SE 0.15: breakpoint None → 1.2 (the same as the positive case).sp.unified_sensitivity:rho_maxwas ignored on the data path (always the 1.3 rule), while the documented default 1.0 was used on the summary path, so the two paths disagreed. The default is now Oster's min(1, 1.3 R²) on both and an explicit value is honoured (registry default 1.0 → None). The RR CI was the rescaled t CI, not the interval the E-value used; it is nowEValue'sexp(0.91 d ± 1.78 se/sd)(1e-12).sp.attrition_bounds(method="lee")keptn − ceil(qn)observations, one fewer than Lee's rule, and disagreed withsp.lee_bounds. It now shares that helper: (0.70358, 1.61981) → (0.71883, 1.60455), equal tosp.lee_bounds;trimming="exact"gives (0.70557, 1.61782), equal to Stataleeboundswith exact thresholds.sp.mr_bmawas BIC model averaging under the MR-BMA name. It is now Zuber et al.'s Bayes factor (IVW scaling, normal prior σ = 0.5), equal to theirsummary_mvMR_BF(posterior probabilities 7e-15), withmodel_averaged_estimateadded.method="bic"returns the old quantity.sp.mr_multivariableshrank the SE under under-dispersion (RSE < 1 multiplied the SE down);MendelianRandomization::mr_mvivwfloors the factor at 1, and so does StatsPAI now (9e-16 / 2e-16). Only data with RSE < 1 change.sp.mr_mediationtotal effect used the fixed-effect IVW SE (the defect round 1 fixed insp.mr_ivw); it now callssp.mr_ivw.sp.grapplewas a different estimator: a joint Gaussian MLE with a numerical-Hessian SE, not GRAPPLE's robust profile score. It now solves GRAPPLE's estimating equations (Tukey by default;loss="huber"/"l2") with its sandwich. LDL data: β 2.694 → 2.7197 (GRAPPLE 2.7196), SE 0.510 → 0.5520. It warns (RuntimeWarning) when the (τ², β) alternation does not converge.sp.transport_weights_fn/sp.transport_generalizeSE used Σw(y − m)² instead of Σw²(y − m)², so it was not invariant to rescaling w: 0.0864 → 0.1556 (=sandwich::vcovHC(HC0)).sp.identify_transportadmitted descendants of X in the adjustment set and weighted by the target margin P(Z)_T; for X→Z→Y, S→Z it returned a wrong formula; for that graph it now reports "not transportable" under the covariates-only design.sp.randomize:method="complete"silently ran simple randomization; stratum counts used round-half-to-even and gave odd-stratum misfits to a fixed arm (5 → 2 treated, 7 → 4); cluster assignment was Bernoulli; re-randomization redrew by simple randomization, discarding strata and clusters. It now followsrandomizr2.0.1'scomplete_rarule within strata and over clusters, and re-randomization redraws from the same design. Draws differ for the same seed.
DiD and synthetic control
- The shared simplex solver stopped at SLSQP tolerance.
solve_simplex_weights(every no-covariate SCM fit, includingsp.synthequal-V fits,conformal_synth,multi_outcome_synth,synth_experimental_designand the sensitivity tools) returned weights about 1e-7 off the optimum. Identified problems (full column rank, or ridge > 0) now use the exact active-set solver; rank-deficient problems keep SLSQP. Treated fit: weights 9e-8 → 2e-14 abs, ATT 3e-8 → 5e-15 rel fromSynth::synth/ quadprog. Outputs move by about 1e-7 (a pinned pre-RMSPE 0.17714 → 0.1771399093).synth_loo,synth_time_placebo,synth_donor_sensitivityandsynth_rmspe_filternow match per-fitSynth::synth1.1.10 to ≤ 1e-13 (3e-14 forsynth_loo). sp.synth_loo/sp.synth_time_placebo: degenerate naive p-values. With one placebo post-periodnp.std= 0 gave se = 0, z = inf and p = 0, so the last time placebo was always "significant" (fixture: att −0.041, p 0.0). SE and p are now NaN when T1 < 2 or SE ≤ 0; the docstrings call them descriptive i.i.d.-gap quantities (alphais unused).sp.conformal_synthcomputed a different procedure from Chernozhukov–Wüthrich–Zhu. Weights were fit on the pre-period only and never re-estimated under the null, pointwise p used (1 + #)/(T0 + 1) on pre-fit residuals, and the joint test used |mean| of the residuals. It is nowscinference's procedure: the SC refit on all T0 + T1 periods with the post outcomes shifted by θ0, moving-block S = Σ|u| over length-T1 cyclic blocks, pointwise p from T0 plus one post-period, CIs by grid inversion. Joint p-values equalscinferenceon every grid point where its solver succeeds (see Reference-implementation findings). On thetest_cov95_synth_variantspanel p 0.0556 → 0.444, and at α = 0.05 the CI is (−inf, inf) because T = 18 < 20; the estimate is unchanged. When α is below the smallest attainable p the set is (−inf, inf); grid truncation warns and setsmodel_info['ci_truncated']; the default grid is ±max(5·sd(pre-residuals), 3·max|post gap|);seis documented as a CI-implied scale.sp.synth_survival: the exponentiated-gradient loop stopped short (weights off by 0.03 and SSR 7% too large on a 12-period, 8-donor example) and now uses the exact solver; the placebo band had the wrong sign (gap + q, now [gap − q_hi, gap − q_lo]); the band was documented as uniform but is pointwise; a bareexcept Exception: continueis removed. No package computes this estimator (identity tests only).sp.synth_power/sp.synth_mde: rejection usedratio >=the interpolated (1 − α) quantile of the placebo ratios, which is not the rank testsp.synthreports and is anti-conservative; it now rejects whenplacebo_rank_pvalue <= α. The null baseline kept the real effect (the observed post gaps were used uncentred); they are now centred before δ is injected. Prop. 99 (n_sim50): power at δ = 0 1.0 → 0.0; MDE 0 → 10.9. With J = 8 placebos and α = 0.05 (1/9 > α)synth_mdenow returns inf.sp.shift_share_political: the Rotemberg weights were normalised by Σ|·|, so they did not sum to one, and had nobeta_k; they are nowsp.bartik's GPSS weights, equal tobartik.weight::bw(max |α_k| 0.447 before, 0.702 after). The share-balance F used df1 = K; with shares summing to one it is rank([1, S]) − 1: F 0.924 → 1.143, p 0.490 → 0.357 (=anova(lm)). The summary labelled the HC1 SE "AKM shock-cluster"; HC1 stays asse(=AER::ivreg+ HC1) and AKM, AKM0 and EHW are added todiagnostics(=ShiftShareSE::ivreg_ss).sp.shift_share_political_panel: the two-way within transform was one pass of unit-then-time demeaning, inexact on unbalanced panels (now alternating projections, equal tofixestat 1e-14 on a 7-row-deleted panel);cluster="twoway"was HC0 on unit × time cells (now Cameron–Gelbach–Miller unit + time − cell, =fixest ~unit+time);cluster="shock"used the wrong AKM formula (nowivreg_sswith FE dummies, 1.2e-15); the Rotemberg weights demeaned x by period only and were Σ|·|-normalised (now GPSS on the FE-residualised x, =bwwith FE dummies);first_stage_Fwas var(D̂)/var(resid) (now the homoskedastic partial F with FE df). Balanced-panel β and the unit / time SEs were already right.
Fixed (round 2)¶
Spatial, survey and structural
sp.malmquistforwardsusigma/vsigma/emean/cluster; these columns were dropped before the call, so every documented use of**frontier_kwargsraisedMethodIncompatibility.- The
sp.malmquistdocstring no longer attributes the definition to Coelli & Rao (a DEA paper); the FGLR citation key isfare1992productivity.
Treatment effects
sp.policy_valueraisesValueErrorunlessscoresandpolicyare 1-D and of equal length (a scalar policy is still accepted). An (n, 1) score column broadcast to an n × n outer product and silently returned mean(scores)·mean(policy). Valid inputs are unchanged; results equalgrf::average_treatment_effect(subset = pi == 1)× mean(pi) to 6.7e-16.
Decomposition and QTE
sp.beyond_average_latecarried its own copy of the complier-CDF code and a bareexcept Exception: passin the bootstrap; it now delegates to the shared QTE core (numbers identical) and degenerate resamples are counted by the existing warning.
ML causal inference
- The forest-inference module header cited
athey2019surrogatefor GRF; it isathey2019generalized. A misusedchernozhukov2020genericcitation (a different paper) is removed.
Sensitivity, Mendelian randomization, transport and trial design
- The MR-BMA reference in the
sp.mr_bmadocs and registry named the co-authors as "Zuber, Colijn, Staley & Burgess"; the paper (Nature Communications 11:29, 2020, doi:10.1038/s41467-019-13870-3) is by Zuber, Colijn, Klaver & Burgess. Corrected everywhere (paper.bibwas already right).
DiD and synthetic control
sp.shift_share_political/sp.shift_share_political_panel: "Park & Xu" and the "§4.2" attributions were unverified; the paper is Park (2026), single-authored, arXiv:2603.00135 (paper.bib::park2026shift).sp.sequential_sdidis cohort-by-cohort SDID (sp.sdidon truncated cohort sub-panels; per-cohort ATT(g) equalssynthdid::synthdid_estimate0.0.9 to 1e-8), not the Arkhangelsky–Samkov sequential estimator (arXiv:2404.00164), which aggregates by cohort, loops over horizons, imputes τ̂ back and uses a Bayesian bootstrap. Module, summary andmodel_infoare relabelled ("cohort_by_cohort_sdid") andmodel_info['estimator']is added. Thecohort_weights="size"docstring said units × post-periods; it weights by units. Dropped cohorts now warn.sp.synth_experimental_designis a leave-one-out SC-fit ranking heuristic, not the Abadie–Zhao design (arXiv:2108.02196), which is a joint w / v MIQP targeting population predictor means.methodis now"loo_sc_fit_ranking"(was"abadie_zhao_2025").docs/guides/synth_experimental.mdis rewritten: it presented a variance formula that does not appear in that paper and an unsourcedconcentration_weight ≈ 0.5recommendation.expected_varianceis documented as a sum of pre-period MSPEs, not an ATT variance.sp.synth_survivalno longer claims to be Han & Shah's SSC (arXiv:2511.14133), a different estimator (survival scale, unconstrained PCR weights); its citation key isabadie2010synthetic.sp.multi_outcome_synth's citation lists all three authors;sp.qqsynth's docstring is corrected.
Added (round 2)¶
Time series
sp.structural_break(pvalue_method="hansen" | "simulate"); module-levelhansen_supf_pvalue; Bai-Perron critical-value tables intimeseries/_break_tables.py.- Parity file
tests/reference_parity/test_r2_ts_parity.pyagainst strucchange, mbreaks, Stataestat sbsingle/pvalsup()andxtbreak.
Spatial, survey and structural
sp.malmquist(efficiency="bc" | "jlms" | "residual").sp.mgwr(criterion=, bw_min=, bw_max=, bws_same_times=, rss_score=, search_tol=, search_max_iter=, kernel_eps=); results carrybse,tvalues,ENP_j,tr_S,sigma2,aicc/aic/bic/llf,bw_init,bws_history,scores,converged.sp.spatial_panel(twoways_lag="splm" | "within"):"within"is the Lee–Yu (2010) eq. 21 dummy-model profile likelihood (vce="oim"only); the default is unchanged.- Parity files
test_r2_frontier_parity.py(sfaR, metafrontier, sfa, Statachks) andtest_r2_spatial_parity.py(PySAL mgwr, Stataxsmle).
Treatment effects
- Parity file
test_r2_teffects_parity.py:gformula_ice_fnpoint estimate againstltmle(gcomp=TRUE)(2.1e-11),policy_valueagainst grf / policytree, TMLE ATT againsttmle::tmle.
RD and IV
sp.rd2d(..., weights=)(WBATE),q,deriv,tangvec,kernel_type,vce,cluster,fuzzy,fitmethod,bwparam,method,masspoints,bwcheck,scaleregul,scalebiascrct,stdvars,kink_unknown,kink_position,cqt,distance,side, mirroring Rrd2d.sp.rd_discrete(order=, kernel=, opt_criterion=);p_value_rdhonest.- Parity file
tests/reference_parity/test_rd_open_R_parity.pyagainst Rrd2dandRDHonest.
Decomposition and QTE
sp.cfm_decompose(thresholds=, inversion="interpolate" | "step");inversion="step"with the same thresholds reproduces Statacdeco, method(logit)(CDFs 2.4e-10, quantiles exact).sp.fairlieper-variable delta-method SEs indetailed['se'].- Parity file
tests/reference_parity/test_decomp_qte_parity.pyagainst Statacdeco,qrprocess,fairlie,shapley2/ineqdeco,paramedandivqte.
ML causal inference
vce=onsp.calibration_test/sp.test_calibration,sp.blp_testandsp.gate_test;priorities=,se_method="half_sample",n_bootstrap=onsp.rate;propensity=,baseline=,proxy=,seed=onsp.blp_testandsp.gate_test.fold_indices=onsp.dml_panelandsp.dml_model_averaging; stacked residuals indml_model_averaging().model_info.q_hat=onsp.direct_methodandsp.doubly_robust.- Parity files
test_ml_causal_R_parity.py(grf, GenericML),test_ml_causal_obp_parity.py(Open Bandit Pipeline) andtest_ml_causal_dml_parity.py(fixest, ddml, DoubleML).
Sensitivity, Mendelian randomization, transport and trial design
sp.subgroup_analysisheterogeneity test also reportsF,pvalue_Fanddf_resid(Statatestparm).sp.attrition_test(correction=)(False= Statatabulate, chi2);sp.balance_check(equal_var=)(True=iebaltab);sp.attrition_bounds(trimming=).sp.mediate_sensitivity(conf_level=, eps=, max_iter=);sp.calibrate_confounding_strength(multipliers=);sp.mr_bma(prior_sd=, method=);sp.grapple(loss=, k=, tol=, max_iter=).- Parity files
test_misc_sens_R_parity.py(mice, mediation, sensemakr, EValue, MendelianRandomization, metafor, causaleffect, Zuber et al.'ssummary_mvMR_BF, GRAPPLE) andtest_misc_sens_stata_parity.py(Stataregress,mi estimate,leebounds,iebaltab).
DiD and synthetic control
sp.synth_rmspe_filter(metric="rmspe" | "mspe", placebo_pool= "include_treated" | "exclude_treated"): the SCtools / ADH MSPE cut-offs and placebo pools without the treated unit (defaults unchanged); the placebo table and treated ratio indf.attrs. Both conventions matchSCtools::mspe.test0.3.3.1.sp.conformal_synth:model_info['joint_pvalue_grid'],['ci_truncated'],*_ci_unbounded.sp.shift_share_political: AKM / AKM0 SE, CI and p indiagnostics;beta_kinrotemberg_top.sp.shift_share_political_panel:diagnostics['akm0_ci'],['balanced'].sp.multi_outcome_synth:model_info['placebo_failures']; shared weights and per-outcome ATTs matchaugsynth::augsynth_multiout0.2.0.- Parity file
tests/reference_parity/test_synth_rest_R_parity.pyagainst Synth, SCtools, scinference, augsynth, synthdid, AER, ShiftShareSE, bartik.weight and fixest.
Citations
paper.bibgains verified entrieschernozhukov2025generic(Econometrica 93(4), 1121–1164),hansen1997approximate,choi2001unitandfuentes2001parametric.
Changed (round 2)¶
sp.structural_break(method="bai-perron"):min_segmentmust be one of .05, .10, .15, .20, .25 andalphaone of .10, .05, .025, .01; other values raise.p_valuesisNone.sp.mgwr:tolis the SOC threshold.sp.rd2d/sp.boundary_rd: defaultapproach"distance"→"location";approach="pooled"delegates tosp.rdrobust.sp.rd2d_bwreturns a per-point DataFrame (breaking).sp.rd_discrete(K=)is ignored with aDeprecationWarning.sp.melly_decompose/sp.machado_mata: defaultn_tau_qr99 → 100.sp.cfm_decomposemonotonises the DR CDF by rearrangement (sorting) instead of a running maximum; identical when the raw CDF is monotone. Its logit-failure fallback catchesLinAlgError/ConvergenceFailureand warns instead of a silentexcept Exception.- Bootstrap failures in
sp.machado_mata,sp.mediation_decomposeandsp.beyond_average_lateare counted and warned; ≤ 10 usable replications raise inmediation_decompose, andmachado_matawarns and returnsse=None. An invalidinference=raises inmediation_decompose, andreference/inferenceare validated inmelly_decompose,machado_mataandcfm_decompose. sp.calibration_test/sp.test_calibration: the output has nonullcolumn; rows that are not the training sample raise.sp.rateraises on non-trainingX.sp.calibrate_confounding_strength:dof=is required.sp.unified_sensitivity(rho_max=None): one default (Oster's min(1, 1.3 R²)) on both paths.sp.grappleissues aRuntimeWarningwhen the (τ², β) alternation does not converge.sp.synth_loo/sp.synth_time_placebo: silently dropped fits raise aRuntimeWarning; the summary line is relabelled.sp.conformal_synth:pre_treatment_rmseis no longer rounded to 6 decimals.sp.synth_experimental_design().methodis"loo_sc_fit_ranking".
Not fixed in this release. sp.rd_extrapolate(method="ols") evaluates
every point at the unconditional mean of Z, so detail.cate is flat in x
(1.89778 at all 3 points on rd_open_extrap.csv) and equals the global
regression-adjustment ATE (Stata teffects ra 1.897784556341). It does not
extrapolate the Angrist–Rokkanen CATE(x) = E[Y1 − Y0 | X = x]. The
sp.margins_at / sp.contrast / sp.pwcompare p-values and intervals use
N(0, 1) instead of Stata's t(df_r) (G − 1 under clustering), pwcompare's
Sidak adjustment underflows to 0 below p ≈ 1e-17, sp.margins(method="mem")
evaluates factor dummies at the base level, an at= variable not in the
model is ignored, and the margins family averages over all of data=
rather than the estimation sample; strict xfails pin Stata's numbers.
Estimates and SEs of those three functions are already bit-exact with
Stata. Not fixed in this release: src/statspai/postestimation/ is being reworked on a separate line, which the xfails will flag when it lands.
Reference-implementation findings (round 2)¶
- Stata
sbcusum.adohard-codes CUSUM boundary constants whose crossing probabilities are 0.0100000791, 0.0499991147 and 0.0999993370 (relative errors −7.7e-7, +2.5e-6, +1.2e-6). StatsPAI uses the exact root of strucchange's closed form, which an independent density-propagation computation confirms to about 1e-12. - Stata
_xturfisher.adocodes the Fisher L* scale as3*(5N+3)while its df is 5N+4; Stata's own[XT] xtunitrootmanual prints 5N+4, as do plm and the logistic-moment identity (asserted for N ∈ {1, 2, 5, 12, 50}). - Stata
estat sbsingleuses a different trimmed range from strucchange when 0.15·n is not an integer (n = 250: first regimes 38..212 against 37..213); the p-values agree on the fixtures. - Stata
chksdrops rows with y ≤ 0 even in the linear model (it runsgen ln(depvar)and thendrop if rowmiss): 90 of 500 rows on the fixture. The comparison runs on y + 10. - R
metafrontier0.3.1 (SFA branch):EC_groupis Shephard-oriented whileTC_groupis Farrell-oriented, soMPI_groupmixes the two. - R
sfa::zsfm1.2.0: L-BFGS-B stops with a score of 3e-4 to 7e-4; the T2 comparison is on its own log-likelihood polished to its optimum. - Stata
xsmle1.4.5, two-way SAR / SDM: its reported maximum is below StatsPAI's by 4.85e-5 (SAR) and 5.15e-4 (SDM) on its own objective;type(both, leeyu)returns thetype(ind, leeyu)fit (N = 768 = 48·16), so the time effects are dropped. - R
rd2d1.0.0,kernel_type="rad": it fits at radius sqrt(hx² + hy²) but rescales the variance with hx·hy, inflating every radial SE by sqrt((hx² + hy²)/(hx·hy)) (√2 when hx = hy). Weightedlm()plussandwich::vcovHC(HC0)at that radius equals StatsPAI's SE to 1e-10. - R
RDHonest::RDHonestBMEaddsmaximum.bias(outcome units) to a z statistic in its p-value; StatsPAI reports the p-value with bias/SE and keeps R's asp_value_rdhonest. - Stata
paramed, no covariates, with interaction: the NIE gradient uses θ3·a1 where θ2 + θ3·a1 belongs (the covariate branch is right). Its SE 0.055580 is reproduced exactly from that gradient; the correct SE is 0.096576. - R
mediation::medsensstops its default iteration about 1e-4 short of the fixed point at |ρ| = 0.9;eps=sqrt(machine eps)reproduces it. - GRAPPLE (GitHub 317e837) stops its
optim/unirootearly (score O(1e-3) at its estimate): fitted β agrees to 8e-4, τ² to 6e-5, SE to 3e-5; its sandwich at its own point matches to 8e-16 (l2). - R
scinferencecallslimSolve::lsei(type=1)without checkingIsError. At θ0 ∈ {8.75, 9} lsei fails and returns infeasible weights (SSR 310.4 against 86.6 at the simplex optimum), and scinference reports joint p 0.55 / 0.65 where the value is 0.2 / 0.2. StatsPAI equals scinference's code with quadprog as the solver on every grid point. - Stata
synth.adorounds the unit weights to 3 decimals (roundmat), so no Stata SCM tool can reach T2; not run as a reference.
Defaults aligned with the reference implementation¶
sp.roc_curve(se_method=)defaults to"delong"(was"hanley", an approximation no reference uses by default). DeLong is the default of RpROCand Stataroctab;se_method="hanley"restores the old SE.sp.direct_standardize(ci_method=)defaults to"gamma"(was"lognormal", which no package computes). The Fay-Feuer gamma interval is the default of Repitools::ageadjust.direct;ci_method="lognormal"restores the old interval.sp.power_case_control(test=)defaults to"chi2"(was"wald"). The Pearson chi-squared test is what Statapower twoproportionsand Rpower.prop.testcompute;test="wald"restores the old power (the docstring example moves from 0.9213 to 0.9171, and the required cases from 138 to 141).sp.svydesign(lonely_psu=)keeps its default: a single-PSU stratum contributes zero variance (Statasingleunit(certainty)) and now warns unless the rule is given explicitly; Rsurveyfails by default and Stata reports a missing SE. Passlonely_psu="fail"for R's behaviour.
Provenance¶
sp.cuminc/sp.finegray: the module docstring ofstatspai.survival.competing_risksnow records that Gray's CIF variance, the covariance of Gray's test and thecrrsandwich variance were reimplemented from Gray (1988) and Fine & Gray (1999) as NumPy, with conventions fixed against Rcmprsk2.2-12 (Bob Gray, GPL-2 | GPL-3); nocmprskcode is copied, andcmprskis used only to generate test reference values.
Added¶
- Opt-in IRM ATE OOF audit records.
sp.dml,sp.DoubleML.fit, andsp.DoubleMLIRM.fitacceptstore_oof,observation_ids, and in-memoryexternal_predictions.sp.OOFPredictionsvalidates row/fold alignment and declared training scopes;sp.OOFBundleexports all cross-fit repeats with versioned, hash-checked JSON.result.get_oof()andget_residuals()return defensive snapshots;bundle.score_concentration()reports squared-score concentration with explicit unavailable states. The supported audit path is unweighted, unnormalised binary IRM ATE. Normal fits retain their numerical behavior, and ordinary result serialization excludes individual OOF arrays. Seedocs/dml_oof_audit.mdfor scope and provenance limitations. -
DML diagnostic semantics correction. The former orthogonality p-value tested a score centered by construction and was uninformative. Its statistic and p-value are now unavailable (
None) with an explicit explanation; IRM plots label the displayed series as a centered IRM score residual. -
One Stata
vce()grammar on every SE-bearing regression estimator.robust=/vce=acceptTrue,"robust","vce(robust)","r","oim","hc0"–"hc3", and a cluster variable written inline —vce="cluster firm","vce(cluster firm)","cl firm"— onregress,ivreg/iv,glm,logit,probit,cloglog,poisson,nbreg,ologit,oprobit,mlogit,clogit,zip_model,zinb,hurdle,truncreg,biprobit,betareg,fracreg,etregress,liml,ppmlhdfe,panel_logit,panel_probitandsubgroup_analysis. One parser (statspai.core._vcov_spec.parse_se_request) resolves every spelling; a spelling an estimator does not implement raisesMethodIncompatibilityinstead of falling back to other standard errors.vce="robust"onsp.regress(previously an error) is HC1, as in Stata. - House-style keyword spellings on the causal entry points. The panel
identifier is accepted as
id=, covariates ascovariates=, and adoption cohorts asfirst_treat=, alongside each estimator's native spelling:callaway_santanna/staggered_cs/staggered_sa/sun_abraham(id/unit->i,time->t,first_treat/cohort->g),bjs/gardner_did/wooldridge_did/etwfe/stacked_did/did_multiplegt(_dyn)(id/unit->group,covariates->controls,treat->treatment),event_study/synth/sdid/gsynth/augsynth/fect(id->unit), the RD family (running->x,cutoff->c,covariates->covs;xstays the running variable),psm(treat->d,covariates->X), andcontrols=ondid,ddd,did_2x2,drdid,match,psmatch2,ipw,aipw,tmle,ebalance,metalearner,dml. Signatures and schemas are unchanged; passing both spellings raisesTypeError, and a misspelt keyword names the closest parameter (runing=-> "did you mean 'running'"). Alias maps are listed bysp.describe_function(name)["aliases"]. sp.stata("...", data=df)runs Stata command lines throughsp.from_stataand returns the fitted result: several lines (or;),*///comments, andtest/lincom/margins, dydx(...)applied to the latest fit. A line whose translation is incomplete —xtreg, fewithout thextsetpanel id — raises instead of running a different model.sp.list_functions(core=True)returns the ~30 everyday estimation and post-estimation verbs (sp.registry.CORE_FUNCTIONS), in curated order.inspect.signature(sp.iv)/sp.rd(andhelp) show the dispatcher's parameters instead of(*args, **kwargs).result.test(...)andresult.lincom(...)onEconometricResultsandCausalResult, mirroring Stata's post-estimation commands.sp.testalso acceptstest x1 x2, chainedx1 = x2 = x3, grouped(x1 = 0) (x2 = 1),2*x1,_consand_b[x].sp.glm(..., information="observed" | "expected"). Observed information (Stata's default) is now the variance bread;"expected"reproduces R'sglm. They coincide for canonical links.nbregkeeps the MASS::glm.nbstandard error (conditional on the dispersion) inmodel_info["se_conditional_on_dispersion"].-
Stata 18 reference fixtures
tests/reference_parity/_fixtures/ _generate_vce_grammar_stata.do(29 estimators x oim / robust / cluster) and_generate_postestimation_stata.do(test / lincom / p-values / margins), pinned bytest_vce_grammar_stata_parity.pyandtest_postestimation_stata_parity.pyat the 1e-6 default budget (three documented optimiser-stopping exceptions, each asserted by comparing the objective at both solutions). -
sp.causal_forest(..., fe="twoway", id=..., time=...): causal forests with fixed effects for panel data. Unit (and period) effects are removed within every node and every honest leaf, not once globally [@kattenberg2023causal]. On a staggered panel where adoption selects on the unit effect (N = 300 units, T = 6, 4 seeds) the pooled forest's CATT bias was +0.36 to +2.37 and the FE forest's -0.06 to +0.03 (RMSE 0.32-0.42 with heterogeneous effects). Unbalanced panels are handled by iterating the two-way within transformation to 1e-10. Doubly-robust averages are refused for FE forests (no propensity exists for a within-unit design). clusters=forsp.causal_forest. Trees are subsampled, split into honest halves and cross-fitted by cluster, and every average, calibration test, BLP and RATE reports cluster-robust standard errors;equalize_cluster_weights=gives clusters equal weight. Repeated observations of a unit must be clustered.sp.did_forest: difference-in-differences causal forests for staggered adoption. One honest causal forest per clean Callaway-Sant'Anna group-time comparison on the long-differenced outcome (the DiD causal forest of [@gavrilova2025difference] applied to every group-time cell), doubly-robust ATT(g,t), event-study / cohort / overall aggregates with unit- or cluster-level influence-function standard errors, a joint pre-trend Wald test, unit-level CATEs with little-bag standard errors and a per-cell heterogeneity test. Monte Carlo (200 replications, N = 800, T = 7, two cohorts): seedocs/guides/heterogeneity_panel_forests.md.sp.calibrate_cate: CATE predictions rescaled by the out-of-bag best-linear-predictor calibration [@chernozhukov2025generic]; warns when no heterogeneity is detected and, for FE forests, that the slope does not de-attenuate (measured, see the guide).CausalForest.oob_effect(),.effect_variance(),.split_frequencies(), precomputedY_hat=/W_hat=nuisances, and a doubly-robust average for continuous treatments (average_treatment_effect(target_sample="all"), the Riesz representer(W - W_hat) / Var(W | X)). On Card (1995) the return to a year of schooling is 0.0768 (SE 0.0044).
Changed¶
sp.metalearnerstates what its standard error refers to.result.estimate/result.seare the AIPW average and are numerically identical acrosslearner='s'|'t'|'x'|'dr'; the learner-specific quantity ismodel_info['cate_mean'], which ships no standard error. The docstring now says so explicitly andmodel_infocarriesse_refers_toandcate_mean_se = None, so a caller cannot pair the two by accident. No numerical change.-
Neural estimators (
sp.tarnet,sp.dragonnet,sp.cfrnet) flag their standard error.seis the bootstrap dispersion of the mean of the fitted individual effects with the network held fixed (se_method = 'unit_bootstrap_cate_plugin'), which ignores estimation uncertainty in the network; the ML4CI Monte Carlo finds it at 0.3–0.9 of the sampling dispersion.model_infonow carriesse_valid_for_ate = Falseand anse_note. No numerical change. -
Printed summaries: coefficient tables label the statistic
z/P>|z|for likelihood-based fits (the p-values already used the normal); diagnostics print counts as integers (N instruments : 1, not1.0000); theCausalResultfooter omitsNonefields and backend flags and shows floats to six significant digits. Stored values are unchanged. sp.etable([r1, r2])accepts a list; a non-result argument or an empty call raisesTypeError(both used to return an empty DataFrame), and a mixed pyfixest / native call no longer drops the native columns.margins_at,contrastandpwcomparerefuse models whose prediction scale is not the linear index (logit, poisson, …) and results without a coefficient covariance matrix, instead of returning index-scale numbers or assuming zero covariances.-
betareg(link="cloglog")fits the cloglog link (it silently fitted logit) andlink="loglog"is available; outcomes on or outside (0, 1) raise instead of being clipped to [1e-6, 1-1e-6].fracregrejects outcomes outside [0, 1].truncregdrops observations outside the truncation limits with a warning, as Stata does.xtnbreg(model="re")raises onrobust=/cluster=instead of warning and ignoring them.panel_logit(method="fe")raises onrobust=/cluster=(Stata rc 198) and points tosp.clogit(..., vce="cluster id"). -
sp.causal_forestdefaults follow GRF:n_estimators=2000(was 100), subsampling without replacement (bootstrap=Truenow raises),ci_group_size=2(1 with a warning whenmax_samples > 0.5), and nuisances from out-of-bag regression forests on[X, W](user sklearn estimators are cross-fitted in cluster-respecting folds). - For GRF forests,
calibration_testis the regression ofY - Y_haton(W - W_hat) * mean(tau)and(W - W_hat) * (tau - mean(tau))with HC3 (cluster-robust) standard errors and addst_vs_zero/p_one_sided;best_linear_projectionregresses AIPW scores on unstandardised covariates with HC3 standard errors;average_treatment_effect( target_sample="overlap")is the partially linear coefficient. These are the definitions ofgrf::test_calibration,grf::best_linear_projectionandgrf::average_treatment_effect. In-sample inference functions refuse X/Y/T other than the training data.
Fixed¶
-
sp.causal_forestaccepted a keyword its registry entry did not name and rejected the one it did. The registry advertisedn_treeswhile the callable only tookn_estimators, so a caller followingsp.describe_function("causal_forest")got aTypeError. The registry now namesn_estimatorsand the function acceptsn_treesas an alias (through@accepts_aliases, so passing both raisesTypeErrorand the alias is listed bysp.describe_function). Found by the ML4CI agent-surface audit; it was the only hard schema-versus-signature failure among 252 audited callables. -
sp.from_statapost-estimation translations run.margins, dydx(x)emitteddydx=,test x1 x2emittedterms=, andcontrast xemittedterms=— keywordssp.margins,sp.testandsp.contrastdo not take, so every copied call raised. They now emitvariables=(plusmethod="mem"foratmeansand anat={...}dict), the restriction text ashypothesis=, andvariable=;lincomis translated (withlevel()→alpha), andmarginsplottargetssp.marginsplot. Predictive margins (marginswithoutdydx) and options with nosp.marginscounterpart (eyex,over()...) are refused instead of being mapped to average marginal effects. The structural translation sweep exempted post-estimation tools; a new test dispatches each payload on a fitted model. sp.statano longer warns "pipe the previous estimator's result_id" onmargins/test/lincom/contrast/marginsplotlines: that note is advice forsp.from_statacallers, andsp.statadoes the piping itself.- README: a sixth beginner example (clustered logit +
margins/test/lincom/sp.stata, checked against Stata 18), thevce()grammar and house-style argument names in the Stata/R table, and refreshed tier counts; the new outputs are pinned bytests/test_readme_examples.py. sp.from_stata("xtreg y x, fe")without a panel id printedsp.feols('y ~ x', ...)— a runnable pooled regression; the formula now keeps the| <panel_id>fixed-effect placeholder.- Docstring examples of
EconometricResults/CausalResultmethods and of the registry helpers ran on undefinedsp/df(or showed placeholder output), sopytest --doctest-modulesfailed on 21 of them; each is now self-contained with checked output. -
docs/stats.md(per-module table, source / test LOC) and the README at-a-glance LOC figures regenerated fromscripts/registry_stats.py. -
sp.from_stataIV translations were not runnable.ivreg2/ivregressjoined multi-variable lists with spaces (y ~ x1 x2 + (d ~ z1 z2)), whichsp.ivregrejects, and refused exogenous regressors written after the parentheses or several endogenous regressors;ivreghdfeshared the last two gaps. The note about Stata'ssmalloption was also inverted:sp.ivregSEs matchivregress ..., small, so the note now appears whensmallis absent. sp.cross_validate(result)now explains that the result does not carry its data and prints a runnablesp.cross_validate(df, ...)call rebuilt from the result's provenance (the old message pointed at a non-existentdata=argument). Data mode acceptssp.ivreg's own formula,y ~ (d ~ z) + x.sp.regtable(...).to_text()widens columns to fit their widest cell; a fixed 14-character column ran long labels into their neighbours.WeakIVConfidenceSethas a compactreprinstead of dumping every grid array.examples/rd_lee.pyand the RD snippet in the review-time documentation crashed (they used columnslee_2008_senate()no longer returns);tests/test_example_scripts.pynow runs every example script.sp.datasets.lee_2008_senate()documentation no longer attributes the bundled U.S. Senate extract to Lee (2008), which studies House elections; the data are Cattaneo, Frandsen & Titiunik (2015), distributed with Rrdrobust.-
docs/guides/migration-from-r.mdmapped R post-estimation calls to four methods that do not exist (result.robust(),result.summary(vcov=),result.test(),result.marginal_effects()) and to a multiway-cluster spelling that raises; they now point atsp.regress(robust=),sp.test,sp.marginsandvcov={"CRV1": "c1 + c2"}. -
Citation keys: the best-linear-predictor calibration test was attributed to
chernozhukov2020generic(a paper on quantile effect functions) and the generalized random forest toathey2019surrogate(the surrogate index). They now point tochernozhukov2025genericandathey2019generalized;souto2025forestslists its second author's family name as recorded by arXiv and DataCite. New verified entries:kattenberg2023causal,gavrilova2025difference,aytug2026attenuated,aytug2026fixed,aytug2026causalfe,chernozhukov2025generic.
⚠️ Correctness¶
-
sp.dml(model="plr", score="IV-type")now computes DoubleML's IV-type score. The option fits the third nuisanceg(X) = E[Y − θ̃D | X](cross-fitted on the same partition with a clone ofml_g,θ̃the preliminary partialling-out estimate) and solvesE[(Y − Dθ − g(X))(D − m(X))] = 0, asdoubleml.DoubleMLPLRdoes in both its ports. Since the option was introduced it had substitutedl(X) = E[Y|X]forg(X)while keeping the IV-type denominatorΣ v̂·D. That variant is unbiased at the truth but is not Neyman-orthogonal in the propensity nuisance — the Gateaux derivative of its moment in the direction ofm̂isθ₀·E[m₀(X)δ(X)], so regularisation error inm̂entered the estimate at first order — and it reproduced neither DoubleML port: on the ML4CI benchmark (n = 2,000, p = 20, five shared folds, OLS learners) it returned 1.0232 where both ports return 1.0094, a gap of 0.40 standard errors that the unpinned parity test's 0.05 tolerance could not see. With linear nuisance learners the corrected option coincides with partialling out to machine precision (the ML4CI companion paper's Proposition 1); with regularised learners it is a distinct estimator, and its standard error changes accordingly.tests/test_dml_ivtype_reference.pypins it to DoubleML-for-Python on a shared fold vector to 1e-10 (linear) and 1e-8 (lasso). Found by the fold-controlled reconciliation in the ML4CI companion paper. See MIGRATION.md. -
sp.causal_forest(...).ate()and.att()now return the doubly-robust estimate their own standard error describes. The float value was the plug-in average of the fitted CATE predictions while.se,.ciand.pvaluecame from the GRF-style AIPW score, whose point estimate is a different number: on a misspecified binary design (n = 1,500, 500 trees) the two differ by 0.049 for the ATE and 0.120 for the ATT against a standard error of 0.081, so the printed interval was not centred on the number beside it and a caller who compared the point estimate with a benchmark was not comparing the quantity the interval covered. The float is now the doubly-robust estimate — whatgrf::average_treatment_effectreturns — and the plug-in average is kept indetail["plug_in_estimate"], with the difference indetail["plug_in_minus_aipw"]as a diagnostic (it grows where the fitted propensity approaches its bounds). A continuous treatment, where the score cannot be formed and the aggregation falls back to the plug-in average, is unaffected. The package's own reference-parity testtest_causal_forest_aipw_recovery.py::test_plugin_is_more_biased_than_aipwasserted that the plug-in average is the more biased aggregation while reading it offcf.ate(), which is what made the defect visible. Found while tracing the causal forest's miss on the LaLonde benchmark in the ML4CI companion paper. See MIGRATION.md. -
Rows with a missing cluster variable leave the estimation sample, on every estimator that takes the Stata
vce()grammar (regress,ivreg,liml,glm,logit,probit,cloglog,poisson,nbreg,ppmlhdfe,ologit,oprobit,mlogit,clogit,zip_model,zinb,hurdle,truncreg,biprobit,etregress,fracreg,betareg,panel_logit,panel_probit), as Stata'svce(cluster v)and Rfixestdo. AStatsPAIWarninggives the count andmodel_info["n_missing_cluster_dropped"]records it. Before, the estimators disagreed:regress,glm,logitand the count / zero-inflated models raised, the ordered and multinomial logits dropped the rows, andivreg/limlkept them in the fit -- coefficients and clustered standard errors then differed from Stata's (and from the same call on the complete rows). One decorator (core._vcov_spec.markout_clusters) now does it for all of them;tests/test_cluster_markout.pypins every estimator to its fit on the complete rows. -
sp.margins(fit, data=df)returned NaN whendfhad a missing value in a model variable (for example the rows the fit itself had dropped). It now raisesMethodIncompatibilitynaming the row count; omitdata=to average over the fit's own estimation sample (Stata'se(sample)).sp.stata'smarginsline no longer passes the raw data when the fit carries its design, so it averages over the estimation sample as Stata does. -
ML robust / cluster standard errors now follow Stata.
vce="robust"on likelihood-based estimators omitted Stata's N/(N-1) factor (SEs ~0.04% small at N = 1,200);glm,ologit,oprobit,mlogit,clogit,zip,zinbandhurdlecluster SEs carried the regress family's (N-1)/(N-K) factor that Stata's ML commands do not apply.vce="hc0"keeps the unscaled RsandwichHC0. - Options that were accepted and ignored now take effect:
robust=/cluster=ontruncreg,biprobit,betareg,panel_logit/panel_probit(RE), androbust=onsubgroup_analysis.logit/probit/cloglogansweredvce="cluster g"with heteroskedasticity- robust SEs;poissonandnbreganswered it (andnbreg"hc3") with model-based SEs;ologitfamily answered any unknown string with OIM. nbregstandard errors. The robust / cluster sandwich used the Poisson scorey - mu(robust SEs up to ~150% off against Stata on overdispersed data) and the OIM bread ignored the beta / dispersion cross-information;dispersion="constant"(NB1) coefficients were 8.6% off. Both now come from the joint (beta, ln dispersion) likelihood, polished by Newton, and match Statanbregto ~1e-12 (NB2) / 1e-7 (NB1).sp.test/sp.lincomused a diagonal covariance. Any restriction on two correlated coefficients was wrong (test x1 = x2after robust OLS: F 154.4 vs Stata 167.0); they now use the full covariance and the fit's own reference distribution (F / t or chi2 / z). A misspelled coefficient used to be read as zero and now raises.- p-values and confidence intervals. Clustered
regress/ivregused t(N-K); they now use t(G-1) as Stata does (a p-value of 7e-32 vs Stata's 9e-15 on 40 clusters). Likelihood-based estimators used t(N-K) and now use z, as Stata reports. sp.marginsreturned index coefficients. Afterlogitit reported beta (0.568) where Stata'smargins, dydx(*)reports the average marginal effect on Pr(y) (0.126), and with interaction terms its "SE" was std(dydx)/sqrt(n). It now computes AMEs on the prediction scale (identity, logit, probit, cloglog, log links) with delta-method SEs from the full covariance, matching Stata to ~1e-10.- LIML robust / cluster meat uses the instrument-projected regressors
P_Z X, as Stata
ivregress limlandlinearmodelsdo, instead of (I - kappa M_Z) X (~0.05% SE shift);sp.liml(robust=...)now applies the same N/(N-K) small-sample factor as its classical and cluster SEs and assp.iv(method="liml"). clogitstandard errors came from BFGS's quasi-Newtonhess_inv(off by up to 4% against Stata); they now use the analytic information.glmwith a non-canonical link uses the observed information (see Added) and iterates until the coefficients, not only the deviance, have converged (gaussian/log coefficients were ~1e-4 short of the MLE).-
Newton-polished, complex-step observed information for
truncreg,biprobit,betareg,fracreg,mlogit,ologit,oprobit,zip,zinb,hurdleand panel logit / probit replaces second-difference Hessians (1e-6 – 3e-5 relative SE error). -
sp.ivreg/sp.ivfirst-stage F now uses the requested variance estimator. Withrobust=orcluster=, the first-stage F (thediagnosticsentry,model_info['first_stage_f'], the weak-instrument warning andsp.regtable's "First-stage F" row) was still the classical homoskedastic F while every other number used the robust / clustered vcov. It is now the Wald test of the excluded instruments under that vcov divided by the number of instruments, onF(m, N-k)orF(m, G-1)-- what Stata'sestat firststagereports afterivregress ..., vce(robust)/vce(cluster). On the Card (1995) extract with HC1 it moves from 16.72 to 17.51. Checked against Stata 18 to ~1e-13 for one and two instruments, HC1 and clustered (tests/reference_parity/test_iv_first_stage_f_stata_parity.py). Non-robust fits are unchanged. The classical F stays available asFirst-stage F, non-robust (<endog>)andmodel_info['first_stage'][j]['f_statistic_nonrobust'], since Stock-Yogo critical values are tabulated for it.sp.lasso_ivdefaults to HC1, so its reported first-stage F changes too. -
sp.cross_validatecould compare different models or different variance estimators. Three defects, each able to produce a wrong verdict: (1) with an IV formula (y ~ w1 + w2 | x ~ z) the first exogenous regressor was promoted to "treatment" and dropped from the statspai and linearmodels engines' models, which then agreed with each other against pyfixest; (2) the statspai engine ignoredcluster=for OLS / FE / IV andvcov=for IV, and the R engine passed fixest the string"cluster", which clusters on the first fixed effect (or errors without one); (3) linearmodels reported large-sample SEs. Every engine now fits the same model with the same variance estimator and small-sample convention; on OLS / FE / IV with clustered and HC1 errors the statspai, pyfixest, R and linearmodels SEs agree to ~1e-10 (linearmodels with absorbed FE differs by a documented FE-dof convention, ~0.15%). -
sp.regtable(..., model_labels=[...])with a repeated label silently dropped columns fromto_dataframe(),to_excel(),to_word(),save('.csv'),to_dict()/to_json()andsp.collectexports: each row was keyed by label, so["OLS", "OLS", "2SLS"]exported two columns, the survivingOLSholding the second model. Text / LaTeX / Markdown / HTML were unaffected. All exports now keep every model;to_dict()["columns"]disambiguates a repeated label as"OLS (1)","OLS (2)"whilemodel_labelskeeps the labels as given. -
Weak-IV confidence sets printed inward-biased endpoints.
WeakIVConfidenceSet.summary()and.as_intervals()(Anderson-Rubin, CLR, K) reported the extreme grid points inside the set, not the root-foundlower/upper, so the printed interval was too narrow by up to a grid step (Card 1995 AR set:[0.0389, 0.2601]printed,[0.0384, 0.2612]correct). The outer endpoints now come fromlower/upper; interior boundaries of a disconnected set stay at grid resolution. -
sp.causal_forestnow grows generalized random forests. Since it was introduced the estimator grew scikit-learn regression trees on the treatment residual -- splits chased variation in the propensity residual, not treatment-effect heterogeneity -- kept the sklearn leaf mean of that residual in leaves with fewer than two honest rows, and ran every in-sample diagnostic (ATE, calibration test, RATE, BLP) on predictions from trees that had seen the rows. The new default engine implements the GRF algorithm [@athey2019generalized]: gradient-based causal splits with stabilised child constraints, honest cluster-level subsampling with leaf pruning, out-of-bag nuisance forests and CATE predictions, and bootstrap-of-little- bags variances. On five designs with tau(x) = 1 + 2 x0 (n = 2000, 500 trees) the correlation of the CATE with the truth rose from 0.922 to 0.990, RMSE fell from 1.02 to 0.31 and the predicted spread went from 59% to 93% of the true spread, matching R grf 2.6.1 (0.990 / 0.305 / 93%). Against grf on a shared fixture, RMSE ratios are 0.998 (i.i.d.) and 1.017 (clustered), median pointwise-variance ratios 1.01 / 0.94 and CI coverage within one point (tier T3,tests/reference_parity/test_grf_engine_statistical_parity.py); the calibration test, AIPW ATE/ATT/ATC/overlap, BLP andsandwich::vcovCLHC0-HC3 given a forest match grf / sandwich to 8.5e-15. Track A module 13: ATE SE now within 0.22% of grf (was 7.7%).split_rule="legacy"reproduces the old estimator for one release. See MIGRATION.md. CausalForest.effect_interval()returned percentiles of individual tree predictions, which are not confidence intervals for the forest's CATE. It now returnstau(x) +/- z sqrt(V(x))with the little-bag variance.sp.honest_variancemeasured no sampling uncertainty: it re-split one fixed vector of predictions and divided by sqrt(n_splits), so its SE went to zero as n_splits grew. It is deprecated and, for GRF forests, returns the doubly-robust ATE and its influence-function SE.sp.callaway_santanna(control_group="notyettreated")admitted already-treated cohorts as controls for pre-treatment cells underbase_period="universal", and ignoredanticipationin the control set: the cutoff wasG > twhere RdidusesG > max(t, base) + anticipation(get_did_cohort_index). On a three-cohort panel cohort 6's placebo ATT(6, 1..3) were 0.54-0.58 with covariates (R: -0.03 to -0.02) and 1.21-1.43 without (R: 0.27-0.47). All cells now match Rdid2.3.0 to 3.6e-12 (ATT) / 8.9e-11 (SE) across panel and repeated cross-sections, both base periods,anticipation0 and 1, with and without covariates (tests/reference_parity/test_cs_notyet_cutoff_parity.py). The old rule is what Statacsdid, asinrdoes, and the docs had called it the R convention: onmpdtaRdidgives ATT(2007, 2004) = 0.033813 (the csdid default number), not asinr's 0.032971. It remains available asnotyet_cutoff="asinr", still pinned to Stata.notyet_cutoffis now also honoured for repeated cross-sections, where it was ignored.sp.did_bcfcompared treated and never-treated units over different calendar windows: never-treated units were split at the median period whatever the cohort's adoption date, so any non-linear common trend entered the ATT (common quadratic trend, true ATT 1: cohort CATTs 0.01 and 1.64, now 1.07 and 1.03). Each cohort is now compared with never-treated units over its own pre and post windows. The covariate-path standard error (the average posterior spread divided by sqrt(n)) is replaced by the bootstrap standard deviation of the cohort ATT, combined conservatively across cohorts; the silent fallback to a difference in means on any exception is removed. The docstring no longer attributes the estimator to [@souto2025forests], whose model is specified in outcome levels.
⚠️ JSS Track A closure: the last open cross-language rows (StatsPAI vs R / Stata)¶
Closes the items the JSS manuscript's tolerance ledger still carried as open
or convention-only after phase 3. Every number below is on the committed
same-byte fixtures of tests/r_parity/ and tests/stata_parity/; both
reference sides re-derive with zero drift (R 89/89, Stata 85/85, Python 89/89).
⚠️ Correctness¶
sp.gardner_did/sp.did_2stage: the default standard error is now the did2s corrected two-stage clustered variance. The previous default (vce='analytic') clustered only the Stage-2 residuals, treating the imputed counterfactual as known, and understated uncertainty (~26% onmpdta; the docstring warned of ~0.78 coverage at a nominal 95%). The default now builds the Stage-2 cluster sandwich from the influence function of both stages, exactly as Rdid2s::did2s1.2.1 and Statadid2sdo [@gardner2022twostage; @butts2022stage]: withX1the Stage-1 design,X10that design with treated rows zeroed,e1the Stage-1 residual andX2/e2the Stage-2 design and residual,gamma = (X10'X10)^{-1} X1'X2,s_g = sum_{i in g} (x2_i e2_i - gamma' x10_i e1_i),V = (X2'X2)^{-1} [sum_g s_g s_g'] (X2'X2)^{-1}, with no small-sample cluster factor (neither reference applies one). On the mpdta replica the static-ATT SE moves from 0.0051177 to 0.0069036 and agrees with Rdid2sat rel 2.7e-10 and Statadid2sat 1.3e-14; all 26 event-study horizon SEs matchdid2sat 2.1e-14. Point estimates are unchanged in every mode. The event-study overall ATT SE is now the delta-method SE through the full cross-horizon covariance (model_info['event_study']['vcov']) instead of treating horizons as independent.vce='stage2'reproduces the pre-1.29 number bit for bit (with a warning);model_info['se_convention']records which variance was used. Track A module73_did2sregistersrel_se: 1e-6and its SE is a strict three-way parity row rather than a documented gap. See MIGRATION.md.
Added¶
sp.aggte(..., share_variance=True). The aggregated Callaway--Sant'Anna variance carries the sampling variability of the estimated cohort shares (Rdid:::wif, the authors' convention) by default;share_variance=Falseholds the shares fixed, which is what Statacsdid'sestat groupGAveragedoes. Point estimates are identical either way; only the'group'overall (and any multi-cohort cell) SE moves. On thempdtareplica the fixed-share group-overall SE joinscsdidat rel 6.2e-15, so Track A module 04 now pins the Stata convention as a joining row (group_overall_fixedshare) instead of a 0.27% note.result.model_info["share_variance"]records the choice.sp.match/sp.psm:se_method='abadie_imbens_2016', the Abadie & Imbens (2016) variance for nearest-neighbour ATT matching on an estimated logit propensity score [@abadie2016matching] -- what Statateffects psmatch (y) (t x, logit), atetreports:var = sigma^2_delta - c'V_gamma c + d'V_gamma dwithsigma^2_deltathe population-ATT base term ([sum_treated (y_i - yhat0_i - delta)^2 + sum_i xi2_i (K_i^2 - K'_i)] / N1^2),c,dthe score-derivative vectors of the Stata manual ([CAUSAL] teffects nnmatch, "PSM, ATE, and ATET variance adjustment") andV_gammathe logit observed-information covariance (new modulesrc/statspai/matching/_ai2016.py; the terms are returned inmodel_info['ai2016_components']). Conventions pinned against Stata 18: the within-arm conditional-variance set is the unit plus itsai_matchesnearest same-arm units, ties included (ai_matches=Jis Statann(J+1)); the extra clustering behindd(delta)/d(gamma)is Euclidean on the raw covariates with ties; neighbour sets are found on the unclipped score. Onsp.datasets.nsw_dw()(Track A11_psm) the SE is 621.79328 against Stata's 621.79325 (rel 6.4e-8; withai_matches=2, Statann(3), rel < 1e-6), and plugging Stata's owne(bps)/e(Vps)into the formula reproducese(V)to 1e-12, so the residual is Stata's ML stopping rule. Term by term: base SE 673.42,c'Vc = 74,905,d'Vd = 8,036-- the estimated-score term is negative for the ATT here, which is why the corrected SE sits below the psmatch2ai(1)SE (643.35). The default ('abadie_imbens', Abadie--Imbens 2006 sample-ATT, =psmatch2, ai(J)) is unchanged; the 3.4% between the two Stata commands is now a documented convention with two named sources (population- vs sample-ATT target, and the estimated-score term onlyteffectscharges for) rather than an open item. Requiresdistance='propensity',method='nearest',estimand='ATT',bias_correction=False; anything else raisesMethodIncompatibility.sp.fect(cv=True): fect's cross-validation for the factor number (method='ife', gridr_range=(0, 5)) and the nuclear-norm penalty (method='mc', fect'snlambda=10log grid orlambda_grid=), with fect's rolling / block / treated-units holdouts (cv_method,cv_prop,cv_nobs,cv_donut,cv_buffer,k), criteriamspe/gmspe/moment/pcand thecv_rule(1se/min/1pct) re-pick; the CV table is inmodel_info["cv"], the selection inr_cv/lambda_cv; the final fit is refitted at the selected value, sosp.fect(cv=True)equalssp.fect(r=<selected>)bit for bit. Evidence: the CV scoring equals fect's on identical folds (rel 5.9e-8 ife, 6.5e-9 mc) and the curve and selection agree with R across 20 fold seeds (T3;tests/reference_parity/test_fect_interflex_cv_parity.py). Note thatfect()'s user-facing default iscv.method = "rolling"(the"all_units"infect.default's signature is overridden), which the port follows.sp.interflex(estimator='kernel', cv=True): interflex's bandwidth cross-validation (kfold,bw_grid,metric,random_state); the CV table is inmodel_info["cv"], the selection inbw_cv. Evidence: per-fold losses equal interflex'sgetError.CVon identical folds (rel 2.8e-13); curve and selection agree with R across 20 fold seeds (T3). The JSS manuscript's "CV selectors not ported" limitation is closed.- Stata bridges for Track A
18_augsynthand19_gsynth(audited Stata/Mata algorithm bridges, the same class as08_dml).18_augsynth.doreproducesaugsynth0.2.0's Ridge ASCM (simplex-QP SCM weights on donor-mean-centred pre-period outcomes, the 21-point lambda path with leave-one-period-out folds and the 1-SE rule recomputed in Mata, ridge correction): ATT within 1.7e-8 of StatsPAI and 7.9e-6 of R, where the R residual isaugsynth::synth_qp's OSQP stopping tolerance (eps 1e-8; at 1e-13 augsynth agrees with both exact solvers to 1e-11).allsynth(SSC 1.32) refuses the fixture (K + 2donors) and, where it runs, is a different estimand.19_gsynth.doimplements gsynth's control-only factor convention (two-way demeaned control panel, rank-r SVD, treated-unit pre-period projection on[1, F], r fixed at the Rr.cv): ATT within 2.2e-15 of StatsPAI and 7.4e-14 of R.fect_stata method(ife)is a different estimator (IFEct EM over all untreated cells; converged Rfect(method="ife")-0.33206 vs gsynth -0.32417) and is emitted as the unjoinedife_att_avg_fect_statadiagnostic;fect_statahas nomethod(gsynth).compare.py::STATA_SKIP_REASONis down to four modules (13, 77, 79, 80); 85 of 89 Track A modules carry a Stata reference.
Fixed¶
sp.rdplotraisedIndexErrorwhen the largest running-variable value fell one ulp past the last evenly spaced bin edge (the phase-3_rdplot_coreport ofrdrobust::rdplot; on the Lee-Senate replicax_max = 0.8635116005610454whileseq(c, x_max, range/J)ended at0.8635116005610453). The R port'sseqnow snaps its last element to the endpoint, so the maximum observation is inside the last bin asfindInterval(rightmost.closed = TRUE)intends; bin means and counts are unchanged for every other observation. Found by the JSS figure script (ex04_lee_rd), which is why the worked examples are executed rather than summarised.
Changed (parity harness)¶
- Track A
27_glmm_aghqmoves from the machine tier to the iterative tier (rel_est1e-6 -> 5e-6). Not a convention gap: thelogLikrow joinslme4at 1e-13 andlme4's own deviance function reproduces StatsPAI's optimum to 5e-11, but the 8-point AGQ objective is flat along (intercept, sqrt(var)) andlme4's optimisers (bobyqa,nloptwrap,optimx/nlminb) scatter by 1.1e-6 to 1.5e-6 in the intercept at the same deviance; Statamelogit(mvaghermite) sits 2e-8 from StatsPAI and all sides agree to 3e-8 at 30 quadrature points. The contract test now also guardslogLikat 1e-9 so quadrature drift cannot hide behind the optimiser-noise budget. Registered indocs/dev/r_parity_tolerances.md("Loosening applied 2026-09-22"). Strictness tiers are now 79 machine / 8 iterative / 1 moderate / 1 T4. - Track A
18_augsynth's 2e-5 point budget is re-graded A (mechanism: OSQP tolerance on the R side), unchanged in value. sp.gardner_didno longer emits the "0/n bootstrap replicates succeeded"RuntimeWarningin static mode (the per-coefficient series was never filled there; the reported SE was always the correct overall bootstrap SE), and warns when the cluster variable has a single level under the defaultvce.paper.bibgainsabadie2016matching(Abadie & Imbens 2016, Econometrica 84(2), 781-807, DOI 10.3982/ECTA11293; refs verified via Crossref and doi.org).
[1.28.0] — 2026-09-13¶
Added¶
-
n_jobs=for the classic SCM placebo loop.sp.SyntheticControl(..., n_jobs=...)andsp.synth(..., method='classic', n_jobs=...)fit the in-space placebos in worker processes (-1= every CPU). Each worker runs the unchanged serial code on the same inputs, so every placebo statistic, the p-value and the SE are bit-identical ton_jobs=1(the default, which keeps the previous behaviour). Workers usespawnon every platform; if the pool cannot start, the loop falls back to serial with aRuntimeWarningand records the reason inmodel_info['placebo_parallel_fallback'].model_info['placebo_n_jobs']reports the worker count actually used. -
model_info['in_predictor_hull']andperfect_fit='exact_balance'for classic SCM with covariates. Nested V-W fits now report, by an exact linear-programming check, whether the treated unit's predictors lie inside the donors' convex hull. Inside it, infinitely many donor-weight vectors fit the predictors exactly, so the ADH V search returns path-dependent weights and is slow: on Prop 99 with four covariates, 20 of the 39 states are inside, including the slowest placebos (Maine 323 s, Alabama 108 s). The default (perfect_fit='legacy') keeps the ADH search and every existing number.perfect_fit='exact_balance'instead balances every predictor exactly and returns the weights with the best pre-treatment outcome fit among them: a convex QP solved bytrust-constr, polished by an exact KKT solve on the active support, and certified by a Frank–Wolfe gap. All 20 in-hull Prop 99 states certify (worst relative gap 6.5e-11, constraint violation <= 1.6e-12) in 3.5 s together; out-of-hull units still go through the V search. This is a different estimator, not a faster ADH: the V search can zero out some predictors and so fit the outcome better. Montana's pre-period SSE is 12417 underexact_balanceagainst 2898 under the search, while Georgia's is 122 against 1022, Maine's 168 against 3215 and South Dakota's 34 against 2115. SLSQP was tried for the QP and rejected: on these degenerate equality-constrained problems it reported success at points far from the optimum (Illinois 4113 against a certified 553). California Prop 99 with Abadie's four covariates and all 38 placebos finishes in 534 s withn_jobs=-1on 8 cores; the serial loop had passed 22 CPU-minutes when it was stopped. The wall time is set by the slowest in-hull placebos, not divided evenly by the core count. sp.cs_jackknife— cluster-jackknife (CV3) inference for Callaway–Sant'Anna aggregates [@karim2026improved]. Deletes one cluster at a time, re-estimates every ATT(g, t) and the cohort shares, re-aggregates withsp.aggte(type=...), and reports(R-1)/R * sum (ATT_(-h) - ATT)^2witht(R-1)inference — the few-cluster / few-treated-cluster alternative to the analytic and multiplier-bootstrap standard errors, which both over-reject there. Follows the authors' Rdidjackand Statacsdidjackexactly, including the single-treated-cluster skip. On the castle-doctrine panel (50 states; never- and not-yet-treated controls, unweighted and population-weighted, simple / dynamic / group / calendar) every aggregate and every delete-one replicate reproduces Rdidjack0.1.0 to 4e-15, and Statacsdidjack0.5.2 matches to 4e-15 on the twelve aggregates it implements (tests/test_cs_jackknife_reference.py). Runningcsdidjackneeds one workaround:csdid1.81 calls an undefinedeaster_eggprogram with probability 1/1000 per call, so define it as a no-op first. A delete-one sample that cannot identify the aggregate (e.g. deleting the only never-treated unit) raisesDataInsufficientnaming the cluster rather than dropping the replicate.
⚠️ Correctness¶
- Synthetic-control placebo p-values dropped the treated unit from the
ranking. Every in-space placebo p-value in
sp.synthcomputedmean(placebo >= treated)— denominatorJ, treated unit not counted — and then floored the result at1/(J+1). The permutation p-value of Abadie, Diamond & Hainmueller (2010) ranks the treated unit together with itsJplacebos:p = (1 + #{placebo >= treated}) / (J + 1). The two agree only when the treated unit is the most extreme; at rankr > 1the old value was(r-1)/J, too small. On California Proposition 99 (the README example) California ranks 3rd of 39 states andsummary()printed2/38 = 0.0526; it now prints3/39 = 0.0769. The new p-value is never smaller than the old one. All sites now share one helper,statspai.synth._core.placebo_rank_pvalue.
Affected: the placebo pvalue of the native estimators in
synth/scm.py (classic sp.synth / sp.SyntheticControl), penscm.py,
demeaned.py, robust.py, sparse.py, gsynth.py, mc.py,
staggered.py, multi_outcome.py (per-outcome and overall),
augsynth.py, kernel.py, and discos.py (headline, Cramér–von Mises and
first/second-order stochastic-dominance tests). Not affected: point
estimates, weights, placebo-dispersion SEs and CIs; conformal, scpi,
Bayesian/BSTS and sdid inference. See MIGRATION.md.
Fixed¶
-
src/statspai/_parity_index.jsonis ASCII again. The alias evidence note written by_parity_taxonomy.AliasProof.evidence_note()used an em-dash, which put five non-ASCII bytes into the shipped parity index from 1.26.0 onward. The JSS submission archive requires data files to be ASCII at the source (it verifies.json/.csv/.lockrather than transliterating them, because the fixture lock and provenance audit trace those bytes), somake submission-readyrefused the 1.27.0 tree. The note now uses--; only the text of those fivenotesentries changes, no grade or number does.tests/test_archive_ascii_contract.pyruns the archive's ASCII check in the ordinary suite, since the original lives in the git-ignoredPaper-JSS/tree and never ran in a worktree or in CI. -
sp.SyntheticControlno longer swallows failed in-space placebo fits. A dropped placebo shrinks the permutation distribution and moves the p-value, so it now raises aRuntimeWarningand is listed inmodel_info['placebo_failures'](unit,error_type,message).
Changed¶
- Nested V-W fits (
covariates=/special_predictors=) are faster per inner solve. The inner simplex least-squares solve now hands SLSQP the exact Jacobian of the adding-up constraint instead of letting it finite-difference it every iteration. On California Prop 99 with Abadie's four covariates the treated-unit fit drops from 13.6 s to 5.9 s. Each placebo is still slow: placebo states that lie inside the donors' covariate hull (e.g. Alabama, 108 s; Arkansas, 77 s) have a continuum of perfect-fit inner solutions, so SLSQP iterates ~100 times per solve and the outer Nelder-Mead often exhausts its iteration budget. Making a single fit faster changes which of the observationally equivalent weight vectors is reported, so it is left for a separate, documented change; usen_jobs=(below) to cut wall time without changing any number. The optimum is unchanged: outer loss equal to 10 significant digits, donor weights within 1e-8. Committed parity outputs move by at most 3.5e-8 relative (07_scmheadline) and 1e-15 (52_scm_unique); both still reproduce byte-identically. The07_scmtable'ssolver_best_startlabel changes fromregressiontodirichlet_3, two starts whose losses tie to 1e-12. A warm-started inner solve was tried and rejected: when W(V) is non-unique it makes the outer loss history-dependent and sent one Basque start into a worse basin.
Docs¶
- README / README_CN: the synthetic-control example output was stale
(Montana 0.84 / Nevada 0.16, ATT -13.09). It now shows what the code prints
(ATT -19.76, p = 3/39, six donors led by Utah 0.377), explains the rank-based
p-value, and points to
covariates=for an ADH-style specification.
⚠️ Evidence-grade corrections¶
No estimator's output changed in this section. What changed is which functions the parity index vouches for as agreeing with R or Stata.
- 31 functions were graded bit-exact against R or Stata on tests that
never consulted either. Each was a curated promotion in
scripts/build_parity_index.pywhose only test checked closed-form identities —total == within + between,E = RR + sqrt(RR(RR − 1)), a star graph's hub scoring 1.0 — several with areference_versionsfield naming an R build that nothing had run, and one asserting in its docstring that an R package "implements the identical closed form" in place of comparing against it. That is known-truth evidence (T1), and it was being counted as cross-language parity (T2), the claim the JSS manuscript rests on. - Eight now have the comparison they claimed, built in this release:
evalue_rragainstEValue::evalues.RR(1e-12, ten cases including the branch rules);degree_centrality,betweenness_centralityandclusteringagainst igraph;eigenvector_centralityagainstsna::evcent(5e-11);mragainstMendelianRandomization(see Mendelian randomisation below — the comparison found that its default standard error was half the reference's);das_guptaandkitagawa_decomposeagainstDasGuptR(see decomposition below —das_guptawas decomposing a different aggregate on stratified data). The old eigenvector note also said the L2 normalisation matched igraph; igraph max-scales, and 2.x ignoresscale = FALSE. - Twenty-three move to
analytical-only, the grade their tests support:auc,roc_curve,bootstrap,breakdown_frontier,contrast,direct_standardize,gelbach,icc,indirect_standardize,kdensity,lee_bounds,lrtest,manski_bounds,margins_at,mediate_interventional,mediation_decompose,oster_delta,policy_value,power_case_control,pwcompare,sensitivity_specificity,source_decompose,subgroup_decompose. Their tests are unchanged and still pass; they now carry the label those tests earn. Several have R or Stata references and are candidates for real promotion. sensemakrwas also on the list, but its grade was already carried by Track A module22_sensemakr; the redundant entry is removed and the grade is unchanged.- The correction alone took cross-language coverage, as reported by
sp.parity_summary(), from 220 to 194 of 773 estimator callables (28.5% → 25.1%). The real comparisons added elsewhere in this release bring it to 215 (27.8%). - The index now refuses to build such an entry. A promotion claiming an
R or Stata side must list a test that loads a reference fixture or calls
R, or record a
provenancefor embedded constants (the live-captured Stata values behinddid_2x2anddddare the one legitimate case of that today).sidesis also now required: it defaulted to["py", "R"], so an entry could claim an R comparison by omitting the field. Guarded bytests/test_parity_index.py.
⚠️ Correctness fixes — network¶
sp.dyadic_regressiondouble-counted reciprocal dyads in its robust variance. The Aronow–Samii–Assenova estimator lets any two dyads that share a member covary, each pair entering once. The implementation summed scores by node and tookΣₙ SₙSₙ′ − g′g, which weights a pair by the number of members it shares. That coincides with the definition when every unordered pair appears once — so undirected results are unchanged — but on directed data(i, j)and(j, i)share both members and their cross terms entered twice: 1.8% on the standard errors of a 20-node design. The fix subtracts the within-pair score sums instead of the per-row ones. Directed dyadic regressions should be re-run. Rows withi == jnow raise rather than entering with an inconsistent weight. The repository's own brute-force test already encoded the correct definition; it only ever ran on undirected data.
Added — network¶
- Cross-language evidence for 19 network functions against R
igraph,sna,ergmanddyadRobust(tests/reference_parity/test_network_parity.py): centrality scores, PageRank, Bonacich power, Katz, Wasserman–Faust closeness on a disconnected graph, transitivity, assortativity, reciprocity, modularity, components,network_summary, QAPnetlm/netlogitcoefficients, ERGM MPLE, dyadic-robust OLS, and the bundled karate and Florentine datasets — all at 1e-9 or better, except ERGM MPLE standard errors at ≤3.2e-7. HITS and the eigenvector column ofsp.centralityagree with igraph up to a documented normalisation (the ratio is constant across nodes); Louvain is compared as a 200-seed distribution (T3). sp.eigenvector_centrality's docstring claimed its L2 normalisation matched igraph; igraph max-scales. Corrected.sp.florentine_familiesnow documents that the Pucci isolate is omitted relative toergm's 16-nodeflomarriage.- Found while building the reference: the R package
dyadRobustrecodes node ids inside onedplyr::mutate()call, and becausemutateevaluates sequentially the alter recode is built from the already-recoded ego column. A node then gets different codes as ego and as alter unless the ids are already1..Nin first-appearance order. The fixture uses such ids and asserts the precondition.
⚠️ Correctness fixes — Mendelian randomisation¶
Found by comparing every MR function against the R packages that define
it — MendelianRandomization, TwoSampleMR, RadialMR, MRPRESSO and
mr.raps — on the 28-variant LDL-C → CHD data shipped with
MendelianRandomization. The percentages are on that example. MR
analyses should be re-run; MIGRATION.md#mr-sweep has the table.
sp.mr_ivwreported the fixed-effect standard error regardless of heterogeneity — 0.276 against the reference's 0.530 here (Cochran's Q p = 3e-10). Both reference packages default to multiplicative random effects (SE ×max(1, RSE)) with more than three variants, and so doessp.mr_ivwnow;model="fixed"restores the old SE. The point estimate is unchanged.sp.mr("ivw"),sp.mendelian_randomizationandsp.mr_leave_one_outinherit the change (the last used to recompute a fixed-effect SE inline, 49% off).sp.mr_eggerdid not orient variants, so its result depended on which allele each variant was coded against: slope 14% and intercept — the pleiotropy test — 38% off, with 16 of 28 variants having negative exposure associations. Its residual standard error was also not floored at 1.sp.mr_pleiotropy_egger(which carried its own copy of the fit) and Rücker's Q′ insp.mr_heterogeneity(method="egger")are fixed with it.sp.mr_median: a step weighted median instead of Bowden et al.'s interpolated one (0.8%); a penalty using the lower-tail χ² probability — up-weighting exactly the heterogeneous variants it exists to suppress — with nomin(1, 20 q)cap and centred on IVW instead of the weighted median (38%); and a bootstrap that redrew the weights. Newweighting="simple".sp.mr_mode: bandwidth, grid and bootstrap all differed from Hartwig et al.'s (estimate 7% off). The SE is now the bootstrap MAD, as in the reference. Newphi=andrefine=.sp.mr_cmlpenalised its BIC by the number of variants instead of the GWAS sample size the method requires (log 28 against log 17723), and selected six invalid variants where the reference selects two; its SE held the nuisance exposure effects fixed (~12% small). Newn=(without it the old stand-in is used and a warning says so) andmodel_average=;K_maxdefaults ton_snps − 2.sp.mr_rapswas a different estimator under the name: a Tukey loss minimised jointly over(β, log τ²)with a one-dimensional sandwich treating τ² as known — τ² 3.5× too small, the estimate 1.4% off, the SE 47% too small. Rewritten as a port ofmr.raps0.4.3 (loss=,over_dispersion=,pruning=,niter=,tol=); the default loss is now Huber, the package default.tuning_cpassed withoutlosswarns that it now applies to Huber;beta_init/tau2_initare ignored with aDeprecationWarning.sp.mr_steiger's p-value was one-sided and computed as1 − Φ(z), exactly 0.0 beyond z ≈ 8.3 (the reference reports 1.8e-73 here). It is two-sided by default, asTwoSampleMRreports it;alternative="greater"gives the one-sided test.sp.mr_pressois now a port ofMRPRESSO::mr_presso. Outlier p-values were not Bonferroni-adjusted (seven outliers flagged where the reference flags two, moving the corrected estimate 2.4%), SEs were fixed-effect rather than residual-scaled (half the reference's), the outlier test ran when the global test did not reject, and the "distortion test" was a z-test rather than PRESSO's resampling test. Monte Carlo p-values return to the reference'sk / B, reversing 1.5.0's(k + 1) / (B + 1): with Bonferroni the latter's smallest attainable outlier p-value isn / (B + 1), above 0.05 for 50 variants at B = 1000, so the test could never flag anything. A 0 now means "below the Monte Carlo resolution", and a warning fires whenn_bootcannot resolve the adjusted threshold. New fieldsoutlier_pvalues,distortion_coefficient.- Every p-value in
sp.mendeliannow comes from a survival function:1 − cdfis accurate only to ~1e-16 absolute, 1e-7 relative on the heterogeneity p-value of 3e-10 here.
Added — Mendelian randomisation¶
- Cross-language evidence for 15 MR functions
(
tests/reference_parity/test_mr_R_parity.py):mr,mr_ivw,mr_egger,mr_median,mr_mode,mr_cml,mr_steiger,mr_radial,mr_presso,mr_leave_one_out,mr_pleiotropy_egger,mr_heterogeneity,mr_f_statisticandmr_multivariableat 1e-9 or better on every deterministic quantity (bootstrap SEs and simulated p-values are Monte Carlo on both sides and not compared).mr_rapsis gradedaligned: the Gaussian fits match at 1e-8, and for the robust losses the sandwich reproducesmr.raps's SEs at 1e-10 when evaluated at its own estimates, while the fitted values agree to 5e-5 (β) and 1.5e-3 (τ²) becausemr.rapsstopsunirootat its default tolerance andintegrateat 1.2e-4 — StatsPAI's root satisfies the estimating equation more tightly. sp.mr_radial(bonferroni=, alpha=):bonferroni=FalsereproducesRadialMR::ivw_radial's outlier list; the Bonferroni default is kept.- The Mendelian-family guide is updated for all of the above.
⚠️ Correctness fixes — decomposition¶
Found by comparing against DasGuptR, ddecompose and cdgd (the last
two maintained by the methods' authors or their groups).
sp.das_guptadecomposed a different aggregate on stratified data. Its docstring defines the aggregate asΣᵢ ∏_f f_{f,i}over strata, but with more than one row per population the code multiplied the factor means. On Das Gupta's own Table 6.5 (four factors × six age groups) factor A's share of the 1963→1968 change came out 0% whereDasGuptRgives 36.8%, and factor D's +333% where it gives −52.5%. It now sums the per-stratum decompositions; rows are paired by the newby=or by position, and unequal row counts raise. Single-row inputs are unchanged.sp.gap_closing(method="ipw")reweighted in the wrong direction, and so did the reweighting term ofmethod="aipw": both used the reciprocal of the density ratiof_target / f_source. WithY = 2Xand no group effect, the IPW counterfactual gap came out at twice the observed gap instead of zero; AIPW stayed right only while its outcome model was right, i.e. it was not doubly robust. IPW now matchesddecompose::dfl_decomposein both directions (1e-9);method="regression"was already right and matchesddecompose::ob_decompose.-
sp.yu_elwert_decompose(method="efficient")'s components did not add up to the disparity (0.566 against 0.572 on a 2,000-row example): it computed selection as a covariance of doubly robust scores rather than as the residual, and used unnormalised inverse-probability weights. It now followscdgd::cdgd0_manual— Hajek-normalised potential outcomes, selection as the residual — and matches it to 1e-9 given the same nuisance fits. Newinference="analytic"gives the package's efficient-influence-function standard errors.method="plugin"(the default) is unchanged. -
The Gini RIF averaged to neither Gini.
sp.rifreg(statistic="gini"),sp.rif_valuesand the Gini path ofsp.ffl_decomposecombined a midpoint ECDF with then/(n−1)-corrected Gini; oncps_wagethe RIF mean was 0.10405 against a plug-in Gini of 0.10442 and RIF-regression coefficients were up to 1% off. The RIF is nowdineq::rif(method = "gini")'s, to 1e-12 per observation, whose mean is exactly the plug-in Gini; the Gini inside the decomposition machinery (dfl_decompose,ffl_decompose) is that plug-in Gini.sp.inequality_index("gini")keeps its documented bias-corrected default. -
sp.ffl_decomposemislabelled and mis-signed its error terms.spec_errorheld the reweighting error andreweight_errorthe specification error; withreference=1the specification error also had the wrong sign, so the four components did not add up to the gap (0.1184 against 0.1216 for the variance oncps_wage).gapis now the difference of RIF means — the plug-in statistics the components add up to, exactly; for the variance that removes then/(n−1)factor it carried. Every term now matchesddecompose::ob_decompose(reweighting = TRUE)in both directions for the variance, Gini and 10th/50th/90th percentiles. -
sp.gelbach's standard errors dropped a covariance. They were a two-term delta method treating the auxiliary regressions and the long regression as independent. They now follow Gelbach's own Stata commandb1x2(each added variable its own group): the stacked-system sandwich, plus the part due to the long-regression coefficients and both cross terms, robust by default and homoskedastic withrobust=False. The result gains the jointvcovof the contributions andtotal_se(1′V1, asb1x2reports it). Point estimates are unchanged; oncps_wagethe SEs moved by 0.03%. -
sp.bauer_sinning/sp.yun_nonlinearreported no inference and no detailed unexplained part. They now portmvdcmp(Powers, Yoshioka & Yun): the Yun-weighted detail of both the explained and the unexplained component (detailed_unexplained, including the constant), the joint delta-method covariancevcov, andsefor the explained, unexplained and total gap. Point estimates are unchanged. Probit fits now finish with observed-information Newton steps: Fisher scoring stopped ~1e-8 short of the MLE (this also touchessp.fairlie(model="probit")at that level).
Added — decomposition¶
- Cross-language evidence for
das_gupta,kitagawa_decompose,gap_closing,yu_elwert_decompose,rifreg,ffl_decomposeand (beyond the mean)dfl_decompose(tests/reference_parity/test_decomp_R_parity.py), on Das Gupta's worked examples, simulated data andcps_wage, read by both sides from the same bytes. - Stata-side evidence for
gelbach(b1x2, robust and homoskedastic, to 1e-9 on the full covariance),subgroup_decompose(ineqdeco, GE(0), GE(1), GE(2) total / within / between to 1e-12) andsource_decompose(descogini, every share, Gini and Gini correlation to 1e-12). - Evidence for
bauer_sinningagainst Statamvdcmp: logit to 1e-9 on every term, detail and covariance; probit aligned to 2e-6, becausemvdcmpfits its probits at Stata's default tolerance (the fixture records that Stata's tightly converged probit equals StatsPAI's to 1e-12). sp.source_decompose(gini="population"): the plug-in Gini ofdescoginiand Lerman & Yitzhaki's covariance formula. The default keeps then/(n−1)-corrected Gini ofsp.inequality_index; shares, Gini correlations and each source's percentage are identical under both.quantile_convention="rifreg"forsp.rifreg,sp.rif_valuesandsp.ffl_decompose:rifreg/ddecomposeuse the indicatory ≤ qwheredinequsesy < q.sp.dfl_decompose(stat_convention="hmisc"): the weighted variance (Σw(y − ȳ)² / (Σw − 1)) and quantile ofHmisc, whichddecompose::dfl_decomposeuses for the reweighted counterfactual; with it the variance and quantile decompositions matchddecomposeto 1e-12. The default keeps the reliability-weight variance. Neither is wrong — under non-uniform weights the two are different estimators — and the choice moves the composition effect in the fourth significant digit.
⚠️ Correctness fixes — dynamic panels¶
sp.xtdpdsysdid not reproduce Stata'sxtdpdsys. It ranxtabond2's default moment set — strictly exogenous regressors instrumenting both equations in one combined column, and theh(3)one-step weight — whereas Stata'sxtdpdsysinstruments them in the differenced equation only and weights withh(2)(cross quadrants ofHzeroed). Onabdata(nonL.n w k) the lagged-dependent coefficient was 0.686 wherextdpdsysreports 0.542, andw's −0.20 where it reports −0.62.sp.xtdpdsysnow defaults to Stata's convention and matches it to 1e-12 one-step robust, two-step Windmeijer and classical, instrument count included. The Stata fixture had carried thextdpdsyscovariate spec all along; no test read it. Re-run system-GMM results obtained throughsp.xtdpdsys.sp.xtabond(method="system")keepsxtabond2's defaults and is unchanged.
Added — dynamic panels¶
h=(1, 2, 3;xtabond2'sh()) andiv_equation=("both","diff","level") onsp.xtabondandsp.xtdpdsys;sp.xtdpdsys(..., iv_equation="both", h=3)is the previous estimate.xtlsdvcgraded bit-exact against Stataxtlsdvc(Bruno): its coefficient comparison over every initialiser and bias order had been in the suite for months without a registered promotion. A test now also reads theabdatafixture'sB4spec — Stata's defaultxtabondwith_cons— confirming every slope and SE is unaffected by the constant StatsPAI's difference GMM omits.sp.from_stata("xtdpdsys ...")now translates to a runnablesp.xtdpdsys(...)call; it failed loud with a stale "system GMM not implemented" message.
⚠️ Correctness fixes — production functions¶
sp.olley_pakesandsp.levinsohn_petrinwere not the OP / LP estimators. They put every input into a degree-3 polynomial and estimated the labour coefficient in a GMM second stage that used current labour as its own instrument. OP and LP read the free-input coefficient off the stage-1 regression of output on labour and a polynomial in (capital, proxy), then choose the capital coefficient to minimise the squared productivity innovations under a cubic Markov polynomial. That is what Stata and Rprodestcompute. On the parity panel (true labour elasticity 0.60) labour moves from 0.999 (OP) / 1.003 (LP) to 0.633 / 0.615. The stage-1 coefficient now matches bothprodestimplementations to 1e-12; the capital coefficient is the exact minimiser, where both references stop their optimisers early.- Lags shifted rows instead of periods. Every production-function
estimator lagged by position within a firm, so a firm that skipped 2002
paired 2003 with 2001. They now lag by calendar year (
time - 1, missing across a gap), as Stata'sL.andprodest'slagPaneldo. The "non-consecutive time periods" warning is gone;diagnostics["n_calendar_gaps"]counts the affected rows. Duplicate (firm, year) rows or a non-numerictimenow raise. sp.ackerberg_caves_frazerreturned an arbitrary local optimum: Nelder-Mead on the GMM criterion from five fixed starts, with a linear Markov process by default. It now solves the just-identified moment conditions exactly (analytic Jacobian) from the stage-1 coefficients and a grid of starts. It returns the root nearest the stage-1 coefficients, lists every root indiagnostics["acf_roots"]and warns when there is more than one. On the parity panel RprodestACFstops 2.4e-6 from the same root; Stataprodest, acfstops at points that are not roots.sp.wooldridge_prodtreated labour as exogenous. It minimised a stacked sum of squares in which labour was its own instrument in the productivity equation, where labour is correlated with the innovation. It is now a joint GMM:- equation 1 is instrumented by current labour and the control-function terms;
- equation 2 by capital, lagged labour and lagged control-function terms;
- the Markov polynomial is free, and SEs are analytic and firm-clustered
(previously NaN without
boot_reps).
convention="prodest" reproduces Stata prodest, method(wrdg) to 1e-8,
coefficients and its unadjusted variance. That convention imposes a
unit-slope Markov process, which on the parity panel (rho = 0.7) puts
capital at -0.64 against a true 0.30; the default puts it at 0.34. R
prodestWRDG also drops the second equation's intercept and leaves the
constant out of its instruments (capital -0.79).
Changed — production functions¶
- Default
productivity_degreeis 3 (cubicg, as in bothprodestimplementations) forolley_pakes,levinsohn_petrin,ackerberg_caves_frazerandwooldridge_prod. It was 1, and 2 forwooldridge_prod, whose defaultpolynomial_degreeis now 3 (was 2).sp.prod_fnpasses the degrees through only when they are given. functional_form="translog"raisesNotImplementedErrorfor OP and LP, whose free-input coefficients are linear stage-1 coefficients. ACF keeps translog.sp.wooldridge_prodgainsvce=("cluster","robust","unadjusted") andconvention=("statspai","prodest").- The firm bootstrap makes each resampled firm its own panel, so a firm drawn twice no longer collides with itself in the lag.
Added — production functions¶
tests/reference_parity/test_prodest_parity.pyagainst Stata 18prodest(SSC) and Rprodest1.0.2 on a simulated unbalanced panel with calendar gaps (_fixtures/_generate_prodest_{data.py,R.R,stata.do}).
⚠️ Correctness fixes — small p-values package-wide¶
- 335 p-values in 180 modules were computed as
1 − cdf(x), including the coefficient table of every result built onEconometricResults(sp.regressand the estimators that share it). In double precision1 − cdfis accurate only to ~1e-16 absolute: a p-value of 1e-10 carries a relative error near 1e-6, and anything below ~1e-16 is returned as exactly 0.0 (for a normal test, any |z| above ~8.3). They are now the distribution's survival function,sf(x), which is accurate across the whole range. Moderate p-values move by at most a few units in the 16th decimal; tiny ones become correct instead of 0. The rewrite was done on the syntax tree —1 − <scipy distribution>.cdf(args)→.sf(args), including frozen distributions — so operator precedence is never guessed; two call sites through lazily importedscipy.statswere converted by hand. Found when the MR sweep's heterogeneity p-value (3e-10) disagreed withTwoSampleMRat 1e-7.
[1.27.0] — 2026-09-12¶
⚠️ Correctness¶
- ETWFE cohort-level ATTs were read off an unsaturated regression and were
wrong by up to 37%.
sp.wooldridge_didandsp.etwfeeach ran two regressions on the same data: a saturated cohort × period design, which fed the event-study output, and a separate design carrying a single post dummy per cohort, which feddetailand — forsp.wooldridge_did— the headline. The second design is not saturated in cohort × period, so under dynamic treatment effects the already-treated cohorts enter the period fixed effects and contaminate every treatment coefficient. That is the forbidden comparison of Goodman-Bacon (2021), i.e. precisely the bias extended TWFE exists to remove. Cohort ATTs are now aggregated from the saturated cells (treated-observation weights within cohort), which is what Retwfe::emfx(type='group')and Statajwdid, estat groupreport.
On the committed 17_etwfe bytes, sp.etwfe(...).detail moves from
-0.040562 / -0.035247 / -0.037735 to
-0.0390171350 / -0.0311139256 / -0.0274615453, against R's
-0.0390171349687 / -0.0311139255617 / -0.0274615452587 — the old values
were 3.9%, 13.3% and 37.4% away from the reference. On a deterministic DGP
with true cohort ATTs 3.0 and 2.5, sp.wooldridge_did returned 2.67 and 1.63
(headline 2.15 against a true 2.78) and now returns 3.00 and 2.44
(headline 2.72).
Affected: sp.wooldridge_did — estimate, se, pvalue, ci,
detail; sp.etwfe(...).detail on every cgroup / panel combination;
sp.etwfe_emfx(type='group'); sp.etwfe_emfx(..., weighting='cohort') for
every type. Not affected: the sp.etwfe headline
(estimate / se / ci) and sp.etwfe_emfx(..., weighting='treated'),
which already aggregated the saturated cells — the certified 17_etwfe
pooled ATT is bit-identical before and after
(-0.035108276608100). See MIGRATION.md.
sp.wooldridge_didclustered SEs ignored the absorbed fixed effects in the CR1 small-sample factor. It counted only the explicit design columns in(N-1)/(N-K), whilefixest'sssc(fixef.K="nested")andreghdfe's default count every absorbed effect that is not nested inside the cluster variable.sp.event_studyandsp.sun_abrahamwere moved onto that rule in 1.24.0 (parity module 85);sp.wooldridge_didnow uses the samedid._core.fe_dof_not_nestedhelper. SEs rise by a uniform ~0.08% on17_etwfe, which takes the per-cohort SE agreement with R from 8.0e-4 to between 1.3e-8 and 5.5e-6, and with Statajwdidto 2e-15. Point estimates are unchanged by this item.
⚠️ Correctness fixes — sp.sqreg¶
sp.sqregrounded its returned coefficients and standard errors to four decimal places. Not for display — in the values the caller gets back. A coefficient of order1e-3therefore came back with one significant digit, capping agreement withquantreg::rqat 7.8e-03 relative on this repository's own fixture, and any downstream arithmetic inherited that. Removing the rounding puts the coefficients at 3.5e-14 against R, which is where the estimator always was. The reference-parity test previously asserted four decimals and recorded that ceiling as the estimator's accuracy; it now pins the real one, plus the sparsity-convention structure of the standard errors.
⚠️ Correctness fixes — panel¶
sp.panel_logit(method='re')andsp.panel_probit(method='re')were fitted without an intercept. Both built their design matrix from the regressor list alone. That is correct for conditional FE logit, where the constant is differenced out, and wrong for a random-effects model, where it is a parameter — so every slope was biased. On a balanced N=60, T=12 panel the error is 0.39% against Stata'sxtprobit, re; on a design whose regressors are not centred there is no bound on it.
The gap's stubbornness is what identified it: 0.39% at 12 quadrature
points and 0.39% at 30. A non-adaptive Gauss-Hermite rule that is merely
coarse converges as points are added; one integrating the wrong
likelihood does not. With the intercept restored, agreement improves to
4.0e-08 at 60 points and the log-likelihood matches Stata to 1.7e-09.
Random-effects panel binary results should be recomputed.
- sp.panel_fgls ran Stata's igls, not xtgls. It re-estimated the
variance parameters from the GLS residuals and iterated to convergence.
Stata's xtgls is two-step by default; iterating is the igls option.
The docstring meanwhile said "Equivalent to Stata's
xtgls y x, panels(het)". 2.8% apart on the slope. The default is now
the two-step estimator, matching xtgls to 6.3e-16, and igls=True
keeps the previous behaviour under the name Stata gives it (matching
xtgls, igls to 4.7e-08).
⚠️ Correctness fixes — weak-IV inference and diagnostics¶
sp.vifreturned values rounded to two decimals.VIFwas rounded to 2 and1/VIFto 4 — in the returned frame, not in a display. The conventional multicollinearity threshold is 10, so two decimals decide it in the fourth significant digit. Againstcar::vifthe rounding capped agreement at 2.9e-3; without it the factors match to 2.0e-16. Third instance of this defect class in this release, aftersp.sqregandsp.rdbwselect.- The grid-inversion confidence sets reported grid points, not
boundaries.
sp.anderson_rubin_ci,sp.conditional_lr_ciandsp.k_test_cireturned the extreme β on the grid that was still inside the acceptance region, which biases every interval inward by up to one grid step — 8.1e-3 and 4.9e-3 on a 400-point grid. The endpoints are now located by bisecting the acceptance boundary off-grid; the grid still decides emptiness, disconnection and unboundedness.
This one was visible from inside the package: sp.anderson_rubin_test
computes the same AR interval analytically and matched ivmodel to
1.2e-14 the whole time, so the two entry points disagreed about one
quantity by three orders of magnitude. A test now asserts they agree.
Added — weak-IV inference and diagnostics¶
- Five functions pinned against R in
tests/reference_parity/test_weakiv_meta_parity.py:anderson_rubin_test,anderson_rubin_ciandvifat machine level againstivmodel::AR.testandcar::vif;meta_analysisat 6.2e-16 againstmetafor::rmaacross all nine reported quantities;conditional_lr_cigraded T3 againstivmodel::CLR, with the test asserting the error shrinks withn_sim(3.8e-3 at 5,000, 1.7e-4 at 200,000) rather than pinning a tolerance to a simulated critical value.
⚠️ Correctness fixes — spatial¶
sp.lm_testsreversed the model-selection conclusion it exists to inform. Two independent errors in Anselin's LM battery, and in both the comment above the line stated the correct formula while the code did something else:Twas computed astr(WW) + Σ_ij w_ij (WW')_ijwhere the statistic needstr(W'W) + tr(WW). On a row-standardised rook lattice that is 28.22 against the correct 56.89, soLM_errcame out doubled.- the lag statistic's
Jwas built fromM(Wy)instead ofM(WXβ̂). SinceWy = WXβ̂ + We, that mixes the residual's own spatial structure into the denominator of the very statistic that is testing for it.
Measured against spdep::lm.RStests: LM_err 39.47 → 19.58,
LM_lag 20.15 → 23.90, Robust_LM_lag 1.17 → 4.36, and
Robust_LM_err 20.49 (p = 6e-6) → 0.0397 (p = 0.84). That last row
is the Anselin decision rule for choosing a spatial lag over a spatial
error specification; StatsPAI was reporting overwhelming evidence for
residual error dependence where there is none. Any specification
chosen with sp.lm_tests should be re-checked.
- sp.join_counts reported BW at twice the BB/WW convention. The
0.5 that halves the double sum was applied to BB and WW and omitted
for BW, so the identity BB + WW + BW = S0/2 failed — 70.75 against
50 on a 10×10 rook lattice. BB and WW were always correct.
- sp.getis_ord_local(star=False) standardised Gi with Gi's moments.
Gi excludes observation i from its own neighbourhood, so Ord and Getis
(1995) standardise it with the exclude-self mean and variance; the code
used the whole-sample moments for both branches. star=True (Gi) was
always exact. Relative error against spdep::localG: ~1.5.
- sp.moran_residuals tested the statistic against the wrong null.
The statistic itself was exact (5e-16 against spdep::lm.morantest),
but the p-value came from the null distribution of Moran's I for an
observed variable. OLS residuals are a projection of y, so their
null depends on the design matrix. Pass the new X= argument for
Cliff and Ord's regression-residual null; without it the old
(documented) fallback is kept.
Added — spatial¶
sp.gearyreports a closed-form variance, z-score and p-value. They wereNaNwheneverpermutations=0, althoughsp.moranreported its analytic null in the same situation. Bothspdep::geary.testconventions are available through the newassumption=argument ('randomisation', the default, and'normality') and match to 1.5e-14. ⚠️ The reportedz_scorealso changes sign to(E[C] − C)/sd, so a positive z now means positive spatial autocorrelation —spdep's convention and the onesp.moranalready used. Two-sided p-values are unaffected.- Thirteen spatial functions are now pinned against
spdep1.4.2 /spatialreg1.4.3 intests/reference_parity/test_spdep_parity.py:moran,moran_local,geary,getis_ord_g,getis_ord_local,join_counts,lm_tests,moran_residuals,slx,sac,impacts,knn_weightsanddistance_band.
⚠️ Correctness fixes — sp.etregress¶
method='mle', the default, was not a maximum likelihood fit. Themlebranch ofregression/selection.pywas a verbatim copy of thetwostepbranch, under a comment reading "Use two-step as approximation for MLE", while the docstring said "Equivalent to Stata'setregress y x, treat(D = z)" — which is the MLE. On a two-instrument replica the default returned a treatment effect of 1.978 where Stata returns 1.787, 10.7% away.sp.etregressnow maximises the endogenous-treatment likelihood; its log-likelihood, score and observed information reproduce Stata's standard errors to 1e-10 at Stata's own parameter vector. Any endogenous-treatment estimate taken from the default should be recomputed.method='twostep'standard errors ignored the estimated first stage. They were the naive OLS standard errors of the hazard-augmented regression — the source carried the comment "SE (simplified — should use bootstrap for correct SE)" — so they omitted both Heckman's correction and the hazard-induced heteroskedasticity. Measured against Stata: 11.2% too small, i.e. t-statistics 12.5% too large. The point estimates were already exact and are unchanged; the corrected covariance now matches Stata to 5e-9.robust=andcluster=were accepted and never used. Neither argument appeared anywhere in the function body, sosp.etregress(..., robust='robust')silently returned classical standard errors. Both now select the variance estimator, reproducing Stata'svce(robust)(sandwich with theN/(N-1)factor) andvce(cluster)(g/(g-1)).sp.etregressnow rejects a non-binary treatment and an unknownmethod=instead of proceeding.
⚠️ Correctness fixes — RD subpackage¶
sp.rdbwselect(fuzzy=...)parsed the argument and threw it away.rd/bandwidth.pyread the treatment column, dropped its missing rows — and then never passed it to the cascade, so the returned bandwidth was the sharp one while the docstring said it "accounts for first-stage variance in the Wald / IV estimator". On a two-sided-noncompliance replica of the Lee 2008 senate data that is a 9%–16% error inh. Anyone who took a bandwidth fromsp.rdbwselect(fuzzy=...)on a design with noncompliance on both sides should recompute it. Designs with one-sided noncompliance are unaffected:rdbwselectitself drops the first stage and returns the sharp bandwidth when a side has no variation in it (R'sperf_comp), which is reproduced.-
Fuzzy RD reported a bias-corrected estimate and robust SE that were not
rdrobust's.sp.rdrobust(fuzzy=...)took the sharp bias-corrected estimate and divided it by a separately bias-corrected first stage. That is a different estimator: R applies the bias-correction operator toYandTjointly and formstau_bc = tau_cl − s_Y′(bias_Y, bias_T)with the delta-method weightss_Y = [1/tau_T, −tau_Y/tau_T²], which also collapse the residual matrix before the sandwich, so the covariance between numerator and denominator is carried. Measured on the senate replica: robust estimate 0.96% off, robust SE 2.5% off, conventional SE 1.1% off. Fuzzy-RD robust confidence intervals should be recomputed. The whole fuzzy surface —p1–2, covariates,hc0–hc3,mserd,cerrd,msetwo, two kernels — now matchesrdrobust4.0.0 to 6.3e-13. -
sp.rdbwselectreturned bandwidths 2.8x–4.8x too narrow. The public selector ran a single-step rule of thumb of its own instead of the Calonico–Cattaneo–Titiunik three-stage cascade thatsp.rdrobustalready used:rd/bandwidth.pynever importedrd/_cct_bandwidth.py. That formula's exponent 1/5 equals CCT's 1/(2p+3) only atp == 1, it did not vary with the polynomial order, and it produced no separate bias bandwidthbat all. On the Lee 2008 senate replica it returnedh = 4.63whererdrobust::rdbwselectreturns17.75, while the docstring advertised Calonico, Cattaneo and Farrell (2020). A user who read a bandwidth fromsp.rdbwselectand passed it tosp.rdrobust(h=...)therefore got a materially different estimate fromsp.rdrobust's own default with nothing to signal the mismatch. Anyone who took a bandwidth fromsp.rdbwselectshould recompute it. Now pinned against R and Stata by Track A module88_rdbwselect. -
The four
combbandwidth selectors were never implemented.msecomb1,msecomb2,cercomb1andcercomb2fell through to the plainmserd/cerrdcascade inrd/_cct_bandwidth.py. The reference runs therd,twoandsumcascades to completion and combines the finishedhandbelement-wise per side —comb1 = min(rd, sum),comb2 = median(two, rd, sum).comb1masked the defect whereverrdis already the smaller of the pair;msecomb2came out 2.8e-4 low andcercomb25.5e-3 low on the Lee replica. This path is shared withsp.rdrobust(bwselect=...), so the defect reached an already-certified surface:sp.rdrobustresults computed with anycombselector should be recomputed. All ten selectors now agree withrdrobust::rdbwselectto 1.8e-12. -
Local-randomization RD ignored missing data and could report
p = 0.000from a statistic that never computed.sp.rdrandinf,sp.rdwinselect,sp.rdsensitivityandsp.rdrboundshad no missing-data handling, whilesp.rdrobustin the same subpackage has always dropped incomplete rows. On the Lee 2008 senate replica — 93 missing outcomes —sp.rdrandinfreturnedestimate = nan, and becauseabs(nan) >= abs(nan)is False for every draw, the permutation counter never incremented and the reported p-value was exactly 0.000, the most significant answer the test can give. All four now drop non-finite rows and warn with the count.sp.rdwinselect's window recommendation is affected even when no outcome is missing: it was counting rows with missing covariates that the reference excludes, putting every window's sample size aboverdlocrand's by up to 20% (right-side counts[14, 25, 36, 47, 57, 65]against R's[10, 21, 31, 40, 48, 54]on the rdlocrand senate fixture). All twelve counts now match R exactly, andtests/reference_parity/test_rdlocrand_parity.pyasserts them — the fixture carried them all along but the test checked only the window grid, which is fixed bywmin/wstepand so could not detect the difference. -
sp.rdmswas not computingrdmulti::rdms, and its docstring said it was. It fitted a two-dimensional local linear in(x1, x2)inside a Euclidean window whose width came from Silverman's kernel density rule of thumb — a category error rather than a tuning choice — with a homoskedastic variance and no bias correction. On a 6,000-row two-score design with known effects of 1.5 / 2.0 / 2.5 at three boundary points it used 13 / 8 / 27 observations whererdmulti::rdmsused 519 / 725 / 743, and returned 1.322 / 4.804 / 2.326 against the reference's 1.396 / 1.778 / 2.463 — the middle point 170% out, with a standard error of 4.21 against 0.13. Recompute anything that used this function. It now builds the score the reference builds — Euclidean distance to the boundary point signed by treatment status — and delegates tosp.rdrobust, inheriting the CCT cascade and robust bias correction. Pinned by Track A module89_rdms: 7.6e-12 against R, 3.3e-9 against Stata, with the six effective sample sizes exactly equal on all three sides. -
sp.rdrobustrounded its reported bandwidth to six decimals on the defaultbwselectpath, while thecctspelling of the same selector reported full precision — 0.342397 against 0.34239727634748. The estimate was always computed at full precision, so this was invisible from the result, butmodel_info["bandwidth_h"]is read back and re-fitted at byrd/diagnostics.py,rd/dashboard.pyandrd/rdrobust.pyitself. Same defect as thesp.rdbwselectrounding above, one layer down; the existing precision test only covered thecctspelling, which is why it survived. -
RD covariates collinear with the running variable are now refused instead of silently producing a meaningless estimate. A covariate that is a smooth function of the running variable is already absorbed by the polynomial terms the estimator fits, so the augmented design loses rank and the covariate adjustment is not identified. Both StatsPAI and
rdrobusttry a Cholesky factorisation and fall back to a pseudo-inverse when it fails — but NumPy's Cholesky succeeds on the singular matrix where R's refuses, so the fallback never fired here and the degenerate solve was returned as an ordinary answer, 3.8e-3 away fromrdrobust's. Carrying on was not even self-consistent: on one covariate scalingsp.rdrobustwent on to report a NaN standard error, and on another the solve raised a bareLinAlgError: Singular matrixnaming no covariate.sp.rdrobustandsp.rdbwselectnow raise aValueErrorthat names the offending covariate. R refuses the same design undercovs_drop=FALSE.
Changed¶
-
sp.wooldridge_didiscertifiedagain — on evidence this time. 1.26.0 withdrew the grade because the registry had credited the function with Track A module17_etwfeas an alias ofsp.etwfewhile nothing in the module ever called it. The withdrawal was right; its stated reason was not. It recorded the two as "different estimators (saturated cohort × post TWFE versus ETWFE)", but the design was not saturated at all — it was the defect fixed above. Module17_etwfenow callssp.wooldridge_diddirectly, so the grade rests on an artifact rather than on an assertion about an alias. The alias claim itself stays refuted and is still pinned bytests/reference_parity/test_track_a_alias_equivalence.py: the two functions report different documented aggregations (cohort-size-weightedATT(g)under a never-treated comparison group versus the treated-observation-weighted simple ATT). On17_etwfethesp.etwfedefault is 15.9% away from thesp.wooldridge_didheadline, andsp.etwfe(cgroup='nevertreated')— same comparison group, different weights — is still 10.5% away. -
Track A module
17_etwfenow pins eight statistics instead of one. The pooled ATT matched R and Stata to 1e-13 for three releases while every cohort ATT beneath it was wrong: a headline row cannot police the aggregation under it. The module addsatt_group_notyet_<g>,att_etwfe_neverandatt_group_never_<g>, three-way againstetwfe::emfx(type='group')andjwdid, estat group. All eight rows sit inside the module's existing registered budget (rel_est1e-6,rel_se1e-3) — worst observed is 2.9e-13 / 5.5e-6 against R and 1.7e-13 / 6.0e-4 against Stata. No tolerance was widened.
Added¶
sp.rdmsgainedtreat=(R'szvar), the treatment indicator. Treatment on a two-dimensional boundary is not implied by the coordinates, which is why the reference requires it; omitting it falls back tox1 >= cutoff1and warns, since that is an assumption about the design rather than a fact about the data.sp.rdmsalso now forwards**rdrobust_kwargs(p,bwselect,vce,cluster,covs).- Track A module
89_rdms:sp.rdmsagainstrdmulti::rdms(R) and therdmsado (Stata), both Cattaneo-group code.rdmultiis not on SSC (ssc describe rdmultireturnsr(601), tested rather than assumed), so the Stata sidenet installs it from the rdpackages GitHub mirror into a local gitignored ado path, leaving the user's PLUS directory alone. sp.rdsampsigained a data mode (data=,y=,x=,c=), matchingrdpower::rdsampsi(data = ...).sp.rdpowerhas had one since the CCT cascade work; its sibling did not, so R's reference call had no StatsPAI counterpart and therdsampsireference values already sitting intests/reference_parity/_fixtures/rdlocrand_R.jsoncould not be asserted against anything. All three effect sizes now reproduce R's required sample sizes exactly (integers, so no tolerance applies). Two details decide the answer and a tolerance band would have hidden both: the sample size is ceilinged inside the Newton–Raphson solve rather than at the end, and the sides are allocated bysqrt(variance), not by their observed counts — allocating by counts reproduces the total to ~1% while splitting the sides visibly wrong.- Track A module
88_rdbwselect:sp.rdbwselectagainstrdrobust::rdbwselect(R) and therdbwselectado (Stata), both maintained by the Cattaneo group, so neither side is a bridge. Sixty-eight bandwidths across all ten selectors, polynomial orders 1–3, three kernels, covariate adjustment, clustering and the RKD derivative: 1.8e-12 against R, 3.7e-9 against Stata. certwois pinned against R only. Statardbwselect10.0.0 exits with a conformability errorr(3200)on that selector, reproducibly and including onrdrobust_senate.dta, the dataset the package's own authors ship. StatsPAI reproduces R'scertwoto 1e-12; the.dofile asserts the failure still occurs so a fixed upstream release restores the cell rather than leaving a stale exclusion behind.
Fixed¶
-
A non-strict
xfailhid the ETWFE defect for fifteen minor versions.tests/reference_parity/test_did_variants_parity.py::test_wooldridge_did_matches_Rcarriedxfail(strict=False)from v1.11 recording "2.15 vs R etwfe's 2.75 — ~22% downward bias ... likely a cohort-weighting difference in the Mundlak transform, flagged for v1.11". The diagnosis was wrong (reweighting the reported cohort ATTs only reaches 2.21) andstrict=Falsemeant the assertion could neither fail nor announce a fix. Worse, the registry cited that file assp.wooldridge_did's known-truth evidence while it contained no passing known-truth assertion for the function. The xfail is now a strict test, joined bytest_wooldridge_did_recovers_known_cohort_atts, which checks the per-cohort ATTs against the fixture's owntaucolumn. -
sp.rdsensitivityno longer hangs. It built a matplotlib figure unconditionally and ended withplt.show(), which under an interactive backend blocks until a human closes the window — so the call never returned in a script, a test run, a CI job or an agent session. Plotting is now opt-in viaplot=False, the figure is attached toresult.attrs["figure"], andshow()is never called. Measured: over ten minutes before being killed, against 0.58s now. sp.rdbwselectno longer rounds its output to six decimals. A bandwidth is an input to the next estimator, and rounding it capped downstream agreement at ~1e-6 relative.sp.rdbwselectacceptsmsesumandcersum._VALID_METHODSlisted eight of the ten selectorsrdrobustoffers, omitting exactly the two sum-form cascades thatcomb1/comb2are built from.
[1.26.0] — 2026-09-10¶
⚠️ Evidence-grade corrections¶
No estimator's numerical output changed in this section. What changed is which functions the parity ledger vouches for — a claim about the numbers, not the numbers themselves. Users who cited a grade below should re-read it.
sp.wooldridge_didwas certified on a false alias and is nowvalidated. The registry credited it with Track A module17_etwfeas an alias ofsp.etwfe. Measured on that module's committed bytes with identical arguments,sp.wooldridge_didreturns ATT −0.0378480795 (SE 0.0058045845) whilesp.etwfereturns −0.0351082766 (SE 0.0069250918) under its default not-yet-treated control group and −0.0329765138 (SE 0.0077660899) undercgroup='nevertreated'— 6.1% and 12.1% apart, with no setting reconciling them. They are different estimators (saturated cohort×post TWFE versus ETWFE with a not-yet-treated control group). The alias and the bare "Track A parity seed" note it rested on were withdrawn; the function keeps the grade its own known-truth evidence supports. The refuting measurement is pinned intests/reference_parity/test_track_a_alias_equivalence.pyso the claim cannot silently return.- 85 functions were under-graded and are now
certified. The registry derived its tier by scanning the Track A README, which names one API per module row while a module typically exercises several — so functions with committed cross-language evidence (sar,sem,sar_gmmamong them) sat atapi_stable. Registry tiers are now derived from the committed_parity_index.json, which is built from the goldens themselves. - Dataset loaders, DGP helpers and introspection functions no longer read
as verified. A test scan credited every
sp.f(call site in a parity test, including the helper that builds the fixture and the accessors a test calls to check metadata.sp.dgp_didandsp.california_prop99were gradedvalidated;sp.bibtex— a citation resolver — was published indocs/parity.mdasexternal-replication, andsp.describe_functionasanalytical-only. Non-estimator symbols are now excluded by construction from both the registry tier and the index, so infrastructure carries zero parity rows. - Two certified rows are compared against a Python reference, not R or
Stata, and are now named rather than folded into the R/Stata claim:
sp.metalearner(againsteconml's S- and T-learners) andsp.dml_sensitivity(againstDoubleML's sensitivity analysis). Both follow CLAUDE.md §5.1 — compare against the implementation the method's own authors maintain — so the evidence stands; what was inaccurate was the blanket statement that every certified symbol links to an R or Stata module. The set is pinned by a test.
Added¶
sp.parity_summary()gainsby_evidence_kindanddenominators. Cross-language parity (compared against a named R/Stata implementation) and internal evidence (known-truth recovery, published-number replication) answer different questions and are now reported separately instead of being summed into one "verified" figure.denominatorssplits the registered surface into estimator callables, infrastructure and result classes, so the diluted all-registered fraction is no longer the only number on offer: coverage is 169/773 estimator callables (21.9%) cross-language, not 395/1182 (33.4%) of everything.statspai._parity_taxonomy— the single source of truth for the Track A alias table, the non-estimator exclusion set, and the one grade→tier mapping shared by the registry andscripts/build_parity_index.py. Every alias entry names the pytest that proves the equivalence on the module's committed bytes and records the deviation actually measured, per leg.tests/reference_parity/test_track_a_alias_equivalence.py— proves each registered alias against its canonical entry point on the Track A CSV bytes. Four aliases agree exactly;sp.hdfe_olsagrees withsp.fast.feolsto 3.6e-15 on coefficients and 1.9e-12 on the CR1 clustered standard error (within-cluster summation in the sandwich meat amplifies 1e-15 residual differences by about three orders — a documented floating-point mechanism, budgeted per leg rather than by widening the whole alias).
Changed¶
docs/parity.mdno longer prints a combined "verified (subtotal)" row. The page reported 399 verified out of 1,182, which a reader could take as "399 functions agree with Stata/R" when only 169 were compared against external software. The summary now separates the two evidence kinds and adds an honest-denominator table.- The registry↔index reconciliation test became bidirectional and lost its
divergence allowlist. It previously guarded only over-claiming, which let
the 85-function under-grading grow unnoticed; it now asserts zero
divergence in both directions for every stable symbol, with one
enumerated carve-out (
sp.did_multiplegt_dyn: experimental API, parity-backed numbers).
[1.25.1] — 2026-09-07¶
Fixed¶
sp.interflex_plot's docstring example referenced an undefineddf, so the docstring-example execution ratchet (scripts/check_example_execution.py --max-failures 0, the Parity-guards CI job) failed on the 1.24.1 and 1.25.0 tags. The example now builds its own 400-row frame; the ratchet reports 1,157 examples run, 0 failures.- The generated Track A tables
tests/r_parity/results/parity_table.texandparity_table_3way.texcaptioned themselves "1.20.0 source snapshot"; the caption now reads the release version frompyproject.toml.
Changed¶
-
sp.etwfe/sp.etwfe_emfxresults recordmodel_info['weights'],model_info['agg_weights']andmodel_info['cell_weight_column']('n_cell_obs'for unweighted fits, unchanged numerics); theetwfe_emfxweight_columnbookkeeping readsevent_w_treated_obsonly for weighted fits under the Stata rule. -
sp.did_multiplegt_dynmethod label and registry text no longer claim that the analytic influence-function variance is unimplemented or that switch-off events are ignored; the remaining MVP scope (nocontrols=, trends,normalized/continuousoptions, no heteroskedastic-weights variant, joint tests from the bootstrap only) is stated instead.stabilitystaysexperimental. -
sp.gardner_didin event-study mode now reports the overall ATT's SE assqrt(a' V a)on the full Stage-2 covariance (exposed asmodel_info['vcov']/['cell_labels']) instead of treating horizons as independent; the point estimate is the same weight-share average and equals the static ATT whenever every post horizon is requested.vce='none'now skips inference (NaNse,ci,pvalue) rather than computing the old SE silently; a single cluster raisesValueError(the two-stage cluster sum is identically zero there) instead of returning a floating-point-noise SE; and the static-mode bootstrap no longer emits a spurious "0/199 bootstrap replicates succeeded"RuntimeWarning(the per-coefficient array was never filled outside event-study mode and was overwritten anyway). -
Line-length hygiene only, no behaviour change: 35 over-long comment and plain-string lines under
src/statspaiwere wrapped (string literals split into adjacent literals, checked byte-identical) so the CI flake8 count ratchet (scripts/quality_gate.py flake8, baseline 1,000) passes again at 988.tests/r_parity/compare.pyandtests/perf/compare_perf.pywere wrapped the same way;tests/r_parity/TIER_A_FIXTURE_LOCK.jsonwas refreshed for the presentation-onlycompare.pychange andtests/test_parity_harness_contract.pystill passes.
[1.25.0] — 2026-09-06¶
Added¶
sp.audit(result)now returns anAuditReport— adictsubclass that renders as a readable reviewer checklist when printed, while remaining byte-identical as a payload (json.dumps,report["checks"], equality against a plain dict, and every MCP tool response are unchanged). New conveniences:.summary(),.to_frame(),.missing,.failed, and.checks_by_status.print(sp.audit(r))previously emitted one line of nested Python literals for an object whose whole purpose is to be read.sp.regtable(..., rules=)selects the horizontal-rule characters of the plain-text render:"auto"(default),"unicode", or"ascii".tests/r_parity/verify_reproduce_py.py, the StatsPAI-side counterpart ofverify_reproduce.py: it re-executes every Track A module into a staging directory and checks that the regenerated fixture CSV is byte-identical to the committed one and that the regenerated_py.jsonreproduces the committed golden to 1e-9. Both sides of every parity row are now re-derivable, not just the R side.
⚠️ Correctness — causal-forest aggregates on a non-binary treatment¶
Output-changing; see MIGRATION.md (1.25.0) for the re-run recipe. Binary-
treatment results are unchanged.
sp.causal_forest(..., discrete_treatment=False).ate()returned a nonsense doubly-robust estimate. The AIPW score divides bye(1-e)for a propensitye, so it is defined only for a binary treatment; with a continuous one the same nuisance slot holdsE[T|X]on the treatment's own scale, and the clip into[0.01, 0.99]turned it into a ~1,200x multiplier. On the Card returns-to-schooling design the reported ATE was −1266.6 against a mean CATE of 0.086, carryingp = 0.0000.average_treatment_effectnow detects a non-binary treatment, warns (AssumptionWarning), and returns the plug-in average of the fitted effects withmethod="plug_in"andplug_in_reason="non_binary_treatment";grfrefuses the aggregation outright for the same reason. Binary-treatment results are unchanged.ScalarEffectprintsdescriptive SEinstead ofSEwhen the attached inference is a plug-in aggregation rather than a doubly-robust score, so the weaker quantity is not read as the stronger one.
Fixed¶
print(sp.regtable(...))raisedUnicodeEncodeErroron a Windows console. The text renderer built its rules from U+2501/U+2500, which no 8-bit code page contains, so printing a table on a cp1252 console failed before the first row. The defaultrules="auto"keeps the box-drawing rules wherever stdout can encode them and falls back to=/-(and an ASCIIR2label) where it cannot; the fallback is length-preserving, so columns stay aligned. Output on a UTF-8 terminal is unchanged.- Four Track A modules could no longer regenerate their own fixtures.
sp.datasets.card_1995()andsp.datasets.california_prop99()had theirsimulated=default flipped fromTruetoFalse, but modules 01, 02, 08 (Card) and 12 (Prop 99) still called them bare — so re-running a module overwrote its frozen CSV with different data, silently leaving the R golden computed on bytes the Python side no longer produced. The four modules now passsimulated=Trueexplicitly. No committed number changes; the fixtures and goldens are the ones that were always there. - Both loaders' docstrings claimed
simulated : bool, default Truewhile their signatures saidFalse— the drift above is what that contradiction cost. - Track A module 11's Python SE row was frozen before the 1.22 change of
sp.match's default from the matched-pair SE to Abadie–Imbens, so the committed golden (436.49) no longer matched what the code returns (643.35). The row is refreshed and renamedse_pair_effect→se_abadie_imbens; theatt_psmparity row is unchanged. Modules 14, 53 and 55 had historical sandwich-SE goldens drifting at 1e-9 and are likewise refreshed to the current output.
[1.24.1] — 2026-09-06¶
Added¶
sp.modelsummarynow also accepts a single list (or tuple) of fitted models, matching Rmodelsummary's calling convention:sp.modelsummary([m1, m2])≡sp.modelsummary(m1, m2).- Original-data parity module
02_mpdta_originalgains a second row: the overall dynamic/event-study ATT (−0.07724 on both sides, anchored to the did vignette's printed −0.0772 withxformla = ~1). - Original-data parity module
03_basque_originalgains a Stata side (03_basque_original.do+ JSON): on the originalSynth::basquebytes, Statasynthreturns −0.894257 under the same ADH nested specification, within 4e-4 of both StatsPAI (−0.894589) and RSynth(−0.894427) — the 2.3e-2 R/Stata split exists only on the calibrated replica. - The B=1,000 Track-B materialization now also covers
sp.paneltwo-way FE,sp.sun_abrahamoverall ATT,sp.rdrobustsharp robust CI, andsp.sdidplacebo-SE rows (eleven materialized rows in total).
Fixed¶
- The
sp.datasets.mpdta()metadata advertised a "published simple ATT −0.0454 (R did::att_gt on original mpdta)" that nodidspecification reproduces and that could not be verified against the did vignette. The attribute and docstring now carry the value Rdidactually returns on the original bytes: −0.03995 (est_method='reg', never-treated, analytic SEs; seetests/orig_parity/02_mpdta_original). Estimator outputs are unaffected — this was dataset metadata only.
[1.24.0] — 2026-09-05¶
⚠️ Correctness — standard-error conventions pinned to the reference implementations (JSS parity closure)¶
Four Track A modules carried standard-error gaps that the tolerance audit graded "unexplained" or "budget bounds nothing". Each has been traced to a specific construction on the StatsPAI side, fixed, and pinned to both references at machine level where a like-for-like row exists. Point estimates are unchanged throughout; standard errors move as described.
-
sp.sun_abrahamdegrees of freedom counted cells that do not exist. The saturated cohort × relative-time design built one column for every pair on the full event window, including pairs no cohort ever occupies (nine of 21 columns onmpdtawere identically zero and survived only through the ridge safeguard). Those phantom columns enteredKin the cluster-robust small-sample factor, and the time fixed effects, which are not nested in the unit cluster, did not. Only observed cells are now parameters andKfollows thefixest/reghdfenested rule (12cells +5year effects =17onmpdta). Effect: event-study and aggregate SEs change by about 0.08% onmpdta(0.7%–2.2% on the weighted fixture).sp.sun_abrahamnow reproduces Stataeventstudyinteractto8e-12on every event time andfixest::sunabto8e-12on theagg = "att"ATT and at every single-cohort event time. Newshare_variance=keyword:True(default) carries the Sun–Abraham (2021, Prop. 3) cohort-share term aseventstudyinteractdoes;Falsereproducesfixest::sunab, which treats the shares as fixed, to1e-9weighted and unweighted. The former "0.7–2.2% aggregation-variance open item" is closed: the only remaining difference between the two references is that documented, positive semi-definite term. -
sp.melogit/sp.meglmfixed-effect standard errors omitted the variance-component uncertainty. The covariance was the conditional (Schur-complement) information with the random-effect variance held at its estimate, which understated fixed-effect SEs by up to 1.9% on the parity fixtures. It is now the fixed-effect block of the inverse observed-information Hessian of the marginal (Laplace / AGHQ) log-likelihood over all parameters, the construction behind Statamelogit's defaultvce(oim). AGHQ fixed-effect SEs matchlme4and Stata to5e-7/4e-6(module 27); Laplace matches Stata to4e-6(module 26). The conditional matrix is kept asresult._cov_fixed_conditional; if the numerical Hessian is not positive definite the estimator falls back to it and warns. -
sp.ppmlhdferobust standard errors matched neitherppmlhdfenorfixest::fepois. The sandwich was scaled by(N-1)/(N-k)withkthe slopes only. Newssc=keyword:"stata"(default) appliesN/(N-1), the Stataglm/ppmlhdfe vce(robust)convention, and matchesppmlhdfeto1e-11;"fixest"appliesN/(N-K)withKthe slopes plus absorbed fixed-effect levels (minus one per additional dimension) and matchesfixest::fepoisto1e-8;"none"applies no factor.robust="robust"now means the sandwich with thesscfactor (it was HC0); userobust="hc0"for the bare sandwich. Effect on the gravity fixture: SEs move by 0.1%–0.2%. -
sp.xtfrontier(model="ti")Stata reference was a different model. Not a code change, but a parity-ledger correction: Stata'sxtfrontier, tiestimates the Battese–Coelli (1988) truncated-normal model with a freemu, whereas StatsPAI and Rfrontier::sfafit the Pitt–Lee half-normal model. The Stata module now imposesconstraint [mu]_cons = 0; the intercept andsigma_urows that were previously excused as "Stata-scale diagnostics" (1.7% and 29% off) now agree to1e-6, and Stata's analytic-Hessian standard errors match the StatsPAI numerical observed-information SEs to3e-6on every row, which pins the StatsPAI Hessian as exact;frontier's covariance is the loose side (0.1%–1.8%).
Added¶
-
sp.fect— counterfactual estimators for TSCS data (Liu, Wang and Xu 2024). A native port of the estimation core of the R packagefect: the untreated potential outcome of every treated unit-period is imputed from a model fitted on the untreated cells only — two-way fixed effects (method="fe"), interactive fixed effects withrfactors ("ife"), or nuclear-norm matrix completion ("mc") — and the ATT is the mean ofY - Y(0)over treated cells, with the by-relative-period path in fect's coding (fect_time: 0 = last untreated period, 1 = first treated period;relative_time = fect_time - 1in the StatsPAI convention). Handles staggered adoption, multiple treated units, unbalanced panels and treatment reversals; optional unit bootstrap or jackknife standard errors. The port follows fect's EM map step for step (fixest two-way initial fit, E-step fill, two-way demeaning,panel_factorSVD with thesqrt(T)/sqrt(N)normalisation or the soft-threshold onE/(T*N), relative convergence on the fitted surface and on the interactive component), so Track A module 86 pins all three outcome models on one staggered two-factor panel againstfect::fectat1e-10(same iteration count on both sides) and against the authors' Stata portfect_stata(installed from GitHub into a local ado path;ssc describe fectreturns r(601)) at1e-9for fe/mc and1.5e-7for ife, where the Stata port's own EM stopping rule sets the floor.sp.fect(method="fe")equalssp.did_imputationon a staggered panel without reversals (pinned by test). -
sp.interflex— multiplicative interaction models with diagnostics (Hainmueller, Mummolo and Xu 2019). Conditional marginal effects of a binary or continuous treatment across a moderator by the linear, binning (quantile or explicit cutoffs, bin-median centring) and kernel (local linear, Gaussian, density-adaptive bandwidth) estimators, with HC1 delta-method standard errors (vce="robust", or"homoscedastic";vce="bootstrap"for the kernel estimator), the average treatment / marginal effect, the L-kurtosis of the moderator and the Wald / LR tests of the linear-interaction restriction, plussp.interflex_plot. The port follows the R package's conventions to the bit — including R'sstats::density()linear-binning + FFT grid (old.coords = FALSE) for the adaptive bandwidth — so Track A module 87 pins all 20 rows against Rinterflexat6e-14; the SSC Statainterflexcommand matches the linear and binning rows at1e-14and its fixed Gaussian kernel is reproduced byadaptive=False. Referencehainmueller2019muchverified via Crossref and Cambridge Core. -
sp.panel_view— panelView-style panel display and summary (Mou, Liu and Xu 2023). Treatment-status tiles (type="treat", throughsp.treatment_rollout_plot) or outcome trajectories by treatment status with the never-treated mean (type="outcome"), returning a summary of the facts that decide between a two-way FE, a staggered DiD and a counterfactual design: adoption periods,staggered,has_reversals,n_never_treated,n_missing_cells. Referencemou2023panel(JSS 107(7)) verified via Crossref and the JSS article page.
Changed¶
- Track A parity rows are now like-for-like per reference. Modules 05
(Sun–Abraham), 33 (VAR), 37 and 47 (PPML-HDFE) emit one row per
documented convention (
att_rel_<e>_fixedshare,eq_*__Tk,beta_*__fixestK) and each reference side emits only the rows in its own convention, so every compared standard error is a machine-level row and the convention difference is demonstrated by two StatsPAI rows rather than absorbed into a tolerance. Registeredrel_sebudgets tightened accordingly: 050.25 → 3e-2, 265e-2 → 1e-2, 275e-2 → 2e-5, 331e-3 → 1e-6, 371e-2 → 2e-6, 475e-2 → 1e-6; 29 re-registered at5e-2with its mechanism (was1e-3and bounded no gated row). - Every Stata-side standard-error gap now has a reconstruction.
csdid'sestat groupGAverage SE (0.27% fromdid::aggteand StatsPAI onmpdta) is the fixed-cohort-share aggregation of the joint cell influence functions and is rebuilt from StatsPAI's own influence functions to 1e-14;ddml's PLIV SE isivreg'sN/(N-K)factor;lpdid's horizon SEs carryreghdfe's K. Two new contract tests gate the registeredrel_sebudget on every R-joined SE row (all 85 modules pass) and require any Stata SE row over budget to be registered incompare.py::STATA_SE_GAP_NOTESwith its mechanism. sp.event_studyfollows thefixest/reghdfenested degrees-of-freedom rule. The cluster-robust small-sample factor now counts the absorbed time effects (not nested in the unit cluster), so module 85's eight event-time SEs are three-way machine level (9e-14R,2e-15Stata) with default settings on every side; the R script no longer needsssc(fixef.K = "none"). SEs move by 0.28% on that fixture.
First full CI matrix run after v1.23.0 (13 legs: Linux / macOS / Windows × Python 3.9–3.13) came back 12 red. Triaging it turned up three defects that were real, not runner noise: two estimators whose answer depended on which BLAS the platform shipped, and a byte-pinned data fixture that Windows' checkout silently rewrote. Details below.
⚠️ Correctness¶
-
sp.iv.anderson_rubin_ci/clr_cicomputed confidence sets from a singular instrument Gram matrix. Every weak-IV routine iniv/weak_iv_ci.pyforms(Z'Z)⁻¹. With collinear instruments that matrix is singular, and the outcome depended on the BLAS build: some raised a bareLinAlgError: Singular matrixfrom inside NumPy, others returned an inverse and the routine reported a confidence set built on it. The instrument matrix is now rank-checked in_prep, so all entry points raiseIdentificationFailurenaming the redundant instruments. -
sp.gesoriented its first edge on floating-point noise. BIC cannot distinguishi → jfromj → ifor an isolated pair — both encode the same Markov equivalence class, so the scores are equal in exact arithmetic. The greedy search compared raw gains with>, so last-bit LAPACK rounding decided the orientation, and that choice cascades: take the reverse edge on a colliderX → Z ← Yand conditioning onZmakes the spuriousX — Yexplaining-away edge score better, so the search converges on a complete undirected graph instead of the v-structure. Linux and macOS recovered the collider; Windows returned{X—Y, X—Z, Y—Z}. Candidates must now beat the incumbent by more than a relative tolerance, which hands the tie-break to the deterministic scan order. Verified to recover the correct CPDAG on the collider and chain DGPs across seeds 0–3.
Fixed¶
-
Windows: the
mpdtareference fixture was rewritten at checkout. Fivetests/reference_parity/modules pin the SHA-256 oftests/orig_parity/data/02_mpdta_original.csvso the R/Stata golden numbers can never be silently re-locked against different data. The GitHub Windows runner checks out withcore.autocrlf=true, which rewrote LF to CRLF and changed the digest — 63 tests errored with "mpdta fixture changed", a checkout artefact reported as a provenance breach. A new.gitattributesmarks the data fixtures-textso their bytes are identical on every platform. It also freezes the NIST StRD certified datasets, which ship from NIST with CRLF and must not be normalised either. -
Windows: gate scripts crashed printing their own verdict. Eight scripts in
scripts/emit status glyphs (✓,⚠,≥, CJK) that cp1252 cannot encode, so on a redirected Windows stream they died withUnicodeEncodeError— a gate exiting non-zero for its output encoding is indistinguishable from a gate that found a real problem. They now route throughscripts/_stdio.force_utf8_stdio(). Complements the v1.23.0 fix that covered the shipped package (StatsPAIError.__str__and friends); this covers the tooling. -
Windows: the
Rscripttest stub was never used.tests/'s HonestDiD backend tests install a#!/bin/shstub namedRscriptonPATH, whichshutil.whichcannot see on Windows (it only considers PATHEXT suffixes). The runner resolved the real preinstalledRscriptinstead and the tests failed with "R package 'HonestDiD' is not installed". The fixture now emits a.baton Windows, so the same subprocess path is exercised on every OS rather than skipped. -
pip install statspai[fixest]was unresolvable on Python 3.9.pyfixest>=0.25requires Python ≥ 3.10 and the pin carried no environment marker. It is nowpyfixest>=0.25.0; python_version >= '3.10', so the extra resolves to nothing on 3.9 instead of failing to install. -
A
.[dev]-only checkout could not run the test suite green. 29 tests failed withImportError: pyfixest is required…/ the rdrobust equivalent rather than skipping, so anyone following the documentedpip install -e ".[dev]" && pytest— a reviewer, say — saw a red suite caused entirely by optional extras. Those tests now skip when the extra is absent. The golden-master gate reports such cases as unavailable and still checks every other case, and refuses to re-pin from an incomplete environment so the missing pins cannot be silently dropped. -
sp.callaway_santanna: a non-converged propensity logit degraded silently on statsmodels >= 0.15. With a separated or collinear covariate thex=propensity fit cannot settle. Through statsmodels 0.14 the Newton step raisedLinAlgErrorout of the singular Hessian, which_estimate_pscorecaught, announced, and replaced with the unconditional propensity. statsmodels 0.15 walks that same step instead and returns diverging coefficients (converged=Falseafter 500 iterations), so theexceptbranch never fired — and the fit already passeswarn_convergence=False, suppressing statsmodels' own notice. The result was a p(X) pinned at the clipping bounds by coefficients that never settled, feeding the IPW/DR weights with nothing said to the caller. The fit's convergence flag is now checked directly and aConvergenceWarningnames the unreliable weights. The fitted p(X) is still what the estimate uses: substituting the constant here would move the point estimate of every non-converged fit, and a diverged logit is not evidence that the unconditional propensity is the better answer. No numerical change on any converged fit.
Known issue (not fixed here)¶
sp.did_2x2does not refuse a rank-deficient design, andsp.did(method='twfe')can hand it one. When the treatment column passed astreatis already thegroup × postinteraction — which is howsp.dgp_didand most event-study panels encode treatment — theDandD×Tdesign columns are identical. The estimator invertsXw'Xwand falls back tonp.linalg.pinvonLinAlgError, so the reported ATT is the minimum-norm solution, which splits the identified coefficient evenly across the two duplicate columns: roughly half the true effect (0.48 / 0.34 / 0.50 against a true 1.0 on threedgp_didseeds), with no warning, and a different number again on a BLAS that does not detect the singularity. A fix — refuse the design indid_2x2, and collapse a time-varyingtreatto the ever-treated group indicator insp.did— restores 0.96 / 1.02 / 0.94 on those seeds, but it also fires on thesp.paper/sp.recommendpaths, so landing it needs an audit of every internaldid_2x2caller and moves headline DiD numbers. Held for a deliberate decision rather than shipped half-done.
Changed¶
- Bibliography: one master, derived per-paper subsets, shipped in the wheel.
The root
paper.bib(now 689 entries) is the single verified source of truth; per-paper bibliographies are extracted from it by the newtools/bib_subset.py(extract/check/packaged) instead of being hand-curated. The JSS manuscript'sjss-bib.bibis now such a derived file: its 16 references that were absent from the master were re-verified (Crossref, doi.org, jstatsoft.org, the arXiv API, proceedings.neurips.cc) and promoted, and five keys that duplicated existing master entries were re-keyed in the manuscript (athey2019grf→athey2019generalized,liu2024fect→liu2024practical,bach2024doublemlr→bach2024doubleml,kaul2015synthetic→kaul2022standard,lundberg2020closing→lundberg2024gap). The archival bib for the non-compiled long-form sections is derived the same way (--minus jss-bib.bib). New gates: pre-commitbib-packaged-sync, pre-pushbib-subset-jss/bib-subset-jss-archival, citation-audit Gate 1b. - Master bibliography corrections surfaced by the derivation (refs verified via Crossref, doi.org, OpenAlex, Semantic Scholar, the arXiv API, OpenLibrary and the NBER catalogue):
card1995usingnow records the work every consumer of the key means (the 1995 University of Toronto Press chapter thatsp.datasets.card_1995,sp.replicate("card_1995")and the JSS manuscript cite as "Card (1995)"). It previously held the 1993 NBER working paper, which is dropped as a separate record (one entry per work). ⚠️ The archived JOSSpaper.mdcites this key; the published JOSS PDF shows the NBER record, so a regeneratedpaper.mdwould now render the chapter instead.calonico2025rdhtenow records the rdhte software paper (arXiv:2507.01128) that the JSS manuscript cites; the CRAN package record it used to hold moved tocalonico2025rdhtepackage, and the methods paper thesp.rdhtedocstring describes (arXiv:2503.13696) is the newcalonico2025treatment, which the docstring and the inlineCausalResult._CITATIONS["rdhte"]text now cite verbatim.hartford2017deep(Deep IV, ICML 2017) no longer attaches the arXiv preprint's DOI (10.48550/arXiv.1612.09596) to the PMLR proceedings record, the mismatch the JOSS editors flagged for this style of entry. PMLR issues no DOI, so the entry now carries the proceedings URL,editor/series/volume/publisher, and the arXiveprint, following thebrown2020languageconvention; the inlineCausalResult._CITATIONS["deepiv"]copies incore/results.pyanddeepiv/deep_iv.pymatch the master field for field (refs verified via the PMLR abstract page and DBLP).- Volume / issue / page fields backfilled on 26 journal entries where the
previously hand-curated JSS bibliography and the Crossref record agree
(e.g.
abadie2003economic,angrist1991does,goodmanbacon2021difference,imbens2008regression);kitagawa1955componentspages from the Taylor & Francis record;roodman2019fastjournal name normalised to "The Stata Journal". - Master bibliography is now BibTeX-safe end to end. Deriving the JSS
bibliography exposed two latent defects in
paper.bib: (1) 56 entries carried raw non-ASCII characters (Ørregaard,Dubé,’,–,R²), which BibTeX's name abbreviation splits mid-byte — the compiled JSS reference list literally read "Nielsen M�" — so every entry now uses LaTeX escapes ({\O}rregaard,{\'e},--,$R^2$); (2) 56 verification blurbs lived innote, which.bststyles print, so the JSS reference list would have carried "Verified 2026-08-24 via Crossref…" text and underscores that broke the build. Provenance now lives inannote(ignored by every bibliography style);noteis reserved for reader-facing text such as "arXiv preprint, first posted 2025-06-21". -
sp.bibtex()now works on a pip install. The wheel ships a byte-identical copy of the master atstatspai/paper.bib(package data +MANIFEST.in), and both runtime readers (sp.bibtex/ the MCPbibtextool, andsp.recommend_benchmark's §10 cross-check) resolve it throughstatspai._bibpath.master_bib_path(). Previously the wheel carried no bibliography, so installed users silently received an empty index. ⚠️ Behaviour change: the resolver no longer falls back to./paper.bibin the current working directory, and raisesFileNotFoundErrorinstead of returning an empty index when no bibliography can be found. -
The JOSS paper is published — Wang & Rozelle (2026), Journal of Open Source Software 11(125), 10604, https://doi.org/10.21105/joss.10604 (review: openjournals/joss-reviews#10604).
sp.citation()now returns the article as the preferred citation; the versioned software entry moved tosp.citation(which="software"), andwhich="both"returns the pair.CITATION.cffgained the matchingpreferred-citationblock,.zenodo.jsonlinks future deposits to the article (isDescribedBy), and the README / docs citation blocks and JOSS badge point at the DOI.paper.md/paper.bibare now the archived published text and are not edited further. -
CI installs
.[dev,fixest]on every matrix leg rather than.[dev], so the HDFE surface is exercised cross-platform instead of skipped. The 3.9 leg resolves the extra to nothing (see above) and skips those tests.
Testing¶
tests/tier_eg/test_did_invariance.pyandtest_did_robustness.pypassed the already-interactedtreatcolumn tosp.did_2x2, which wants the treatment GROUP indicator and forms the interaction itself. That made theDandD×Tdesign columns identical, so every invariance in both modules was pinned on a rank-deficient pseudo-inverse artefact: 1.34 against a true ATT of 2.0. They now pass thegroupindicator the API documents, the estimate becomes 2.076, and covariate-reorder invariance holds exactly. The estimator-side guard for this class of design is not in this release — see the note below.test_callaway_santanna_warns_when_pscore_logit_degradesprovoked the propensity failure with a perfectly separated covariate and asserted the fallback warning — so it was pinned on statsmodels raising, not on StatsPAI warning. The pandas-3 canary lane, which resolves the newest dependencies, went red on it the moment statsmodels 0.15 stopped raising (1 failed of 16,653). It now injects the failure viamonkeypatchlike every other test in that module, and a second test keeps the separated design but accepts either loud path, so whichever way a future statsmodels degrades, the "propensity-score logit" complaint has to appear.test_ar_ci_with_exog_and_multi_instrumentsseeded its second instrument with the same seed as the fixture, replaying the fixture's first draw.z2came out as exactly1.6 * z, so the "multi-instrument" test never had two instruments. Reseeded.
[1.23.0] — 2026-08-24¶
Aligns the DiD family with Baker, Callaway, Cunningham, Goodman-Bacon & Sant'Anna (2026), Journal of Economic Literature 64(2), 498–557, doi:10.1257/jel.20251650 — the JEL practitioner's guide. Refs verified via Crossref and the published PDF.
⚠️ Correctness¶
sp.did_imputation's analytic standard errors were built from an approximation and were materially too small. Every quantity this estimator reports is linear in the outcome, so the weightsvwithtau = v'yare computable; StatsPAI instead approximated the fixed-effect projection with balanced-panel unit/time shares, and centred treated residuals on the global horizon mean rather than thev²-weighted mean within each (cohort, relative-time) block. Both reference implementations — Statadid_imputationand Rdidimputation::se_inner— use the exact form and agree with each other to ~3e-9.
The cost: the headline ATT standard error was 36% too small on the
module-84 fixture and 18% too small on mpdta; event-study horizon
standard errors were 4.9–13% off with non-uniform sign. The direction
on the headline is the dangerous one. hetby and project standard
errors used the same approximation and are also fixed.
did/_bjs_variance.py now computes the exact variance. All of it
reproduces Stata to ~5e-8. A cluster-scaling study (400-800 reps per
cell) shows the fix is complete rather than merely better: mean SE over
empirical SD sits at 1 at every size (0.94-1.01), and coverage reaches
0.950 at 480 clusters. The residual shortfall at 60 clusters
(0.938) is a small-cluster effect, not a missing term — the standard
error is noisy there (dispersion 9.2% against 3.2% at 480), which
fattens the studentised distribution. A t(G-1) critical value barely
helps (0.938 to 0.941), because the problem is the variability of the
standard error rather than the degrees of freedom; vce='bootstrap'
is the remedy, and se_method='auto' selects it below 30 clusters.
The approximation this replaced delivered roughly 0.87.
The runtime warning that called this path "anti-conservative (~0.87
coverage)" has been removed, because it described the approximation
rather than the estimator. Re-run any sp.did_imputation inference.
See MIGRATION.md.
How it hid: module 16 emitted StatsPAI's SE as se_cluster_if while R
emitted se_didimputation and Stata se_stata_did_imputation, so the
row never joined and the 18% gap sat in the archive uncompared by
construction, documented as "SE rows are side-specific". All three now
emit se_att and the comparison is live.
sp.did_imputationpre-treatment event-study coefficients were thefect/did2sin-sample residual averages, not the BJS ones the docstring advertised — and they are attenuated toward zero. The lags were and remain imputation residuals; the leads were means ofY − Ŷ(0)at pre-treatment relative times. Those are in-sample prediction errors, because the pre-treatment outcomes of eventually-treated units are part of the training data. Li & Strezhnev (2025), restated in Roth (2026, appendix A), show that in a non-staggered design this equals the symmetric benchmark multiplied byN0/N, the untreated share of units. We reproduce that identity to 1e-10. The direction is the dangerous one: with 90% of units treated the reported leads are one tenth of the truth.
Correction to an earlier wording of this entry, which said that made a violated assumption "look satisfied" and that the joint pre-trend test inherited the attenuation. Measured against the exact variance (see the entry above), it does not: the standard error attenuates by the same N0/N factor, so every t-statistic and the joint test are unchanged — verified at 50% and 90% treated, where the coefficients shrink 2x and 10x and the t-statistics do not move. What the attenuation damages is magnitude-based reasoning: the plotted path, and Rambachan-Roth sensitivity, which asks how large a violation would overturn a result. Against the BJS convention the difference is a different shape rather than a rescaling, and there the tests do move — on the module-84 fixture the rel-3 lead runs from t = +5.9 to t = -0.27.
The new
pretrend_method= selects the convention and defaults to 'bjs',
which reproduces Stata did_imputation, pretrends(k) coefficients and
standard errors to 1e-12 / 1e-13 relative on two designs. Post-treatment
coefficients and the overall ATT are unchanged under every option.
Re-read any pre-trend evidence taken from sp.did_imputation's event
study; see MIGRATION.md.
sp.gardner_did uses the same in-sample construction and is not
affected: did2s is the reference it documents, and its coefficients
reproduce R did2s 1.2.1 to 3.5e-14 at all 26 horizons of the same
fixture. A convention is a defect only when it is not the one the
function claims.
-
sp.did(..., weights=)silently returned the unweighted estimand on every staggered path. The argument was validated, its column added to the required-column list — and then never forwarded to the Callaway–Sant'Anna, Sun–Abraham, BJS, or SDID branches. No warning fired. This is not a precision bug: ω enters the definition of the target parameter (Baker et al. §3.1). The unweighted ATT averages over treated units; the ω-weighted ATT averages over the population those units represent. They can differ in sign — in the paper's own Medicaid data, +0.1 versus −2.6 deaths per 100,000. Anyone who passedweights=to a staggered estimator received an answer to a different question than the one they asked.sp.callaway_santannanow implements ω end-to-end (point estimate, propensity/outcome nuisance models, influence functions, cohort shares, all foursp.aggteaggregations); methods that do not implement ω now raise instead of ignoring it. Re-run any staggered analysis that passedweights=. -
Callaway–Sant'Anna
estimator="dr"standard errors omitted both nuisance estimation effects. DR is Neyman-orthogonal in each nuisance separately, so the terms are second-order and the cost was far smaller than in theipwcase — but second-order is not zero: the gap toDRDID::drdid_panelreached 0.9% on the reference grid. The three remaining pieces (inf.treat.2,inf.cont.2,inf.cont.3, read from DRDID 1.2.3 source) are now propagated.drstandard errors change whenever covariates are supplied; without covariates the correction is identically zero (the two outcome-regression terms cancel and the score term vanishes at a constant propensity), so no covariate-free result moves. After the fix all three covariate strategies agree with Rdid2.3.0 to ≤4.6e−11 across a 72-cell grid. -
Callaway–Sant'Anna
estimator="ipw"standard errors were up to 89% too large. The ATT(g,t) influence function centred both arms on the ATT —(w1 − w0)ΔY − ATT·w1— whereDRDIDcentres each arm on its own Hájek mean,w1(ΔY − η₁) − w0(ΔY − η₀). Both weight vectors are normalised to mean one, so the two agree in expectation and point estimates were never affected; the variances differ.ipwadditionally omitted the propensity-score estimation effect, which IPW requires because it is not Neyman-orthogonal in the score. Againstdid::att_gt(est_method="ipw")the SE was 9–11% too large unweighted and up to 89% too large weighted, always upward — conservative, but wrong. After the fix, agreement is 5e−11. Thedrpath moves by <1%;estimator="reg"is unaffected (it already carried the full delta-method term). Re-run every z-statistic, p-value, and confidence interval produced withestimator="ipw". Oneipwpoint estimate moves by a single ULP onmpdta(…374 → …373) because the corrected form reassociates an algebraically identical expression; that is rounding, not a change of estimand. -
sp.drdid'smethod=argument did nothing on repeated cross sections, and its standard error was a bootstrap. On the pooled path (noid=),method='imp'andmethod='trad'returned the same number — they agreed to 3e-13, i.e. floating-point reassociation. Whatever the caller asked for, they gotDRDID::drdid_rc1, reported under an "Improved"/"Traditional" label chosen by an argument that had no effect. The standard error came from resampling rather than from the Sant'Anna–Zhao influence function, so it was random,n_boot-dependent and 1.5–5.5% off the reference. Both are fixed:method=now selectsdrdid_imp_rc/drdid_rcexactly as RDRDID::drdid(estMethod=)does, and every path reports the analytic influence-function SE. Re-run anysp.drdidcall made withoutid=. The point estimate moves whenevermethod='imp'was used (or defaulted to); the SE moves in every case.
The same path also had a silent-degradation branch: when a
treatment × period cell was too small to fit the outcome regression it
quietly dropped the covariates and returned an unadjusted 2×2 DID under
the doubly-robust label. It now raises DataInsufficient.
-
Callaway–Sant'Anna
estimator='ipw_abadie'standard errors were ~34% too large. The influence function omitted the propensity-score estimation effect. IPW is not Neyman-orthogonal in the score, so this is not a second-order omission — the siblingestimator='ipw'(Hájek-normalised) already carried the term; the Abadie (2005) variant did not. Now matchesDRDID::ipw_did_panelto 3e-13. Point estimates were never affected. Re-run every z-statistic, p-value and confidence interval produced withestimator='ipw_abadie'. -
Callaway–Sant'Anna clustered multiplier-bootstrap standard errors were wrong whenever cluster sizes were unequal.
callaway_santanna(clustervars=..., bstrap=True)collapsed the influence functions to cluster means overn_clusters(se = bSigma / sqrt(n_clusters)). That is exactly what CRANdid2.3.0 does, so it was a faithful port — but it is only the cluster-robust variance when every cluster has the same size. A cluster enters with weight1/|c|, so small clusters dominate: on a county panel clustered by state with sizes 1–150, the SEs came out ~5x too large; on a milder 3–317 spread, 1.5–11x. Upstreamdid(GitHub master, post-2.3.0) switched to cluster sums withse = bSigma·sqrt(n_clusters)/nfor this reason, andcsdidtracks the corrected form. StatsPAI now does too, matchingcsdidto within Monte-Carlo error. Equal-sized clusters are unaffected — the two formulas coincide there — as is the unclustered bootstrap, which was always correct. Re-run any clusteredbstrap=Trueinference on unbalanced clusters; note this now differs from CRANdid2.3.0 output, deliberately. -
anticipation > 0moved the pre-treatment placebos underbase_period="varying". StatsPAI shifted the base period back byanticipationfor every cell. Rdidshifts it only once a cell is post-treatment (compute.att_gt.R, thepretblock); a pre-treatment placebo keeps the period immediately before it, so its value does not move withanticipationat all. StatsPAI's version changed every pre-treatment ATT(g,t) and dropped the earliest ones from the grid entirely. Post-treatment ATT(g,t), and therefore every aggregation, were never affected — this changes pre-trend tests and event-study leads. Cohorts with no period satisfyingt + anticipation < gare now dropped with a warning instead of silently. The period grid is also compared with strict inequalities rather thant − 1arithmetic, so irregularly spaced periods (1990, 1995, 2000, …) resolve to the neighbouring observed period instead of dropping the cell. -
sp.didsilently swallowed unknown keyword arguments.**kwargsis only consumed by thesdidandbjsbranches; everywhere else it was dropped.sp.did(..., post="post")— an argument that appears in StatsPAI's own older tutorials and, as this change discovered, in seven of its own test modules — was accepted and did nothing. Unknown keywords now raise with a hint naming the common stale spellings. -
sp.iv(absorb=..., cluster=...)crashed whenever the clustering variable was also an absorbed fixed effect — that is, on the single most common panel specification there is (absorb="county",cluster="county"). The working frame selected the shared column twice, so the cluster series came back as a two-column DataFrame and pandas raisedValueError: If using all scalar values, you must pass an index. Fixed; the case is now covered by Stata parity tests. -
Absorbed IV standard errors charged degrees of freedom for fixed effects nested inside the clustering dimension. A fixed effect nested within a cluster is annihilated by the cluster sums and costs no residual DOF —
reghdfe'sdofadjustments(clusters)andfixest'sfixef.K="nested"both drop it, andivreg2reports the charge ase(sdofminus). StatsPAI charged it anyway, and separately failed to charge the constant the absorbed block spans. Net effect on the canonical unit-FE / cluster-by-unit panel: standard errors inflated by roughlysqrt((N-k)/(N-k-G_unit))— about 5% at 120 clusters, always in the conservative direction, but not the numberivreghdfeprints.sp.iv(absorb=..., cluster=...)now matchesivreghdfeto machine precision and reportsmodel_info["fe_dof_charged"]andmodel_info["fe_nested_in_cluster"]. Re-run absorbed IV specifications whose standard errors you quoted. -
Cluster-robust GMM used a heteroskedasticity-only meat. With
method="gmm", cluster=..., the optimal weight matrix clustered but the reported sandwich variance did not:Omegawas rebuilt assum_i w_i w_i' e_i^2, ignoring within-cluster correlation entirely. Standard errors were therefore too small whenever moments correlate within cluster — the situation clustering exists to handle. The meat is now the cluster-sum form, and the standard finite-sample factor is applied (previously the GMM path applied none at all). -
Over-identification tests ignored the variance assumption. 2SLS reported the Sargan statistic no matter what: under
robust=orcluster=that is a test whose null distribution assumes away the very correlation being corrected for. Followingivreg2, StatsPAI now reports Sargan under i.i.d. errors and Hansen's J as soon as the vcov is robust or clustered (multiway included), matchingivreghdfeexactly. -
Kleibergen–Paap rk statistics were not robust to the fitted vcov, and the rk LM was off by a factor of
n. Three separate defects: (i)sp.ivalways requestedcov_type="robust", so a cluster-robust fit was reported alongside a heteroskedasticity-only instrument-strength test — biased toward looking strong; (ii) the small-sample factorsivreg2applies were missing, and absorbed FE degrees of freedom were not charged; (iii)rk_lmscaled the whitened reduced form bysqrt(n)instead ofn, inflating the underidentification statistic by a factor ofn(millions, whereivreg2prints tens). All three are fixed:KP rk LMandKP rk Wald Fnow reproduceranktest/ivreg2to machine precision under i.i.d., HC-robust, one-way and multiway clustering, with or without absorbed FE. Any reported KP rk LM statistic from an earlier version was wrong. -
The augmented-diagnostics path dropped a control from the reduced form. It stripped
X_exog[:, 0]unconditionally as "the intercept", which is right only when there is an intercept — underabsorb=the FE block spans the constant and column 0 is a genuine covariate. The first-stage rank tests were therefore computed on the wrong specification for every absorbed fit. Now conditional on the column actually being constant.
Added¶
- Parity module 85 pins the dynamic TWFE event study — the benchmark every "TWFE-comparable" claim is stated against. It was the largest block of unpinned objects in the reference-claim audit: the specification the literature compares its estimators to carried no reference value of its own. All eight event-time coefficients now agree three ways, worst 5.7e-14.
Two conventions had to be matched rather than tolerated. fixest
counts absorbed fixed effects in the small-sample degrees-of-freedom
correction and sp.event_study does not, so the R side runs under
ssc(fixef.K = "none") and then matches the standard errors to
9.3e-14. Stata reghdfe offers no equivalent setting — its K counts
the 8 event-time coefficients plus 8 non-redundant time effects plus
the constant — so its standard error sits a uniform 2.803e-03 away.
That gap is reconstructed, not tolerated: rel_se stays at 1e-9
(widening it to 3e-3 would stop checking the py↔R agreement the
module exists to pin) and a dedicated contract test rebuilds every
reghdfe standard error from the StatsPAI one via
sqrt((N − K_py) / (N − K_Stata)), matching to 1e-12. The module's
headline metric is rel_est, which would never have inspected the
standard errors at all — a convention label is earned by reproducing
the other package's number, not by naming the convention.
- The reference-claim audit counts the group and calendar
aggregations. They had been a stated vocabulary gap: module 04
pinned both vectors three ways and
scripts/audit_reference_claims.pydid not score them, so they surfaced only as unclassified statistics. They now have slots, and the probe discovers support by callingsp.aggterather than consulting a hand-maintained list of which estimators aggregate — a stale list would understate coverage as silently as a loose tolerance overstates it.
Two classifier fixes came with it: a runner may now return several
results (probing only Callaway--Sant'Anna's dynamic aggregate had
reported its cohort and calendar vectors as absent, when what was
absent was the second sp.aggte call), and es_+0-style labels no
longer fall through to unclassified — the bare e alternative could
not match them, so module 85's own pins went uncounted.
Object coverage moves from 30 pinned / 61 reported to 38 / 65. The denominator grew on purpose; an audit that only ever shrinks its own denominator is measuring the wrong thing.
- Parity module 04 now pins Callaway--Sant'Anna's aggregation vectors, not one scalar. The most-used estimator in the family had exactly one pinned number — the simple ATT — while its event-study path, group vector and calendar vector carried no reference value at all. It now pins 18 statistics across all three: estimate and standard error, on the dynamic event study, the group vector and the calendar vector. Seventeen of the eighteen are three-way machine precision (worst 2.5e-15 py–R, 1.0e-14 py–Stata).
base_period is now pinned explicitly on all three sides. StatsPAI and
Stata csdid long2 use the universal base period; R did defaults to
varying. The simple ATT averages post-treatment cells only and cannot
see that option, which is why this module matched for years without it
being set — and why extending it to the event study would have reported
a spurious disagreement on every pre-treatment cell.
One row is not a three-way match and is recorded rather than absorbed:
group_overall, where StatsPAI equals R did::aggte to 1.2e-16 and
Stata csdid differs by 0.27% on the standard error alone. It is
not labelled a convention: this project's bar is reconstructing the
other package's number, and the obvious candidate (csdid treating
cohort shares as fixed) does not fit — mpdta's treated cohorts are
exactly equal in size and a fixed-weight reconstruction overshoots both
values.
Object coverage moves from 26 pinned / 35 unpinned to 30 / 31.
- Parity module 84: the BJS pre-treatment lead vector. Module 16 pins
the pooled ATT; this pins the object the v1.23.0 correctness fix
actually lived in. The three leads reproduce Stata
did_imputation, pretrends(3)at rel < 1e-14 on both estimate and standard error. The four horizons reproduce Rdidimputation0.5.1 and Stata at ~1e-8 on the point estimate.
R and Stata implement different pre-trend normalisations and
neither exposes the other's: R takes pretrends as a flag and omits
relative time -1, Stata takes a count and pools earlier periods, so the
leads are a py-Stata pin and the R side stays on the horizons.
Packaging this module is what exposed the variance defect fixed below. Both estimate and SE are now inside the tolerance budget.
-
HDFE-IV parity with Stata
ivreghdfe, end to end.sp.iv(absorb=)grew from a 2SLS-only prototype into the full estimator surface, pinned against Stata 18 intests/reference_parity/test_iv_hdfe_stata_parity.py: -
every estimator absorbs —
2sls,liml,fuller,gmm,jiveall run in residualised space (previously LIML/Fuller/GMM/JIVE raisedNotImplementedError); - multiway clustering on any path,
cluster=["county", "ym"]orcluster="county + ym", using the Cameron–Gelbach–Miller inclusion–exclusion estimator withivreg2's singleG_minfinite-sample factor and a PSD projection; gmm_vcov="efficient"reports the textbook efficient-GMM variance thativreg2 gmm2sprints, alongside the default (more agnostic) sandwich;-
interacted fixed effects in the fixest spelling,
absorb=["county", "prov^year"], materialised into a single factor rather than left for the caller to precompute. -
sp.conley()accepts IV results. Spatial and spatio-temporal HAC variances now work on 2SLS fits — including absorbed ones — by building the meat from the projected scoresX_hat * urather than the OLS design, which previously failed withKeyError: 'X'. Matches Stataacreg ... spatial pfe1() pfe2()to machine precision withdistance="planar". -
absorb=andcluster=on the weak-IV toolkit.sp.effective_f_test,sp.anderson_rubin_testandsp.iv_diagall take the same fixed-effect and clustering spec as the estimator, so the diagnostics describe the specification actually fitted. The AR statistic gains a genuine cluster-robust form (referred toF(k_z, G-1)) instead of the homoskedastic F, and reportsar_ci_disjointwhen the confidence set is a union of intervals rather than one. -
sp.zero_first_stage— the zero-first-stage exclusion test, plus van Kippersluis–Rietveld's pleiotropy-robust corrected estimate. Give it the subsample where the instrument is inert and it reports the premise (is the first stage actually zero there?), the test (the reduced form there is the direct effect), and the consequence (implied bias in the main-sample IV estimate, and the corrected point estimate with a cluster-bootstrap interval). Takes the sameexog/absorb/clusterspec as the estimator. Ref verified via Oxford Academic and PubMed: van Kippersluis & Rietveld (2018), IJE 47(4), 1279-1288, doi:10.1093/ije/dyx002. -
sp.diversity_index— Shannon, species richness, Pielou evenness, Simpson (concentration / Gini–Simpson / inverse) and Hill numbers of any order, from long-format sighting records or a site-by-species matrix, grouped straight onto a panel index.min_records=makes the small-sample filter explicit rather than a footnote. Refs verified via Wiley/Nature/ScienceDirect/ESA and a second independent index for each: Shannon (1948) doi:10.1002/j.1538-7305.1948.tb01338.x, Simpson (1949) doi:10.1038/163688a0, Pielou (1966) doi:10.1016/0022-5193(66)90013-0, Hill (1973) doi:10.2307/1934352. -
Guide + runnable example: policy-intensity panels.
docs/guides/policy_index_hdfe_iv.mdandexamples/policy_index_hdfe_iv.pywalk the whole design — build the outcome, absorb two-way FE, instrument, read the identification panel, swap in spatial/serial variances, split subsamples, test mechanisms — with a table of exactly which Stata command each call reproduces.
Fixed¶
-
The parity comparator silently discarded every statistic the R side did not emit.
compare.py::collect()skipped any Python row without an R counterpart, which threw away real py-Stata evidence in four modules —31_dfl(3 rows),59_liml(2),28_frontier(1) and the new84_bjs_pretrends(3) — nine rows in total, every one of which agrees at machine precision. Rows are now kept whenever either reference carries the statistic, and rows neither carries are recorded inUNMATCHED_ROWSinstead of vanishing. "Emitted and never compared" is exactly how an unpinned object hides, which is the subject of this release. -
scripts/audit_reference_claims.pymis-filedtau<k>horizons as the headline statistic, because the relative-time pattern matched a baretbeforetau. Horizon rows were crediting coverage the archive did not have. Object coverage is now 61 reported / 26 with a reference value / 35 without. -
R-style covariate formulas on
sp.callaway_santannaandsp.drdid.x=/covariates=now accept a one-sided formula as well as a list of column names, so anxformlacopied out of Rdid/csdid/DRDIDworks as written:
sp.callaway_santanna(df, y="lemp", g="first_treat", t="year",
i="countyreal", x="~ lpop + I(lpop**2)")
A left-hand side is accepted and ignored (R's xformla carries one),
~ 1 means no covariates, and a plain string without ~ is still a
single column name. Terms are materialised with patsy, so
transformations and interactions that have no column behind them —
I(x**2), np.log(x), x1:x2, x1*x2 — no longer have to be
hand-expanded first. Matches csdid's xformla exactly, including
the quadratic case (ATT to 1.8e-15).
Inside I(...) the expression is Python, where ^ is bitwise XOR
rather than exponentiation, so an unconverted R I(x^2) is rejected
with an explanation instead of quietly computing something else.
-
sp.callaway_santannarepeated-cross-section and unbalanced-panel paths gainedweights=,clustervars=,bstrap=,cband=andboot_weight_type=. Until now those paths reported analytic per-cell standard errors only, andweights=/clustervars=raised rather than being ignored — honest, but a real capability gap against Rdidandcsdid, both of which support all of it underpanel=FALSE. -
Weights enter the cell moments, the propensity/outcome nuisances and the influence functions, and cohort shares become shares of weight mass rather than head counts, so
sp.aggteweights cohorts consistently with what the cells estimate. Matchescsdidexactly (ATT to 3e-15, standard errors to machine precision) fordr/ipw/reg, on both the repeated-cross-section and the unbalanced-panel route. - Under
allow_unbalanced_panel=Truethe weight and the cluster label must be time-invariant within unit: the fold sums a unit's contributions, so a within-unit-varying weight would silently reweight its own periods against each other. Both are checked and raise. -
Clustering beyond the unit still requires
bstrap=True, as on the panel path. The analytic standard errors cannot express within-cluster dependence, so reporting them underclustervarswould understate uncertainty. -
sp.did_design_contract— the forward-engineering recipe as an object. \citet-style prose is not a contract; a field is. Baker et al. (2026, §6) argue a DiD study should fix its target parameter, state its identifying assumption, choose an estimator and declare an inference frame before reading a coefficient. This reports which of those eight steps a fitted result actually pins down. It never fills a slot with a default: a comparison group the result does not record is reportedundetermined, because never-treated, not-yet-treated and all-periods parallel trends are three different assumptions and choosing one by omission is still choosing. Step 8 ("keep learning") is reportednot-evidencedrather than given a fabricated status. -
sp.did_cluster_diagnostics— the cluster count, graded against published evidence. Ulloa-Perez et al. (2025) ran a 1,000-replication grid at 30, 50 and 100 clusters and found that at 30 every modern staggered estimator they evaluated under-covered a nominal 95% interval — two-way Mundlak between 60% and 85% — with coverage improving as clusters accumulated. This counts the clusters treatment is assigned at, reports clusters per cohort, and grades the design against that grid. Below 30 it reportsbelow-evidencerather than extrapolating: their result is the closest evidence available and it does not extend downward. Defaulting the cluster level to the unit id warns, because the two coincide only under independent unit-level assignment and assuming so is the optimistic error. -
scripts/audit_reference_claims.py— object-level parity coverage. A parity archive answers "does this function agree with its reference?" The answerable question is narrower: a function reports a headline estimate, its standard error, an event-study vector, the pre-treatment half of that vector, and diagnostics, and a parity module pins some of them. The script runs each estimator, records which objects it actually hands back, and cross-references the committed parity result JSONs for which of those objects carry a pinned reference value. On the nine core staggered DiD estimators it reports 61 reported objects, 22 pinned, 39 unpinned — including the Callaway–Sant'Anna event-study vector, whose Track A module pins only the simple ATT. The defect above lived in exactly that gap. Output is committed atdocs/parity_object_coverage.md;--check Nfails a build when the unpinned count grows.
The script deliberately keeps its headline free of judgement calls: it
reports reported-vs-pinned, and separately flags whether a function's
documentation names a runnable reference anywhere, with the matched
string attached so the flag can be checked. An earlier revision folded
the two together and matched the R package fect inside the word
"effects", which inflated the count.
-
New R
did2s1.2.1 reference pins.sp.gardner_did's event study andsp.did_imputation(pretrend_method="in-sample")both reproducedid2scoefficients (3.5e-14 and 1.4e-12), confirming that the in-sample lead construction is did2s's rather than an approximation of it.sp.gardner_did's analytic event-study standard errors are measured against the same reference for the first time: a median of 0.71x the did2s value across horizons, which prices the existing runtime warning that the analytic SE ignores stage-one estimation. The direction is not uniform (one horizon reaches 1.52x), so an omitted positive variance term is not the whole story. -
sp.event_study_conventionandsp.compare_event_study_conventions— the reference-period convention as an inspectable object. Roth (2026) shows that recent DiD methods build their pre-treatment event-study coefficients differently from their post-treatment ones, so on the same non-staggered panel with no treatment effect and a linear parallel-trends violation, dynamic TWFE draws a straight line, Callaway–Sant'Anna with a varying base period draws a kink, and the BJS imputation default draws a jump.sp.event_study_convention()returns what each estimator differences against and whether the two halves of the path are symmetric;sp.compare_event_study_conventions()runs them on one panel and splits the gap against the TWFE benchmark into a harmless common shift and an asymmetry, so the kink, the jump and theN0/Nattenuation each get a separate numerical signature. It warns when the recorded convention disagrees with what the data show, so the registry cannot drift away from the code. -
sp.did_imputation(..., pretrend_method=)—'bjs'(new default, matches Statadid_imputation, pretrends(k)to 1e-12),'in-sample'(the previous behaviour, and what Rfect/did2sreport), and'symmetric'(Roth'sβ̂^{BJS,new}, which equals the dynamic TWFE event study up to a common vertical shift and so restores the usual visual heuristics; non-staggered balanced designs only, and it raises rather than extrapolating theN/N0factor beyond where Roth derives it).model_infonow carriespretrend_methodandevent_study_convention. Under'bjs'the joint pre-trend test uses the auxiliary regression's full cluster-robust covariance instead of the diagonal approximation. -
sp.staggered_rolloutnow covers the whole of Rstaggered1.2.2 (Roth & Sant'Anna 2023 [@roth2023efficient]). Five additions, each pinned against the reference implementation to ≤1e−9 on three panels —did::mpdta, a randomised rollout with no never-treated units (somax(g)is finite, a branch mpdta never reaches), and the same design with the effect switched off: - The adjusted standard error. R reports two SEs and StatsPAI
implemented only the conservative one. Under random adoption timing
part of the pre-period covariance is identified, so the Neyman
bound can be tightened;
se_type="adjusted"reports the tightened version, which is what R prints as its primaryse. Both are always inmodel_info. The default stays"neyman", so no existing number moves — the adjusted SE is never larger, making the default the cautious one. estimand="eventstudy"with a scalar or a vectorevent_time. A vector returns one row per event time in.detailand the joint covariance inmodel_info["vcov"], so the summary.estimateaverages post-adoption event times with a correct SE rather than pretending they are independent.- Fisher randomisation inference (
fisher=True). Adoption dates are permuted across units — the null the design actually licenses — and the studentised statistic recomputed. No asymptotics, which is the point of a randomised rollout. Both p-values (conservative and adjusted) are reported. use_last_treated_only, the Sun-Abraham comparison group.-
Singleton cohorts are dropped with a warning instead of raising, matching R: a lone unit in one cohort no longer sinks the design.
-
sp.staggered_rollout(..., use_did_a0=False)— the general form of the efficient estimator, which uses every pre-period as a control instead of the single DiD contrast atg − 1.betabecomes a vector and the estimator is weakly more efficient, at the cost of estimating more nuisance weights. Requiresefficient=Trueand says so: the plug-in fixesbeta = 1, which is a single contrast and has no meaning against a vector control set — R errors there too, and computing something anyway would invent an estimator with no reference behind it. -
sp.distributional_did— the treatment effect on the distribution of the outcome, bin by bin (RdidFF::distDD). Wheresp.functional_form_testasks whether the design's implied counterfactual density is a density at all, this asks where treatment moved probability mass. The per-bin effects sum to zero by construction, so the content is the shape: a mean ATT of zero is perfectly consistent with large offsetting movements in the tails, and this is what shows them. Pinned againstdistDDto 4e-16 including standard errors, which the reference exposes on this path and not on the functional-form one.
It also survives a case the reference does not: with automatic binning on
mpdta one bin comes out empty, and didFF 0.1.0 then builds its table
from levels(droplevels(bins)) (19 entries) alongside 20 point estimates
and dies with "arguments imply differing number of rows: 20, 19".
StatsPAI reports all 20 bins and flags the empty one.
-
Agent-facing routing for the design-based family.
sp.did(method="staggered_rollout" | "staggered_cs" | "staggered_sa")now works, and deliberately skips the Bacon decomposition, the pre-trend test and honest-DiD sensitivity, logging why. All three are about parallel trends: a pre-trend test neither supports nor threatens an assumption you are not making, honest-DiD relaxes one you did not assume, and Bacon's "switch to Callaway-Sant'Anna" advice does not apply. Reporting a passing pre-trend test beside a design-based estimate is a category error that invites the reader to think the estimate rests on an assumption it does not.sp.recommendnow surfaces the branch as well — but never selects it, because whether adoption timing was randomised is a claim about how the data came to be and no test on the data can settle it. -
R migration:
sp.from_rnow translatesstaggered::staggered,staggered_cs,staggered_sa,didFF::didFFanddistDD, mapping R'sbeta = 1toefficient=False,use_DiD_A0 = FALSEtouse_did_a0=False,compute_fishertofisher,weightsnametoweights, and routingdistDDtosp.distributional_did. -
docs/guides/randomized_rollout.md— a guide for the whole branch: the one question that decides it, which of the two standard errors to report, the randomisation test, the design-based event study, the general control set, and the three diagnostics to stop running once you are on it. -
sp.staggered_csandsp.staggered_sa— the Callaway-Sant'Anna and Sun-Abraham estimands with design-based inference, mirroring Rstaggered::staggered_cs/staggered_sa. Same weights assp.callaway_santanna/sp.sun_abraham, but the standard error comes from random adoption timing rather than parallel trends; units already treated in the first period are dropped, since ATT(g,t) is not identified for them. Use these when timing was randomised and the familiar estimand is wanted; use the parallel-trends versions when it was not. -
sp.functional_form_test(..., weights=)— sampling weights, which reach the implied density and the test statistic. Pinned againstdidFF(weightsname=)to 2e−15, including on the rejection panel where the p-value is 0 rather than 1, so weights are verified through the critical value and not merely through the point estimates. -
sp.functional_form_testgainspanel,allow_unbalanced_panel,balance_e,min_eandmax_e, completing the passthrough tosp.callaway_santannaandsp.aggte. The last three restrict which event times enter aaggregation="dynamic"aggregate, matchingdidFF's arguments of the same names. -
FunctionalFormResult.plot()— the implied-density bar chart, with negative bins in their own colour andlb/ubto window the outcome range (didFF'slb_graph/ub_graph). This is the figure the test is really about: the p-value compresses into one number what the bars show directly, which is whether the distribution the design implies for the treated group is a distribution at all. -
sp.drdidnow reaches all 14 estimators of RDRDID1.2.3, each matching to machine precision. New keyword-only argumentsest_method('dr'/'ipw'/'reg'/'twfe'),normalized,locally_efficient,weights(DRDID'si.weights) andtrim_levelcombine with the existingmethodandid=to select:
panel (id=) |
repeated cross-sections | |
|---|---|---|
dr |
drdid_panel, drdid_imp_panel |
drdid_rc, drdid_imp_rc, drdid_rc1, drdid_imp_rc1 |
ipw |
std_ipw_did_panel, ipw_did_panel |
std_ipw_did_rc, ipw_did_rc |
reg |
reg_did_panel |
reg_did_rc |
twfe |
twfe_did_panel |
twfe_did_rc |
Six of these had no implementation anywhere in StatsPAI before
(drdid_rc1, drdid_imp_rc, drdid_imp_rc1, ipw_did_rc,
twfe_did_rc, plus drdid_panel on the id= path, which used to
raise); three more existed only as private helpers behind
callaway_santanna's repeated-cross-section branch. For scale, the
d2cml-ai/DRDIDpy port ships 6 of the 14 and returns bare
(att, influence_function) tuples with no standard error, CI or
p-value. result.model_info["engine"] names the DRDID function that
ran, so a migration can be checked line by line.
'twfe' is provided for comparison, not as a recommendation: with
covariates it is exactly the specification Sant'Anna & Zhao (2020) and
Caetano & Callaway (2024) warn about.
-
Inverse probability tilting is now solved to convergence.
_calibrated_pscore(theDRDID::pscore.calport behind everymethod='imp'estimator) stopped at BFGS's tolerance where R uses a trust-region Newton method. Tilting earns its name by making the covariate-balance conditions hold exactly at the optimum, which is what kills the first-order nuisance terms in the improved estimators — so "close" is not good enough: a 1.5e-9 slack in p̂(X) moveddrdid_imp_rcby 7e-4 in the ATT and 2.9% in the SE. A Newton refinement pass now drives the gradient to 1e-13. Improved-estimator results move in the last few digits. -
sp.callaway_santanna(..., allow_unbalanced_panel=True)— the missing counterpart to Rdid::att_gt(allow_unbalanced_panel = TRUE). Withpanel=Trueand units missing periods, the default route forms within-unit differences anyway, so a unit missing either the base or the comparison period silently drops out of that cell and the effective sample varies across cells. The new option switches to the repeated-cross-section estimators, which never difference within unit and so keep every observed row, then folds the influence functions back to the unit level (R's.rowid <- idname) sonstays the unit count and the SEs still carry within-unit correlation. Skipping that fold would treat a unit's pre and post rows as independent draws and understate every SE — invisible in the point estimate. Matches Rdid2.3.0 to ≤7e−15 on ATT(g,t) and to machine precision on standard errors, acrossdr/ipw/regwith and without covariates, and through all foursp.aggteaggregations. Inert when the panel turns out to be balanced, matching R. Not yet supported alongsideweights=orclustervars=, which raise rather than being ignored. -
ω weights on
sp.sun_abraham. The weight enters the two-way fixed-effect projection (weighted alternating projections — demeaning by the unweighted mean and then solving by WLS would leave the fixed effects correlated with the regressors), the least-squares solve, the cluster-robust variance, and the interaction weights, which become shares of ω-mass rather than head counts. Event-study point estimates matchfixest::sunab(..., weights=)to 1.6e−9 weighted and 3e−8 unweighted. Standard errors are not claimed at parity: StatsPAI andfixestdiffer on the interaction-weighted aggregation variance by ~0.7% unweighted and ~2.2% weighted. That gap predates this work, is largest where several cohorts are aggregated, and is now pinned as a documented open item rather than asserted as a pass. -
sp.did_balance— pinned against Rcobalt4.6.2 and an independent transcription of the formula. Unweighted, all three agree to ~1e−14. Weighted, StatsPAI follows Baker et al. §4.1 (weighted variances in the denominator) whilecobaltdeliberately holds the denominator at the unweighted pooled SD so balance stays comparable across reweightings; the two differ by a few percent and the test suite proves the gap is exactly that denominator choice. -
sp.did_balance— covariate balance in the shape of Baker et al. Table 4: Imbens–Rubin normalized differences computed on baseline covariate levels and on covariate changes across the treatment date, weighted and unweighted side by side, flagged at |·| > 0.25 (Imbens & Rubin 2015, p. 277). The changes panel is the informative half: DiD identifies off trends, so a covariate balanced in levels can still be moving differentially. Ships.summary()and.to_latex(). -
model_info["parallel_trends"]— every Callaway–Sant'Anna fit now records which parallel-trends assumption it imposes (PT-GT-NEV/PT-GT-NYT, and the conditionalCPT-variants), with the formal statement, the comparison group, whether pre-trends are restricted, and the trade-off..summary()prints it next to the estimate. Baker et al. §5.2.2: "we strongly recommend that researchers clearly state the specific parallel trends assumption they are actually imposing." -
sp.cs_reportnow runs the eight-step forward-engineering workflow of Baker et al. §6, not just estimate → aggregate → sensitivity. It adds covariate balance, the Roth–Sant'Anna (2023b) functional-form test, an overlap diagnostic, and RA/IPW/DR triangulation (the paper's Table 7 / Figure 4), and returnsreport.forward_engineering_checklist()as a machine-readable frame. New arguments:weights,balance,triangulate,functional_form. Steps that fail are recorded inreport.degradations, never dropped.
Changed¶
- ⚠️
sp.functional_form_test(n_bins=)now defaults to"auto", not10, and the p-value moves. The binning is the test — it decides the resolution at which a negative implied density can be seen — so a default that differs from the reference implementation makes the two packages' p-values non-comparable while looking like they should be."auto"isdidFF's rule: an outcome with fewer than 20 distinct untreated values is treated as discrete (one bin per value, with a warning), otherwise it is cut intomin(20, n_distinct)equal-width bins. To reproduce results computed under 1.21.0/1.22.0, passn_bins=10explicitly. An explicit integer always cuts, however few distinct values the outcome takes. See MIGRATION.md. sp.functional_form_test(binpoints=)now pads cut points that stop short of the outcome range, with a warning, instead of silently dropping the uncovered mass out of the density being tested — again matchingdidFF. Passing bothbinpointsand an explicitn_binsnow raises instead of silently preferringbinpoints.sp.functional_form_test(...).tablegains alevelcolumn (the bin's upper endpoint), which is whatdidFFtabulates and plots against.sp.did(method="twfe")on a panel with more than two periods now warns that collapsing to a median pre/post split estimatesATT_avg − (average pre-period differential trend), not the average post-treatment ATT (Baker et al. eq. 22; −2.53 vs −0.70 in their data).sp.did_2x2(..., covariates=[...])now warns that an additively-entered TWFE covariate yields a possibly non-convex weighted average of covariate-specific effects plus misspecification bias, not the ATT (Caetano & Callaway 2024), and points atsp.drdid.docs/guides/choosing_did_estimator.mdrestructured target-parameter first, and stale API examples corrected (post=,max_M=,repeated_cs=,sp.drdid(d=, post=),sp.ddd(t1=, t2=, t3=)were all wrong against the shipped signatures).
Fixed¶
-
⚠️ Citation integrity, part 2: five more duplicate groups merged and six broken citations repaired. Every one of the remaining duplicate pairs was a preprint/working-paper record sitting beside the published one, and in each case the code cited the preprint key while its own
Referencestext printed the published reference — the same drift as the Belloni case below. Merged, after two-source verification each:darolles2010nonparametric(SSRN) →darolles2011nonparametric(Econometrica 79(5), 1541–1565,10.3982/ECTA6539);lundberg2020closing(SocArXiv) →lundberg2024gap— note the key and the year both moved, because the issue year is 2024 (53(2), 507–570) while Crossref reports the 2022 online-first date and the old key said 2021, three different years for one paper;goldsmith2020bartik(NBER WP 2018) →goldsmithpinkham2020bartik(AER 110(8), 2586–2624);egami2024using→egami2023imperfect(NeurIPS 2023);yadlowsky2021evaluating("JASA (forthcoming)") →yadlowsky2025evaluating(JASA 120(549), 38–51). Worst of the set:bartik/shift_share.pyhard-codes a BibTeX record intoCausalResult._CITATIONS, and it used the keygoldsmith2020bartikwhile holding the AER 2020 metadata — so.cite()andpaper.bibdisagreed under one key. Six broken bracketed citations intests/reference_parity/named keys that never existed (@therneau2000survival×2,@dechaisemartin2020twoway,@wooldridge2021twoway,@correia2017hdfe,@berge2018fixest); every one of those works was already inpaper.bibunder another key, so they were plausible-looking guesses, not missing literature, and no new entry was needed.paper.bib668 → 660 entries (8 duplicates removed across both passes). New guardtests/test_citation_integrity.pyfails on any unresolvable citation key, any shared DOI, and any shared title. Refs verified via Crossref (10.3982/ECTA6539,10.1177/00491241211055769,10.1257/aer.20181047,10.1080/01621459.2024.2393466,10.52202/075280-3000), SAGE, AEA, TSE, arXiv (2306.04746) and Ingenta. -
⚠️ Citation integrity: duplicate
paper.bibentries split three papers across five keys, and three IV docstrings cited a preprint while printing the published reference.paper.bibcarried bothbelloni2011sparse(SSRN preprint,10.2139/ssrn.1910169) andbelloni2012sparse(Econometrica 80(6), 2369–2429,10.3982/ECTA9626) for the same paper.statspai/rlasso/*andpaper.mdcited the published entry;iv/post_lasso.py,iv/ivdml.pyandregression/advanced_iv.pycited the preprint one — while their ownReferencestext read "Econometrica, 80(6), 2369-2429". Exactly the format drift §10 warns about. Gardner's two-stage DiD had it worse: three keys for one paper (gardner2022twostagearXiv,gardner2022stagemisc,gardner2021two"Working paper, University of Mississippi"), andgardner_2s.pydated it 2021 while carrying the arXiv id, with its single bib key sitting on the Butts & Gardner line but pointing at Gardner. Additionallytest_did_variants_parity.pycited@gardner2021twostage, a key that does not exist inpaper.bib. All now resolve to one verified entry each; the three duplicate entries were removed andgardner2022twostagegained its DataCite DOI. Every fact re-verified against two independent sources and nothing written from memory, per §10 — refs verified via Crossref (api.crossref.org/works/10.3982/ECTA9626,.../10.32614/rj-2022-048), RePEc, arXiv (2207.05943,1010.4345) and DataCite. No new citations were introduced. -
⚠️ Agent-native gap:
sp.ivadvertised 5 of the 26 methods it routes.sp.ivis a callable subpackage and the entry point for the entire IV family, but its registrymethodenum listed only2sls,liml,fuller,gmm,jive. Sincesp.describe_function('iv')andsp.function_schema('iv')are how an agent discovers what a function can do, 21 working, tested estimators were invisible: an agent reading the schema would conclude StatsPAI has no marginal treatment effects (method='mte'), no Mogstad–Santos–Torgovitsky sharp bounds ('ivmte_bounds'), no Conley–Hansen–Rossi plausibly-exogenous sensitivity ('plausibly_exog_ltz'/'plausibly_exog_uci'), no rigorous/post-Lasso instrument selection ('post_lasso','rlasso','lasso'), none of the many-weak-instrument jackknife variants ('jive1','ujive','ijive','rjive','jive_mw','many_weak_ar'), and no'npiv'/'kernel'/'ivdml'/'deepiv'/'ivqreg'/'bayes'/'continuous_late'/'shift_share'. The enum and the description now cover all 26, so the family stays discoverable through the documented dispatcher rather than through 26 separate top-level symbols — the house pattern of CLAUDE.md §3 rule 4. No new public functions; the registered count is unchanged at 1,171, so no README /docs/stats.md/ JSS figure moves. New guardtests/test_iv_dispatcher_registry_sync.pypins the enum againststatspai.iv._METHOD_ALIASESin both directions — a method that routes must be advertised, and an advertised method must route. -
docs/guides/robustness_workflow.mddocumented a return value that does not exist. Two blocks calledsp.robustness_report(r)on a fittedCausalResult— the signature is(data, formula, x, ...)— and described the return as a dict withidentification/specification/sensitivitykeys and averdict: 'ROBUST' | 'MARGINAL' | 'FRAGILE'. It actually returns aRobustnessResult(.summary(),.results_df,.plot(),.baseline_estimate,.n_checks) and covers Layer-2 specification robustness for a regression design only. Rewritten to the real API, with the "one call returns a verdict" claim removed. -
Three documented calls did not exist against the shipped signatures.
docs/guides/choosing_did_estimator.mdadvertisedsp.stacked_did(df, y, g, t, i, event_window=6)— there is noevent_windowparameter (the name was copied from the neighbouringsp.sun_abrahamrow) and the positional order was that ofcallaway_santanna, notstacked_did; the correct call issp.stacked_did(df, y, group, time, first_treat, window=(-5, 5)). The same table mapped de Chaisemartin–D'Haultfœuille tosp.did_multiplegt(df, y, treat, g, t, i), whose positional order silently bindsgroup=treat,time=g,treatment=t. Anddocs/guides/migration-from-r.mdmappedHonestDiD::createSensitivityResultstosp.honest_did(cs_result, Mbar=...);Mbarbelongs tosp.sensitivity_rron the following row, whilehonest_didtakesm_grid. New guardtests/test_docs_call_signatures.pyparses every inlinesp.*(...)span indocs/guides/(278 of them) and fails when a documented keyword names no real parameter, or when positional arguments overflow the signature. Argument elision (sp.dml(...),sp.callaway_santanna(..., estimator='dr')) stays legal. -
Fourteen guides recommended
sp.*functions that do not exist, and six of those were real estimators reachable only through the family dispatcher.choosing_iv_estimator.mdtold readers to callsp.weak_iv_ci,sp.post_lasso,sp.jive_variants,sp.plausibly_exogenous,sp.mteandsp.ivmte_lp— every one raisesAttributeError. The estimators all ship and are tested; the correct entry point issp.iv(..., method=...), which routes 26 methods (see the separate entry below). The guides now usesp.anderson_rubin_ci/sp.jive/sp.rlasso_ivwhere a top-level export exists, andsp.iv(method='post_lasso'|'ltz'|'uci'|'mte'| 'ivmte_bounds'|'ujive')for the rest. Also fixed:sp.rdmulti→sp.rdmc,sp.pcalg→sp.pc_algorithm,sp.S_Learner→sp.metalearner,sp.bootstrap_ci→sp.bootstrap,sp.permutation_test→sp.ri_test,sp.audit_result→sp.audit,sp.sensitivity→sp.unified_sensitivity,sp.bounds(...)(a module, not a callable) →sp.partial_identification,sp.vecm→sp.johansen, and a doubledsp.sp.prefix in two family guides. The same sweep over the published reference pages replacedsp.moran_i/sp.geary_c/sp.local_moran/sp.join_count/sp.spatial_weights/sp.sararwith their real names, mappedsp.cointegrationandsp.chow_test/sp.quandt_andrews/sp.bai_perronontosp.engle_granger/sp.johansen/sp.structural_break(method=...), replaced the fivesp.<x>_learnerentries withsp.metalearner(..., learner=...), and dropped thesp.nelson_aalen,sp.aft_frailtyandsp.sdemsnippets, which documented estimators StatsPAI does not implement. -
Thirty-one more wrong keywords across nine guides. Same class as the
event_windowbug:sp.kitagawa_test(d=, z=)(reallytreatment=/instrument=),sp.sensemakr(r, benchmark_covariates=)(takesdataandbenchmark=, not a fitted result),sp.spec_curve(treat=, covariate_sets=, estimators=)(reallyx=/controls=/se_types=),sp.rdbalance(covariates=)→covs=,sp.rdplacebo(true_cutoff=)→c=,sp.trimming(r, threshold=),sp.cs_report(group=, time=, first_treat=)→i=/t=/g=,sp.causal_survival_forest(duration=, treatment=)→time=/treat=,sp.bartik(unit=, time=),sp.regtable(robustness=), and ansp.event_study(d=, t=, i=, method=, pretrend_test=)block in the DiD guide where none of the five keywords exist.docs/guides/is now clean: zero unresolvable names, zero wrong keywords, across 713 documented calls. -
The registry's test-evidence ledger cited ten test files that never exercise the function they were attached to.
sp.describe_function(name)['validation_notes']reports these paths as"API/unit contract evidence: <path>", so an agent or referee reads them as the place the estimator is covered.stacked_didpointed attests/test_did.py, which contains no stacked-DiD test at all;cic,ddd,did_analysis,pretrends_test,breslow_day_test,mr_clustandtarget_trial_protocolpointed at files they had been split or renamed out of. All ten now cite files that call them (verified by direct-call count). No validation tier changes —stacked_didremainscertifiedon the Track A 75 parity evidence — only the cited evidence. New guardtests/test_registry_validation_evidence.pyparametrises over all 220 ledger entries and fails on a path that is missing or that never mentions its function. -
The "Alternatives (ranked)" renderer double-prefixed
sp.._agent_docs.pyprependssp.to every entry in a spec'salternatives, but six registry entries (text_treatment_effect,llm_annotator_correct,llm_dag_constrained) already spelled theirs with the prefix — so the generated "## For Agents" blocks, andsp.describe_function(...)["alternatives"], told agents to callsp.sp.regressandsp.sp.pc_algorithm. The six entries now hold bare names, matching every other entry, and the renderer strips a leadingsp.defensively so a future entry cannot reintroduce it.schemas/agent_cards.jsonregenerated. -
87 wrong keywords across the 13 published API-reference pages. These were written against an older API and never re-verified, so the reference — the page a reader trusts most — was the least accurate surface in the repo.
sp.sar/sp.sem/sp.sdm/sp.slxwere documented as(df, y=, x=, W=)when every one is(W, data, formula);sp.gwr/sp.mgwrwere shown with a DataFrame API when they take(coords, y, X, bw=)arrays;sp.spatial_panel(y=, x=, i=, t=, fe=)is really(data, formula, entity=, time=, effects=);sp.cox(time=, vce=)is(duration=, robust=);sp.aft(time=, event=, x=, dist=)is formula-based withfamily=;sp.assumption_audittakes a fitted result, not(df, outcome=, treatment=); plusspec_curve,robustness_report,verify,evalue,sensemakr,manski_bounds,honest_did, six RD entry points, five neural/BART estimators,notears,ges, the threepanel_*models,mixed,meglm,xtfrontier,balance_diagnostics,var,bvar, and the threesynth_*power helpers. Four documented parameters turned out never to have been implemented at all —sp.cox's counting-processtime_start/time_stop(its whole "Time-varying covariates" section),sp.garch(model='egarch'|'gjrgarch'), andsp.aft(dist='gamma')— and those claims were removed rather than rewritten. All 53 rewritten calls were bind-checked againstinspect.signature. The reference pages are now held to the same zero-tolerance guard as the guides: one sweep over both surfaces, no baseline, no allowance.
[1.22.0] — 2026-08-07¶
⚠️ Correctness¶
-
sp.cardinality_matchviolated its own SMD tolerance. The estimator maximises the number matched subject to a standardised-mean-difference bound on every covariate — the bound is the whole point. It relaxed the binary program to a continuous LP and kept the topround(Σz)weights; rounding does not preserve linear constraints, so the returned sample breached the tolerance and the balance table reported the breach as though it were fine. On a 12-cell seed × tolerance grid, 9 were infeasible, worst 0.0631 against a 0.05 request. Now solved exactly withscipy.optimize.milp(HiGHS): 16 of 16 feasible, count equal to an independently-derived optimum in every cell. Matched sets and effect estimates change — the old ones were outside the feasible region. Newtime_limitargument; an unsolvable request now raises. -
sp.overlap_weightsfitted a penalised logit. It estimated the propensity score withsklearn.LogisticRegression(C=1e6)— penalised however largeCis — whilesp.match/sp.psmatch2have always used the unpenalised MLE, so one package gave two propensity scores for the same specification. It is also a theory mismatch: the overlap weights' exact-balance property (Li, Morgan & Zaslavsky 2018) holds at the score equations of the unpenalised logit. Against Rglm/WeightIt, the penalised fit sat 8.5e-06 from the reference and pushed ATO/ATE/ATT/ATC ~1e-6 off; the MLE agrees to 2.6e-14 and ~1e-14. Estimates move by ~1e-6, toward the reference. -
The weighted Kolmogorov-Smirnov balance statistic understated imbalance. The shared
_ks_stat— reported asks_statbysp.ps_balanceandks_stat_weightedbysp.balance_diagnostics— delegates the unweighted case to the exactscipy.stats.ks_2sampbut computed the weighted case by linearly interpolating cumulative weights between order statistics. An empirical CDF is a step function, so the interpolant cuts the corner at every jump and reports a smaller maximum gap than exists. Over random samples the understatement reached ~0.06 absolute and tens of percent relative at n = 30 (0.0919 reported where the statistic is 0.1492), shrinking to ~0.004 at n = 400 — enough to answer "is my weighted KS under 0.1?" the wrong way. It also left one branch of one function less accurate than the other for no reason: with equal weights the interpolated branch drifted up to 0.063 fromks_2samp, where the exact step-function ECDF (asearchsorted, no more expensive) reproduces it to 1.1e-16.ks_stat/ks_stat_weightedtherefore increase; they are diagnostics, not estimates, and no effect estimate or standard error changes. Zero total weight now returnsnaninstead of dividing by zero. SeeMIGRATION.md. -
sp.aggteandsp.callaway_santannastandard errors were too small. The Callaway–Sant'Anna aggregation weights are estimated cohort sharesp̂_g = P̂(G = g), but the variance treated them as fixed — dropping the weight-estimation influence term (Rdid:::wif). Reported SEs were anti-conservative on every aggregation mixing more than one adoption cohort, by up to 8.4% in the cases measured. Point estimates were never affected (they already matched Rdidto ~1e-11);se, the confidence interval, and the p-value all move, and only ever wider.
On real did::mpdta: simple SE 0.0117467 → 0.0120340, calendar
t=2006 0.0184354 → 0.0201259 — both now equal to R. Single-cohort
aggregates (type='group' cells) were always correct, because the p̂_g
factors cancel there; that is why no internal consistency check caught
this. Fixed in sp.aggte, sp.aggte_from_influence, and
callaway_santanna's own headline SE, all of which now derive from one
shared primitive (did/_core.py), so the analytic and bstrap=True
paths cannot drift apart. See MIGRATION.md.
Found by the new Cheng–Hoekstra castle-doctrine replication, which
compares StatsPAI against Stata 18 MP and R did on real data; the
regression guard is tests/reference_parity/test_aggte_r_did_parity.py.
-
fmt="auto"outsidesp.regtablerendered the literal string"auto"into every cell.sp.sumstats/sp.mean_comparisonapplied the format with a barefmt % value, and"auto" % valueon a template carrying no conversion specifier returns the template unchanged — so the table filled with the wordautoinstead of raising. Every exporter now resolves precision through the same validator, which rejects unusable input at the call site. -
sp.etabledropped standard errors on the non-pyfixest path. The fallback returned bare coefficients — no standard errors, no significance markers, no rounding — which reads as a finished table while omitting what a reader needs to judge any estimate. It now reports every term the model exposes (including auxiliary parameters such as Tobit'ssigma) ascoef*** (se)at a shared decimal place. -
p-values printed as an exact
0. Result-object exports renderedp_valueas0.0000, claiming certainty no finite sample supports. They now floor at<0.001. -
Table precision is now chosen per coefficient/SE pair, not per cell.
fmt="auto"used to pick decimals from each value's own magnitude independently, so a row could render as-5.22 (45.3)— an estimate and its own standard error disagreeing about precision, which is not a convention any economics journal follows. Each coefficient row now resolves to a single decimal count, chosen so neither half loses a significant digit, shared by every model column in the panel. Tables built withfmt="auto"will show different (correctly paired) digits; explicitfmt="%.3f"-style templates are untouched. SeeMIGRATION.md. -
fmt="auto"no longer rounds small estimates to zero. The old ladder bottomed out at three decimals, so a coefficient of0.00042printed as0.000. Precision now extends below that floor, and a value under the decimal ceiling escapes to scientific notation rather than displaying a nonzero estimate as an exact zero. Values at or above0.001are unaffected. -
sp.regress/sp.ivregsilently ignoredvcov=, returning unclustered standard errors. The pyfixest spelling a user carries over fromsp.feols—vcov={"CRV1": "firm"}orvcov="hetero"— fell through**kwargsintofit()and was dropped on the floor. No warning: the call succeeded and reported default SEs. In a 15-cluster example the reported SE was 0.109 where the clustered value is 0.063, i.e. 1.7× too small.vcov=is now a canonical alias honoured by both functions (CRV1/CRV2/CRV3map tocluster=and the matching small-sample correction;iid/hetero/HC0–HC3map torobust=), and supplying it alongside a conflictingrobust=/cluster=/vce=raises instead of silently preferring one. Anyone who passedvcov=tosp.regressorsp.ivregshould re-run: the previously reported standard errors were wrong. -
sp.regress/sp.ivregsilently swallowed any unrecognised keyword. A misspelledrobsut="hc1"produced default standard errors with no error. Both now raiseTypeError, matchingsp.feolsandsp.did_2x2, which already did. -
Durbin-Wu-Hausman endogeneity test was NaN or silently wrong for integer-typed endogenous regressors.
_hausman_testaccumulated first-stage residuals intonp.empty_like(X_endog), which inherits the input dtype. With a 0/1 integer treatment — the common case, and exactly whatsp.ivreg("y ~ (d ~ z)")receives from a.astype(int)column — the residuals truncated to all-zero, the augmented design went singular, and the reported statistic wasNaN. With integer counts the truncation was partial and produced a finite but wrong F (4971.6 against a correct 5316.7) with nothing to signal it. Now computed in float regardless of input dtype; the float path is bit-identical to before. The same buffer pattern was hardened in the JIVE estimators, whose public wrappers already cast upstream (no numeric change there). sp.matchdefault standard errors change for nearest-neighbour matching.se_method='auto'resolved to'ai', the simple matched-pair SE; it now resolves to'abadie_imbens'. Point estimates are unchanged; standard errors get larger, by roughly 10% to 79%.
'ai' treats matched pairs as independent and ignores the extra
variance from reusing controls under matching with replacement. Measured
over 36 designs × 1000 replications
(benchmarks/matching_se_coverage.py), it never reached nominal
coverage in any cell — 0.71 to 0.92 against a nominal 0.95, running
0.56–0.91× the true sampling SD. 'abadie_imbens' is the only option
measured to be correctly sized (0.95–1.04×, coverage 0.905–0.956); Stata
psmatch2's own default errs the other way (1.50–1.69×, coverage
0.994–1.000).
Pass se_method='ai' to recover the previous numbers; it now warns
rather than passing silently. Unaffected: method='kernel'/'radius'
(already 'psmatch2'), method='llr' ('bootstrap'), and any explicit
se_method=. See MIGRATION.md.
sp.matchreported an ATT that silently covered only part of the treated sample. Treated units are dropped when the control pool is exhausted (replace=False), when a caliper admits no donor, or by common-support trimming — and the average over the survivors was returned as "the ATT" with no indication. On a 400-row design withn_matches=4, replace=Falseand 209 treated / 191 controls, 161 of 209 treated units (77%) got no match at all; the returned number averaged the remaining 23%, selected by matching order. Even 1:1 on the same data silently dropped 18.
sp.match now warns, names the cause, and gives the arithmetic
(replace=False needs n_matches × n_treated ≤ n_control).
model_info gains n_treated_unmatched and
n_treated_partially_matched. No numbers change — the estimand was
undisclosed, not miscomputed.
sp.psmatch2(...).psm_did(weight='fweight')computedaweightnumbers. The option handed_weighttosp.feols, which applies Stata aweight semantics (df = n_rows - k), while the docstring anddocs/guides/psm_did.mdboth advertised the Stata linereg y i.treat##i.post [fweight=_weight], whosedfisΣw - k. The coefficient was right; the standard error belonged to a different recipe than the one documented. On the new reference fixture the DiD SE is 0.250051 underaweightand 0.214797 underfweight— the advertised interval was ~14% narrower than the code produced.
weight= now takes 'aweight' | 'fweight' | 'none', and the default
moved from 'fweight' to 'aweight'. The two are numerically
identical to the old default, so no result from a default call
changes. An explicit weight='fweight' now genuinely computes Stata's
fweight degrees of freedom, implemented by physically replicating
rows — which is what a frequency weight means. Verified against Stata
18 MP: expand _weight + unweighted regress reproduces
regress [fweight=_weight] bit-for-bit, iid and under cluster().
Non-integer weights are refused exactly as Stata refuses them. See
MIGRATION.md.
-
docs/guides/psm_did.mdclaimedm.balance()reported "exactly what Statapstestreports". It does not:pstestkeeps the unmatched pooled SD in the denominator of the post-matching standardised bias, whilebalance()uses the matched-sample SD. On the reference fixture the post-matching figure forx1is 13.91 underpstestand 14.73 underbalance(). Both conventions are defensible; presenting one as the other is not. The guide now states the difference, and the newm.pstest()reproduces Stata's table exactly. -
sp.did_multiplegt_dyn's bootstrap used a different switch date than its point estimate. The estimate finds each unit's first treatment change in either direction (_first_switch); the cluster bootstrap re-derived it asmin(time | d == 1)— "first period treated", which is the first change only for switch-on units. A unit going 1 → 0 at F was therefore assigned_F= its own first period, which has no base period F−1, so it dropped out of every replicate.
Effect on non-absorbing panels (any panel with switch-off events):
switchers='out'-style samples returned a NaN standard error, and
the pooled bootstrap SE silently collapsed onto the switch-in-only SE
(0.182973 vs the correct 0.119610 on the new fixture) because the
switch-off units were broken in every draw. Point estimates were never
affected. Absorbing panels are bit-identical — there "first period
treated" is the first change — which is why the existing
DIDmultiplegtDYN parity suite never caught it. Fixed, and pinned by
tests/reference_parity/test_multiplegt_dyn_options_parity.py.
sp.sun_abrahamstandard errors were missing the cohort-share term. The IW estimator δ̂ℓ = Σ_g ŵ{g,ℓ} β̂_{g,ℓ} multiplies two estimated objects, so Sun & Abraham (2021) Prop. 3 gives it a two-part variance:w' Var(β̂) w + β' Var(ŵ) β. StatsPAI reported only the first part, understating the SE wherever more than one cohort contributes at a relative time. The omission was invisible at single-cohort event times, where Var(ŵ) is degenerate — onmpdtathe SE agreed with Stata to 0.02% there and drifted up to 2.0% at two-cohort event times. Fixed; per-event-time SEs at multi-cohort event times now rise slightly (~0.6–2% onmpdta), and the agreement with Stataeventstudyinteractis now a uniform 0.02–0.08% across all event times. Point estimates are unchanged.
Note that R fixest::sunab treats the shares as fixed and so reports
the smaller, first-term-only SE. StatsPAI deliberately follows
eventstudyinteract, Liyang Sun's own implementation of her paper.
See MIGRATION.md.
Added¶
-
DiD option depth vs Stata — one campaign, six estimators. Audited against the installed
.adohelp files rather than a summary, which overturned two commonly-repeated claims:csdid'sasinris a control-set convention for pre-treatment ATT(g,t), not an "as-if-random test", andlong/long2are base-period options already covered bybase_period=. -
sp.callaway_santanna:notyet_cutoff=('period' = Rdid/csdid, asinr, the default; 'cohort' =csdid's own default),estimator='stdipw'(explicit alias — StatsPAI's'ipw'always was the stabilized estimator, matching Rdid) and the genuinely newestimator='ipw_abadie'(Abadie 2005, which is whatcsdidcallsmethod(ipw)), pluspscore_trim=0.995matching DRDID'strim.level, which StatsPAI previously did not apply at all. Trimming that binds is counted indiagnostics['n_pscore_trimmed']and warns rather than acting silently. sp.sun_abraham:control_cohort=(Stataeventstudyinteract'scontrol_cohort()), accepting a 0/1 indicator column or a cohort value.sp.did_imputation/sp.bjs:unit_covariates=,time_covariates=andfe=(Stata'sunitcontrols(),timecontrols(),fe()), plusproject=(Stata'sproject()) for regressing the imputed effects on covariates. The projection reuses the estimator's own influence function and reduces exactly to the ATT when projecting on a constant.sp.did_multiplegt_dyn:switchers='in'|'out',same_switchers=andeffects_equal=, matching the current dCDH command (the review that prompted this asked for options from the deprecateddid_multiplegt).sp.sun_abrahamgains a joint pre-trend test — it had none — and both it andsp.callaway_santannagainpretest=/pretest_periods=. The test uses the full covariance across leads, not the diagonal.- One shared
se_method=vocabulary across the DiD family ('analytic','bootstrap','multiplier'/'wboot','auto'), layered over each estimator's native spelling (bstrap=,vce=) without moving any default.'auto'switches to a bootstrap at or below 30 clusters, the top of the range where Cameron, Gelbach & Miller (2008) document over-rejection.
All new paths are pinned against Stata 18 MP with reproducible
do-files under tests/stata_parity/ (82-85).
-
One precision vocabulary across every exporter, borrowed from the spellings Stata and R users already type.
sp.regtable,sp.esttab,sp.modelsummary,sp.sumstats,sp.mean_comparison,sp.outreg2,sp.etable,sp.fast.etableand the result-object exports all accept:digits=3(Rmodelsummary/stargazer),fmt="%.3f"(Stataesttab'sb(%9.3f)),fmt="r3"andfmt="s3"(Rfixest's round / significant-digit codes), andfmt="auto"(StatsPAI's journal-adaptive pairing). Passing bothfmtanddigitsraises. -
Tidy / glance frames pair their estimate and standard error.
.to_markdown()/.to_html()on a result object now resolve one decimal place per row, so a row reads13,387 844 [11,733, 15,040]instead of13386.6 843.643 11733.1..to_excel()deliberately keeps numeric cells at six decimals: a spreadsheet is a data-interchange target that gets sorted, charted and recomputed, not a presentation surface. -
Decimal-place control across the table family.
sp.regtablegainsse_fmt=(precision of the standard-error row alone, for deliberately breaking the coef/SE pairing),stats_fmt=(R² / adj. R² / F rows andtests=footer statistics, which previously ignoredfmtand were hard-pinned to"%.3f"), anddigits=(int alias:digits=3≡fmt="%.3f"; passing both raises).fmt=now accepts a plain int, sofmt=3works instead of raisingTypeError: unsupported operand type(s) for +=: 'float' and 'str'from inside the renderer. Invalid values are rejected at the call site with a message naming the parameter.sp.esttabandsp.modelsummaryacceptdigits=too. -
Journal templates now carry precision. All eight presets (
aer,qje,econometrica,restat,jf,aeja,jpe,restud) supplyfmt="auto", sotemplate=sets decimals along with star levels, the SE-row label, stats selection and notes. An explicitfmt=still wins. -
fmt/se_fmt/stats_fmt/digitsare registered.sp.function_schema("regtable")previously exposed 22 parameters and omitted precision entirely, so agents had no way to discover the knob.
Changed¶
-
sp.regtabledefaults tofmt="auto"(was"%.3f"), andsp.esttab/sp.modelsummaryfollow it (both were"%.4f"). For sub-unit coefficients the rendered output is byte-identical to the old"%.3f"default — the committed table snapshots did not move. It differs where fixed precision was actually wrong: a dollar-magnitude coefficient now reads2,108*** (472)instead of2108.412*** (471.938). -
SASP within-transformation replication:
sp.datasets.sasp_panel()andsp.replicate('sasp_within'). The real Survey of Adult Service Providers session panel (Cunningham & Kendall 2011, JUE 69(3)), used in Chapter 8 of the Mixtape to teach fixed effects.analytic_sample=Trueapplies the book's recipe (complete cases, then providers with exactly four sessions — 1028 rows, 257 providers). All three routes match Stata 18 MP to ~5e-10: pooled OLS 0.013407 (HC1 0.028300), within 0.051034 (cluster 0.028283), and manual demeaning viasp.demean, which reproducesxtreg, feexactly — standard error included. ShipsSASP_COVARIATESandSASP_TIME_INVARIANT: twelve provider-level controls have no within variation and are annihilated by demeaning.tests/reference_parity/test_sasp_within_parity.py. -
Thornton (2008) randomization-inference replication:
sp.datasets.thornton_hiv()andsp.replicate('thornton_2008'). The real Malawi HIV-incentive experiment (AER 98(5)). Group means, the simple difference (0.450551852) and the HC1 OLS standard error (0.020857971) all match Stata 18 MP exactly, andsp.ri_testreturns the same statistic with a permutation p-value.tests/reference_parity/test_thornton_ri_parity.py. -
Texas prison-expansion replication:
sp.datasets.texas_prison()andsp.replicate('texas_1993'). The real 51-state × 16-year panel (1985–2000) behind Chapter 10 of Cunningham's Causal Inference: The Mixtape, where Texas roughly doubled prison capacity from 1993. Shipped deliberately as a non-parity case: the book's recipe puts four lagged outcomes among the predictors, which leaves the predictor-weight matrix V weakly identified (Kaul et al. 2015) and makes classic SCM's nested V–W problem non-convex. Statasynthandsp.synthconverge to different local optima — Stata picks CA .408 / IL .360 / LA .122 / FL .109, StatsPAI picks FL .436 / NY .311 / IL .253 — yet the estimated effect agrees to ~3% (23,074 vs 23,779 mean 1994–2000 gap). StatsPAI reaches the lower pre-treatment RMSE (865 vs 1227) and returns the identical optimum at 4 and 40 random starts. The guide says plainly: report the effect, don't interpret the donor weights, and use the modern track or the outcome-only recipe (convex, unique solution) when a number has to reproduce across software.tests/reference_parity/test_texas_synth_parity.py. -
Castle-doctrine replication:
sp.datasets.castle_doctrine()andsp.replicate('castle_2013'). The real Cheng & Hoekstra (2013) panel (50 states × 11 years, 21 staggered adopters, 29 never-treated) — the Chapter 9 dataset of Cunningham's Causal Inference: The Mixtape — bundled as a 550×29 CSV. The 44 region × year dummies and 51 state linear trends are regenerated on demand (region_year_fe=True,state_trends=True) rather than shipped, andevent_time=Trueaddstime_til/gvar. Every number is pinned in CI against Stata 18 MP and Rdidintests/reference_parity/test_castle_stata_parity.py: four TWFE specifications (includingaweightsemantics and the 19 collinear drops in the saturated spec) agree to ≤1e-6;sp.bacon_decompositionreproduces Statabacondecompcell by cell across all 25 comparisons;sp.callaway_santannamatches Rdid::aggteand Statacsdidto 1e-9. New guide:docs/guides/mixtape_castle_replication.md. (refs verified via Crossref + doi.org content negotiation) -
Replication guides are now executed in CI.
tests/test_replication_guides_execute.pyruns every code block shipped bysp.replicate(...)against that entry's own dataset, so a guide that no longer runs fails the build instead of a user's session. -
Track A cross-language coverage: 61 → 68 Stata-referenced modules. Seven modules that previously had only a Python↔R comparison now carry a materialized Stata artifact, so the three-way harness covers 68 of the 81 Track A modules. Every one was measured, not assumed, against a licensed Stata 18 MP on 2026-08-06:
78_multiplegt_dyn(did_multiplegt_dyn, worst rel 2.1e-15 across the absorbing and switch-off designs),81_didm(did_multiplegt_old, 3.2e-15),73_did2s(did2s, 2.4e-12 — the Stata SE lands on R's, which localises this module's documented SE gap to a StatsPAI default rather than an R quirk),71_dml_family(ddmlconsuming the same explicit fold partition throughfoldvar(); IRM 2.1e-12, IIVM 3.6e-10, PLIV 6.9e-7 with ddml's second-stage intercept identified as the source),75_stacked(hand-built stack +reghdfe, 7.1e-13 — three independent stack constructions of an estimand with no packaged owner),76_pretrends(pretrends, 5.1e-4, inside the registered 1e-3 budget; the closed-form likelihood-ratio row agrees to 1e-15), and74_cic(cic,discrete_cicolumn — eight of nine deciles bit-identical toqte::CiC, withqte_50and the ATT registered inSTATA_HEADLINE_GAP_EXCEPTIONSas an inverse-CDF tie-break gap). Strictness tiers across all 81 modules are now 72 / 7 / 1 / 1 on the 81 R-joined modules (machine / iterative / moderate / methodological-T4). -
Track A Stata coverage: 68 → 75 of the 81 modules (second pass). Seven more bridges, and the py↔Stata comparison is now a contract rather than a display:
compare.py::stata_headline_auditfails a module whose Stata column exceeds its own registered tolerance unless the gap is registered with a measurement, and separately fails a module whose Stata artifact joins no headline row at all. 65_spatial(5.1e-8 on all 14 SAR/SEM/SDM parameters),66_spatial_gmm(7.3e-16 including standard errors, via an audited Mata GS2SLS bridge becausespregress, gs2slsuses a wider instrument set thanstsls(W2X=FALSE)),67_panel_glm(1.8e-9, point estimates only),68_demean_within(8.8e-15),69_balance_panel(exact),70_policy_tree(1.4e-16, depth 1; exact depth-2 needs policytree's incremental search and is not approximated), and72_tmle(1.9e-9 on psi, 1.4e-11 on the SE, via an audited Mata bridge — Stata ships no TMLE andeltmlewraps the same R package this module pins).20_bacondeepened from 3 to 9 joined rows:bacondecomp's per-pair detail matrix is now decoded into the six timing comparisons instead of only its aggregate.-
Six skips remain, each measured:
13_causal_forest(needs Stata 19'scate),18_augsynth/19_gsynth(measured convention mismatches), and77_ddd/79_didff/80_contdid(no Stata implementation). -
⚠️ Two silent under-comparisons found by the new contract.
34_lp's Stata artifact joined nothing: it emittedirf_direct_ols_h*rows that the R side did not, and the three-way join requires an R counterpart, so the whole Stata column fell out while the module still counted as having a Stata reference. The R side now computes the same direct-OLS Jörda regression and all six rows join at 4.4e-15.68_demean_withinsampled its middle observation at Python indexn//2but named it from R's 1-basedn %/% 2, so three of its ten statistics never joined in the py↔R comparison either; both sides now sample the same observation.tests/panel/test_demean_parity_within.pyencoded the same inconsistency (it hard-coded the R index under a comment claiming it was the Python one) and now checks the observation all three sides share. -
Every remaining
STATA_SKIP_REASONre-measured. Three reasons asserted that a package was "not installed in the verified local runtime" and two asserted that no Stata implementation existed; both classes were falsifiable by typingssc install. The thirteen surviving skips now state what was run and what disagreed, and thessc describereturn codes behind the "no Stata implementation" claims are recorded. - Reproducibility ledgers re-derived live rather than audited frozen.
The R leg now reports 81/81 modules reproducing (previously the committed
report covered 64) and the Stata leg 68/68, both with zero drift.
76_pretrendsgains a documented reproduction-tolerance override becausemvtnorm::pmvnorm's Genz-Bretz integrator is randomised inside the package's call path; its closed-form rows still reproduce to ~1e-15. -
Manuscript and cover-letter module counts corrected. The JSS paper described a 64-module R harness and a 61-module Stata bridge; the harness had grown to 81 and 68 without the prose following, so the paper was understating its own evidence.
-
sp.psmatch2(...).pstest()— Stata'spstesttable, digit for digit. Per-covariate means,%biasbefore/after,%reduct |bias|, t / p and V(T)/V(C) match Stata to 1e-14; the summary block (Ps R2, LR chi2, MeanBias, MedBias, Rubin's B and R with the 2001 flags) matches to 1e-9. Notablypstestfits its own probit — refit on the matched sample with[iw=_weight]— for the Rubin block, rather than reusing psmatch2's logit propensity score. -
llr_stata_compat=Truereproduces Stata'spsmatch2 ..., llrsubstitution exactly, for reconciling a published psmatch2 number. With its default Epanechnikov kernel psmatch2 does not run LLR: it smooths the outcome withlpoly, deg(1)fitted on the on-support controls, then runs nearest-neighbour matching — so a treated unit's raw outcome is compared with its match's smoothed one. Matches Stata to 6.4e-12 (ATT) and 9.9e-09 (SE). The new_stata_lpolymodule reproduces Stata'slpolyto 2.6e-14; note its Epanechnikov is the unit-variance one (support ±√5), not psmatch2's compactepan, which would rescale the bandwidth by √5. -
method='llr': local linear regression matching (Heckman, Ichimura & Todd 1997), matching Stata psmatch2 to ~4e-11 across the tricube / biweight / normal / uniform kernels, with_weightand_yagreeing row for row. Two psmatch2 behaviours are now documented rather than inherited: Stata reports no analytic SE for LLR (seatt = .), andpsmatch2 ..., llrwith its default Epanechnikov kernel does not run LLR at all — it substitutes nearest-neighbour matching on anlpoly-smoothed outcome. StatsPAI runs genuine LLR and warns about the divergence. -
se_method='bootstrap'onsp.match/se='bootstrap'onsp.psmatch2: arm-stratified resampling that re-estimates the propensity score every replication, so unlike every analytic option it accounts for the fitted score's sampling variability. Seeded and reproducible. It is the default formethod='llr', and warns formethod='nearest'per Abadie & Imbens (2008). -
matched_dataforestimand='ATE',method='stratify'andmethod='cem'. These three previously returnedNone— the latter two with no explanation at all. ATE emits the Abadie-Imbens weight1 + K_M(i)(a signed-sum weight, flagged as not a frequency weight); stratification and CEM emit cell weights plus a_stratumcolumn. Every frame reproduces its own estimator's point estimate to machine precision. -
method='mahalanobis'throughsp.psmatch2now actually uses the Mahalanobis metric; the explicitdistance='propensity'default was overridingsp.match's legacy alias, so it silently matched on the propensity score. -
Continuous AI-label measurement-error correction.
sp.llm_annotator_correctnow handles continuous LLM scores (e.g. sentiment in[-1, 1]) alongside the existing binary / multi-class paths: regression calibration on the human-audited subsample (Fuller 1987; Carroll et al. 2006), which reduces to the classical reliability-ratio correctionβ/λwhen no covariates are present.methodgains'auto'(new default — routes discrete labels to Hausman / confusion-matrix, continuous scores to calibration) and'reliability'; discrete inputs behave exactly as before. The bias-corrected bootstrap covers the new path too. (refs verified via Crossref + publisher pages) -
Prediction-powered inference:
sp.ppi_mean/sp.ppi_ols. Valid CIs that combine a small human-labeled sample with a large ML/LLM-predicted sample (Angelopoulos et al. 2023, Science, doi:10.1126/science.adi6000), with PPI++ power tuning (arXiv:2311.01453) on by default — junk predictions collapse to classical labeled-only inference instead of corrupting it. (refs verified via Crossref + arXiv) -
CausalForest.ate()/.att()now carry inference. They returnsp.ScalarEffect— afloatsubclass whose value is bit-identical to the historical plug-in mean CATE (arithmetic,float(), and format specs unchanged) but which prints with the GRF-style doubly-robust SE / 95% CI / p-value and exposes them as.se/.ci/.pvalue/.detail. Previouslyprint(cf.ate(X))showed a bare number with no inference at all. -
Labeled matrix-completion output.
sp.matrix_completion/sp.mc_panelresults now shipmodel_info['completed_df'](a units × periods DataFrame),units/periods/treated_units, andcounterfactual(completed rows for the treated units) — no more guessing which raw matrix row is the treated unit (row order follows the pivot's lexicographic unit sort, which silently differs from insertion order for labels like省1/省10/省2). -
Canonical
y/d/xparameter aliases. One agent-friendly vocabulary across estimators, additive only:tarnet/cfrnet/dragonnet/metalearneracceptd=(treat) andx=(covariates);did_2x2acceptsd=;lasso_ivacceptsd=(x_endog, str or list);synthacceptsy=(outcome); andcausal_forestgains a column-name interface (data=df, y=..., d=..., x=[...]) beside its formula and array interfaces. Supplying both spellings with conflicting values raises. -
Uniform CATE access.
CausalResult.cateandCausalResult.effect(X=None)mirrorCausalForest.effect();tarnet/cfrnet/dragonnetresults can now predict effects for new covariates via the attached fitted network.cate_summary/cate_by_group/cate_plotaccept a fittedcausal_forestmodel or a raw effect array, not justCausalResult. -
Panel-counterfactual conveniences on results.
result.weights(donor weights as a DataFrame),result.counterfactual()(synthetic / completed outcome path with a time index), andresult.gaps(treated − counterfactual) work uniformly onsp.synthandsp.matrix_completionresults.EconometricResults.coef(term)returns one coefficient's full inference row without thetidy().set_index().loc[...]dance;CausalResult.coefis a float-compatibleScalarEffectcarrying the headline effect's SE / CI / p-value (duck-typing probes likefloat(getattr(res, "coef", res.estimate))keep working). -
sp.policy_targeting— budget-constrained treatment targeting from CATE estimates (rank by predicted effect, treat until the budget or themin_effectguard binds), with treat-all and random-assignment baselines. Accepts raw arrays, CATE-bearing results, or fitted forests. -
Event-study rows lost their event time in
tidy(). Every row of a dynamicsp.aggte(..., type="dynamic")result was labelledatt(g=,t=)— the group-time template applied to a frame that has neither column — so the table could not be read or plotted without going back to.detail. Labels are now derived from the columns the detail frame actually carries:event_-3/event_+2for dynamic,att(g=2004)for group,att(t=2007)for calendar, and the existingatt(g=…,t=…)for full group-time results (unchanged). -
summary()dumped whole pandas Series into the footer. Onesp.sdidsummary ran to 110 lines, ~90 of them three raw Series (time weights, observed path, synthetic path) printed row by row. Vector-valuedmodel_infoentries now render as<31 values; see .model_info["Y_synth"]>; the data itself is unchanged and still reachable. SDID: 110 → 25 lines. -
Solver telemetry no longer crowds out the result.
sp.synthprinted elevenSolver Near Best Weight L1 Max-style internals. These stay inmodel_infofor auditing but are collapsed to a disclosed count ((11 solver/diagnostic entries hidden; see .model_info)); the substantiveWeight Solution Nonuniquedisclosure stays visible. SCM: 52 → 42 lines. -
sp.love_plotaccepts the matching result that produced it.love_plot(m)now reads the treatment column, covariate list, and matched weights off a fittedsp.psmatch2/sp.matchresult instead of requiring the caller to restate all three (and risk a mismatch between the plot and the estimate it documents). Raw-DataFrame calls are unchanged; explicittreatment=/covariates=still win. -
sp.psmatch2summary saidCommon support: none, which reads as "no overlap was found" rather than "no support restriction was imposed". Now spelled out. -
DML summary footer labels are readable.
Ml G:→ML model for g(X) — outcome,Ml M:→ML model for m(X) — treatment,Dml Model→DML model; private (underscore-prefixed)model_infokeys such as_pscoreno longer leak into the footer.
Fixed¶
-
128 registry entries hid parameters from
sp.function_schema(). The agent-facing schema is built from hand-writtenparams=lists that the auto-builder deliberately never overwrites, so adding an argument to a signature left it invisible to agent callers — silently, with no warning.callaway_santanna,sun_abraham,did_imputationanddid_multiplegt_dynare now complete (this also surfaceddid_imputation's long-missingvce,n_boot,boot_seedandsun_abraham'saggregation). The remaining 124 entries — 501 hidden parameters, includingregress'svce/weights/ Conley options — are frozen inscripts/registry_param_drift_baseline.jsonand ratcheted bytests/test_registry_param_drift.py, so the debt can shrink but not grow. -
sp.sbw(...).solver_statusreported the estimand, not the solver. The field was assigned the literal"att"/"atc"/"ate", so a caller asking whether the optimiser converged got a string that could never answer, while the actualscipy.optimize.minimizeoutcome was computed and discarded — including whether the loosened-ftolretry succeeded, which exists precisely because SLSQP does fail on this problem. It now holds"optimal"or a message naming why not (forestimand='ate', which runs two solves, the worse of the two). Diagnostics only — no weight or estimate changes, and_solve_sbwstill raises if the balance constraints are violated, so a returned solution was always feasible. The estimand remains onresult.estimandand inresult.method. SeeMIGRATION.md. -
sp.genmatchadvertised a bootstrap standard error it never computed. The module docstring andGenMatchResultboth calledatt_sea "bootstrap SE"; the code computessd(Y_t - Y_c) / sqrt(n_pairs), the matched-pair SE, and there is no bootstrap in the file. The distinction is not cosmetic: genetic matching matches with replacement (on the test fixture, 142 matches drew on 81 unique controls, one control serving 12 treated units), so the formula treats dependent pairs as independent. The same formula measured onsp.matchover 36 designs × 1000 replications (benchmarks/matching_se_coverage.py) runs 0.56–0.91× the true sampling SD, with coverage 0.71–0.92 against a nominal 0.95. The docstrings are corrected with a warning block stating whatatt_seconditions on, and aUserWarningnow fires when controls are reused, quoting that run's own reuse count. No numbers change — the interval was mislabelled, not miscomputed; treatatt_seas a lower bound and usesp.match(se_method='abadie_imbens')or a bootstrap of the whole pipeline for inference that covers. -
Two matching exception handlers swallowed everything.
pstest's balance probit caught every exception where only an unfittable fit (singular design, perfect separation once the weights concentrate) should be reported as missing; andsp.match's bootstrap loop counted any exception as a degenerate resample, so anAttributeErrorfrom a refactor would have been absorbed into the failed-replication tally. Both now catch their real cases (LinAlgError/ValueError/ZeroDivisionError/PerfectSeparationError, and StatsPAI's typed errors plus linear-algebra failures respectively). No behaviour change on paths that already worked — an unexpected exception now surfaces instead of being miscounted. -
sp.ri_testdropped rows with missing values silently. Passingcluster=where the cluster id is itself missing shrank the sample without saying so, and the observed statistic then referred to a different subsample than the unclustered call — which reads as a discrepancy rather than a smaller sample. On Thornton (2008) four of 2834 rows have no village id, moving the statistic from 0.450552 to 0.451982. It now warns with the counts, matching whatsp.callaway_santannaalready did. No numbers change; the drop was always happening. -
sp.rd_honest's schema advertised an option the function rejects. The registry listedopt_criterionchoices as["mse", "fwer"], but the callable only accepts"mse"/"flci"/"oci"— an agent that readsp.function_schema("rd_honest")and passed"fwer"got aValueError. Corrected to the accepted set. Thesclassparameter (Holder vs. Taylor smoothness class, added with the WP-6 interval fix) was also missing from the spec and the docstring entirely; both now document it. A new test (tests/test_rd_validation.py) runs every enumerated choice the schema advertises through the estimator, so an agent-facing enum can no longer drift away from what the function accepts. -
sp.lsdvc(bias_order=3)crashed on NumPy ≥ 2.5. The third-order Bun-Kiviet term converted a(1, 1)array with barefloat(...), which NumPy 2.5 turned from aDeprecationWarningintoTypeError: only 0-dimensional arrays can be converted to Python scalars. Now uses.item(), matching the adjacent first- and second-order terms. Caught by the pandas-3 CI lane, which tracks the newest NumPy. -
The recommendation hit-rate ratchet had been red since 2026-08-01. The corpus entry for
lalonde_1986_nsw_experimentallistededucationamong its covariates, butsp.datasets.nsw_lalonde()ships theMatchIt::lalondeextract whose schooling column iseduc(onlysp.datasets.nsw_dw()spells iteducation), so every audit-dynamic fit raisedMethodIncompatibility. The--checkgate also failed silently on that condition — only the hit-rate floor printed a reason — so the CI log named neither the gate nor the offending entry. Every gate now reports what tripped it and which corpus entries are responsible. -
The JSS validation-boundary census had drifted out of the docs.
docs/jss_source_audit_dossier.mdanddocs/guides/stability.mdstill quoted the pre-1.21.0 registry (1,154 functions across 86 submodules; 73certified/ 298validated/ 780api_stable/ 3experimental; 371 certified/validated; 670 unbacked), while the live registry reports 1,157 across 87 and 76 / 303 / 775 / 3, giving 379 certified/validated against 666 stable auto-registered symbols. Every count is re-derived fromscripts/stability_audit.pyandvalidation_evidence_audit.pyrather than hand-carried. The stability guide also still named1.20.0as the snapshot's package metadata; the manifest reports1.21.0. -
⚠️ Correctness fix — a NaN outcome returned ATT = 0.0000 with SE = 0.0000 and p = 1.0000 instead of raising.
sp.callaway_santanna,sp.sun_abraham,sp.did_2x2,sp.did_imputation, andsp.etwfereshape the long panel withpivot_table, which drops entirely-NaN rows and columns outright. A cohort or period whose outcome had been wiped — the usual cause being a failed upstream merge — therefore left a panel that looked perfectly balanced: no NaN cell survived for the unbalanced-panel warning to count, so it never fired. Every ATT(g, t) then lost its cell and contributed 0.0, and the headline came back as a precisely-estimated null withn_obsstill reporting the full row count. The realistic case is the dangerous one: wiping only the treated cohort's outcome producedATT = 0.0000, SE = 0.0000, p = 1.0000, n = 320with no warning of any kind. All five estimators now drop unusable rows up front through a shareddid/_core.drop_unusable_rowsguard and raiseDataInsufficientwhen none survive; partial missingness warns with the per-column NaN counts and estimates on the remainder.sp.etwfeadditionally raises when cohorts exist but no treated post-treatment cell does, which previously produced the same silent 0.0 through a degenerate all-zero interaction dummy. A fully-NaN covariate was the quieter twin of the same defect — it dropped out of the regression and returned the unadjusted estimate while the caller believed they had adjusted for it — and now raises as well; a partially-NaN covariate previously returned a bareNaNand now warns and estimates on the complete rows. Estimates and standard errors on healthy data are bit-identical across every estimator, control group, and inference option; the only reporting change is thatn_obsnow counts the actual estimation sample rather than rows that never entered it. (CLAUDE.md §7: fail loudly.)
[1.21.0] — 2026-08-03¶
Changed¶
- The JSS census pins now reconcile instead of freezing a literal.
tests/test_jss_release_manifest.pyasserted exact tier counts and had to be re-pinned three times in one day — every symbol promoted fromapi_stabletovalidated, which is unambiguously good news for the paper, arrived as a red test. The literals were also redundant: what actually guards the manuscript's claims isvalidate_claims.py, which diffs the prose against the same live computation.
The test now asserts the census closes — the four tiers partition
sp.list_functions() exactly, and certified_validated equals
certified + validated — plus the assertions that were always the point:
status == PASS, and zero symbols carrying a grade their evidence does
not support. symbols_with_limitations is asserted non-zero rather than
exact, so the manuscript's candour about scope cannot quietly evaporate
without a failure.
Census re-synced to the live registry: 1,154 functions, 73
certified / 298 validated / 780 api_stable / 3
experimental, scoped-validation surface 371, harsh denominator
670, 306 registry-evidence source files. Verified independently
via sp.describe_function (73+298+780+3 = 1,154).
Changed¶
sp.continuous_did(method="cgs")is deprecated. It was an MVP standing in for an estimator StatsPAI did not have: outcome regression only, a bootstrap standard error, and formula details left as[待核验].sp.cgs_continuous_didis the real thing and is pinned against the authors' package. The mode warns and will be removed after one minor release; see MIGRATION.md.
⚠️ Correctness¶
-
sp.did_multiplegthad three defects, all found by finally getting a working reference. The estimator was carried as unpinnable because the CRAN package's 2.x rewrite returnsNaNfrommode="old"even on its own bundled example. That is true — and it is not the whole story: the archived 0.1.4, the release the estimator originally shipped in, works fine. Pinned against it (Track A module81_didm), the static DID_M effect was already bit-exact. The other two paths were not. -
Dynamic effects at horizon ≥ 1 counted switchers who switched again inside the window. "The effect of having switched ℓ periods ago" is not defined for a unit that switched again at ℓ−1, and the reference excludes them. On the fixture this moved the horizon-1 effect from 0.9974 to 1.2146 and the switcher count from 175 to 140.
- Placebos ignored the pre-window stability condition — units already moving before the switch were counted as clean pre-trend observations. Invisible on absorbing panels, which is why it survived.
Both now match the reference to 5e-15.
A third thing looked like a defect and is not. The placebo's sign
differs between dCDH's own two implementations: on did::mpdta both give
|placebo_1| = 0.024269 and their three effects agree to six decimals,
but Stata's did_multiplegt_old reports +0.024269 and DIDmultiplegt
0.1.4 reports -0.024269. An intermediate pass here treated R as
authoritative and flipped the default, which would have silently moved
every placebo StatsPAI had reported. The new placebo_sign parameter
selects the convention, the default keeps Stata's, and a test pins that
the two are exact negatives so neither side can drift.
sp.did_multiplegt_dynsilently dropped switch-off events. Treatment turning off is a switch, and handling both directions is the whole reason the dCDH design exists — so dropping those events did not merely lose precision, it changed the estimand on any non-absorbing panel. Now handled the way the reference does: a switch-off unit's controls must share its baseline treatment level (units that were on and stayed on, not the never-treated), and the difference is divided by the change in treatment so both directions measure the same effect per unit of treatment.
Track A module 78_multiplegt_dyn now carries two designs, absorbing and
switching, and every effect, placebo, aggregate and switcher count
matches at 5e-15 in both. Absorbing panels are unaffected — verified, since
with a binary treatment every control already shares the baseline of zero.
This closes the [待核验] marker on switch-off; the paper's own variance
formula is what remains.
- All eight bool-typed
robust=sites now reject the string form; seven of them used to accept it silently.sp.interactive_feandsp.mixlogitwere the last two, and they close the list named instatspai._house_style.ROBUST_BOOL_HINTS.
The hazard, once more because it is worth stating plainly:
robust="cluster" was read as truthy and the request disappeared.
Clustering lives on a separate cluster= argument on every one of these
estimators, so the caller received unclustered standard errors from a
call that looked like it asked for clustered ones. Correct numbers for
what was computed; not the numbers that were asked for; nothing printed.
Only sp.did had the guard. sp.ddd, sp.did_2x2, sp.did_analysis,
sp.xtabond, sp.xtdpdsys, sp.interactive_fe and sp.mixlogit did not.
New tests/test_robust_flag_contract.py (98 tests) drives the contract
off ROBUST_BOOL_HINTS itself and asserts the two lists agree, so a ninth
bool-typed robust= site either implements the guard or fails the suite.
Changed¶
- One implementation of the flag validator, in
statspai/core/_validate.py. It had reached five copies acrossdid/__init__.py,did/callaway_santanna.py,did/_core.py,gmm/arellano_bond.pyandbartik/political.py.core/_vcov.pyis the cross-package home its own header claims for correctness-sensitive basics (§4); the DiD and dynamic-panel families now alias it, and a test pins that they do.
⚠️ Correctness¶
sp.xtabond/sp.xtdpdsyssilently ignored a stringrobust=.robustis a boolean on the dynamic-panel family and a string HC-type selector on the regression family (robust="HC1"). Passing the string form here was accepted, evaluated as truthy, and returned the default Windmeijer-corrected sandwich — the caller asked for HC1 and got something else, with no warning.
The damaging case is robust="cluster". Clustering is a separate
cluster= argument, so a user who wrote robust="cluster" received
unclustered standard errors and no indication the request had been
dropped. Reported SEs were simply not the ones asked for.
Both entry points now raise ValueError naming the actual alternative
(cluster=<column>). sp.did has carried the equivalent guard for
longer; statspai._house_style.ROBUST_BOOL_HINTS documents this
bool-vs-string split as the highest-impact hazard in the signature
surface, and these two were listed in it while being unguarded.
Behaviour change: robust=1 / robust=0 now raise as well, matching
sp.did's _require_bool. Numerical output for robust=True /
robust=False is unchanged — this converts a silent wrong answer into a
loud one.
Not audited here: the other five entries in ROBUST_BOOL_HINTS (ddd,
did_2x2, did_analysis, interactive_fe, mixlogit). A first probe
did not reach their robust validation because their signatures differ,
so their status is unknown, not clean.
- sp.ddd, sp.did_2x2 and sp.did_analysis silently ignored a string
robust=, and sp.check_absorbing's registry entry still named the old
parameter. Both found by probing rather than by a failing test.
robust is boolean across the DiD family and a string HC-type selector on
the regression family. Passing robust="cluster" to these three was
accepted, read as truthy, and returned unclustered standard errors —
clustering lives on a separate cluster= argument, so the request simply
vanished. The output looked entirely normal. sp.did had rejected
non-booleans for some time, so the guarantee was inconsistent within one
module.
All three now raise, and the message names cluster=<column> as the real
alternative. robust=1 / robust=0 raise too, matching sp.did.
Separately, the check_absorbing(treatment=) → treat= rename shipped
without updating the function's ParamSpec in the registry, so
sp.function_schema("check_absorbing") advertised a parameter that no
longer existed and omitted the one that did —
test_registry_signature_contract caught it on the next broad run. Fixed,
with the schemas bundle regenerated.
Changed¶
_require_boolconsolidated intostatspai/did/_core.py. Two byte-identical private copies existed indid/__init__.pyanddid/callaway_santanna.py, which is what §4 forbids; both now alias the shared helper, and a test asserts there is exactly one implementation. This is the "separate, test-guarded pass"_core.py's own header asks for.
Changed¶
-
⚠️
sp.check_absorbing(treatment=)renamed totreat=. The signature house-style ratchet flagged it as a new legacy-spelling site;treatis the canonical spelling (statspai._house_style.CANONICAL_TREATMENT). Renamed outright rather than aliased because the function has not shipped in a release yet — it is new in this Unreleased cycle, so no deprecation window is owed.sp.recommend's owntreatment=is unchanged. -
Signature house-style baseline:
se58 → 60, with the reason recorded in the baseline file. Two new sites, both introduced by this line of work:sp.gmm(vcov=)andsp.xtdpdsys(robust=). Neither was renamed, and the reasoning matters more than the number: each matches the spelling its own family already uses —sp.feols(vcov=)andsp.xtabond(robust=)— so renaming them in isolation would trade a house-style violation for a sibling inconsistency, which is the worse defect from a user's point of view.xtdpdsysis already registered in_house_style.ROBUST_BOOL_HINTSas awaiting the coordinatedvce=convergence; the floor should come back down when that lands, not before.
Changed¶
-
The JSS release-manifest tests pinned a census three drifts behind.
tests/test_jss_release_manifest.pyasserted 66certified/ 276validated/ 800api_stableand a 342-symbol scoped-validation surface. Live: 70 / 297 / 783 and 367, plussymbols_with_limitations9 → 13. The pin is deliberate — it is a ratchet on what the paper claims — so it is re-pinned to the verified live values rather than loosened. -
Registry-census numbers in the stability guide and JSS source dossier re-synced to the live registry. The published census had drifted a full tier cycle behind: 1,145 → 1,153 registered public functions, and the tier split moved from 66/276/800/3 to 70
certified/ 297validated/ 783api_stable/ 3experimental. The scoped-validation surface is therefore 367 symbols, not 342, and the harsh denominator 671, not - Verified independently against
sp.describe_functionbefore editing: 70+297+783+3 = 1,153 reconciles exactly.
Note the direction — 17 symbols moved out of api_stable into
certified/validated while the surface grew by 8. The stale numbers were
understating the package's own evidence coverage.
One correction is not a re-sync: docs/jss_source_audit_dossier.md gave
282 registry-evidence source files for both the submitted archive and
the live inventory. Those are different quantities — the archive carries
152, the live validation-note inventory 305. The single stale
number was overstating the archive's evidence footprint by roughly 2×.
⚠️ Correctness¶
-
sp.rdrobustreported 12.39 where Rrdrobustreports 7.41 on rdrobust's ownrdrobust_RDsenatewith default settings — a 67% overstatement of the headline RD effect. Five independent defects: the MSE bandwidth formula hard-coded a1/5exponent, which equals CCT's1/(2p+3)only atp=1(sohwas identical for p=1 and p=2, and 2.8–4.8× too narrow); the bias bandwidthbwas never computed (b == hin 36/36 cells);bwselect='msesum'/'cersum'raisedValueError; and the bias-corrected estimate was aq-order refit onbrather than CCT'stau_p(h)minus an estimated bias term — a different estimand. All now match rdrobust 4.0.0 across abwselect × p × kernelgrid (36 cells):h5.6e-08,b1.5e-08, conventional coefficient 4.0e-12 and SE 3.6e-12, bias-corrected coefficient 4.4e-12, robust SE 3.6e-12, robust CI 6.2e-12. Twoexternal_paritytests had pinned the defect as a published value while contradicting the document they cite:PUBLISHED_REFERENCE_VALUES.mdrecords Lee (2008) Table 2 as 0.080, and the test pinned 0.0616 (−23%); a second pinned 0.073 with the comment "paper 0.077". They now read 0.0768 and 0.077545 against R's 0.076339 and 0.077547. New parity suites:tests/reference_parity/test_rdrobust_parity.py,test_rdrobust_params_parity.py. See MIGRATION. -
sp.rdrobustgainsvce=, andcluster=now enters the bandwidth. R exposesvcein {nn,hc0--hc3,cr1--cr3}; StatsPAI exposed none of it, so an R script settingvce=did not port and there was no way to ask for anything but the nearest-neighbour variance. All five non-cluster kinds are now accepted, andcluster=is threaded through the CCT cascade rather than being applied to inference alone.
All 17 cells of the new tests/reference_parity/test_rd_vce_parity.py
agree with R rdrobust 4.0.0 to <= 5.5e-11 on h, b, both
coefficients and both SEs. Three defects had to land together, and each
was invisible until the one before it was gone:
- The regularisation term
Rin the cascade is a sandwich variance too. Leaving it onnnresiduals whileVusedhc*lefth8e-3 off withVandBboth already exact to 1e-10 -- small enough to read as accumulated float error rather than a bug. cluster+nnresiduals understate the SE ~10x (0.077 against R's 0.702). R silently promotesnn/hc0/hc1+clustertocr1, whose residuals arehc1's; nearest-neighbour differencing removes exactly the within-cluster correlation a clustered variance exists to capture. StatsPAI now makes the same substitution, and documents it.gammais pooled across sides in the estimator (ZWZ_p = ZWZ_p_l + ZWZ_p_r) while the bandwidth cascade solves it per side. Solving per side in both left the covariate-adjusted estimate 1.2e-3 off with the bandwidth already exact at 4e-14.
Also fixed: k_override = q + 1 on the clustered robust variance (worth
6e-4 on the SE), and vce is validated in sp.rdrobust itself rather
than in the CCT helper, whose except ValueError fallback would have
turned a typo into a silent switch to a different variance estimator.
⚠️ correctness fix: sp.rdrobust(..., cluster=...) previously
reported both bandwidths and both standard errors incorrectly. Users who
pinned clustered RD numbers from 1.20.x or earlier should re-run.
sp.rddensityis anchored to Rrddensity2.6 (WP-3). Unlike therdrobustcascade, this module was already correct -- it was simply unanchored, so a refactor could have shifted its numbers with nothing to notice. Six cells (three designs xpin {2, 3}) now pin the manipulation statistic, its p-value, both bandwidths, both one-sided densities and both effective sample sizes: max relative deviation 2.2e-08.
The three designs are chosen so the suite can fail in both directions --
a smooth N(0,1) that a trigger-happy test would reject, and one with 45%
of the mass just left of the cutoff deleted that a dead test would miss --
and test_design_is_discriminating asserts R itself separates them.
One gap surfaced: model_info reported n_left/n_right as full-side
counts while R reports the counts inside the bandwidth. On the senate data
those differ by a factor of five, so anyone reading them as the effective
sample was overstating it. Added n_eff_left/n_eff_right, which match R
exactly; the existing keys keep their meaning.
-
Local randomization and power anchored to rdlocrand 2.0 / rdpower 3.0 (WP-4). Writing the parity suite surfaced three defects in quantities no test had looked at:
-
⚠️ correctness fix:
sp.rdrandinf's asymptotic p-value used the wrong reference distribution. The standard error is Welch's, but the p-value referred it to atwithn1 + n0 - 2degrees of freedom -- pooled df for an unpooled SE, which is not any single test. rdlocrand uses the normal, which is also what asymptotic means. Onrdsenateat w = ±5 the old form reported 1.68e-10 where rdlocrand reports 2.49e-11, a factor of 6.7. Now exact. - ⚠️ correctness fix:
statistic='ranksum'was a different statistic. StatsPAI returned|U − μ| / σfrom scipy's Mann-Whitney U with the no-ties variance; rdlocrand uses the standardised rank sum of the control group with the empirical rank variances², which is tie-robust. The senate running variable is heavily tied, so the two differed by 2–3x, and the sign was discarded as well. sp.rdpowergained a data mode. R'srdpower(data = ...)runsrdrobustinternally and uses its robust bias-corrected SE; StatsPAI offered only a design-stage calculator with a different parameterisation, so R's numbers were simply unreachable. Data mode agrees to 4.2e-14 -- possible only because the WP-1/WP-2 cascade work madese_robustitself exact. Design mode is untouched.
Also: statistic='ttest' is now accepted as an alias for 'diffmeans',
as in rdlocrand where both names select the same branch.
The suite deliberately does not pin the randomization p-value: it is an RNG draw and R's stream is not reproducible from Python, so asserting equality would be pinning noise. It is checked by its sampling behaviour instead.
sp.rdmcgainscutoff_var=, the designrdmulti::rdmc()actually implements (WP-5). The docstring claimed equivalence tordmulti::rdmc()while the function could not express that estimator's central argument. R'srdmctakesC, a per-unit cutoff: in the multi-cutoff design each unit faces its own threshold, and cutoffc's effect is identified only from the units assigned toc. StatsPAI offered only a shared-running-variable estimator where every unit enters every cutoff's local regression, under one Silverman rule-of-thumb bandwidth.
These are different estimators and the gap is large. On a design with effects of 2.0 / 5.0 / −3.0, the shared path returned 0.22 / 0.51 / 0.66 — units belonging to one cutoff were pooled into the others' windows, averaging the effects away and losing even the sign of the third.
cutoff_var= delegates each cutoff to sp.rdrobust, so it inherits the
CCT cascade: coefficients, robust SEs, per-cutoff bandwidths, effective
sample sizes and pooling weights all match R to ~1e-13 (bandwidths to
2.4e-08). Pooling follows rdmulti — weights are Nh_c / ΣNh, not inverse
variance. The shared-running-variable path is unchanged and still
reachable via cutoffs=; the docstring now states plainly which is which.
-
⚠️ correctness fix:
sp.rd_honestreported intervals ~1.7x too wide (WP-6). Now anchored to RRDHonest1.0.1.9000 across 18 cells, max relative deviation 7.6e-08. The point estimate was always right; the interval was built from two approximations that are not the Armstrong-Kolesár construction: -
The worst-case bias was a closed form,
M · h² · C_kernel. The honest bias depends on the realised kernel weights, so no function ofhalone can be it. It ran 1.58–1.60x too large, and the ratio moved withh— the signature of a quantity that is design-dependent on one side of the comparison and not the other. The correct Hölder bound isM/2 · |Σ_{x<0} wᵢxᵢ² − Σ_{x≥0} wᵢxᵢ²|. - The interval double-counted the bias: it used
τ ± (cv·se + bias)wherecv = cv_{1−α}(bias/se)already accounts for it. Atbias/se = 1.3the correct half-length is2.95·se; the old form gave6.24·se.
Net effect on the two reference designs: half-lengths of 0.199 and 5.511 where RDHonest gives 0.120 and 3.155. Being too wide is not a safe failure — it is a different, less informative procedure reported under Armstrong-Kolesár's name.
The p-value was also the naive 2(1 − Φ(|τ|/se)), which ignores the
very bias the rest of the function exists to bound, so a result could
show an honest CI containing zero next to p < 0.05. It is now obtained
by inverting the honest interval, and a test asserts the two agree at
α ∈ {0.01, 0.05, 0.10}.
New: sclass= selects the Hölder (default, matching RDHonest) or Taylor
smoothness class; opt_criterion accepts 'oci' alongside 'mse' and
'flci'; model_info carries eff_obs and sclass. The bandwidth
selector now reproduces RDHonest's full pilot chain
(OptBW → PrelimVar(EHW) → IKBW → PrelimVar(Silverman)), and M its
rule of thumb, both exactly.
Two pinned values in tests/test_cov95_rd_misc.py recorded the old
output; they were re-derived by running RDHonest on that same design
rather than by accepting whatever the new code produced.
Known issues¶
- Fuzzy RD does not go through the CCT path.
sp.rdrobust(fuzzy=...)falls back to the legacyq-order refit and rescales it by the first stage; R applies the bias-correction operator to the outcome and the treatment jointly under a shareds_Y. Pinned asxfail(strict)intest_rdrobust_params_parity.pyso it turns into a failure the moment it starts working.
Fixed¶
- ⚠️ §10: the citation auditor could not see the shape this repo writes
citations in. A full run over
src/anddocs/now reports 596 OK / 0 mismatch across all 238 unique arXiv, NBER and DOI identifiers, every one resolved against its primary source. Getting there required fixing the auditor, not the citations — and the most important fix explains how three fabricated attributions survived it for two months.
diff_citation correctly identified the phantom surname, then discarded it
for not being "in author position". The position test accepted
Surname, J., Surname & Other, Surname and Other, Surname et al. and
Surname ( — but not Surname YEAR, which is exactly StatsPAI's house
style: (Kiciman-Sharma 2025, arXiv 2402.11068), (Sharma-Xue 2025),
(Wüthrich-Zhu 2025, arXiv 2505.09706). All three June 2026 fabrications
had that shape. A regression test pins it.
Precision fixes, each paired with a test proving the fabrication it must not suppress is still caught:
- transliteration folding (
ß→ss,ø→o,ı→i, …) — arXiv writes "Hess" where OpenAlex andpaper.bibwrite "Heß", so a correctly cited name was reported as both missing and phantom; - markdown emphasis spans stripped like quoted ones, so an italicised
title in a
docs/guidesreference list stops donating words like "Landscape" to the author position; - CJK clause separators (
、/。/;) narrow the claim block, so a Chinese planning doc listing eight unrelated authors above an arXiv id no longer attributes them to it; - non-adjacent parenthesised citations dropped —
(Lei-Candes 2021)two clauses away belongs to a different paper — while the parenthetical next to the id is kept and checked; - an unclosed quote/emphasis marker means the span runs past the id, so a retraction note quoting the error it retracts is no longer flagged as committing it;
- all-caps tokens treated as acronyms (
IEEE DSAA 2016,CGS 2024) and possessives folded to the bare surname (StatsPAI's).
11 new tests (56→59 in tests/test_audit_citations.py).
Added¶
sp.spillover_did— stop measuring the direct effect against units the treatment already reached (WP-8). The standard fix for spatial spillovers is a spatial lag of treatment in a TWFE regression, which inherits every problem TWFE has under staggered adoption and adds one of its own: the controls nearest the treated are exactly the ones the spillover reaches. Butts's answer is to sort untreated units by distance into spillover rings plus clean controls beyond every ring, and estimate the direct effect and each ring's effect against the clean controls only.
There is no reference implementation anywhere — nothing on CRAN or
GitHub — so this ships with design-recovery evidence rather than parity,
and says so in the registry limitations, the docstring and the guide. The
test file plants a direct effect and two ring effects across ten seeds and
recovers all three, checks the confidence interval's coverage, and
asserts that sp.spatial_did is biased on the same data where this is
not — which is the whole reason the estimator exists.
se_method="analytic"onsp.did_multiplegt_dyn. Every horizon is a switcher-weighted sum of two-sample mean differences, so its influence function is the matching sum of within-group deviations; combining the horizons as functions rather than adding their variances is what carries the fact that they share control units. That removes the need for 500 bootstrap draws to get a standard error — roughly a hundredfold speedup.
It is offered, not claimed. The variance is not dCDH's own derivation
and is not pinned against DIDmultiplegtDYN: on the parity fixture it
runs about 1% below the package's reported standard errors at every
horizon, and 1.7% below on the aggregate. It does agree with this module's
own cluster bootstrap, which is what makes it usable. The default stays on
the bootstrap, the gap is pinned as a bound rather than papered over, and
the remaining [待核验] on the paper's variance formula stands.
sp.cgs_continuous_did— a dose has no single ATT, so stop reporting one (WP-5). With a continuous treatment the two-way fixed-effects coefficient averages the 0.2-dose and the 0.8-dose comparison with weights nobody chose and that can be negative. Callaway, Goodman-Bacon & Sant'Anna (2024) replace it with two curves:ATT(d), the effect of dosedamong units that got it, andACRT(d), its derivative — the causal response atd, which is what a question about a marginal change in the dose is actually asking.
Estimation is a B-spline regression of the within-unit outcome change on the dose among the treated, levelled against the zero-dose group's mean change. Degree and knots are the only smoothing choices and both are explicit; there is no hidden bandwidth.
Pinned against contdid 0.1.1, the authors' own package: both curves at
four grid points and both overall quantities, across three spline
specifications, agree to 1e-12. Track A module 80_contdid plus
tests/reference_parity/test_cgs_continuous_parity.py.
Two scope notes, stated rather than implied. Standard errors come from the
per-cell influence function; contdid routes its own through the pte
aggregation layer, which is not replicated. And staggered designs
aggregate cells with StatsPAI's own treated-count weights — the per-cell
estimator is what the parity covers, and a test restricts a staggered
panel to a single cell to show that is where the agreement lives.
-
curve_basisonsp.cgs_continuous_did.contdidfits the spline on the range of the observed treated doses but evaluates the curves it reports on a basis re-anchored to the ends of the dose grid. The fitted coefficients then multiply a differently scaled basis, so the reported curves are a rescaled version of the fitted dose response: on the parity fixture the reported ACRT sits 10% above the overall ACRT the same call returns, and at degree 1 the gap is exactly the ratio of the two ranges. StatsPAI uses one consistent basis by default — soacrt_dandoverall_acrtagree, as they must — and keeps the reference's behaviour undercurve_basis="reference"for reproducing that package's output. Both are pinned. -
sp.ddd_heterogeneousgrows covariates, analytic standard errors and a not-yet-treated control group (WP-8). It shipped as an MVP: unconditional cell means, a cluster bootstrap, never-treated controls only. All three limits are gone.
The estimand is a signed sum of three two-by-two comparisons against the
treated-and-eligible cell, so each piece is an ordinary doubly-robust DiD
with a known influence function, and the DDD's influence function is the
same signed combination reweighted by the comparisons' subsample shares.
That gives x= covariates under conditional DDD parallel trends,
est_method= in {"dr", "ipw", "reg"}, and se="analytic" — exact, fast,
and no RNG. Ported formula-by-formula from triplediff 0.2.4 and pinned
against it: cells and standard errors agree at 1e-12 across all three
nuisance combinations, as does the aggregate once the Callaway-Sant'Anna
estimated-weight correction is included.
Defaults are unchanged. Without covariates se still resolves to
"bootstrap", because that is what earlier releases reported and it is
the only path that fills in model_info["placebo_joint_test"]. With
covariates it resolves to "analytic", where there is no prior behaviour
to preserve.
control_group="notyettreated" runs the DDD against each later-treated
cohort separately and combines them by minimum distance, the structure the
reference uses. It does not reproduce triplediff 0.2.4's numbers on
that path, deliberately — see below.
- ⚠️ Divergence recorded, not inherited:
triplediff0.2.4 misindexes its not-yet-treated influence functions. It writes each control cohort's influence function into the panel-length vector with a boolean index of the wrong length; R prints "number of items to replace is not a multiple of replacement length" on every such call. The combined influence function then carries nonzero entries for units in no comparison at all — on the parity fixture, all 150 units of a cohort that is neither treated nor a control for that cell — and that feeds the minimum-distance weights, the estimate and the standard error.
The evidence is pinned rather than asserted. Per-control-cohort estimates agree with the reference exactly, so the combination is the only place the two can differ. Cells where the comparison happens to span the whole panel have a full-length boolean, nothing goes wrong, and those agree exactly too — including their minimum-distance standard errors. Only the cells where it does not span the panel diverge. Reproducing that would mean encoding an upstream indexing defect, so StatsPAI does not.
sp.functional_form_test— is your parallel-trends assumption a claim about the outcome, or about the scale you happened to write it on? Roth & Sant'Anna (2023) show that parallel trends in levels and parallel trends in logs are different assumptions, and give a testable condition for when the distinction stops mattering: the counterfactual untreated distribution the design implies for the treated group has to be a real distribution, so its density cannot go negative anywhere.
The implementation bins the outcome, recovers each bin's implied
counterfactual mass through sp.callaway_santanna on a sign-flipped
indicator, and tests the resulting moment inequalities with a
least-favourable max-t statistic. Rejection means the levels-vs-logs choice
is doing identifying work and has to be argued for rather than assumed; a
large p-value is a failure to reject, not a licence, and the result object
says so.
Pinned against didFF 0.1.0 — Sant'Anna's own package — on did::mpdta
and on a multiplicative DGP built to reject: all sixteen implied-density
bins agree at 1.2e-15, and both p-values match. Track A module 79_didff
plus tests/reference_parity/test_functional_form_parity.py.
Two binning details had to match R exactly, each worth ~3e-4 per bin: the
bins come from the untreated observations only, and R's
cut(x, breaks = n) pushes only the two outer edges out by dx/1000,
leaving the interior grid unpadded.
-
aggtenow exposes the aggregate influence function.model_info["overall_influence_function"]carries the unit-level influence function of the reported overall ATT, so downstream tests that need the whole function rather than its second moment — the functional-form test stacks one per outcome bin to get their joint covariance — no longer have to re-derive it. -
75 quarantined Tier-D analytic tests reclaimed, and one of them anchors a bug
mainhas since fixed. Five files sat untracked in a_wave1_tierD_anchors_optional/directory inside a stale worktree — known-truth anchors forsp.engle_granger,sp.johansen,sp.cusum_test,sp.irf, plus spatial / structural / inference / LDV-regression cases (69 passing, 6 skipped). They ran green against today'smainapart from one stale assertion, now fixed (cusum_test'scritical_valuebecame the BDE boundary array rather than a scalar threshold, somax_cusum > critical_valueraised rather than compared).
The valuable one was marked xfail: sp.cusum_test used to compare
max|CUSUM| against a flat constant (1.358) instead of the
Brown-Durbin-Evans boundary a*(sqrt(T) + 2t/sqrt(T)), giving an empirical
size of 0.31–0.35 against a nominal 0.05 that did not shrink with n — a
stable series rejected roughly one time in three. main adopted the BDE
boundary in the meantime, so the test now XPASSes. Converted to a plain
assertion and kept, rather than deleted: a false-positive rate that high is
exactly the defect that returns when someone "simplifies" the boundary back
to a constant.
- Parametric AFT reference parity across all four distributions.
sp.survregwas pinned against Rsurvival::survregfor Weibull only. Extended to exponential, lognormal and loglogistic as well (11 tests,tests/reference_parity/test_survreg_aft_parity.py), with committed R reference JSON so the suite runs without R. The existing Weibull + formula-interface tests intest_aft_parity.pystay — they cover the formula API this one does not.
Changed¶
- A synth test that skipped in every environment anyone develops in now
actually runs.
test_bayesian_optional_dep_noteassertedfind_spec("pymc") is None— a claim about the environment, not the estimator — and skipped itself whenever pymc was installed. Since the optionalbayesextra installs pymc for the separatesp.bayes_*estimators, it skipped essentially always. Replaced bytest_bayesian_synth_needs_neither_pymc_nor_tfp, which asserts the real invariant (the synth Bayesian path imports neither backend) by inspecting the module source, and therefore holds whether or not pymc is present.
Fixed¶
-
⚠️ §10: three fabricated author attributions on real arXiv IDs, plus two
author={Anonymous}placeholders inpaper.bib. An audit in June 2026 had already found the first group. Its fix was committed to a branch that never reachedmain, so the wrong attributions shipped for two months while the auditor that caught them sat unused. Corrected now, each re-verified against two independent sources (the canonical arXiv abstract page and OpenAlex) rather than trusted from the earlier commit message: -
sp.llm_dag_propose(causal_llm/llm_dag.py,causal_llm/__init__.py) anddocs/guides/causal_mas.mdcited "Kiciman-Sharma 2025, arXiv 2402.11068". arXiv 2402.11068 is Wan, Lu, Wu, Hu & Li (2024), Large Language Models for Causal Discovery: Current Landscape and Future Directions. The intended reference is Kıcıman, Ness, Sharma & Tan (2023), arXiv 2305.00050 — already inpaper.bib, correctly, askiciman2023causal. Note the June fix itself said "Kıcıman, Ness & Sharma", dropping the fourth author. docs/guides/causal_mas.mdalso gave the Wan et al. entry the wrong title ("Enhancing Causal Discovery with Large Language Models"), which survived the June pass.docs/guides/qte_family.mdheadedsp.dist_ivwith "Sharma-Xue 2025"; arXiv 2502.07641 is Holovchak, Saengkyongam, Meinshausen & Shen (2025), Distributional Instrumental Variable Method.paper.bibcarriedauthor={Anonymous}forcohort_anchored2025anddesign_robust_es2026while the corresponding docstrings named specific people. Here the docstrings were right: arXiv 2509.01829 is Ziyi Liu (2025) and arXiv 2601.18801 is Craig S. Wright (2026). Both entries now carry the verified authors and a DOI.- Added
hess2025efficientfor arXiv 2502.13022 (Heß, Frauen, Melnychuk & Feuerriegel, 2025), cited bysp.opebut absent from the bibliography.
New offline guard tests/test_citation_attribution_drift.py (10 tests)
cross-checks every parenthesised author attribution sitting next to an
arXiv ID in src/ and docs/ against paper.bib. The existing
tools/audit_citations.py needs a network and someone to run it; this
runs in CI. It is proven to discriminate rather than merely pass: the
three real regressions are asserted to fire and their corrected forms to
stay silent.
Fixed¶
- ⚠️ Correctness:
sp.pretrends_poweranswered a different question than the paper it cites. The docstring said "implements the power calculation from Roth (2022)", and the number returned was the power of the joint Wald test that every pre-period coefficient is zero. That is not what Roth (2022) analyses, and it is not what hispretrendspackage computes. The pre-test in the paper is the one analysts actually run: look at the event-study plot, object if any pre-period coefficient is individually significant. Its power is a multivariate-normal rectangle probability, not a non-central chi-squared tail.
The two are not close. On the reference fixture, at a linear violation of
slope 0.02, the coefficient-by-coefficient test has power 0.332 and the
joint Wald test 0.157 — a factor of more than two, in the direction that
makes a passed pre-test look less informative than it is. Nor are they
comparable at face value: the joint test has size exactly alpha, while
the eyeball test rejects with probability above alpha under the null,
because each of the K coefficients gets its own alpha-level look.
test="individual" is now the default and matches pretrends to within
that package's own Monte-Carlo noise (worst gap 4.3e-4; mvtnorm::pmvnorm
is randomised and spreads over ~5e-4 across repeated calls). Pass
test="joint" for the previous quantity, which is also always returned as
power_joint, so no caller has to choose blind. See MIGRATION.md.
The return dict gains power_under_null, bayes_factor,
likelihood_ratio, test, threshold_tstat and power_joint; no
existing key was removed.
- ⚠️ Correctness:
sp.did_multiplegt_dyn's placebos were not the estimator's placebos. The de Chaisemartin-D'Haultfœuille placebo at lag ℓ is the effect window reflected aboutF-1—Y_{F-1-ℓ}againstY_{F-1}, a long difference matching the effect it mirrors. StatsPAI computedY_{F-1-ℓ}againstY_{F-1-ℓ-1}: a one-period difference sliding backwards, which is a different quantity and needed one more pre-period than the real placebo does. Lag 1 therefore silently dropped the earliest cohort — on the parity fixture it ran on 96 switchers where the reference uses 146.
That matters because the placebos are the module's parallel-trends
diagnostic: joint_placebo_test was testing the wrong thing on the wrong
subsample. Every placebo value changes; the effects do not. Now matches
DIDmultiplegtDYN 2.3.4 to 5e-15, switcher counts included. See
MIGRATION.md.
The module docstring carried [待核验] markers on the control-group
window, the per-horizon weights and the placebo definition. The parity
settles the first two as already correct and the third as wrong; the
markers on switch-off handling and the analytical IF variance stand,
and those are what still keep this estimator off a paper-faithful claim.
-
aggregationonsp.did_multiplegt_dyn. The headline estimate averages the dynamic horizons with equal weight;DIDmultiplegtDYN'sAv_tot_effweights each horizon by the switchers behind it, which differs whenever later horizons rest on fewer cohorts — the normal case in staggered designs. Both are now reachable, the default is unchanged, andmodel_info["aggregation"]records which was used. -
The parity index under-reported three QTE estimators.
sp.panel_qtet,sp.qdidandsp.qteare each pinned againstqte1.3.1 with committed frozen R fixtures —panel_qtetat 6.8e-12 across all 19 quantiles — but the index listed all three asanalytical-only, sides['py'], because the promotion table did not carry them. That is whatsp.describe_function,docs/parity.mdand the JSS parity tables read, so the evidence existed and nobody could see it. Now recorded as sides['py', 'R']:panel_qtetbit-exact,qdidandqtealigned with their optimiser-plateau convention gaps stated. -
Roth (2022) was cited under the wrong title. Four call sites wrote "Pre-test with Caution"; the published title is "Pretest with Caution" (AER: Insights 4(3), 305-322). Corrected, and
paper.bibgained the verified volume/issue/pages. Same forcengiz2019effect(QJE 134(3), 1405-1454). Both verified via Crossref and OpenAlex. -
⚠️ Retraction:
sp.xtabondnever had a gapped-panel divergence. StatsPAI shipped a warning telling users that on panels with interior time gaps its coefficients differ from Stata'sxtabond2by 2-6% because of an "undocumented gap-weighting convention", and the guide carried a matching Known limits entry. Both were wrong. The divergence was in the reference fixture, not the estimator.
The Stata side had been written as gmm(L.n, lag(k k)). On a gap-free
panel that is the same moment set as gmm(n, lag(k+1 k+1)) — which is
what gmm_lags=(k+1, k+1) means — so all 39 other reference specs agreed
and the mapping looked safe. On a holed panel they differ: Stata
materialises the expression L.n row by row, leaving it missing wherever
the preceding row is absent, and xtabond2 then lags that already-holed
series. The instrument ends up requiring both period t-k-1 and period
t-k to exist, where the level form requires only t-k-1. Both moment
sets are valid; only one is the one gmm_lags names.
Re-run against the level form, sp.xtabond matches xtabond2 on gapped
data to 1.06e-12 worst case across ten new reference specs (five
single-lag depths, one-step, two-step Windmeijer, forward orthogonal
deviations, system GMM, multi-regressor). No numerical output changed —
the estimator was always right — but the warning text did, and anyone who
read the old one and discounted their gapped-panel results should not
have had to.
The warning now says what is actually true: first differencing loses two
equations per hole, orthogonal=True loses one, so prefer FOD on gappy
panels. A test asserts the retracted claims cannot reappear in it.
Reference fixture goes from 39 to 49 specs.
Added¶
-
sp.pretrends_slope_for_power— how big a pre-trend a passed pre-test actually rules out. Invertssp.pretrends_power: returns the slope of a linear pre-trend the pre-test would catch a given fraction of the time (0.5 by default). Mirrorspretrends::slope_for_powerand is pinned against it. -
Cross-language references for three DiD estimators that had none (WP-7). All three were carried as analytical-only in the parity index:
-
sp.ddd_heterogeneousvstriplediff::ddd(Ortiz-Villavicencio & Sant'Anna 2025, CRAN 0.2.4). With no covariates the doubly-robust DDD reduces to the unconditional cell means, and the six post-treatment ATT(g,t) cells agree at 1e-14. Track A module77_ddd. sp.stacked_didvs a hand-writtenfixeststack. No CRAN package implements Cengiz-Dube-Lindner-Zipperer stacking, so the reference is an independent R construction rather than a library call; all fourteen statistics agree at 1.3e-13. Track A module75_stacked.-
sp.pretrends_powervspretrends0.1.0 — see the correctness entry above. Track A module76_pretrends, iterative tier. -
weight_byonsp.ddd_heterogeneous. The overall ATT weights each (g, t) cell by treated units in the affected subgroup;triplediff'sagg_ddd(type="simple")weights by whole-cohort share instead. On a panel where the affected share varies across cohorts the two differ visibly (2.369 vs 2.455 on the parity fixture). Both are now reachable, the default is unchanged, andmodel_info["weight_by"]records which was used. -
sp.check_absorbing— detect reverting treatment before it silently biases a cohort estimator (WP-6). Callaway-Sant'Anna, Sun-Abraham,did_imputation, ETWFE andstacked_didall represent treatment by the period a unit is first treated. That is lossless only when treatment is absorbing; under reversal they treat post-reversal periods as still-treated and collapse toward zero. On a 150-unit panel where a third of units switch on at t=4 and off at t=7 (true ATT 1.5),sp.callaway_santannareturns 0.71 — a 53% error, with no warning, because(y, g, t, i)simply does not contain the reversal.
Because the cohort estimators cannot see the indicator, the check lives
upstream. sp.recommend now runs it and, on a reverting panel, leads with
sp.did_multiplegt (stable, Stata-validated, designed for switching)
instead of Callaway-Sant'Anna, and records the reversal in
.warnings. Absorbing panels are unaffected and stay silent.
Cross-checked against R fect 2.4.1 on the committed reverting fixture:
the reversal-capable estimators cluster near the truth
(sp.did_multiplegt and sp.lp_did ~1.43, R fect 1.405) while the
cohort estimator sits at 0.71. New guards:
tests/test_absorbing_treatment.py (12 tests) and
tests/reference_parity/test_absorbing_reference.py (4).
Also pinned while investigating: sp.lp_did's h=0 estimate coincides
with sp.did_multiplegt to machine precision by construction — with
not-yet-treated clean controls the LP-DiD h=0 contrast is the dCDH
switcher-vs-stayer cell — while their standard errors differ. That
identity now has a test so it is not mistaken for a wiring bug.
- The two remaining unpinned conventions in the ML-causal block are now
asserted rather than described.
sp.gmm'svcov='iid'andvcov='mds'estimateSby the same moment outer product — correct for moments independent across observations, and what R'sgmmdoes (itstwostep_iidandtwostep_mdsfixtures are identical to the last digit) — but that equivalence lived only in a source comment. It is now a test, alongside checks thathac_bandwidth=1reduces exactly to the MDS form and thatcenter=is not a no-op. Forsp.metalearner's DR-learner, the gap againsteconmlis now attributed instead of merely noted: StatsPAI fits the outcome nuisance per arm while econml'sDRLearnerfits one joint regression on[X, T], so with a linear learner the two agree only when the treatment effect is constant — the maximum elementwise gap falls from 5.2e-2 to 4.7e-3 when the effect is made constant on a shared fold partition, and that contrast is itself asserted. What is pinned exactly for DR is the operator: given the cross-fitted nuisances StatsPAI actually used, the pseudo-outcome must equal the AIPW score to 1e-12, which separates "our nuisance models differ from econml's" (a modelling choice) from "our doubly-robust score is wrong" (a defect). sp.staggered_rollout— Roth & Sant'Anna (2023) efficient design-based DiD (WP-3). When adoption timing is randomised (policy lotteries, phased launches, wave-randomised RCTs), parallel trends is neither what the design delivers nor what should be assumed. This estimator identifies off the randomisation and is efficient in the class of linear combinations of cohort-period means, using each cohort's last pre-treatment period as an optimal control moment (beta* = Xvar^-1 X_theta_cov).efficient=Falsegives the plug-in;estimand=selects'simple'/'cohort'/'calendar'weighting. Inference is the conservative (Neyman) variance.
Parity vs R staggered 1.2.2 on canonical did::mpdta: all six
estimand × efficient combinations match to ≤1e-8 on both the
point estimate and the SE (simple/efficient −0.0470539142, SE
0.0116138788). New guard:
tests/reference_parity/test_staggered_rollout_parity.py (18 tests).
Two traps are closed by construction. R's staggered requires
never-treated units coded g = Inf and silently returns −0.3704 instead
of −0.0471 when they arrive as 0; sp.staggered_rollout accepts 0 /
NaN / inf and normalises. And because this is a different
estimand from sp.callaway_santanna (−0.0471 vs −0.0400 on mpdta,
where timing is not randomised), the registry records random adoption
timing as the identifying assumption and flags the estimator as
not-recommended for observational rollouts.
- Dynamic panel GMM, substantially rebuilt (
sp.xtabond, newsp.xtdpdsys). The 2026-07 classic-design audit rated this the weakest classic design in the package: two exported functions, one materialisedplm::pgmmrow, and no way to express most applied specifications. It now matches Stata 18xtabond/xtabond2/xtdpdsysto machine precision across 20 specifications on the canonical Arellano-Bond (1991)abdataemployment panel. New capabilities, each reference-verified: - Blundell-Bond system GMM —
method='system', or thesp.xtdpdsysalias. Stacks the level equation, instruments it with lagged differences, and reports the intercept the differenced equation cannot identify.sp.panel(method='system')was documented but routed toNotImplementedError; it now works. Matchesxtabond2 ... robust(one-step, two-step Windmeijer, collapsed) to ~1e-12. - Lag operators in the regressor list —
x=["l(0/1).w", "l(0/2).k"], so the Arellano-Bond (1991) Table 4 specification is a single call. - Instrument classes —
predetermined=/endogenous=with their own lag windows, replacing "everything is strictly exogenous". Lags followxtabond2's absolute convention; the mapping to Stataxtabond'slagstruct()is documented and pinned by tests. collapse=True(Roodman 2009) for instrument proliferation, matchingxtabond2, collapse.orthogonal=True— Arellano-Bover forward orthogonal deviations, which lose one observation per gap instead of two. Matchesxtabond2, orthogonalfor difference and system GMM, one- and two-step.time_dummies=Trueand, for system GMM,constant=.- Difference-in-Hansen (C) tests of each instrument subset, in
model_info['difference_in_hansen']— the only way to test system GMM's extra level moments rather than assume them. Matchesxtabond2's "Difference-in-Hansen tests of exogeneity of instrument subsets" block. - Anderson-Hsiao (1981) simple IV -
method='ah'(alsosp.panel(method='ah')), withah_instrument='levels'(y_{t-2}) or'differences'(Δy_{t-2}). One pooled instrument instead of the block-diagonal set, so it cannot be driven by instrument proliferation- the natural robustness check on an Arellano-Bond fit. Matches
xtabond2to ~1e-14 in all four variants.
- the natural robustness check on an Arellano-Bond fit. Matches
cluster=- cluster-robust standard errors on a coarser unit than the panel id (industry, region). Only the sandwich meat re-groups; the one-step weight stays a within-unit object. Matchesxtabond2, cluster(). A finer-than-unit cluster variable raises, and multi-step GMM with fewer clusters than moment conditions refuses rather than inverting a rank-deficient weight.steps=- generalisestwostep: any number of steps,'iterated'(run the recursion to a fixed point) or'cue'(the continuously-updated estimator, which never depends on preliminary residuals). Convergence is reported inmodel_info.- Instrument-count guardrail: a warning (and a
model_infonote) when the moment count reaches the number of units. - The Hansen J is now reported for one-step fits too (it is defined at the two-step optimum regardless of which step is reported, and it is the only over-identification test robust to heteroskedasticity).
- The Arellano-Bond AR(1)/AR(2) tests now match Stata across the whole
VCE menu - one-step robust, one-step classical, two-step conventional
and two-step Windmeijer - for difference and system GMM, and also
under
orthogonal=Trueandcluster=. Both of the latter were settled by readingxtabond2's own Mata source rather than inferring from outputs: the AR variance is accumulated over units even when the coefficients are clustered, and under orthogonal deviations the test mixes two row spaces (differenced residuals for the statistic, the estimation basis for the influence adjustment). Coefficients, standard errors and AR statistics now agree with Stata /xtabond2to 1.7e-11 across all 29 reference specifications. sp.xtlsdvcgained a reference-parity suite. It shipped as a registered public function with 10% test coverage. It turns out to be correct - it matches Stata'sxtlsdvc(Bruno 2005) to 1e-9 across all three initialisers and all three bias orders - but nothing was holding it there.tests/reference_parity/test_lsdvc_parity.pynow does, and coverage ofstatspai/gmm/as a whole went from 81% to 97% (newtests/test_dynpanel_internals.pycovers the lag-operator grammar, panel-layout validation and the fail-loudly guards).-
The per-unit Python loops in the weight matrix, the moment covariance, the Windmeijer derivative and the AR-test lag vector were replaced by segment sums and banded row-pair products: a 20,000-unit, 15-period two-step fit went from 2.56 s to 1.12 s, system GMM from 6.24 s to 2.94 s. New benchmark:
benchmarks/bench_dynpanel.py. -
sp.gmm(general moment-condition GMM) rebuilt. It was a thin BFGS wrapper with a numerical Jacobian and no way to state the dependence structure of the moments. It now carries an analytic closed-form path when the moment conditions are affine in theta (every linear IV / 2SLS moment - detected by probing the Jacobian, and exact rather than iterated); an optional analyticjacobian=;vcov=covering i.i.d., heteroskedasticity-robust, HAC (Bartlett) and one-way clustered moment covariances; acenter=switch for the centring convention (R centres, Stata does not, and the J statistic is not invariant to it); and honest convergence reporting. Verified against Rgmm::gmmfor two-step, iterated, CUE and HAC, and against an independent closed-form solution to 2e-15. New suite:tests/reference_parity/test_general_gmm_parity.py. -
Numerics moved to
statspai/gmm/_dynpanel/(spec / data / moments / estimate / inference / diagnostics);sp.xtabondis the presentation layer. New suites:tests/reference_parity/test_dynpanel_abdata_parity.py(45 tests against Stata 18 +xtabond2+ Rplm::pgmm+pdynmc) andtests/test_xtabond_golden.py(regression lock on the pre-existing numbers).
Changed¶
- Bundled datasets now default to the real published data. Five
loaders ship a real extract in
datasets/data/; four of them also offer a calibrated replica behindsimulated=. Their defaults were split — onlynsw_lalondehanded back the real one — so what a bare call returned depended on which dataset you reached for.card_1995,lee_2008_senateandcalifornia_prop99now match it.
The rule is one sentence: if StatsPAI ships the real published data, a
bare call returns it. That is also the variant that reproduces the
papers — card_1995 gives OLS 0.074 / IV 0.132 against Table 2's
0.075 / 0.132, where the replica gives 0.110 / 0.142. The default was
handing users the compromise.
Shapes change where the variants genuinely differ: california_prop99
keeps its columns, card_1995 gains nearc2, and lee_2008_senate
moves from a 6,558-row replica to the 1,390-row
rdrobust::rdrobust_RDsenate extract with different column names and
scale. The two lee frames are deliberately not given a shared
vocabulary — same names on different units would be a worse trap than
the shape change. simulated=True keeps the replica in every case.
tests/test_datasets_catalog.py enforces the rule from the shipped CSV
list, so a new bundled dataset cannot quietly default to its replica.
list_datasets()gained asourcecolumn and stopped misreporting row counts.sourcesays which variant a barename()call returns —"bundled CSV"for a real extract shipped indatasets/data/,"simulated"for a calibrated replica — so the choice is visible in the catalogue instead of buried in each docstring. Threen_obsentries were wrong:nsw_lalonde(445 → 614, after the default flip above),california_prop99(1200 → 1209) andbasque_terrorism(774 → 731), the last two long-standing.tests/test_datasets_catalog.pynow pins every row against its loader, so the table cannot drift again, and asserts the whole catalogue loads with sockets blocked.
⚠️ Correctness¶
sp.unified_sensitivityanalysed the intercept, not your treatment effect._extract_estimatetookparams.iloc[0]andstd_errors.iloc[0]; for any formula regression that is the intercept. Sosp.unified_sensitivity(ols_fit)answered "how sensitive is the intercept to unmeasured confounding?" — paired with the intercept's standard error — while the caller read it as a statement about their treatment effect. On the LaLonde baseline it pulled66.51(Intercept) instead of1548.24(treat).
It only surfaced by luck: the intercept's CI spanned zero, which sent the
risk-ratio conversion down a branch producing an interval that excluded
its own point estimate and tripped an assertion inside evalue. Whenever
the intercept CI stayed positive, the function returned a confident wrong
number instead.
_extract_estimate now takes estimate, SE and CI from the same term
(CI from conf_int() rather than rebuilt as ±1.96·se), and
unified_sensitivity gained a term= argument. With more than one
non-intercept coefficient it raises MethodIncompatibility listing the
candidates rather than guessing. When treat= is supplied (it already
names the treatment for the Sensemakr component) it doubles as the term,
so no caller has to name the same column twice. Results exposing a scalar
estimate are unaffected. Pinned by
tests/test_unified_sensitivity_term.py.
Two callers carried the same defect and are fixed with it:
EconometricResults.sensitivity() built a "1-entry view" whose
estimate/SE/CI were all iloc[0] and handed it over as a scalar, which
looked unambiguous and so bypassed coefficient selection entirely; it now
passes the result through. The MCP sensitivity tool received the
caller's treat and dropped it, returning the intercept's sensitivity to
agents; it now forwards it.
recommend(...).run_all()swallowed estimator failures into strings. A recommendation that raised was recorded as"Error: ..."in the comparison dict and nowhere else — no warning, nothing machine-readable. On a design where every recommendation failed,run_all()returned two innocuous-looking strings and zero warnings; the caller only found out on theAttributeErrorfrom calling a method on astr. Failures now go throughrecord_degradation(warn + structured entry) and land in the newRecommendationResult.degradations. The returned dict is unchanged — this adds a channel rather than replacing one.
Added¶
-
scripts/orchestration_assertion_audit.py— a pre-push ratchet against smoke-only assertions in orchestration tests. Five shipped defects this cycle hid behind assertions that only proved a call did not raise (assert isError is False,"verdict" in payload,out["pipeline"] == "pipeline_iv"). The audit flags test functions that assert a status/shape signal without ever pinning a value, and the baseline may only shrink. Error-path tests that assert which explanation came back are exempt; comparing a status field to a literal is not. -
A
sp.paper()whose robustness section failed reporteddegradations == []. The pipeline drivesdiagnose → recommend → estimate → robustnessand swallows per-stage failures so a draft always renders. It recorded those failures only as a prose line in the paper's "Pipeline notes" section: noWorkflowDegradedWarning, and nothing indraft.degradations. A programmatic consumer — the agent-native path this package exists for — read[]and concluded the draft was complete while its entire robustness section was missing. Only a human reading the prose would have noticed.
Stage failures now go through record_degradation as CLAUDE.md §7
requires, so they warn and land in draft.degradations with
{section, error_type, message}. The human-readable note is unchanged;
this adds the structured channel rather than replacing the prose one.
The "no fitted result" case is recorded too, instead of being narrated
only.
pipeline_didreported its two hardest stages as the word "computed".honest_didandbacon_decompositionran, returned full payloads, and then summarised themselves as"computed"and"computed weight decomposition". That text is what reaches the markdown narrative the pipeline exists to hand an agent verbatim, so the report said nothing about the only questions those two stages answer: how far parallel trends can bend before the effect stops being distinguishable from zero, and whether TWFE puts negative weight on any 2x2 comparison. The numbers were in the payload the whole time.
Now: breakdown M=0.556 (rejects zero up to there) and
negative weight share=0; 4 2x2 comparisons; TWFE beta=1.286.
Neither stage had any test coverage — the suite only exercised the 2x2 branch, where both are correctly skipped for want of an event-study table. A staggered-adoption case now covers the branch that reaches them.
pipeline_ivfailed three of its four stages on every run. The Anderson-Rubin and effective-F stages were handed the fitted result wheresp.effective_f_test/sp.anderson_rubin_testwant(data, endog, instruments, ...), so both raisedTypeError: missing N required positional argumentsevery time. The effective-F reader then looked for.F/.statisticattributes on what is actually a dict keyedF_eff, printingF=nanunder anokstatus. The estimate stage read"ivreg: "with no number, because an IV fit exposes a coefficient vector rather than a scalar.estimate. And the e-value stage was markedfailedfor a design boundary — an IV fit legitimately cannot feedevalue_from_result.
The formula is now parsed with statspai.core.utils.parse_formula to
supply the columns the diagnostics need, the effective F reads F_eff
and reports the Olea-Pflueger strength verdict, the estimate stage
reports the endogenous coefficient and its SE, and the e-value boundary
is reported as skipped rather than failed. The tool's test asserted
out["pipeline"] == "pipeline_iv" and "stages" in out, both of which
a three-quarters-broken pipeline satisfies.
-
sp.preflightrejected the canonicaltreatment=spelling. Its column checks readkwargs["treat"]only, sosp.preflight(df, "did", treatment="treat", ...)reportedrequired argument 'treat' not providedand returned FAIL — while the legacytreat=passed. The house style is migrating towardstreatment, and the MCP tool schema advertises it, so every MCP DiD preflight failed. Checks now accept the documented aliases (treat/treatment,y/outcome,time/period) and the failure message lists the spellings it will take. -
sensitivity_from_result(method="evalue")returned no E-value.sp.evalue_from_resulthands back a plain dict, which has noto_dictand noestimateattribute, so_default_serializerfell through to the field-by-field branch and produced an empty payload: an agent gotsource_result_idand citations, and nothing else. The serialiser now passes a dict through unchanged — it is already JSON-serialisable. -
The MCP
sensitivitytool returned no numbers at all.SensitivityDashboardhad noto_dict, so the generic serialiser fell back to hunting forestimate/sefields, found none on a dashboard, and produced an empty payload — an agent callingsensitivityover MCP received{"narrative": "**sensitivity**"}: a bold title and nothing else. No E-value, no Oster delta, no robustness value.
The transcript test covering it asserted only isError is False, which
an empty payload satisfies, so the gap stayed green. to_dict now emits
the numbers, _outcome_sd reads the outcome vector the result already
carries (so the tool needs no extra arguments to scale the E-value), and
the test asserts the returned E-value equals the treatment coefficient's,
computed independently.
- The E-value was computed from un-standardised coefficients.
unified_sensitivityforcedmeasure='RR'and passed a raw regression coefficient straight through whenever it was positive. A $1,548 treatment effect was therefore read as a risk ratio of 1548, yielding an E-value of 3096 — arithmetically valid, meaningless as a quantity. The E-value is defined on the risk-ratio scale, so a mean difference has to be standardised first:RR ~ exp(0.91 * d)(vanderweele2017sensitivity), whichsp.evalue(measure='OLS', sd=...)already implemented and this function was bypassing. On the LaLonde baseline the E-value is 1.71, not 3096 — a confounder association one can actually argue about.
New measure= (default 'auto') and outcome_sd=. 'auto' reads the
result's own estimand and treats a difference as a difference; the
outcome SD comes from data/y when supplied. Without a scale the
E-value is now skipped with an explanatory note rather than invented.
Pass measure='RR' when the estimate really is a ratio.
-
Oster's delta disagreed with
sp.oster_deltaon the same specification.r2_treated/r2_controlledread like sensemakr's partial R^2 but were consumed as the short- and long-regression R^2 for Oster. Passing sensemakr-style values — the natural reading of those names — silently produced delta* = −12.765 wheresp.oster_deltareported −2.339 on the same data, so one pipeline could print two contradictory deltas. Whendata,y,treatandcontrolsare available the R^2 are now derived from the data, so the two agree by construction. Renamed tor2_short/r2_long; the old names still work and emit aDeprecationWarning. -
sp.aipwwas not reproducible: its defaultseedis now42, wasNone. The cross-fitting fold split came fromnp.random.default_rng(seed), anddefault_rng(None)seeds from OS entropy. Three identical calls on the same 614-row LaLonde frame returned +\(308.87 / +\)149.84 / +$905.21 — a spread wider than the effect being estimated. Worse, the drift could not be pinned from the outside:np.random.default_rngignores the legacy global RNG, so evennp.random.seed(...)before every call did not help.
This propagated. sp.causal_question(...).estimate() resolves a
selection-on-observables plan to cross-fitted AIPW, so the headline number
of an entire estimand-first pipeline changed on every run.
The new default matches sp.tmle, sp.bcf and sp.super_learner, which
already defaulted to 42. An audit of the rest of the stochastic surface
found sp.dml, sp.tmle and sp.metalearner already deterministic —
sp.aipw was the only offender. Pass seed=None explicitly to opt back
into a fresh split per call; the seed actually used is now recorded in
result.model_info['seed']. Pinned by
tests/test_estimator_determinism.py.
-
sp.sensitivity_dashboardnow warns when it tested nothing. Most dimensions re-estimate the model on perturbed samples, so withoutdata=every dimension drops out and the result was an empty dashboard gradedoverall_stability='?'— which reads far too much like a pass. It now raises aRuntimeWarningnaming the fix. Populated dashboards are unchanged and stay quiet. (The dashboard picks the treatment coefficient correctly; it does not share theunified_sensitivityintercept defect above —tests/test_unified_sensitivity_term.pyasserts that.) -
sp.xtabond's AR(1)/AR(2) tests were wrong underorthogonal=True. The statistic was computed on the forward-orthogonal-deviation residuals.xtabond2prints "Arellano-Bond test for AR(1) in first differences" whatever the transform, and means it: the test is a statement about first differences. Onabdatathe AR(1) z came out +4.11 where Stata reports -3.25 - an inverted conclusion, not a tolerance question. It is now computed on first-differenced residuals at the fitted coefficients, with the influence adjustment taken from the estimation basis, and matches to 1e-13. Onlyorthogonal=Truefits are affected. -
sp.xtabond's AR(1)/AR(2) tests double-countedcluster=. The variance has three terms; only the last,(W'q)' Avar(beta) (W'q), should see the clustering. All three were being grouped by cluster, moving the statistic by 10-14%. Onlycluster=fits are affected; coefficients and standard errors were always correct. -
sp.xtabond's two-step AR(1)/AR(2) tests used the wrong coefficient variance. The Arellano-Bond test variance contains a(W'q)' Avar(beta) (W'q)term. It was always evaluated at the uncorrected robust sandwich, even when the reported VCE was the Windmeijer-corrected or the conventional two-step one - so the specification test and the coefficient table disagreed aboutAvar(beta). Onabdatawith the Arellano-Bond (1991) Table 4 specification the two-step AR(1) z was -4.32 where Stata reports -3.10 (a 39% error); on the plain AR(1) spec -2.24 against -2.10. The term now uses the reported VCE and every AR statistic matches Stata to ~1e-12. One-step results are unchanged - there the two variance estimators coincide and the swap is identically zero. Onlytwostep=Truefits move. See MIGRATION. -
sp.gmm(se='unadjusted')returned a variance that did not describe the estimator. It computed(D'WD)^-1/nfor whatever weightWwas supplied. That is the variance of the GMM estimator only whenWis efficient; with any other weight the correct variance is the sandwich, and the reported one understated it.se='unadjusted'now returns the efficient-GMM form(D' S^-1 D)^-1/n- identical whenWis efficient, so two-step and CUE numbers are unchanged - and warns when the weight in force is not efficient. See MIGRATION. -
sp.ltmlestandard errors were 250–400× too small: the efficient influence curve was missing its martingale term. The LTMLE influence curve isΣ_k H_k (Q*_{k+1} − Q*_k) + (Q*_1 − ψ). Only the second term was computed, so the reported SE was the dispersion of a fitted conditional mean rather than the sampling variability of the estimator. On a two-period DGP with a known ATE the reported SE came out 0.00024 against a Monte-Carlo standard deviation of 0.059 at n = 500 — a factor of 250 — and the gap widened with n (353× at n = 2000, 405× at n = 8000) because the quantity was not even converging at the √n rate. Every confidence interval, p-value and.summary()significance marksp.ltmleproduced was meaningless; the point estimate was unaffected. The martingale sum is now accumulated across time points, giving SE/Monte-Carlo ratios of 1.03–1.04 and a stablese·√n. Because the module targets with a one-step rather than a fully iterated fluctuation, the term is near zero but not identically so, leaving the SE mildly anti-conservative: measured over 200 replications it is ~13% below the Monte-Carlo standard deviation at n = 1000 (coverage 0.905) and ~7% below at n = 4000 (coverage 0.930). Those figures, and the remaining gap, are now stated in the function's Notes rather than described only as a qualitative caveat. New guards:tests/reference_parity/test_ml_causal_recovery_parity_round2.pycompares the reported SE against the estimator's Monte-Carlo dispersion and checks thatse·√nis stable across n — the only kind of test that catches a missing influence-curve term, since no single-fit assertion can see it. See MIGRATION. -
sp.gmm(se='unadjusted')silently understated the variance under any inefficient weight.(D'WD)⁻¹/nis the variance of the GMM estimator only whenWis the efficient weightS⁻¹; under any other weight — including the identity used bymethod='onestep', and anyW=the caller supplies — the estimator's variance is the sandwich, and the efficient formula is smaller. The old code returned it unconditionally and said nothing. It now warns and names the alternatives.se='robust'(the default) was and remains correct, so callers on the default path were unaffected. Two further conventions in the same function were wrong against their references: linear moment conditions were sent to BFGS although GMM has a closed form whenever the moments are affine in θ (the Jacobian is now probed at two parameter values, and the closed form reproduces R's analytic two-step to 1e-12 instead of to optimiser slack —diagnostics['n_iter'] == 0reports when it fired); and the Bartlett HAC kernel must be evaluated atlag/bandwidth, vanishing atlag == bandwidth, to match Rsandwich— stating it as Newey–West's1 − lag/(q+1)instead moves HAC standard errors by ~3%.sp.gmmalso gainsjacobian=,vcov=('mds'/'iid'/'hac'/'cluster'),cluster=,hac_bandwidth=andcenter=(moment centring: Rgmmcentres by default, Stata does not), plus convergence reporting and loud rejection of under-identified models. Verified 17/17 against Rgmm1.9.1 intests/reference_parity/test_general_gmm_parity.py, which was already onmainasserting this API against an implementation that did not have it. See MIGRATION. -
sp.xtabonddeleted instruments when a covariate had missing values. The estimator began with a listwisedata[[id, time, y] + x].dropna(), so aNaNin any covariate at period t removedy_{i,t}from the instrument pool and from the lag pool, not just from the estimation sample. Any specification with lagged regressors — which necessarily have leadingNaNs — was therefore silently amputated. Onabdatawith the Arellano-Bond (1991) Table 4 specification this used 19 of 32 instruments and 331 of 611 observations, returning ρ̂ = 0.660 where Stata returns 0.849 (a 22% error). Availability is now evaluated per variable. Fits with no missing covariate values are unaffected — the golden-value lock confirms every previously-reported number on complete data is unchanged. See MIGRATION.
Added¶
-
sp.panel_qtet— Callaway & Li (2019) panel QTT. Recovers the counterfactual distribution of untreated outcomes for the treated group via distributional DiD plus a copula-stability assumption, so it reports the shape of the effect and not just its location. Needs a balanced three-period panel — the third period is what identifies the copula. Exact parity with Rqte::panel.qtet: max |difference| = 6.8e-12 across 19 quantiles onlalonde.psid.panel, the tightest alignment in the QTE family (the estimator composesecdfevaluations and type-7 quantiles, both of which have exact numpy equivalents, so there is no optimiser-convention gap to absorb). The fixture generator re-implements the five algorithm steps by hand and fails if they drift from the package, so the claim is an algorithmic match rather than a tolerance. Recovers a known constant shift to 0.06 and a known quantile fan to 0.08 on a DGP whose ATT is zero — the case a mean estimator cannot see. Two diagnostics R does not provide:model_info['copula_check']tests copula stability on the untreated group (where, unlike the treated att, both copulas are observed), andmodel_info['coherence_check']detects when mass points in the outcome break the rank map. Onlalonde.psid.panelthe latter fires: 131 of 185 treated units havere74 == 0, so the counterfactual mean lands at 8,786 against a distributional-DiD value of 4,023 and the QTT curve is distorted — R produces the identical curve silently. The reportedateis the plain mean DiD, matching R, because the mean needs only distributional DiD. New parity suite:tests/reference_parity/test_panel_qtet_parity.py. -
Firpo (2007) efficient unconditional QTE and QTT:
sp.qte(method='firpo_qte')andsp.qte(method='firpo_qtt'), with analytic influence-function standard errors (Monte-Carlo coverage 0.93–0.97 at nominal 95%) and a sharedqte/_core.pyof weighted-quantile, Abadie-κ, kernel-density and rearrangement primitives. Verified against Rqte1.3.1ci.qte/ci.qtetonlalonde.expandlalonde.psid: our exact check-function minimiser attains an objective no worse than R's at every quantile, in both arms, across all eight dataset × estimand × covariate combinations, and agrees numerically with R at 19/19 (QTE) and 16/19 (QTT) quantiles on the dense PSID sample. The remaining gaps are plateau ambiguity: R minimises viastats::optimize(golden section, tol.Machine$double.eps^0.25) on a piecewise-linear objective, so its point value is not a well-defined functional of the data — it returns-5.93e-06where the exact answer is0. New parity suite:tests/reference_parity/test_firpo_qte_parity.py.
⚠️ Correctness¶
sp.qte(method='quantile_regression')was labelled Firpo (2007) but computed a different estimand. It returns the coefficient onDin a quantile regression ofYonD + controls— a conditional QTE, which has no causal interpretation without rank invariance — while Firpo (2007) is the unconditional propensity-reweighted estimator. The numbers were never wrong for what they were; the name and the reference were. Renamed tomethod='conditional_qr'with the Koenker & Bassett (1978) attribution; the old name still works and raises aDeprecationWarning(removal in 1.23.0). The defaultmethod=changed from'quantile_regression'to'firpo_qte'. Relatedly,method='distribution'computes the QTT, not the QTE (treated quantiles are unweighted; only controls are reweighted byp/(1−p)); its numbers are unchanged but its label now says QTT. See MIGRATION.-
sp.qdidwas attributed to Athey & Imbens (2006) changes-in-changes. It implements QDiD,[Q₁₁(τ) − Q₁₀(τ)] − [Q₀₁(τ) − Q₀₀(τ)], which is the estimator Athey & Imbens propose CiC instead of and explicitly criticise; R'sqtepackage keepsQDiD()andCiC()separate for the same reason. Numbers unchanged; the method label, docstring and registry entry no longer claim CiC, and point tosp.cicfor the real thing. -
sp.dist_iv/sp.kan_dlatedid not estimate a quantile treatment effect. Both computed a Wald ratio of quantiles,[Q(τ|Z=1) − Q(τ|Z=0)] / [E(D|Z=1) − E(D|Z=0)]. The quantile operator is not linear, so the mean-Wald rescaling does not carry over and this expression is inconsistent for any quantile estimand. On a 30%/50%/20% always-taker/complier/never-taker design whose true complierQTE(τ) ≡ 2.0, the old code returned ≈ 4.0 at every τ at n = 200,000 — an asymptotic bias of exactly1 / Δp— and0.0at τ = 0.75 where the quantile grid crossed an always-taker mass point. Both functions now use Abadie (2002, 2003) κ-weighted complier CDFs, the estimatorsp.beyond_average_latein the same module already implemented correctly; the two now agree to 1e-10. Recovery on the design above is 2.003 / 2.014 / 2.023, and a scale-shift design whose true QTE is the fanΦ⁻¹(τ)is recovered to <0.06 at five quantiles. Also fixed alongside:covariates=was accepted and then silently discarded (it now selects the Frölich & Melly (2013) unconditional IV-QTE weighting and changes the estimate); a constant instrument returned an all-NaNresult object and now raises; a first stage that is positive only by sampling noise now emits a weak-instrument warning; and standard errors are now analytic influence-function based by default (Monte-Carlo coverage 0.950 / 0.957 / 0.963 against nominal 95%). New guards:tests/reference_parity/test_dist_iv_parity.py. See MIGRATION. -
sp.callaway_santanna(base_period='varying')omitted thee = −1event-study placebo. The (g, t) grid builder skippedt == g − 1 − anticipationfor every base-period scheme, on the reasoning that it is the reference period. That holds only underbase_period='universal'; under'varying'the base fort = g−1isg−2, so ATT(g, g−1) is a genuine estimable placebo — the one Rdidand Statacsdidboth report. With the cell restored,base_period='varying'now matches both references to the printed precision across the entire event study rather than only the post-treatment half. On canonicaldid::mpdta:e−3 = +0.030507,e−2 = −0.000563,e−1 = −0.024459,e0 = −0.019932,e1 = −0.050957,e2 = −0.137259,e3 = −0.100811. The defaultbase_period='universal'path is untouched, and post-treatment coefficients are unchanged under both schemes. Downstream consumers of the pre-period vector (sp.honest_did,sp.sensitivity_rr,sp.pretrends_test) now see one additional pre-period under'varying', which shifts their output. New parity guard:tests/reference_parity/test_cs_base_period_parity.py. See MIGRATION. sp.aggte(type='group')overall ATT was an unweighted mean of the cohort effects. Rdid::aggte(type="group")reportssum_g (p_g / sum_g p_g) * θ(g)— each cohort weighted by its share of treated units. StatsPAI collapsed the θ(g) vector with equal1/Kweights, so the overall figure was only right when every cohort happened to be the same size. Thecohort_sizesseries was already plumbed through fromsp.callaway_santannaand was being passed into the weight builder unused. Per-cohort θ(g) values in.detailwere always correct; only the headline scalar moves. Surfaced by thempdtareconciliation in the DiD reconciliation study. See MIGRATION.sp.aggte(..., bstrap=False)standard errors were anti-conservative by roughly a third. The non-bootstrap path summed the per-cell variances as though the ATT(g, t) cells were independent (sqrt(Σ wₖ² seₖ²)). They are not: cells share control units, so the omitted covariances are large and positive. Measured against the multiplier bootstrap on simulated staggered panels the analytic SE averaged 0.635× the correct value, which turns a nominal 5% Wald test into a ~21–23% rejection rate under a true null. Both the per-cell and the overall SE now aggregate through the influence functions (sqrt(mean((Ψ w)²)/n)), which is what Rdidreports and what thebstrap=Truebranch already computed for the overall estimate.sp.aggtedefaults tobstrap=True, so callers on the default path were unaffected; the bug bit explicitbstrap=Falsecallers and the automatic fallback taken when no influence matrix is available. A side effect is thatsp.aggte(type='simple', bstrap=False)now agrees withsp.callaway_santanna's own headline SE, which it previously contradicted for the identical estimand.sp.callaway_santanna's headline SE was already covariance-aware and does not change. See MIGRATION.- Both
sp.aggtefixes are now verified against Rdid2.3.0 on canonicaldid::mpdta: all four aggregations (simple,group,calendar,dynamic) matchdid::aggteto 10 decimal places on the point estimate — including the previously-wronggroupoverall (−0.0310182822) — and the analytic SEs land within 2.5% of R's, where the old independence formula gave ~0.64x. New guard:tests/reference_parity/test_aggte_mpdta_parity.py(12 ATT(g,t) cells plus all four aggregations). sp.cbpswas not solving the Imai-Ratkovic problem. The GMM was posed in the raw covariate basis with an empirical outer-product weighting matrix. CBPS is defined in a standardised, orthonormalised basis with the model-implied moment covariance frozen at the starting value, and neither the just-identified quadratic form nor the GMM weighting is basis-invariant — so the old code minimised a different objective and returned a different estimator. OnMatchIt::lalondethe ATE/over estimate was 8.6x offCBPS::CBPS(1585.99 vs 165.88) and ATT/over was 25% off; coefficients differed by up to 170%. The solver is now a faithful port (standardise → SVD → logit MLE → scalar rescaling on [0.8, 1.1] → BFGS on the balance loss → multi-start BFGS on the GMM loss). ATE (both variants) and ATT/exact now agree with R to ≤5e-3, and the just-identified variant balances covariates to <1e-6 |SMD| whereCBPS::CBPSleaves ~1e-3. Everysp.cbpsnumber changes. See MIGRATION.sp.ebalancedid not achieve the exact moment balance entropy balancing is defined by. The dual was minimised with L-BFGS-B on unscaled constraints, so on covariates with mixed scales the optimiser stopped early: onMatchIt::lalondethe reweighted control mean ofre74sat 2.66 away from the treated mean (1.3e-3 relative), and the convergence check could not catch it because it compared an absolute gap of 0.01 against moments measured in dollars. Replaced with Newton + backtracking line search on scale-normalised constraints, converged on the gradient. The relative moment gap is now ~1e-15 (ebal::ebalanceitself stops around 1e-7) and the ATT matchesebalto 3.2e-7. The convergence warning is now on the standardised gap. ATT estimates move in the 3rd significant figure. See MIGRATION.- Genetic matching's distance did not match its own documented
formula.
sp.genmatch's module docstring specified the generalised Mahalanobis distanceD' S^(-1/2) W S^(-1/2) D, but the kernel standardised by the control group's variances only, with an inline comment noting the whitening was "omitted for speed". The metricMatching::Match(Weight = 3, Weight.matrix = W)implements — verified pair-for-pair — is the diagonal of the full-sample variances. The kernel now uses those, and given the same diagonalWit reproducesMatching::Match's assignment on all 163 uniquely matched treated units ofMatchIt::lalonde. Genetic-matching results change; the docstring's claim about the full covariance has been corrected to describe what is actually computed (and what the reference computes). See MIGRATION. - Mahalanobis matching used the total covariance, not the pooled
within-group covariance.
sp.match(distance='mahalanobis')andsp.optimal_match(metric='mahalanobis')both built the metric fromcov(X)over the pooled sample. The Mahalanobis matching metric of Rubin (1980) [@rubin1980bias] — the reference the module already cited, and the oneMatchItuses — is the pooled within-group covariance[(n₁−1)S₁ + (n₀−1)S₀] / (n₁+n₀−2). The total covariance is inflated along the direction in which the group means differ, which is exactly the direction matching must resolve most finely. The new default (mahalanobis_cov='pooled') reproducesMatchIt:::mahalanobis_distto 1e-15;mahalanobis_cov='total'restores the old metric. Mahalanobis matching estimates change. See MIGRATION. sp.policy_treereturned a greedy tree while documenting an exact one. The module docstring promised that "for depth-1 and depth-2 trees, an exact solution is found via exhaustive search over all possible splits". The implementation instead scored each candidate root split as though both children were terminal leaves, then recursed — a one-step lookahead, which is exact for a depth-1 stump but not for depth 2, the default. It also subsampled candidate thresholds to at most 50 quantiles per covariate, so it did not return the greedy optimum over the full split grid either. Depth ≤ 2 now solves the Athey–Wager welfare objective exactly, by exhaustive search over the complete grid of distinct covariate values (statspai.policy_learning._exact_tree), matchingpolicytree::policy_tree'sx <= tsplit convention and its "smallest permitted terminal node" reading ofmin_leaf_size. On the new Track A module-70 fixture the old search fell 0.70% short of the welfare optimum and assigned 78 of 1200 units to the wrong arm; the exact search now reproducespolicytree1.2.4 to 9.6e-16 with all 1200 per-row policy decisions identical at both depths. Learned policies,value_policy,value_gain,fraction_treatedandrulesall change formax_depth=2. Depth ≥ 3 remains greedy (exhaustive search is combinatorially infeasible) and now says so via the newresult["search_mode"]field. See MIGRATION.sp.causal_forest.average_treatment_effect(target_sample='treated' /'control')did not use grf's ATT/ATC estimator. The docstring advertised "GRF-style ATE/ATT/ATC/ATO aggregation" and said the ATT/ATC scores use "the analogous Robins doubly-robust weighting". The ATE path did matchgrfexactly, but ATT/ATC divided a single Robins score byp̂₁, whereasgrf::average_treatment_effectreports a plug-in CATE average over the target arm plus a Hájek-normalised doubly-robust correction, and adds the two variance components rather than taking the dispersion of one score vector. Given identical forest outputs —grf's owntau.hat,Y.hat,W.hat— the old route agreed withgrfon the ATT point estimate to 9.3e-5 but returned a standard error 12% larger. That gap was invisible end-to-end, where it hid inside the module-13rel_se ≤ 0.50band. ATT/ATC now followgrf's decomposition exactly (grf_att_atc) and reproducegrf2.6.1's estimate andstd.errto 1e-15 on shared forest outputs. ATT/ATC point estimates move slightly and their standard errors move materially; ATE and ATO are unchanged. See MIGRATION.sp.dml(model='irm'|'iivm')standard errors usedn − 1where every neighbouring code path usedn. The unweighted IRM and IIVM branches normalised the influence-function variance withddof=1. Four other paths did not:sp.dml's own PLR and PLIV models, the weighted IRM / IIVM branches, and thenormalize_ipw/ATTE branch insideirm.pyitself (which usesmean(psi**2)). So the same dispatcher reported two different variance conventions depending on which score it took.DoubleMLnormalises byn. Both paths now do too, which makes the IRM/IIVM standard errors matchDoubleML1.0.2 to 1.1e-10 on a shared fold partition — previously they were larger by exactlysqrt(n/(n−1))(verified: observed ratio 1.00025009389849 againstsqrt(2000/1999) = 1.00025009378908). The effect is 0.025% at n = 2000 and grows as n shrinks (≈1% at n = 50); point estimates are unchanged. See MIGRATION.sp.dml_sensitivity's bias scaling factor omitted the treatment term. The module header states it implements the Chernozhukov– Cinelli–Newey–Sharma–Syrgkanis (2022) DML omitted-variable-bias bound, whose PLR scaling factor isS = sqrt(σ²ν²)withσ² = E[(Y − ℓ(X) − θ(D − m(X)))²]— the structural residual — andν² = 1/E[(D − m(X))²]. The code usedsd(Y − ℓ(X))for the numerator, leavingθ(D − m)inside it. Sincesd(Y − ℓ)² = σ² + θ²·sd(D − m)², this inflatesS, which overstates the bias bound and understates the robustness value — the analysis reported estimates as less robust to unobserved confounding than the bound actually implies. On a linear-nuisance PLR fit the bias bound came out 27% too large (0.0671 vs 0.0529) andRV_1was 0.454 instead of 0.533. With the structural residual,bias_boundand the adjustedthetabounds reproducedoubleml'ssensitivity_analysisto 2.5e-15 andRVto 9.2e-8.rv_q,rv_qa,bias_bound, the adjusted range, and every benchmark row change. IRM is untouched: it storesy_residas the score residualψ − θ̂, which is already centred. See MIGRATION.
Changed¶
-
⚠️
sp.honest_did(method='smoothness')now solves the real Rambachan-Roth FLCI (WP-2). The native path used to returnθ̂ ± M·(e+1) ± z·SE— a worst-case bias bolted onto a Wald interval, which ignored the pre-period covariance and came out narrower than the reference, overstating robustness. It now computes the optimal fixed-length confidence interval: a convex program (linear objective, linear constraints, one convex quadratic — solved with SLSQP, no new dependency) for the optimal affine estimator over the event-study covariance, in the newstatspai.did._flcimodule. -
⚠️ The R backend was itself being fed
diag(se²).sp.honest_did(backend='r')built its own diagonal covariance from the reported SEs, discarding the cross-period covariance — so the reference-backed path was solving a different problem from the one Rambachan-Roth define, and the two backends could not be compared. Both now receive the full event-study covariance, recovered from the Callaway-Sant'Anna influence functions asW (Ψ'Ψ/n²) W'. With matched inputs the native FLCI and RHonestDiD0.2.8 agree to ~7e-5 on the interval width on canonicaldid::mpdta; the residual isHonestDiD's own Monte-Carlo folded-normal quantile (10⁶ draws, ~2e-3 of quantile error), where StatsPAI inverts the CDF exactly.
Two consequences worth knowing: the interval is no longer centred on
θ̂ (its centre is the optimal affine estimator, which extrapolates the
pre-trend), and M=0 no longer reduces to the Wald interval —
Δ^SD(0) still permits an arbitrary linear pre-trend. R behaves the
same way. method='relative_magnitude' remains a worst-case-bias
approximation and still warns; a native ARP conditional/hybrid solver is
the remaining gap. New guard:
tests/reference_parity/test_honest_did_backend_parity.py.
See MIGRATION.
- ⚠️
sp.callaway_santanna(panel=False, estimator='reg')with covariates changed estimator. It previously residualised the outcome on the covariates using the never-treated pool with period fixed effects and then took 2×2 cell-mean differences — a StatsPAI-specific approximation with no reference implementation behind it. It now calls the Sant'Anna-Zhaoreg_did_rcinside each (g, t) cell, which is what Rdiddoes. ATT and SE both move; in exchange the result now matches R to ~1e-11. Repeated cross-sections without covariates are unchanged. See MIGRATION. sp.callaway_santannanow warns on unbalanced panels. ATT(g, t) is built from within-unit differencesY_t − Y_base, so a unit missing either period silently drops out of that cell and the effective sample varies cell to cell. Rdidtakes a different route entirely —allow_unbalanced_panel=TRUEswitches to the repeated cross-section estimator — so the two disagree materially on identical data (−0.0295 vs −0.0595 on a holed-outdid::mpdta). StatsPAI previously accepted such input silently and returned the panel-differenced answer, which reads as a parity failure when it is an estimator difference. The warning reports how many units and unit-period cells are missing and points atsp.balance_panel. Balanced input is unaffected and stays silent; no numerical output changed. Native repeated-cross-section support for CS remains open work.sp.honest_did(backend='native')now warns that it is not the Rambachan-Roth confidence set. The native path returnsθ̂ ± bias_bound ± z·SE— the worst-case bias added to an ordinary Wald interval — not the FLCI or the ARP conditional/hybrid confidence set, and it ignores the pre-period covariance structure. Verified against RHonestDiD0.2.8 on canonicaldid::mpdta: the two agree to <5e-3 undermethod='relative_magnitude', but undermethod='smoothness'the native interval is narrower at every M tested (width 0.046 vs 0.051 at M=0; 0.086 vs 0.097 at M=0.02), with the gap widening in M. Narrower means it overstates robustness to parallel-trends violations, so this is now a loudUserWarningpointing atbackend='r'for reference-backed intervals, rather than a docstring footnote. No numerical output changed. Implementing a native FLCI remains open. New guard:tests/reference_parity/test_honest_did_backend_parity.py.
Added¶
- TMLE re-anchored to
tmle::tmle(Track A module 72).sp.tmlewas alreadybit-exact, but against a frozen base-R fixture — a hand-rolledstats::glmTMLE written for the occasion. That certifies arithmetic against a reference StatsPAI itself specified. The anchor is nowtmle2.1.1 itself:psiagrees to 1.9e-9 and the standard error to 1.4e-11, with the initialQ/g1Wfits shared through the CSV so the row grades the targeting step alone. The frozen-glm fixture is retained as independent second evidence.sp.parity_status('tmle')['reference']now readstmle::tmle. sp.tmlegainsQ=,g1W=andfluctuation=.Q(an(n, 2)matrix of[Q(0,W), Q(1,W)], matchingtmle::tmle's argument) andg1Wbypass the Super Learner stage entirely, so the targeting step can be driven by externally-estimated nuisances.fluctuation=selects the submodel: the default'single'is the documented one-clever-covariate fit with a scalarepsilon(unchanged numerics);'per_arm'is the two-covariate submodel the R package uses and reports a 2-vector. The two are asymptotically equivalent but differ at finite n — about 1.3e-3 relatively on the module-72 fixture — which is why the parity row pins'per_arm'while the default stays put.model_infogainsepsilon_vec,fluctuationandnuisance_source;epsilonkeeps its scalar type under'single'and isNoneunder'per_arm', where no scalar exists.- Meta-learner cross-package parity.
sp.metalearnercarried only anexternal-replicationgrade: it reproduced DGP truths from the CausalML book, which certifies consistency, not that it computes the same function as anyone else. The S-, T- and X-learner CATE vectors are now pinned elementwise againsteconml.metalearners(observed max |Δ| ≤ 1.1e-15), moving the record tobit-exact. The grade is explicitly scoped: it covers the CATE functions thatlearner=selects, notresult.estimate, which is a doubly-robust AIPW ATE invariant tolearner=by design. sp.dml_panelaccepts the same learner aliases assp.dml.sp.dml(ml_g='linear')worked whilesp.dml_panel(ml_g='linear')failed inside scikit-learn'sclone()with a message naming neither the parameter nor the accepted values. Both now route throughresolve_learner.- Six DML / ML-causal estimators entered the parity index.
dml_panel,model_averaging_dml,super_learner,auto_cate,cate_evalandxlearnerwere absent fromsp.parity_statusentirely — indistinguishable, to a user or todocs/parity.md, from estimators with no evidence at all, despite having unit tests.tests/reference_parity/test_ml_causal_recovery_parity.pygives each a known-truth recovery or exact-identity check, so all six now carry an honestanalytical-onlygrade. The tests are written to discriminate: the X-learner check requires correlation with the true CATE surface rather than only its mean, and thecate_evalcheck requires RATE to separate the true CATE from a constant one. sp.dml_sensitivitypinned todoubleml'ssensitivity_analysis. The DML omitted-variable-bias analysis had no cross-package reference and was absent from the parity index. It is now pinned intests/external_parity/test_dml_sensitivity_parity.py, which caught the scaling-factor defect above. The reference is doubleml-for-py rather than R:DoubleML1.0.2's R6 classes expose no sensitivity method onDoubleMLPLRor on the base class, so the feature has no R counterpart to align against. Both engines share an explicit fold partition, and the test asserts the two PLR fits are identical before comparing any sensitivity quantity, so a failure can only be the sensitivity code.RVais asserted as a documented convention gap (~1.4e-3) rather than claimed exact: StatsPAI exhausts|θ| − z·sewith the unadjusted standard error,doublemllets the standard error move with the confounding scenario.- DML family cross-language parity (Track A module 71).
sp.dml's parity grade was certified formodel='plr'only — the index record said so — while IRM / PLIV / IIVM were pinned solely against doubleml-for-py, an optional import that skips silently when absent. All three are now pinned toDoubleML(R) 1.0.2 at the machine tier: PLIV agrees to 6.5e-16 (all nuisances closed-form least squares), and IRM / IIVM to 1.1e-10, the floor set by solving the same unpenalised logistic MLE with lbfgs on one side and IRLS on the other. Both engines consume the same explicit fold partition, so cross-fitting contributes no Monte Carlo term and the residual is the estimator alone.sp.parity_status('dml')now enumerates all four certified variants instead of naming only PLR. (The same index fix makessp.parity_status('decompose')report both its certified calls.) sp.dmlacceptsfold_indices=for every model class. Previously PLR only; IRM / PLIV / IIVM raised rather than silently ignore the argument. All four now share a_make_splitshelper, so a supplied partition fixes the split and the estimate stops depending onrandom_state. Supplying folds bypassesStratifiedKFold, so a partition that leaves a training fold single-classed now raisesDataInsufficientnaming the fold instead of fitting a degenerate propensity. See MIGRATION.- Clean-overlap coverage guard for the causal forest (Track B). The
existing Track B forest row stresses overlap loss; nothing evidenced
SE calibration on the clean-overlap DGP where module 13 grades the
point estimate.
tests/coverage_monte_carlo/test_coverage_robustness.py ::test_causal_forest_clean_overlap_ate_and_attcloses that gap, asserting ATE and ATT coverage inside [0.90, 0.98] on the module-13 DGP; a B=300 pilot measured 94.3% for both against a nominal 95%. That is what certifies the rewritten ATT standard error, rather than merely comparing it to grf's. - Causal-forest AIPW operator pinned exactly to
grf(module 13 strengthened). A causal forest factors into a stochastic part (the forest) and a closed-form part (the AIPW operator mapping(Y, W, tau.hat, Y.hat, W.hat)to scores, estimate and SE). Only the first resists cross-implementation pinning, but the previous evidence graded them together, leaving arel_se ≤ 0.50band that could not distinguish forest noise from a formula error — and, as it turned out, was hiding one (see the ATT fix above). The operator is now exposed asaipw_scores/grf_att_atcand pinned against a frozengrf2.6.1 fixture carrying grf's own forest outputs andgrf::get_scores()vector: StatsPAI reproduces the score vector elementwise to 2.3e-14 and grf's reported ATE and ATT (estimate andstd.err) to 1e-15 (tests/reference_parity/test_grf_aipw_operator_parity.py,_fixtures/_generate_grf_scores.R). With the operator exact, the module-13 band now covers forest RNG alone;rel_setightens from 0.50 to 0.25 and the record carries a factor-level note sosp.parity_status('causal_forest')reports what is exact and what is Monte Carlo. sp.policy_treegainsscores=,search=andsplit_step=.scores=accepts a pre-computed doubly-robust score vector and skips the internal cross-fitted AIPW step entirely, so the tree search can be driven by scores fromsp.causal_forest,sp.dml, or any external estimator — and, in the parity harness, by the same score vector R sees.search=selects'exact'/'greedy'/'auto'and the result records which one ran underresult['search_mode'].split_step=thins the candidate split grid the waypolicytree::policy_tree'ssplit.stepdoes.- Policy-learning cross-package parity (Track A module 70).
sp.policy_treeis now pinned topolicytree::policy_tree1.2.4 (tests/r_parity/70_policy_tree.{py,R}) at the machine tier, moving it fromanalytical-onlytobit-exactinsp.parity_status. The module shares the AIPW score vector through the input CSV, so both engines maximise the identical objective and the residual gap is a pure tree-search difference — 9.6e-16 across policy value, treated fraction and root split at depths 1 and 2. A companion test (tests/reference_parity/test_policy_tree_r_parity.py) asserts the two engines' full per-row policy vectors are elementwise identical, which a scalar comparison cannot establish, andtests/test_policy_tree_exact_search.pychecks the exact search against an independent brute-force enumeration over randomised problems with ties. sp.matchnow reproducesMatching::Matchexactly under matching with replacement. Two conventions were missing. (i)ties='all'pools every equidistant control and splits the weight instead of taking the lowest-index one, removing a row-order dependence; combined withtie_tolerance=1e-5it reproducesMatching::Match'sdistance.tolerancerule — a control counts as tied when its squared inverse-variance-weighted distance is within the tolerance of the minimum. (ii)se_method='abadie_imbens_pop'adds the Abadie-Imbens (2006) population ATT variance thatMatching::Matchreports by default, which carries a control-reuse penalty and a treatment-effect heterogeneity term. The existingse_method='abadie_imbens'is unchanged: it is the sample ATT conditional variance of Stata'spsmatch2 , ai(), a different estimand (the two differ by ~9% onMatchIt::lalonde). Together these give ATT and SE agreement withMatching::Matchat rel <= 1e-9 / 1e-8 for both M = 1 and M = 3.sp.sbw(tolerance_scale=...)— the balance tolerance now names its own units.deltawas always applied against the full-sample standard deviation, so a tolerance could not be reproduced againstsbw::sbw, which quotes it against either the target group (bal_std="target") or the reweighted group (bal_std="group"). Both are now selectable and reproducesbw::sbwto rel <= 4e-10 atbal_tol0.05 and 0.02; the previous behaviour remains the default undertolerance_scale='sd'. OnMatchIt::lalondethe conventions move the ATT by ~1% at the same nominal tolerance.- Matching / weighting cross-package parity. The module's headline
estimator (
sp.psmvsMatchIt) was pinned, butcbps,ebalance,optimal_match,genmatchandsbwhad unit tests only and no reference alignment — which is how thesp.cbpsandsp.ebalancedefects above survived. Newtests/reference_parity/ test_matching_r_parity.py(21 tests) pinssp.cbps,sp.ebalance,sp.matchandsp.optimal_matchagainst CBPS 0.24, ebal 0.2.1, MatchIt 4.7.2 and optmatch 0.10.8 onMatchIt::lalonde, with the fixture and generator committed (_fixtures/matching_lalonde.csv,_fixtures/matching_R.json,_fixtures/_generate_matching_r.R). Where the reference stops short of its own optimum the test asserts dominance on the estimator's defining objective rather than pinning R's slack; both remaining parity boundaries are registered assp.matchlimitations.sp.cbps,sp.ebalance,sp.matchandsp.optimal_matchare now carried in the parity index (sp.parity_status). sp.match(m_order=...)— greedy matching order is now a documented choice. Nearest-neighbour matching without replacement is order-dependent, and StatsPAI silently processed treated units closest-pair-first. OnMatchIt::lalondewith Mahalanobis distance the convention moves the ATT across a >5x range, so it belongs in the API, not in the implementation.'data'and'closest'reproduce the MatchIt rules of the same name exactly; the previous behaviour is'smallest_min_dist'and remains the default.sp.match(caliper_scale='raw'|'sd').'raw'(default) keeps the Statapsmatch2 , caliper()convention of a width on the distance scale;'sd'expresses it in standard deviations of the propensity score, matchingMatchIt(std.caliper = TRUE).sp.optimal_match(metric='propensity')for optimal matching on a fitted logistic propensity score, theoptmatch::pairmatch/MatchIt(method='optimal')idiom.OptimalMatchResultalso gains.att/.estimateaccessors — 1:1 matching on the treated targets the ATT, and the sole existing accessor was named.ate.sp.ebalancenow reportsweights_full, the entropy-balancing weights laid out over every retained row (treated = 1) so they can be joined back onto the input frame.weightsremains control-only, theebalconvention.max_standardized_moment_gapis also recorded.- Repeated cross-sections for
sp.callaway_santanna(WP-1).panel=Falsepreviously accepted onlyestimator='reg', forcedcontrol_group='nevertreated', and refusedbstrap— which left CPS/ACS/DHS-style data with no usable estimator. The (g, t) loop now hands each two-period sub-sample to the matching Sant'Anna-Zhao estimator, which is what Rdid::att_gt(panel=FALSE)does (confirmed fromdid:::compute.att_gt):dr→DRDID::drdid_rc,ipw→std_ipw_did_rc,reg→reg_did_rc. All three estimators, both control groups, andbstrapnow work, and the per-cell influence functions feedsp.aggte's multiplier bootstrap and event-study aggregation unchanged.
Parity vs R did 2.3.0 on did::mpdta (xformla=~lpop): all six
est_method × control_group combinations match to ≤1e-11 on the
point estimate and ≤0.15% on the SE. New guards:
tests/reference_parity/test_cs_rcs_parity.py (20 tests) and
tests/reference_parity/test_drdid_rc_parity.py (12 tests) for the
underlying primitives in the new statspai.did._rcs module.
clustervars remains unsupported under panel=False and raises —
observations are not nested in units there, so the cluster bootstrap
needs a separate design.
- Nonlinear ETWFE — sp.etwfe(family='poisson'|'logit'). Wooldridge
(2023) staggered DiD for count and binary outcomes, closing the largest
capability gap in the DiD family: until now every DiD estimator in
StatsPAI forced a linear model, so count outcomes (mortality, arrests,
patents), binary outcomes (employment, take-up), and shares had no
correct estimator. Fits the saturated Wooldridge/Mundlak design —
cohort dummies + period dummies + post-treatment cohort×period
interactions — by maximum likelihood with cluster-robust SEs, then
reports the average marginal effect on the response scale with a
delta-method SE. That is the R etwfe::emfx(type='simple') estimand,
so a Poisson fit returns an effect in counts and a logit fit a
probability difference, not a link-scale coefficient.
model_info['event_study'] carries the per-event-time AMEs and
.detail the per-cohort AMEs.
Verified against R etwfe 0.6.2 on simulated panels: the simple AME
matches to 1e-10 for both families (Poisson 1.2720480537, logit
0.2438009516) and every event-time AME to 1e-6, with SEs agreeing to
~1e-5 relative (fixest and statsmodels apply different
finite-sample corrections to the clustered sandwich). Both R-generated
fixtures are committed. New guard:
tests/reference_parity/test_etwfe_glm_parity.py (19 tests).
family=None/'gaussian' is the default and the existing linear path
is byte-for-byte unchanged (mpdta cgroup='notyet' still −0.0477099183).
The nonlinear branch raises rather than silently ignoring xvar,
panel=False, or cgroup='nevertreated', and rejects negative
outcomes under Poisson / non-0-1 outcomes under logit.
- Cross-software parity coverage for sp.sun_abraham and
sp.gardner_did on the canonical did::mpdta panel, closing two
rows that previously had no matched-option runner
(tests/reference_parity/test_sunab_did2s_mpdta_parity.py). Against
fixest 0.14.0, every Sun-Abraham IW event-study coefficient matches
fixest::sunab to ≤1e-6 and the SEs to ≤1%; aggregation='fixest_att'
reproduces fixest::summary(agg='att') to 1e-8 (−0.0399512752).
Against did2s 1.2.1, sp.gardner_did's point estimate matches to
~1e-8 (−0.0477099). Two conventions are now pinned rather than
folklore: sp.sun_abraham defaults to aggregation='event_time'
(equal-weighted across event times), which on this unbalanced panel
gives −0.0772 against fixest's treated-cohort-size-weighted −0.0400;
and sp.gardner_did's default analytic SE runs ~18% below R's
two-stage SE, which vce='bootstrap' recovers to within ~3%.
- CS inference parity batch (Stata csdid / R did gap-closure, part 1).
sp.callaway_santanna gains the full R did::att_gt inference surface:
bstrap= (multiplier-bootstrap SEs per ATT(g,t) — the Stata
csdid, wboot path), biters= (replications; Stata reps()),
cband= (uniform sup-t confidence bands, cband_lower/cband_upper
columns in .detail + model_info['crit_val_uniform']),
clustervars= (two-level clustering beyond the unit id, R mboot
convention: at most one extra time-invariant variable; requires
bstrap=True — analytic SEs would silently understate otherwise, so
passing clustervars without bstrap raises), boot_weight_type=
('rademacher'/'mammen'), and random_state=. sp.aggte picks up
the cluster ids automatically, so downstream aggregations inherit the
clustering. Parity vs R did 2.3.0 (biters=9999): ATT(g,t) point
estimates ≤ 1e-6, bootstrap SEs within 3%, uniform critical values
within 2%, clustered SEs within 10% (15-cluster fixture; see
tests/reference_parity/test_cs_inference_parity.py).
- Influence-function export + post-hoc aggregation (Stata
csdid saverif() equivalent). New sp.influence_functions(result,
path=) exports the per-unit ATT(g,t) influence functions of a
callaway_santanna fit as a tidy, self-contained DataFrame (CSV /
parquet), and sp.aggte_from_influence(source, type=, **aggte_opts)
recomputes any aggregation — event-study, group, calendar, overall,
with multiplier bootstrap and uniform bands — from the export alone,
no refit and no original data. Round-trips are exact
(aggte_from_influence(influence_functions(cs)) ≡ aggte(cs) at the
same seed), including clustered fits (a cluster column is carried
through). New shared primitive did._core.multiplier_bootstrap
hosts the (cluster-aware, weight-type-aware) bootstrap used by both
paths.
- BJS did_imputation depth batch (Stata did_imputation gap-closure,
part 2). Six new options mirroring Borusyak-Jaravel-Spiess's Stata
command: pretrends=k (estimate the k placebo horizons -k..-1 and
report their joint Wald in model_info['pretrend_test']; the in-fit
test stays independence-based/conservative and points to
sp.bjs_pretrend_joint for the covariance-aware version),
balanced=True (Stata hbalance — keep only eventually-treated units
observed at every non-negative requested horizon, with a loud warning
listing drops; the vce='bootstrap' resample uses the balanced
sample), min_n= (Stata minn() — drop thin event-study horizons,
excluded from the pre-trend test), hetby= (heterogeneous ATTs by a
time-invariant unit variable, IF-based SEs, in
model_info['hetby']), save_weights=True (Stata saveweights() —
the exact estimation weights w with ATT = w'y, treated rows
1/N1, untreated rows the negative FE-projection weights; the
identity w'y == ATT is test-enforced), and save_residuals=True
(Stata saveresid() — untreated-fit residuals, NaN on treated
rows). Aliases sp.bjs / sp.borusyak_jaravel_spiess inherit all
six.
Changed¶
-
⚠️ Multiplier-bootstrap weights: Mammen → Rademacher (R parity). The CS multiplier bootstrap (
sp.aggte, and the newsp.callaway_santanna(bstrap=True)) now draws Rademacher (±1) weights by default instead of Mammen two-point weights. The Rdidpackage's documentation cites Mammen (1993), but what its implementation actually draws is Rademacher (BMisc::multiplier_bootstrap; verified empirically against BMisc 1.4.x — the weights take values ±1 with equal probability). With few clusters the two distributions give visibly different IQR-rescaled SEs (up to ~10% at 15 clusters), so matching R requires matching the implementation, not the citation. Point estimates are unchanged; bootstrap SEs move by draw noise (same asymptotic variance). Pinned test values re-pinned;boot_weight_type='mammen'remains available onsp.callaway_santanna. -
Conley spatial + time HAC (
sp.conley).sp.conleygainstime=,lag_cutoff=,time_kernel=,unit=,lag_cutoff_cross=, anddistance=for a full spatio-temporal HAC in the Hsiang (2010) / Stataacregstyle: a spatial kernel times a time kernel (uniform or Bartlett), with per-unitde-duplication so the KD-tree is built on the distinct locations (e.g. 575 counties) rather than every panel row. Standard errors match Stataacreg ..., spatial lag() id() time() hac bartlettto ~1e-14 and the 575×262 = 150,650-row stress case runs in ~2 s at ~280 MB (the dense form would need ~180 GB). Spatial-only calls are bit-identical to before. [@conley1999estimation; @hsiang2010temperatures] - Varying-slope fixed effects in the native HDFE kernel.
sp.hdfe_olsnow absorbsi.f#c.x(slope-only) andi.f##c.x(intercept + slope), plus the fixestf[x]/f[[x]]forms, via newsweep_slopekernels in the alternating-projections engine (weighted paths included). Coefficients, SEs, ande(df_a)match Statareghdfe absorb(i.f#c.x)to ~1e-6 across 19 kernel and 41 formula configurations. Thepanel/feols.pyparser now acceptsa:b,a*b,f1^f2,i.f#c.x,i.f##c.x, andf[x]on both sides of|, matching the pyfixest-backedsp.feols.sp.SlopeSpecexposes the programmatic form. - Event-study binning and interval reference periods.
sp.event_studygainsbin_width=(decade-style bins anchored at the treatment boundary, so τ=-1 and τ=0 never share a bin) and an extendedref_period=accepting("<=", -50)/(">=", 20)/[-3, -2, -1]for a whole omitted base span — the standard AER event-study layout. Plain-intref_periodis bit-identical to before. sp.parallel_trends_robustness. One call chains the joint pre-trend test, Roth (2022) power, and Rambachan-Roth (2023) honest CIs with a breakdownMbar*per restriction family (SD / RM), returning a result with.summary()/.plot()/.to_latex()and a one-line verdict. [@rambachan2023more; @roth2022pretest]sp.ciccovariates (Athey-Imbens two-step).sp.cic(..., covariates=)residualizes on the covariates and the group×time design (A&I 2006 p. 466) and bootstraps both stages jointly, re-fitting the first stage inside every replicate. Bootstrapping step 2 alone (the hand-rolled feols→residuals→cic recipe) holds the first-stage coefficients fixed and understates the SE by ~4-8%.- GIS panel pre-processing (
sp.line_length_in_polygon,sp.share_within_buffer,sp.distance_to_feature). Thin geopandas wrappers (optionalpip install statspai[spatial]extra) with a strict CRS guard: they refuse to compute a length or buffer in a geographic CRS (degrees) and name a suitable projection instead, rather than returning plausible-but-meaningless numbers. New guidedocs/guides/gis_panel_construction.md. -
Agent-native negative guidance.
FunctionSpecgainsnot_recommended_whenandcost_profile; ~26 high-risk entries (the DiD family, the dense-O(n²)feols/hdfe_ols/ppmlhdfevce="conley"paths, the synth family,optimal_match) now tell an agent when not to call them and what they cost, surfaced on the description, agent card, and JSON schema. -
References sections on core estimator docstrings.
sp.did,sp.did_2x2,sp.iv(module +IVRegression+ legacyivreg),sp.metalearner,sp.match,sp.aipw,sp.aggte,sp.event_study, andsp.tmlenow cite their foundational literature withpaper.bibkeys (all entries verified via Crossref/DOI;goodman2021differenceadded to the rootpaper.bibfrom the already-verifiedpapers/copy). - Loud-degradation regression tests. New tests pin that
sp.session()warns when torch/jax RNG snapshot/seed/restore fails (fake-torch harness) and thatsp.dist_ivwarns when the quantile-IV point estimate itself is NaN (degenerate instrument). - All six core modules now meet the ≥95% coverage target. 139 new
assertions-first gap-fill tests (
test_cov95_did_gapfill_r2.py,test_cov95_rd_gapfill_r2.py,test_cov95_synth_gapfill_r2.py) liftdid93.3% → 95.9% (+171 lines),rd+52 lines,synth+65 lines;iv/dml/panelwere already above target.
Changed¶
sp.feolsrefuses silently-dropped varying slopes. The pyfixest backend acceptsy ~ d | county + pref[year]but does not absorb the slope — it returned the same estimate asy ~ d | county(off Statareghdfeby ~5× on the test fixture) with no warning.sp.feols/sp.fepois/sp.feglmnow raiseMethodIncompatibilitynaming the two correct paths (i(f, x)on the RHS, orsp.hdfe_olswithi.f#c.x).- Conley SE coordinate alignment fails loudly instead of mis-pairing.
When the estimator dropped rows (missing values / singleton FE), the
vce="conley"path had no reliable record of which rows survived, so positional coordinate pairing could attach a residual to the wrong location. It now requires the coordinate frame to line up one-to-one with the design and, when it doesn't, raises with adropna/reset_indexrecipe rather than guessing. sp.event_studyexposes the pre-period covariance (opt-in this release). It now computes the full cluster-robust covariance of the event-time coefficients (always inmodel_info['vcov']). The pre-period submatrix that flipspretrends_test/pretrends_power/sensitivity_rr/honest_didfrom the historical diagonal (independent-pre-coefficients) approximation onto the correct covariance is written only withexpose_pre_vcov=Truefor now, to hold published honest-DiD numbers stable during the JOSS review; the diagonal fallback still fires by default and now warns loudly that it is assuming independence. The default becomes the full covariance in a future release (as a flagged ⚠️ correctness fix).sp.pipeline_did's honest-DiD stage now runs. It calledhonest_did(betas=, sigma=, num_pre_periods=, …), a signature that has not existed for several releases, so the stage raised and was recorded asfailedon every run; a second guard using a stale extractor skipped it even when the call was fixed. Both are corrected, and an inapplicable design (a 2×2 DiD with no pre-periods) is now reported asskipped, notfailed. Dead legacybetas=/sigma=branches removed fromagent/workflow_tools.pyandagent/pipeline_tools.py.- Recoverable agent-tool argument errors. Missing-argument errors on the
agent surface (
detect_design,preflight,cross_validate,honest_did_from_result) now follow "expected argument X, got […]; trycorrected_call(...)" instead of a bare requirement string. sp.session()RNG hooks fail loudly. The fourexcept Exception: passsites around torch/jax RNG snapshot/seed/restore now emitStatsPAIWarningdescribing exactly which reproducibility guarantee was lost. Thetests/test_no_silent_degradation.pyratchet is now atBARE_SWALLOW_MAX = 0/SILENT_NONE_MAX = 0— the orchestration silent-degradation debt is fully paid down.sp.dist_ivwarns when the point estimate is NaN for any requested quantile (degenerate instrument split or first stage below 1e-6) instead of returning silent NaNs;sp.ivqregwarns when Brent refinement of the profile objective fails and the grid-search optimum is retained;CausalWorkflow.cate()routes per-learner CATE extraction failures throughrecord_degradation; narrow-except cleanups inworkflow/paper.py,workflow/causal_workflow.py, andagent/_resources.pystop masking genuine registry/coercion bugs.- Silent nuisance / bootstrap degradations across the DiD & DR family now
fail loudly. A second silent-degradation audit closed a further batch of
except Exceptionsites that had degraded numerical results without a signal: (a)sp.callaway_santanna's covariate propensity-score logit and outcome regression, andsp.aipw's propensity/outcome nuisances, now emit aConvergenceWarningwhen a covariate fit fails and the estimator falls back to an unconditional (constant) nuisance — previously the point estimate (CS) or the influence-function SE (AIPW) silently changed with no warning; (b) six DiD bootstrap loops (gardner_did,did_imputation,did_timevarying_covariates,ddd_heterogeneous,did_multiplegt_dyn,continuous_did) migrated fromexcept: continue+np.nanstdover survivors to the sharedcore._bootstrap.bootstrap_se, which reports the replicate failure rate (e.g. "3/199 replicates failed"); (c)CausalWorkflow.estimate()'s two estimator-substitution fallbacks now callrecord_degradation(emitWorkflowDegradedWarning+ populatewf.degradations) instead of only a free-text pipeline note; (d) the agent serializer surfaces acoefficients_error/fields_errormarker instead of silently dropping the coefficient block, andpaper.py's reviewer-audit catch sites record degradations. Point estimates and SEs on the healthy (non-failure) path are unchanged; only the previously-silent failure paths now warn. - Paper materials (
papers/) regenerated and hardened. The arXiv manuscript draft is renamed tostatspai-paper-arxiv.md(fixing the misspelled filename), rewritten against v1.20.0 reality (1,139 functions / 87 submodules, validation-tier and parity-matrix infrastructure, agent-native P1 features), and all result tables are regenerated with pinned software versions.run_experiments.pygains a timing warm-up, a seededsp.aipwcall, Abadie–Imbens matching SEs, and a CATE-quality (RMSE/correlation vs known truth) meta-learner table replacing the pre-v1.11.4 per-learner ATE framing;run_replication.py's Lee (2008) exercise now runs on the realrdrobustSenate extract with the pinned triangular/CCT configuration (robust estimate 7.51pp vs Lee's ≈7.99pp headline) instead of crashing on stale column names.
⚠️ Correctness¶
These change point estimates (or turn silent wrong answers into errors) for
the affected calls. See MIGRATION.md before comparing new output to earlier
StatsPAI runs.
sp.cicnow reproduces the Athey-Imbens estimator (Statacic). The step-2 counterfactual composed the empirical CDFs with the control-post (y01) and treated-pre (y10) cells transposed relative to A&I (2006) eq. 9, and used linearly-interpolated CDF/quantile functions on a finite τ grid instead of the step-function ECDF and its generalized inverse. The unconditional ATT converged ~0.5% away from the reference (2.8% with covariates). It now computesk(y) = F_01⁻¹(F_00(y))on the step ECDF and matches Kranker's Statacic(a direct port of the A&I Matlab) to the printed digits — e.g. 2.999904 on the test fixture, where the old grid-dependent code gave 3.01792 at the defaultn_grid=200(3.01388 in the large-grid limit). Everysp.cicpoint estimate and QTE moves slightly. [@athey2006identification]- Multiway cluster-robust SEs in the panel HDFE path no longer collapse.
sp.hdfe_ols/sp.feols' native N-way cluster sandwich built its intersection clusters by"\0".join-ing the dimension labels, butpd.factorizetruncates object strings at an embedded NUL byte — so every intersection collapsed onto its first cluster variable and, e.g.,cluster(prov, year)andcluster(pref, year)returned identical SEs. Replaced with a mixed-radix integer code combination (_factorize_multi), mirroring the v1.17.0 fix already applied to the standalonesp.multiway_cluster_vcovinference path. Two-way and higher cluster SEs from the panel HDFE path change (toward Statareghdfe). - Conley non-positive-definite variances now report
nan, not0. Kernel-weighted spatial HAC is not PSD by construction; with a uniform kernelS'WSroutinely lands with negative diagonal entries (Stataacregreports those SEs as missing). Every Conley path (sp.conley,feols/hdfe_olsvce="conley") previously ran the variance throughsqrt(max(V, 0)), silently turning a negative variance intose = 0— i.e.t = ∞,p = 0. Such terms now returnnanwith a loudRuntimeWarningnaming remedies (rounding-level negatives are still clamped silently). The underlying covariance is unchanged and still matchesacregto ~1e-12 where it is PSD. sp.proximal_surrogate_indexpoint estimates no longer depend on the units of the proxyW. The linear bridge was fit with a second-stage design[1, W, S_hat, X], butS_hat(the stage-1 projection ofSon[1, W, X]) is an exact affine function of those columns, so the design was rank-deficient and the "bridge slope" wasnp.linalg.lstsq's minimum-norm artifact — rescalingWby 10 moved the confounded-ATE estimate by two orders of magnitude on identical data. The second stage now excludesW(classical 2SLS exclusion:Y ~ [1, S_hat, X], i.e. the bridge momentE[(Y - h(S,X)) · (1, W, X)'] = 0), making the estimate invariant to instrument scaling and recovering the true long-term ATE on the persistent-confounding DGP (population bridge slopeCov(Y,W)/Cov(S,W)). Every previousproximal_surrogate_indexestimate moves — earlier numbers were unit-dependent artifacts. Also new: under-identification (len(proxies) < len(surrogates)) and a rank-deficient projected first stage now raise loudly instead of returning a minimum-norm solution. Guards:tests/reference_parity/test_surrogate_parity.py::test_proximal_surrogate_index_recovers_confounded_ate(previously skipped) and::test_proximal_surrogate_index_invariant_to_proxy_units.sp.callaway_santanna(control_group="nevertreated")now fails loudly when the panel has no never-treated units instead of silently returningATT = 0.0. With every unit eventually treated, eachATT(g,t)lost its comparison cell and returned0.0, which aggregated to a headline ATT of0.0— a wrong number that reads as "no effect" rather than an error (no warning was emitted). The estimator now raisesMethodIncompatibilitywith a hint to usecontrol_group="notyettreated"or add never-treated units. The internal group encoding treatsNaN/infgas never-treated (0), so a panel with any never-treated (includingNaN-coded) unit is unaffected, andcontrol_group="notyettreated"is unaffected. No previously-valid estimate moves — only the silent0.0degenerate path changes. New guard:tests/tier_eg/test_did_robustness.py::test_cs_no_never_treated_control_documented.-
sp.eigenvector_centralityon bipartite graphs now returns the true leading eigenvector instead of a spurious near-uniform vector. The score was computed by naive power iterationx <- A x, which oscillates on a bipartite graph (its spectrum is symmetric,lambda_max = -lambda_min) and never converges — a star returned ~1/sqrt(n)for every node instead of a dominant hub. The leading eigenvector is now recovered by direct eigendecomposition (eighfor undirected,eigfor directed), so a star hub scores1/sqrt(2)and its leaves1/sqrt(8), matching the igraph / networkx convention. New guard intests/reference_parity/test_network_centrality_parity.py. -
sp.ges(Greedy Equivalence Search) no longer adds a spurious edge between the parents of a collider. The greedy edge search had no acyclicity constraint, so on a v-structureX -> Z <- Yit would add edges into and out ofZ; conditioning on the collider then made the independent parentsX,Ylook dependent and a falseX -- Yedge was added (observed on every seed of a strong-signal collider). The search now rejects edges that create a directed cycle, and the recovered DAG is reported as its CPDAG (v-structures stay directed, reversible edges render undirected). Colliders now recover the correctX -> Z <- Y; chains recover the undirected skeleton. New guard:tests/reference_parity/test_ges_parity.py. -
sp.dist_iv/sp.kan_dlatewith a binary instrument no longer return a silent all-NaNlate_q. The Wald quantile-LATE split used a strictZ > median(Z)rule, which leaves the high group empty whenever the median sits on the top mass point of a discrete instrument (a binaryZwith more 1s than 0s hasmedian == 1, soZ > 1is empty) — roughly half of ordinary data draws returned NaN at every quantile with no error. The split now falls back toZ >= medianso both instrument groups are non-empty, giving up (a single NaN) only when the instrument is genuinely constant. Estimates on the previously-working draws (including the documentedseed=42example) are unchanged. New guard:tests/reference_parity/test_dist_iv_parity.py. sp.contrast/sp.pwcomparewithC(var)categoricals now return the correct treatment contrasts instead of all zeros. The predictive-margin engine matched design-matrix terms by raw column name, so a model fit with a formula-encoded factor (terms likeC(g)[T.1]) never fired the dummies when a level was set — every contrast, SE, and p-value came back0. The margin builder now parsesC(var)[T.level](and string levels) so the reference / adjacent / pairwise contrasts equal the corresponding dummy coefficients exactly (verified to ≤1e-15). Models specified with numeric-coded factors were already correct and are unchanged. New parity guard:tests/reference_parity/test_contrast_pwcompare_parity.py.sp.did_multiplegtDID_M / dynamic / placebo now conditions switcher-vs-stayer cells on the baseline treatmentd_{t-1}, matching the de Chaisemartin–D'Haultfoeuille estimator and Statadid_multiplegt (old). Static DID_M no longer pools already-treated stayers with untreated stayers, switch-off cells enter with the treatment-gain sign, dynamic effects use robust stayers that keep the baseline treatment through the full horizon, and placebo effects use the Stata mirror sign convention. New parity guards cover both Stata-pinned static panels and hand-checkable dynamic/placebo regressions.sp.sar/sp.sdmcoefficient standard errors now come from the full(β, ρ, σ²)information matrix (the leading block of its inverse), matchingspatialreg::lagsarlm. The previous concentratedσ²(XᵀX)⁻¹treatedρas known and understated the coefficient SEs — most visibly ~2× too small on the intercept for a row-standardisedW. Point estimates and theρ/λSEs are substantively unchanged; the boundedρ/λML optimiser was also tightened (xatol=1e-10), shifting estimates by ≲1e-5 toward the exact MLE.sp.semandsp.slxstandard errors are unaffected. SeeMIGRATION.md.sp.etwfecontrol-group and headline aggregation now honors the publiccgroupcontract. The defaultcgroup="notyet"headline is the treated-observation-weighted simple ATT used by Retwfe::emfx(type="simple")and Statajwdid, estat simple;cgroup="nevertreated"now matches Retwfe(cgroup="never").sp.wooldridge_didremains the historical saturated TWFE helper, andsp.etwfe_emfx(..., weighting=)still exposes the historical cohort-share aggregation throughweighting="cohort".sp.event_studyheadline ATT SE now uses the full coefficient covariance. The overall ATT is the mean of the post-period event-time coefficients, but its SE was computed assqrt(mean(se²)/m)— treating those coefficients as independent even though the full cluster-robust covariance was computed (and returned inmodel_info["vcov"]) right above. Event-time coefficients share a reference period and fixed effects, so the off-diagonal terms are large: on a 60-unit staggered test panel the old headline SE was 0.129 vs. the correctw'Vwvalue 0.226 (~2× understated; p-values and CIs correspondingly overstated significance). The headlineestimateis unchanged;se/pvalue/cimove for everysp.event_studycall. The same independence approximation is fixed insp.design_robust_event_study(validated against a 400-draw cluster bootstrap of the full procedure: analytic 0.3065 vs bootstrap 0.3101, where the old formula gave 0.2414) andsp.cohort_anchored_event_study(the per-event-time bootstrap loops were merged into one joint cluster-bootstrap so the headline SE is the bootstrap SD of the post-period average itself; the old per-kloops also re-seededdefault_rng(0)per event time, so this is faster as well). Theor 1e-6fabricated-SE fallback in both helpers is gone — an unavailable SE is nowNaNplus a warning, never a made-up number.sp.event_studypre-trend joint test is now cluster-robust. The per-coefficient SEs were cluster-robust, butmodel_info["pretrend_test"]plugged the classical homoskedasticσ²(X'X)⁻¹into the joint F quadratic form — invalid under within-cluster serial correlation and inconsistent with the SEs printed next to it. It is now a Wald test on the same cluster-robustvcovblock withF(q, G-1)(Stata convention); the result dict gains adf_denomkey and thetestlabel changed to"Cluster-robust joint Wald test on pre-treatment coefficients".- Weighted HC1-robust SEs in
sp.did_2x2/sp.ddd(Stata parity). With analytic weights therobust=Truebranch built the sandwich meat asX'diag(w·e²)X, but the WLS score isw·x·e, so the correct meat isΣ w²e²xx'(Stata aweight-robust / Rsandwichconvention). SEs were ~9% off Stataregress ..., [aw=w] robuston dispersed weights — while the cluster branch in the same functions squared the score correctly all along. Both now match Stata 18 MP to machine precision (~2e-16), pinned intests/reference_parity/test_did2x2_ddd_weighted_robust_parity.py. Unweighted and clustered SEs are unchanged. sp.parallel_trends_robustnessno longer inverts the verdict for maximally robust effects._breakdown_for_familyreturnsinfwhen the honest CI still excludes zero at the upper search bound (Mbar = 1e4) — the most robust possible outcome — but the verdict builder'snot np.isfinite(...)guard routedinfinto the "NOT robust: the CI already includes zero at M = 0" message: the exact opposite of the truth, for any effect large relative to its SE (e.g. outcomes in raw currency units).infnow gets its own "robust over the entire searched range" verdict; NaN (failed) families are excluded from the binding-familymin(previously order-dependent) and reported in an explicit note. Thebreakdown/ci_gridtables were always correct — only the verdict sentence was wrong.sp.conleyrefuses duplicated(unit, time)cells. The spatio-temporal cross-unit block resolves rows through a single-valued(unit, time) → rowlookup (last-write-wins), so a duplicated cell silently kept only one duplicate row in the cross-unit terms while the within-unit block kept them all — an inconsistent hybrid with wrong SEs (a dense-reference check diverges by O(1) on the meat with one duplicate row and agrees to ~9e-16 without). Realistic trigger: passing a coarser geography than the row level (unit="county"on plant rows), which passes the coordinate-constancy guard. Now raises aValueErrornaming the offending unit — the same restriction Stata'sacregimposes.
Added¶
- Every result class now exposes the full export protocol
(
to_dict/to_latex/to_markdown/to_excel/to_word/cite).ResultProtocolMixingained real default implementations —to_markdown()(GitHub table of the scalar fields),to_excel()(two-column Field/Value.xlsxvia openpyxl), andto_word()(delegates to a bespoketo_docxwhen the class defines one, else writes a Field/Value.docxtable via python-docx) — and the mixin was attached to the ~165 result classes that previously only had.summary()(epi, iv, rd, fast, conformal_causal, causal_discovery, survival, bayes, timeseries, spatial, smart, causal_rl, mendelian, bounds, qte, decomposition, and ~25 more modules) — all 286 result classes now expose the full protocol. Bespoke exporters always win over the mixin defaults (ordinary MRO), so no existing output changes.cite()remains zero-hallucination: it only returns verifiedpaper.bibkeys. sp.function_schemanow returns real parameter lists for the 9 dispatchers that previously exposed empty schemas (multi_cutoff_rd,geographic_rd,boundary_rd,multi_score_rd,anderson_rubin_ci,conditional_lr_ci,prevalence_ratio,diagnostic_test,etable). Thin*args/**kwargsaliases now transplant their target's__signature__(sohelp()is informative too), and purely variadic dispatchers fall back to their documented NumPy-styleParameterssection when the signature carries no named parameters.sp.rddispatcher docstring gained a runnable doctest example (default CCT path +method="honest"), closing the last Tier-1 dispatcher without a>>>example.- New loud-fallback contract tests (
tests/test_loud_fallbacks.py) pinning the silent-degradation fixes below.
Fixed¶
CausalResult.to_latex()emitted non-compiling LaTeX. Thetablenotesblock sat directly inside\begin{table}with nothreeparttablewrapper, so pasting the default DiD/RD table into a paper hard-failed pdflatex withEnvironment tablenotes undefined. The body is now wrapped inthreeparttable(verified to compile) and the method gains apath=kwarg to write the source to file, matching every sibling exporter (to_markdown/to_html/to_excel/to_word).caption/labelbecome keyword-only; a single positional argument is now the file path (r.to_latex("att.tex")), which is what the docs already showed.sp.bootstrapBCa intervals had a biased acceleration jackknife. Three defects in theci_method="bca"path: (a) a failed leave-one-out replicate was imputed withtheta_hat, zeroing its deviation and biasing the accelerationatoward 0 (BCa silently degrading toward BC) — failures are now dropped and reported viaRuntimeWarning; (b) the jackknife was capped at the first 200 rows, which is systematically biased when the data are ordered — it now takes a random 200-unit subsample with a loud warning thatais approximate; (c) undercluster=the jackknife deleted individual rows while the bootstrap resampled clusters — it now leave-one-cluster-out, matching the resampling unit.- Four registered functions were unreachable as
sp.<name>.particle_filter,openai_client,anthropic_client, andecho_clientappeared insp.list_functions()but only resolved via the submodule path (sp.assimilation.*/sp.causal_llm.*), so an agent iterating the registry and callinggetattr(sp, name)crashed on those four. They are now lazily re-exported at top level (design principle #1); all 1,145 registry names resolve assp.<name>, and cold-import laziness is preserved (verifiedstatspai.causal_llmis not eagerly imported). - Estimators no longer reset the caller's global NumPy/torch RNG.
sp.notears,sp.deep_iv, the neural-causal models (tarnet/cfrnet/dragonnet), and the DML cross-fit engine callednp.random.seed(...)/torch.manual_seed(...)on the global RNG because the libraries they wrap read from it — silently resetting a user's own random stream mid-session (a hard-to-trace reproducibility bug). A newpreserve_global_rngdecorator snapshots and restores the global RNG state around each fit; internal draws are byte-identical (samerandom_state→ same result, verified), so no numbers move — only the leak is removed. sp.kaplan_meierexported empty files without warning.KMResultis not a dataclass and keeps all of its state in private attributes (_tables,_alpha,_provenance), exposing the data through thesurvival_table/median_survivalproperties. The mixin's non-dataclass fallback harvested only public__dict__entries, soto_dict()returned{}andto_excel/to_word/to_markdown/to_latexproduced header-only artifacts while reporting success — a silent degradation (§7).result_to_dictnow also sweeps publicpropertydescriptors declared on the class, and a result that yields nothing exportable raises aRuntimeWarninginstead of quietly writing an empty workbook.KMResultwas the only affected class (audited across all 254 mixin-bearing result classes); no numeric output moves.ResultProtocolMixin.to_markdowntruncated tables on multi-line values. A literal\ninside a field value ended the table row and orphaned the remainder as body text, silently dropping every subsequent row. Newlines (and\r) are now collapsed to spaces; pipes stay escaped.ResultProtocolMixin.to_wordcould raiseTypeErroron bespoke renderers. The delegation branch always calledto_docx(filename, title), butto_docx(self, filename)implementations exist (e.g.RegtableResult). The title is now forwarded only when the callee accepts a second positional argument.sp.rifreg/sp.source_decomposecould not export to Excel. The decompositionto_excelraisedRuntimeError: Result has no exportable panelsfor results carrying no DataFrame panel, making.to_excel()unusable on those classes despite the §3 contract promising it. They now fall back to the single Field/Value sheet; thepath=Nonebytes branch is unchanged.- New regression guards in
tests/test_export_protocol_regressions.pypinning all four defects above. ResultProtocolMixin.to_excelrecursed forever. The mixin'sto_exceldelegated togetattr(self, "to_excel")— i.e. itself — so any inheriting result class that did not override it raisedRecursionErroron the first call. It now writes a real workbook.- Silent solver/degradation paths now warn (§7 失败要响亮):
sp.qreg: when the exact HiGHS linear-programming quantile solve fails, the IRLS fallback now emits aRuntimeWarning(the IRLS solution is approximate) instead of switching silently.sp.principal_strat(all three methods): bootstrap-replicate failures now surface through the sharedbootstrap_sehelper — aRuntimeWarningreports the failure fraction, and a collapsed bootstrap yields an honestNaNSE. Healthy-path numerics unchanged.sp.regtable(apply_coef=...): a user transform (or its derivative) that raises now warns that the cell is reported untransformed / without the delta-method rescaling, instead of silently mixing transformed and untransformed cells in one table.- pyfixest adapter: failures to attach
residuals/fitted_valuesnow warn (previously they silently disabledcr2_se/wild_cluster_boot/conleydownstream). - Rust HDFE
singleton_maskerrors now warn once per process before the (numerically identical) NumPy fallback.
Docs¶
docs/index.mdun-staled: banner and BibTeX now say v1.20.0, headline count updated to the live 1,139 functions, and the release-highlights table is explicitly labelled as the early-era (≤v1.5.0) table with a pointer to the changelog for everything newer.registry_stats.py --checknow pins the exact function count indocs/index.md(was a loose "1,000+").docs/stats.md§1/§2 reconciled and regenerated from the live tree (702 files / 347,786 LOC core; 1,052 files / 199,714 LOC tests); README at-a-glance LOC figures refreshed (EN + CN).README_CN.mdre-synced withREADME.md: ported the missing "Export Results" section, the "Cross-language parity, made queryable" subsection, and the Docs badge.-
mkdocs.yml:guides/reproducibility.mdandguides/translator.mdare now reachable from the nav. -
Analytical parity coverage for 10 previously uncovered estimator families — structural (
olley_pakes,levinsohn_petrin,ackerberg_caves_frazer,wooldridge_prod,prod_fn,markup,blp), longitudinal regimes (longitudinal_analyze,longitudinal_contrast,regime,always_treat,never_treat),msm, parametric g-formula (gformula_ice_fn,gformula_mc), fairness (demographic_parity,equalized_odds,fairness_audit,orthogonal_to_bias,counterfactual_fairness,evidence_without_injustice), multiple imputation (mice,mi_estimate),multi_treatment, off-policy evaluation (sharp_ope_unobserved,causal_policy_forest), surrogate index (surrogate_index,long_term_from_short,proximal_surrogate_index), and target-trial emulation (target_trial_protocol,target_trial_emulate,clone_censor_weight,immortal_time_check,target_trial_checklist,target_trial_report). Each family gets deterministic-DGP known-truth recovery tests plus machine-precision internal identities undertests/reference_parity/test_<family>_parity.py; the parity index now carries 340 graded records. This pass surfaced theproximal_surrogate_indexrank-deficient-bridge limitation, fixed in the ⚠️ Correctness entry above (the recovery test that documented the skip is now enabled).
The 10 new test files shipped in PR #39 (f9cc2932, merged despite
the misleading title feat(parity): analytical-only mice + mi_estimate
Rubin-rules recovery (#39), which only highlights the imputation
family) are:
| test file | estimator family covered |
|---|---|
tests/reference_parity/test_structural_parity.py |
structural (olley_pakes, levinsohn_petrin, ackerberg_caves_frazer, wooldridge_prod, prod_fn, markup, blp) |
tests/reference_parity/test_longitudinal_parity.py |
longitudinal regimes (longitudinal_analyze, longitudinal_contrast, regime, always_treat, never_treat) |
tests/reference_parity/test_msm_family_parity.py |
msm |
tests/reference_parity/test_gformula_family_parity.py |
parametric g-formula (gformula_ice_fn, gformula_mc) |
tests/reference_parity/test_fairness_parity.py |
fairness (demographic_parity, equalized_odds, fairness_audit, orthogonal_to_bias, counterfactual_fairness, evidence_without_injustice) |
tests/reference_parity/test_imputation_parity.py |
multiple imputation (mice, mi_estimate) |
tests/reference_parity/test_multi_treatment_parity.py |
multi_treatment |
tests/reference_parity/test_ope_parity.py |
off-policy evaluation (sharp_ope_unobserved, causal_policy_forest) |
tests/reference_parity/test_surrogate_parity.py |
surrogate index (surrogate_index, long_term_from_short, proximal_surrogate_index) |
tests/reference_parity/test_target_trial_parity.py |
target-trial emulation (target_trial_protocol, target_trial_emulate, clone_censor_weight, immortal_time_check, target_trial_checklist, target_trial_report) |
See docs/dev/parity_pr39.md for the
reviewer-facing rationale (why the title was narrower than the body,
what to expect on git log / JOSS audit).
- **Parity index coverage expansion — 305 estimators now carry a graded
parity record (129 bit-exact), queryable via
sp.parity_status()/sp.parity_summary(). This closes the Tier-D worklist of reference-less estimators to zero. Across multiple sessions this pass added closed-form / known-truth guards across decomposition (Gelbach, Das-Gupta, Kitagawa, Lerman-Yitzhaki source, subgroup-Theil, natural-effects mediation, interventional effects, four-way-decomposition, sensitivity-mr_DL, partial_corr_pvalue, immigration_quartet, etc.), robustness (sensemakr,oster_delta,breakdown_frontier), inference (bootstrap,lrtest,icc,fisher_exact,romano_wolf,anderson_rubin_test,effective_f_test,tF_critical_value,mr_f_statistic,mr_steiger,mr_mode,meta_analysis,cluster_robust_se), bounds (manski_bounds,lee_bounds,selection_bounds,horowitz_manski), policy learning (policy_value,policy_tree), postestimation (margins_at,mrIVW), time series (structural_break,cusum_test,engle_granger), causal discovery (fci,gesCPDAGs), conformal (conformal_ite,conformal_fair_ite), interference (cluster_cross_interference), transportability (pate,front_door), QTE (ivqreg,beyond_average_late,continuous_iv_late,dist_iv), the first network rows (degree_centrality,betweenness_centrality,clustering), and the causal-forest / synthetic-control aggregates (rateAUTOC sign anchor +honest_variancemean-CATE identity,geoliftexact convex-combo lift recovery,bcf_factor_exposureadding-up identities + effect recovery,bayes_synthDirichlet-simplex ATT recovery,calibration_test/test_calibrationBLP heterogeneity detection). A later pass extended the guards across the spatial, time-series, panel, survival, frontier and distributional-decomposition families: spatial autocorrelation (moran_local,getis_ord_local,join_countson a segregated field) and spatial regression (slx= augmented OLS,sac/sarar_gmmrho recovery,spatial_dideffect + no-spurious-spillover,spatial_iv,spatial_panel); time series (garchpersistence,granger_causalitydirection,irf= closed-formA**h,johansenrank,panel_unitroot,bvarMinnesota prior,itslevel shift); panel (panel_fgls,interactive_fe,panel_logit,panel_probit); competing risks (cumincCIF closed form + Gray's test,finegray,cox_frailty); efficiency frontiers (malmquistM = EC·TCidentity,metafrontierenvelope + technology-gap ratio); and decompositions (rifreg-at-mean = OLS,shapley_inequalityadditivity,fairlieandffl_decomposeaggregate identities). A further pass added interference (peer_effectslinear-in-means recovery), transportability (transport_generalizehomogeneous-effect invariance + covariate-shift ordering), conformal causal inference (weighted_conformal_predictionmarginal coverage,conformal_ite_intervalcoverage + ATE point,conformal_cate), and shift-share (ssaggregateBorusyak-Hull-Jaravel location-level equivalence). Finally, the Bayesian and policy-weight layers:bayes_iv/bayes_rdposterior recovery withrhat < 1.01/ adequate ESS convergence guards (skipped without the[bayes]extra), and the Mogstad-Santos-Torgovitskypolicy_weight_ate/policy_weight_subsidy/policy_weight_marginal/policy_weight_observed_prteclosed-form weight identities. Every record traces to a committedtests/reference_parity/guard; grades are honest (bit-exact only for machine-precision identities, analytical-only for DGP-recovery / coverage guarantees). Seedocs/dev/parity_gap_inventory.md. - Universal SE menu — every regression-family estimator is now 8/8 native,
externally validated. The reviewer question "is wild cluster bootstrap
usable on any estimator or only
feols?" now has a clean answer:regress,feols,ivreg,panel(method="fe")andhdfe_olseach expose the full canonicalvce=menu (classical / HC / cluster / two-waycluster=[a,b]/ CR2–CR3 / jackknife / wild cluster bootstrap / Conley spatial HAC), andfepois/feglmadd CR2–CR3/jackknife plus the score wild bootstrap. Every cell is pinned to an external reference (tests/reference_parity/REFERENCES.md); the coverage ratchetscripts/se_menu_matrix.py --checknow holds 64 native cells / 0 unsafe. Highlights: sp.feols/sp.panel(method="fe")/sp.hdfe_ols:vce="CR2"/"CR3"/"jackknife"(Pustejovsky–Tipton 2018 on the within design — matches RclubSandwich::vcovCR(plm, model="within")to machine precision) andvce="conley"(Stataacregplanar-distance convention).panelalso gains two-waycluster=["a", "b"](CGM 2011).sp.hdfe_ols(vce="robust"): HC1 with reghdfe'sN/(N-k-df_a)factor — matches Statareghdfe, vce(robust)exactly.sp.panel(method="fe", vce="wild", cluster=...): WCR wild cluster bootstrap on the entity-within design, byte-identical tosp.regress(vce="wild")on the hand-demeaned data.sp.fepois/sp.feglmvce="CR2"/"CR3"/"jackknife": the IRLS-weighted clubSandwich glm adjustment on the FE-as-dummies design — matches RclubSandwich::vcovCR(glm)for Poisson and logit. (The weighted projection does not carry CR2 leverage through FE absorption, so the dummy design is required; guarded against high-dimensional FE.)sp.fepois/sp.feglmvce="wild": the restricted score wild cluster bootstrap (Kline–Santos 2012) with Stataboottest's exact studentization — reverse-engineered from boottest's enumerated bootstrap distribution (cluster-share-centered CRVE + strict exceedance counting) and bit-exact vsboottestin the enumerated regime (2^G <= reps), verified for Poisson and logit.sp.ppmlhdfe(cluster=["a", "b"]): CGM-2011 two-way clustering on the FE-residualised PPML design — byte-identical to Statappmlhdfe, cluster(a b).sp.ppmlhdfe(vce="wild"): the boottest-convention score bootstrap computed on the FE-absorbed design at scale (the weighted-FWL reduction of the one-step numerator is exact, verified to 1e-17; runs 500-level FE in <1s) — byte-identical tofepois(vce="wild")on low-dimensional FE and hence transitively bit-exact vs Stataboottest. A beyond-Stata capability:boottestcannot run after Stata'sppmlhdfeat all.vce="CR2"/"CR3"added with the same reference-matching dummy design + high-dim guard asfepois.sp.fepois/sp.feglm/sp.ppmlhdfevce="conley": GLM Conley spatial HAC referenced to Rconleyreg(the only reference supporting GLMs; reproduces its spherical uniform kernel to ~1e-7). OLS menu keeps the Stataacregplanar convention; the split is documented.sp.did(method="2x2", vce="wild", cluster=...): the canonical few-clusters DiD inference (MacKinnon-Webb 2017) — WCR wild cluster bootstrap on the interaction regression, validated against Stataboottestand byte-identical tosp.regress(vce="wild")on the same design. Staggered methods refusevce=(Callaway-Sant'Anna's multiplier bootstrap on influence functions is itself the wild bootstrap for that estimator and is exposed viasp.aggte).diddeliberately does NOT offer unclustered classical/HC SEs (Bertrand-Duflo-Mullainathan 2004). Seedocs/guides/grammar.mdfor the full matrix and the verified-reference map.- Cross-language parity — within-transformation pinned to textbook
mean-within. New Track A module
68_demean_withinalignssp.demean(solver="map")against the textbook entity-mean projection (algorithmic, no R package sibling). Worst observed gap is 3.5e-15 (machine tier). The function is now graded bit-exact in the parity matrix. - Cross-language parity — panel-balance filter aligned. New Track A
module
69_balance_panelalignssp.balance_panelagainst the base Rcounts == n_periodsrow filter on an unbalanced 5×4 panel. All eight rows agree to 0.0 (bit-exact). The function is now graded bit-exact in the parity matrix. - Cross-language parity — absorbed-FE panel GLM family added. New Track A
module
67_panel_glmalignssp.feglm(Bernoulli logit) andsp.fepoisagainstfixest::feglmandfixest::fepois(single entity fixed effect absorbed by both sides). Coefficients agree to ~1e-8 (machine tier); SEs differ at ~1e-5 because the two IWLS implementations iterate to slightly different working-weight roots — well within the iterative tier. Both functions are now graded bit-exact in the parity matrix. - Cross-language parity — spatial family opened. Two new Track A modules (spatialreg 1.4.3 / spdep 1.4.2, row-standardised 12×12 rook lattice):
65_spatialalignssp.sar/sp.sem/sp.sdmagainstspatialreg::lagsarlm,spatialreg::errorsarlm, andspatialreg::lagsarlm(Durbin=TRUE)— all three bit-exact (worst relative error 8.3e-8 on estimates, 2.0e-8 on SEs).66_spatial_gmmalignssp.sar_gmmagainstspatialreg::stsls(W2X=FALSE)(a closed-form spatial-2SLS projection — coefficients and n−k SEs agree to ~1e-15) andsp.sem_gmmagainstspatialreg::GMerrorsar(coefficients and the spatial-errorlambdabit-exact, worst 4.6e-8; point-only, as the two coefficient-SE variance estimators differ by convention).
Together this moves the spatial family from 0 verified estimators to 5.
sp.sarar_gmm is left unverified: its joint GS-lag + GM-error path does not
match spatialreg::gstsls (different moment sequence).
- sp.rlasso — rigorous (data-driven) Lasso, a faithful port of R's
hdm. A new first-class module (statspai.rlasso) that ports
hdm::rlasso / rlassoEffect / rlassoIV line-for-line, validated to
agree numerically with hdm 0.3.2:
- sp.rlasso(X, y, post=, intercept=, penalty=, control=) — rigorous
post-Lasso with the data-driven penalty
λ₀ = 2c√n·Φ⁻¹(1−γ/2p) (c=1.1, γ=0.1/log n), heteroskedastic
penalty loadings refined by iteration, and hdm's LassoShooting
coordinate descent. Matches hdm::rlasso to machine precision
(coefficients, λ₀, loadings, residuals and selected support are
bit-exact across post/intercept/homoscedastic variants).
- sp.rlasso_effect(x, y, d, method=) / sp.rlasso_effects(...) —
treatment-effect inference after Lasso-selecting controls
("partialling out" and "double selection"); α and SE match
hdm::rlassoEffect to ~1e-14.
- sp.rlasso_iv(y, d, z, x, select_Z=, select_X=) — IV with rigorous
selection of instruments and/or controls (hdm::rlassoIV). On the
canonical eminent-domain application it reproduces
hdm::rlassoIV(..., select.X=FALSE, select.Z=TRUE) exactly
(coef 0.2274, SE 0.2466) — resolving the previously-tracked
divergence where the older iv.bch_post_lasso_iv was ~17× off
(0.013). All four selection regimes (Z-only, X-only, both, none)
match hdm to ~1e-6 on a well-conditioned design. Also routable
through the IV family dispatcher: sp.iv(method='rlasso', ...)
(instrument selection by default; double selection when exog=
controls are passed).
- sp.RlassoRegressor / sp.RlassoClassifier — scikit-learn-compatible
adapters so the rigorous Lasso can serve as a Double-ML nuisance
learner: sp.dml(model='plr', ml_g='rlasso', ml_m='rlasso') now
works (clone-safe across cross-fitting folds).
- sp.rlassologit — the logistic rigorous (post-)Lasso, a faithful
port of hdm::rlassologit. hdm delegates the penalized fit to glmnet's
binomial lasso at a single data-driven λ; StatsPAI reproduces glmnet
directly (IRLS + weighted coordinate descent, 1/n deviance,
population-variance standardization, pmin clamp). The selected
support matches glmnet exactly; engine coefficients match R glmnet
4.1 to ~1e-6; post=True coefficients/residuals (unpenalized logistic
refit on the selected set) match hdm to ~1e-9. sp.RlassologitClassifier
is a genuine (calibrated) logistic propensity for Double-ML —
sp.dml(model='irm', ml_m='rlassologit') — unlike the linear-probability
RlassoClassifier. The high-dim logistic effect (rlassologitEffect)
is intentionally deferred (a separate parity exercise). 5 hdm/glmnet
parity pins in test_rlassologit_parity.py.
Coverage: tests/reference_parity/test_rlasso_parity.py (17 pins vs
hdm, generated by _generate_rlasso.R — including rlasso_effects
multi-target and a tight sp.dml(ml_g='rlasso') pin against a manual
R DoubleML-PLR-with-hdm::rlasso reference on shared folds, agreeing to
machine precision) and tests/test_rlasso.py (24 behavioural/edge tests,
incl. the §3 result-contract). References (verified): belloni2012sparse
(Econometrica 80(6), doi 10.3982/ECTA9626), belloni2014inference
(RES 81(2)), chernozhukov2016hdm (R Journal 8(2), doi
10.32614/RJ-2016-040).
- Implementation note: hdm's IV routines use MASS::ginv with tolerance
√eps on the (rank-deficient) control blocks; matching that cutoff —
rather than numpy pinv's default rcond=1e-15 — is what restores
eminent-domain parity. iv.bch_post_lasso_iv keeps its original
numerics (its docstring now flags sp.rlasso_iv as the hdm-faithful
path).
sp.dml— DoubleML-compatible score & IPW options. Additive options onsp.dml/ the per-model classes, matchingdoubleml-for-py:score='IV-type'formodel='plr'(moment denominatorΣ v̂·Dinstead ofΣ v̂²); default stays'partialling out'.score='ATTE'formodel='irm'(effect on the treated); default stays'ATE'.normalize_ipw=(Hájek self-normalized IPW) andtrimming_threshold=(symmetric propensity clip) forirm/iivm. Each new path is pinned againstdoubleml-for-py0.11.3 intests/external_parity/test_dml_python_parity.py(7 pins total) and guarded bytests/test_dml_score_options.py. Defaults reproduce the previous output bit-for-bit —score=None/normalize_ipw=False/trimming_threshold=0.01is the historical estimator (verified PLR partialling-out and IRM ATE both unchanged to machine precision). This is a feature addition, not a correctness change to any default. New options are surfaced inresult.model_info(score,normalize_ipw,trimming_threshold). See the expanded guidedocs/guides/sp_dml_vs_doubleml.md, which also adds a custom-orthogonal-score extension recipe and a DoubleML-alignment roadmap.
Changed¶
-
Docs (JOSS maintenance): registry-count claims re-aligned to the live registry.
paper.md, the two reviewer-facing docs for the other review track, anddocs/agent_cards_spec.mdstill quoted the release-1.16.0 snapshot (1,020 functions / 81 submodules); they now reflect the 1.20.0 reality reported bypython scripts/registry_stats.py(1,139 functions / 87 submodules;paper.mduses drift-proof floors "more than 1,100 / more than 80"). The local-only JSS release-gate expectations intests/test_jss_release_manifest.pywere refreshed to the currentvalidation_statusdistribution (certified 66, validated 271, api_stable 799, experimental 3; certified+validated 337 — previously 61/50/1016/3 and 111). Documentation and test-expectation maintenance only — no estimator or numerical-path changes. -
Docs / citations. Added the verified
hdm(Chernozhukov, Hansen & Spindler, The R Journal 8(2), 2016, doi10.32614/RJ-2016-040) and Chiang–Kato–Ma–Sasaki (JBES 40(3), 2022, doi10.1080/07350015.2021.1895815) references topaper.bib; citehdmfrom the post-Lasso IV / RD-lasso modules that implement its methods. The DML guide documents the relationship tohdmopenly. (The original hand-rolledbch_post_lasso_ivunder-selected instruments vshdm::rlassoIVon weak-instrument designs; this is now resolved by the dedicatedsp.rlassoport —sp.rlasso_ivreproduceshdm::rlassoIVexactly — andbch_post_lasso_ivis deprecated. See thesp.rlassoentry above.) - Quality gate (
scripts/quality_gate.py). Themypydebt ratchet now counts only StatsPAI-authored errors (lines undersrc/statspai/), so errors mypy surfaces while following imports into installed third-party packages no longer inflate the count in a venv-dependent way — the same environment stability theignore_missing_importsconfig note already targets. Failure detection no longer keys off the exit code or a config-warning regex (a third-party parse error underpython_version = 3.9, or a newer mypy's deprecation note about that floor, drove the gate to a spurious permanent failure); a run is now trusted whenever mypy actually produced analysis. With the count reflecting only our code — measured at 0 under the pinned mypy 1.x —DEFAULT_MYPY_MAXis tightened from1058to25, converting a dormant ratchet into a live one. New guards intests/test_import_budget.py. StatsPAI_full_data_analysis_skill/SKILL.md. The frontmatter description, keyword list, and triggers now advertise the distributional / gap-decomposition family (sp.oaxaca,sp.kitagawa_decompose,sp.dfl_decompose,sp.gelbach,sp.fairlie,sp.rif_decompositionvia thesp.decomposedispatcher), which the runtime already ships but the skill never surfaced, so downstream skill family-tagging no longer reports zero coverage for it (closes #35). TheSkillOpt-style execution gate (task-local card)subsection (thebest_skillcard plus its seven promotion rules) is restored inside the operating-loop section so the weekly downstream sync gate stops re-deleting it (closes #34).
Deprecated¶
iv.bch_post_lasso_ivnow emits aDeprecationWarning. It is StatsPAI's original from-memory BCH-2012 reconstruction and does not agree numerically withhdm(≈17× off on eminent domain). Use the new, parity-testedsp.rlasso_ivinstead. Behaviour is unchanged during the deprecation window; seeMIGRATION.md.
Fixed¶
sp.spatial_ivnow accepts a native StatsPAIWobject. Passing a weights object built bysp.queen_weights/sp.rook_weights/sp.knn_weights(the natural way to get spatial weights) raised an opaqueIndexError: tuple index out of rangedeep inside the estimator._coerce_Wassumed the libpysal convention whereW.full()returns a(array, ids)tuple and indexedW.full()[0], but StatsPAI's ownW.full()returns the dense(n, n)array directly — so[0]sliced out the first row, a length-nvector, and the matrix multiply collapsed. The coercion now detects the tuple form and otherwise uses the array as-is, so both StatsPAI and libpysal weights work; calls that passed a raw NumPy array (the only path that worked before) are numerically unchanged. New guard:tests/reference_parity/test_spatial_models_parity.py::test_spatial_iv_accepts_native_W_object.sp.peer_effectsand thesp.networkgraph layer now accept a native StatsPAIWobject. Both carried the sameW.full()[0]first-row bug asspatial_iv(peer_effects raisedAxisError: axis 1 is out of boundson the row-normalisation;network._to_densesilently returned a length-nvector instead of the(n, n)adjacency). Both now detect the libpysal(array, ids)tuple form and otherwise use the dense array directly. Raw-array / scipy-sparse inputs are unaffected. New guard:tests/reference_parity/test_peer_effects_parity.py::test_peer_effects_accepts_native_W_object.
[1.20.0] — 2026-06-22¶
⚠️ Correctness¶
These change inference output (standard errors / p-values / CIs), not point
estimates. See MIGRATION.md for per-function detail.
sp.cusum_test— the recursive-residual CUSUM now compares the path against the Brown–Durbin–Evans linear boundarya·[1 + 2 s/(n−k)](a = 0.948at 5%) instead of a constant1.358(the sup|Brownian-bridge| value, which belongs to the OLS-CUSUM). The old boundary rejected ≈32% of stable series at a nominal 5% level (empirical size 0.32 → 0.04).result["critical_value"]is now the per-recursion boundary array.sp.lee_bounds— the confidence interval is now the genuine Imbens & Manski (2004) interval for the partially identified parameter (critical valueC_ninterpolating between the one- and two-sidedz), replacing a Horowitz–Manski set interval that over-covered. CIs are narrower (correct).sp.rdrobust/sp.rd2d/ RD HTE /sp.rd_bias_aware_fuzzy— the heteroskedasticity-robust local-polynomial variance now uses the Calonico–Cattaneo–Titiunik (2014) kernel weightingX'W·diag(e²)·W·X(kernel weight squared). The previous meat carried only one power of the kernel weight, inflating every HC-robust RD standard error (≈1.4× for a uniform kernel vs Rrdrobustvce="hc0"). Point estimates are unchanged; SEs now match R. (Cluster-robust RD SEs were already correct.)sp.callaway_santannapre-trend test — the joint pre-trend Wald p-value (model_info["pretrend_test"]) now applies a Hotelling-T² finite-sample correction, referringW·(G−k)/(k·(G−1))toF(k, G−k)instead of the plug-inχ²(k). The pre-period ATT(g,t) are strongly correlated and the covariance is estimated, so the plug-in χ² over-rejected (empirical size ≈0.15 at a nominal 5% level for ~60 units → ≈0.07). ATT point estimates and SEs are unchanged.gardner_did(event_study=True)overall ATT — the headline ATT is now the treated-observation-weighted mean of post-period coefficients (thedid2sconvention), matching the non-event-study path exactly. Previously an unweighted mean disagreed with the non-ES ATT under heterogeneous effects / unbalanced horizon support (e.g. 1.63 vs 1.75).sp.hdfe_ols/sp.absorb_olscluster-robust SEs (native HDFE backend) — the CRV1 finite-sample factor(N−1)/(N−K) · G/(G−1)no longer counts, inK, fixed-effect levels that are nested within the cluster variable. In the canonicalabsorb(unit + time) + cluster(unit)layout the absorbedunitFE is fully nested incluster(unit), so the cluster-robust sandwich already captures that within-cluster correlation; chargingunit's(G−1)levels again inKinflated every clustered SE. The native backend now omits nested-FE levels from the cluster DOF (matching thereghdfe/pyfixest/sp.feolsconvention) while still charging non-nested levels (e.g.time). Point estimates and non-clustered (iid/hetero) SEs are unchanged; onlycluster=SEs change — they get smaller. The reporter's MRE (#26) now givessp.hdfe_olsandsp.feolsidentical SEs (ratio 1.0); the prior inflation was ≈5.4% on the synthetic panel and ≈6.3% on a 37,869-row firm-year panel. Two new result fields expose the correction:dof_fe_cluster(FE dof actually charged to CRV1) andnested_fe/nested_fe_in_cluster(which absorbed dimensions were detected as nested in the cluster).
Known limitations¶
sp.rdrobustbias-corrected estimate forrho != 1(b != h). The "Robust" row currently uses the standalone order-(p+1)fit atbrather than the Calonico–Cattaneo–Titiunikμ̂_p(h) − bias(b)construction; the two coincide only at the defaultrho=1. Point/SE differ slightly from Rrdrobustwhenb != h. The default (rho=1) is exact. A full CCT bias-corrected point+variance forrho != 1is tracked for an R-parity sprint.
Added¶
- CausalPy-inspired convergence layer (one contract across quasi-experiments).
sp.counterfactual_data/sp.counterfactual_plot— one contract that normalises any observed-vs-counterfactual result (causal impact, synthetic control, interrupted time series, and the Bayesian time-series designs) into a tidy frame and a two-panel observed-vs-counterfactual + pointwise-effect plot with uncertainty bands.sp.itsnow stores the observed/fitted/counterfactual series so it joins the contract (previously itsdetailwas empty).sp.ancova/sp.negd— lightweight pre/post quasi-experiment wrappers (covariate-adjusted ANCOVA; non-equivalent group design via ANCOVA or change-score) returning the unifiedCausalResultwith surfaced assumptions.sp.geolift— geo-experiment lift measurement: aggregates treated markets and builds a synthetic counterfactual from untreated markets (viasp.synth), reporting ATT + relative lift % and feedingcounterfactual_plot.sp.bayes_its/sp.bayes_synth— Bayesian interrupted time series (segmented; estimand = level change) and Bayesian synthetic control (Dirichlet-simplex donor weights; estimand = ATT). Both return posterior counterfactual trajectories with credible bands and require thebayesextra.- Inference engine switch.
sp.rdrobust(..., engine='bayes')andsp.did_2x2(..., engine='bayes')route the same design to the Bayesian backend (sp.bayes_rd/sp.bayes_did); options with no Bayesian analogue raise rather than being silently dropped.sp.causal_question(..., engine= 'bayes')routes regression-discontinuity questions tosp.bayes_rd.engine='ols'(default) is unchanged. gardner_did(vce='bootstrap')anddid_imputation(vce='bootstrap')— a pairs-cluster bootstrap of the full estimator for valid overall-ATT inference. The analytic defaults cluster only the second-stage / imputation residuals and ignore the variance from estimating the fixed effects, so they are anti-conservative; aUserWarningnow steers users tovce='bootstrap'. Simulated 95% coverage improves from ≈0.78 → ≈0.90 (gardner_did) and ≈0.87 → ≈0.94 (did_imputation). Default behaviour and point estimates are unchanged;n_boot/boot_seedcontrol the bootstrap.sp.iv(..., cluster=<Series>)— passing the cluster as apandas.Series(or array) is now accepted, matching the documented signature; previously it raisedValueError: truth value of a Series is ambiguous. A misaligned cluster vector now fails loudly with a clear length-mismatch error.sp.effect_summary(...)/EffectSummary— a decision-ready table-plus- text summary surface for frequentistCausalResultobjects and Bayesian posterior results, withCausalResult.effect_summary(...)andBayesianCausalResult.effect_summary(...)method hooks.sp.counterfactual_data(...)andsp.counterfactual_plot(...)— a shared observed-vs-counterfactual contract for causal-impact, synthetic-control, and interrupted-time-series results.sp.its(...)now stores the fitted no-intervention counterfactual series inITSResult.detailso it joins the same plotting path.sp.design_intake(...)andstatspai.checks— additive agent-facing contracts for pre-estimator design routing and pluggable diagnostic checks, backed by a newquality_gate.py contract-inventorydrift guard.sp.rdrobust(..., engine='bayes')— sharp-RD calls can now route to the existing Bayesian RD backend while the defaultengine='ols'path remains byte-identical.sp.ancova(...)andsp.negd(...)— named pre/post quasi-experimental wrappers around the shared OLS machinery, surfacing ANCOVA, change-score NEGD, assumptions, and regression-to-the-mean warnings asCausalResultobjects.scripts/agent_workflow_spec_audit.py— a static audit for agent empirical-analysis workflow specs, including a bundled DID example contract and focused regression tests.
Changed¶
sp.match(nearest-neighbour) default SE. The public docstring and a newUserWarningnow flag that the defaultse_method='ai'matched-pair SE is anti-conservative under matching with replacement (~0.81 coverage at a nominal 95% level) and recommendse_method='abadie_imbens'. The default number is unchanged (JOSS-review stability); the internal_ai_sedocstring no longer mislabels the simple SE as Abadie–Imbens (2006).
Fixed¶
- pandas 3.0 compatibility (formulas with string categoricals). pandas 3.0
makes
StringDtypethe default for text columns, which patsy's categorical sniffer cannot interpret — so any formula with a string categorical (e.g.y ~ x + C(group)) raisedTypeError: Cannot interpret '<StringDtype>' as a data typeinsp.regress/sp.ols/sp.glm/sp.svyglmat bothfitandpredict. String-extension columns are now coerced toobjectat every patsy entry point. No-op on pandas < 3.0; point estimates and standard errors are unchanged. - Nonlinear (Fairlie/Yun) decomposition probit covariance symmetry. On a
singular / collinear design the Newton-fallback covariance from
numpy.linalg.pinvcould pick up BLAS-backend-dependent asymmetric float noise off the diagonal, so the reported probit covariance was not exactly symmetric on rank-deficient designs (and differed across platforms). It is now symmetrised by construction. Variances (the diagonal / reported SEs) and point estimates are unchanged — a ~1e-15 no-op on well-conditioned fits. - Nonlinear (Fairlie/Yun) decomposition probit singular-information
fallback.
_probit_fitnow chooses the SVD pseudo-inverse from the information-matrix condition number instead of relying onnp.linalg.invto raise on rank-deficient designs. Some OpenBLAS/LAPACK builds returned an indefinite inverse-like matrix with huge negative variances rather than raising, which made the singular-design regression fail in the pandas-3 CI lane. Well-conditioned fits keep the exactinvpath, so point estimates and regular-design covariance output are unchanged. sp.fast.feols(..., backend='jax')rank-deficiency detection. Newer JAX returns a finite least-norm solution for a singular design instead ofNaN/Inf, so perfectly collinear regressors silently produced output. The JAX path now raisesNumericalInstabilityon a rank-deficient design (via the bread condition number), matching the nativefeolsbehaviour.sp.regress/sp.olsconstant-outcome warning. A zero-variance outcome now emits an explicitUserWarning(R² is undefined); previously this relied on an incidental NumPy divide warning that newer NumPy no longer raises.- Agent tool error envelope.
execute_toolno longer crashes if a custom result'sto_dict()raises an unexpected exception type; the error-handling fallback now degrades gracefully on any exception.
Internal¶
- Test-suite forward-compatibility (no library behaviour change). The
default
pytestrun is now clean under pandas 3.0 and across BLAS backends: theerror_taxonomygovernance ratchet moved from the default suite to a dedicated CI step (so a fresh clone is not gated by a drifting count); a CoW read-only test-setup bug was fixed (Series.to_numpy().copy()); platform- sensitive RD/synthetic-control coverage snapshots now check data-driven standard errors / optimiser outputs for validity while keeping point estimates pinned (cross-language numerical parity is still guarded bytests/reference_parity/); and arviz/PyMC/exception-type test assumptions were made version-agnostic. A new CI lane runs the full suite under pandas 3.x on every push/PR.
[1.19.0] — 2026-06-20¶
Added¶
- Cross-engine validation (
sp.cross_validate). Estimate one model with several independent engines and report whether they agree — operationalising the cross-package-reproducibility discipline (Scott Cunningham: "estimate the same model two ways, trust it only when they match") as one call for humans and agents. - Engines. StatsPAI native, pyfixest, linearmodels, DoubleML
(in-process Python), R's
fixestanddidviaRscript, and Stata (regress/ivregress/reghdfe) via batchdo.engines="auto"runs every installed, applicable backend; a named-but- missing engine is reportedunavailableand recorded as a degradation — never silently skipped. - Estimands.
ols,feols(HDFE),iv(2SLS),poisson,dml, anddid(Callaway–Sant'Anna: StatsPAI'scallaway_santannaoverall ATT vs R'sdid::att_gt+aggte, reproducing Scott Cunningham's exact cross-package experiment — they agree to ~1e-15 on the canonicalmpdta). Accepts a fixest-style formula, structured args, or a fitted StatsPAI result. TheR::didadapter coerces integer cohort/time/id columns to numeric —att_gtsilently returns a different estimate for integer vs numeric group columns, a real cross-package fragility the cross-check neutralises. - Honest verdicts. AGREE / PARTIAL / DISAGREE / INSUFFICIENT, with a
tolerance regime chosen and explained per estimand: closed-form methods
(OLS/IV/FE) compared at
rtol=1e-6; randomised methods (DML/forests) on a standard-error scale. Verified: StatsPAI reproduces R'sfixestand pyfixest to machine precision (max_rel ≈ 1e-15) on OLS/IV/FE. - Result object.
CrossValidationResultwith.summary(),.plot(),.to_markdown(),.to_latex(),.to_dict(detail="agent")(verdict + per-engine table + next steps + version/dataprovenance,engine_status_counts, andcan_claim_cross_engine_agreement). Registered and MCP-exposed. - Data-source ingestion normalisers (
sp.from_worldbank/sp.from_fred/sp.from_sdmx). Reshape a payload a data MCP already fetched (World Bank Indicators, FRED series, OECD/Eurostat SDMX-JSON) into a tidy long/wide panel ready forsp.detect_design→sp.recommend→sp.cross_validate. Pure normalisers — no network calls, deterministic and offline-testable. The normalized frames carrydf.attrs["provenance"], whichsp.cross_validatepreserves undercv.provenance["data"]. - Social network analysis (
sp.network). A new numpy/scipy-native SNA module aligned with R'sigraph/sna/statnetand Stata'snwcommands— nonetworkxdependency. Covers the full applied stack: - Graph object + factory —
sp.network_graph(...)builds from a dense/ sparse adjacency, edge list, or tidyDataFrame(directed/undirected, weighted); asymmetric matrices passed as undirected are symmetrised with a loud warning, never silently. - Descriptives —
sp.network_summary,sp.transitivity,sp.clustering,sp.reciprocity,sp.assortativity(Newman),sp.network_components. Verified to matchnetworkxto machine precision on Zachary's karate club (density, transitivity, average clustering, diameter, average path length, degree assortativity). - Centrality —
sp.centrality(g, kind=...)dispatcher plus degree, closeness (Wasserman-Faust), betweenness (Brandes 2001), eigenvector, Katz, PageRank (Brin-Page), Bonacich power, and HITS. Betweenness/ closeness/eigenvector/Katz matchnetworkx; PageRank matches the exact Google-matrix stationary distribution (igraph-equivalent) to 1e-13. - Community detection —
sp.community_detection(g, method=...)with Louvain (Blondel 2008), greedy/CNM (Clauset-Newman-Moore 2004), and label propagation (Raghavan 2007), plussp.network_modularity. Greedy reproducesnetworkx's CNM partition exactly on the karate club (Q = 0.3807, 3 communities); Louvain reaches Q ≈ 0.419. - Network regression —
sp.netlm/sp.netlogit(QAP / MRQAP with Dekker-Krackhardt-Snijders double-semi-partialling) andsp.dyadic_regressionwith Aronow-Samii-Assenova (2015) dyadic-cluster- robust standard errors (verified against a brute-force O(D²) computation). - Network formation —
sp.ergm(...)fits exponential random graph models by maximum pseudo-likelihood (MPLE; Strauss-Ikeda 1990). MPLE coincides with the exact MLE for dyad-independent terms (edges / nodematch / nodecov / absdiff / mutual); dyad-dependent terms (triangles) emit a loud warning that MPLE is approximate. Full MCMC-MLE and SAOM/RSiena dynamics are documented as the roadmap (not silently stubbed). - Data & plots —
sp.karate_club()(Zachary 1977) andsp.florentine_families()(Padgett-Ansell 1993) canonical reference networks;sp.network_plot(...)node-link drawing with a numpy Fruchterman-Reingold layout and lazymatplotlibimport (no hard dependency).
All eight flagship functions carry curated agent-native registry specs
(sp.describe_function / sp.function_schema). 21 new references verified
via the Crossref API and added to paper.bib. New test suite
tests/test_network.py (42 cases: karate/Florentine parity, analytic
small-graph closed forms, and boundary cases).
⚠ Correctness¶
sp.conformal_synthaverage-effect p-value corrected to the moving-block test (Chernozhukov, Wüthrich & Zhu 2021). The per-period conformal p-values were already correct, but the average post-treatment-effect p-value compared aT1-averaged statistic (|mean of the T1 post-period gaps − τ0|) against single pre-period residuals. AT1-average has roughly1/√T1the spread of one residual, so this scale mismatch pinned the average p-value at its1/(T0+1)floor regardless of the data._conformal_avg_pvaluenow builds the null from all length-T1cyclic blocks of the full residual series under H0 (matched scale); atT1 = 1it reduces exactly to the per-period p-value. Effect: only the average-effect.pvalue(and its inverted average CI) change; the point estimate, SE, and per-period results are unchanged. Null p-values are now well-calibrated (mean ≈ 0.5 under H0 instead of being biased toward the floor). Regression anchortest_cov95_synth_variants.pyupdated (pvalue1/12 → 1/18 on the fixture).sp.sensitivity_dashboardno longer fabricates subsample / outlier stability (CLAUDE.md §7). The "80% subsample stability" and "outlier sensitivity (trimming)" dimensions previously reported metrics built frombaseline_est + N(0, baseline_se)Gaussian jitter andbaseline_est * (1 + N(0, frac_removed))— i.e. synthetic noise around the point estimate, with the actually-sliced subsample never re-estimated. They now perform a genuine OLS re-fit of the headline coefficient on the stored linear design (data_info['X']/'y') for each 80% row resample / outcome-trimmed subset, gated by a self-consistency check (the full-sample re-fit must reproduce the baseline). Results that do not expose a plain linear design (weighted / IV / DiD / synthetic-control families) now skip these dimensions instead of emitting fabricated numbers. The unimplemented MSMtrim_sweep(its loop body waspass) is relabelled as the positivity check it actually is, with every flag tied to the real max-stabilized-weight signal. Also fixes the headline-coefficient selection, which excluded only Stata's_consand so analysed the intercept for patsy-Interceptregressions; it now excludes_cons/Intercept/constand analyses the first real regressor.registry.pysp.bridgekindparameter metadata fix. A positionalParamSpec(...)call placed the description text in thedefaultslot and the allowed-values list in thedescriptionslot; thekindenum/description/ default are now correct (keyword arguments), so the generated agent/MCP schema forsp.bridgedescribes itskindchoices accurately._agent_cards_extra.pyduplicate agent cards removed. Thenetwork_hteandinward_outward_spilloveragent-native cards were each defined twice in one dict literal; the earlier (richer) definitions were silently shadowed by the later ones. The dead shadowed entries were removed (the runtime-active cards are unchanged).
Added¶
- Agent-native result protocol (
.to_dict()/.to_latex()/.cite()) on 64 domain result objects. The flagshipCausalResult/EconometricResultsalready exposed the full protocol, but the lighter result dataclasses only had.summary(). A newResultProtocolMixin(statspai._result_serialize) now gives 64 result classes — across the negative-control / proximal / mediation / interference (orthogonal + cluster designs) / ITS / Rosenbaum / BCF, plus the Mendelian-randomization, meta-learner, multiple-testing, multilevel, QTE, robustness, transport, target-trial, OPE, longitudinal-TMLE, g-formula, bootstrap, meta-analysis, matching, selection and time-series families — a uniform, JSON-safe.to_dict()(numpy / pandas / NaN-aware, via the existingcore.results._to_jsonable), a compact booktabs.to_latex()table of the scalar fields, and a.cite()returning the estimator's verified paper.bib citation key(s). The citation keys are sourced from the modules' existing references and checked againstpaper.bib— zero-hallucination (CLAUDE.md §10): a method with no single canonical paper honestly returns a placeholder rather than a fabricated reference, and a CI test (tests/test_result_protocol.py) fails if any_citation_keysvalue is absent frompaper.bib. The serialisation logic lives in exactly one place. Pinned bytests/test_result_protocol.pyandtests/test_result_to_dict.py. - 29 estimators made agent-discoverable (
__all__/ registry drift repair). A family of estimators — the proximal / negative-control identification methods (sp.double_negative_control,sp.proximal_regression,sp.negative_control_outcome,sp.negative_control_exposure), the off-policy-evaluation estimators (sp.ips,sp.snips,sp.doubly_robust,sp.direct_method),sp.four_way_decomposition,sp.its,sp.ltmle,sp.harvest_did,sp.overlap_weighted_did,sp.network_hte,sp.inward_outward_spillover, the shift-share political-economy designs, the neural dose-response estimators (sp.vcnet,sp.scigan),sp.rosenbaum_bounds/sp.rosenbaum_gamma, the BCF extensions, and more — already shipped onsp.<name>but were missing from__all__. Because the registry auto-pass walks__all__, every one of them was invisible tosp.list_functions(),sp.describe_function()andsp.function_schema(), violating the agent-native contract that the help tools resolve for every public symbol. They (plus their result classes) are now in__all__, so they appear in the registry (1033 → 1070 functions),from statspai import *is complete, and the MCP schema bundle covers them. Eleven of them additionally gained hand-written agent-native cards (identifying assumptions, failure modes, alternatives). The__all__↔registry drift guard baseline shrank from 44 → 25, locking the gain in. Pinned bytests/test_registry_drift_repair.py(discoverability + correctness-by-construction on a known-truth DGP). - Full coefficient table on limited-dependent-variable models.
sp.tobitandsp.heckmannow expose the entire coefficient vector through.params/.std_errors/.tvalues/.pvalues(plus a new.coef_table()helper), matching the Stata (tobit,heckman) and R (AER::tobit,sampleSelection::heckit) convention. Previously.paramssurfaced only the headline first-regressor coefficient, so an agent callingsp.tobit(...).paramsgot a one-element Series instead ofconst, every regressor, andsigma. The headline.estimate/.se/.ciare unchanged andisinstance(result, CausalResult)still holds, so no consumer breaks; as a bonussp.etable(tobit_fit)now renders the full regression table. Implemented via a smallLimitedDepResultsubclass — the sharedCausalResultbase is untouched. Pinned intests/test_limited_dep_lane.py. - Standard result accessors on parametric survival models.
sp.aft(AFTResult) andsp.cox_frailty(FrailtyResult) now expose.params/.std_errors/.tvalues/.pvalueslike every other estimator. Previously the coefficient vector was reachable only through raw.beta/.searrays andAFTResult.paramswas literallyNone, so an agent calling the documented accessor got nothing.sp.aftadditionally now reports the scale standard error (log(sigma)SE was computed in the Hessian and then discarded by a[:k]slice). The two independent Weibull AFT implementations (sp.aftandsp.survreg) are cross-validated to agree on coefficients (~1e-5) and standard errors (~1e-7). Pinned intests/test_limited_dep_lane.py. sp.garchnow reports parameter standard errors / inference. The GARCH(p,q) fit previously returned only point estimates (omega,alpha,beta,mu) with no standard errors, so the volatility parameters could not be tested or given confidence intervals. SEs now come from the inverse observed information (numerical Hessian at the MLE) and are exposed through the standard.params/.std_errors/.tvalues/.pvaluesaccessors, with a coef/std-err/z/p table in.summary(). Monte-Carlo coverage tracks the empirical SD of α̂/β̂ to ratio ~0.96. Pinned intests/test_garch_inference.py.sp.bvarnow reports posterior uncertainty (SD + credible intervals). The Bayesian VAR previously returned only the posterior-mean coefficient matrix, so credible intervals could not be formed. The marginal posterior standard deviation is now available in closed form from the matrix-normal posteriorB ~ MN(B_post, (X'X + V⁻¹)⁻¹, Σ)assd[i,k] = sqrt([(X'X+V⁻¹)⁻¹]ᵢᵢ · Σ_kk), exposed via the newcoef_sdattribute and acredible_interval(level)method, with a posterior-SD block added to.summary(). Validated against statsmodels VAR: in the loose-prior limit the posterior SD equals the OLS VAR standard error (to the n vs n-k df factor), and the Minnesota prior correctly shrinks it. Pinned intests/test_bvar_posterior_se.py.gwr(geographically weighted regression) now reports local standard errors and t-values. GWR previously returned only the local coefficient matrixparams (n, k), so a user could not tell where a covariate's effect is significant — the core purpose of GWR. Local SEs now follow Fotheringham, Brunsdon & Charlton (2002, §2.4),Var(β̂ᵢ) = σ̂² CᵢCᵢ'withCᵢ = (X'WᵢX)⁻¹X'Wᵢandσ̂² = RSS / (n − 2 tr(S) + tr(S'S)), exposed via the newseandtvalsattributes. Verified against the global limit: at a very large bandwidth the per-location SE collapses to the OLS standard error (to ~0.5%, identical across locations). Pinned intests/spatial/test_gwr_local_se.py. (Multiscale GWR /mgwrlocal SEs remain a known follow-up — their back-fitting variance is materially more involved.)
Fixed¶
- Fail-loud input validation on the proximal / negative-control / mediation
/ network-interference estimators.
sp.double_negative_control,sp.proximal_regression,sp.negative_control_outcome,sp.negative_control_exposure,sp.four_way_decomposition,sp.network_hteandsp.inward_outward_spilloverpreviously sliceddata[[cols]](often without evendropna) with no checks, so a typo'd column name surfaced as a crypticKeyError/LinAlgErrorand an empty, all-NaN, or too-small frame produced a silent NaN / silent garbage estimate from the downstream linear algebra (the "吞异常返回 NaN" anti-pattern —inward_outward_spilloverhappily decomposed 3 data points). They now validate up front through a new package-levelstatspai._input_validationhelper (one shared primitive, not re-implemented per estimator): a non-DataFrame raisessp.StatsPAIError, a missing column raisesDataInsufficient(both aStatsPAIErrorand aValueError, message keeps the conventional "Missing columns" phrasing), and too few complete rows to identify the model raisesDataInsufficient, each with an agent-nativerecovery_hint. The happy-path numerics are byte-identical (the four-way decomposition still satisfies TE = CDE + INT_ref + INT_med + PIE exactly; the network estimators recover their direct/spillover/inward/outward coefficients on a known DGP). Also fixes a latentNameErrorinsp.proximal_regression's documented graceful-degradation path: when the treatment-bridge logistic fit failed (e.g. sklearn absent), the fallback branch then referenced an unboundlr, so the "degrade gracefully" promise itself crashed. The two network estimators additionally gained agent-native cards (assumptions / failure modes / alternatives). Pinned bytests/test_input_validation.py,tests/test_proximal_input_validation.pyandtests/test_interference_orthogonal.py. - Fail-loud input validation on the DiD / time-series / shift-share /
sensitivity estimators. A second hardening sweep closes the same
silent-failure class in five more estimators that an investigation surfaced:
sp.itsreturned a silent 0.0 / NaN segmented-regression on an empty, single-row, or all-NaN series (and a bareKeyErroron a typo'd column);sp.overlap_weighted_didreturned a silentestimate=0.0on an all-NaN outcome (the overlap-weighted cell mean NaN-sums to zero);sp.dl_propensity_scorereturned a silent degenerate[0.02]score on a single row and a bareKeyErroron a typo'd column;sp.shift_share_politicalreturned a silentestimate=nanon an all-NaN outcome (its panel sibling already guarded this); andsp.rosenbaum_bounds/sp.rosenbaum_gammaleaked a cryptic pandasKeyErroron a missing column in the DataFrame interface. All now raise aStatsPAIErrorsubclass (DataInsufficient/NumericalInstability, both alsoValueError) with an agent-nativerecovery_hint, while happy-path numerics are unchanged.sp.itsalso gained a min-observations floor (n > parameters) and an intervention-in-range check;sp.dl_propensity_scorevalidates ≥2 rows spanning both treatment classes without dropping rows (the returned score stays row-aligned). Pinned bytests/test_estimator_input_hardening.py. Separately, the long-standingtest_dl_propensity_score_returns_valid_probspinned a non-convergingMLPClassifier's output toatol=1e-12(sklearn/BLAS-version fragile); it now asserts environment-robust invariants instead. The same sweep also clarifies four estimators that already failed loudly but with a confusing message on an all-NaN outcome:sp.ltmleraised a raw sklearnInput y contains NaN, andsp.bcf_factor_exposure/sp.bcf_longitudinal/sp.bcf_ordinalraisedTreatment must be binary (0/1)(the real cause — an empty frame after the inner BCF'sdropna— was mislabelled). All four now raise an error naming the outcome column before any model fit. Likewisesp.synth_experimental_designreported a misleadingk must be in [1, -1]on an empty / fully-missing / single-unit panel; it now raisesDataInsufficientnaming the collapsed panel (and an unbalanced panel raises a clear balance hint), via the sharedrequire_columnsguard. - ⚠️ Correctness —
sp.regress(..., weights=)was silently ignored. The OLS estimator accepted aweights=argument via**kwargsand then dropped it, returning the unweighted OLS fit with no warning. It now fits weighted least squares with Stataaweightsemantics: point estimates, classical / HC1-robust / clustered standard errors, and R² matchregress y x [aw=w]to machine precision (verified live against Stata 18 MP). Invalid weights (non-positive, NaN/inf, wrong length, missing column) now raiseValueErrorinstead of being ignored. This is the same fail-silently bug class fixed forsp.feolsin 1.18.0, now closed in thesp.regress/ OLS path. The unweighted path is byte-identical. SeeMIGRATION.md. sp.iv(..., robust=)rejected standard spellings.sp.iv(robust='HC1')(uppercase) raisedValueError: Unknown robust type: HC1even thoughsp.regressaccepts it — the IV path did not normalise the SE-type string. The IV estimators now accept case-insensitive'hc0'–'hc3', the Stata-style aliasesTrue/'robust'(≡ HC1) and'white'(≡ HC0), and raise a clear message for anything else. Classical and robust IV standard errors matchivregress 2sls, small/ivregress 2sls, robust small(the finite-sample t convention StatsPAI uses) to machine precision. Pinned intests/reference_parity/test_regress_weights_iv_robust_parity.py.- ⚠️ Correctness —
sp.biprobitalways reported zero error correlation. The bivariate-probit MLE estimatedrhovia a per-observationscipy.stats.multivariate_normal.cdfloop whose ~1e-8 precision floor corrupted BFGS's finite-difference gradient, so BFGS died at the starting point on its first step (status 2) andrhostayed pinned at its initial value of 0 for every dataset — the model silently claimed the two equations' errors were uncorrelated no matter how correlated they actually were (a researcher would wrongly conclude "no cross-equation selection"). The bivariate-normal CDF is now a smooth, fully vectorised Drezner–Wesolowsky (1990) Gauss–Legendre quadrature (matches SciPy to ~8e-6, ~280× faster), so the optimiser recoversrho: on simulated data with true ρ≈0.45 it returns ρ̂≈0.47 (p<0.001), and on independent errors ρ̂≈0 (p≈0.97).biprobitnow also reportsmodel_info['converged']. Marginal coefficients match separate univariate probits and reproduce the pre-regression reference values (tests/test_v06_round2.py::TestSelectionModels::test_biprobit, previously failing, now green). Behavioural regression test added intests/test_limited_dep_lane.py. Reference: Drezner & Wesolowsky (1990), J. Stat. Comput. Simul. 35(1–2), 101–107 (verified via Taylor & Francis + Semantic Scholar). - ⚠️ Correctness —
sp.sar/sp.semspatial-parameter standard errors were overstated ~40-50%. The maximum-likelihood SE for the spatial-lag coefficientrho(SAR, alsosp.sdm) and the spatial-error coefficientlambda(SEM) dropped thetr(G'G)term from the information matrix (G = W(I-ρW)^{-1}) and, for SAR, used a projection ofXin place of(G Xβ). The reported SE came out ~1.5× (SAR) / ~1.4× (SEM) too large, so spatial dependence was systematically under-detected (t-statistics ~33% too small). The information matrix is now the textbook Ord (1975) / Anselin (1988) form. Verified two ways: (i) the reported SE now equals the inverse numerical Hessian of the exact log-likelihood (slogdetJacobian) to <5%, and (ii) Monte-Carlo coverage matches the empirical SD of ρ̂/λ̂ (ratio 0.89/0.94, vs 1.51/1.39 before). Point estimates are unchanged. Pinned intests/spatial/test_ml_se_information.py. - Reliability — spurious
converged: Falseon the BFGS-based MLEs.sp.tobit,sp.heckman's selection probit,sp.truncreg,sp.zip/sp.zinb,sp.betaregandsp.biprobitreportedmodel_info['converged'] = Falseat perfectly good optima because SciPy's BFGS returnssuccess=Falsewith status 2 ("Desired error not necessarily achieved due to precision loss") on flat log-likelihoods — a line-search artefact, not a real failure (Nelder-Mead reaches the identical objective and coefficients to ~1e-13). A single shared helper (statspai.regression._optim_helpers.robust_convergence) now derives the flag from the gradient norm at the optimum (successor ‖∇‖ < 1e-3 with a finite objective) and returnsmodel_info['gradient_norm']for transparency. It only ever relaxes a false negative — a genuinely non-converged run keeps a large gradient and still reportsFalse. Point estimates and standard errors are byte-identical; only the boolean flag changes. Pinned intests/test_limited_dep_lane.py.
[1.18.0] — 2026-06-15¶
Added¶
sp.psmatch2Abadie-Imbens (2006) heteroskedasticity-robust SE (Statapsmatch2 , ai(J)).sp.psmatch2(..., ai=J)(orse='abadie_imbens') estimates the within-cell outcome noiseσ²(X)from each unit'sJnearest same-arm neighbours (psmatch2's_self_y) and formsseatt = sqrt(Σ_i (J/(J+1))(Y_i−Ȳ_self_i)²·(D_i−(1−D_i)w_i)²)/N1— reproducing Stata'sr(seatt)digit for digit (machine precision, since the within-arm match and_weightare both discrete).sp.matchgainsse_method='abadie_imbens'+ai_matches=J. Pinned intests/reference_parity/test_psmatch2_parity.pyagainst Stata 18ai(1)/ai(2). (Note: psmatch2'sllrlocal-linear matching is intentionally not ported — Stata routes its default through thelpolycommand, whose bandwidth/boundary handling is not bit-reproducible; a faithful "align with Stata" port is infeasible, so it is omitted rather than shipped misaligned.)sp.psmatch2kernel & radius matching + Stata's digit-exact analytic SE. Building on the matched-frame work below,sp.psmatch2now acceptsmethod={'neighbor','kernel','radius'}(kernel useskernel=/bwidth=; radius is a uniform kernel with bandwidthcaliper), reproducing Statapsmatch2 , kernel/, radius. Kernel/radius assemble the matched frame with per-treated propensity-kernel weights (_weight_j = Σ_i K_ij/Σ_k K_ik, summing to the on-support treated count) and the matched-control mean_y. A newse={'psmatch2','ai'}selects the standard error:'psmatch2'(now thesp.psmatch2default) reproduces Stata's analytic ATT SEsqrt(var1/N1 + var0·Σw²/N1²)(var0 over used controls) digit for digit — nearest-neighbour SE and radius ATT+SE match Stata 18 to machine precision (≤1e-12); the smooth Epanechnikov kernel ATT matches to ~1e-8, bounded only by the independent logit propensity-score estimate (the matching algorithm is exact given the same score).sp.matchgains the samemethod='kernel'/'radius',kernel,bwidth,se_methodoptions; its nearest-neighbour SE default is unchanged (se_method='auto'→ AI), so existing results are untouched. Algorithm and SE formula were reverse-engineered frompsmatch2.adoand pinned against Stata 18 intests/reference_parity/test_psmatch2_parity.py(with kernel/radius fixtures and scalars).sp.psmatch2— Statapsmatch2-faithful propensity-score matching with a full post-matching toolkit (src/statspai/matching/psmatch2.py). Closes the gap thatsp.match(method='psm')produced only an ATT, not the per-observation matched-sample variables Stata writes back.sp.psmatch2returns aPSMatch2Resultwhose.matched_datacarries the psmatch2 columns —_id,_treated,_pscore,_support,_weight,_n1 … _nk,_nn,_pdif,_y— and exposes the three operations that previously required Stata:.balance()(post-matching balance, thepstestanalogue:smd_rawbefore vs_weight-weightedsmd_weightedafter),.psplot()(matched-sample propensity-score density with the controls reweighted by_weight), and.psm_did()(frequency-weighted PSM-DID — merges_weight/_supportonto a panel byid, keeps the matched sample, and fits the weightedy ~ treat*postDiD viasp.feols, dropping FE-absorbed main effects automatically).outcome=is optional (Stata-faithful: the matched frame is produced for PSM-DID even without a baseline outcome). Verified row-for-row against Stata 18psmatch2— ATT exact to 6 digits, and_pscore/_weight/_nn/_pdif/_n1(neighbour identity) /_yall match (tests/reference_parity/ test_psmatch2_parity.py,tests/test_psmatch2.py, +40 tests). New guide:docs/guides/psm_did.md. Refs verified via Crossref (Heckman-Ichimura-Todd 1997, DOI 10.2307/2971733) and the SSC archive (Leuven & Sianesi 2003, S432001).-
sp.match/sp.psmnow attach aresult.matched_dataframe for the nearest-neighbour path (the same psmatch2 columns above), plus acommon_support={'none','minmax'}option (default'none', i.e. the historical behaviour — the matched frame is pure additive bookkeeping over the assignment already used for the point estimate, so the ATT/SE are unchanged).common_support='minmax'mirrors Stata'scommon: off-support treated are dropped before matching and the ATT is taken over the on-support treated. -
Analytic-anchor reference parity for 10 previously smoke-only headline estimator families (
tests/reference_parity/, +97 tests). First real numerical guarantees forproximal/fortified_pci/bidirectional_pci,principal_strat/survivor_average_causal_effect,qte/qdid,distributional_te/stochastic_dominance,interference/spillover/network_exposure, causal discovery (pc_algorithm/lingam/notears),matrix_completion/mc_panel,gmm/xtabond,dose_response/continuous_did, andbunching/general_bunching. Every file is hermetic (numpy/scipy only, no R, fixed seeds, ~40s total) and uses non-tautological anchors — known-DGP recovery within a documented σ band, closed-form collapses (e.g. the QTE location-shift identity to 4e-16), naive-bias contrasts (the naive estimator is provably off, the method recovers truth), and structure-recovery metrics (skeleton precision = recall = 1). Each was adversarially verified to FAIL under a 20% injected estimate bias; DGPs and tolerance rationale are logged intests/reference_parity/REFERENCES.md.
Changed¶
- R-parity SE tolerances tightened for 9 mid-tier modules (
42_nbreg,43_heckman,28_frontier,44_mlogit,41_tobit,14_ols_cluster,24_coxph,46_clogit,49_oprobit) to ≈3× the worst observed gap across both reference sides (floored at the1e-6machine tier) — none loosened;docs/dev/r_parity_tolerances.mdcarries the before/after table and the harness contract test stays green. - Hardened ~555 weak assertions across 12 core-estimator test suites
(DiD / RD / DML / synth / panel / matching / proximal / principal-strat /
GMM / bounds):
is-not-None/se>0-only checks replaced with recovery-band pins, sign correctness, CI-bracketing, and closed-form structural identities. Surfaced (documented, not regressions):sp.continuous_didreturns NaN SE/CI on a 2-period DGP (self-labeled dose-bin heuristic); FEfitted_valuesexclude entity effects by design.
Fixed¶
-
⚠️ Correctness —
sp.feolsnow honoursweights=when no fixed effects are absorbed. The intercept-only fallback path (feolswith regressors but no FE terms) accepted theweights=argument but silently discarded it and returned an unweighted OLS fit — coefficients, standard errors, and R² were all affected. The fallback now solves the weighted (WLS) normal equations, validates the weight vector (finite, non-negative, positive total mass), aligns an array-valuedweights=to the post-dropnasample, and threads the weights through the clustered sandwich. FE-absorbed paths were already weighted and are numerically unchanged; no-weights=calls are unchanged. Guarded by new cases intests/test_panel_cov_feols.py. See MIGRATION.md. -
Typo
boostrap→bootstrapin astructural/production/wooldridge.pycomment; added.codespellrc(curated econ-vocabulary ignore list) socodespellruns clean as a dev convenience.pyproject.tomladvertisesTyping :: Typed(py.typed ships) and pointsDocumentationat the MkDocs site. -
Docstring
Examplescoverage campaign — 35.9% → 92.3% of the public surface, every example verified runnable. Added verifiedExamplesblocks to the registered functions that lacked them (coverage 370/1031 → 952/1031) and repaired the package's long-standing illustrative examples on headline functions (sp.did(df, ...)with a placeholderdf, baredid(...),sp.synth.california_prop99()submodule-access bugs) so they actually execute. A newscripts/check_example_execution.pyextracts every example's>>>source (dropping# doctest: +SKIPlines reserved for heavy-optional / external-data blocks) and runs it: the package now reports 939 examples run, 0 failing. Two CI gates inparity-guards.ymllock both dimensions —examples_coverage --check(presence ratchet, budget tightened 561 → 79) andcheck_example_execution --max-failures 0(runnability ratchet) — so a new public function can no longer ship without a running example. - Reference-parity anchors for the doubly-robust trio —
sp.tmle,sp.ipw,sp.g_computation(27 anchors, 44 tests,tests/reference_parity/test_{tmle,ipw,gformula}_parity.py). The three estimators previously had smoke tests only. Anchors: saturated closed-form collapses (hand-computed stratified cells; TMLE = AIPW = g-formula at the nonparametric MLE), frozen base-R fixtures (stats::glm/lmonly — generation scripts + CSVs committed; the TMLE logistic fluctuation is hand-coded in base R and psi/SE pinned at 1e-9), the TMLE EIF-mean-zero targeting property, cross-estimator combined-SE parity, confounded-DGP recovery with an explicit naive-bias contrast, and SE-vs-Monte-Carlo-SD sanity bands. Every file passed an adversarial verification round including a 2% injected-bias mutation check. - Three new user guides + nav repair.
docs/guides/panel_data.md(FE/RE/Hausman/HDFE/dynamic GMM — previously the largest no-guide gap),docs/guides/sensitivity_analysis.md(a design→tool decision tree across sensemakr / Oster / E-value / Rosenbaum / honest-DiD / weak-IV / DML-sensitivity / Manski-Lee bounds), anddocs/guides/mediation.md(ACME/ADE, interventional effects, front-door, Gelbach). Every code block executed against the live package before landing; all citations grep- verified againstpaper.bib. Four previously orphaned guides (synth, migration-from-r, mixtape_ch09_did, agent_native_workflow) are now wired into the mkdocs nav, andmkdocs build --strictis green. - R/Stata parity tolerance registry
(
docs/dev/r_parity_tolerances.md, linked next to the JSS dossier). Every per-module tolerance intests/r_parity/compare.pynow carries an A (mechanistic) / B (empirical, with recomputed observed gaps) / C (honestly unjustified) grade, and 13 stale loose SE budgets were tightened — none loosened — including the former11_psm rel_se = 5.0and fourrel_se = 1.0entries, all re-verified against the committed golden artifacts (36/36 harness contract tests). - Verified
Examplesblocks for 27 high-frequency public functions (mixed, absorb_ols, demean, aft, local_projections, granger_causality, etable, sun_abraham, etwfe, ivreg, jive, rdplot, rd_honest, marginsplot, synth, dml, xlearner, propensity_score, front_door, mediation_decompose, psm, cbps, overlap_weights, love_plot, balance_diagnostics, rif_decomposition, dfl_decompose). Each example executed before landing and independently re-run by a verifier agent (27/27 pass). Newscripts/examples_coverage.pyaudits Examples coverage per category (currently 370/1031 registered functions, 35.9%) for future ratcheting. - Performance-regression ratchet (
scripts/benchmark_ratchet.py+ scheduledbenchmarks.ymlworkflow). The released PyPI wheel and the source tree are benchmarked back-to-back on the same runner twice a month; anysp_*timing >1.5× slower than the release fails the scheduled run (PRs are never blocked). A committedbenchmarks/baseline.jsonenables the same-machine local check. - Community/supply-chain files:
SECURITY.md(private vulnerability reporting policy with scope notes) and.github/dependabot.yml(weekly pip + GitHub-Actions, monthly cargo update PRs). - Track A cross-language parity expansion: 56 → 64 modules
(
tests/r_parity/57–64, all three sides py + R + Stata). New modules: binary logit (sp.logit), Poisson ML (sp.poisson), LIML k-class (sp.limlvsivmodel::LIML/ivregress liml, small), SUR one-step FGLS (sp.suregvssystemfitnoDfCor /sureg), beta regression (sp.betaregvsbetareg/ Statabetareg), truncated regression (sp.truncregvstruncregmethod=NR /truncreg, ll(0)), ZIP and ZINB (sp.zip_model/sp.zinbvspscl::zeroinfl/zip/zinb). Seven of the eight land in the machine tier (rel ≤ 1e-6; LIML and SUR at ~1e-15 including SEs); ZINB registers an honest 1e-5 budget because its likelihood is flat near the optimum. Strictness tiers are now 57 machine / 5 iterative / 1 moderate / 1 methodological-T4, with 61 of 64 modules carrying a Stata reference. The eight symbols are promoted to thecertifiedvalidation tier (registry: 61 certified / 24 validated). Documented convention findings: Stataivregress limlneedssmallfor the shared RSS/(n−k) divisor;systemfit's defaultgeomeanresidual covariance corresponds to Statasureg, dfk, not thesuregdefault; Rbetaregreports expected-information SEs vs observed-information in sp/Stata (≤0.7% documented gap). - Parity reproducibility harness hardening.
verify_reproduce.pynow also re-runs the non-CSV modules (10/21/23), so all 64 R golden artifacts are gate-verified (64/64 reproduce, 0 drift; Stata leg 61/61);_gen_renv_lock.Radds the previously missing canonical reference packages (EValue, sfaR, ddecompose, dineq, censReg, sampleSelection, nnet, clubSandwich, DRDID, sensemakr + the new ivmodel, systemfit, betareg, truncreg, pscl), regeneratingrenv.lockat 285 pinned packages. - R/Stata reference-parity expansion — panel, count/quantile, and SDID
estimators now carry frozen cross-package parity (19 new tests,
tests/reference_parity/124 → 143). Each ships a deterministic data fixture, a_generate_*.Rscript, and a frozen*_R.json: sp.panelvs Rplm— within (FE), Swamy-Arora RE, and between, with classical and cluster-robust SEs (matchesplm::vcovHCHC1/group); coefficients and classical SEs agree to ~1e-5. Closes the gap wherepanel, a core ≥95% estimator, had no reference parity.sp.poisson/sp.nbreg/sp.qreg/sp.tobit/sp.zip_model/sp.zinbvsglm/MASS::glm.nb/quantreg::rq/AER::tobit/pscl::zeroinfl— exact coefficient parity (Poisson/NB/Tobit also exact model SEs and Tobit σ; NBalpha == 1/theta).sp.sdidvs the authors'synthdidR package (Arkhangelsky et al. 2021) onsp.california_prop99()— point estimate agrees to ~1e-6 (−17.8985 packs/capita).- NIST StRD one-way ANOVA certification for
sp.regress(tests/numerical_accuracy/test_nist_strd_anova.py). All 11 NIST ANOVA datasets (SiRstv, SmLs01–09, AtmWtAg) bundled verbatim; a one-way ANOVA is OLS ofy ~ C(group), so these certify the F-statistic / R² / sum-of-squares numerical accuracy. Backed by the new mean-centred OLS fit (see ⚠️ Correctness below),sp.regressreproduces the certified F to machine precision through the average-difficulty family (incl. n=18009); the three highest-difficulty designs (SmLs07/08/09, 9 constant leading digits) reach the irreducible IEEE-754 float64 floor (~7e-5) of their data and are checked at a documented 1e-3 tolerance. All reference R DOIs verified via Crossref. - Tier D analytic special-case test campaign — 24 reference-less estimators
now carry known-truth tests (77 tests across 9
tests/test_tierD_*.pyfiles). Estimators that had no numerical-assertion test (only smoke calls or none) are now anchored to closed-form identities or known-DGP recovery: partial-identification bounds (horowitz_manski,iv_bounds,oster_delta,trimming), power/MDE (power,mde,power_cluster_rct,power_iv),frontdoor,ps_balance,moran_local,effective_f_test,stepwise,feglm,mi_estimate, boundary/multi-score RD aliases (boundary_rd,geographic_rd,multi_score_rd), production functions (levpet,opreg),peer_effects,notch,model_averaging_dml,test_calibration. A read-only classifierscripts/tierd_classify.pygrades every registered function's test evidence (reference / anchored / weak / smoke / untested) and emits the worklist; the zero-guard P1 floor went from 25 → 0 (the lone remainder,blp, fixed in this release — see Fixed). Purely additive: no estimator numerics changed. - Tier B executable replication notebooks
(
Paper-JSS/replication/notebooks/*.ipynb). Self-contained Jupyter notebooks reproduce the headline numbers of Card (1995), ADH (2010) Prop 99, LaLonde/DW NSW, Lee (2008) RD, and Graddy (2006) from the bundled real data; each ends with a drift-guard cell, andtests/test_replication_notebooks.pyexecutes them headless in CI (fails on drift). Generated byscripts/build_replication_notebooks.py; newnotebooksextra (pip install -e ".[notebooks]"). - Agent-native surface hardening (agent-infra work line). A batch of
additive metadata / result-object / MCP-runtime improvements that make the
schema an agent reads (
sp.describe_function) trustworthy. No estimator numerics, signatures, orpaper.md/paper.bibtouched (seedocs/dev/agent_infra_campaign.mdfor the JOSS-isolation contract): EconometricResults.cite(format=...)— citation parity withCausalResult. Previouslysp.bib_for(<regression result>)raised because onlyCausalResultcarried.cite(). The bib key resolves exactly frommodel_info['citation_key'] → model_type → methodagainst the sharedCausalResult._CITATIONS; OLS/logit/probit/poisson return a placeholder rather than a fuzzy/fabricated match (§10 zero-hallucination). Pure addition (tests/test_econometric_results_cite.py).- Opt-in TTL + reason-aware misses for the MCP result cache. New
STATSPAI_MCP_RESULT_CACHE_TTLenv var (default unset = no expiry, prior behaviour byte-identical); expired handles are swept lazily on access / eagerly on insert, and a bounded ledger records why a handle left (ttl/lru/explicit) so the "result expired" hint an agent receives is tailored to the cause. Newevict()/purge_expired()/stats()(tests/test_result_cache_ttl.py). - Docstring parser now reads type-less NumPy param headers (
namethen an indented description, no: type), recovering 65 parameter descriptions across 41 functions (feols,causal_forest,match, …) thatdescribe_functionwas silently dropping. No new dependency; a column-0 barename branch with the false-positive boundary pinned bytests/test_docstring_param_parser.py. - Three forward CI guards (test-only, no runtime change): every MCP
workflow tool has a real dispatch branch
(
tests/test_workflow_tool_dispatch_contract.py); the 42-edgeFunctionSpec.inherits_fromgraph stays free of dangling parents / self-references / cycles (tests/test_inherits_from_integrity.py); and a concrete source annotation never collapses toAnyin auto-generated specs (tests/test_auto_spec_type_resolution.py).
Changed¶
- Repositioned StatsPAI as a "library" (not "platform") across the README,
README_CN,
pyproject.tomldescription, and the GitHub repo description. Documentation/positioning wording only — no API, behaviour, or estimator numerics changed.
Fixed¶
- Silent numerical fallbacks now fail loudly (CLAUDE.md §3.7 sweep, 15
sites). Optimizer/solver failures that previously degraded in silence
now emit
ConvergenceWarning/StatsPAIWarningand record a diagnostics flag, with the fallback documented in the docstring — numerical outputs are bit-identical:sp.ebalance(failed dual → uniform weights, nowmodel_info['weights_fallback']),sp.genmatch(degenerate KS → p=1.0, now counted inresult.detail['ks_test_failures']), bootstrap-replicate failures insp.horowitz_manski/sp.iv_bounds/sp.oster_delta/sp.selection_bounds(model_info['n_boot_failed']), propensity fallbacks insp.opeIPS/SNIPS/DR (diagnostics['propensity_fallback']), bridge-function fallbacks in proximalbidirectional/fortified/pci_regression/proxy_selector, LTMLE targeting-step failures (detail['targeting_failures']), and balance/attrition diagnostics inexperimental. Orchestration-layer stragglers inworkflow.causal_workflow,smart.benchmark, andsmart.identificationnow route throughrecord_degradation(bare- swallow debt ratchet 8 → 7 intests/test_no_silent_degradation.py). - ⚠️ Correctness —
sp.unified_sensitivitydashboard components revived and corrected. (1) The Sensemakr component was 100% dead code: it calledsensemakr(result, treatment=...)against the real signaturesensemakr(data, y, treat, controls, ...), so it always raisedTypeErrorthat was swallowed intodash.notes. The dashboard now accepts optionaldata=,y=,treat=,controls=(forwarded byresult.sensitivity()) and runs the real Cinelli-Hazlett RV (rv_q1=rv_q, plusrv_qa); without them it is skipped with an actionable note pointing tosp.sensemakr(data, y, treat, controls). (2) The Rosenbaum component was equally dead: it imported a nonexistent module (diagnostics.rosenbaum_boundsinstead ofdiagnostics.rosenbaum) and passed the result object where outcome arrays were expected. It now coercesresult.matched_pairs((treated, control) arrays, (n, 2) array, or DataFrame/dict with treated/control entries) and callssp.rosenbaum_bounds(treated, control, alternative="two-sided"). (3) The Oster component routedr2_treated/r2_controlledintooster_bounds(r2_short=, r2_long=)swapped relative to its documented "short and long regression respectively" contract, and reported the input proportionalitydelta(always 1.0) instead of the breakdowndelta_for_zero; both fixed, sodash.oster["delta"]is now the actual Oster δ*. Locked by parity tests against directsp.sensemakr/sp.rosenbaum_bounds/sp.oster_boundscalls intests/test_unified_sensitivity.py. -
RIF/UQR parity now mirrors
dineq::rif's exact density convention. Thequantile_convention="dineq"path now ports Rstats::density's binned Gaussian estimator at the quantile, rather than a direct Gaussian kernel average with the samebw.nrd0bandwidth. The Stata/Mata bridge for32_rifimplements the same binned interpolation, so Python, R, and Stata agree at machine precision; Track A strictness moves to 57 machine-level / 5 iterative / 1 moderate / 1 methodological modules after the multinomial and ordered logit R references, the LMM REML reference, the AGHQ GLMM reference, the cross-sectional SFA default optimizer, thextabondplm::pgmmreference, and the newer GLM / IV / system / limited-dependent variable rows were pinned to tight optimizer/reporting tolerances. -
⚠️ Correctness fix (pandas ≥ 3.0):
sp.horowitz_manskibounds silently collapsed to0.0/0.0when a covariate stratum mapped to NaN. The internal_create_stratahelper discretises continuous covariates withpd.qcut, then cast the bin labels with.astype(str). On a degenerate covariate (e.g. a constant column, whereqcut(..., duplicates='drop')yields all-NaN) pandas < 3.0 stringified NaN to"nan"— accidentally forming one valid stratum — but pandas ≥ 3.0 preserves it as<NA>, so every per-stratum mask matched nothing and the bounds summed to zero with no error. NaN strata are now bucketed into an explicit-1sentinel stratum, recovering the correct closed-form bounds. The analytic guard (tests/test_tierD_bounds_analytic.py::TestHorowitzManskiAnalytic::test_single_stratum_matches_closed_form) pins the single-stratum case to the closed form on both pandas 2.x and 3.x. Numerics are unchanged on pandas < 3.0. -
⚠️ Functionality fix:
sp.blpwas non-functional on every estimation path. The GMM objective called_gmm_objective(..., maxiter=1000)but the parameter is namedmaxiter_inner, so everysp.blpcall raisedTypeError: _gmm_objective() got an unexpected keyword argument 'maxiter'before producing any output. Renamed the keyword at both call sites (first- and second-stage GMM). A new analytic recovery test (tests/test_tierD_structural_analytic.py::TestBLPAnalytic) recovers the known linear price/characteristic coefficients on a logit DGP with endogenous price and valid cost instruments, and guards the keyword regression directly. This clears the last Tier D zero-guard remainder. -
⚠️ Correctness fix:
sp.granger_causalitytest statistic was wrong by orders of magnitude. The Wald variance was a placeholderV = sigma2 * I(the outcome residual variance as if it were the coefficient covariance), ignoring the design-matrix term(X'X)⁻¹. The F-statistic was therefore too small by a factor of ≈T·Var(regressors), so the test almost never rejected regardless of the true causal structure — on a DGP wherex[t-1]drivesywith coefficient 0.8, the genuine F is ≈326 (p≈1e-16) but the function returned F≈0.36 (p≈0.70,reject=False).VARResultnow carries(X'X)⁻¹andgranger_causalityforms the proper coefficient covarianceσ²_caused·(X'X)⁻¹, so the F now matches the restricted-vs-unrestricted OLS F-test and detects causal direction correctly. Guarded bytests/test_tierD_p2_timeseries_analytic.py. No prior valid result is invalidated (the old statistic was statistically meaningless); no JOSS/JSS table usesgranger_causality. Found by the Tier D campaign (CLAUDE.md §5). -
⚠️ Correctness fix: d-separation (
statspai.dag) was wrong on forks and colliders. The moralisation step in_d_separatedmarried siblings (children of a common parent) instead of co-parents (parents of a common child). As a result conditioning on a common cause failed to block a fork (A ⊥ C | MonM→A, M→CreturnedFalse) and conditioning on a collider failed to open it (A ⊥ C | KonA→K←CreturnedTrue) — the two non-trivial d-separation cases were both backwards. This propagated to everything built on_d_separated:DAG.d_separated,adjustment_sets,backdoor_paths,do_rule1/2/3,do_calculus_apply,swig, anddag_recommend_estimator. Moralisation now connects every pair of a node's parents; the chain/fork/collider truths and back-door adjustment-set finding are correct (e.g.adjustment_sets("X","Y")onW→X, W→Y, X→Ynow returns[{W}]). Guarded bytests/test_tierD_p2_dag_dsep_analytic.py; all 9 dag-touching test files still pass (none had pinned the broken behaviour). No JOSS/JSS table uses these graph routines. Found by the Tier D campaign. -
⚠️ Correctness fix:
sp.evalueHR E-values and confidence-interval E-values (now full REValueparity). Two behaviours that can move a previously-reported number (#21): measure='HR'was always treated as a rare-outcome ratio (OR ≈ RR ≈ HR). It now uses the exact common-outcome conversion(1 − 0.5^√HR)/(1 − 0.5^√(1/HR))by default (rare=False), matchingEValue::evalues.HR. HR-based E-values therefore change for non-rare outcomes; passrare=Trueto recover the old rare approximation. (ORalready converted to RR by default, so OR results are unchanged;RRis unaffected.)-
A confidence-interval E-value is now clamped to exactly 1 when the interval contains the null (or a user-supplied
truereference). The E-value was previously computed from the interval limit regardless, so a non-significant result could report a spurious E-value > 1; it now correctly reports 1 (no unmeasured confounding is needed to explain a result already compatible with the null). The keywordrare_outcomeis renamedrare; the old name still works as aDeprecationWarningalias. Verified at machine precision against REValueacross every measure (tests/r_parity/23_evalue.py, 26 rows, worst relative difference 5.8e-14). JOSS/JSS note: this is the23_evaluerow of the JSS cross-language parity table (Paper-JSS/manuscript/tables/appendix_b_parity.tex); the change increases agreement with the gold-standard R package and the row stays a machine-precision PASS (CI "Numerical reference parity" + "R closed-form parity" jobs green). No JOSS (#10604) figure uses an HR or CI E-value. -
Agent-UX:
describe_functionadvertised stale params / wrong defaults for 29 hand-written specs (two invariant classes, 18 + 11). An agent that reads the schema and callssp.<name>(**kwargs)verbatim was led intoTypeErrors and incorrect defaults. No estimator numerics, signatures, or parity numbers changed — metadata only. - 18 specs advertised param names the function cannot accept (e.g.
metalearnersaidtreatment/method; the signature — and the spec's own example — usetreat/learner). Fixed to match real signatures;tests/test_registry_signature_contract.pynow locks two invariants over all hand-written specs (no phantom params; every required signature param documented). -
11 specs reported a default value the estimator does not use — e.g.
drdidmethod'dr'→'imp'(enum['dr','or','ipw','reg','stdipw']→['imp','trad']),did_bcfn_trees200→50,harvest_didreference'pre'(str)→-1(int),iv_compare/iv_diagdefaults stored as string-reprs-of-tuples → real lists.tests/test_registry_default_contract.pylocks that a pinned default neither contradicts the signature nor falls outside its own enum. (Also surfaced and corrected a latent duplicateharvest_didregistration.) -
⚠️ Reproducibility fix:
sp.match(method='nearest')now resolves exact nearest-neighbor ties by source DataFrame index. The Euclidean/propensity nearest-neighbor path previously relied onargpartition/ incidental row order for equal-distance controls (and target order when matching without replacement), so exact ties on discrete or binary covariates could move the ATT across environments. Equal-distance ties now select lower-index pool units first, with lower-index target units used as the without-replacement fallback. A shuffled-row regression test guards the policy, andtests/test_tierD_lalonde_psm_guard.pyis tightened from the old cross-backend band (width ~$300) to two exact anchors (±0.1): GitHub CI is now bitwise-identical across ubuntu/windows/macos (1967.94), while Accelerate on macOS 26 lands at1963.43— a residual ULP-level near-tie sensitivity in the BLAS-computed distances, not in the tie-break itself. Existing results change only when previous data contained exact equal-distance ties. See MIGRATION.md.
Known issues¶
- NIST StRD Linear Least Squares certification for the OLS kernel
(
tests/numerical_accuracy/test_nist_strd_ols.py). All 11 NIST Statistical Reference Datasets for linear regression (Norris, Pontius, NoInt1/2, Filip, Longley, Wampler1–5) are bundled verbatim undertests/numerical_accuracy/_fixtures/nist_strd/and checked against their embedded certified values. Both the low-levelols_fit/OLSEstimatorkernel and the certified standard errors are verified, plus a QR-beats-normal-equations guard on the ill-conditioned designs. Lives in a dedicatedtests/numerical_accuracy/suite (separate from the R/Statareference_parity/fixtures) since it certifies certified-value accuracy rather than cross-language parity.
Changed (⚠️ Correctness)¶
-
sp.regressnow fits in mean-centred (Frisch-Waugh-Lovell) coordinates, fixing coefficient/F/R² loss under large constant offsets. When an intercept is present,OLSEstimator.estimatedemeansyand the non-intercept regressors before the least-squares solve and reconstructs the intercept, so a large constant offset iny(or a regressor) no longer destroys the slope coefficients through catastrophic cancellation. FWL makes this algebraically identical to the previous fit, so well-conditioned designs are unchanged to machine precision (verified: coefficients matchnumpy.linalg.lstsqto ~1e-15; the NIST StRD Linear LS certification and all R/Stata parity suites are unchanged). The effect is only visible on pathological designs: on the NIST StRD ANOVA datasetsSmLs07/08/09(9 constant leading digits, e.g.y ≈ 1.0000000004e12) the certified-F relative error drops from ≈4.8e-4 / 9.9e-3 / 2.3e-2 to the irreducible IEEE-754 float64 floor (~3–7e-5). Previously these were the lone failing NIST ANOVA cases; they now pass at a documented float64-floor tolerance. No user-facing numbers on real data change. -
OLS kernel now solves via QR factorisation instead of the normal equations.
ols_fit(coefficients) andOLSEstimator.estimate(covariance) previously solved(X'X) b = X'yand formedinv(X'X), which squares the condition number of the design matrix. On well-conditioned data nothing observable changes (results match the old path to ~1e-12; the fullreference_parity+external_parityJOSS suites are unaffected). On ill-conditioned designs the new path is dramatically more accurate: on the NIST StRD suite the degree-10 Filippelli polynomial went from 0 correct digits to ~7, Longley from ~7.7 to ~11, and Wampler1 from ~6.1 to ~9.6 correct digits. Any code that fit OLS on near-collinear or high-degree polynomial designs and relied on (silently wrong) coefficients will now get materially different — correct — numbers. See MIGRATION.md. - Exact-fit OLS no longer emits a divide-by-zero
RuntimeWarning. When a regression fits the data exactly (R² == 1, e.g. NIST Wampler1/2), the F-statistic is now reported asinf(matching the NIST-certified "Infinity") withf_pvalue = 0.0, computed without tripping the warning. The reported value is unchanged for every non-exact fit. - Auxiliary OLS in
sp.did(method='wooldridge')and the Romano–Wolf step-down (sp.romano_wolf) hardened to the same QR solve. Both carried private_ols_fithelpers that still formedinv(X'X)— Wooldridge for both the coefficients and the cluster/HC1 sandwich bread, Romano–Wolf for the bread only (its coefficients were already QR). They now reuse the QR factor ((X'X)⁻¹ = R⁻¹R⁻ᵀ). Output is bit-identical to ~1e-15 on well-conditioned designs (verified directly, and the Callaway–Sant'Anna R-parity pin plus the MHT suites are unchanged); the change only adds headroom on near-collinear designs (e.g. saturated group×period dummies). No API change. sp.regressnow fails loudly on a perfectly collinear design instead of returning unidentified garbage. A rank-deficient design (duplicate or proportional regressors, the dummy-variable trap with complementary 0/1 dummies, or a constant non-intercept regressor) previously returned enormous meaningless coefficients (e.g.~1e14) with no warning. It now raisesNumericalInstability, naming the offending columns in.diagnostics. Detection is structural (duplicate/proportional columns, zero-variance regressors), not conditioning-based — a singular-value/rank tolerance loose enough to catch collinearity would also flag legitimately ill-conditioned full-rank designs such as NIST Filippelli (s_min/s_max ~ 6e-16), so those still fit. General exact dependence among 3+ columns that is not a pairwise duplicate/constant is intentionally not auto-detected for the same reason. See MIGRATION.md.sp.logit/sp.probitnow warn on perfect (quasi-complete) separation. When the outcome is perfectly predicted by the linear index the MLE does not exist; Newton–Raphson previously "converged" by its step tolerance to large finite coefficients with no signal. It now emits aConvergenceWarning(the reported coefficients/SEs are stopping-rule artefacts; the hint suggests penalized/Firth logistic regression). Non-separable fits are unaffected.
Fixed¶
-
sp.iv(method='lasso', formula=...)raisedTypeErrorinstead of fitting. The IV dispatcher forwardedformula=verbatim intolasso_iv, which takes nativex_endog/z/x_exoglists and does not accept aformulaargument, so the Patsy-style formula route (sp.iv(method='lasso', formula="y ~ (d ~ z1 + z2) + x", data=df)) failed withlasso_iv() got an unexpected keyword argument 'formula'. The dispatcher now parses the formula intolasso_iv's native parameter names (and accepts the canonicalendog/instruments/exogaliases), so the formula path returns the same estimates as the explicitx_endog=[...],z=[...]calling convention — verified bit-for-bit (atol=0) intests/test_iv_cov_tail.py::test_dispatch_lasso_formula_matches_native. No existing numerics move: the nativelasso_ivpath is unchanged; this only turns a previously-erroring route into a working one. -
decomposition/_kernel_density_atNumPy 1.25DeprecationWarning. The legacy RIF kernel-density helper calledfloat()on the length-1 array returned byscipy.stats.gaussian_kde(...)(point), which NumPy 1.25 deprecates (and a future NumPy will turn into an error). It now indexes the single element first (float(np.asarray(kde(point)).ravel()[0])). The returned value is identical — it is the same elementfloat()already extracted — so no decomposition output changes; the warning is simply gone. -
sp.event_studycrashed on string/extension-dtype time columns under pandas ≥ 3.0. The non-numeric-time branch guarded onnp.issubdtype(col.dtype, np.number), which raisesTypeError: Cannot interpret '<StringDtype...>'when pandas ≥ 3.0 infers a string column asStringDtype(rather thanobject). Switched both checks topd.api.types.is_numeric_dtype(col), whose truth value is identical for every numpy numeric dtype — so numeric-time results are byte-for-byte unchanged (verified against the full event-study suite and thedidreference-parity set); it only repairs the previously-crashing string-time path. Surfaced by the Windows/macOS CI matrix on pandas 3.0 / numpy 2.4. -
sp.lpcmci/sp.dynotearswould crash on string columns under pandas ≥ 3.0. Their default numeric-variable filter used the samenp.issubdtype(col.dtype, np.number)anti-pattern, which raises on aStringDtypecolumn. Switched topd.api.types.is_numeric_dtype— identical numeric-column selection (verified against the causal-discovery suite), now simply skipping string/extension columns instead of crashing. Pre-emptive hardening found by a repo-wide scan after theevent_studyfix above.
Added¶
-
Registry-example bind guard (
tests/test_registry_examples_bind.py). A parametrized test now statically parses every registeredexamplestring and binds the keyword arguments of itssp.<name>(...)call against the real signature, failing if an example references a keyword the function does not accept or does not parse. This locks down the agent copy-paste path — an agent readssp.describe_function(name)and runs the example verbatim — so the registry/example drift fixed below cannot silently return. 373 examples bind green. -
parityoptional extra — opt-in DoubleML reference pin forsp.dml.pip install -e ".[dev,parity]"now installsdoubleml-for-py(the Python DoubleML reference of Bach, Chernozhukov, Kurz & Spindler, JMLR 23(53), 2022), sotests/external_parity/test_dml_python_parity.pyruns instead of silently skipping. Under identical scikit-learn learners and folds,sp.dml(model='plr')reproducesdoubleml-for-pyto machine precision on the seed-42 fixture —|Δ coefficient| = 1.1e-16and|Δ standard error| = 1.4e-17, i.e. one float64 unit in the last place.doublemlremains not a runtime dependency. The measured numbers, software versions, and the divergence discussion are recorded in the source-audit evidence trail (docs/jss_source_audit_dossier.mdplus the parity artifacts undertests/external_parity/). Verified by installing the extra and running bothtests/external_parity/test_dml_python_parity.pyandtests/reference_parity/test_dml_parity.py(55 DML tests green).
Fixed¶
-
⚠️ Correctness:
sp.drdid(method='trad')returned ~half the true ATT. The traditional doubly-robust DiD branch ofsp.drdid(Sant'Anna & Zhao 2020) normalised each of its four cell terms (treated/control × post/pre) by the full sample sizeninstead of by that cell's weight mass. This multiplied every term by the cell's sample share (~0.25 each on a balanced 2×2), biasing the ATT toward zero by ~50%: on a 2×2 with a true ATT of 2.0 (raw DiD 1.96) the traditional estimator returned ≈1.04. Each term is now normalised by its own weight total, somethod='trad'reduces exactly to the raw 2×2 DiD when no covariates are supplied and recovers the true ATT with covariates. The improved (locally efficient)method='imp'— the default — already normalised correctly and is unchanged, so no default-path or parity/dossier numbers move.sp.drdidnow also raisesValueErroron an unknownmethodinstead of silently treating it as traditional (previously e.g.method='ipw'ran the traditional branch). See MIGRATION.md. -
⚠️ Correctness:
sp.multiway_cluster_vcovundercounted intersection clusters, biasing multiway-cluster-robust standard errors. The Cameron-Gelbach-Miller inclusion-exclusion builds an intersection cluster (the unique combinations of the clustering dimensions); its key was formed by joining the dimensions into a single string with a"\0"separator, but NumPy fixed-width unicode strips the embedded NUL, so e.g.(1, 23)and(12, 3)both collapsed to"123". On a 40×50 crossed-cluster DGP this merged 1733 true intersection clusters into 1639, biasing the two-way SE by ~0.2% (~0.5% at three-way) versus the canonical estimator. The intersection key is now built collision-free vianp.unique(axis=0)on per-dimension integer codes.sp.multiway_cluster_vcovnow reproducessandwich::vcovCLandsp.twoway_clusterto machine precision (two-way exact; three-way rel ~4e-7), pinned by the newtests/r_paritymodule 56 and a direct twoway-vs-multiway regression test. Propagates todid.harvestandpanel.feolsmultiway-clustered SEs.sp.twoway_clusteritself was already correct (distinct collision-free key) and is unchanged. See MIGRATION.md. -
.glance()crashed (OverflowError: cannot convert float infinity to integer) on Cox and parametric-survival (survreg) results. Those estimators deliberately storedf_resid = infto signal a large-sample (normal) reference distribution, butglance()cast the residual degrees of freedom withint()unconditionally. The cast now passes non-finite degrees of freedom through unchanged; finite results keep an integerdf_resid(no change). A crash-hunt across ~48 fitted results confirmed the rest of the §3 unified-result export surface (summary/to_latex/to_markdown/to_word/to_excel/cite/tidy/for_agent/plot) is otherwise clean across 20+ estimator families. Covered bytests/test_glance_survival.py. -
sp.event_studyresults crashed the library's own exporters, plotters and pre-trend tools (canonical-column mismatch).sp.event_studyemitted its coefficient table under the column nameestimate, but the rest of the DID family — and every downstream consumer — keys on the canonicalattcolumn (did._core.EVENT_STUDY_COLUMNS). So the canonical event-study estimator was incompatible with its own tooling:.tidy()(and the.to_markdown()/.to_excel()/.to_word()exporters that delegate to it) raisedTypeError: unsupported operand type(s) for /: 'NoneType' and 'float';.plot()/.event_study_plot()/sp.enhanced_event_study_plotraisedKeyError: 'att'; andsp.honest_did/sp.breakdown_mraisedValueError: missing {'att'}. The event-study table now carries the canonicalattcolumn (withestimateretained as a backward-compatible alias), fixing every consumer at the source. No numerical change. -
sp.pretrends_test/sp.pretrends_summarycrashed (LinAlgError: Singular matrix) on every standardsp.event_studyresult — the same reference-period defect already fixed inpretrends_power: the SE = 0 omitted period made the diagonal VCV singular. It is now dropped before inversion, with a clearValueErroron a genuinely collinear pre-period set. -
sp.diagnose_resultcrashed (TypeError: bad operand type for abs(): 'str') onsp.synthresults. The donor-pool check iterated the synthetic weights, butsp.synthstores them as a['unit', 'weight']DataFrame, so iteration yielded column-name strings. The weights are now coerced to their numeric values regardless of container (DataFrame / Series / dict / array). -
sp.pretrends_powercrashed (LinAlgError: Singular matrix) on every standardsp.event_studyresult. Roth's (2022) pre-trend power calculation inverts the pre-period variance–covariance matrix, but the omitted reference period (relative time −1) is reported with a standard error of exactly zero, so the diagonal VCV was singular andnp.linalg.invraised on the exact workflow shown in the function's own docstring. The reference period (and any other mechanically-normalised, zero-SE period) is now dropped before inversion — it is the baseline, not an estimated coefficient — so the joint pre-trend test runs on the estimated pre-periods only. A full-rankmodel_info['vcv_pre']and a full-lengthdeltaare aligned to the retained periods, and a still-singular VCV now raises a clearValueError(collinear pre-periods) instead of an opaque NumPy error. No output changes for any call that previously succeeded. Covered bytests/test_pretrends_power.py. -
⚠️ Correctness —
sp.structural_breaksup-F p-value used the wrong null distribution. The Chow/sup-F statistic is a supremum of the F statistic over candidate break points, so under H0 it follows the Andrews (1993) sup-F law — notF(k, n-2k). The previous code referred the maximised statistic to the ordinary F CDF, which ignored the maximisation and massively over-rejected: on pure Gaussian white noise at the 5% level the test flagged a spurious structural break in 33–37% of series (measured, n ∈ {100, 200, 400}). The p-value is now computed from the Andrews (1993) limiting null — a q-vector Brownian-bridge functional sampled by a deterministic, cached simulation on a grid tied to the sample size — restoring nominal size (~0.05) while retaining power (1.00 / 0.88 to detect a one-/half-σ mean shift at n=200). The same correct threshold now drives the Bai-Perron sequentialsupF(l+1|l)stopping rule (previously the same naive-F over-detection), somethod='bai-perron'no longer over-segments noise. As a side benefit the Bai-Perron result now populatesf_stats/p_values(one sup-F statistic and Andrews p-value per detected break, chronologically aligned) instead of returningNone. Reference verified via Crossref / Econometric Society / RePEc: Andrews, D.W.K. (1993), Econometrica 61(4), 821-856, doi:10.2307/2951764. SeeMIGRATION.md. -
33 registered
examplestrings were statically broken (agent-UX). Six failed to parse (stray/unmatchedparens, a positional-after-keyword...placeholder, an unclosed call) and 27 passed a keyword the function does not accept — a deterministicTypeError/SyntaxErroron the exact agent copy-paste path. Root causes were two long-standing parameter-name drifts: the Mendelian-randomization family (mr_egger/mr_ivw/mr_raps/mr_presso) used the shortb_exp/b_out/se_exp/se_outnames instead of the implementedbeta_exposure/beta_outcome/se_exposure/se_outcome, and the Bayesian family (bayes_rd/bayes_fuzzy_rd/bayes_mte/bayes_did/bayes_iv) plusmetalearner/tmle/causal_impact/sensemakr/spec_curve/qreg/tobit/heckman/cluster_cross_interference/causal_dqn/pci_mtp/bartik/ffl_decomposedrifted from their signatures.qreg/tobit/heckman/spec_curveadditionally had wrong call shapes (formula passed positionally intodata; flatcontrolswhere a list-of-lists was required) and were rebuilt to runnable form and executed to confirm. Fixed acrossregistry.pyand_baseline_cards.py; the regeneratedschemas/bundle stays in sync. Guarded by the new bind test above. sp.heckman/sp.tobitMCP tool schemas advertised parameters the functions reject (agent-native path). The priorexample-string fix above repaired the copy-paste path, but the other drifted field — theFunctionSpec.paramsthat generate each tool's MCPinput_schema— was left pointing at a never-implemented formula API:heckmanexposed requiredformula/select_formulaandtobitexposedformula/lower/upper, while the implementations takedata, y, x, select, zanddata, y, x, ll, ul. An agent calling either tool exactly as the manifest instructed hit a deterministicTypeError: ... got an unexpected keyword argument 'formula'. Theexample-bind guard above does not cover this because it validates theexamplefield, notparams. Re-pointed bothparamsblocks at the real signatures inregistry.py; the regeneratedschemas/bundle is back in sync and both tools now dispatch (sp.heckmanrecovers the known-truth β=2.05 fixture through the MCP dispatch layer). The estimator numerics were never affected — Python-directsp.heckman(...)/sp.tobit(...)always worked; only the MCP schema was wrong.- ⚠️ Correctness —
sp.stabilized_weights/sp.msmsilently dropped all IPTW adjustment on single-period panels. On a single-period (point- treatment) panel the within-unit lagged-treatment column is all-zero, which made the logistic treatment-model design singular. The internal_logit_probahelper swallowed the resultingLinAlgErrorand silently returned the marginal mean for both the numerator and denominator models, so every stabilized weight collapsed to exactly1.0— turning the marginal structural model into an unweighted, confounded regression with no warning (a CLAUDE.md §7 silent-degradation violation)._logit_probanow drops zero-variance columns before fitting (the numerically correct move: such columns only duplicate the intercept thatadd_constantadds) and, if the treatment model still fails to converge (e.g. perfect separation), emits aRuntimeWarninginstead of silently degrading. On the regression fixture the fixed path reproduces a textbook stabilized-IPTW computation to machine precision (max|Δw| = 1.8e-15); the already-correct multi-period path is unchanged. Verified bytests/test_msm_singleperiod_iptw_regression.py(3 new tests) plus the existingtests/test_msm.py(8 green total). See MIGRATION.md. - CI: scikit-learn 1.9 compatibility —
LassoCV(n_alphas=...)removal. scikit-learn 1.7 deprecated then_alphasargument of the coordinate-descent CV estimators (LassoCV/ElasticNetCV/…) in favour of passing an integer toalphas, and 1.9 removed it outright — constructingLassoCV(n_alphas=20)now raisesTypeError: LassoCV.__init__() got an unexpected keyword argument 'n_alphas'. TheCI/CD Pipelinematrix resolves to scikit-learn 1.9.0, sosp.tmle(method='hal')(HALRegressor) andsp.rd_flex(learner='lasso')both failed at construction (tests/test_hal_tmle.py,tests/test_estimator_provenance_round5.py::TestHalTmleProvenance,tests/test_low_cov_battery.py::test_hal_regressor_predicts_finite). A new version-robust shimstatspai.compat.sklearn.lasso_cv_alphas_kwargs(n)emits{"alphas": n}on scikit-learn >= 1.7 and{"n_alphas": n}on older releases; both call sites now route through it. The number of path alphas (20 for HAL, 50 forrd_flex) is unchanged — no numerical effect. Verified on the local scikit-learn 1.6.1 pin (8 HAL tests + 6rd_flextests green) and by version-logic assertion across 1.6/1.7/1.8/1.9. -
CI:
schemas/functions.jsonno longer drifts by pandas version. The auto-registered schema export stringified parameter annotations viastr(typing.Optional[pandas.DataFrame]), which pandas 3.0 renders aspandas.DataFramewhile pandas < 3.0 renders as the internalpandas.core.frame.DataFrame. Because pandas 3.0 requires Python >= 3.11, the committed bundle matched the CIubuntu x 3.10shard but was flagged stale on every 3.11/3.12/3.13 shard, failingCI/CD Pipeline(tests/test_schema_export.py::test_committed_schemas_dir_is_in_sync)._stringify_annotationnow canonicalises pandas internal module paths to their public form, so the exported bundle is byte-identical across pandas versions. Verified by regenerating under both pandas 2.x and pandas 3.0 and confirming identical output. Agent-facing schema metadata only — no numerical effect. -
CI: Windows shards no longer fail on validation-evidence path separators.
_scan_reference_testsrecorded each parity test as a validation-evidence note viastr(path.relative_to(root)), which emits OS-native separators — so Windows runners wrotetests\reference_parity\...backslash notes. That (a) failedtests/test_jss_validation_api.py::test_certified_validated_symbols_have_attached_evidence_notesbecause the JSS evidence-grade markers are forward-slash, and (b) driftedagent_cards.jsonaway from the POSIX-generated committed bundle, failing the schema-sync guard on every Windows shard. Notes are now built withPath.as_posix()and the test files are iterated in a POSIX-keyed sort, so the evidence notes — and the exported bundle — are byte-identical on Windows and POSIX. Agent-facing metadata only — no numerical effect. -
CI hardening: swept the same OS-portability class across the codebase. A multi-agent audit surfaced the remaining latent siblings of the two bugs above (none yet red, but each a Windows landmine of the identical class):
validation._rel(artifact paths insp.validation_report()) andscripts/stability_audit.pynow build paths withPath.as_posix();tests/test_causal_workflow.pyandtests/test_paper_tables.pynow read the UTF-8 HTML/LaTeX/markdown reports they generate withencoding="utf-8"(cp1252 default would mojibake on Windows — CLAUDE.md §5). Two POSIX-runnable schema-bundle invariants were added (no backslash separators; no internalpandas.core.paths) so both classes are caught on any shard, not only the one whose regeneration diverges. -
Docs:
docs/reference/dml.mddocumented foursp.dmlpatterns that raised on copy-paste.r.coef→r.estimate;r.ci(alpha=0.05)→r.ci(a(lower, upper)tuple — the level is set viasp.dml(..., alpha=));r.influence_function(never exposed onCausalResult) →r.diagnostics/r.pvalue; and an IRM example passing a non-existenttrim=keyword (propensity clipping is automatic at[0.01, 0.99], with the clip counts reported inr.diagnostics). Every documented result attribute and all fourmodel=types now run on the bundled fixture. Docs only — no API or numerical change. -
Docs: corrected an unverified claim in
docs/guides/sp_dml_vs_doubleml.md. The guide asserted that matching propensity-trimming thresholds eliminates the smallsp.dml-vs-doubleml-for-pyIRM gap; the gap (0.0076 absolute, ≈ 0.10 SE) is verified not to move with trimming nor withnormalize_ipw, and stems from internal AIPW score construction. PLR agreement is now stated as the measured machine-precision figure (1.1e-16/1.4e-17) rather than "four decimal places", and an over-broad "PLR / PLIV / IIVM agree to machine precision" line was narrowed to the PLR case that is actually pinned. -
Docs: refreshed live registry-stats drift.
docs/stats.md(per-module table + the measured source/test LOC rows),README.md, andREADME_CN.mdnow matchpython scripts/registry_stats.py(269,043 core LOC / 96,514 test LOC; 1,020 functions across 81 submodules). This restorestests/test_jss_release_manifest.py::test_registry_stats_docs_are_live(scripts/registry_stats.py --checkexits 0). Docs only — no API change.
[1.16.1] — 2026-06-01¶
⚠️ Correctness fix — sp.synth() default restored to canonical classic SCM¶
sp.synth(...)now defaults tomethod='classic'(the Abadie, Diamond & Hainmueller 2010 synthetic control) again, matching the documented default in thesp.synthdocstring (method : str, default 'classic'), theSynth::synth→sp.synthmapping in the migration-from-R guide, and the canonical Prop99 examples in the docs andexamples/synth_prop99.py. The signature default had silently drifted tomethod='augmented'(Augmented SCM, Ben-Michael, Feller & Rothstein 2021), whose ridge correction is designed to allow negative donor weights by extrapolating outside the donor convex hull — surprising for the canonicalsynth()entry point and inconsistent with the package's own documentation. A baresp.synth(...)call again returns convex, non-negative, sum-to-one donor weights. Augmented SCM remains fully available viamethod='augmented'(or'ascm'); every non-default method is unchanged. Verified against the full synth test surface (170 tests) and the RSynthrecovery parity test. Guarded bytests/test_synth.py::TestSyntheticControl::test_weights_non_negative.
⚠️ Correctness fix — synthetic-control weights projected back onto the simplex¶
solve_simplex_weights(the inner W solver shared bysp.synthand the SCM/sdid/augsynth/gsynth family) now projects the SLSQP solution back onto the unit simplex — clipping sub-tolerance negative weights to zero and renormalising to sum 1 — before returning. SLSQP enforces thew_j ≥ 0, Σw = 1constraints only up to its own tolerance, so the rawresult.xcould carry small negative donor weights (observed down to≈ -7.5e-4), violating the non-negativity invariant that the synthetic control estimand and every reference implementation (RSynth,gsynth) rely on. Donor weights change by the solver's sub-tolerance noise; the projection moves the native output toward the reference clean-simplex solution, so reference parity is preserved (verified against the synth parity suite). Guarded bytests/test_synth.py::TestSyntheticControl::test_weights_non_negative.
Fixed — agent schema generation preserves full typing shapes¶
sp.function_schema/ the registry schema generator now keep parametrised typing annotations intact across Python 3.9–3.13.registry._stringify_annotationcheckedhasattr(ann, "__name__")before inspecting typing generics; on Python 3.10+ aliases such asOptional[Dict[str, Any]]expose__name__, so the helper collapsed them to the bare origin name (Optional,Dict) and dropped the inner element types. It now resolvestyping.-prefixed and__origin__-bearing annotations first, so the machine-readable parameter shapes agents consume stay stable and version-independent. No estimator numbers change.
Docs¶
- Reviewer-facing validation docs (
docs/jss_source_audit_dossier.md,Paper-JSS/README.md,README.md) refreshed: the focused reviewer follow-up regression command is documented, thetests/test_joss_reviewer_followups.pycompatibility path is restored for the public review thread (delegating totests/test_external_reviewer_followups.py), and the activity/measurement dates are updated to 2026-06-01. Livedocs/stats.mdcounts re-measured against the 1.16.1 source tree (source LOC 269,010).
[1.16.0+source.20260531] — 2026-05-31¶
Correctness fix — RD density native path now ports rddensity defaults¶
sp.rddensity(backend="native")now mirrors the defaultrddensity::rddensityunrestricted triangular-kernel path instead of using a dependency-light Silverman-style pilot bandwidth and ECDF-slope approximation. The native implementation portsrdbwdensitycombination bandwidths, mass-point ECDF handling, and jackknife CJM local-polynomial density inference. On the Lee/RD Senate replica, native StatsPAI now matchesrddensityon the robust p-value atrel = 3.3e-11and on the Stata reference atrel = 8.9e-11;09_rddensitymoves from a T4 bandwidth-selector disclosure to a T2 native reference-parity pass. Side-specific manual bandwidths remain supported, andbackend="r"still delegates to the R package for users who want direct package execution. Guarded bytests/test_rddensity_io.pyand the Track A parity harness.
⚠️ Correctness fix — causal-forest ATE/ATT now doubly-robust (AIPW)¶
CausalForest.average_treatment_effect(and thetarget_sample"all"/"treated"/"control"/"overlap"aggregations) now returns the doubly-robust AIPW influence-function mean — the estimandgrf::average_treatment_effectreports — instead of a plug-in average of the regularisation-shrunk CATE predictions. The plug-in average overshoots the true effect (≈ 15 % on a clean-overlap DGP) and disagreed withgrfby an order of magnitude on overlap-pathological samples. The AIPW score reuses the forest's own cross-fitted nuisancesm̂(X)=Ê[Y|X],ê(X)=Ê[T|X]and the CATEτ̂(X):Γ_i = τ̂ + (T−ê)/(ê(1−ê))·(Y − m̂ − (T−ê)τ̂). Numbers change for any code readingaverage_treatment_effect(...)['estimate']; the SE is now the influence-function SEsd(Γ)/√n. The plug-incf.ate()/cf.att()convenience methods are unchanged and retained, but are no longer the validation estimand. Users who reported a causal-forest ATE fromaverage_treatment_effectshould re-run.- On a clean-overlap DGP (
e(X)∈[0.30,0.70], known ATE = 1) the AIPW ATE recovers the truth within 0.2 SE and agrees withgrfatrel = 0.0019(z = 0.037combined SE); the ATT agrees atrel = 0.0028(z = 0.05). For the JSS source snapshot,13_causal_forestis now a T3 combined-Monte-Carlo-error pass: the row is like-for-like AIPW versusgrfand is graded against combined sampling error, not sold as deterministic machine-precision equality. The strictness-tier denominator at this checkpoint was50 / 4 / 1 / 1 on the 56 R-joined modules(the current source snapshot has since expanded to57 / 5 / 1 / 1 on 64— see the Unreleased Track A entry): the forest row is now the only moderate-stochastic T3 row, and the remaining methodological/T4 bucket is the documented classical-SCM non-uniqueness/reference-disagreement gap. - Guards:
tests/reference_parity/test_causal_forest_aipw_recovery.py(recovery against truth, no R needed) and the tightenedtests/reference_parity/test_grf_parity.py(combined-SE parity vs a committedgrffixture). - Synthetic-control solver certified on identified problems and exact
Synthparity exposed when reviewers need it. Addedtests/reference_parity/test_scm_recovery.pyand the cross-language moduletests/r_parity/52_scm_unique: on a DGP whose synthetic-control weights are uniquely identified (treated unit exactly a convex combination of donors in the pre-period),sp.synth(method="classic")recovers the exact weights and gap (pre-RMSE = 0) and agrees withSynth::synthat machine-level point precision after fixing the predictor-weight vector and tightening the inneripopQP controls. For the ambiguous Basque-data row, the parity harness keeps the native default visible as a documented donor-weight-non-uniqueness/reference-disagreement gap. On the same ADH special-predictor specification, native StatsPAI tracks Statasynthatrel = 4.2e-4while RSynthand Stata differ atrel = 2.3e-2; the methodological-gap ledger now fails if this guard disappears. Users who need exact R numbers can call the optionalsp.synth(method="classic", backend="synth")bridge. The release claim is therefore native-solver certification on identified SCM problems plus an explicit Basque reference-disagreement limitation, not a hidden machine-precision victory on a non-unique original-data example. - Augmented SCM native path now ports
augsynth's centered Ridge+SCM weight convention. The Basque18_augsynthrow now compares the native Python estimator directly withaugsynth::augsynth, not the optional R bridge: ATT relative error is7.9e-06and pre-RMSPE relative error is3.0e-06. - Generalized SCM native path now ports
gsynth/fect's two-way FE factor convention. The Basque19_gsynthrow now compares the native Python estimator directly withgsynth::gsynth, not the optional R bridge: ATT and pre-RMSPE match at machine precision, moving the row out of the T4 factor-convention bucket.backend="augsynth"andbackend="gsynth"remain reference-package migration bridges, not native parity comparators.
Changed — synthetic-DID regularisation aligned to synthdid convention¶
sp.sdidnative unit/time weights now use the \citet{arkhangelsky2021synthetic}synthdidregularisation and Frank-Wolfe weight solver. The native path now mirrorssynthdid:::collapsed.form,synthdid:::sc.weight.fw, the default sparsification step,ζ_ω = (N_tr · T_post)^{1/4} · σ̂, andζ_λ = 10^{-6}·σ̂, whereσ̂is the standard deviation of the control units' pre-treatment first differences. On the California-99 replica, the native ATT now matchessynthdid::synthdid_estimateon identical CSV bytes atrel = 2.6e-15; the row moves from a T4 regularisation-zeta gap to a T2 native reference-parity pass. The SE still uses StatsPAI's deterministic all-control placebo convention whilesynthdid_seuses random placebo replications, so the SE remains compared under a 5% tolerance rather than sold as bitwise equality.
Added — MCP protocol modernization¶
- Protocol version negotiation + bump to
2025-06-18(src/statspai/agent/mcp_server.py) — the server now negotiates its protocol revision with the client (SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")): it echoes the client's requested revision when supported, else offers the latest. Replaces the hard-codedprotocolVersion: "2024-11-05". Fully backward-compatible — a client negotiating2024-11-05ignores the new fields below. - Tool annotations (MCP
2025-03-26) — every advertised tool now carriesannotations: {readOnlyHint: true, openWorldHint: false}. StatsPAI tools read the supplied dataset and compute; they never mutate the input file or external state, so a client can auto-approve calls without a confirmation prompt. A manifest entry may override either hint. - Structured tool output (MCP
2025-06-18) — everytools/callnow returns the result object asstructuredContentalongside the existingtextblock, so agents get typed data without re-parsing serialized JSON. Error envelopes are structured too (branch onerror_kind). Strict-JSON guarantee (noNaN/Infinity) holds for the structured payload as well. Every tool advertises a compactoutputSchema; the full documented result envelope (estimate/std_error/conf_low/conf_high/method/n_obs/coefficients/diagnostics/violations/next_steps/next_calls/citations/result_id/error…) is served once via the newstatspai://schema/resultresource rather than inlined into all ~480 tools — which keeps thetools/listpayload ~1.2 MB smaller (the schema is byte-identical per tool, so inlining it everywhere was 50% duplication).
Added — JSS reproducibility hardening (Track A R/Stata parity)¶
- R reference environment locked + proven reproducible
(
tests/r_parity/) — every committedresults/*_R.jsongolden value now carries an inlineprovenanceblock (R version, platform, BLAS/LAPACK, and the version of each attached/loaded package), emitted by_common.R::.r_provenance. The full 245-package dependency closure is pinned intests/r_parity/renv.lock(exact versions; true GitHub commit SHAs foraugsynth/synthdid), with a human-readable manifest intests/r_parity/R_ENVIRONMENT.md. A newverify_reproduce.pyre-runs each R reference on the committed CSV bytes and diffs every statistic against the golden value at a 1e-9 reproducibility tolerance: 46 of 47 data-driven modules reproduce bit-for-bit under R 4.5.2; the report isresults/REPRODUCIBILITY_REPORT.md. r-parity.ymlCI workflow — re-runs the closed-form / MLE / matching R core (fixest, sandwich, AER, survival, MASS, oaxaca, MatchIt) on every push and fails the build on any drift from the committed golden JSON, so the cross-language closed-form parity is genuinely refreshed in CI rather than only frozen.sp.validation_report(collect_tests=True)— shells out topytest --collect-onlyand returns the authoritative, parametrize-expanded parity test counts (124 reference-parity, 52 external-parity, 12 coverage Monte Carlo on the current source snapshot); a regression test pins those three to the JSS manuscript headline so a parity test added/removed without updating the paper fails CI. Defaultvalidation_report()path is unchanged (fast, metadata-only).- Strictness-tier breakdown in the Track A parity tables
(
tests/r_parity/compare.py) — each module is classified by its registered point-estimate tolerance into machine-level / iterative / moderate / methodological-T4 tiers (50 / 4 / 1 / 1 on the 56 R-joined modules at this checkpoint; 57 / 5 / 1 / 1 on the 64 R-joined modules in the current source snapshot), shown in the Markdown ledger and the LaTeX appendix caption so a machine-level point-estimate match is not flattened together with a deliberately loose stochastic or documented-convention tolerance. - Stata leg brought to the same rigor as R (
tests/stata_parity/) —_common.donow writes an inlineprovenanceblock (engine version, edition, OS) onto every*_Stata.json;verify_reproduce_stata.pyre-runs each.doon the committed CSV bytes and confirms all 53 Stata modules at this checkpoint (61 in the current source snapshot) reproduce bit-for-bit (worst rel 0) under Stata 18 MP, including the iterative-optimiser commands (set seed 42+ deterministic solvers);_capture_stata_env.do+_gen_stata_env.pypin the engine and the verbatim*!banner of 17 community ado packages inSTATA_ENVIRONMENT.md(the Stata analogue ofrenv.lock). Reproduction ledger:results/REPRODUCIBILITY_REPORT_STATA.md. - Provenance drift guard
(
tests/test_parity_harness_contract.py) — the normal-CI contract suite now fails the build if any committed*_R.json(r_version + packages) or*_Stata.json(stata_version + edition) loses its provenance block.
Changed — Track A R golden values regenerated under the locked environment¶
- The current R parity ledger covers 55 rendered R-joined modules under
R 4.5.2 with the
renv.lockpackage set so each is self-describing. The material parity-status movement is the added52_scm_uniquecounterpart for classical SCM: an identified synthetic-control DGP now separates native solver correctness from ambiguous real-data weight selection, while the Basque row remains a documented native non-uniqueness gap. This is primarily a reference-fixture and evidence refresh, not a broadstatspaiestimator-output change. - The 44 committed
results/*_Stata.jsonwere likewise refreshed to embed the engineprovenanceblock; their numbers are unchanged (every module reproduces at exactly 0 under the locked Stata 18 MP environment).
Fixed — MCP cold-start bundle drift¶
- Stale schema-bundle guard (
.github/workflows/parity-guards.yml) — the MCP server servestools/list+ resources from the committedschemas/bundle on its cold-start fast path, gated only on a matchingstatspai_version. A registry change within the same version (a refactor between releases) drifted the bundle silently, so the server served a stale tool list. CI now runspython scripts/dump_schemas.py --checkalongside the existingregistry_stats --check, failing the build until the bundle is regenerated. Regenerated the bundle (schemas/+src/statspai/schemas/) to clear the existing drift.
Added — agent-native sprint¶
- Agent-card metadata overlay (
src/statspai/_agent_cards_extra.py) — 89 curated Tier-A cards (assumptions / pre_conditions / failure_modes / alternatives / typical_n_min) for certified + validated estimators that previously had none, applied viaregistry._apply_agent_card_seedswith extend-missing semantics (hand-writtenFunctionSpeccontent always wins). Lifts curated agent-native field coverage roughly threefold. Everyalternativeandexceptionis CI-validated to resolve. - Relational-integrity contract suite (
tests/test_agent_native_contract.py) — guards that every agent-cardalternatives/failure_modes.alternativeresolves to a real function/MCP tool, everyfailure_modes.exceptionis a real class, every advertised MCP tool is executable, every_FOLLOWUP_BY_TOOLnext-call is an advertised tool, and every_CITATIONS_BY_TOOLbib key exists inpaper.bib. - Machine-readable schema bundle (
scripts/dump_schemas.py,src/statspai/_schema_export.py,schemas/) — an import-free, versioned bundle (tools.json/functions.json/agent_cards.json/result.schema.json/index.json) so a non-Python client can discover the full surface offline. Includes a JSON Schema (draft 2020-12) for the agent-facing result payload, contract-tested against realCausalResultandEconometricResultsoutputs.--checkgates drift. - MCP-sampling LLM client (
statspai.causal_llm.sampling_client) —SamplingLLMClient/resolve_llm_client()bridge the MCP server→clientsampling/createMessageround-trip into theLLMClientinterface, sosp.llm_dag_propose(and friends) can reuse the connected agent's own model with no extra API key, falling back to the deterministic heuristic when sampling is unavailable. interpret_resultMCP tool — natural-language explanation of a fitted result from its cached handle. Wiresresolve_llm_client()into the server's tool-dispatch path: when the client advertised sampling it reuses the agent's own model (grounded in the result's own numbers — the model is told not to invent estimates), and degrades to a deterministic structured brief otherwise. Mid-call sampling failures fall back loudly (the error is surfaced insampling_error, never swallowed). Optionalquestion/audienceknobs; exposed as a dataless tool so strict-schema clients dispatch it without adata_path.- Auto-tool citation enrichment —
_enrichment.build_citationsnow falls back to verified citation tokens in a function's registryreferencefield, so hundreds of carded estimators carry citations in their MCP output automatically. Only keys that resolve inpaper.bibare ever surfaced (CLAUDE.md §10 red line holds). - Agent-workflow regression net (
tests/agent_eval/) — an end-to-end transcript test (detect_design → preflight → fit(as_handle) → audit_result → sensitivity_from_result) plus handle-chaining and graceful-failure UX contracts. - Docs —
docs/guides/agent_native_workflow.md, an operational playbook for driving StatsPAI as an agent.
Added — regression-table export¶
- Symmetric single-model export surface on
EconometricResults—sp.regress/sp.ols/sp.ivresults now expose.to_latex(),.to_html(),.to_markdown(),.to_excel(), and.to_word(), closing the asymmetry where these lived only onCausalResult(sosp.did(...).to_latex()worked butsp.regress(...).to_latex()raisedAttributeError). Each method delegates to the canonicalsp.regtablerenderer and forwards everyregtablekeyword (coef_labels/keep/drop/order/stats/se_type/stars/fmt/template/notes…);to_latexaddscaption=/label=, and the string formats accept an optionalpath=. - Agent-native table serialisation (
RegtableResult.to_dict()/.to_json()) — a JSON-safe payload with three layers: metadata, the rendered cell grid (the formatted"2.067***"/"(0.074)"strings), and the numeric truth per model (estimate / SE / t / p / CI / stats / depvar). NaN/Inf coerce tonull.renders=True(or a format list) optionally embeds rendered strings.RegtableResult.save()andregtable(..., filename=...)now recognise the.jsonextension. - Docs —
docs/guides/exporting-regression-tables.md: single- and multi-model export across all six formats, the agent-native payload, journal templates, multi-panel /Collectioncontainers, and a Stata (esttab/estout/outreg2) and R (modelsummary/stargazer/fixest::etable/texreg) cross-reference table. Every code snippet was executed to verify it runs.migration-from-r.mdcorrected:sp.regressreturnsEconometricResults(notCausalResult) and theetablemapping points atsp.regtable. RegtableResult.from_dict()— the inverse ofto_dict(). The payload now carries arender_specblock (fmt/alpha/panel_sizes/add_rows/keep/drop/order/se_label) so a serialised table reconstructs and re-renders byte-identically across text/LaTeX/HTML/Markdown for the common feature set (exoticmulti_se/eform/column_spanners/testsare documented as not surviving the round-trip). Makes the JSON a faithful cache, not just a snapshot.- Journal-grade LaTeX —
to_latex(siunitx=True)decimal-aligns numeric columns withsiunitxv3Scolumns (coefficients align on the decimal point; stars ride along as\textsuperscriptviatable-space-text-post; SE / text cells wrapped so they are not mis-parsed).threeparttable=Truemoves the footnotes into atablenotesblock;siunitx_preamble=Trueemits the required\usepackagehint. Unsupported regimes raiseNotImplementedErrorrather than emit non-compiling LaTeX. The default LaTeX path is byte-identical. Threaded throughEconometricResults.to_latex. sp.coefplot_tikz()—pgfplots/ TikZ coefficient forest plot (the LaTeX-native counterpart tosp.coefplot, whose(fig, ax)already gives PNG/PDF viafig.savefig): one\addplotseries per model with horizontal CI error bars, reversed y-axis, dashed zero line;coef_labels/level/standaloneoptions. Auto-registered.- Export-surface contract (
tests/test_export_surface_contract.py) — 55 parametrized checks assertingsp.regtable(r)consumes registered result-class outputs (EconometricResults/CausalResult/PanelResults/FrontierResult) and round-trips, so a future non-exportable result fails loudly. Guide §7–§8 document coefplots and the table/non-table boundary. Collection.to_dict()/.to_json()andPaperTables.to_dict()/.to_json()— agent-native serialisation extended to the multi-table containers. Each regression-table item/panel reusesRegtableResult.to_dict()(and staysfrom_dict-round-trippable); DataFrame items round-trip throughDataFrame.to_json(NaN → null); text/heading carry their string. Bothto_json()emit strict JSON.
Fixed¶
- Two dangling enrichment citations —
_CITATIONS_BY_TOOLreferenced a mistypeddechaisemartin2020twoway(corrected to the existingdechaisemartin2020two) and acattaneo2015randomizationkey absent frompaper.bib(added, verified via De Gruyter DOI 10.1515/jci-2013-0010 and the rdpackages reference). refs verified via aeaweb.org + RePEc (de Chaisemartin & D'Haultfœuille 2020) and degruyterbrill.com + rdpackages (Cattaneo, Frandsen & Titiunik 2015). - Four dangling agent-card alternatives —
rif_regression(→rif_decomposition),sp.ope_ipw(→sp.ipw), and twofixest_in_rexternal references (→hdfe_ols) pointed at non-existent functions; an agent following them would have hitAttributeError.
[1.16.0] — 2026-05-29¶
⚠️ Correctness fix¶
-
sp.qregPowell sandwich SE was wrong by a factor of √n — every pre-fix p-value, z-statistic, and confidence interval emitted bysp.qregwas unusable. The closed-form Koenker (2005, eq. 3.7) iid kernel sandwich isV = τ(1−τ) / f̂(0)² · (X'X)⁻¹._qreg_sehad an extra factor ofnin the denominator (/ (n * f0**2)), so the reported SE was the correct SE divided by √n — on n = 500 the SE was ~20× too small. The fix removes the spuriousn. After the fix the three-way parity at the median tolerance (tests/r_parity/40_qreg) matchesquantreg::rqwithin 1.4–6.8 % and Stataqregwithin 2.9 %, consistent with the documented kernel-vs-Koenker-Bassett SE method gap. Action: any analysis that previously usedsp.qregSE, z-statistic, p-value, or CI must be re-run; point estimates are unaffected. SeeMIGRATION.md§ sp-qreg-se-fix for the per-call impact and rerun recipe. -
sp.xtabond(Arellano-Bond difference GMM) point estimates AND SEs were wrong — finding #12. The estimator built a flat, fixed set of lagged-level instrument columns (gmm_lags=(2,5)) and then dropped every row missing any of them, which on a short panel discards most of the sample; it also usedW = (Z'Z)⁻¹as the one-step weight. The correct Arellano-Bond estimator uses a block-diagonal GMM instrument matrix (every available deeper lag is a period-specific moment, missing lags filled with 0, no rows dropped) and the one-step weightW = (Σᵢ Zᵢ'H Zᵢ)⁻¹whereHcarries the MA(1) structure of the differenced errors (2 on the diagonal, −1 on the first off-diagonals). On the parity DGP the old code gaveβ_{y₋₁}=0.264 (se 0.224)vs Stata's0.391 (se 0.046)— a 48 % estimate gap and an 80 % SE gap. After the rewrite the one-step robust estimates match Stata'sxtabond y x, lags(1) vce(robust)to machine precision (tests/r_parity/50_xtabond, rel ≈ 1e-15 on both β and SE). The defaultgmm_lagsis now(2, None)(all available deeper lags, matching Stata's default; pass an explicit max to cap). Two-step GMM now applies the Windmeijer (2005) finite-sample SE correction. Action: re-run any analysis that usedsp.xtabond— both point estimates and SEs change. SeeMIGRATION.md§ sp-xtabond-fix. -
sp.xtabond(method='system')/sp.panel(method='system')now raiseNotImplementedErrorinstead of returning an unvalidated (and, after the difference-GMM rewrite, badly distorted) estimate. Proper Blundell-Bond system GMM requires a stacked level equation and its own Stataxtdpdsysparity reference, which is planned for a future release. Action: usemethod='difference'(Arellano-Bond), now validated to machine precision.
Added — Parity coverage expansion (2026-05-28 session)¶
- 15 net-new parity modules (
tests/r_parity/{37–51}_*) coveringsp.ppmlhdfe,sp.drdid,sp.arima,sp.qreg,sp.tobit,sp.nbreg,sp.heckman,sp.mlogit,sp.ologit,sp.clogit,sp.probit,sp.oprobit,sp.xtabond,sp.newey, and a 3-FE PPML variant. The 3-way Track A table (tests/r_parity/results/parity_table_3way.md) covered 50 R-joined modules versus 36 previously at that checkpoint, with a Stata reference for 43 versus 21. The current source snapshot supersedes that checkpoint with 64 R-joined modules, 61 Stata references, and a materialized50_xtabondR reference throughplm::pgmm. The expansion surfaced the qreg and newey SE fixes above and further P1/P2 findings recorded intests/r_parity/PARITY_SESSION_2026-05-28.md.
Fixed¶
- Cleaned up external-review follow-ups: removed two uncited duplicate BibTeX entries that caused editorialbot DOI suggestions, aligned the AKM shift-share citation key / DOI metadata, and refreshed v1.15.6 wording in reviewer-facing docs and README release callouts.
tools/audit_citations.pynow treats transient HTTP/socket/SSL timeouts as unresolved citation lookups instead of leaking Python tracebacks.tests/r_parity/36_mediation.pyreferencedmodel_info["n_boot"], butsp.mediation's schema renamed this ton_boot_requested/n_boot_successful/n_boot_failed. The parity script crashed before producing JSON; pinned it to the new key.
[1.15.6] — 2026-05-24¶
Changed — Co-authorship, software-journal submission readiness¶
- Added Scott Rozelle as co-author across all package metadata:
pyproject.toml,src/statspai/__init__.py(__author__),CITATION.cff,.zenodo.json,mkdocs.yml, the package citation templates insrc/statspai/_citation.py(BibTeX / APA / plain), and the README BibTeX snippets (English and Chinese). - ⚠️ Downstream-facing rename: unified the package BibTeX key to
wang2026statspai(CLAUDE.md §10lastnameYEARkeywordconvention). Previous keys emitted or documented in earlier versions (wang_statspai_2026,wang_rozelle_statspai_2026,statspai2026software, barestatspai) are removed in favor of a single canonical key. Downstream.texfiles that cite the previous key need a one-line rename to\cite{wang2026statspai}. The impact surface is small — only users who literally copied the previous BibTeX entry into their own.bibare affected; users who regenerate viasp.citation("bibtex")get the new key automatically. sp.citation("bibtex")now emits the unified key and the updated author list.sp.citation("apa")andsp.citation("plain")already reflected the co-author; both surfaces now also carry the 1.15.6 version string.CITATION.cffversion/date-releasedbumped to1.15.6/2026-05-24.
Added — reviewer-facing documentation¶
- reviewer guide — install, smoke test, representative offline
examples, targeted tests, and build check, intended as a short reviewer
path. All five smoke-test API calls are verified against the current
registry (
ivreg,callaway_santanna+aggte,rdrobust,synth,describe_function/function_schema). - validation dossier — project status, registry counts, validation tracks (R-parity / Stata-parity / reference-parity / Monte Carlo coverage / snapshot tests / citation audits), parity anchors, research-use statement (working-paper use; no published peer-reviewed article yet), open-core / commercial-downstream disclosure (StatsPAI Inc. + CoPaper.AI), and reproducible-check commands.
- Both pages added to the MkDocs navigation.
Changed — paper.md (software-journal manuscript)¶
- Repo URL casing corrected to canonical
StatsPAI; added Zenodo archive reference (@wang2026statspai). - Research-impact paragraph rephrased to match the actual current state: StatsPAI is used in working-paper workflows connected to Stanford REAP; no peer-reviewed research article using the package has yet been published.
- AI Usage Disclosure rewritten to spell out exactly what generative AI was used for (code generation, refactoring, test scaffolding, documentation drafting, manuscript copy-editing), to note that exact model identifiers were not retained for all exploratory sessions, and to confirm that generative AI will not be used to produce substantive responses to journal editors or reviewers.
- Acknowledgements split: explicit Author Contributions subsection attributing roles to each author, and an open-core / commercial-downstream disclosure (StatsPAI Inc. is the legal entity; CoPaper.AI is a commercial downstream product that may call the MIT-licensed StatsPAI package; the package itself remains permanently open source under MIT).
paper.bibadds thewang2026statspaisoftware entry pointing at the Zenodo concept DOI.
[1.15.5] — 2026-05-21¶
Added — Agent-card coverage ratchet and baseline enrichment¶
- Added
scripts/agent_card_coverage.py,docs/agent_cards_spec.md, andtests/test_agent_card_coverage.pyto make raw curated agent-card metadata measurable and CI-ratcheted. The committed floor tracks 15 counters across Tier-B, Tier-A, Tier-S, per-field coverage, and certified / validated evidence counts. - Added generated
src/statspai/_baseline_cards.pyplusscripts/gen_baseline_cards.pyto fill empty Tier-B fields from docstrings without overwriting curated registry entries. The baseline pass lifts tags to 100% of the then-current v1.15.5 registry and keeps examples / references limited to mechanically extracted, auditable content. - Added
FunctionSpec.inherits_fromand inherited agent-card rendering for canonical estimator variants. Child specs keep their own descriptions, examples, parameters, references, validation status, and limitations, while sharing parent assumptions, preconditions, failure modes, alternatives, andtypical_n_minwhere appropriate.
Changed — Registry and documentation refresh¶
- Expanded validation evidence seeds for tested long-tail estimators so the agent registry distinguishes stable APIs from functions with explicit unit, regression, parity, or reference-test coverage.
- Refreshed registry count, module statistics, and agent-platform positioning for the then-current v1.15.5 public surface.
- Updated DiD and agent-facing docs to mark
continuous_did(method='cgs')anddid_multiplegt_dynas experimental MVP paths rather than fully paper-parity estimators.
[1.15.4] — 2026-05-18¶
Added — Auto-CJK font fallback on import¶
import statspai as spnow auto-registers a detected CJK font (PingFang SC / Microsoft YaHei / Noto Sans CJK / SimHei / Source Han Sans / …) as a per-glyph fallback in matplotlib'sfont.familylist. Chinese text in plots renders correctly without callingsp.use_chinese()on any system that already has a CJK font installed (default on macOS / Windows / most modern Linux desktops).- Safe-by-design: the user's primary family stays at
font.family[0], so Latin text is rendered with the original primary font (DejaVu Sans / Helvetica / Times New Roman / …) — no visual change for English-only plots.axes.unicode_minusis untouched, so the minus sign on tick labels stays as the proper U+2212 from the Latin primary. - Opt-out: set environment variable
STATSPAI_NO_AUTO_CJK=1before importing statspai. The explicitsp.use_chinese()entry point still exists for users who want a specific font or who need primary-font control (e.g.,sp.use_chinese('serif')). - Mechanism:
font.familybecomes['sans-serif', '<best CJK sans>', '<best CJK serif>'](or whatever family was set). matplotlib 3.6+ per-glyph fallback walks this list, so CJK glyphs missing from the primary fall through to the appended CJK font without affecting Latin glyphs. Empirically required vs. appending tofont.sans-serif, which does not trigger fallback in matplotlib 3.10. - New tests:
tests/test_auto_cjk_fallback.pycovers the 7-point behavior contract (primary preserved, unicode_minus preserved, family-specific lists preserved, Chinese renders without warnings, user override wins, idempotent, env var opt-out).
[1.15.3] — 2026-05-17¶
Fixed — PyPI long-description hero banner¶
- The v1.15.2 PyPI project page rendered the hero banner as a broken
image. Root cause:
README.mdandREADME_CN.mdreferenceddocs/logo/readme-1.pngwith a repo-relative path, which GitHub resolves against the rendered tree but PyPI's long-description renderer has no base URL for and therefore leaves as a 404. Both READMEs now point at the absolute raw GitHub URLhttps://raw.githubusercontent.com/brycewang-stanford/StatsPAI/main/docs/logo/readme-1.pngso the banner loads on PyPI / TestPyPI / Open Source Insights / any other off-GitHub README renderer. - No code changes. All shipped artifacts have the same module hashes as v1.15.2 except for the regenerated long-description metadata baked into the wheel + sdist.
[1.15.2] — 2026-05-17¶
Headline¶
Patch release on top of v1.15.1. No estimator numerical paths change. Three independent hardening tracks land together:
- Agent-native infrastructure —
sp.agent.mcp_serveris now strict- JSON-clean over the wire (noNaN/Infinityliterals reaching Claude Desktop / RFC 8259 parsers), agent schema metadata extraction is more complete, and a newtextextra makessentence-transformersan explicit opt-in for the v1.6causal_textsurface instead of a soft import surprise. sp.replicatedual-track — Card (1995), Abadie-Diamond- Hainmueller (2010), Lalonde (1986) / DW (1999), and Lee (2008) replications are promoted from single-track stubs to full classic + modern recipes that ship with the original public- domain CSVs and pinned golden numbers.- Release packaging — wheel smoke tests now fail loudly on
import error,
py.typedships in the wheel for downstreammypy --strictconsumers, the result_repr_html_path escapes user-controlled cell content (notebook XSS-safety), and theformulaicdependency thatsp.spatialparses formulas with is declared explicitly instead of relying onlinearmodels's transitive resolution.
Added — text optional extra¶
- New
[project.optional-dependencies] text = ["sentence-transformers>=2.2.0"]inpyproject.toml. The v1.6sp.causal_textMVP used to lazy-importsentence_transformersand raise an opaqueImportErroron first call.pip install statspai[text]now wires the dependency explicitly; the lazy import still triggers a clear pointer to the extra when missing.
Added — sp.replicate dual-track guides for four canonical papers¶
- Card (1995) — proximity-to-college IV for returns to schooling.
- Abadie, Diamond & Hainmueller (2010) — California Proposition 99 synthetic-control.
- Lalonde (1986) / Dehejia-Wahba (1999) — NSW + PSID-1 (MatchIt
subset,
n=614) propensity-score matching. - Lee (2008) — US Senate RD (
n=1390) bandwidth-selected jump.
Each entry now ships a classic track (the estimator the paper
used: 2SLS for Card, weighted synthetic-control for Abadie, naive
OLS / adjusted OLS / 1:1 NN PSM for Lalonde, local-linear CCT RD
for Lee) and a modern track (DML PLR + entropy balancing,
bias-corrected robust RD, multi-method synth-compare). Real CSVs
land under src/statspai/datasets/data/
(card_1995.csv, california_prop99.csv, lalonde_matchit.csv,
lee_2008_senate.csv) and sp.datasets.nsw_lalonde /
sp.datasets.lee_2008_senate gain a simulated=False real-data
branch with published-paper benchmarks exposed via df.attrs. The
Lalonde classic track now reproduces DW (1999) Table 3-4 within a
$5 drift tolerance; the Lee CCT track returns Conv 7.414 and
bias-corrected robust 7.507 (SE 1.741, h=17.754), matching the
R rdrobust reference. All 13 BibTeX keys cited across the four
entries are verified in paper.bib.
Fixed — MCP wire format is strict-JSON-clean¶
sp.agent.mcp_serverused to serialise responses withjson.dumps(..., default=_json_default), which does not intercept native Pythonfloat('nan')/float('inf')— those become the non-standard literalsNaN/Infinityin the JSON output, which RFC 8259 parsers (including Claude Desktop'sJSON.parse) reject with errors like"No number after minus sign". Responses now pass through_clean_floats(recursively replacesNaN/±Infinitywithnullacrossdict/list/tuplecontainers) and serialise withallow_nan=False, so the server can never emit a JSON token a strict parser refuses. Covered by 273-line regression suitetests/test_mcp_nan_inf.py.- Agent schema metadata extraction (
sp.function_schema,sp.describe_function) now surfaces more signature detail for registry entries built from auto-introspection. - Stability-tier audit (
scripts/stability_audit.py) accounts for evidence files more precisely; newtests/test_agent_schema.pylocks the schema metadata fields agents rely on.
Fixed — result HTML escaping (notebook XSS-safety)¶
CausalResult._repr_html_(and the surrounding rich-display helpers) now route every user-derived cell throughhtml.escape. Previously, any string column whose contents contained</>/&/"would interpolate raw into the rendered HTML, opening a path for notebook XSS when a result was displayed in Jupyter / VS Code / nbviewer. New regression test:tests/test_results_html_escape.py.
Fixed — release packaging hygiene¶
pyproject.tomlbumps the build requirement tosetuptools>=77.0.0and migrateslicense = {text = "MIT"}to the modern PEP 639license = "MIT"+license-files = ["LICENSE"]pair. Drops the deprecatedLicense :: OSI Approved :: ...classifier path implicitly.MANIFEST.innow includessrc/statspai/py.typedand the sdist test fixtures sopip install --no-binary :all:andmypy --strictboth behave correctly on the published artifacts..github/workflows/build-wheels.ymland.github/workflows/ci-cd.yml: wheel smoke tests now fail the job onImportErrorinstead of swallowing it as a warning. Releases that silently ship a broken wheel are no longer possible from a green CI run.- New explicit dependency
formulaic>=0.6.0independencies(sp.spatial.*parses Wilkinson formulas through it; relying onlinearmodels's transitive resolution broke when downstream users pinned olderlinearmodels).
Docs¶
- Software-journal submission
paper.mdis rewritten for the Scott Rozelle review pass — tighter scope statement, cleaner schema description, explicit AI-use disclosure, 12 May 2026 submission date. Cited bibliography entries inpaper.bibare refreshed to match. - README / README_CN add the hero banner image
(
docs/logo/readme-1.png). - Track-C performance comparison table
(
tests/perf/results/perf_table.tex) switches to\scriptsizewith package-name macros and a direction-aware "Winner" column; log-log figure regenerated to match.
Internal¶
- Perf-benchmark harness factored — new
tests/perf/_common.pyshared utilities;tests/perf/05_feols_jax_bootstrap_bench.pyrewritten on top. - Full-suite validation snapshot refreshed in
docs/test_results_full_suite.md.
[1.15.1] — 2026-05-07¶
Headline¶
Patch release on top of v1.15.0 preparing the public PyPI cut. Existing
estimator defaults are preserved. The only new runtime path is opt-in:
sp.rdrobust(..., bwselect='cct') delegates to the official
rdrobust>=1.3 Python package for bit-equal R rdrobust::rdrobust
replications. The default bwselect='mserd' remains StatsPAI's
internal MSE-optimal recipe.
The release notes and README now also document the negative-binomial
count-regression implementation that is already exposed through
sp.nbreg, sp.xtnbreg, and sp.menbreg. This is documentation /
release-packaging work plus the 1.15.0 → 1.15.1 version bump; it does
not change the negative-binomial numerical path.
Docs — negative-binomial regression implementation note¶
sp.nbregis a log-link MLE for overdispersed non-negative count outcomes. The defaultdispersion="mean"path is NB2,Var(Y|X)=mu + alpha * mu^2;dispersion="constant"switches to NB1,Var(Y|X)=mu * (1 + delta).- The optimizer starts from a Poisson IRLS fit. It then alternates
NB-weighted IRLS updates for the coefficient vector with scalar
profile-likelihood optimization for the dispersion parameter on the
log scale (
alphafor NB2,deltafor NB1). - Inference uses the NB working-weight bread.
robust="robust"/"hc0"/"hc1"select sandwich SEs, andcluster=selects cluster-robust SEs.irr=Truereports incidence-rate ratios by exponentiating coefficients and delta-method SEs. - Offsets and exposure are supported (
offset=supplies a log offset;exposure=is logged internally). Diagnostics include log-likelihood, AIC/BIC, pseudo-R2, fitted values / residuals, and a one-sided likelihood-ratio test of the dispersion path against Poisson using the standard 50:50 chi-bar-square mixture. - Formula fixed effects such as
y ~ x | idare implemented by explicit dummy expansion. That choice is transparent and compatible with the MLE path, but it is intended for moderate-cardinality panels; high-cardinality HDFE count models should use the Poisson/PPML surface (sp.fepois,sp.ppmlhdfe) unless a full dummy NB fit is really intended. sp.xtnbreg(model="fe")wrapssp.nbregwith entity/time fixed effects and defaultscluster=to the entity id.model="pooled"strips the panel fixed-effect part.model="re"dispatches tosp.menbreg, the random-intercept NB2 GLMM, and returns the multilevelMEGLMResult.
Added — R-parity opt-in for sp.rdrobust¶
- New
bwselect='cct'insp.rdrobustdelegates the entire estimation (bandwidth selection + bias-corrected inference) to the officialrdrobust>=1.3Python port (Calonico, Cattaneo & Titiunik 2014). This guarantees bit-equal alignment with Rrdrobust::rdrobustfor users who need exact replication of CCT-2014 published numbers — for example the canonical Lee/CCT Senate case where R returnsConv = 7.4141 / Robust = 7.5065 / h = 17.754. The internalbwselect='mserd'(default) is kept unchanged for backward compatibility — it uses StatsPAI's own MSE-optimal recipe which can drift from R'srdbwselectby up to ~70% on certain datasets (documented intests/orig_parity/results/parity_table_orig.mdrow 52, module05_lee_original). - Install with
pip install statspai[rd-cct](adds the officialrdrobust>=1.3dependency). Callingbwselect='cct'without it raises a clearImportErrorpointing to the install command. - See MIGRATION.md
for guidance on when to switch from
'mserd'to'cct'.
Tests — did::aggte parity lock¶
- Added
TestAggteRParityintests/external_parity/test_published_replications.py. Assertssp.aggte(type='simple')is bit-equal (≤1e-10) with Rdid::aggterecorded intests/orig_parity/results/02_mpdta_original_R.json, andtype='dynamic'matches R's published vignette output to 1e-3. Prevents future refactors from silently drifting away from R. - Added
TestCCTDelegationParityand anImportError-guarded test that pin the newbwselect='cct'delegation to RrdrobustSenate-replication numbers (Conv 7.4141, Robust 7.5065, h=17.754, 1e-3 tolerance).
Internal¶
- Added
[project.optional-dependencies] rd-cct = ["rdrobust>=1.3"]topyproject.toml. sp.datasets.list_datasets()now returns six columns (addedpaper_originalcolumn to honestly distinguish the published paper number from the simulated-replica's actual estimator output).
[1.15.0] — 2026-05-05¶
Docs — sp.dml_panel citation correction¶
- ⚠️ Docs-only correction —
sp.dml_panel(originally shipped in v1.7) was attributed in its docstring, registry entry, README blurb, and CHANGELOG release note to "Semenova & Chernozhukov (2023) Econometrics Journal 26(2), Debiased Machine Learning of Conditional Average Treatment Effects and Other Causal Functions." That citation is fabricated: independent verification via Crossref and the Oxford ECTJ issue TOC confirms no Semenova or Chernozhukov paper appears anywhere in Econometrics Journal 26(2) (May 2023), and the cited title in fact belongs to Semenova & Chernozhukov (2021) ECTJ 24(2) 264-289 (DOI 10.1093/ectj/utaa027) — a paper on CATE / debiased ML for causal functions, unrelated to long-panel PLR with fixed effects. - The estimator's actual reference is Clarke, P. S. & Polselli, A.
(2025). "Double Machine Learning for Static Panel Models with
Fixed Effects." The Econometrics Journal 29(1) 69-86, DOI
10.1093/ectj/utaf011,
arXiv:2312.08174. The paper specifies the within-group / first-
difference transform, block-k-fold cross-fitting that allocates
each unit's full time series to a single fold, and cluster-robust
variance at the unit level — point-for-point match with the
StatsPAI implementation. Companion Stata package:
xtdml. sp.synth(method='cluster')method-citations registry: ClusterSC second-author surname corrected (was a misattribution; now matches the arXiv 2503.21629 author list — Rho, Tang, Bergam, Cummings, Misra).paper.bibwas already correct; the typo only lived insrc/statspai/synth/report.py.- Updated callsites:
paper.bib(newclarke2025doubleentry),src/statspai/dml/panel_dml.py(module docstring + within-transform comment),src/statspai/dml/__init__.py(lazy-export tag),src/statspai/registry.py(FunctionSpec description + reference field),README.md(Long-panel Double-ML row),src/statspai/synth/report.py(ClusterSC author list), and the historical v1.7 entry below (annotated, not silently rewritten). No code logic, numerical path, API signature, or test changed — pure citation correction. - Refs verified via Crossref (DOIs
10.1093/ectj/utaf011and arXiv2503.21629) and OpenAlex.
Docs — v1.14 GPU sprint follow-up¶
- JSS manuscript (
Paper-JSS/manuscript/, gitignored — local working copy) gets a substantive expansion of §5.6 Performance backbone documentingsp.fast.feols_jax,sp.fast.feols_jax_bootstrap(with the score-formulation derivation for wild and wild-cluster variants and the Cameron-Gelbach-Miller 2008 attribution added tojss-bib.bib), the Rustcluster_meatkernel, andsp.iv(absorb=...). §6 originally sketched an accelerator-benchmark table forsp.fast.feols_jax_bootstrap, but the JSS source snapshot now relies only on packaged measurements. The active Track C table is generated from measured CPU benchmarks undertests/perf/, and GPU/JAX timing remains an opt-in engineering benchmark until a dedicated accelerator run is packaged as evidence. - Software-journal bullet 5 in
paper.mdsimplified from a four-paragraph exposition to a single tight paragraph that cross-references the JSS companion paper for full architecture detail. - Note on version numbering: the
pyproject.tomlversion moved from 1.14.0 (GPU sprint cut, commita87d788) to 1.15.0 (RDD polish cut) within a single day. Both[1.14.0]and[1.15.0]entries below remain historically correct for the work each release contained; no retroactive renaming is needed. PyPI publishes1.13.1 → 1.15.0directly —1.14.0was an internal cut that was never released to PyPI and is recorded here for git / CHANGELOG history only.
Headline¶
Five pushes in this cycle. First, an IV-module polish to the post-2022
reporting standard (the sp.iv.iv_diag bundle, see below). Second, a
synthetic-control polish pass: supported synthetic-control estimators in
that release now have a publication-oriented table-export pipeline, the trajectory
and gap plots gain prediction-interval / pre-RMSPE ribbon options
following Cattaneo, Feng and Titiunik (2021, JASA 116, DOI
10.1080/01621459.2021.1979561) and Cattaneo, Feng, Palomba and Titiunik
(2025, JSS 113(1), DOI 10.18637/jss.v113.i01), and the SDID schema
is canonicalised end-to-end so sp.synth_report(method='sdid', ...)
produces a full Markdown / text / LaTeX report rather than a row of
N/As. Seven 2022–2025 SCM citations were added to paper.bib,
each verified independently via Crossref / arXiv (refs verified
via crossref + arxiv). Third, a decomposition-module polish — see
the dedicated section below. Fourth, a ML+causal polish wave (v1.15)
covering DML / meta-learners / causal forests / causal discovery /
policy learning / mediation — see the dedicated section below. Fifth,
an RDD module polish (v1.15) to the 2018–2026 frontier with three
new estimators (sp.rd_flex, sp.rd_bias_aware_fuzzy, sp.rd_discrete),
three reporting helpers (sp.rd_dashboard, sp.rd_compare,
sp.rd_robustness_table), an sp.rdrobust polish pass with the CCT-2018
rho parameter and discrete-RV / weak-first-stage warnings, and a
sp.rdplotdensity upgrade to the Cattaneo-Jansson-Ma (2020) boundary-
adaptive density estimator — see the dedicated section below.
Added — ML+causal polish (v1.15)¶
- DML-OVB sensitivity analysis (
sp.dml_sensitivity,DMLSensitivityResult) implementing the Chernozhukov–Cinelli– Newey–Sharma–Syrgkanis (2022) "Long Story Short" framework (NBER WP 30302; arXiv:2112.13398). Returns the robustness value RV_q (strength of confounder needed to shrink the estimate to zero), the significance-loss value RV_{q,α}, scenario bias bounds for user-specified (cf_y, cf_d), benchmark-covariate comparisons, and aplot()rendering bias contours over the (cf_d, cf_y) grid à la Rsensemakr. Refs verified via NBER + arXiv. - DML diagnostics bundle (
sp.dml_diagnostics,DMLDiagnostics) bundles overlap (propensity histogram for IRM; |D-residual| distribution for PLR), score density (with N(0,σ̂²) overlay and Q-Q plot), residual-balance check (corr(X_k, Ỹ) and corr(X_k, D̃) for each covariate), and an orthogonality-score test in a single 2×2 publication-style panel matching DoubleML's defaults (Bach–Kurz–Chernozhukov–Spindler–Klaassen 2024, JSS 108(3), DOI 10.18637/jss.v108.i03). - Backbone-agnostic CATE evaluation (
sp.cate_eval,CATEEvalResult) computing Yadlowsky–Fleming–Shah–Brunskill– Wager (2025) RATE / AUTOC / Qini with closed-form influence- function SEs for any CATE array (meta-learner, BCF, conformal- CATE, neural-CATE), so the metric is decoupled from the forest backbone. JASA 120(549), DOI 10.1080/01621459.2024.2393466 (arXiv:2111.07966). Verified via Crossref + arXiv. - ⚠️ Correctness fix —
forest.CausalForest.best_linear_projectionis rewritten to use the Semenova–Chernozhukov (2021) AIPW pseudo-outcome Γ_i with HC1 standard errors. The previous implementation regressed the plug-in CATE estimate on X with naïve OLS SEs, which was anti-conservative in finite samples. Econometrics Journal 24(2): 264–289, DOI 10.1093/ectj/utaa027. Users who relied on the prior BLP SEs should re-fit and report the new HC1 numbers. - ⚠️ Correctness fix —
mediation.mediateno longer silently substitutes the point estimate for failed bootstrap replicates (which artificially shrunk SEs). Each failure now triggers up to five retry draws; remaining failures are dropped, and aRuntimeWarningfires if more than 10% of replicates fail. The result'smodel_infoexposesn_boot_requested,n_boot_successful,n_boot_failed, andboot_failure_ratefor audit. SEs estimated under heavy bootstrap failure on prior versions should be regenerated. - OPE namespace deduplication —
sp.policy_learning.OPEResultis now an alias for the canonicalsp.ope.estimators.OPEResult, soisinstance(sp.direct_method(X, A, R, π), sp.OPEResult)is True regardless of which entry point was used. The legacyestimator/n_obsattributes survive as properties on the unified class. - Causal-discovery graph visualization — every result class
(
LiNGAMResult,GESResult,FCIResult,ICPResult,PCMCIResult,LPCMCIResult,DYNOTEARSResult) and the dict- shaped returns fromsp.notearsandsp.pc_algorithm(now promoted to aDAGDictthin subclass) expose a unified.to_networkx()/.to_dot()/.plot()/.edge_list()API. Module-level helperssp.causal_discovery.{to_networkx, to_dot, plot_dag, edge_list, shd}work standalone on any adjacency matrix;shd()follows the Tsamardinos–Brown–Aliferis (2006) Structural Hamming Distance convention. - PolicyTreeResult promotion —
sp.policy_treenow returns aPolicyTreeResult(subclass ofdictfor full back-compat) with influence-function SE on the policy value and a 95% CI from the AIPW scores, plus a Graphviz-styleplot_tree(),summary(),to_latex(),to_excel(), andcite()(Athey & Wager 2021, Econometrica 89(1)). - Mediation sensitivity plot upgrade —
MediateSensitivityResult.plot()now produces a publication-style ACME(ρ) curve with coloured fill for the {ACME>0} / {ACME<0} regions, annotated baseline, and explicit ρ-at-zero (the robustness threshold). CausalResult.to_wordadded alongside the existing.to_latex/.to_excel. Renders a publication-style three-block Word document (estimates / detail / notes) using the AER booktab styling helpers inoutput/_aer_style.py. Coverage: supported estimators that already returnsCausalResult(DML, TMLE, BCF, mediate, conformal_cate, proximal.p2sls, matrix_completion, metalearners, hal_tmle, did, rd, synth) now has uniform LaTeX / Excel / Word export.- DTR + QTE test coverage —
tests/test_dtr.py(10 new tests) andtests/test_qte.py(7 new tests) close two zero-coverage modules flagged in the v1.13 audit. Tests verify (i) Q-learning exactly recovers the optimal terminal-stage rule under a linear blip, (ii) A-learning's terminal contrast aligns with the truth, (iii)qteandqdidrecover the constant-shift / parallel- trends benchmarks of Firpo (2007) and Athey–Imbens (2006) respectively to within 0.30 absolute error at every quantile, and (iv)distributional_te's CDFs are monotone with stochastic dominance in the right direction. tests/test_ml_causal_polish.py(22 new tests) covers all of the above end-to-end (BLP DR-score recovery, mediation bootstrap diagnostics, OPE isinstance, DAG viz,PolicyTreeResultcontract, DML sensitivity / diagnostics,cate_evaldirection,to_wordintegration).- Citation expansion — 4 new bib entries added to
paper.bib, each verified independently via NBER / arXiv / journal site:chernozhukov2022long,semenova2021debiased,yadlowsky2025evaluating,bach2024doubleml.
Added — decomposition module polish (v1.15)¶
- Yu–Elwert (2025) nonparametric causal decomposition
(
sp.yu_elwert_decompose, dispatcher aliasesyu_elwert/cdgd) — splits an observed group disparity into baseline, prevalence, effect, and selection mechanisms. Two estimators: a plug-in version that returns exactly zero residual by algebraic identity, and a doubly-robustmethod="efficient"augmented variant. Cluster-aware bootstrap inference, plot helper (yu_elwert_mechanisms_plot), and a per-component CI table. Aligned conceptually with the Rcdgdpackage. Reference verified via Crossref: doi:10.1214/24-AOAS1990 (Annals of Applied Statistics, 19(1) 821–845). - Unified
DecompResultMixin— every result class insp.decomposition(Oaxaca, Gelbach, RIF, FFL, DFL, Machado–Mata, Melly, CFM, Fairlie, Bauer–Sinning, Yun, Kitagawa, Das Gupta, Subgroup / Source / Shapley inequality, GapClosing, Mediation, Disparity, Yu–Elwert) now exposes the same surface:confint(alpha),cite()/cite("bibtex_keys"),to_dict(),to_json(),to_excel(path)(multi-sheet workbook), andto_word(path)(python-docx report) in addition to the existingsummary()/plot()/to_latex(). - Plot polish —
sp.decomposition.plotsnow ships a unified Material-style palette (DECOMP_PALETTE) and a despined minimal grid viaapply_decomp_style. New helpers:forest_plot(with significance shading),mediation_forest, andyu_elwert_mechanisms_plot. Existing plots (detailed_waterfall,quantile_process_plot,dfl_plot,ffl_waterfall,gap_closing_plot,inequality_subgroup_plot,counterfactual_cdf_plot,rif_heatmap) gain optional 95% CI whiskers / ribbons whenever the result carries SEs. - Wild bootstrap in
decomposition._common.wild_bootstrap_statwith Rademacher and Mammen multipliers and cluster-aware multiplier sharing (Cameron–Gelbach–Miller 2008 style).analytical_ci(point, se, alpha)helper for two-sided normal intervals. - Citation expansion — 15 new bib entries added to
paper.bib, each verified via Crossref:blinder1973wage,oaxaca1973male,neumark1988employers,cotton1988estimation,reimers1983labor,jann2008blinder,gelbach2016covariates,kline2011oaxaca,shorrocks1980class,cowell2007income,riosavila2020rif,kroger2021kitagawa,oaxaca2025meets,yu2025nonparametric,park2024choosing(refs verified via Crossref). - Docs — new family guide
docs/guides/decomposition_family.md, wired into MkDocs nav under the v1.15 entry. Decomposition section inpaper.mdrewritten with inline citation keys. - Tests —
tests/test_decomposition_polish.py(14 new tests) covering the Yu–Elwert algebraic identity, bootstrap inference, dispatcher routing, plot smoke test, the unified mixin (cite / to_dict / to_excel / confint), and the wild bootstrap helper (Rademacher / Mammen / clustered).
Added — synthetic control polish¶
sp.synth_to_latex(obj, ...)— booktabs LaTeX for anyCausalResultfromsp.synth(method=...)or for aSynthComparison(side-by-side multi-method layout). Optional donor-weights panel, configurable significance stars, and a\\hlinefall-back for non-booktabs documents.sp.synth_to_markdown(obj, ...)— pipe-table Markdown counterpart rendering on GitHub, in pandoc, and in static-site generators.sp.synth_to_excel(obj, path, ...)— multi-sheet workbook with Summary / Weights / Diagnostics / per-method Gap sheets. Soft dependency onopenpyxl; raises an actionableModuleNotFoundErrorwith install hint when missing.SynthComparison.to_latex(),.to_markdown(),.to_excel()— thin object-method wrappers over the three exporters above.sp.synthplot(..., pi_band=True)— overlay the prediction-interval / conformal CI ribbon on the synthetic counterfactual when the result carriesperiod_resultswithpi_lower/pi_uppercolumns (sp.scpi,sp.conformal_synth).sp.synthplot(..., pre_band=True)— overlay a \(\\pm 1.96 \\times\)pre-RMSPE noise envelope on the trajectory and gap plots (the convention popularised in Abadie, Diamond and Hainmueller 2010, JASA).- Method-aware citations in
sp.synth_report(): SDID, scpi, conformal, gsynth, augmented, matrix-completion, multi-outcome, cluster, sparse, fdid, BSTS, and penalised SCM each close with their own citation rather than the generic Abadie--Diamond-- Hainmueller (2010) reference. - SDID schema canonicalisation in
sp.synth_report(): the report now backfillsn_pre_periods/n_post_periods/n_donors/pre_treatment_rmse/gap_tablefrom SDID-style keys (T_pre,T_post,n_control,Y_obs) so the Markdown / text / LaTeX output is uniform across SC variants. - Seven new verified bib entries in
paper.bib: Abadie & Cattaneo (2021, JASA 116(536), 1713–1715, DOI 10.1080/01621459.2021.2002600); Abadie & Vives-i-Bastida (2022, arXiv:2203.06279); Cattaneo, Feng, Palomba & Titiunik (2025, JSS 113(1), DOI 10.18637/jss.v113.i01); Liu, Wang & Xu (2024, AJPS 68(1), 160–176, DOI 10.1111/ajps.12723); Qiu, Shi, Miao, Dobriban & Tchetgen Tchetgen (2024, Biometrics 80(2), ujae055, DOI 10.1093/biomtc/ujae055); Clarke, Pailañir, Athey & Imbens (2024, Stata Journal 24(4), 557–598, DOI 10.1177/1536867X241297914); Bottmer, Imbens, Spiess & Warnick (2024, JBES 42(2), 762–773, DOI 10.1080/07350015.2023.2238788). - 17 new tests in
tests/test_synth_exports.pycovering single-result and comparison LaTeX / Markdown / Excel exports, the new plot options, and SDID-canonicalised reports.
Added — IV polish (v1.15)¶
sp.iv.iv_diag(data, y, endog, instruments, exog, ...)— modern IV reporting bundle. Returns anIVDiagResultcontaining:- 2SLS point estimate, analytic + pairs / wild Rademacher bootstrap SEs and CIs (cluster-aware) following Davidson--MacKinnon (2010) and Young (2022, EER 147, 104112);
- Olea--Pflueger (2013, JBES 31(3), 358–369) robust effective F;
- Lee--McCrary--Moreira--Porter (2022, AER 112(10), 3260–3290)
tFadjusted critical value and tF-corrected confidence interval; - Anderson--Rubin (1949) / optional Moreira (2003) CLR / optional Kleibergen (2002) K weak-IV-robust confidence sets;
- Kleibergen--Paap (2006, J. Econometrics 133, 97–126) rk LM and Wald F;
- Conley--Hansen--Rossi (2012, ReStat 94(1), 260–272) plausibly-exogenous LTZ sensitivity CI;
- Blandhol--Bonney--Mogstad--Torgovitsky (NBER WP 29709, latest
revision Jan 2025) and Słoczyński (2024, arXiv:2011.06695)
TSLS-as-LATEnegative-weights caveat — automatically surfaced when covariates are present and the endogenous regressor is binary 0/1; - OLS comparator (informative; not causal) per Young (2022).
IVDiagResultexposes.summary(),.to_frame(),.to_dict(),.to_latex(),.to_excel(),.to_word(), and.plot('diagnostic'|'forest'|'weak_iv'|'first_stage').sp.iv.iv_compare(formula, data, methods=...)— run several k-class / JIVE estimators side-by-side and return a one-row-per- method comparison DataFrame (point, SE, CI, first-stage F). Auto-resolves the endogenous coefficient name across heterogeneous result classes.sp.iv.plot.plot_iv_forest(table, reference=...)— forest plot of estimates and CIs across methods.sp.iv.plot.plot_iv_forest_from_diag(result)— forest plot built directly from anIVDiagResultbundle.sp.iv.plot.plot_weak_iv_ci_overlay(result)— Wald / tF / AR / CLR / K / pairs- and wild-bootstrap / LTZ confidence-set overlay.sp.iv.plot.plot_iv_diagnostics(result)— 2x2 panel: first-stage scatter, AR set, weak-IV-CI overlay, leverage diagnostic (Young 2022 spirit).sp.iv_diag,sp.iv_compare,sp.IVDiagResultre-exported at top level for agent ergonomics; both also wired intosp.list_functionsvia the registry.- New verified
paper.bibentries (DOI / arXiv ID double-checked against Crossref + journal pages, May 2026):keane2024practical,young2022consistency,lal2024much,mikusheva2022inference,borusyak2023nonrandom,borusyak2025practical,kaido2021decentralization,chernozhukov2022automatic,chernozhukov2022rieszn,brinch2017beyond,bennett2023minimax,blandhol2025tsls,sloczynski2024should. Existing entries enriched with verified volume / issue / pages:mikusheva2024weak,lee2022valid,borusyak2022quasi,masten2021salvaging.
Changed — IV polish (v1.15)¶
docs/guides/choosing_iv_estimator.mdadds §10 (sp.iv.iv_compareforest comparison), §11 (sp.iv.iv_diagmodern reporting bundle), and a TL;DR pointer to the new bundle.paper.mdgains a self-contained IV bullet under Methodological coverage documenting the new bundle and recent methodology references.
Notes — IV polish (v1.15)¶
iv_diagis single-endogenous by design; for multi-endogenous specifications continue to usesp.weakrobustplussp.iv.sanderson_windmeijerper regressor.- Existing IV functions (
sp.iv,sp.weakrobust,sp.kleibergen_paap_rk,sp.anderson_rubin_test, etc.) are unchanged —iv_diagis purely additive and does not alter any numeric path. The 18 new tests intests/iv/test_iv_diag.pyall pass; no regressions in the 188 prior IV tests.
Added — RDD polish (v1.15)¶
RDD module polish tracking the 2018–2026 literature. Six
additions close the gap between sp.rd and the canonical R/Stata
rdpackages ecosystem on three fronts — recent methodology,
publication-oriented reporting, and automatic diagnostics:
- Three new estimators corresponding to flagship 2018–2025 papers:
sp.rd_flex— flexible (machine-learning) covariate adjustment via cross-fit residualisation following Noack, Olma & Rothe (2025, arXiv:2107.07942 v5). Built-in learnersboost/forest/ridge/lasso(any sklearn regressor accepted) with K-fold cross-fitting; reduces τ̂ variance whenever covariates predict the outcome and remains consistent under free-of-cutoff continuity of η. Reports out-of-sample R² and variance reduction relative to plainrdrobust.sp.rd_bias_aware_fuzzy— Anderson–Rubin-style bias-aware CI for fuzzy RD following Noack & Rothe (2024, Econometrica 92(3), 687–711, doi:10.3982/ECTA19466). Robust to weak first stages and avoids the power asymmetry of conventional fuzzy 2SLS-style CIs documented by Kaliski, Keane & Neal (2025, NBER 33972). Reports first-stage F and warns on F < 10.-
sp.rd_discrete— honest CIs for RD with a discrete running variable following Kolesár & Rothe (2018, AER 108(8), 2277–2304, doi:10.1257/aer.20160945). Two smoothness classes: bounded second derivative (bsd) and bounded misspecification (bm). Both have provable finite-sample coverage when standard rdrobust asymptotics break down because mass points are sparse. -
Three new reporting helpers for the standard CCT–Cattaneo– Idrobo–Titiunik best-practice workflow:
sp.rd_dashboard— single-figure 4-panel diagnostic (RD plot, CJM-2020 density, covariate balance, bandwidth sensitivity) following the recommendations of Calonico, Cattaneo & Titiunik (2015, JASA 110(512), doi:10.1080/01621459.2015.1017578) and Cattaneo, Idrobo & Titiunik (2024, doi:10.1017/9781009441896).sp.rd_compare— side-by-side estimation across an arbitrary list of methods (rdrobust,honest,randinf,flex, …) on the same data; returns a tidypd.DataFrameready forsp.outreg2/sp.modelsummary.-
sp.rd_robustness_table— sweep over kernel × bandwidth × poly × donut, returning paper-facing specifications withto_latex()/to_excel()for one-shot supplemental tables. -
sp.rdrobustpolish: - New
rhoparameter — Calonico, Cattaneo & Farrell (2018, JASA 113(522)) ratio bandwidthb = h / rho. - Auto warning when running variable has < 30 distinct values,
pointing to
sp.rd_discrete. - Auto warning on fuzzy first-stage F < 10, pointing to
sp.rd_bias_aware_fuzzyand quoting the Kaliski-Keane-Neal (2025) ITT recommendation. Both warnings can be silenced viawarn_mass_points=False/warn_weak_first_stage=False. -
First-stage F now exposed at
result.model_info['first_stage_F']. -
sp.rdplotdensityupgrade: replaced the legacy kernel-sum density estimate with the boundary-adaptive local-polynomial CDF- regression density of Cattaneo, Jansson & Ma (2020, JASA 115(531), 1449–1455). Same signature; better boundary behaviour. -
Dispatcher:
sp.rd(..., method='flex' | 'bias_aware' | 'discrete')with full alias coverage (rd_flex,flexible,ml_adjust,bias_aware_fuzzy,noack_rothe,rd_discrete,kolesar_rothe,discrete_rv, …).
Tests — RDD polish (v1.15)¶
- New
tests/test_rd_polish.pywith 21 checks: estimator parity recovery, dispatcher routing, warning behaviour, dashboard smoke tests. - All 156 RD tests (existing + new) pass on Python 3.13 / macOS.
Citations — RDD polish (v1.15)¶
DOI-verified via Crossref / publisher pages 2026-05-05:
noack2024biasaware, noack2025flexible, kolesar2018inference,
kaliski2025power, cattaneo2024extensions,
cattaneopalomba2025covariates, calonico2015optimal.
[1.14.0] — 2026-05-05¶
Headline¶
GPU-acceleration sprint. Three workloads now opt into accelerator
backends without changing their public API: (1) the neural causal
estimators (sp.deepiv, sp.tarnet, sp.cfrnet, sp.dragonnet,
sp.cevae) route through PyTorch CUDA / MPS via a centralised
device resolver; (2) sp.fast.feols_jax runs the full WLS solve on
JAX / XLA; and (3) sp.fast.feols_jax_bootstrap lifts a JIT-compiled
single-iteration WLS kernel to a jax.vmap batched primitive,
giving a 10–100x speedup over sequential CPU bootstrap on CUDA / TPU
at B ≥ 1000. Four bootstrap variants share the same JAX kernel
infrastructure: pairs (multinomial-weight resampling), cluster
(Cameron–Gelbach–Miller 2008 §III.A), wild (row-level Rademacher),
and wild cluster (Cameron–Gelbach–Miller 2008 §III.B); the wild
variants use the score formulation
β* = β̂ + (X'WX)⁻¹ X'W (η ⊙ û) which is mathematically identical
to refitting on y* = X β̂ + η ⊙ û but needs one mat-vec per
iteration instead of a full QR. A new cluster_meat Rust kernel
in statspai_hdfe (PyO3 + Rayon, parallel over clusters) is wired
behind statspai.core._numba_kernels.cluster_meat with the existing
numba kernel as automatic fallback. sp.iv(absorb=...) is the new
2SLS-with-HDFE entry point: residualises y, exogenous controls,
endogenous regressors, and instruments by one or more FE columns
via the Phase-1 Rust demean kernel before fitting, with the residual
DOF adjusted by Σ(G_k - 1) to charge the absorbed FE rank against
iid / HC1 / CR1 SEs. A new docs/guides/gpu_acceleration.md is the
canonical landing page for the accelerator story; the README and
paper.md link to it and explicitly bound the GPU promise (most
estimators are CPU-only by design — DiD / RD / synth / GMM are
bandwidth-bound or small-K convex programs where a tuned CPU
kernel matches GPU performance).
Added¶
sp.fast.feols_jax— JAX-backed end-to-end OLS / WLS with HDFE. Same formula DSL andFeolsResultreturn type assp.fast.feols; the WLS solve and HC1 sandwich run on the default JAX device. CR1 cluster sandwich delegates to the existingcrve(which itself dispatches to the newcluster_meatRust kernel when built). Defaultdtype="float64"preserves bit-comparable numerics;dtype="float32"available for the GPU fast path.sp.fast.feols_jax_bootstrap— vmap'd bootstrap with four variants (pairs,cluster,wild,wild_cluster).vmap_chunk_sizeparameter for memory control on tight devices. Same-seed → bit-identical reproducibility viajax.randomPRNG. Returns aFeolsBootstrapResultdataclass withcoef,se_boot, percentileci_lower/ci_upper, and the fullboot_betastable for custom CI methods.sp.iv(absorb=...)— 2SLS with HDFE residualisation. Accepts"firm + year"string syntax or["firm", "year"]list. LIML / Fuller / GMM / JIVE raiseNotImplementedError(Phase 3b).STATSPAI_TORCH_DEVICEenvironment variable (cpu/cuda/cuda:N/mps/auto) routes neural causal estimators through the requested device. Defaultcpupreserves existing pinned numerics; explicitcudaraises if the device is unavailable rather than silently falling back. Newsp.fast.torch_device_info()mirrorssp.fast.jax_device_info().statspai_hdfe::cluster_meatRust kernel — Rayon parallel over clusters with thread-local k×k upper-triangle accumulator and elementwise reduction. Bumped the crate version 0.5.0-alpha.1 → 0.7.0-alpha.1. Activation requires a one-timepip install maturin && cd rust/statspai_hdfe && maturin develop --release; Python falls back to the numba kernel transparently when the Rust extension is absent.docs/guides/gpu_acceleration.md— accelerator landing page with activation recipes, a Google Colab quickstart benchmark, and an explicit "what is not GPU-accelerated and why" table.
Changed¶
paper.mdadds a fifth bullet to the Unique features list documenting the accelerator story, and notes the Rust HDFE / cluster-meat kernel in the implementation paragraph.README.mdcomparison-table accelerator row now links to the new GPU guide; the What StatsPAI is — and is not bullet expands to explicitly mentionfeols_jax,feols_jax_bootstrap, and the vmap mechanism.
Internal¶
- New helper
_jax_prep_inputsshares formula-parse + FE-residualise logic betweenfeols_jaxandfeols_jax_bootstrap.feols_jaxitself is unchanged in this release; consolidation into a shared call site is a candidate follow-up. - Rust crate adds
src/cluster.rs(kernel) and acluster_meatPyO3 binding insrc/lib.rs. 3 cargo unit tests cover small-DGP reference parity, k=1 closed form, and empty-input safety.
Verified¶
- 10 PyTorch device-resolver tests
(
tests/test_torch_device_resolver.py); 51 existing neural tests pass without numerical drift on default CPU. - 9
cluster_meatRust parity tests (tests/test_cluster_meat_rust.py) — auto-skip whenstatspai_hdfeis not built. - 13
sp.iv(absorb=)parity tests vs explicit drop-first dummies (tests/test_iv_absorb.py); coefficients agree toatol=1e-9, iid SE tortol=1e-3, cluster SE tortol=1e-2. - 12
feols_jaxparity tests vsfeols(tests/test_jax_feols.py); iid / hc1 / cr1 / weighted / float32 / 6 error-path validations. - 24
feols_jax_bootstraptests (tests/test_jax_feols_bootstrap.py); convergence to HC1 SE for pairs / wild and CR1 SE for cluster / wild_cluster at B=2000 (rtol 10–15%); algebraic identity check that the wild score formulation reproduces the literal "refit on pseudo-y" bootstrap bit-for-bit on a no-FE DGP (atol=1e-9).
[1.13.1] — 2026-05-05¶
Headline¶
Stability tiers, external-validity dossier, and cold-start surgery in a
single release. Every FunctionSpec now carries a stability field
plus per-function limitations, surfaced through
sp.describe_function, sp.help, sp.list_functions(stability=...),
the statspai list CLI, and the LLM-facing sp.function_schema
description; sp.recommend / sp.causal / sp.paper default to
dropping experimental / deprecated entries unless
allow_experimental=True is passed — closing a path where an agent
could silently land on a frontier MVP. Eight high-impact estimators
(aipw, aggte, pretrends_test, sensitivity_rr, mccrary_test,
oster_bounds, wild_cluster_bootstrap, rd_honest) are upgraded
from auto-registered stubs to hand-written specs with full assumption
/ failure-mode / alternative metadata. A weak-instrument preflight
gate in sp.preflight(data, "ivreg", formula=...) raises a structured
warning row when the first-stage F falls below the Staiger–Stock
(1997) or Stock–Yogo (2005) thresholds, and sp.recommend(...
design='iv') adaptively reorders LIML / AR ahead of 2SLS on weak
first stages. A 36-module R parity harness, 21-module Stata parity
harness, 4-dataset original-paper replay (Card 1995, Callaway–Sant'Anna
mpdta, Abadie Basque, LaLonde NSW + PSID-1), Track-C performance
harness (HDFE / CS-DiD / SCM / DML log-log scaling), B=1000
Monte-Carlo coverage run, and a 900-trial CausalAgentBench prompt
suite all ship under tests/r_parity/, tests/stata_parity/,
tests/orig_parity/, tests/perf/, and tests/agent_bench/ with
paired R/Python/Stata drivers, JSON results, and 3-way Markdown +
LaTeX parity tables suitable for direct paper inclusion. A new
sp.validation_report() / sp.coverage_matrix() /
sp.reproduce_jss_tables() meta-API summarises the live registry,
materialises the parity / coverage / agent-bench artifacts as JSON,
and optionally re-runs the harnesses end-to-end so referees can
verify StatsPAI's external-validity claims without leaving Python.
Cold-start surgery in three steps brings sklearn submodules pulled by
import statspai from 245 to 0 (statspai.forest lazy-loaded —
Step 1B; 18 estimator files import sklearn lazily inside function
bodies — Step 1C; HAL TMLE classes drop sklearn class inheritance —
Step 1D), pinned by a new
test_sklearn_budget_ceiling_on_bare_import_statspai contract. The
workflow / paper orchestration layer replaces silent except: pass
paths with WorkflowDegradedWarning + structured degradations
records on the result object, so optional-stage failures surface in
PaperDraft.to_dict() and the rendered Pipeline notes section
instead of disappearing. sp.principal_strat(instrument=...) ships a
proper Angrist-Imbens-Rubin Wald-LATE estimator (the kwarg was
previously stubbed); sp.hal_tmle(variant='projection') keeps its
NotImplementedError but now points at a written-out RFC
(docs/rfc/hal_tmle_projection.md) instead of raising in silence.
Lazy-loading of optional families via __getattr__ keeps import
statspai fast without breaking same-name function/subpackage
collisions (bartik, deepiv, proximal, …) — pinned by a
late-bind / post-import-shadow contract test and a committed
__init__.pyi stub generator so IDE / mypy see lazy-loaded names. A
latent Callaway–Sant'Anna REG inference scaling bug — discovered
because the parity harness flagged it — is fixed in
did/callaway_santanna.py.
Added¶
- Weak-instrument preflight gate in
sp.preflight(data, "ivreg", formula=...). The newfirst_stage_strengthcheck parses the Wilkinson IV formula, runs the first-stage OLS, and emits awarningrow when the partial F-statistic falls below either the Staiger–Stock (1997) rule of thumb (F < 10, "very weak") or the Stock–Yogo (2005) 10% maximum-size critical value of 16.38 for one endog / one instrument (F < 16.38, "weak"). The warning payload includes structuredrecovery_hintspointing atmethod='liml',inference='ar', andsp.anderson_rubin_ci(...)so an LLM agent can branch on the typed envelope without parsing prose. Closes the §5.3 robustness DGP follow-up: Track B'stest_iv_weak_instrument_undercoveragedocuments that 2SLS+HC1 under-covers at the 0.88 level on a pi=0.10 first stage; the preflight now flags this before the user pays for the 2SLS fit. - Adaptive IV ranking in
sp.recommend(... design='iv'). When the live first-stage F is below 10 the recommendation list is reordered: LIML moves to the top, an Anderson–Rubin row is inserted, and the 2SLS row is annotated withvery_weak_iv=Trueplus a rationale that explains the HC1-coverage failure mode. When F is in [10, 16.38) the order stays 2SLS → LIML but the rationales reference the Stock–Yogo threshold. The 2SLS row now also carriesfirst_stage_Fas a numeric field so downstream tooling can consume it without regex. -
Preflight / recommend tests.
tests/test_preflight.pyaddsTestIVFirstStageStrength(6 cases covering strong / weak / borderline / non-IV / missing-columns / JSON-safe payloads).tests/test_smart_workflow.py::TestRecommendadds two cases pinning the 2SLS-first ordering on strong instruments and the LIML-first / AR-included ordering on weak instruments. -
R parity harness — 36 paired R/Python modules. New
tests/r_parity/ships 36 paired scripts (one R, one Python) that replay the same DGPs throughfixest/did/csdid/gsynth/MatchIt/DoubleML/rdrobust/Synth/lme4/plm/frontier/MR-PRESSO/lavaan/mediation/WeightIt/cobalt/mlogit/nlme/ordinal/ … and StatsPAI's matching estimator. Each module emits<id>_R.jsonand<id>_py.json;compare.pyproduces a 3-way parity table (parity_table.md/.tex/parity_table_3way.md/.tex) tightened with a small ID-column + longtable layout for direct paper inclusion. Two parallel tracks — "orig" (canonical-dataset replays) and "perf" (timing under matched DGPs) — share the same compare-tooling. -
Stata parity harness — 21-module StatsPAI ↔ Stata 3-way compare. New
tests/stata_parity/ships 21 paired.do/.pyscripts coveringreghdfe,xtreg,csdid,did_imputation,synth,synth_runner,ivreg2,xtivreg,rdrobust,psmatch2,teffects,xtfrontier,mixed/melogit/mepoisson,bayes,gmm,boottest, plus the Stata→Python translator round-trip. Drivers write Stata results to JSON via thestata-mcpstata_dotool; Python drivers run StatsPAI throughimport statspai as sp;compare_stata.pyjoins on(module, estimator, statistic)and emitsparity_table_stata.md/parity_table_stata.texplus the 3-way StatsPAI ↔ R ↔ Stata table. -
Canonical-dataset original-paper replays. New
tests/orig_parity/adds 4 module pairs that replay each paper's headline number bit-equal to the published value: Card (1995) returns-to-schooling onwooldridge::card; Callaway–Sant'Anna (2021) staggered DiD ondid::mpdta(the package's vendored minimum-wage panel); Abadie–Diamond–Hainmueller Basque onSynth::basque; LaLonde (1986) NSW onMatchIt::lalondeplus a 4b sub-module oncausalsens::lalonde.psid(true Dehejia–Wahba NSW + PSID-1, 2675 obs) wheresp.regress+sp.psmrecover the published −15,205 to relative tolerance 1.5e-05. Drivers + bundled data + JSON results +parity_table_orig.mdare all committed. -
Track-C performance harness — log-log timing. New
tests/perf/adds matched R/Python timing runs for HDFE (fixest::feolsvssp.feols), CS-DiD (did::att_gtvssp.callaway_santanna), SCM (Synth::synthvssp.synth), and DML (DoubleML::DoubleMLPLRvssp.dml) at log-spaced N. Drivers pluscompare_perf.pyproduceperf_table.md/.texand atrack_c_loglog.{pdf,png}log-log scaling figure. -
Coverage Monte Carlo at B=1000. New
tests/coverage_monte_carlo/run_b1000.pymeasures 95% CI coverage for OLS (0.952), 2×2 DiD (0.955), and strong-Z IV (0.962) — all inside the 99% Wilson band [0.935, 0.967] around nominal 0.95. The full slowpytest tests/coverage_monte_carlo/ -m slowsweep at B=1000 also passes 8/8 (753.51 s). Frozen run lives atresults_b1000/coverage_b1000.json; previous fast-track results remain at the default B=200. -
CausalAgentBench scaffolding (mock-mode shipped, API run gated). New
tests/agent_bench/ships a 50-prompt × L1/L2/L3 difficulty × 6 cells × 3 reps = 900-trial agent bench with a deterministic mock-LLM runner (runners/mock_llm.py), a frozen OSF pre-registration protocol (prompts/_protocol.md), and a grader (runners/grader.py) emitting an H1–H5 directional results table. Mock dry-run completes in <1 s and producesresults/headline.md+results/scores.csv+results/trials.jsonl; the production--apiflag is one switch away once the OSF pre-registration and API budget clear. -
sp.validation_report/sp.coverage_matrix/sp.reproduce_jss_tables— JSS-grade validation meta-API. Newsrc/statspai/validation.py(863 LOC, 58-test battery intests/test_jss_validation_api.py) exposes three top-level functions for the paper-submission audit trail:sp.validation_report()summarises the livesp.registryplus the materialised parity / coverage / agent-bench artifacts as a structuredValidationReport(registry.total_functions,evidence.r_parity_modules,evidence.stata_parity_modules,evidence.coverage_b1000,evidence.agent_bench_trials, …) with a one-paragraph.summary()and full JSON.to_dict();sp.coverage_matrix()enumerates every reference-implementation parity claim with its expected tolerance, observed gap, and the driver script that produced the JSON;sp.reproduce_jss_tables()returns aReproductionResultenumerating the exactRscript,python,pytest, andxelatexcommands needed to regenerate every table — and, when called withdry_run=False, executes them in dependency order with timing + return codes recorded per step. The default mode is metadata-only (no R / Stata / LaTeX required), soimport statspai as sp; print(sp.validation_report().summary())works on a stockpip install statspai==1.13.1install. -
8 high-impact estimators upgraded from auto-registered to hand-written FunctionSpec.
aipw,aggte,pretrends_test,sensitivity_rr,mccrary_test,oster_bounds,wild_cluster_bootstrap, andrd_honestnow ship with full agent-native metadata: 2–4 assumptions per spec, 2 failure modes with recovery hints, ranked alternatives, typical_n_min, vetted references with paper.bib bib keys, and full enum-validated ParamSpecs. Previously these were auto-registered with only the first docstring line and inferred parameter types — agents callingsp.describe_function('aipw')could not see the doubly-robust guarantee, the propensity overlap requirement, or the alternatives to fall back on. Hand-written count moves from 203 to 211; auto- registered drops from 768 to 760. (Step H of v1.13 stability roadmap.) -
sp.principal_strat(instrument=...)— encouragement-design AIR / Wald LATE. The previously-stubbedinstrument=parameter now routes to a proper estimator (Angrist-Imbens-Rubin 1996 §4): given binary instrumentZ, treatmentD, post-treatment stratumS, and outcomeY, under randomZ+ monotonicity D(1)>=D(0) + exclusion + SUTVA, the function reports two Wald LATEs among Z-compliers —τ_Yfor the effect ofDon the outcome andτ_Sfor the effect ofDon the post-treatment stratum variable — plus the complier shareπ_C(Z), all with bootstrap SE/CI. ARuntimeWarningis emitted when the first stage degenerates or points in the wrong direction for the supplied instrument coding.method=is ignored on this path because identification comes fromZ, not from the post-treatment stratum decomposition. Thelimitationsentry is rewritten: the only remaining gap on this path is always-survivor SACE under encouragement design (Mealli & Pacini 2013, partial identification). Seven new tests intests/test_principal_strat.py. -
sp.hal_tmle(variant='projection')RFC + sharper error. Rather than ship an unverified port of the Li-Qiu-Wang-vdL (2025) §3.2 Riesz-projection step (the v1.11.x code path was a no-op on the point estimate — see CHANGELOG), v1.13 keeps theNotImplementedErrorand addsdocs/rfc/hal_tmle_projection.mdwith the full implementation roadmap and the parity-test gates that must clear before the variant can be promoted tostable. The runtime exception message now points at the RFC and asks reporters to file an issue with the publication's headline number they'd like to match — so the next maintainer to pick this up has a clear target. Registrylimitationsentry updated with the RFC link. -
Smart layer respects
FunctionSpec.stability.sp.recommend(...),sp.causal(...), andsp.paper(...)now accept anallow_experimental: bool = Falseflag (default agent-safe). WhenFalse, recommendations whose backing function is registered asstability='experimental'(or'deprecated') are dropped from the ranked output and the workflow'swarnings/pipeline_notesrecords what was filtered. PassTrueto include frontier MVPs (e.g.did_multiplegt_dyn,text_treatment_effect). This closes a gap where an LLM agent askingsp.causal(df, ...)for an applied analysis could silently land on a frontier MVP just because the recommender ranked it first. Tests intests/test_smart_stability_gating.py. - Stability reverse-audit script.
scripts/stability_audit.pycross-checks everystability='stable'claim in the registry against parity-test coverage intests/reference_parity/andtests/external_parity/. Splits the catalogue into hand-written vs. auto-registered specs (the latter having been silently classifiedstableby default) and reports the count of unbacked claims in each bucket.--checkmode is CI-friendly and fails when the unbacked-handwritten count exceeds a loose floor (currently 220) — bumping the floor requires editing the script as a deliberate quality signal. Does NOT auto-downgrade; the call to flip a function fromstabletoexperimentalbelongs to a maintainer who has read the code. Tests intests/test_stability_audit.py. The audit fixed a registry bug along the way: auto-registered specs were never tagged_auto=Trueon theFunctionSpecinstance, sodescribe_functionerror hints and the audit itself couldn't distinguish them from hand-written entries; that's now fixed viaobject.__setattr__inside_auto_spec_from_callable. - Runtime consistency tests for
FunctionSpec.limitations. Eachlimitationsentry on aFunctionSpecis now structurally audited bytests/test_limitations_consistency.pyso the registry's parity-grade-with-known-gaps claims cannot drift away from runtime behaviour: every entry must (a) use vetted vocabulary and (b) be classified as either runtime-testable (a curated map calls the function with the unimplemented value and asserts the documented exception) or descriptively-soft (silent fallback / caveat, whitelisted inLIMITATIONS_DESCRIPTIVE_ONLY). Adding a new limitation without classifying it now fails CI. Caught one drift bug in this pass: thecgroup='nevertreated' + panel=Falselimitation was attached towooldridge_did, but only theetwfealias exposes those parameters — moved toetwfeand surfaced the missingcgroupParamSpec to the schema. - Test-coverage battery for the four worst-covered files +
parity-grade smoke battery across
did/synth/rd/iv/tmle/bayes. The v1.12.x audit flagged six causal-family modules at low statement coverage (did14.7%,synth12.9%,rd16.9%,iv18.0%,tmle14.8%,bayes14.1%) with four files entirely unexercised:wooldridge_did.py,did_imputation.py,synth/report.py,workflow/paper.py. Five new test files raise per-file coverage to synth/report.py 4% → 81%, wooldridge_did.py 76% → 93%, did_imputation.py 85% → 99%, workflow/paper.py 66% → 86% and add a 30-test cross-family smoke battery (tests/test_low_cov_battery.py) that exercises every headline estimator's CI/SE/point-estimate contract: tests/test_synth_report.py(25 tests) — full text/markdown/LaTeX SCM report renderer + every sensitivity sub-block + the LaTeX escape table.tests/test_wooldridge_did_branches.py(31 tests) — Bacon + dCDH decomposition, repeated-CS / never-only / xvar dispatch branches, everyetwfevalidation guard, all fouretwfe_emfxaggregations includinginclude_leads=True.tests/test_did_imputation_branches.py(14 tests) — everyValueErrorguard, the controls + horizon event-study path with pre-trend chi-squared test, and the_cluster_se_horizonN_k == 0short-circuit.tests/test_paper_branches.py(31 tests) — every YAML/TeX/MD helper, all fourto_qmdrendering branches (single vs. multi-format, author / bibliography / csl),to_docxfallback whenpython-docxis missing,write()extension dispatch, and the_render_dag_sectiontext + mermaid branches.-
CausalResult.summary()accepts both event-study column conventions. The sharedsummary()previously hard-coded(relative_time, att)and crashed withKeyError: 'relative_time'onwooldridge_did/etwferesults, which carry the(rel_time, estimate)schema instead. The renderer now auto-detects whichever pair is present and silently skips the event-study block when neither is — every existing caller keeps its formatting and the wooldridge family no longer crashes a user's.summary()call. Regression-pinned bytest_wooldridge_did_summary_renders_event_study. -
Stability tiers and per-function
limitations(parity-grade vs. frontier-grade visibility). EveryFunctionSpecnow carries astabilityfield ("stable"/"experimental"/"deprecated", exposed assp.STABILITY_TIERS) and alimitationslist that enumerates partial-implementation gaps inside otherwise stable functions (e.g.hal_tmle(variant='projection'),principal_strat(instrument=...),rdrobust(weights=...)). The fields flow throughsp.describe_function,sp.agent_card,sp.function_schema(description prefix +Known limitations:suffix so LLM tool-callers see the gap before calling),sp.list_functions(stability=...),sp.agent_cards(stability=...), theSTABILITYblock insp.help(), the per-function detail insp.help('<name>'), and a newstatspai list --stability ...CLI flag. This closes a layering gap where users (and agents) could not tell which functions are numerically aligned and signature-locked vs. which are MVP / RFC-tracked frontier work, and where specific unimplemented variants were only discoverable by triggeringNotImplementedErrormid-pipeline. Initial tagging covers the three causal_text /did_multiplegt_dynexperimental entries plus variant-level limitations onhal_tmle,principal_strat,rdrobust,callaway_santanna,wooldridge_did,network_exposure, andcontinuous_did. See the newdocs/guides/stability.mdfor the contract and promotion path.
Changed¶
-
Cold-start: lazy-load
statspai.forest(Step 1B).import statspaipreviously chainedfrom .forest.causal_forest import CausalForest, causal_forestplus three sibling eager imports forforest_inference/multi_arm_forest/iv_forestat module load, transitively pulling ~245sklearn.*submodules intosys.modules(~270 ms cumulative on cold cache) for every session — even ones that never touch heterogeneous-effect forests. The four eager lines are removed; the ten public leaves (CausalForest,causal_forest,calibration_test,test_calibration,rate,honest_variance,multi_arm_forest,MultiArmForestResult,iv_forest,IVForestResult) now resolve via_LAZY_ATTRSkeyed to dotted submodule paths (e.g.forest.causal_forest) and fault in on firstsp.<name>access.forestdoes not collide with a top-level function (nosp.forestcallable export) so the standard lazy path is safe;sp.causal's callable shim and thestatspai.causaldeprecation shim continue to work unchanged. Pinned by three new contracts intests/test_late_bind_contracts.py—import statspaimust not pre-load anystatspai.forest.*submodule (subprocess-isolated to avoidsys.modulespollution that would corrupt downstreamisinstancechecks); each of the 10 forest leaves must resolve to a callable on first access; and a downstreamfrom statspai.forest.causal_forest import CausalForestmust not re-shadowsp.causal_forestto the leaf module via Python's post-import attribute binding. Other sklearn-eager paths (did/overlap_did,metalearners/*,policy_learning/*,synth/cluster, plus ~7 conflict-prone same-name modules pinned eager for the late-bind contract) still pullsklearnon bare import; those are tracked separately for Step 1C and do not block this lazy-forest win. -
Cold-start: drop sklearn class inheritance from HAL estimators (Step 1D).
HALRegressor/HALClassifierintmle/hal_tmle.pypreviously subclassedsklearn.base.BaseEstimatorplus a Mixin, which pulled ~39sklearn.*submodules intosys.modulesat module-load time — the only remaining sklearn footprint after Steps 1B/1C lazy-loadedforestand the 18 estimator files. The inheritance is gratuitous here:super_learner.fitonly needssklearn.base.clone(learner)(which is duck-typed —get_params(deep=False)+cls(**params)reconstruction) plus.fit/.predict/.predict_proba; no code path calls.score(...),is_classifier(...), oris_regressor(...)on the HAL classes. Replaced the inheritance with a minimal_BaseHALproviding theget_params/set_params/__repr__slice thatclone()actually consumes (introspection viainspect.signature(self.__init__), with object identity preserved so sklearn's post-cloneparam1 is param2sanity check passes)._estimator_type = "regressor"/"classifier"class attributes keepsklearn.base.is_regressor/is_classifierreturning True for any future external caller. After Step 1D,import statspaipulls zero sklearn submodules — full 245 → 0 — and thetest_sklearn_budget_ceiling_on_bare_import_statspaicontract is tightened from<= 50to<= 0to pin the floor. 152 tests acrosstest_hal_tmle/test_tmle/test_late_bind_contracts/test_low_cov_battery/test_metalearnerspass cleanly. -
Cold-start: lazy-import sklearn across 18 estimator files (Step 1C). Building on Step 1B, every remaining top-level
from sklearn.X import Yindid/overlap_did.py,metalearners/{auto_cate,metalearners,auto_cate_tuned}.py,policy_learning/{policy_tree,ope}.py,synth/cluster.py,proximal/pci_regression.py,bcf/{bcf,longitudinal}.py,tmle/{tmle,super_learner,ltmle,ltmle_survival}.py,dose_response/gps.py,multi_treatment/multi_ipw.py,mediation/four_way.py, andinterference/orthogonal.pywas moved inside the function bodies that actually use it.BaseEstimatortype annotations were converted to string-literal form underif TYPE_CHECKING:soinspect.signature/ Pyright / mypy still resolve them without forcingsklearn.baseat module load. Several long-standing dead imports were dropped (BaseEstimator/is_classifier/cross_val_predictinmetalearners/metalearners.py;LinearRegressioninproximal/pci_regression.py;BaseEstimator/clone/GradientBoostingClassifierinmulti_treatment/multi_ipw.py; etc.). After Step 1B + 1C,import statspaipulls 39 sklearn submodules instead of 245 — a 5.3× reduction. The 39 aresklearn.baseplus its mandatory deps, pulled bytmle/hal_tmle.pywhoseHALRegressor(BaseEstimator, RegressorMixin)/HALClassifier(BaseEstimator, ClassifierMixin)need sklearn at class-definition time; refactoring that inheritance hierarchy is out of scope. Pinned by a newtest_sklearn_budget_ceiling_on_bare_import_statspaicontract intests/test_late_bind_contracts.py(≤ 50 ceiling, ~39 floor + 11 slack for sklearn-version drift) running in a subprocess so the cold-state measurement does not perturb other tests'sys.modules. 248 tests across the 18 affected modules (metalearners / metalearner_frontiers / auto_cate / auto_cate_tuned / overlap_did / tmle / hal_tmle / proximal / proximal_frontiers / bcf_longitudinal / bcf_ordinal / conformal_bcf_bunching_mc / policy_learning / mediation / mediation_sensitivity / interference_extensions / late_bind_contracts / causal_forest_grf / forest_inference / ope_cevae / ope_extensions / cluster_rct) pass cleanly. -
README lead aligned with the agent-native + parity-validated positioning.
README.mdandREADME_CN.mdboth foreground "first agent-native Python platform" and surface R / Stata parity validation in the lead paragraph (was previously buried in the Task View comparison section further down). -
sp.recommend()now defaults to an agent-safe stability gate: recommendations whose registry entry is markedstability='experimental'orstability='deprecated'are dropped unless the caller passesallow_experimental=True. The filter keeps backward compatibility for unknown custom recommendation entries, records dropped names inRecommendationResult.warnings, and is forwarded throughsp.causal(..., allow_experimental=...)andsp.paper(..., allow_experimental=...)so higher-level workflows cannot silently land on frontier MVP estimators. - Hardened the workflow/paper orchestration layer so optional failures
no longer disappear silently.
sp.causal(...).run(full=True)now records optional-stage failures (compare_estimators,sensitivity_panel,cate) inworkflow.pipeline_notes, andsp.causal(...).report(fmt='markdown')renders those notes in a dedicated section instead of silently dropping the context. sp.paper(...)now constructs its internalCausalWorkflowwithauto_run=Falseand advances stages exactly once. This removes the prior double-execution path where the workflow could fully auto-run beforepaper()manually re-randiagnose/recommend/estimate(and sometimesrobustness) again.PaperDraftnow surfaces orchestration degradations directly in aPipeline notessection and includesdegradationsinto_dict(), so missing DAG/citation/provenance/section-rendering steps are visible in the artifact itself rather than only via warnings.
Fixed¶
-
⚠️ Correctness —
sp.callaway_santanna(method='reg')inference. The Callaway–Sant'Anna outcome-regression (REG) path produced influence-function standard errors that were inconsistent with the IPW / DR variants because the control-regression uncertainty was not propagated and the per-cohort scaling in the influence-function aggregation was off by the cohort-size weighting. Coverage simulations under thempdtaDGP were running ~88% (nominal 95%) onmethod='reg', while'ipw'and'dr'were inside the 99% Wilson band. The fix tightens the REG influence-function scaling and explicitly adds the control-regression contribution; the parity table attests/r_parity/results/parity_table_3way.mdand the coverage frame attests/coverage_monte_carlo/FINDINGS.mdare refreshed accordingly. Regression-pinned bytests/reference_parity/test_did_parity.pyand the newtests/r_parity/04_csdid.pydriver. Re-run any v1.10–v1.13 Callaway–Sant'Anna analyses that usedmethod='reg';'ipw'and'dr'are unchanged. -
isinstance(res, sp.OPEResult)no longer false-negative on results fromsp.ope.*. During the lazy-load refactor of optional families the eager re-export path that used to bindsp.OPEResulttostatspai.ope.estimators.OPEResultwas dropped, sosp.OPEResultsilently resolved to a parallel class defined instatspai.policy_learning.ope— andisinstance(sp.ope.ips(...), sp.OPEResult)flipped fromTrue(v1.12.2) toFalse. The eagerfrom .policy_learning import ... OPEResultis removed sosp.OPEResultfalls through to the lazy_register_lazy("ope", "OPEResult", ...)table, restoring v1.12.2 class identity. Regression-pinned bytests/test_ope_cevae.py::test_ips_close_to_true_value. - Hand-written registry specs for
aggteandprincipal_stratnow exactly match their callable signatures (na_rm,alpha,seed), with a regression test guarding the new v1.13 hand-written upgrades against future signature drift. - The natural-language
sp.paper(data, question, ..., include_robustness=False)path no longer runs or renders the robustness section implicitly viasp.causalauto-run side effects. paper_from_question()now carries its collected degradation records into the returnedPaperDraft, so late provenance/citation/DAG failures remain inspectable after draft construction.- Top-level
statspai.__all__is now de-duplicated in order-preserving fashion, reducing public-surface drift between the import namespace and registry/help tooling. - The top-level function-first API now survives the
sp.ivbootstrap path for same-name families likebartikanddeepiv. The root package eagerly rebinds the 14 function/subpackage collisions (proximal,principal_strat,bartik,bridge,causal_impact,bcf,bunching,deepiv,dose_response,frontier,interference,msm,multi_treatment,tmle) whilestatspai.ivlazy-loads its optionalbartik/deepivre-exports, sosp.bartik(...)/sp.deepiv(...)stay callable instead of degenerating into bare module objects after import order changes. smart.assumptions,smart.brief,smart.identification,smart.sensitivity, andsmart.verifynow lazy-importworkflow._degradationonly inside failure paths. That removes a prematureworkflow/__init__import duringimport statspai, which had reintroduced partially initialized top-level symbols and made the lazy API order-sensitive.- Added a committed
src/statspai/__init__.pyigenerator and pinned it with a regression test so IDE/type-checker visibility tracks the live runtime namespace. The stub generator now skips exported constants during leaf scanning and correctly typesSTABILITY_TIERSasfrozenset[str], avoiding duplicate/conflicting declarations. - Pinned the two binding hazards introduced by the lazy-load refactor
with 21 explicit contracts in
tests/test_late_bind_contracts.py: the five late-bind aliases re-bound by_article_aliases(mediation,policy_tree,dml,matrix_completion,causal_discovery) plus thesp.ivcallable dispatcher must each remain callable rather than degenerating to a module on import re-order; and the 14 function/subpackage collisions (proximal,principal_strat,bridge,bcf,bunching,dose_response,multi_treatment,causal_impact,frontier,interference,tmle,msm,deepiv,bartik) must survive a downstreamfrom statspai.X import Ywithout the auto-bound submodule silently re-shadowing the function. Closes the residual gap left by Codex's lazy-load refactor and Claude Code's same-name eager-rebind follow-up.
[1.12.2] — 2026-05-01¶
Headline¶
ML-routing for the estimand-first DSL (sp.causal_question) plus a
shared robustness battery so sp.paper(...) renders the same audit
section regardless of entry point. The Egami et al. (2023) LLM-label
corrector graduates from binary-only to multi-class with a
bias-corrected bootstrap, and DML's IV variants (sp.dml(model='pliv'),
sp.dml(model='iivm')) now honour sample_weight end-to-end.
Citation metadata fixes the wrong Zenodo DOI shipped under v1.12.1 —
no estimator output changes.
Added¶
sp.llm_annotator_correct(causal_text/llm_annotator.py) — three v1.7-deferred upgrades to the Egami et al. (2023) measurement-error correction for LLM-derived treatment labels. Backward compatible: the binary-T numerical path is unchanged, every existing kwarg keeps its default behaviour, and existing diagnostics retain their keys.- Multi-class treatment. The corrector now auto-detects the class
set from the union of LLM and human labels. For K ≥ 3 the
confusion matrix
M[i, j] = P(T_obs=j | T_true=i), validation- marginalπ[i], and Bayes posteriorQ[i, j] = P(T_true=i | T_obs=j)are assembled; the K×K coefficient transformθ_obs = T θ_trueis inverted to recover per-class corrected contrasts. Headline.estimatereports the smallest non-reference class; full vector ships in.detail(per-class naive/corrected estimate, SE, CI, p-value). Singular / near-singularTraisesIdentificationFailure. - Bias-corrected bootstrap. Optional
bootstrap=Truejointly resamples the full sample (validation rows + unlabeled rows) and re-runs the entire correction pipelinen_bootstraptimes (default 500), reporting Efron-Tibshirani bias-corrected percentile CIs that reflect validation-set sampling uncertainty. New kwargs:bootstrap,n_bootstrap,bootstrap_seed. First-order SE/CI remain available inmodel_info['first_order_se' / '_ci'];bootstrapsub-dict reportsn_valid,n_failed,seed,method,mean,median. - SE inflation factor diagnostic. Both binary and multi-class
paths populate
model_info['se_inflation_factor']— a delta- method multiplier (≥ 1) the user can apply to the first-order SE for an honest accounting of validation-set noise. For binary it is derived analytically from the binomial variances ofp_01andp_10; for multi-class it is a finite-difference Jacobian-based heuristic (usebootstrap=Truefor the rigorous version). - Multi-class diagnostics also expose
confusion_matrix,q_posterior,transform_matrix,condition_number,pi_validation,headline_contrast. sp.causal_question(..., design=...)now accepts the four ML-selection-on-observables tags directly:design='dml' | 'tmle' | 'metalearner' | 'causal_forest'. The planner records the right identification story / assumptions for each, and the dispatcher now routes to the corresponding estimator with targeted validation (e.g. DML covariates required; PLIV / IIVM scalar-instrument guard; causal-forest binary-treatment guard for ATE inference).- New guide:
docs/guides/choosing_ml_causal_estimator.md— decision tree for choosing between DML / TMLE / metalearner / causal_forest, plus a side-by-side comparison of estimands, IV support, and inference. - Shared robustness battery:
workflow/_robustness.py+run_robustness_battery(...). Bothsp.paper(data, question, ...)andsp.paper(CausalQuestion(...))now render the same design-aware robustness section instead of splitting between a thin NL path and a placeholder estimand-first path. - Weighted
sample_weightsupport insp.dml(model='pliv')andsp.dml(model='iivm'). The IV orthogonality moment, residualisation step, and downstream sandwich SE are all weighted consistently (E[w · ψ(W; θ, η)] = 0); unit weights reproduce the unweighted path bit-for-bit. Closes the lastsample_weightgap indml/after the v1.12.0 PLR / interactive audits —sp.dml's four core estimators now all support survey / inverse-probability weights.
Changed¶
sp.causal(...).robustness()now delegates to the shared robustness battery and still preserves backwards compatibility via the legacy flatrobustness_findingsdict; structured per-finding records are additionally available under['_findings'].paper.bib/ docs metadata filled in missing bibliographic details for TMLE / causal forest / meta-learner references and removed a duplicate van der Laan entry so the new ML-estimator guide and the expandedcausal_questiondocstrings resolve cleanly.docs/guides/causal_text_family.mdand the registry card forsp.llm_annotator_correctnow describe the new multi-class, bootstrap, and SE-inflation-factor behaviour rather than the old binary-only path.
Fixed¶
sp.paper(CausalQuestion(...))no longer emits a placeholder Robustness section pointing users back tosp.causal(...); it now runs the same substantive battery as the natural-language paper path.sp.causal_question(..., estimand='CATE')now auto-promotes tometalearneronly when effect modifiers are actually declared. Without covariates it falls back honestly to a scalar ATE path with an explicit warning, soidentify()andestimate()agree.design='causal_forest'now reports the population ATE summary via cross-fit AIPW influence-function inference instead of leaving the planner with a CATE-only story and no principled scalar ATE layer.
[1.12.1] — 2026-04-30¶
Citation metadata polish — no numerical or API changes to any estimator.
Added¶
sp.citation(format=...)— package-level citation helper returning BibTeX (default), APA, plain text, or the rawCITATION.cffcontents. Distinct fromsp.cite(), which formats individual coefficients inline.sp.__citation__exposes the default BibTeX entry as astrfor one-liners.CITATION.cffat the repository root — GitHub renders a "Cite this repository" button from it; bundled in the sdist viaMANIFEST.in.- Zenodo DOI 10.5281/zenodo.19933900
(concept DOI; always resolves to the latest archived release). The
DOI now appears in
sp.citation()output, the README citation block, and a DOI badge alongside the existing review-status badge. .zenodo.jsonso future GitHub Releases mint version-specific DOIs with consistent metadata (creators, keywords, license, related identifiers).
[1.12.0] — 2026-04-30¶
Headline¶
The whole dml/ module got a careful audit. sp.dml / sp.dml_panel
/ sp.dml_model_averaging all stay backwards-compatible at the
call-site level (existing scripts keep working) but several internal
numerical behaviours change — see the ⚠️ Correctness section and
MIGRATION.md.
⚠️ Correctness¶
sp.dml(model='irm')andsp.dml(model='iivm')now useStratifiedKFold(stratified by D and Z respectively) — the oldKFoldcould produce a fold whose subgroup mask was empty, in which case the AIPW score for that fold's test rows was silently filled with zeros (biased point estimate, biased SE). Empty subgroups now raiseIdentificationFailurewith a clear remedy. Estimates may shift slightly on data sets where the oldKFoldhappened to produce extreme folds.sp.dml_panel(binary_treatment=True)is now a deprecated no-op. The previous classifier path fit a propensity on within-demeaned features but raw {0,1} labels — there is no clean interpretation as E[D̃ | X̃] for the result. The estimator now always uses a regressor on D̃ (PLR-with-FE is agnostic to D's type). ADeprecationWarningis emitted, andD ∈ {0,1}is validated when the flag is True.sp.dml_model_averagingnow drops rows with NaN in y / treat / covariates / sample_weight (matching every other DML class); previously NaNs propagated into sklearn fits and could produce NaN estimates undetected by the existingdenom < 1e-12guard.sp.dml_model_averaging: the defaultweight_ruleis now"short_stacking"— Ahrens, Hansen, Schaffer & Wiemann (2025, JAE) eq. 7 — which solves a constrained least squares stacking problem on cross-fitted nuisance predictions and plugs the stacked nuisance into a single PLR moment equation. The previous"inverse_risk"default (heuristic 1/MSE-weighted average of per-candidate θ̂_k) was not in the cited paper and is preserved as a clearly labelled baseline. New"single_best"matches the paper's footnote 8 formulation. Per-nuisance stacking weights are exposed asmodel_info["weights_g"]/weights_m.sp.dml(model='pliv')raisesRuntimeErrorwhen the ML-residualised partial correlation|corr(z̃, d̃)|falls below1e-3(was1e-6, too lenient to catch genuine weak-IV collapse). A newmodel_info["diagnostics"]block reports the partial correlation and an approximate first-stage F.
Added¶
- All four
sp.dml(model=…)variants now accept arandom_state=argument (default 42) controlling fold assignment. Repeated splits userandom_state + repso a single seed fully determines the result. sample_weight=support onsp.dml(model='plr'),sp.dml(model='irm'),sp.dml_panel, andsp.dml_model_averaging(any weight rule). The weighted estimator uses a Z-estimator sandwich variance throughout.sp.dml(model='pliv')andsp.dml(model='iivm')raiseNotImplementedErrorif a non-trivial weight is supplied — the weighted Wald-ratio variance derivation is non-trivial and lands in a follow-up.sample_weightmay be passed as a 1-D array, a pandas Series, or a column name string.- New
model_info["diagnostics"]block on every variant: - PLR: residual scales, partial correlation y_resid·d_resid, within-R² of each nuisance.
- IRM: propensity p01/p99/min/max, n clipped below/above the
[0.01, 0.99]overlap clip, n times the subgroup g̃₁/g̃₀ fit fell back to the subgroup mean. - IIVM: instrument-propensity p01/p99/min/max, clipping counts, subgroup fallbacks for both g(z, X) and r(z, X), and E[ψ_b] (the LATE Wald-ratio denominator — proximity to zero indicates a weak first stage).
- PLIV: first-stage partial correlation, approximate first-stage F, residual scales.
- panel_dml: y/d residual std, within-R², cluster Ω, weighted flag.
sp.dml_panel(sample_weight=…)does a weighted within transform (subtract weighted unit / time means) and reports a weighted Liang-Zeger cluster SE.
Changed¶
- Internal flag rename
_BINARY_TREATMENT→_ML_M_TARGET_BINARYand_BINARY_INSTRUMENT→_ML_R_TARGET_BINARYon the per-model DML classes. The new names describe the nuisance-target shape (the IIVMml_mactually models the instrument propensity, not D). These flags are private (underscore-prefixed); no public API change. paper.bib: filled in the missingvolume/number/pagesfields on@ahrens2025model(40(3):249–269), verified via the Wiley Online Library record and the JAE issue listing.
Internal¶
- Per-rep diagnostics now flow back to
model_info["diagnostics"]via a new_aggregate_diagnosticshelper on_DoubleMLBase. Each subclass populatesself._last_rep_diagnosticsinside_fit_one_rep; the base merges across reps (sum for counts, mean for floats, OR for booleans, concat for lists).
⚠️ Correctness — TMLE module audit pass¶
sp.tmle.SuperLearnerpreviously ran NNLS and post-hoc-normalised weights to sum to 1, which is not the simplex-constrained optimum (rescaling an unconstrained NNLS solution gives the simplex optimum only when the unconstrained sum already equals 1, a measure-zero event). Replaced with a direct SLSQP QP on the simplex; ensemble predictions are now genuinely the convex combination minimising squared loss. Affects every downstream caller —sp.tmle,sp.hal_tmle, and any user code that builds a Super Learner directly. Numerical results will shift slightly on data sets where the old NNLS solution did not happen to be on the simplex.sp.tmle.ltmlecensoring half-implementation: the regime-following indicator now includes& (C_k_obs == 1)so censored units are excluded from the targeting equation rather than continuing to contribute with1/p_c-inflated weights. (sp.tmle.ltmle_survivalwas already correct on this;ltmle.pywas the regression.)sp.tmle.ltmle_survivalinfluence function: previously used-H * (T_k - h_star_regime)summed across intervals as the influence function for both the RMST contrast and the terminal risk difference at K. The proper EIF for :math:E[S^a(t)](Cai & van der Laan 2020) needs the survival-product factor :math:S^a(t)/S^a(j)and the IC for the terminal RD at K is the EIF of :math:S^a(K)alone (NOT the cumulative-across-K RMST IC). Refactored_run_regimeto expose the per-subject sequencesS_seq,h_star_seq,H_seq,T_seq; the call site now computes the RMST and terminal-RD EIFs separately via_eif_rmstand_eif_survival_at_k. SE estimates change — generally smaller for RMST (was conservative), and the terminal-RD SE is now correctly tied to its target functional rather than picking up RMST's cross-time aggregation.sp.hal_tmle(variant='projection')was a no-op in v1.11.x and earlier. The projection variant ran an ad-hoc shrinkage onmodel_info["eps"]after the point estimate had already been computed; the variant flag did not change the estimate. The path now raises :class:NotImplementedErrorhonestly until the proper Riesz-projection step (Li-Qiu-Wang-vdL 2025 §3.2) is ported.sp.hal_tmledocstring previously claimed the basis was "rich enough to approximate any càdlàg function of bounded variation", the property of full HAL (Benkeser & van der Laan 2016). The implementation only builds main-effects indicator basis functions :math:\\mathbb 1\\{x_j \\le a_j\\}— i.e. L1-penalised additive piecewise-constant regression, NOT full HAL. Docstring is corrected; numerical behaviour unchanged.
Fixed — TMLE convergence + overlap diagnostics¶
sp.tmle._fit_epsilonnow emits aUserWarningwhen the Newton iteration on the fluctuation parameter fails to converge inmax_itersteps, instead of silently returning the last value (which yields a non-targeted plug-in). The warning includes the final score magnitude and ε for diagnosis.sp.tmlenow reportsmodel_info['propensity_diagnostics'](min, max, p01, p99, n clipped below/above, clip share) and emits aUserWarningwhen ≥ 5 % of propensities hit thepropensity_boundsclip — same overlap convention assp.metalearner. AIPW scores blow up at e≈0/1, so heavy clipping silently changes the estimand from ATE in the population to ATE on the trimmed sample.sp.tmle.SuperLearner(task='classification')validates that the target is binary (was silently dropping non-{0,1} columns ofpredict_proba); switches toStratifiedKFoldso every fold has both classes;predict()clips to (1e-6, 1-1e-6) for classification (was inconsistent withpredict_probawhich already clipped).
Fixed — TMLE / HAL-TMLE citations (§10 verification pass)¶
paper.bibnow records three previously-uncatalogued HAL-TMLE references with full Crossref/arXiv-verified metadata (added 2026-04-30):@li2025regularized— arXiv:2506.17214, verified via arxiv.org. Earlier inline-cited title inhal_tmle.pywas"Highly Adaptive Lasso Implementations"; the paper's actual title is"Highly Adaptive Lasso Implied Working Models"— fixed in docstring +model_info['citation'].@vanderlaan2023efficient— IJB 19(1):261–289, doi 10.1515/ijb-2019-0092, verified via degruyterbrill.com.@benkeser2016highly— IEEE DSAA 2016, pp. 689–696, doi 10.1109/DSAA.2016.93, verified via Crossref API.tmle.py:_CITATIONS['tmle']now includes thevanderlaan2006targetedreference that the docstring already cites (was missing — docstring promised it via[@vanderlaan2006targeted]but the inline BibTeX registered onlyvanderlaan2007super). Author punctuation / capitalisation aligned topaper.bib.ltmle_survival.pycai2020stepreference reformatted to match paper.bib (year 2020 vs the previous docstring's 2019; the IJB volume's nominal year is 2020).- Dropped the dangling "Qian-van der Laan Section 4" reference from
hal_tmle.pyprojection-variant docstring (the paper was never in References section and the cited Section 4 doesn't exist in any HAL-TMLE paper).
⚠️ Correctness — sp.metalearner unifies ATE / SE via AIPW influence function¶
- ATE for all learners (
learner ∈ {'s','t','x','r','dr'}) is now the mean of the AIPW (DR) pseudo-outcome :math:\varphi_i = \hat\mu_1(X_i) - \hat\mu_0(X_i) + D_i(Y_i-\hat\mu_1(X_i))/\hat e(X_i) - (1-D_i)(Y_i-\hat\mu_0(X_i))/(1-\hat e(X_i)), and SE is :math:\sigma(\varphi)/\sqrt n. AIPW is the semiparametric-efficient estimating function for :math:E[Y(1)-Y(0)](van der Laan & Robins 2003; Kennedy 2023), so the SE is valid for any CATE estimator the user picks vialearner=. - Previously S/T/X/R-Learner used
mean(τ̂(X))for ATE and a re-sampling bootstrap of the fitted CATE values for SE. That bootstrap silently treated τ̂ as fixed and only captured empirical- mean variation — completely missing the dominant component (estimation error in τ̂ itself). Result: SEs were systematically too small and CIs severely under-covered. - DR-Learner: ATE was previously
mean(τ̂(X))from the regularised CATE fit, while SE usedstd(φ)/√nfrom the raw pseudo-outcome — a finite-sample inconsistency that disappears under the newmean(φ)ATE. - New
model_info['se_method'] = 'aipw_influence_function'(was'bootstrap'for S/T/X/R,'influence_function'for DR).model_info['ate_method'] = 'aipw_dr_pseudo_outcome'.n_bootstrapparameter is deprecated and ignored; will be removed in a future minor release. - New
model_info['aipw_diagnostics']block reports clipped-propensity counts and share.UserWarningfires when ≥ 5 % of propensities hit the (0.01, 0.99) overlap clip — overlap is poor and the AIPW score may be biased toward the trimmed sample.
Fixed — Künzel et al. 2019 author hallucination (§10 red line)¶
metalearners.pypreviously listedSeetharam, Liang, Atheyas co-authors of Künzel et al. 2019 PNAS — those are invented names. Correct authors per the canonical record inpaper.bib: Künzel, Sekhon, Bickel, Yu (PNAS 116(10), 4156–4165, doi 10.1073/pnas.1804597116). Both the docstring and the inline BibTeX (used byresult.cite()) now matchpaper.bibbyte-for-byte. Verification path:paper.bib:99← Crossref / doi.org / Google Scholar all confirmKünzel, Sekhon, Bickel, Yu.Kennedy, Edward H(no period) was also out of sync with@kennedy2023towardsinpaper.bib(Edward H.); fixed.
Refactor — PLR variance code: psi → psi_inner / psi_score¶
dml/plr.pypreviously named the inner residual(Y − ĝ − θ̂(D − m̂))aspsi, even though the Neyman-orthogonal score is the product withd_resid. The misnomer made the variance linenp.mean((d_resid * psi)**2)look wrong on a cursory read. Renamed topsi_inner(the residual) andpsi_score(the actual scorepsi_inner * d_resid); math is unchanged. PLIV/IIVM already used the consistentpsi-as-score convention.
[1.11.4] — 2026-04-30¶
Fixed — sp.dml accepts string learner aliases¶
sp.dml(..., ml_g='rf', ml_m='rf')previously crashed withTypeError: Cannot clone object 'rf' (type str): it does not seem to be a scikit-learn estimator …once cross-fitting reachedsklearn.base.clone. The error surfaced in all four DML variants (PLR / IRM / PLIV / IIVM), not just PLR.- New
dml/_learners.pyresolves user-supplied strings into appropriately configured scikit-learn estimators:'rf'/'gbm'/'lasso'/'ridge'/'linear'/'ols'/'logistic'/'xgb'/'lgbm'(case-insensitive, with common synonyms). Classifier variants are selected automatically for the propensity (ml_mundermodel='irm') and instrument (ml_rundermodel='iivm') roles. - Estimator objects (anything exposing
.fit+.get_params) pass through unchanged. Unknown aliases / wrong types now raise an immediate, descriptiveValueError/TypeErrorat construction time rather than the cryptic clone error mid-cross-fit. - Optional dependencies (
xgboost,lightgbm) are imported lazily — not installed → cleanImportErrorwith install hint.
[1.11.3] — 2026-04-30¶
Fixed — output layer graceful degradation restored¶
to_excel/to_word: revert optional-dependency handling fromraise ImportErrorback towarnings.warn() + return, restoring graceful degradation whenopenpyxl/python-docxare absent. (Regression introduced in v1.11.2.)
[1.11.2] — 2026-04-29¶
Internal refactor only — collapses esttab, modelsummary, and
outreg2 to thin facades over the shared regtable engine. No API
changes, no estimator numerics changed.
Changed — output layer facades collapsed into regtable¶
outreg2→ thinregtablefacade; all formatting delegated toregtable.py(sharedFormatOptions/ star formatter / numeric formatter). Oldoutreg2.pyretained as import shim for backward compatibility.modelsummary→ thinregtablefacade; the summary layout logic is nowregtable.FormatOptionsdriven. Oldmodelsummary.pykept as import shim.esttab/EstimateTable→ thinregtablefacade; identical dispatch path asoutreg2andmodelsummary.- The
regtablesnapshot baselines added inc608528ensure any future drift is caught by the test suite.
Tests¶
test_regtable.py(new): 12 snapshot cases covering everyfmtvariant, star placement, confidence-interval style, andreorder/drop/keeppath.
[1.11.1] — 2026-04-29¶
Polish patch for the v1.11 agent surface. Closes the four "留意 / 没做" items from the v1.11 release notes: mcp_server.py 1,475-line bloat, from_stata Tier 3, deeper from_r, and the MCP sampling abstraction layer. No estimator numerics changed.
Added — from_stata Tier 3 (long-tail, ~95% coverage)¶
ppmlhdfe→sp.ppmlhdfe(Correia-Guimarães-Zylkin Poisson-PML with HDFE, multi-FE absorb).mlogit/oprobit→sp.glm(family='multinomial' / 'ordered_probit')with a translation note pointing strict diagnostics atresult.raw_model.xtabond/xtdpdsys→sp.xtabond/sp.xtdpdsys(Arellano- Bond difference / Blundell-Bond system GMM).bunching→sp.bunching(Saez 2010, Kleven-Waseem 2013).boottest→sp.wild_cluster_bootstrap(Roodman-Webb wild-cluster bootstrap; takes a fitted result).mi estimate: <inner>→ translation hint pointing atsp.mi_estimate(Stata's nested grammar isn't auto-parsed).- 33 alias entries / 29 distinct handlers total.
Added — from_r deepening (5 → 11 callables)¶
glmwith smart routing:family=binomial→sp.logit,family=binomial(link="probit")→sp.probit,family=poisson→sp.poisson, otherwisesp.glm.lmer→sp.multilevel;glmer→sp.glmer.plm(formula, data=df, model='within', index=c('id','t'))→sp.panel(method='within', id='id', time='t').matchit(treat ~ x, data=df, method='nearest')→sp.matchwith method-name aliasing (nearest→nn, genetic→genmatch).- R Synth synth() now emits a structured field-mapping note (predictors → predictors, dependent → outcome, unit.variable → unit, time.variable → time, treatment.identifier → treated_unit, time.predictors.prior[max] + 1 → treatment_time).
Added — MCP sampling/createMessage abstraction (opt-in)¶
- New
agent/_sampling.py: request_sampling(messages, max_tokens, ...)— server-to-client LLM request; blocks until response or timeout.set_capability(bool)/get_capability()— client capability advertisement flag.set_writer(callable)/route_response(message)— stdio writer registration + reply matcher.- Wire-up:
_handle_initializereadsparams.capabilities.sampling.handle_requestroutes JSON-RPC replies to pending sampling requests viaroute_response.serve_stdioregisters / clears the writer + capability flag.- Fail-closed:
UnsupportedSamplingErrorraised when no capability is advertised OR no writer is registered, so existing LLM helpers (llm_dag_propose/llm_evalue/llm_sensitivity) keep working via their user-API-key paths until clients (Claude Desktop, Cursor, …) advertisesampling. STATSPAI_MCP_SAMPLING_TIMEOUT_SECONDS(default60) caps every request;SamplingTimeoutErroron overage.
Changed — mcp_server.py split into leaf modules¶
- v1.11.0:
mcp_server.pywas 1,475 lines. - v1.11.1: split into 5 leaf modules:
_errors.py(35 LOC) —RpcError/InvalidParamsError/ResourceNotFoundErrortyped taxonomy._prompts.py(344 LOC) — 10 prompt templates plusSafeDict,handle_prompts_list,handle_prompts_get._resources.py(313 LOC) — catalog text / function detail / handle reads / templates list. Handlers acceptjson_defaultplus error classes via dependency injection (no circular import)._data_loader.py(176 LOC) —load_dataframe/ size cap / LRU cache / remote-URL routing._sampling.py(227 LOC) — see above.mcp_server.pyshrunk to 817 lines (well under the CLAUDE.md §4 ~800-line guideline).- All v1.x private names re-exported via thin import shims so
external code reaching for
_PROMPTS/_load_dataframe/_RpcErroretc. still works. - Test fixture compatibility preserved:
monkeypatch.setattr( agent.tools, '_resolve_fn', …)continues to take effect because the dispatch path looks up_resolve_fnvia the parent package namespace at call time (carried over from the v1.11.0 split).
Tests¶
test_mcp_sampling.py(10 cases, new):- Fail-closed when capability or writer unset.
- Round-trip via mock client thread (success + error envelope).
- Timeout via env-var override.
- serve_stdio integration (capability + writer lifecycle).
- Unsolicited / malformed reply handling.
test_translation.pyextended:- 8 new Stata Tier-3 round-trips + 3 edge cases.
- 10 new R round-trips.
- 61 → 82 cases; coverage assertion checks every distinct handler.
424/424 pass across all agent + MCP + translation + runner + sampling suites.
[1.11.0] — 2026-04-29¶
Agent-native infrastructure follow-up to v1.10. Closes the four follow-up items the v1.10 release notes flagged: tools.py subpackage split, Stata/R command translators, concurrent runner with progress notifications, and tool-call timeouts. No estimator numerics changed; this is the agent-orchestration layer.
Added — from_stata / from_r translators¶
- New
agent/_translation/subpackage exposesfrom_stata(line) → {ok, tool, arguments, python_code, notes, source, input}andfrom_r(line)with the same shape. - 21 distinct Stata handlers / 25 alias entries covering ~85% of real econ workflows:
- Tier 1 (~60% coverage):
regress/reg,xtreg,reghdfe,ivreg2/ivregress,csdid,did_imputation,synth,rdrobust. - Tier 2 (push to ~85%):
probit/logit/poisson/nbreg(shared GLM scaffold),tobit,heckman,rdplot,rddensity,teffects(ipw / nnmatch / psmatch / ra / aipw),margins/marginsplot,contrast,test,xtset/tsset(no-op note). - 5 R handlers:
feols,felm,lm,att_gt/did. fixest'sy ~ x | id^year | (d ~ z) | clusterpipe-form decomposition preserved. - Returns close-match
suggestionsfor unrecognised commands — never silently guesses. - Surfaces
notesfor partial mappings (e.g. Stataifclause →df.query(...)instructions). - Tests:
tests/test_translation.py— 61 cases, every distinct Stata handler covered by ≥1 round-trip.
Added — concurrent runner + progress notifications + timeouts¶
- New
agent/_runner.py: run_with_progress(work, progress_token, timeout, drain)— zero-argwork()runs in a worker thread; main loop drains a thread-safe queue.progress(value, total, message)— tool-side helper. No-op when no channel is registered (safe for in-process tests / directexecute_toolcallers).tool_timeout()— readsSTATSPAI_MCP_TOOL_TIMEOUT_SECONDS(default600;0disables). Hard wall-clock cap;TimeoutErrorsurfaces as-32000with the env-var name embedded._handle_tools_callruns every dispatch through the runner; readsparams._meta.progressTokenper MCP 2024-11-05._make_progress_drainwritesnotifications/progressJSON-RPC messages to the active stdio sink mid-call.serve_stdioregisters / unregisters the stdout sink so in-process tests don't accidentally write to a closed handle.- Threading rather than asyncio for cross-platform reliability —
decision documented in
_runner.py.
Changed — tools.py split into agent/tools/ subpackage¶
- Pre-1.11:
agent/tools.pywas 1,024 lines. - v1.11 layout:
agent/tools/
├── __init__.py # public API + legacy private re-exports
├── _helpers.py # _scalar_or_none / _default_serializer / _identification_serializer
├── _dispatch.py # tool_manifest / execute_tool / _resolve_fn
└── _specs/ # TOOL_REGISTRY split by family
├── _regression.py / _did.py / _iv.py / _rd.py
└── _matching.py / _diag.py / _orchestrate.py
- Public API unchanged (
from statspai.agent.tools import tool_manifest, execute_tool, TOOL_REGISTRY). - Legacy private imports preserved (
from .tools import _default_serializercontinues to resolve). - Test-fixture hook preserved:
monkeypatch.setattr(agent.tools, '_resolve_fn', …)works becauseexecute_toollooks up_resolve_fnvia the parent package namespace at call time. causal/recommendbespoke serializers promoted from inline lambdas to module-level functions (readable in stack traces).
MCP wire-up¶
from_stata/from_rregistered as workflow tools inWORKFLOW_TOOL_NAMES, dataless override list, and surface intools/list.- 393/393 pass across
test_mcp_protocol.py/test_mcp_error_envelope.py/test_mcp_result_handle.py/test_mcp_enrichment.py/test_mcp_image_content.py/test_mcp_pipelines.py/test_mcp_prompts_expanded.py/test_mcp_runner.py(new) /test_translation.py(new) plus the existing agent + registry + help + exceptions suites.
Known follow-ups (not in 1.11)¶
mcp_server.pyis now 1,475 lines — the 10 prompt-template dictionaries account for most of the bloat. Splitting them into_prompts.pyis a half-day mechanical follow-up.- MCP
sampling/createMessageserver-initiated LLM requests (forllm_dag_proposeetc. to reuse the client's auth) deferred pending Claude Desktop / Cursor capability advertisement.
[1.10.0] — 2026-04-29¶
Agent-native / MCP layer overhaul. Closes the chained-workflow gap that
the v1.9 stateless tools couldn't span and turns the sp.agent /
statspai.agent.mcp_server surface into a proper experimentation
workbench. No estimator numerics changed; this is purely the
discovery / orchestration / output layer.
Added¶
- Result handles (
as_handle=True) — everyexecute_tool/tools/callinvocation can now return aresult_id/result_uri(statspai://result/<id>) pointing to the fitted object. Backed by an in-process LRU cache (agent/_result_cache.py, default 32 entries, env overrideSTATSPAI_MCP_RESULT_CACHE_SIZE). Resource read returns the agent-detailto_dictpayload + a provenance block (originating tool + arguments + class name). - Handle-based workflow tools —
audit_result,brief_result,sensitivity_from_result,honest_did_from_resultaccept aresult_idinstead of forcing the LLM to ferry betas/sigma arrays back across turns.honest_did_from_resultauto-extractsbetas/sigma/num_pre_periods/num_post_periodsfrom the cached result (CallawaySantanna, EventStudy, BJS, SA shapes supported via best-effort attribute walk). - First-class workflow primitives —
audit,preflight,detect_design,briefare now hand-curated MCP tools (previously surfaced only via the auto-generated manifest with one-line descriptions). Schemas describe expected columns explicitly. bibtextool — pulls verified BibTeX entries frompaper.bib(single source of truth per CLAUDE.md §10). Unknown keys return empty bodies + close-match suggestions, never fabricated entries. Closes the citation-hallucination loophole at the source.- Composite pipelines —
pipeline_did/pipeline_iv/pipeline_rdrun preflight + estimator + audit + sensitivity + brief in one call, return a markdown narrative + cachedresult_id+ per-stage status.pipeline_rdattaches anrdplotPNG as MCP image content. - Image content blocks —
_handle_tools_callpromotes any_plot_pngbytes returned by a tool to a second{type: "image", mimeType: "image/png"}content block (Claude vision and any MCP image-capable client renders it inline). Newplot_from_resulttool renders the canonical diagnostic plot for a cached result (event-study / rdplot / synth-gap / love-plot / cate-plot / coef-plot — auto-detected by class name). - Output enrichment — every tool return now carries:
next_calls— pre-builttools/callpayloads withresult_idand forwarded base args; agents copy-paste verbatim.citations— verified bib keys (static map; empty list ⇒ intentionally absent, never invent) + BibTeX bodies pulled frompaper.bib.narrative— short markdown digest (method + estimate + CI + N + violations).- Expanded prompt templates —
prompts/listjumps from 3 to 10:audit_did_result(rewired topipeline_did),audit_iv_result,audit_rd_result,design_then_estimate,robustness_followup,paper_render,compare_methods,policy_evaluation,synth_full,decompose_inequality. - Schema injections — every MCP tool now exposes:
data_path(URL-aware:s3://,gs://,https://, plus.dta/.feather/.arrow/.jsonlin addition to the legacy.csv/.parquet/.xlsx/.json).data_columns— column projection for parquet / feather / stata fast partial reads.data_sample_n— deterministic uniform random subsample (seed=0) for fast iteration on large panels.result_id— handle reference for chained calls.as_handle— opt-in result caching.initializereturns a session-levelinstructionsblock describing the recommended workflow (detect_design → preflight → fitas_handle=true→audit_result→*_from_result→bibtex).statspai://result/{id}URI template advertised viaresources/templates/list.
Changed¶
_DATALESS_TOOLSis now registry-derived. A new_dataless_tool_names()helper walks the registry and marks any spec without a requireddataparameter as dataless; the hand-curated_DATALESS_OVERRIDESset covers stub-backed tools the registry can't reach (workflow / handle / bibtex / plot tools). The legacy_DATALESS_TOOLSconstant stays as a backward-compat alias.auto_tool_manifest(max_tools=...)default bumped 250 → 500 and emits aRuntimeWarningwhen more eligible tools exist than the cap admits — silent truncation was hiding registry growth.tool_manifest()no longer silently swallows auto-merge failures. ARuntimeWarningfires before the curated-only fallback so operators / CI log scrapers can detect registry introspection regressions._load_dataframeis LRU-cached by(path, mtime, columns)so repeatedtools/callinvocations on the same file are O(1) after the first load. New 2 GiB default file-size cap (env overrideSTATSPAI_MCP_MAX_DATA_BYTES; set to0to disable).- Bad/missing
data_pathnow surfaces as JSON-RPC-32602(invalid params) rather than the generic-32000. Clients branching on error codes get a cleaner signal. - Traceback exposure on
-32000errors gated bySTATSPAI_MCP_DEBUG=1. Production deployments no longer leak internal paths / class names through the JSON-RPC error envelope by default. _json_defaultcovers every type we've actually seen leak through the agent / MCP wire:np.bool_,np.complexfloating,np.datetime64,np.timedelta64, NaN / Inf →null,pd.Index/Timedelta/Categorical/Interval,set/frozenset,bytes(b64-wrapped),Decimal,pathlib.PurePath,Enum, dataclasses.oaxaca-style estimators with their owndetailparameter shadowed via MCP. The schema'sdetailenum is server-side control (forwarded toresult.to_dict(detail=...)); collisions are resolved by force-overwriting the registry's version. Affected estimators remain reachable via the direct Python API.
Tests¶
- New:
test_mcp_result_handle.py(31 cases) — result-cache LRU, resource read, handle-based workflows,_json_defaulttypes,STATSPAI_MCP_DEBUGgating. - New:
test_mcp_enrichment.py(14 cases) —next_calls/citations/narrativeshape;bibtexround-trip. - New:
test_mcp_image_content.py(4 cases) — PNG promotion to MCP image content block. - New:
test_mcp_pipelines.py(7 cases) — pipeline_did / iv / rd. - New:
test_mcp_prompts_expanded.py(5 cases) — full 10-prompt template surface. - Existing
test_mcp_protocol.pyupdated to use the registry-derived_dataless_tool_names()helper rather than the static override set.
321/321 pass across all tests/test_*agent*.py + test_mcp_*.py +
test_registry.py + test_help.py + test_exceptions.py.
New modules¶
src/statspai/agent/_result_cache.py— bounded LRU cache + entry metadata.src/statspai/agent/auto_dispatch.py— registry-driven dispatch for non-curated tools (filters kwargs againstParamSpec).src/statspai/agent/workflow_tools.py— handle-based + workflow primitive tools (audit_result / brief_result / *_from_result / audit / preflight / detect_design / brief / plot_from_result / bibtex).src/statspai/agent/pipeline_tools.py— pipeline_did / pipeline_iv / pipeline_rd composites.src/statspai/agent/_enrichment.py—next_calls+citations+narrativebuilder.
[1.11.2] — 2026-04-29 (addendum)¶
These entries were recorded under an
[Unreleased]heading that was never retitled when later releases were prepended above it. They shipped in v1.11.2, the first tagged release containing their commit (5388ff90). Retitled 2026-09-06; content unchanged.
Changed — output module PR-B (continuation of v1.11.x cleanup)¶
esttab/EstimateTableResultare now thin facades overregtable.output/estimates.pypreviously housed a ~500-lineEstimateTableclass that re-implemented the full renderer pipeline (text / LaTeX / HTML / Markdown / CSV / DataFrame). PR-B/5c collapses it; theesttab()function now translates Stata-flavoured kwargs and forwards tosp.regtable, andEstimateTableResultbecomes a thin pass-through wrapper around the resultingRegtableResultthat preserves the legacy type identity.- Net code:
output/estimates.py987 → 526 lines (-47%). Helpers used byregression_table/mean_comparison/_inline(_ModelData,_extract_model_data,_ci_bounds,_format_starsre-exports,_latex_escape/_html_escape,_STAT_ALIASES/_STAT_DISPLAY,eststo/estclearglobal store) are kept verbatim. EstimateTableResult.to_csv()is implemented viato_dataframe().to_csv()(regtable does not natively expose CSV; the dataframe path is byte-identical to what the legacy esttab produced).- The four exclusive-output flags
se/t/p/cimap to regtable'sse_type=with priorityci > p > t > se(matches legacy behaviour). -
First call emits
DeprecationWarningpointing tosp.regtable. -
modelsummaryis now a thin facade overregtable. The R-stylemodelsummary()previously shipped a ~700-line renderer pipeline (_build_coef_rows/_to_text/_to_latex/_to_html/_to_excel/_to_word) that re-implemented coefficient extraction, star formatting, three-line table styling, and every export format — duplicating code already maintained bysp.regtable. - Net code:
output/modelsummary.py845 → 378 lines (-55%; remainder is module docstring +coefplotkept verbatim +_extract_coefsforcoefplot). - Rendered output now matches
regtableexactly. The dict form ofstars=is reinterpreted (only threshold values used; symbol overrides dropped — useregtable(notation='symbols')for†/‡/§).se_type='brackets'is no longer a separate render mode (emitsUserWarningand falls back to parens; useshow_ci=Truefor[lo, hi]).se_type='none'likewise keeps the SE row. - First call emits
DeprecationWarningpointing tosp.regtable. -
coefplotis unchanged (independent of the table renderer). -
outreg2is now a thin facade overregtable. The Stata-styleOutReg2class andoutreg2()function previously shipped a bespoke 800-line renderer that re-implemented coefficient extraction, star formatting, three-line table styling and Excel/Word/LaTeX export. Collapsed to ~150 lines that translate Stata-flavoured kwargs and forward tosp.regtable— single point of fix for rendering bugs going forward. - Net code:
outreg2.py804 → 341 lines (-58%). - Rendered output now matches
regtableexactly. Visible label changes:Variablescolumn header → blank (book-tab),R-squared/Adj. R-squared/Observations/F-statistic / Trees→R²/Adj. R²/N/F. LaTeX gains a proper star legend. Bug fixes: spurious& None & NoneLaTeX cell removed; the nonsensical/ Treeslabel that appeared on OLS results is gone. show_se=Falseis no longer supported (regression tables without uncertainty are pseudo-science) — emitsUserWarningand keeps the SE row.- First call emits
DeprecationWarningpointing tosp.regtable(...).to_excel(...). Plan to remove the facade in two minor releases. - See
MIGRATION.mdfor the side-by-side rewrite.
Added — output module PR-B foundation (B-1)¶
tests/test_regtable_snapshots.pysnapshot harness. Locks down the byte-stable rendered output ofsp.regtablefor five representative fixtures (simple OLS / multi-model / custom stats / notes+labels / GLM-logit) across four text formats (text / HTML / LaTeX / Markdown) — 20 snapshots total. Whitespace-normalised so diffs survive editor newline handling but catch real renderer drift. Excel / Word are not snapshotted (binary archives are brittle); coverage there is viatest_paper_tables_export.py. Update withSTATSPAI_UPDATE_SNAPSHOTS=1 pytest tests/test_regtable_snapshots.py.
Added — agent / dispatcher work (other sessions)¶
sp.panel()method=expanded with friendly aliases + HDFE.sp.panelalready supported amethod=table of 10 classical-
dynamic estimators (
fe/re/be/fd/pooled/twoway/mundlak/chamberlain/ab/system). The table is now case-insensitive and accepts intuitive aliases that match what users already write (instead of forcing the two-letter Stata shorthand): -
fe←fixed/fixed_effects/within re←random/random_effectsbe←between/between_effectsfd←first_difference/first_diffpooled←pooled_ols/pols/olstwoway←two_way/two_way_fe/2wayab←arellano_bond/gmm/diff_gmmsystem←blundell_bond/bb/system_gmm
Plus a new method='hdfe' (a.k.a. feols / reghdfe /
absorbed_ols) route that delegates to feols.hdfe_ols for
high-dimensional fixed-effects absorption. When the formula has
no | separator, the dispatcher bolts the entity and
time columns on automatically, so
is equivalent to
This closes the Stata reghdfe / R fixest::feols slot in
the sp.panel namespace without forcing users to switch APIs.
sp.panel_logit / sp.panel_probit / sp.interactive_fe /
sp.panel_unitroot are intentionally NOT in the method=
table — they have a different (data, y, x, id, time)-style
signature and remain accessible as standalone functions.
Regression-guarded by tests/test_panel_dispatcher.py (37 new
tests); 31 existing panel-family tests pass.
-
sp.match()method=expanded to cover the full matching toolkit.sp.matchwas already a function with built-inmethod=for classical algorithms (nearest / stratify / cem / psm / mahalanobis); the table now reaches every matching/weighting estimator instatspai.matchingfrom a single entry point: -
Classical:
nearest(default),stratify/subclass/subclassification,cem/coarsened_exact,psm,mahalanobis. - Weighting:
ebalance/entropy/entropy_balancing(Hainmueller 2012),cbps(Imai-Ratkovic 2014),sbw/stable_balancing(Zubizarreta 2015),overlap/ow/overlap_weights(LMZ 2018). - Genetic:
genmatch/genetic(Diamond-Sekhon 2013). - Optimization-based:
optimal/optimal_match(Rosenbaum 1989),cardinality/cardinality_match(Zubizarreta 2014).
The dispatcher translates treat ↔ treatment and y
↔ outcome for the few estimators that internally use the
alternate names (optimal_match, cardinality_match).
Standalone access (sp.ebalance, sp.cbps, sp.genmatch,
sp.sbw, sp.optimal_match, sp.cardinality_match,
sp.overlap_weights) is unchanged.
The dispatcher refuses to silently swallow nonsense: passing a
classical-matching kwarg (caliper= / replace= /
n_matches= / bias_correction= / ps_poly= /
n_strata= / n_bins=) with method='ebalance' etc.
raises TypeError: does not accept these classical-matching
kwargs.
Regression-guarded by tests/test_match_dispatcher.py (31
new tests); 76 existing matching-family tests still pass.
-
sp.rd()is now callable with a unifiedmethod=table. Same PEP 562 callable-module pattern used forsp.ivin this release: thestatspai.rdsubpackage itself dispatches calls, whilesp.rd.rdrobust/sp.rd.rdplot/sp.rd.rdsummaryand all 35+ existing names continue to resolve. The defaultsp.rd(data, y, x, c)call equalssp.rd.rdrobust(data, y, x, c)(CCT 2014 local polynomial). 18 canonicalmethod=aliases route to: -
Local polynomial:
rdrobust/default/rd/robust/local_poly(CCT 2014). - Honest CIs:
honest/armstrong_kolesar/ak. - Local randomisation:
randinf/random/local_randomization. - Heterogeneous effects:
hte/cate. - ML+RD:
forest/causal_forest,boost/gbm,lasso. - Bayesian HTE:
bayes_hte/bayes. - 2D / boundary RD:
rd2d/2d/boundary. - Multi-cutoff:
rdmc/multi_cutoff. - Multi-score / geographic:
rdms/geographic/multi_score. - Kink (RKD):
rkd/kink. - RD-in-time:
rdit/time. - Extrapolation:
extrapolate,multi_extrapolate. - Spillover/interference:
interference/spillover. - Distributional:
distribution,distributional_design. - External validity:
external_validity.
The dispatcher normalises x ↔ running and c ↔
cutoff for methods that use the alternate names internally
(rd_bayes_hte, rd_interference, rd_distribution,
rd_distributional_design).
Diagnostics-only functions (rdbwselect, rdbwsensitivity,
rdbalance, rdplacebo, rdsummary, rdplotdensity,
rdpower, rdsampsi, rdwinselect, rdsensitivity,
rdrbounds) are intentionally NOT in the method= table —
they are not estimators of treatment effects.
Regression-guarded by tests/test_rd_dispatcher.py (22 new
tests); 78 existing rd-family tests still pass.
Performance¶
import statspaicold-start: ~2,070 ms → ~1,680 ms (-19%).output/outreg2.pynow importsopenpyxllazily inside_export_with_formattinginstead of at module load. Top-levelimport openpyxlwas transitively pullingPIL/Pillowviaopenpyxl.drawing.imageon every session even when the user never touchedoutreg2(4 references in repo vs 163 forregtable). After the fix, no heavy modules (openpyxl/docx/PIL/matplotlib) are eagerly loaded at top level. Also drops unused symbol imports (Border/Side/PatternFill/dataframe_to_rows/write_title).
Changed¶
-
Output module: shared formatter helpers. New
output/_format.pyhouses the canonicalformat_stars/fmt_val/fmt_int/fmt_auto/is_missingimplementations.estimates._format_stars/_fmt_val/_fmt_int/_fmt_autoare now thin re-exports under their legacy underscore names; existingregression_table/_inlineimports are unchanged.outreg2._format_number/_format_pvalueandmodelsummary._format_numdelegate to the canonical helpers. Net effect: ~80 lines of duplicate formatters removed; bug fixes in one place propagate to every backend. -
Output module:
modelsummary._stars_strdead code removed. The original implementation had twoforloops where the first usedfor/elsethat always overwrotebestto''before the second loop ran — making the first loop unreachable. Cleaned to keep only the working logic. Behavior identical for all valid inputs. -
Output module:
MeanComparisonResultextracted.output/regression_table.pyhad grown to 3,335 lines and held two unrelated result classes. MovedMeanComparisonResultand the publicmean_comparison()API tooutput/mean_comparison.py(510 lines).regression_table.pyis now 2,831 lines (-15%). Re-exported fromregression_tablefor back-compat;from statspai.output.regression_table import MeanComparisonResultstill works.sp.list_functions()count unchanged. -
Output module:
__init__.pyreorganised. Imports and__all__are grouped by purpose (regression-table renderers / single-table helpers / multi-table bundles / provenance / bibliography / adapters), with a docstring documenting thatregtableis the canonical regression-table renderer and thatesttab/modelsummary/outreg2are Stata/R compatibility surfaces (full consolidation tracked indocs/rfc/output_pr_b_consolidation.md). No symbols added or removed;sp.list_functions()unchanged at 973. -
Top-level
__init__.pydeduplication. Removed redundantfrom .regression.glm/logit_probit/countimports that re-bound the same names twice (lines 245-247 vs 495-501). No public name was added or removed;sp.glm,sp.logit,sp.poissonetc. resolve identically to the earlier (canonical) binding. Same number of registered functions (973). Net: −5 LOC, −5 redundant import statements, identical behaviour.
Fixed¶
sp.iv()is now callable. Prior to this release, thestatspai.ivsubpackage shadowed the function exposed at line 45 ofstatspai/__init__.py(because Python attaches an imported subpackage to its parent's namespace, and the subpackage load happened after the function bind). The result was that every advertised callsite — registry examples, agent summaries, MCP server docs, replication examples, and the live call insrc/statspai/question/question.py:505— raisedTypeError: 'module' object is not callable. Fixed by installing a tinyModuleTypesubclass with__call__onstatspai.iv(PEP 562-style) and removingivfrom theregression.ivimport line so the subpackage isn't shadowed in reverse. Regression-guarded bytests/test_iv_dispatcher.py::test_sp_iv_is_callable(33 new tests total).
Changed¶
-
Unified IV dispatcher.
sp.iv(formula, data, method=...)now routes 25+ method aliases (case- and dash-insensitive) to 19 canonical estimators across theregression.iv/regression.advanced_iv/iv//deepiv/bartikmodules: -
K-class formula path:
2sls(a.k.a.tsls,iv),liml,fuller,gmm,jive. - Modern JIVE:
jive1,ujive,ijive,rjive. - Many-weak:
jive_mw,many_weak_ar. - Lasso:
lasso,post_lasso(a.k.a.bch). - ML/nonparametric:
kernel,npiv,ivdml,deepiv. - Bayesian:
bayes. - LATE/MTE:
continuous_late,mte,ivmte_bounds. - Quantile IV:
ivqreg. - Plausibly exogenous sensitivity:
plausibly_exog_uci,plausibly_exog_ltz. - Shift-share:
shift_share(a.k.a.bartik).
The dispatcher normalises common alias names (endog →
treat/treatment for kernel-style methods, exog →
covariates for ivdml, singleton instruments=['z'] →
instrument='z' for singular-instrument methods), and refuses
ambiguous combinations with TypeError: Got both 'endog' and
'treat'. Standalone access (sp.iv.kernel_iv,
sp.iv.bayesian_iv, sp.ivreg,
from statspai.regression.iv import iv) is unchanged.
sp.iv.fit(...) remains as an explicit alias for the dispatcher.
Diagnostics functions (anderson_rubin_test, effective_f_test,
kleibergen_paap_rk, sanderson_windmeijer,
conditional_lr_test) are intentionally not in the
method= table — they are not estimators.
[1.9.1] — MCP schema + JSON-RPC error polish¶
Patch release on top of 1.9.0. No estimator numerical paths changed. Two MCP-server fixes surfaced by strict-schema clients (Claude Desktop / Cursor) plus one docs typo.
Fixed¶
-
MCP
tools/listschema — dataless tools no longer requiredata_path. Tools whose underlying StatsPAI function does not consume a DataFrame (currentlyhonest_didandsensitivity) used to be advertised withdata_pathinrequired. Strict- schema MCP clients refused to dispatch the call without a CSV path the estimator never reads.data_pathis still exposed as an optional property for clients that always send it; only therequiredlist is conditional now. New_DATALESS_TOOLS = {"honest_did", "sensitivity"}is the single source of truth insrc/statspai/agent/mcp_server.py— keep in sync withTOOL_REGISTRYinagent/tools.py. -
MCP
tools/calltyped error — missingnamereturns -32602. Previously atools/callrequest without anamefield raised a genericValueError, which the dispatcher surfaced as-32000(server fallback). 1.9.0 already promised typed JSON-RPC errors for invalid params (-32602); this fixes the one path that escaped the audit. Regression-guarded bytest_tools_call_missing_name_returns_invalid_params.
Docs¶
- MIGRATION.md — fixed a typo in the 1.9.0
CausalResult.to_dictbyte-identity note: the no-kwargs default is identical toto_dict(detail="standard"), notcite(detail="standard").
[1.9.0] — Agent-native API surface: 12 modules across 4 phases¶
The 1.9.0 line ships StatsPAI's first deliberately agent-shaped API
surface — 12 new top-level entry points designed for Claude Code /
Cursor / Copilot CLI workflows where the LLM, not a human, is doing
the calling. No estimator numerical paths changed; all
additions are new functions or strictly additive parameters with
"agent" as the default so existing behaviour is byte-identical.
Added — Agent serialization & error envelope (Phase 1)¶
-
CausalResult.to_dict(detail=...)andEconometricResults.to_dict(detail=...)— unified payload control with three documented levels: -
"minimal"(~150 tokens) — bare answer; no diagnostics. "standard"(~250 tokens) — current default; coefficients + scalar diagnostics +detail_headrows. Byte-identical to legacyto_dict()."agent"(~620 tokens) — addsviolations/warnings/next_steps/suggested_functionsso an LLM can plan its next call without another round-trip.
for_agent() is now a thin alias for to_dict(detail="agent");
to_agent_summary() is unchanged but its docstring now points
at to_dict(detail="agent") as the canonical flat form.
execute_toolMCP error envelope — when an estimator raises a structuredStatsPAIErrorsubclass, the MCPtools/callresponse now surfaceserror_kind(e.g."method_incompatibility") plus the fullerror_payloaddict (code/recovery_hint/diagnostics/alternative_functions). Legacyerror/remediationfields preserved.
Added — MCP server polish (Phase 1)¶
statspai-mcpconsole script wired inpyproject.tomlsopip install statspaiexposes it on PATH.statspai://function/{name}per-function resources surfacing the registry's full agent-card (description, signature, assumptions, failure_modes, alternatives, typical_n_min, example). Listed via the newresources/templates/listhandler.statspai://functionsmachine-readable JSON index for one-shot tool discovery.- Typed JSON-RPC errors mapped to canonical MCP codes:
-32002(resource not found),-32602(invalid params),-32000(server fallback). Replaces the previous blanket-32000. notifications/*silenced — Claude Desktop / Cursor sendnotifications/initializedafter the handshake; the server now drops any method whose name starts withnotifications/per the MCP spec, instead of replying with-32601noise on every session.- MCP-level
detailparameter ontools/call— agents pickdetail="minimal" | "standard" | "agent"per call to control token cost. Validation rejects invalid values with-32602.
Added — Workflow primitives (Phases 2-4)¶
-
sp.audit(result)— missing-evidence checklist (the read-only counterpart tosp.assumption_audit): inspects what robustness / sensitivity diagnostics are stored on a fitted result and surfaces which method-family checks are still missing. Returns{checks: [{name, question, status, severity, importance, suggest_function, ...}], summary, coverage}with 18 curated checks across DID/RD/IV/synth/matching/OLS. -
sp.detect_design(data, **hints)— heuristic design identifier: returns{design, confidence, identified, candidates, n_obs, columns}withdesign ∈ {"panel", "rd", "cross_section"}. Symmetric(unit, time)pair dedup; RD confidence capped at 0.30 without explicit hint to avoid noise-data false positives. -
sp.preflight(data, method, **kwargs)— method-specific pre-estimation diagnostics distinct fromsp.check_identification(design-level) andsp.assumption_audit(re-runs tests). Cheap shape / column / treatment-binarity / sample-size checks per method family; returns{verdict: "PASS" | "WARN" | "FAIL", checks, summary, known_method}. -
CausalResult.cite(format=...)andsp.bib_for(result)— multi-format citations:"bibtex"(default, byte-identical to legacycite()),"apa"(parsed prose),"json"(structured{type, key, authors, year, title, journal, volume, number, pages, publisher, fields}). LaTeX-diacritic normalisation ({\\"o}→ö); multi-entry BibTeX strings (e.g.twfe_decompositioncites both Goodman-Bacon 2021 AND de Chaisemartin & D'Haultfœuille 2020) round-trip both authors — zero hallucination per CLAUDE.md §10. -
sp.examples(name)— runnable code snippets for any registered function; 10 hand-curated flagship snippets, falls back toregistry.examplefor the rest. -
sp.session(seed=42)— deterministic-RNG context manager snapshotting Pythonrandomand NumPy's legacy global MT19937 generator; restores prior state on exit even when an exception is raised inside the block. Lazy torch / jax interop — never auto-imports. Documented escape hatch fornp.random.default_rng()(which is not covered — passstate.seedexplicitly). -
result.brief()/sp.brief(result)— one-line dashboard string (~95 chars typical, ≤ 140 hard cap) for multi-result agent loops. -
MCP
prompts/list+prompts/get— three curated workflow prompt templates (audit_did_result/design_then_estimate/robustness_followup) surfaced as prompt buttons in MCP-compliant clients.
Changed¶
-
CausalResult.to_dict/EconometricResults.to_dictnow accept a keyword-onlydetailparameter. Default"standard"preserves the legacy shape exactly.CausalResult'sdetail_headis also keyword-only now (was positional-or- keyword) to close theto_dict("agent")foot-gun. -
CausalResult.cite()now acceptsformat=keyword; zero-arg call still returns BibTeX, byte-identical tocite(format="bibtex").
Tests¶
+422 targeted tests across the agent stack, all passing.
Token-budget assertions pin the size of every detail level so
future changes can't accidentally bloat the LLM tool-result channel.
No numerical changes¶
Existing estimator coefficient / SE / CI / p-value paths are byte- identical to 1.8.0. The 12 new modules are introspection, serialization, prompt-rendering, and RNG-management primitives — they read from existing result state, never recompute it.
[1.8.0] — 2026-04-28¶
Internal-development version covering five sp.regtable rounds, the
Native Rust IRLS for sp.fast.fepois, twelve provenance-rollout
phases, the production-function module, the clubSandwich-equivalent
HTZ Wald, the LLM-DAG closed loop, the synth refactor, the
estimand-first paper appendix, the great_tables / CSL pipeline, and
the export trinity (numerical lineage / replication pack / Quarto).
Subsections below preserve the chronological development order.
sp.regtable Round 4 (event_study_table, vcov= recompute, transpose)¶
Three further additions on top of Rounds 1-3. No numerical
changes to any estimator; the vcov= recompute reuses the
fit-time X + residuals already stored on OLS results.
Added¶
-
sp.event_study_table(result, *, regex=None, label_fmt="t={t}", include_reference=False)— adapter that turns an event-study fit into a regtable input. Two extraction paths: -
CausalResult fast path when
model_info['event_study']holds the canonicalrelative_time/estimate/se/ci_lower/ci_upper/pvalueDataFrame produced by :func:sp.event_study. -
Regex path when raw coefficient names like
"tau_-3","lag_-2","::-1"need to be parsed; the first capture group becomes the relative time. Rows are sorted in event-time order regardless of input ordering. -
vcov=parameter on :func:sp.regtable— recompute SE / t / p / 95% CI at print time without re-fitting. Currently supports OLS-style results that storedata_info['X']anddata_info['residuals']: -
"HC0"— White heteroskedasticity-robust "HC1"/"robust"— Stata'srobust(HC0 × n/(n-k))"HC2"— leverage-weighted"HC3"— leverage-squared (recommended for small samples; Long-Ervin 2000)
Columns whose underlying result lacks the X/residuals fields emit
a UserWarning and retain their fit-time SEs, so a
heterogeneous mix of OLS + non-OLS does not blow up.
transpose=Trueon :func:sp.regtable— rows become models, columns become variables. Single-panel only; multi-panel input ormulti_se=is rejected withNotImplementedErrorto keep the layout pivot semantics tight. Renders in text and HTML.
Tests¶
15 new tests in test_regtable_round4_extensions.py covering all
three features, including HC0/HC1/HC2/HC3 ordering verification
under heteroskedasticity, regex extraction fallback, and pivot
guards on multi-panel / multi_se.
577 targeted tests pass (Rounds 1-4 = 528 + 20 + 14 + 15, plus broad anchors). Zero regression.
2026-04-28 — Native Rust IRLS for sp.fast.fepois + production-function module¶
The headline of v1.8.0 is the 3× wall-clock improvement on the
medium HDFE benchmark: sp.fast.fepois runs at 0.855 s vs the
v1.7.x baseline's 2.61 s, and 1.34× of R fixest::fepois (0.64 s)
on the project's standard medium dataset (n=1M, fe1=100k, fe2=1k).
This closes the long-standing wall-clock gap to fixest to 1.34× —
well under the ≤ 1.5× target set in the v1.8 design spec.
Plus a new structural-estimation module: sp.prod_fn ships four
production-function estimators (Olley-Pakes, Levinsohn-Petrin,
Ackerberg-Caves-Frazer, Wooldridge) + De Loecker-Warzynski markup.
Performance — sp.fast.fepois on medium HDFE benchmark¶
| stage | wall | vs fixest | shipped |
|---|---|---|---|
| v1.7.x baseline (Python np.bincount inside Python IRLS) | 2.61 s | 4.08× | — |
| Phase A (Rust scatter, no cache) | 2.45 s | 3.83× | ✓ |
| Phase B0 (Rust sequential + dispatcher cache) | 1.441 s | 2.25× | ✓ |
| Phase B1 (native Rust IRLS, single PyO3 call) | 0.880 s | 1.37× | ✓ |
| Path A (B1 + Rust separation pre-pass) | 0.855 s | 1.34× | ✓ |
| R fixest::fepois | 0.64 s | 1.00× | — |
The closure was driven by three orthogonal contributions, each
verified with a wall-clock spike before the next was committed
(audited at benchmarks/hdfe/AUDIT.md):
- Phase A primitives:
statspai_hdfe.demean_2d_weightedPyO3 binding, Python_weighted_ap_demeandispatcher with NumPy fallback,weighted_demean_matrix_fortran_inplacecrate-internal Rust API. - Phase B0 algorithmic primitive: sort-by-primary-FE permutation
(
sort_perm::primary_fe_sort_perm) + sequential weighted sweep (weighted_group_sweep_sorted) replaces the L2-cache-miss-bound random-scatter inner loop on G1 = 100k bucket arrays. Plus the module-level FE-only-plan fingerprint cache in the dispatcher (avoids ~1.4 s per fepois of recomputingnp.argsort/searchsorted/ secondary perms across IRLS iters). - Phase B1 native Rust IRLS:
irls.rshostsfepois_loop, the full IRLS state machine (working response, working weight, sort-aware weighted demean, hand-coded SPD Cholesky for the WLS solve, eta clip, step-halving, deviance + convergence).FePoisIRLSWorkspaceholds scratch + Aitken history + sorted indices, allocated once per fepois call and reused across all IRLS iters. Single PyO3 call (fepois_irls) eliminates the 12 round-trips per fepois that Phase B0 still had. - Path A — Rust separation pre-pass: the iterative Poisson-
separation drop (drops rows in FE groups whose total y-sum is zero —
Poisson cannot identify them) was the last meaningful Python-side
O(n log n) overhead inside fepois (np.unique + np.isin per pass).
The Rust port (
separation::separation_mask) replaces it with an O(n × n_iter × K) bincount loop. ~25 ms additional wall reduction on the medium benchmark; closes 1.37× → 1.34× of fixest. Reusable by futurefeglmGLM families.
Numerical correctness — preserved at v1.7.x parity¶
sp.fast.fepoisvspyfixest.fepoiscoef on the medium dataset: unchanged (atol < 1e-13 across IRLS-converged fits).- Cluster-robust SE (
vcov="cr1"): the v1.7.x integration is untouched; commit39c94d0(CR1 recovery from auto-checkpoint) remains the canonical implementation. - The Python NumPy fallback path (when the compiled
statspai_hdfewheel is absent) is bit-for-bit identical to the v1.7.x behavior — verified bytest_fepois_falls_back_when_rust_unavailable.
Added¶
- New
statspai_hdfev0.6.0 PyO3 entry points (Rust crate v0.5.0-alpha.1): demean_2d_weighted— Phase A weighted variant of the K-way AP demean.demean_2d_weighted_sorted— Phase B0 sort-aware variant.fepois_irls— Phase B1 single-call IRLS state machine.-
separation_mask— Path A iterative Poisson separation detector. -
sp.prod_fnunified production-function estimator dispatcher with four named entry points (olley_pakes/opreg,levinsohn_petrin/levpet,ackerberg_caves_frazer/acf,wooldridge_prod) plusmarkup(De Loecker-Warzynski) andProductionResult. Cobb-Douglas default + translog functional form; firm-cluster bootstrap SE; full registry coverage. References: Olley-Pakes (1996), Levinsohn-Petrin (2003), Ackerberg-Caves-Frazer (2015), Wooldridge (2009), De Loecker-Warzynski (2012). 23 dedicated tests. sp.fast.fepoisPython dispatcher with three-tier fallback (native Rust IRLS → sort-aware Rust demean → random-scatter Rust demean → pure NumPy) — no user-facing API change.benchmarks/hdfe/run_fepois_phase_a.py,run_fepois_phase_b0.py,run_fepois_phase_b.py— reproducible wall-clock harnesses with hard merge gates.benchmarks/hdfe/AUDIT.md— Phase A round 1 (gate failure + root-cause), Phase B0 round 1 PASS, Phase B1 round 1 PASS audit trails. The audit pattern (measure-before-commit) is the structural counter-measure that prevented Phase A's "assumption broke" failure from repeating in B0 / B1.
Internal¶
- Rust crate
statspai_hdfebumped 0.2.0-alpha.1 → 0.5.0-alpha.1 across Phase A → Phase B → Path A (4 minor crate version bumps). - Python
__version__instatspai_hdfeextension:0.2.0→0.6.0.
Tests — 192 fast-fepois tests pass (was 187 in v1.7.x) + 23 prod_fn tests¶
- Phase B1 native-vs-Python IRLS parity: coef atol ≤ 1e-10, SE atol ≤ 1e-7
(
test_fepois_native_irls_vs_python_irls_parity). - Path A separation parity: 10 random seeds with synthetic zero-cluster
injection; Rust ↔ NumPy mask agreement element-wise
(
test_separation_rust_matches_python_fallback). - Cluster-SE suite intact (5 tests covering validation / NaN rejection / IID-baseline / closed-form / fixest-parity).
- New 23-test prod_fn suite covering OP / LP / ACF / Wooldridge / markup on synthetic Cobb-Douglas / translog DGPs + edge cases + bootstrap-SE reproducibility.
sp.regtable Round 3 (margins_table, tests= footer, fixef_sizes)¶
Three further additions on top of Round 1 + Round 2. No numerical
changes to any estimator (margins_table is a pure adapter; tests=
formats user-supplied test results; fixef_sizes reads pre-existing
model_info['n_fe_levels']).
Added¶
-
sp.margins_table(model)— adapter that wraps a :func:sp.marginsDataFrame as a duck-typed result with.params/.std_errors/.tvalues/.pvalues/.conf_int_*. Pipes straight into :func:sp.regtable, closing the "estimator → marginal-effects table" gap that previously required users to hand-buildadd_rows. Mirrors the R workflowmodelsummary(avg_slopes(model)). The wrapper z-stat is mapped totvaluesso existingse_type='t'/'p'/'ci'paths render unchanged. -
tests=parameter on :func:sp.regtable— render hypothesis-test rows in the diagnostic strip below the stats block.tests={"Wald F": [(12.34, 0.001), (8.91, 0.003)]}→ "Wald F 12.340*** 8.910***". Each per-model entry can be a(stat, p)tuple, a bare p-value,None, or a pre-formatted string. Stars honour the configurednotationfamily for cross-table consistency. Closes the gap to Stata'sestadd scalar/testintegration where reviewers expect Wald / Sargan / Hansen-J / first-stage F right under the main results. -
fixef_sizes=Trueon :func:sp.regtable— auto-emit "# Firm: 1,234" / "# Year: 30" rows showing distinct levels per fixed effect. Readsmodel_info['n_fe_levels']from each result; currently populated bycount.py(Poisson/NegBin) and the pyfixest adapter. Other estimators silently no-op. Mirrors R fixest'setable(..., fixef_sizes=TRUE).
Tests¶
14 new tests in test_regtable_round3_extensions.py covering
all three features across text / LaTeX / HTML renderers.
562 targeted tests pass (Rounds 1-3 = 528 + 20 + 14, plus broad anchors); zero regression on the 33 output / regression test files exercised.
sp.regtable Round 2 (templates, notation, apply_coef, escape, Word/Excel spanners)¶
Five further additions on top of the Round 1 commit. No numerical changes to any estimator; output-layer only.
Added — Five regtable parameters¶
estimate=/statistic=— flexible cell templates that mirror Rmodelsummary's arguments. Placeholders:{estimate},{stars},{std_error},{t_value},{p_value},{conf_low},{conf_high}. Examples:estimate="{stars}{estimate}"for stars-first.statistic="t={t_value}, p={p_value}"for working-paper cells.-
statistic="[{conf_low}, {conf_high}]"for inline CI without needingse_type="ci"separately. Unknown placeholders raise aKeyErrorat theregtable()call site. -
notation=— alternative significance-marker family."stars"(default) keeps("*", "**", "***");"symbols"swaps to("†", "‡", "§")for AER / JPE contexts where star-shaped markers conflict with footnote symbols; pass a custom 3-tuple for any ladder. The footer "p<0.01, ..." line rebuilds itself to match. -
apply_coef=/apply_coef_deriv=— generaliseeformto any callable.apply_coef=lambda b: 100*bfor a percentage transform;apply_coef=np.logfor log-scale;apply_coef_derivenables delta-method SE rescaling (|f'(b)|·SE). Mutually exclusive witheform— both transform the point estimate, and silently combining them would hide whichever the user listed second. -
escape=False— opt out of auto-escape so users can pass raw LaTeX (e.g."$\\beta_1$") or HTML ("<i>β</i>") as labels. Mirrors RkableExtra::escapeandxtable::print. Cell content (numeric estimates, computed stats) is always safe — it never contains user-controlled metacharacters. -
Word + Excel
column_spannersrendering — closes the format parity gap left in Round 1. Word inserts an extra header row with merged cells across each column block; Excel usesws.merge_cellsand the spanner row sits above the model-label row inside the booktab top-rule region.
Tests¶
20 new tests in test_regtable_round2_extensions.py covering all
five features across text / LaTeX / HTML / Word / Excel renderers.
548 targeted tests pass (Round 1's 528 + 20 new), zero regression.
sp.regtable publication-quality extensions¶
Five additions designed to close the remaining gap between
sp.regtable and Stata esttab / R modelsummary /
R fixest::etable for empirical paper writing. No numerical
changes to any estimator; output-layer only.
Added — Five regtable parameters¶
-
eform— reportexp(b)(odds ratios forlogit/probit, incidence-rate ratios forpoisson, hazard ratios for Cox-style models). SE via delta method (exp(b)·SE(b)); CI bounds via(exp(lo), exp(hi))of the original endpoints; t and p unchanged becauseH_0: b=0is equivalent toH_0: exp(b)=1. Acceptsbool(apply to all) orList[bool](per-model — mix logit OR with OLS coefs in the same table). A footer note transparently flags which columns are exponentiated. Mirrors Stataesttab, eform. -
column_spanners— multi-row header above the model labels. Pass a list of(label, span)tuples whose spans partition all model columns, e.g.[("OLS", 2), ("IV", 2)]. Renders as\multicolumn{n}{c}{label}+\cmidrulein LaTeX,colspanin HTML, repeated bold cells in Markdown, and centered ASCII in text. Mirrors Statamgroups()and Rmodelsummary'sgroup. -
coef_map— single-shot rename + reorder + drop. Pass an ordered dict whose keys are coefficients to keep (in display order) and values are display labels. Variables not in the map are dropped. Mutually exclusive with the legacycoef_labels/keep/drop/orderquartet. Mirrors Rmodelsummary'scoef_map. -
stats=["depvar_mean", "depvar_sd"]— auto rows for the dependent variable's sample mean and standard deviation, populated from the result object'sdata_info['y'](orendog/dep_var) at extraction time. Rows render as "Mean of Y" and "SD of Y". Top-5 economics journals routinely require these so reviewers can sanity-check effect magnitudes against the outcome's scale. Aliases:"ymean"/"ysd". -
consistency_check(defaultTrue) — emit aUserWarningwhen sample sizes differ across columns. Disable viaconsistency_check=Falsewhen the N-mismatch is intentional (IV first stage on a subsample, RD bandwidth restriction). Reviewer red flag silenced by default in v1.7.2, surfaced now.
Tests¶
23 new tests in test_regtable_publication_extensions.py covering
all six format renderers (text / LaTeX / HTML / Markdown) plus the
parameter validation paths. Existing 204 output-area tests
unchanged.
Phase 12: provenance rollout to 66/925 (bounds + randomization + imputation)¶
Continues the v1.7.2 provenance rollout. No numerical changes to any estimator. 5 estimators instrumented spanning bounds / randomization inference / imputation. Coverage 61/925 → 66/925.
Added — Provenance for 5 estimators¶
sp.balke_pearl— Balke-Pearl bounds on ATE under monotonicity.sp.lee_bounds— Lee (2009) trimming bounds for selection.sp.manski_bounds— Manski (1990) worst-case ATE bounds.sp.fisher_exact— Fisher randomization test (permutation).sp.imputation.mice— Multiple Imputation by Chained Equations.
Tests¶
6 new (5 per-estimator + 1 multi-estimator integration). All pass.
Phase 11: provenance rollout to 61/925 (spatial + qte + bootstrap + conformal)¶
Continues the v1.7.2 provenance rollout. No numerical changes to any estimator. 7 estimators instrumented spanning spatial / quantile / distributional / bootstrap / conformal. Coverage 54/925 → 61/925.
Added — Provenance for 7 estimators¶
sp.spatial.spatial_did— spatial-lag DiD with spillover decomposition.sp.spatial.spatial_iv— spatial 2SLS.sp.qte.dist_iv— distributional IV / quantile LATE.sp.qte.beyond_average_late— quantile LATE under fuzzy compliance.sp.qte.qte_hd_panel— high-dim panel QTE via LASSO controls.sp.bootstrap— general-purpose bootstrap inference.sp.conformal_cate— conformal prediction intervals for CATE.
Tests¶
8 new (7 per-estimator + 1 multi-estimator integration). All pass.
Phase 10: provenance rollout to 54/925 (panel + decomp + mediation)¶
Continues the v1.7.2 provenance rollout. No numerical changes to
any estimator. 6 estimators instrumented; sp.panel refactored
into outer wrapper + dispatcher (parallel to Phase 4 sp.synth and
Phase 7 sp.etwfe). Coverage 48/925 → 54/925.
Added — Provenance for 6 estimators¶
sp.panel— multi-method panel dispatcher (FE / RE / BE / FD / pooled / twoway / CRE / GMM). Refactored: outerpanelwrapper captures kwargs + calls_dispatch_panel_impl+ attaches provenance once. Public signature unchanged.sp.causal_impact— Brodersen-Gallusser-Koehler-Remy-Scott (2015) BSTS-style impact.sp.mediate— Imai-Keele-Tingley (2010) mediation.sp.mediate_interventional— VanderWeele-Vansteelandt-Robins (2014) interventional (in)direct effects.sp.bartik— Goldsmith-Pinkham-Sorkin-Swift (2020) shift-share IV.sp.decompose— Oaxaca / FFL / DFL / RIF / gap-closing dispatcher;Provenance.functionsurfaces the dispatched method (e.g."sp.decompose.oaxaca").
Skipped — sp.did top-level dispatcher¶
The sp.did dispatcher delegates to already-instrumented inner
estimators (sp.did.callaway_santanna / sp.did.did_2x2 /
sp.did.aggte / sp.sun_abraham / sp.synth(method='sdid')).
With the established overwrite=False semantics, the inner
record's name (more specific) wins. Wrapping the dispatcher would
add no information.
Tests¶
8 new (6 per-estimator + 1 panel method-choice variant + 1 multi-estimator integration). 111 green across the panel / causal_impact / mediation / decomposition / bartik regression sweep.
Phase 9: provenance rollout to 48/925 (TMLE + forest + DR)¶
Continues the v1.7.2 provenance rollout. No numerical changes to any estimator. 12 ML-causal + classical-identification estimators instrumented. Coverage 36/925 → 48/925.
Added — Provenance for 12 estimators¶
ML-causal (8):
sp.tmle— van der Laan & Rose Targeted MLE (with Super Learner).sp.tmle.ltmle— Longitudinal TMLE for static regime contrasts.sp.tmle.hal_tmle— TMLE with Highly Adaptive Lasso nuisance.sp.causal_forest— GRF causal forest factory.sp.multi_arm_forest— multi-arm causal forest.sp.iv_forest— Athey-Tibshirani-Wager IV causal forest.sp.metalearner— S/T/X/R/DR meta-learner dispatcher.sp.bcf— Hahn-Murray-Carvalho Bayesian Causal Forest.
Classical identification (4):
sp.aipw— Augmented IPW (doubly robust, cross-fit).sp.ipw— Inverse Probability Weighting.sp.g_computation— parametric g-formula.sp.front_door— Pearl front-door adjustment.
Pattern reuse: established Phase 3 idiom — assign to _result,
attach_provenance(overwrite=False), return. The hal_tmle →
tmle cascade is handled correctly: inner sp.tmle record wins,
matching the etwfe → wooldridge_did and lasso_iv → iv
patterns from earlier rounds.
Tests¶
14 new (12 per-estimator + 1 metalearner choice variant + 1 multi-estimator integration). 103 green across the hal_tmle / causal_forest / metalearner / bcf / front_door / g_computation regression sweep.
production function estimators (OP / LP / ACF / Wooldridge + translog + DLW markup)¶
Adds proxy-variable production function estimation — Olley-Pakes,
Levinsohn-Petrin, Ackerberg-Caves-Frazer, Wooldridge — plus
Cobb-Douglas + translog functional forms and the De Loecker-Warzynski
markup. Closes the long-standing gap that forced StatsPAI users to
drop into R prodest or Stata prodest for productivity / TFP /
markup work.
Added¶
sp.prod_fn(method=..., functional_form=...)— unified dispatcher ('op' | 'lp' | 'acf' | 'wrdg','cobb-douglas' | 'translog').sp.olley_pakes(aliassp.opreg) — investment-proxy estimator with strictly-positive-investment filter.sp.levinsohn_petrin(aliassp.levpet) — intermediate-input proxy (avoids OP zero-investment selection).sp.ackerberg_caves_frazer(aliassp.acf) — modern default, corrects the OP/LP labor-coefficient identification problem via lagged-labor instruments.sp.wooldridge_prod— joint stacked-NLS estimator (Cobb-Douglas only; translog raises NotImplementedError; full-GMM Wooldridge on roadmap).sp.markup— De Loecker & Warzynski (2012) firm-time markup μ_it = θ_v · (PQ) / (P_v V) with optional η-correction. Supports both Cobb-Douglas (constant θ_v) and translog (firm-time θ_v_it read from the elasticity panel attached to the result).sp.ProductionResult— unified result class withcoef,tfp,productivity_process,cite(),summary(), plusmodel_info["elasticities"]for translog firm-time elasticities.- Translog functional form: input matrix expanded to linear + 0.5*x_j² + cross-term basis; instrument matrix expanded by the same polynomial; firm-time output elasticities computed from ∂y/∂x_j = β_j + β_jj·x_j + Σ_{k≠j} β_jk·x_k.
- Firm-cluster bootstrap SE (Wooldridge 2009 §4 convention) with convergence filtering on each replicate.
- Multi-start Nelder-Mead in stage 2 over 5 economic-prior starts (the OLS warm start is intentionally avoided — it lands in a spurious basin where the productivity AR overfits ω onto ω_lag at implausible β).
- UserWarning on non-consecutive panel time periods (lag operator would silently treat gaps as 1-period lags otherwise).
- 9 new registry entries (5 canonical + 3 aliases + markup) — total rises to 964 functions.
References (verified via Crossref API on 2026-04-27)¶
- Olley & Pakes (1996) Econometrica 64(6) 1263–1297, DOI 10.2307/2171831
- Levinsohn & Petrin (2003) RES 70(2) 317–341, DOI 10.1111/1467-937X.00246
- Ackerberg, Caves & Frazer (2015) Econometrica 83(6) 2411–2451, DOI 10.3982/ECTA13408
- Wooldridge (2009) Economics Letters 104(3) 112–114, DOI 10.1016/j.econlet.2009.04.026
- De Loecker & Warzynski (2012) AER 102(6) 2437–2471, DOI 10.1257/aer.102.6.2437
Tests¶
tests/test_prod_fn.py— 23 tests:- Synthetic DGP recovery (ACF tight; OP/LP loose per ACF's identification critique; Wooldridge feasible-range)
- Translog: 5-coef structure, dispatcher pass-through, CD-truth nesting (β_ll/β_kk/β_lk near 0), markup with firm-time θ_v_it, Wooldridge-translog raises, unknown functional_form raises
- Dispatcher, aliases, bootstrap SE, markup CD path, edge cases (missing columns, too-few-obs, zero-proxy filter, time-gap warning, registry presence, no-bootstrap diagnostics shape).
Notes¶
- Default
productivity_degree=1(linear AR(1)). Higher degrees can overfit ω given ω_lag in finite samples and flatten the GMM objective surface — see dispatcher docstring. - Translog identification caveat: stage-2 instruments are polynomial transforms of the same raw (k, l_lag) pair, so the moment system can be near-singular when state and lagged-free inputs are highly correlated. Higher-order coefficients have larger finite-sample variance than linear ones — bootstrap SEs recommended.
- Gandhi-Navarro-Rivers (2020) flexible-input identification and full efficient-GMM Wooldridge are roadmap items, not in this release.
Phase 8: provenance rollout to 36/925 (IV + matching + DML)¶
Continues the v1.7.2 provenance rollout from Phases 3-4-7. No
numerical changes to any estimator. 12 instrumentation points added
(15 user-facing functions, since the JIVE family of 4 share a single
_run instrumentation). Coverage 21/925 → 36/925.
Added — Provenance instrumentation for 12 more points¶
IV family (9 user-facing names):
sp.liml— Limited Information Maximum Likelihood / Fuller.sp.jive— legacy single-method JIVE (regression/advanced_iv).sp.lasso_iv— Belloni-Chen-Chernozhukov-Hansen (2012). The pre-existingiv()API drift bug here was also repaired —lasso_ivnow builds a formula string for the formula-onlysp.iv()API and maps the legacyrobust='robust'kwarg to the modernhc1enum.sp.iv.bayesian_iv— Chernozhukov-Hong (2003) Anderson-Rubin posterior with Metropolis-Hastings.sp.iv.jive1/sp.iv.ujive/sp.iv.ijive/sp.iv.rjive— all four flow through the shared_rundispatcher;methodarg discriminates and surfaces inProvenance.function("sp.iv.jive1"/"sp.iv.ujive"/ …). One instrumentation point covers four user-facing names.sp.iv.mte— Brinch-Mogstad-Wiswall (2017) polynomial Marginal Treatment Effect.
Matching family (5):
sp.match— main matching dispatcher (PSM / mahalanobis / CEM / strata / coarsened).sp.optimal_match— Hungarian-algorithm 1:1 with caliper.sp.cardinality_match— Zubizarreta (2014) LP with SMD tolerance.sp.genmatch— Diamond-Sekhon (2013) genetic matching.sp.sbw— Zubizarreta (2015) Stable Balancing Weights.
DML (1):
sp.dml— Chernozhukov et al. (2018) Double ML dispatcher covering plr / irm / pliv / iivm. Single-exit pattern.
Pattern reuse: each follows the established Phase 3 idiom — assign
result to _result, call attach_provenance(overwrite=False),
return. overwrite=False semantics preserve the inner-most record
when an outer wrapper (e.g. lasso_iv calling sp.iv) is also
instrumented.
Fixed — sp.lasso_iv API drift (pre-existing)¶
Independent fix: sp.lasso_iv was calling the legacy iv(y=,
x_endog=, x_exog=, z=) signature which is no longer accepted. Now
builds a Patsy-style formula (y ~ (endog ~ z) + exog) for the
current formula-only sp.iv() API.
Tests¶
16 new tests (12 per-estimator + 4 JIVE variants confirming each
gets the right method-discriminated function name + 1
multi-estimator integration). 155 green across the IV + matching +
DML + provenance regression sweep:
- IV:
test_iv.pyandtest_iv_frontiers.py. - Matching:
test_matching.pyandtest_matching_optimal.py. - DML:
test_dml.py,test_dml_iivm.py,test_dml_panel.py,test_dml_split.py. - Provenance: rounds 1+2+3+4.
Documentation¶
docs/guides/replication_workflow.md scorecard updated to 36/925.
production function estimators¶
Adds proxy-variable production function estimation — Olley-Pakes,
Levinsohn-Petrin, Ackerberg-Caves-Frazer, Wooldridge — plus the
De Loecker-Warzynski markup. Closes the long-standing gap that
forced StatsPAI users to drop into R prodest or Stata prodest
for productivity / TFP / markup work.
Added¶
sp.prod_fn(method=...)— unified Cobb-Douglas dispatcher ('op' | 'lp' | 'acf' | 'wrdg').sp.olley_pakes(aliassp.opreg) — investment-proxy estimator with strictly-positive-investment filter.sp.levinsohn_petrin(aliassp.levpet) — intermediate-input proxy (avoids OP zero-investment selection).sp.ackerberg_caves_frazer(aliassp.acf) — modern default, corrects the OP/LP labor-coefficient identification problem via lagged-labor instruments.sp.wooldridge_prod— joint stacked-NLS estimator (one-step GMM with identity weighting and instruments = regressors; full efficient-GMM variant on the roadmap).sp.markup— De Loecker & Warzynski (2012) firm-time markup μ_it = θ_v · (PQ) / (P_v V) with optional η-correction.sp.ProductionResult— unified result class withcoef,tfp,productivity_process,cite(),summary().- Firm-cluster bootstrap SE (Wooldridge 2009 §4 convention) with convergence filtering on each replicate.
- Multi-start Nelder-Mead in stage 2 to dodge the upward-biased OLS warm start (positive selection of labor on ω).
- UserWarning on non-consecutive panel time periods (lag operator would silently treat gaps as 1-period lags otherwise).
- 9 new registry entries (5 canonical + 3 aliases + markup), bringing total to 964 functions.
References (verified via Crossref API on 2026-04-27)¶
- Olley & Pakes (1996) Econometrica 64(6) 1263–1297, DOI 10.2307/2171831
- Levinsohn & Petrin (2003) RES 70(2) 317–341, DOI 10.1111/1467-937X.00246
- Ackerberg, Caves & Frazer (2015) Econometrica 83(6) 2411–2451, DOI 10.3982/ECTA13408
- Wooldridge (2009) Economics Letters 104(3) 112–114, DOI 10.1016/j.econlet.2009.04.026
- De Loecker & Warzynski (2012) AER 102(6) 2437–2471, DOI 10.1257/aer.102.6.2437
Tests¶
tests/test_prod_fn.py— synthetic DGP recovery (ACF tight, OP/LP loose per ACF's identification critique), dispatcher, aliases, bootstrap SE, markup, edge cases (missing columns, too-few-obs, zero-proxy filter, time-gap warning, registry presence). 18 tests.
Notes¶
- Default
productivity_degree=1(linear AR(1)). Higher degrees can overfit ω given ω_lag in finite samples and flatten the GMM objective surface — see dispatcher docstring. - Translog and Gandhi-Navarro-Rivers (2020) production functions are roadmap items, not in this release.
Phase 7: provenance rollout to 21/925 (DiD long-tail + RD)¶
Continues the v1.7.2 provenance rollout established in Phases 3-4.
No numerical changes to any estimator. 12 more estimators
instrumented; sp.etwfe refactored into wrapper + dispatcher
(parallel to the Phase 4 sp.synth move). Coverage now 21/925.
Added — Provenance instrumentation for 12 more estimators¶
DiD long-tail (10):
sp.cic— Athey-Imbens (2006) Changes-in-Changes.sp.cohort_anchored_event_study— staggered-robust ES (arXiv:2509.01829).sp.design_robust_event_study(Wright 2026, arXiv:2601.18801) — orthogonalised event-study under staggered adoption.sp.gardner_did/sp.did_2stage— Gardner (2021) two-stage.sp.harvest_did— Borusyak-Harmon-Hull-Jaravel-Spiess (2025) harvesting.sp.did_misclassified— staggered DiD with treatment misclassification + anticipation (arXiv:2507.20415).sp.stacked_did— Cengiz-Dube-Lindner-Zipperer (2019) stacked.sp.wooldridge_did— Wooldridge (2021) Extended TWFE.sp.etwfe— refactored into outer wrapper + 4-branch_dispatch_etwfe_implso the (with-xvar / never-only / notyet / repeated-cross-section) routing attaches provenance once on the way out. Same pattern as Phase 4'ssp.synthmove.sp.drdid— Sant'Anna-Zhao (2020) doubly robust DiD.
RD (2):
sp.rd_honest— Armstrong-Kolesar (2018, 2020) honest CIs.sp.rkd— Card-Lee-Pei-Weber (2015) Regression Kink Design.
Each follows the established Phase 3 idiom: assign result to
_result, call attach_provenance(overwrite=False), return.
overwrite=False semantics preserve the inner-most record so
estimand-first / sp.causal / sp.paper wrappers don't clobber
the more-specific call name.
Changed — sp.etwfe refactored into outer wrapper + dispatcher¶
Mirrors Phase 4's sp.synth refactor. The previous etwfe had 4
return sites (one per (panel × cgroup × xvar) branch), which
made naive instrumentation maintenance-hostile. New layout:
_dispatch_etwfe_impl(...)— full dispatcher (formeretwfebody), unchanged logic.etwfe(...)— thin outer wrapper that captures kwargs, calls impl, attaches provenance once before returning.
Public signature is bit-identical; the existing wooldridge / etwfe test sweep passes with zero changes.
Tests¶
14 new tests (12 per-estimator + 1 did_2stage alias check + 1
multi-estimator replication_pack integration). 346 green across
the DiD + RD + paper regression sweep (DiD: 214, paper+remaining:
132). Zero regressions across either family.
Documentation¶
docs/guides/replication_workflow.md scorecard updated to reflect
the new 21/925 coverage. Users running get_provenance(result)
can verify any estimator's status locally.
production function estimators¶
Adds proxy-variable production function estimation — Olley-Pakes,
Levinsohn-Petrin, Ackerberg-Caves-Frazer, Wooldridge — plus the
De Loecker-Warzynski markup. Closes the long-standing gap that
forced StatsPAI users to drop into R prodest or Stata prodest
for productivity / TFP / markup work.
Added¶
sp.prod_fn(method=...)— unified Cobb-Douglas dispatcher ('op' | 'lp' | 'acf' | 'wrdg').sp.olley_pakes(aliassp.opreg) — investment-proxy estimator with strictly-positive-investment filter.sp.levinsohn_petrin(aliassp.levpet) — intermediate-input proxy (avoids OP zero-investment selection).sp.ackerberg_caves_frazer(aliassp.acf) — modern default, corrects the OP/LP labor-coefficient identification problem via lagged-labor instruments.sp.wooldridge_prod— joint stacked-NLS estimator (one-step GMM with identity weighting and instruments = regressors; full efficient-GMM variant on the roadmap).sp.markup— De Loecker & Warzynski (2012) firm-time markup μ_it = θ_v · (PQ) / (P_v V) with optional η-correction.sp.ProductionResult— unified result class withcoef,tfp,productivity_process,cite(),summary().- Firm-cluster bootstrap SE (Wooldridge 2009 §4 convention) with convergence filtering on each replicate.
- Multi-start Nelder-Mead in stage 2 to dodge the upward-biased OLS warm start (positive selection of labor on ω).
- UserWarning on non-consecutive panel time periods (lag operator would silently treat gaps as 1-period lags otherwise).
References (verified via Crossref API on 2026-04-27)¶
- Olley & Pakes (1996) Econometrica 64(6) 1263–1297, DOI 10.2307/2171831
- Levinsohn & Petrin (2003) RES 70(2) 317–341, DOI 10.1111/1467-937X.00246
- Ackerberg, Caves & Frazer (2015) Econometrica 83(6) 2411–2451, DOI 10.3982/ECTA13408
- Wooldridge (2009) Economics Letters 104(3) 112–114, DOI 10.1016/j.econlet.2009.04.026
- De Loecker & Warzynski (2012) AER 102(6) 2437–2471, DOI 10.1257/aer.102.6.2437
Tests¶
tests/test_prod_fn.py— synthetic DGP recovery (ACF tight, OP/LP loose per ACF's identification critique), dispatcher, aliases, bootstrap SE, markup, edge cases (missing columns, too-few-obs, zero-proxy filter, time-gap warning, registry presence). 18 tests.
Notes¶
- Default
productivity_degree=1(linear AR(1)). Higher degrees can overfit ω given ω_lag in finite samples and flatten the GMM objective surface — see dispatcher docstring. - Translog and Gandhi-Navarro-Rivers (2020) production functions are roadmap items, not in this release.
clubSandwich-equivalent HTZ Wald (independent PR)¶
Adds a numerically-equivalent Python implementation of R
clubSandwich::Wald_test(..., test="HTZ") for cluster-robust Wald
tests under CR2 sandwich. Closes the BM-vs-HTZ gap documented in
cluster_dof_wald_bm (which uses the BM 2002 simplified formula
and can drift 50–100% from clubSandwich on multi-restriction tests).
Added¶
sp.fast.cluster_wald_htz()— full HTZ Wald test, returnsWaldTestResult(test, q, eta, F_stat, p_value, Q, R, r, V_R).sp.fast.cluster_dof_wald_htz()— DOF-only helper mirroring thecluster_dof_wald_bmsignature for easy substitution.sp.fast.WaldTestResult— frozen dataclass with.summary()and.to_dict().- Pustejovsky-Tipton 2018 §3.2 moment-matching DOF η computed as
q(q+1) / sum(var_mat)withvar_matderived from cluster-pair contributions toR · V^CR2 · R^Tunder a working covariance Φ = I (OLS+CR2; clubSandwich's default). - Hotelling-T² scaling:
F_stat = (η - q + 1) / (η · q) · Qwithp_value = 1 - F_{q, η-q+1}.cdf(F_stat).
Verification¶
- 3 frozen-fixture parity tests vs R clubSandwich 0.6.2 at
rtol < 1e-8(q ∈ {1, 2, 3}, balanced + unbalanced panels; fixture intests/fixtures/htz_clubsandwich.json, no R required in CI). - 3 live-R parity tests at
rtol < 1e-8(skipifRscriptmissing). - 14 unit tests: validation, invariance (X rescale + cluster relabel +
bread arg path), edge cases (singleton cluster warning, zero
residuals short-circuit,
η ≤ q-1rejection, non-uniform weightsNotImplementedError). - Total: 23/23 tests pass.
Scope (v1)¶
- Standalone — no wiring into
crve/feols/fepois/event_study. That's the next PR. - Working covariance
Φlocked toI(OLS+CR2). Non-uniform weights raiseNotImplementedErrorwith a pointer to v2. - HTZ test variant only; HTA / HTB / KZ / Naive / EDF deferred.
References¶
pustejovsky2018smalladded topaper.bibafter Crossref dual-source verification (DOI10.1080/07350015.2016.1247004; authors / year 2018 / vol 36(4) / pp 672–683 / title — all four elements verified per CLAUDE.md §10).- Implementation derived 1:1 from Pustejovsky-Tipton 2018 §3.2 + clubSandwich source (R Wald_testing / get_P_array / total_variance_mat). No GPL code copied; clubSandwich used only as black-box reference.
Phase 5: LLM-DAG closed loop + layered credential resolver¶
Closes the LLM-DAG closed-loop deferred from Phases 2-4. No numerical changes to any estimator. The export pipeline can now auto-propose a DAG via a real LLM (Anthropic Claude or OpenAI GPT) without requiring users to pre-build one — credential resolution follows the industry-standard layered fallback pattern.
Added — sp.causal_llm.get_llm_client() layered credential resolver¶
Resolution order (first match wins):
- Explicit
client=— already-builtLLMClient, pass through. - Explicit
provider=+api_key=— construct directly. - Environment variable —
ANTHROPIC_API_KEY/OPENAI_API_KEY. When both are set, tie-break to the config file's[llm].provider(or to Anthropic if no config). - Config file
~/.config/statspai/llm.toml(XDG-compliant) — storesproviderandmodelpreferences. Never stores API keys — that's the documented industry-standard split (Anthropic SDK / OpenAI SDK / AWS CLI / kubectl all keep keys in environment variables, never plaintext config). - Interactive prompt — only when
sys.stdin.isatty()ANDallow_interactive=True. Walks user through provider + model selection but never asks for the API key over stdin (security: leaks in shell history, no obvious env-var integration path). - Hard error with concrete remediation: lists the env vars to
set + points at
sp.causal_llm.configure_llm(...)for the provider+model preference part.
Added — sp.causal_llm.configure_llm() preferences setter¶
One-shot setter that persists provider+model to the XDG config file. Useful when a user has both env vars set and wants to pin the choice:
import statspai as sp
sp.causal_llm.configure_llm(provider="openai", model="gpt-4o")
# → ~/.config/statspai/llm.toml gets a [llm] block with the choice.
Added — sp.paper(..., llm='auto', llm_domain=...) auto-DAG hook¶
When the user doesn't pass an explicit dag=, llm='auto' (or
llm='heuristic' for a pinned offline path) triggers
llm_dag_propose against the resolved client + the variable list.
Failures (no API key, network error, malformed JSON) silently fall
back to a no-DAG paper — auto-DAG must never break the rest of the
pipeline. Pass llm_client= to override the resolver entirely.
The proposed DAG is materialised as a statspai.dag.graph.DAG and
attached to the PaperDraft, so all downstream rendering (Quarto
mermaid block, replication_pack lineage, Causal DAG appendix) flows
through the existing Phase 3 plumbing — no new branches.
Added — LLMClient.complete() alias (latent bug fix)¶
llm_dag_propose / llm_dag_validate / llm_dag_constrained
all called client.complete(prompt), but the LLMClient base
class only defined chat(role, prompt) and __call__(prompt).
Any user passing a real openai_client / anthropic_client
into the LLM-DAG functions would have hit AttributeError. Added
complete() as an alias on the base class — both names route
through chat(), so no concrete adapter needs changes.
Public exports¶
sp.causal_llm.get_llm_client,
sp.causal_llm.list_available_providers,
sp.causal_llm.configure_llm,
sp.causal_llm.LLMConfigurationError,
sp.causal_llm.llm_config_path,
sp.causal_llm.load_llm_config,
sp.causal_llm.DEFAULT_LLM_MODELS.
Tests¶
27 new tests (tests/test_llm_resolver.py):
- Config file: XDG path, missing/malformed graceful fallback, save round-trip, header comment warns against putting keys in the file.
- Layered fallback: explicit client → explicit provider → env → config tie-break → no-env-no-tty hard error → no-env-tty-no-keys hard error → env-set skips prompt.
configure_llmround-trip + unknown-provider rejection.LLMClient.complete()alias smoke.sp.paper(llm='auto')integration: no-env falls back to heuristic; explicitllm_client=populates the DAG.
221 green across the new + adjacent paper / lineage / replication_pack / estimator-provenance / bibliography / gt suites.
Phase 4: synth refactor + 5 more estimator provenance hookups¶
Continues the v1.7.2 provenance rollout from Phase 3 (4 estimators
instrumented). This round closes the deferred sp.synth dispatcher
refactor and adds 4 more high-leverage estimators. No numerical
changes to any estimator — total provenance coverage now 9/925.
Changed — sp.synth dispatcher refactored for one-shot provenance¶
The previous v1.7.2 instrumentation deferred sp.synth because its
13 method branches each had their own return X(...) call site —
sprinkling 13 attach_provenance calls would've been
maintenance-hostile. Refactor splits responsibility:
_dispatch_synth_impl(...)— full dispatcher (formersynthbody), unchanged logic.synth(...)— thin outer wrapper that captures kwargs, calls impl, then attaches provenance once before returning.
Public signature is bit-identical; the 145-test synth regression
sweep passes with zero changes. All 13 SCM method variants
(classic / penalized / demeaned / unconstrained /
augmented / sdid / factor / staggered / mc /
discos / multi_outcome / scpi / bayesian / bsts /
penscm / fdid / cluster / sparse / kernel /
kernel_ridge) now flow through the same provenance attach.
Added — Provenance instrumentation for 4 more estimators¶
sp.did.did_imputation— Borusyak-Jaravel-Spiess (2024) imputation.sp.did.aggte— Callaway-Sant'Anna ATT(g, t) aggregation. Capturesupstream_run_idandupstream_functionso downstream consumers can trace the aggregation step back to the producingcallaway_santannacall (sp.replication_pack'slineage.jsonthus gets a chain, not just disconnected runs).sp.did.did_multiplegt— de Chaisemartin-D'Haultfoeuille (2020).sp.rd.rdrobust— Calonico-Cattaneo-Titiunik local-polynomial RD with robust bias correction. Captures kernel / bwselect / fuzzy / donut / weights for the full reproduction recipe.
Each follows the established pattern: assign result to _result,
call attach_provenance with overwrite=False, return. Any
upstream-instrumented estimator (sp.causal_question /
sp.paper / aggte) preserves the inner record.
Tests¶
9 new tests (3 synth + 1 did_imputation + 1 aggte upstream-linkage + 1 did_multiplegt + 2 rdrobust + 1 multi-estimator integration). 166 green across the DiD + RD + new provenance regression sweep (95s wall, 145 of which are synth — the refactor is paid for in test time once and forgotten).
Provenance coverage scorecard¶
| v1.7.2 P3 | v1.7.2 P4 (this) | |
|---|---|---|
| Instrumented | 4/925 | 9/925 |
| Estimator | Status |
|---|---|
sp.regress |
P3 ✓ |
sp.callaway_santanna |
P3 ✓ |
sp.did_2x2 |
P3 ✓ |
statspai.regression.iv.iv |
P3 ✓ |
sp.synth (13 method dispatcher) |
P4 ✓ |
sp.did.did_imputation |
P4 ✓ |
sp.did.aggte (chain-aware) |
P4 ✓ |
sp.did.did_multiplegt |
P4 ✓ |
sp.rd.rdrobust |
P4 ✓ |
Remaining 916 estimators bucket into v1.7.3 sprint themes: DiD long-tail (~20), IV variants (~15), synth sub-modules (already flow through dispatcher), DML / TMLE / metalearners (~50), panel / structural (~80), and the long tail (~750).
Phase 3: estimand-first paper + estimator provenance + DAG appendix¶
Layered on top of the Phase 1+2 export trinity. No numerical changes to any estimator. Three additions, each gated to opt-in call sites to keep blast radius small.
Added — Estimand-first sp.paper(causal_question_obj)¶
The Target-Trial-Protocol-shaped declaration now drives the paper end to end. Two equivalent entry points:
# Method-style:
q = sp.causal_question("trained", "wage", data=df, design="did",
time="year", id="worker_id")
draft = q.paper(fmt="qmd")
draft.write("paper.qmd")
# Function-style dispatch:
draft = sp.paper(q, fmt="qmd")
The builder routes through q.identify() + q.estimate() and
assembles Question / Data / Identification / Estimator / Results /
Robustness / References sections whose contents match the
declaration (not natural-language inference). Unlike the
DataFrame-first sp.paper(df, "natural-language question") path,
this preserves the user's pre-registered estimand, design, and
identification claims verbatim — agents that pre-register get
audit-grade traceability for free.
Underlying estimator's result is exposed on
draft.workflow.result so sp.replication_pack and
draft.to_qmd()'s Reproducibility appendix pick up provenance
automatically.
Added — Estimator-level provenance instrumentation (4 of 5)¶
Top-tier estimators now attach_provenance() to their fit result
with overwrite=False semantics — outer wrappers (sp.causal,
sp.paper) preserve the inner estimator's more-specific record:
sp.regress(regression/ols.py).sp.callaway_santanna(did/callaway_santanna.py).sp.did_2x2(did/did_2x2.py).statspai.regression.iv.iv— unified 2SLS / LIML / GMM / JIVE.
Each captures: function name, key kwargs (formula / estimator / control_group / method / etc.), 12-char SHA-256 of the input frame (column-name + dtype + value sensitive), run uuid, version stamps.
Deferred: sp.synth dispatcher (13 method branches, 13+ return
sites). A dedicated v1.7.3 sprint refactors synth into an inner
_dispatch_synth plus an outer wrapper that attaches provenance
once, instead of sprinkling 13 attach calls.
Added — Causal DAG appendix in PaperDraft¶
Pass dag= to sp.paper(...) (or q.paper(dag=...)) and the
draft gains a Causal DAG section that renders fmt-aware:
- Markdown / TeX: text-art with the variable list, edge list,
back-door paths, adjustment sets, and any latent
_L_*confounders. - Quarto (.qmd): native
{mermaid}graph block (Quarto renders to SVG out of the box) plus the same text fallbacks below it.
Identification-relevant info (back-door paths, adjustment sets, bad
controls) is computed from the DAG via the existing
:class:statspai.dag.graph.DAG API; the LLM-DAG closed loop
(sp.llm_dag_propose / validate / constrained) integrates as a
data source — pass any DAG those return into dag=. The paper
builder doesn't itself call any LLM API; that remains the user's
explicit choice.
Added — Public exports¶
sp.paper_from_question— alternative entry point next to the method-styleq.paper()and the dispatcher insp.paper(q).- DAG-section-related fields on :class:
PaperDraft:dag,dag_treatment,dag_outcome.
Tests¶
35 new tests (14 paper_from_question + 8 estimator_provenance + 13 paper_dag_section). 295 green across the full Phase 1+2+3 + adjacent paper / registry / help / output / workflow surface.
HDFE silent-bug fix + completeness pass¶
Layered on top of the v1.8 RC sp.fast.* HDFE stack. One ⚠️
correctness fix (event_study cluster SE), the rest is additive.
⚠️ Correctness — sp.fast.event_study cluster SE¶
sp.fast.event_study was computing CR1 cluster-robust SEs without
charging the absorbed FE rank against residual degrees of freedom.
The small-sample factor used (n-1)/(n-k_dummies) instead of
(n-1)/(n - k_dummies - Σ(G_k - 1)), so SEs were systematically
too small (~3–5% on a typical balanced panel; up to ~10% on
small/uneven designs). The fix passes extra_df = Σ(G_k - 1) —
matching reghdfe / fixest convention — through the new crve
parameter (see Added below). t-statistics and CIs reported by
sp.fast.event_study will now be slightly wider; users
re-running the same data should expect modest changes in the third
decimal of SE.
Added — sp.fast.feols: native OLS HDFE estimator¶
The linear sister of sp.fast.fepois. Pure-Python orchestration on
top of the Phase 1 Rust demean kernel + Phase 4 inference primitives;
independent of pyfixest. Public API mirrors sp.fast.fepois
(formula DSL, vcov, cluster, weights).
vcov∈{"iid", "hc1", "cr1"}. CR1 is FE-rank-aware via the sameextra_df = Σ(G_k - 1)convention used elsewhere in fast/*.- Weighted OLS path routes through the
_weighted_ap_demeanloop (matches pyfixest weighted feols to ~1e-12). - Coefficient parity vs R
fixest::feols: 4.2e-15 at n=1M / fe1=100k / fe2=1k (machine epsilon). Wall-time 135 ms vs R fixest 106 ms vs pyfixest 210 ms — i.e. 1.55× faster than pyfixest, 1.27× slower than fixest's mature C++. Seebenchmarks/hdfe/run_feols_bench.py. - Full
coef()/se()/vcov()/tidy()/summary()surface; drop-in compatible withsp.fast.etablefor side-by-side regression tables alongsidesp.fast.fepoisresults.
Added — Cluster-robust SE in sp.fast.fepois¶
sp.fast.fepois(vcov="cr1", cluster="<col>") now ships. Score uses
the weighted Poisson form obs_weights · (y - μ) · X̃ with the
WLS bread (X̃' diag(μ) X̃)^{-1}; small-sample factor charges
Σ(G_k - 1) via the new crve parameter. NaN cluster values raise.
Added — extra_df parameter on crve / boottest / boottest_wald¶
Backward-compatible extra_df: int = 0 parameter on all three CR1
callers. Default 0 reproduces the prior behaviour bit-for-bit; HDFE
callers should pass extra_df = Σ(G_k - 1) to get the FE-rank-aware
small-sample factor. Documented in each docstring; rejected if < 0.
Added — Bell-McCaffrey / Imbens-Kolesar Satterthwaite DOF¶
Two new helpers for small-G CR2 inference:
sp.fast.cluster_dof_bm(X, cluster, *, contrast, ...)— single 1-D contrast Satterthwaite DOF, formulaν = (Σ_g λ_g)² / Σ_g λ_g²withλ_g = ‖A_g · W_g · X_g · bread · c‖².sp.fast.cluster_dof_wald_bm(X, cluster, *, R, ...)— q-dim matrix Satterthwaite for joint Wald tests, formulaν_W = (Σ tr(E_g E_g'))² / Σ ‖E_g E_g'‖_F². q=1 collapses to the scalar form bit-for-bit.
Honest convention note in both docstrings: these implement BM 2002
§3 simplified, not clubSandwich's Pustejovsky-Tipton 2018 HTZ /
generalized form. The CR2 variance matches clubSandwich exactly;
the DOF differs by 5–10% on typical panels (1-D contrast) and can
differ 50–100% in the q-dim matrix Satterthwaite. For tightest
small-G inference prefer sp.fast.boottest / sp.fast.boottest_wald.
Changed — sp.fast.fe_interact rejects NaN¶
The 2-way fast path was silently producing collision-prone packed
codes when input columns contained NaN (pd.factorize's -1
sentinel leaking into c0 * n1 + c1). Now fail-fast, matching the
fail-fast convention of sp.fast.demean / sp.fast.fepois /
sp.fast.feols. K-way path also restructured to progressive packing
with periodic re-densification, so deeply-nested FE chains can't
overflow int64.
Changed — Registry walks sp.fast.*¶
sp.list_functions() / sp.describe_function() now surface every
public callable in the sp.fast.* namespace under a fast.<name>
key (e.g. fast.feols, fast.cluster_dof_bm). The top-level
pyfixest-backed sp.feols continues to coexist as a separate
registry entry — no name collision. +27 new registry entries on
the v1.8 stack become Agent-discoverable for the first time.
Documentation¶
src/statspai/fast/jax_backend.py— added a verified-blocked note for Apple Silicon (Metal).jax-metal 0.1.1(latest, Apple- maintained) is incompatible with JAX 0.10.0 at the StableHLO bytecode level; even basic ops fail. Verified empirically on M3. Workaround for users with jax-metal installed:JAX_PLATFORMS=cpu.benchmarks/hdfe/SUMMARY.md— added v1.8.1 follow-up section with OLS bench numbers and full delta vs Phase 8.
Tests¶
tests/test_fast_feols.py— 20 new tests (coef / SE parity vs pyfixest and R fixest; weighted; intercept-only; validation; hand closed-form for OLS).tests/test_fast_inference.py— +14 tests (extra_df backward-compat and direction proofs across crve/boottest/boottest_wald; BM and Wald BM DOF coverage).tests/test_fast_event_study.py— +2 tests (FE-rank pin via math identity; R fixest SE parity within 1%).tests/test_fast_fepois.py— +6 tests (cluster CR1 path + R fixest SE parity).tests/test_fast_within_dsl.py— +3 tests (fe_interactNaN rejection; 2-way no-collision; K-way matches pandas tuple path).tests/test_fast_etable.py— +2 tests (etable × FeolsResult; mixed feols + fepois side-by-side).tests/test_registry_new_modules.py— +25 tests (parametrisedfast.*registry coverage; namespace coexistence with top-level).
Total: pytest tests/test_fast_*.py tests/test_hdfe_native.py
tests/test_registry*.py tests/test_help.py — 267 passed,
2 graceful-skip (was 133 at end of Phase 8).
Phase 2: great_tables + CSL pipeline + paper auto-provenance¶
Layered on top of the export trinity below. No numerical changes to any estimator. Three additions, all opt-in, all stdlib + soft optional deps.
Added — sp.gt(result) great_tables adapter¶
Posit's great_tables is the Python port of R's gt — the
publication-oriented table grammar (cell-level styling, spanners,
footnote marks, themes, multi-target HTML/LaTeX/RTF output). The new
adapter dispatches on input type:
- :class:
RegtableResult→ full-fidelity adapter (title, notes, journal preset → gt theme viaopt_footnote_marksandtab_options(table_font_names=...)). - :class:
PaperTables→ flattens panels into row groups viaGT(groupname_col=...). - :class:
MeanComparisonResult→to_dataframe()round-trip. pandas.DataFrame→ wraps verbatim with optionalrowname_col.- Anything with
to_dataframe()→ duck-typed.
Soft dep — great_tables is not required to import StatsPAI.
sp.is_great_tables_available() reports the dep; calling
sp.gt(...) without it raises a friendly ImportError pointing
to pip install great_tables. All 8 journal presets (AER / QJE /
Econometrica / RestStat / JF / AEJA / JPE / RestUd) apply without
crashing.
Added — sp.csl_url() / sp.write_bib() CSL pipeline¶
Quarto needs a .bib and a .csl to render citations. StatsPAI
captures citations as free-form strings on each estimator's
.cite(); this layer bridges:
- CSL URL registry — short journal names (
"aer"/"econometrica"/"qje"/ etc.) → canonical Zotero/styles URLs.sp.csl_url('aer')returns the URL; we deliberately do not bundle.cslfiles (CC-BY-SA-3.0, incompatible with MIT). Userscurlonce at project setup. - Citation → BibTeX — best-effort regex parse of canonical
"Author Y (YEAR). Title. Journal." form into
@articleentries with stablefirstauthor + year + first-title-wordkeys. Falls back to@miscfor unparseable strings rather than dropping them. sp.write_bib(citations, path)— dedupes by computed key, writes a cleanpaper.bibQuarto can resolve.- Replication pack integration —
replication_packnow writes a real BibTeX file (paper/paper.bib) instead of a free-text dump. - Quarto short names —
draft.to_qmd(csl='aer')now resolves tocsl: "american-economic-association.csl"automatically; pre-existing.cslfilenames pass through untouched.
Added — sp.paper() auto-attaches provenance¶
sp.paper() now calls attach_provenance() on workflow.result
after the estimate stage with overwrite=False: estimators that
wire their own provenance at fit() keep their (more specific)
record; estimators that don't gain workflow-level provenance for
free. Downstream consequences:
replication_packnow picks up provenance from a plaindraft = sp.paper(...)workflow with no further work — itslineage.jsonbecomes non-empty automatically.draft.to_qmd()emits thestatspai:YAML block (version/run_id/data_hash) and theReproducibility {.appendix}body section automatically.
This is the aggregation-point pattern for provenance rollout:
v1.7.3+ instruments individual estimators (sp.feols,
sp.did.callaway_santanna, sp.iv.tsls, sp.rd.rdrobust,
sp.synth, …); the workflow-level hook here is the bridge.
Added — Public sp.* exports¶
gt, is_great_tables_available, csl_url, csl_filename,
list_csl_styles, parse_citation_to_bib, make_bib_key,
citations_to_bib_entries, write_bib.
Tests¶
46 new tests (20 gt adapter + 26 bibliography); 226 passing across the full new + adjacent surface. Fast/Rust HDFE territory still untouched — Phase 2 is fully orthogonal to the parallel work.
Export trinity: numerical lineage + replication pack + Quarto emitter¶
Pure-additive export-layer patch. No numerical changes to any estimator. Closes three concrete gaps between StatsPAI's export stack and the R / Posit publication tooling, and lays the foundation for the v1.7.2+ "agent-native paper" line.
Added — sp.replication_pack() (replication archive)¶
One-liner that bundles an analysis into the layout AEA / AEJ data editors expect:
draft = sp.paper(df, "effect of trained on wage")
sp.replication_pack(draft, "submission.zip",
code="analysis.py", paper_format="qmd")
Produces a zip with MANIFEST.json (versions, timestamp, git SHA,
per-file SHA-256), README.md (replication instructions), data/
(CSV + schema manifest), code/, env/requirements.txt (from
pip freeze, fallback importlib.metadata), paper/ (rendered
draft + paper.bib), and lineage.json (aggregated provenance from
any results carrying _provenance). Tolerant by design — every
sub-step that fails is logged in MANIFEST.json["warnings"] rather
than aborting the archive.
Added — sp.Provenance / sp.attach_provenance() (numerical lineage)¶
A small dataclass attached as result._provenance recording: function
name, summarised params, 12-char SHA-256 of the input frame, run uuid,
StatsPAI/Python versions, ISO-8601 timestamp. Hash is column-name +
dtype + value sensitive. Estimators opt in by calling
attach_provenance(result, function="sp.did.foo", params=..., data=df)
at the end of their fit; backwards-compatible — unrecorded estimators
still work, recorded ones gain free traceability into every downstream
artifact (replication_pack, the Quarto appendix, table footers).
Added — PaperDraft.to_qmd() + sp.paper(fmt='qmd') (Quarto emitter)¶
sp.paper() now produces a .qmd document directly:
draft = sp.paper(df, "effect of trained on wage", fmt="qmd")
draft.write("paper.qmd") # quarto render paper.qmd
YAML frontmatter auto-emits format: {pdf,html,docx},
bibliography: paper.bib when citations exist, optional csl: for
journal styles, and a statspai: block carrying version / run_id
/ data_hash for machine-readable provenance. When the underlying
workflow.result has a _provenance record, a Reproducibility
{.appendix} section is appended automatically. YAML escaping is
robust against quotes / colons / newlines in the question text.
Added — Public sp.* exports¶
Provenance, attach_provenance, get_provenance,
compute_data_hash, format_provenance, lineage_summary,
replication_pack, ReplicationPack. Registry entry for
replication_pack is full agent-native (params, returns, example,
tags, assumptions, failure modes, alternatives).
Tests¶
77 new tests (32 lineage + 18 replication pack + 27 Quarto), 232 passing across new + adjacent paper/registry/help suites. Fast/ Rust HDFE territory untouched — runs independently of this patch.
[1.7.1] — 2026-04-26 — fmt="auto" magnitude-adaptive formatting + unified book-tab xlsx style¶
Pure-additive output-layer patch on top of v1.7.0. No numerical changes to any estimator. Two themes — both close gaps that referees and AER/QJE production editors flag in practice:
sp.regtable(..., fmt="auto")(andsp.modelsummary(..., fmt="auto")) pick decimal precision per-cell so a single table can mix dollar-magnitude coefficients (1,521) with elasticity-magnitude coefficients (0.288) without one side rounding to bare0.- Every
*.xlsxwriter instatspai.outputnow emits the strict academic book-tab three-rule layout (top / mid / bottom) in Times New Roman, via a single new shared modulestatspai.output._excel_style.
Added — fmt="auto" magnitude-adaptive formatting (regtable, modelsummary)¶
sp.regtable(..., fmt="auto") (and sp.modelsummary(..., fmt="auto"))
now picks decimal precision per-value so a single table can mix
dollar-magnitude coefficients (e.g. 1,521) with elasticity-magnitude
coefficients (e.g. 0.288) without one side being rounded to zero.
Bucketing: |x| ≥ 1000 → comma-separated integer; ≥ 100 → integer;
≥ 10 → 1 decimal; ≥ 1 → 2 decimals; < 1 → 3 decimals.
Why this matters. Before this addition, passing a single fixed format
like fmt="%.0f" (sensible for a wage regression where coefficients are
in dollars) would silently round any 0.X-magnitude regressor (e.g.
lagged-earnings persistence in a wages model) to bare 0 while keeping
its significance stars — producing 0*** cells that read as nonsense.
fmt="auto" is the recommended setting for any specification with
mixed-magnitude regressors. The default remains fmt="%.3f" for
backwards compatibility.
Closes the gap with R modelsummary::fmt_significant() and Stata
esttab's %g-family format codes.
Changed — Unified book-tab three-line style across all xlsx exports¶
Every *.xlsx writer in :mod:statspai.output now emits the strict
academic book-tab convention (thick top rule above the column header,
thin mid-rule between header and body, thick bottom rule under the
last data row, Times New Roman throughout, no internal grid lines —
mirrors LaTeX booktabs \toprule / \midrule / \bottomrule
verbatim).
Affected entrypoints:
sp.regtable(...).save("xxx.xlsx")(RegtableResult.to_excel) — upgraded from a two-rule layout (heavy/heavy) to strict three-rule (heavy/thin/heavy).sp.mean_comparison(...).to_excel(...)— was previously a stylelesspandas.DataFrame.to_exceldump; now goes through the shared book-tab renderer.sp.sumstats(..., output="xxx.xlsx")— added Times New Roman, top/ mid/bottom rules, merged panel headers forby=MultiIndex columns. Also adds theby_labelsparameter and auto-maps binary 0/1 group keys toControl/Treatedso academic Table 1 reads correctly out of the box (previously rendered raw0/1as panel headers).sp.modelsummary(..., output="xxx.xlsx")— Calibri → Times New Roman, double-line bottom border → strict\bottomrule.sp.outreg2(..., filename="xxx.xlsx")— replaces the legacy Excel grid layout (four-edge per-cell borders) with the book-tab three-rule convention; drops Calibri for Times New Roman.sp.tab(..., output="xxx.xlsx")— was unstyled; now book-tab compliant, chi-square test row appended as italic note below the table.
sp.paper_tables(...).to_xlsx() and sp.collect(...).save("xxx.xlsx")
were already book-tab compliant via _aer_style.excel_booktab_borders
and are unchanged in this release.
Implementation note. The visual conventions live in a single new
module statspai.output._excel_style (Times constants, write_title
/ write_header / write_body / apply_booktab_borders / write_notes
/ autofit_columns / one-shot render_dataframe_to_xlsx). Future
xlsx writers should call these primitives instead of hand-rolling
borders so the book-tab look stays consistent across all of StatsPAI.
Why this matters. Before this change the xlsx layer was three-way
fractured — regtable / outreg2 shipped two-rule or grid
layouts, sumstats / tab / modelsummary had no rules at
all, and only paper_tables / collection matched the AER/QJE
book-tab standard. Authors copying lalonde_sumstats.xlsx straight
into a manuscript got a styleless dump. Every entrypoint now produces
output a referee would accept verbatim.
[1.7.0] — 2026-04-25 — Phase 2 output overhaul: journal presets, auto-diagnostics, multi-SE, sp.cite(), reproducibility footer¶
This release closes the remaining gaps between StatsPAI's table layer
and R::modelsummary / fixest::etable. Six additive features;
no numerical changes to any estimator. One backwards-compat note
(see "Behavior change" below) — pure OLS without clustering or FE
produces byte-identical output to v1.6.x.
Added — Journal presets via template= on regtable¶
sp.regtable(..., template="qje") now picks up the per-journal SE-row
label (e.g. QJE → "Robust standard errors"), default summary-stat
selection (JF/AEJA add Adj. R²; QJE drops R²), and footer notes from a
single source-of-truth registry at
statspai.output._journals.JOURNALS. Eight presets ship: aer, qje,
econometrica, restat, jf, aeja, jpe, restud. Adding a new
journal is one dict entry — regtable, paper_tables.TEMPLATES, and
the top-level sp.JOURNAL_PRESETS view all light up automatically.
Added — Auto-extracted diagnostic rows (diagnostics="auto" default)¶
regtable now reads model_info / diagnostics on each result and
auto-emits journal-quality rows above the summary-stats block:
- Fixed Effects: Yes/No when any column absorbs FE.
- Cluster SE:
<var>with the variable name when any column clusters. - First-stage F for IV models (Olea-Pflueger preferred, falls back to
per-endog F from
sp.IVRegression). - Hansen J p-value for over-identified IV.
- Pre-trend p-value, Treated groups for DiD methods.
- Bandwidth, Kernel, Polynomial order for RD.
Rows are emitted only when at least one column produces a non-empty
cell, and user-supplied add_rows={...} always overrides on label
collision. Pass diagnostics=False (or "off") to disable.
Added — Multi-SE side-by-side¶
sp.regtable(*models, multi_se={"Cluster SE": [...]}) stacks alternative
SE specs under the primary SE row. Bracket styles cycle [] / {} /
⟨⟩ / «» (the fourth pair is guillemets, not pipes — Markdown-safe).
Footer notes record each label automatically. Works across
text / HTML / LaTeX / Markdown / Excel / Word / DataFrame.
Added — sp.cite() inline coefficient reporter¶
sp.cite(result, "treat") returns "0.234*** (0.041)" for direct
embedding in manuscript prose, Jupyter Markdown cells, and Quarto inline
expressions. Mirrors regtable's star / SE / CI conventions for
cross-table consistency. Modes: output="text"|"latex"|"markdown"|"html",
second_row="se"|"t"|"p"|"ci"|"none". Markdown stars are escaped so
they do not collide with bold delimiters.
Added — Reproducibility metadata footer¶
sp.regtable(..., repro=True) appends Reproducibility: StatsPAI v1.X.Y;
2026-04-25 15:30 as the last footer line. Pass a dict to record more:
repro={"data": df, "seed": 42, "extra": "git@<sha>"} adds
data 50000×12 SHA256:abcd1234ef; seed=42; .... Hashing skips frames
over MAX_HASH_ROWS (1M rows) to keep the call fast.
⚠️ Behavior change — diagnostics="auto" default emits new rows¶
regtable previously rendered only the rows you typed via
add_rows={...}. With the new diagnostics="auto" default, tables for
clustered or fixed-effects models now include a Cluster SE: <var> /
Fixed Effects: Yes row that was previously absent. Pure OLS without
clustering or FE produces byte-identical output to v1.6.x. Workarounds:
- Pass
diagnostics=False(or"off") to restore the old behavior. - Override individual rows by passing
add_rows={"Cluster SE": [...]}.
This is the only behavior change in the release; no numerical paths are affected.
[1.6.6] — 2026-04-24¶
Two parallel sub-releases consolidated under one version: the
journal-grade output-layer overhaul (AER/QJE DOCX, paper_tables
docx/xlsx, sp.collect, regtable.alpha, Quarto cross-refs) and the
HDFE LSMR/LSQR solver paired with the ⚠️ Heckman two-step SE
correctness fix.
Output-layer overhaul: AER/QJE DOCX, paper_tables docx/xlsx, sp.collect, regtable.alpha, Quarto cross-refs¶
This release elevates the export layer to journal-grade output. Five additive changes; no breaking changes, no numerical changes to any estimator. Existing scripts continue to produce identical numbers.
Added — Quarto cross-reference output for sp.regtable¶
sp.regtable(..., quarto_label="main", quarto_caption="Wage equation")
now emits a Quarto-cross-referenceable Markdown table via
result.to_quarto() (or result.to_markdown(quarto=True)). The
canonical : <caption> {#tbl-<label>} block is appended so the
manuscript can reference the table with @tbl-<label>.
- The
tbl-prefix is auto-prepended when missing (quarto_label="main"→{#tbl-main}); already-prefixed labels are not double-prefixed. quarto_captionfalls back totitlewhen omitted; if both are absent a generic default is used and aUserWarningis emitted.output="quarto"/output="qmd"make__str__/print()/result.save("table.qmd")round-trip Quarto output end-to-end.- The leading bold-title line is suppressed in Quarto output to avoid duplicating the caption block.
This closes the last ergonomic gap between StatsPAI's export layer and modern reproducible-paper toolchains (Quarto is the de-facto successor to R Markdown for academic econ workflows).
Added — sp.regtable(..., alpha=...) now controls CI width¶
sp.regtable(..., se_type="ci", alpha=0.10) now displays 90% confidence
intervals (and labels them 90% CI); alpha=0.01 displays 99% CIs, etc.
Previously the alpha parameter was documented but ignored — the
displayed CI was always the model's stored 95% CI.
When alpha=0.05 (default) the bounds come from the result's stored
95% CI for backward-compat (typically t-based with model df). For any
other alpha the bounds are recomputed as b ± crit · se, using the
t-distribution when df_resid is known and the standard normal as a
fallback.
sp.esttab(..., ci=True, alpha=...) mirrors the same behaviour. Both
APIs raise ValueError for alpha outside (0, 1).
Added — AER/QJE book-tab DOCX styling¶
sp.regtable(...).to_word(...), sp.sumstats(..., output="*.docx"),
sp.tab(..., output="*.docx") and sp.mean_comparison(...).to_word(...)
now render in book-tab style matching AER / QJE / Econometrica
conventions:
- heavy top rule above column headers (
sz=12) - thin mid rule below the header (
sz=4) - heavy bottom rule above the notes (
sz=12) - no internal vertical or horizontal borders
- Times New Roman, header bold, notes italic 8pt
The shared helper lives in src/statspai/output/_aer_style.py.
Previous DOCX output used the boxed Table Grid style.
Added — sp.paper_tables(...) DOCX / XLSX export¶
sp.PaperTables gains .to_docx(path) and .to_xlsx(path) methods,
and the sp.paper_tables(...) constructor accepts docx_filename= and
xlsx_filename= kwargs. Multi-panel paper bundles now go to a single
Word document (one panel per page, book-tab styled) or a single
workbook (one sheet per panel) in addition to the existing Markdown
and LaTeX outputs.
Added — sp.collect() / sp.Collection session-level container¶
A new container mirroring Stata 15's collect and R's gt::gtsave
workflow — gather any number of regressions, descriptive statistics,
balance tables, and free-form text in one container, then export the
whole bundle to a single .docx / .xlsx / .tex / .md / .html
file.
import statspai as sp
c = sp.collect("Wage analysis", template="aer")
c.add_regression(m1, m2, m3, name="main", title="Table 1: Wage equation")
c.add_summary(df, vars=["wage", "educ"], name="desc")
c.add_balance(df, treatment="treat", variables=["age", "female"], name="bal")
c.add_text("Standard errors clustered at firm level.")
c.save("appendix.docx") # single Word doc, AER book-tab style
c.save("appendix.xlsx") # single workbook, one sheet per item
Collection exposes fluent add_regression / add_table / add_summary /
add_balance / add_text / add_heading (each returns self), plus
list() / get(name) / remove(name) / clear() for inspection. The
public factory sp.collect() is registered with the StatsPAI registry
and visible via sp.help("collect").
Tests¶
tests/test_regtable_alpha.py(6 tests) —alphacontrols CI label and width;esttabparity; recompute matchesscipy.stats.t.ppfby hand.tests/test_aer_word_style.py(6 tests) — OOXML reverse-checks the three rules, asserts no inner vertical borders, italic notes.tests/test_paper_tables_export.py(5 tests) — multi-panel docx / xlsx round-trip with book-tab borders.tests/test_collection.py(18 tests) — construction, chained adds, duplicate-name guard, all five export formats, registry presence.
Files changed¶
src/statspai/output/estimates.py—_ModelDatagainsdf_residslot;_ci_bounds(model, var, alpha)helper;EstimateTable/esttabacceptalpha.src/statspai/output/regression_table.py—RegtableResultaccepts and usesalpha;to_wordrewritten to use_aer_stylehelpers;MeanComparisonResult.to_wordlikewise.src/statspai/output/sumstats.py—_sumstats_to_worduses_aer_style.src/statspai/output/tab.py—_tab_to_worduses_aer_style.src/statspai/output/paper_tables.py—PaperTables.to_docx/to_xlsxadded;paper_tables()acceptsdocx_filename=/xlsx_filename=.src/statspai/output/_aer_style.py— new, OOXML border manipulation + book-tab typography helpers.src/statspai/output/collection.py— new,Collectionclasscollect()factory.src/statspai/output/__init__.py— exportCollection,CollectionItem,collect.src/statspai/__init__.py— exportCollection,CollectionItem,collect; add to public__all__.src/statspai/registry.py— registercollectundercategory="output".
2026-04-24 — HDFE LSMR/LSQR solver + ⚠️ Heckman SE correctness fix¶
⚠️ Correctness fix — sp.heckman two-step standard errors¶
Affected: sp.heckman(...) — the Heckman (1979) two-step selection
model. Point estimates are unchanged; standard errors, t-statistics,
p-values and confidence intervals change, and model_info['sigma'] /
model_info['rho'] now use the correct Greene (2003) formula.
What was wrong. Before v1.6.6, sp.heckman reported an ad-hoc
HC1-style sandwich that (a) ignored the selection-induced
heteroskedasticity Var(y | X, D=1) = σ²(1 − ρ² δ_i), and (b) treated
the inverse Mills ratio λ̂ as a known regressor, ignoring the
first-stage probit estimation error in γ̂ — the "generated regressor"
problem. The code itself flagged this as
"Heckman SEs are complex; robust is conservative". It was a known
limitation, not a false belief; this release upgrades it from
approximate-conservative to textbook-correct.
The fix. sp.heckman now computes the Heckman (1979) / Greene
(2003, eq. 22-22) / Wooldridge (2010, §19.6) analytical variance:
where δ_i = λ̂_i (λ̂_i + Z_iγ̂) ≥ 0, D_δ = diag(δ_i),
F = X*' D_δ Z, and V̂_γ = (Z' diag(w_i) Z)⁻¹ is the probit
information-based variance of γ̂. Consistent σ̂² is
σ̂² = RSS/n_sel + β̂_λ² · mean(δ_i) (Greene 22-21), replacing the
old naive RSS/(n−k). The probit IRLS helper _probit_fit now also
returns V̂_γ for consumption by the second-stage SE computation.
What you'll see. Heckman SEs will generally be smaller than
before when selection is strong (the heteroskedastic factor
1 − ρ² δ_i ≤ 1 trims the structural-error contribution) and
larger when the exclusion restriction is weak (generated-regressor
uncertainty dominates). Match is to Stata's heckman ..., twostep
output and R's sampleSelection::heckit to the documented formula
precision.
Added — test coverage (Heckman)¶
tests/reference_parity/test_heckman_se_parity.py: three tests pinning β̂ and SE to a hand-computed implementation of the Greene (2003) formula, plus a check thatmodel_info['sigma']/rhoexpose the consistent σ̂² estimator.
Fixed¶
src/statspai/regression/heckman.py::heckman— replace naive HC1 sandwich with the Heckman (1979) two-step analytical variance.src/statspai/regression/heckman.py::_probit_fit— now returns(γ̂, V̂_γ); avoids allocating an n×ndiag(w)via broadcasting.
Added — HDFE LSMR/LSQR solver option (additive, pyreghdfe parity)¶
sp.hdfe_ols/sp.absorb_ols/sp.Absorber/sp.demeannow acceptsolver={"map", "lsmr", "lsqr"}(default"map", unchanged)."lsmr"/"lsqr"build a sparse FE design matrix and delegate the within-projection toscipy.sparse.linalg.lsmr/lsqr. Weighted regression uses the standard √w transformation applied to both the sparse design and the response. No new runtime dependency — scipy is already core.- Covers the feature surface of
pyreghdfe: multi-way FE OLS, robust / multi-way cluster SE, singleton drop, weights, Krylov solvers.pyreghdfecan now be archived withsp.hdfe_olsas a strict replacement (seeMIGRATION.md). - New cross-solver parity tests in
tests/test_hdfe_native.pyverify MAP ≡ LSMR ≡ LSQR toatol=1e-6on two-way FE OLS (with and without weights, with and without clustering). MIGRATION.mdgained a "Migrating frompyreghdfe" section with full API mapping.
Behavior¶
- HDFE default solver remains
"map"— all HDFE numerical output (MAP path) is byte-identical to v1.6.5.
[1.6.5] — 2026-04-24 — ⚠️ Standalone LIML correctness fix (follow-up to v1.6.4)¶
⚠️ Correctness fix — standalone sp.liml / sp.iv.liml¶
Affected: the standalone LIML entry point
sp.liml(...) = sp.iv.liml(...) in statspai.regression.advanced_iv.
This is a separate code path from the 2SLS/LIML/Fuller dispatcher
fixed in v1.6.4 (sp.ivreg(method='liml') went through the correct
_k_class_fit implementation and was fixed in the previous release;
the standalone sp.liml did not).
Not affected: sp.ivreg, sp.iv.iv, sp.iv.fit,
sp.ivreg(method='liml' | 'fuller' | '2sls') — all already correct
as of v1.6.4.
What was wrong. Two independent bugs in the standalone LIML:
- κ (Anderson LIML eigenvalue) used non-symmetric solver: the code
called
np.linalg.eigvals(np.linalg.inv(A) @ B)on a non-symmetric product, which can silently return complex eigenvalues and produces a biased κ. This is the same bug fixed iniv.py::_liml_kappain an earlier release, but the standalone module was an orphan copy that never got the fix. Point estimates β̂ were biased as a result. - Cluster / robust SE meat used raw X: same bug as v1.6.4, just in
a different module. Sandwich meat is now built from the k-class
transformed regressor
AX = (I − κ M_Z) X.
The fix.
- κ now computed via
scipy.linalg.eigh(S_exog, S_full)(generalized symmetric eigenvalue problem), aligned withiv.py::_liml_kappa. Falls back to 2SLS (κ = 1) with a warning if the solver returns an implausible κ < 1. - Cluster / robust SE meat now uses
AX = I_kMz @ X_all, matching the FOCX' (I − κ M_Z) (y − X β) = 0.
What you'll see. Users who called sp.liml(...) directly will see
both β̂ and SE change compared to ≤ v1.6.4. After the fix,
sp.liml(...) and sp.ivreg(..., method='liml') produce byte-identical
output, and both agree with linearmodels.IVLIML on β̂ to machine
precision. Cluster SEs differ from linearmodels.IVLIML by ~0.1–0.2%
due to a convention choice (StatsPAI uses the k-class FOC-derived
meat AX; linearmodels uses the 2SLS-style meat X̂ = P_Z X
regardless of κ). The two are asymptotically equivalent and coincide
at κ = 1 (2SLS).
Added — test coverage¶
tests/reference_parity/test_liml_se_parity.py: four tests — hand-computed projected-meat formula match,sp.limlvssp.ivreg(method='liml')internal consistency (byte-exact), andlinearmodels.IVLIMLparity with documented convention tolerance.
Fixed¶
src/statspai/regression/advanced_iv.py::liml— κ solver aligned toscipy.linalg.eighon the symmetric generalized eigenvalue problem; cluster / robust meat now uses projectedAX = I_kMz @ X_all.
[1.6.4] — 2026-04-24 — ⚠️ IV SE correctness fix¶
⚠️ Correctness fix — IV cluster & robust standard errors¶
Affected: sp.iv, sp.ivreg, sp.iv.fit(method='2sls' | 'liml' | 'fuller')
— any call that passes robust={'hc0','hc1','hc2','hc3'} or cluster=.
Not affected: point estimates β̂ are unchanged; nonrobust (default)
standard errors are unchanged; GMM (method='gmm'), JIVE (method='jive'),
and the JIVE variants (ujive/ijive/rjive) are unchanged (they already
used the correct formula).
What was wrong. The 2SLS / LIML / Fuller k-class sandwich meat was
computed with the unprojected regressor matrix X = [X_exog, X_endog]
instead of the projected X̂ = P_W X. The bread
(X' A X)^{-1} = (X̂' X̂)^{-1} was correct; the bug was in
src/statspai/regression/iv.py::_cluster_cov / ::_robust_cov call
sites which passed X_actual where the parameter (already misleadingly
named X_hat) expected the projected regressor.
This deviated from Cameron & Miller (2015), Stata ivregress,
ivreg2 (Baum–Schaffer–Stillman 2007), and linearmodels. The
magnitude of the error depends on first-stage fit: weaker instruments
→ larger inflation of the reported SE. On the audit DGP (n=1000,
40 clusters, moderate first stage) the reported SE on the endogenous
coefficient was 2.46× too large.
The fix. _k_class_fit now computes AX = A @ X_actual and passes
it to _cluster_cov / _robust_cov. For 2SLS (κ=1) this yields
AX = P_W X = X̂; for LIML/Fuller it is the k-class transformed
regressor X − κ M_W X that the k-class FOC X' A (y − X β) = 0
dictates. Matches linearmodels IV2SLS with debiased=True to
machine precision.
What you'll see. Reported SEs for cluster / HC0 / HC1 / HC2 / HC3 under 2SLS / LIML / Fuller will decrease (or occasionally increase) compared to v1.6.3 and earlier. t-statistics, p-values, and confidence intervals will change accordingly. If you cite SEs from StatsPAI IV in a paper, re-run and update the numbers before submission.
Added — test coverage¶
tests/reference_parity/test_iv_se_parity.py: six tests pinning 2SLS cluster / HC0 / HC1 to both a hand-computed projected-meat formula (Cameron–Miller) and tolinearmodels.IV2SLSwithdebiased=True. Closes the coverage gap that let this bug live in_cluster_cov/_robust_covsince the module's introduction.
Fixed¶
src/statspai/regression/iv.py::_k_class_fit— pass projectedAX = A @ X_actualto the sandwich meat.
[1.6.3] — 2026-04-24 — DiD frontier sprint¶
Additive release focused on closing gaps in the DiD module. No numerical changes to existing estimators — all new work is either new functions, new registry entries, new tests, or docstring truth-up where the existing docstring had overstated paper fidelity.
Added — new DiD estimators¶
sp.lp_did— Local-Projections DiD (Dube, Girardi, Jordà & Taylor 2023). Per-horizon long-difference OLS with time FE and cluster-robust SE; 'not-yet-treated' or 'never-treated' control variants. Paper bib key pending — reference carries[待核验].sp.ddd_heterogeneous— Heterogeneity-robust triple differences (Olden & Møen 2022 / Strezhnev 2023). CS-style cohort-time decomposition of DDD with a placebo subgroup, aggregated via switcher-count weights.[@olden2022triple]verified via Crossref; Strezhnev bib key pending.sp.did_timevarying_covariates— DiD with covariates frozen at baseline (Caetano, Callaway, Payne & Rodrigues 2022 — paper version[待核验]). Avoids the bad-controls bias when treatment affects the covariates. Per-(g, t) OR-DiD on frozen baseline X, aggregated with cohort-size weights.sp.did_multiplegt_dyn— dCDH (2024) intertemporal event-study DiD MVP. Long-difference per-horizon estimator with not-yet- treated / never-treated controls, cluster-bootstrap SE, joint placebo and overall Wald tests. Anchored to[@dechaisemartin2024difference](DOI verified). Not paper- parity — switch-off events and analytical IF variance are flagged[待核验], covered indocs/rfc/multiplegt_dyn.md.sp.continuous_did(method='cgs')— Callaway-Goodman-Bacon- Sant'Anna (2024) ATT(d)/ACRT(d) MVP. 2-period design, OR only, Nadaraya-Watson-style local linear smoother over dose, bootstrap SE. Anchored to[@callaway2024difference]. Full CGS cohort aggregation + DR/IPW + analytical IF are flagged[待核验]and tracked indocs/rfc/continuous_did_cgs.md.
Added — shared infrastructure¶
statspai.did._core— shared DiD primitives: cluster-bootstrap resampling with collision-safe relabelling, canonical event-study DataFrame shape, influence-function → SE plumbing, joint Wald. Used by the new estimators above; existing estimators retain their in-file copies (refactor is a separate pass). 16 unit tests.
Added — docstring truth-up (non-numerical)¶
sp.continuous_diddocstring no longer claims "equivalent to the methods in Callaway, Goodman-Bacon & Sant'Anna (2024)". The heuristic modes ('twfe','att_gt','dose_response') are explicitly labelled as heuristic; the CGS MVP lives atmethod='cgs'. Method label in returned CausalResult for the dose-bin heuristic changed from "Continuous DID (Callaway et al. 2024)" to "Continuous DID (dose-bin heuristic)" with estimand name updated accordingly.sp.did_multiplegtdocstring explicitly notes itsdynamic=Hargument is a pair-rollup extension, not equivalent to the dCDH (2024)did_multiplegt_dynestimator (which is now a separate function,sp.did_multiplegt_dyn).
Added — test coverage¶
tests/test_continuous_did_heuristics.py— 11 tests coveringmethod='att_gt'andmethod='dose_response'paths that previously had zero dedicated tests.tests/test_did_core_primitives.py— 16 unit tests for_core.py.tests/test_lp_did.py— 9 tests forsp.lp_did.tests/test_ddd_heterogeneous.py— 7 tests forsp.ddd_heterogeneous.tests/test_did_timevarying_covariates.py— 6 tests.tests/test_did_multiplegt_dyn.py— 10 tests including method-label MVP warning.tests/test_continuous_did_cgs.py— 8 tests including recovery on linear dose-response DGP.tests/reference_parity/test_did_multiplegt_parity.py— skeleton with R fixture script template; skipped untiltests/reference_parity/fixtures/did_multiplegt/*.jsoncommitted.
Added — registry¶
Rich hand-written FunctionSpec entries with agent-card metadata
(assumptions, failure modes with alternative pointers, pre-conditions,
typical_n_min) for 18 previously auto-registered DiD estimators:
did_2x2, drdid, sun_abraham, did_imputation, wooldridge_did,
etwfe, bacon_decomposition, ddd, cic, stacked_did,
event_study, did_analysis, harvest_did, overlap_weighted_did,
cohort_anchored_event_study, design_robust_event_study,
did_misclassified, did_bcf, plus rich entries for the five new
functions above. One fabricated bib key (roth2023trustworthy)
detected and removed during self-review; replaced with [待核验].
Added — documentation¶
docs/guides/choosing_did_estimator.md§4.5 Frontier estimators section distinguishes shipped vs. partial vs. not-yet-landed work and cross-links all three RFC documents. Makes explicit thatsp.did_multiplegt(dynamic=H)is not the dCDH (2024)_dynestimator.
Fixed — citation hygiene¶
paper.bib:dechaisemartin2022fixedupgraded from the SSRN working-paper stub to the published Econometrics Journal 26(3): C1–C30 (2023) version, DOI10.1093/ectj/utac017. Verified via two independent Crossref queries per CLAUDE.md §10 two-source rule.
[1.6.2] — 2026-04-23 — DiD-frontier registry coverage¶
Patch release. Pure-additive: no numerical behaviour changes. Closes a
registry-coverage gap for two already-shipping DiD estimators that were
callable but invisible to sp.list_functions() / sp.describe_function() /
agent discovery (CLAUDE.md §4). Adds the supporting RFC design documents
under docs/rfc/ so the registry reference / remedy pointers resolve.
Added — registry & agent discoverability¶
sp.continuous_didis now registered. DiD with continuous treatment intensity, exposing three modes: (i) TWFE with dose×post interaction, (ii) dose-quantile group-time ATT vs. the untreated (dose=0) arm with bootstrap SE, (iii) local-linear dose-response of ΔY on baseline dose. Callaway, Goodman-Bacon & Sant'Anna (2024) analytical influence-function inference is on the v1.7 roadmap — seedocs/rfc/continuous_did_cgs.md.sp.did_multiplegtis now registered. de Chaisemartin & D'Haultfœuille (2020) DID_M estimator for treatments that switch on and off (unlike Callaway–Sant'Anna which assumes staggered adoption). Supports placebo lags, dynamic horizons, joint placebo Wald test, and cluster-bootstrap SE. The full dCDH (2024) intertemporal event-study (Statadid_multiplegt_dyn) is on the v1.7 roadmap — seedocs/rfc/multiplegt_dyn.md.docs/rfc/— RFC directory for not-yet-landed design docs. Ships withcontinuous_did_cgs.md,multiplegt_dyn.md,did_roadmap_gap_audit.md, plusREADME.mdand a sprint-prep handoff note (HANDOFF_2026-04-23.md).
Changed¶
- None. No estimator output changes. Existing
sp.continuous_did/sp.did_multiplegtcallers observe identical numerical behaviour.
Fixed¶
- None.
Notes for agents¶
- Both estimators now surface in
sp.list_functions()and expose fullParamSpec/FailureMode/alternativesmetadata throughsp.describe_function(). Registered count rises from 923 to 925.
[1.6.1] — 2026-04-23 — CI/CD green-up¶
Patch release. No user-facing behavior or numerical change — all three
fixes target CI matrix reliability. The hashlib.md5 change is
digest-byte-identical to v1.6.0 (verified by assert); embed_texts /
sp.text_treatment_effect outputs are bit-for-bit unchanged.
Fixed — CI/CD green-up¶
- Bandit security gate —
src/statspai/causal_text/_common.pyhashing call now passesusedforsecurity=Falsetohashlib.md5. The digest is used as a deterministic bucket index for hashed-token embeddings, not a security primitive; the flag tells Bandit B324 (CWE-327) that weak-hash use is intentional. Digest bytes are identical to the prior call — no numerical change toembed_textsorsp.text_treatment_effect. - Windows path-separator parity —
tools/audit_bib_coverage.py::_relnow emits POSIX-style paths viaPath.as_posix(), so thecitations_by_keyreport is identical across Windows and POSIX runners. Fixestest_build_report_records_citation_locationsonwindows-latest. - Windows CLI subprocess —
tests/test_suggest_bibkey_backfills.pynow merges the child-process environment withos.environ(soPATHsurvives) before invoking the tool. WindowsCreateProcesshas no_CS_PATHfallback like POSIXexecvpe, so an empty-env child cannot resolvegit.exe. Fixestest_cli_dry_run_does_not_mutateonwindows-latest.
[1.6.0] — 2026-04-21 — P1 Agent-Native × Frontier + Agent-Native Infrastructure¶
Pure-additive release pushing two competitive axes:
- Agent-native — closed-loop LLM-DAG, end-to-end
sp.paper()pipeline, full registry/agent-card metadata for every new function, typed exception taxonomy (StatsPAIError+ 6 subclasses) withrecovery_hint/diagnostics/alternative_functionspayloads, result-object.violations()/.to_agent_summary()methods, and auto-generated## For Agentsblocks in every flagship guide. - Methodological frontier — five post-2020 Mendelian-randomization
estimators (
mr_lap,mr_clust,grapple,mr_cml,mr_raps), long-panel Double-ML (sp.dml_panel), constrained LLM-assisted PC discovery, and twocausal_textMVPs (text-as-treatment, LLM-annotator measurement-error correction).
Together: one new top-level pipeline (sp.paper), four new LLM-aware
dag/text estimators (sp.llm_dag_constrained, sp.llm_dag_validate,
sp.text_treatment_effect, sp.llm_annotator_correct), constrained
PC discovery (sp.pc_algorithm(forbidden=, required=)), five MR
frontier estimators (sp.mr_lap etc.), one long-panel DML estimator
(sp.dml_panel), 36 populated agent cards (was 0 pre-v1.5.1), and 26
## For Agents blocks across 19 guides.
Added — P1-A: closed-loop LLM-assisted causal discovery¶
sp.llm_dag_constrained— iterate propose → constrained PC → CI-test validate → demote until convergence ormax_iter. Returns per-edgellm_score+ci_pvalue+source(required/forbidden/demoted/ci-test) so every kept edge is justified by both the LLM prior and the data.result.to_dag()round-trips intostatspai.dag.DAGfor downstreamrecommend_estimator().sp.llm_dag_validate— per-edge CI-test audit of any declared DAG; flags edges whose implied conditional independence is consistent with the data (i.e. the edge looks spurious).sp.pc_algorithm(forbidden=, required=)— background-knowledge constraints injected into PC. DefaultNonepreserves the prior contract bit-for-bit. Required edges win over forbidden when both reference the same pair.- 18 new tests (
tests/test_llm_dag_loop.py). - Family guide:
docs/guides/llm_dag_family.md.
Added — P1-C: data → publication-draft pipeline¶
sp.paper(data, question, ...)— orchestrator on top ofsp.causal()that parses a natural-language question, runs the fulldiagnose → recommend → estimate → robustnesspipeline, and assembles a 7-sectionPaperDraft(Question / Data / Identification / Estimator / Results / Robustness / References).PaperDraftwithto_markdown()/to_tex()/to_docx()/write(path)/to_dict()/summary()and aparsed_hintsattribute exposing what the question parser extracted.- Lightweight question parser (
statspai.workflow.paper.parse_question) recognises "effect of X on Y", "Y ~ X", DiD / RD / IV / RCT design hints, "instrument(ing)Z", "discontinuity atc", "running variableX". Explicit kwargs always win. - Per-section failure isolation: a failed estimator stage yields a "Pipeline notes" section rather than crashing the draft.
- 27 new tests (
tests/test_paper_pipeline.py). - Family guide:
docs/guides/paper_pipeline.md.
Added — P1-B: sp.causal_text (experimental MVP)¶
sp.text_treatment_effect— Veitch-Wang-Blei (2020 UAI, MVP) text-as-treatment ATE via embedding-projected OLS with HC1 SEs. Hash embedder default (deterministic, dependency-free); lazysbertoptional viapip install sentence-transformers; custom callable embedder also supported.sp.llm_annotator_correct— Egami-Hinck-Stewart-Wei (2024) measurement-error correction for binary LLM-derived treatments. Hausman-style: estimatep_01/p_10on a hand-validated subset (≥30 rows spanning both classes), divide naive coefficient by1 - p_01 - p_10. First-order SE correction; raisesIdentificationFailurewhen the LLM has no information.- Both methods subclass
CausalResult, surfacestatus: "experimental"inresult.diagnostics, and ship full agent-card metadata (assumptions/pre_conditions/failure_modes/alternatives). - 20 new tests (
tests/test_causal_text.py). - Family guide:
docs/guides/causal_text_family.md.
Added — MR Frontier (src/statspai/mendelian/frontier.py)¶
sp.mr_lap— Sample-overlap-corrected IVW (Burgess, Davies & Thompson 2016 closed-form bias correction; conceptually aligned with the Mounier-Kutalik 2023 MR-Lap). Required inputs:overlap_fractionandoverlap_rho(e.g. from LD-score regression).overlap=0exactly reproduces naive IVW.sp.mr_clust— Clustered Mendelian randomization via finite Gaussian mixture on Wald ratios (Foley, Mason, Kirk & Burgess 2021). EM with SNP-specific measurement SE, optional null cluster at θ=0, BIC-selected K. Returns per-cluster estimates, SNP-to-cluster responsibilities, and the BIC path.sp.grapple— Profile-likelihood MR with joint weak-instrument and balanced-pleiotropy robustness (Wang, Zhao, Bowden, Hemani et al. 2021, single-exposure variant). Jointly MLE over causal β and pleiotropy variance τ² via L-BFGS-B; SE from observed Fisher info.sp.mr_cml— Constrained maximum-likelihood MR with L0-sparse pleiotropy, MR-cML-BIC variant (Xue, Shen & Pan 2021). Block- coordinate descent jointly updates causal β, true exposure effects, and a K-sparse pleiotropy vector; K selected by BIC.sp.mr_raps— Robust Adjusted Profile Score (Zhao, Wang, Hemani, Bowden & Small 2020, Annals of Statistics 48(3)). Profile-likelihood MR with Tukey biweight loss + log-variance adjustment; same structural model as GRAPPLE but resistant to gross pleiotropy outliers. Sandwich SE from M-estimator formula.
Added — v1.7 long-panel DML (src/statspai/dml/panel_dml.py)¶
sp.dml_panel— Long-panel Double/Debiased ML for static panel models with fixed effects (Clarke & Polselli 2025 simplified). Absorbs unit (and optional time) fixed effects via within-transform, cross-fits ML nuisance learners with folds that split units (Liang-Zeger compatible), reports cluster-robust SE at the unit level. PLR moment for continuous or binary treatment; empty-covariate fallback reduces to pure FE-OLS. (Citation corrected post-v1.15: the original v1.7 release note attributed this estimator to a "Semenova-Chernozhukov 2023 Econometrics Journal 26(2)" paper that does not exist; the actual reference is Clarke & Polselli (2025) ECTJ 29(1) 69-86, DOI 10.1093/ectj/utaf011, arXiv:2312.08174. See [Unreleased] for the full audit.)
Added — dispatcher + registry wiring¶
sp.mr(method=...)routesmr_lap | lap | sample_overlap,mr_clust | clust | clustered,grapple | profile_likelihood,mr_cml | cml | constrained_ml,mr_raps | raps | robust_profile_scoreto the new estimators.- All six new functions (5 MR +
dml_panel) registered inregistry.pywith fullParamSpecmetadata, category, tags, and reference.sp.describe_function,sp.function_schema, andsp.agent_cardcover them.
Added — tests¶
tests/test_mr_frontier.py— 41 tests covering correctness, boundary validation, cross-method consistency (mr_lapwithoverlap=0== IVW;mr_cmlwithK=0≈ IVW;mr_clusttwo-cluster DGP;mr_rapsoutlier-robustness vs IVW), dispatcher routing, and registry/schema export.tests/test_dml_panel.py— 13 tests covering recovery under homogeneous treatment, FE-OLS agreement in the no-confounding limit, cluster-SE vs iid SE under AR(1) within-unit correlation, time-FE option, boundary validation, and registry metadata.
Deferred (originally scoped for v1.6)¶
- CAUSE (Morrison et al. 2020) — the full variational-Bayes
implementation is ~5000 LOC in the R reference and cannot be
reference-parity validated in-cycle. Replaced with
mr_cml(same use-case: robust to correlated and uncorrelated pleiotropy). CAUSE will land in a later release once reference-parity infrastructure is in place.
Agent-native infrastructure (foundation for v1.6.0)¶
Every layer now speaks in structured data with recovery hints, not prose — this is the foundation the P1 frontier estimators above build on.
Added — agent-native exception taxonomy (statspai.exceptions)¶
StatsPAIErrorroot +AssumptionViolation/IdentificationFailure/DataInsufficient/ConvergenceFailure/NumericalInstability/MethodIncompatibility, each carryingrecovery_hint, machine-readablediagnostics, and a rankedalternative_functionslist.- Warning counterparts:
StatsPAIWarning/ConvergenceWarning/AssumptionWarningplus a rich-payloadsp.exceptions.warn()helper. - Domain errors subclass
ValueError/RuntimeErrorfor backwards compatibility with existingexceptblocks. No estimator behavior changes — migration of existingValueError/RuntimeErrorcall sites will follow incrementally.
Added — agent-native registry schema¶
FunctionSpecextended withassumptions/pre_conditions/failure_modes/alternatives/typical_n_min(all optional).- New
FailureModedataclass:(symptom, exception, remedy, alternative). - New public accessors
sp.agent_card(name)andsp.agent_cards(category=None)returning the superset offunction_schema()plus the agent-native fields. - Flagship families populated:
sp.regress,sp.iv,sp.did,sp.callaway_santanna,sp.rdrobust,sp.synth(was previously auto-registered only).
Added — agent-native methods on result objects¶
CausalResult.violations()andEconometricResults.violations()— inspect stored diagnostics (pre-trend p-value, first-stage F, McCrary, rhat/ESS/divergences, overlap, SMD) and return flagged items withseverity/recovery_hint/alternatives.CausalResult.to_agent_summary()andEconometricResults.to_agent_summary()— JSON-ready structured payload with point estimate, coefficients, scalar diagnostics, violations, and next-steps. Sits alongside existingsummary()(prose) andtidy()(DataFrame).
Added — guide ## For Agents sections¶
- Auto-rendered from registry cards via
sp.render_agent_block(name)andsp.render_agent_blocks(category=…, names=…). scripts/sync_agent_blocks.pyregenerates in-place between<!-- AGENT-BLOCK-START: <name> --> … <!-- AGENT-BLOCK-END -->markers;--checkexits non-zero on drift (CI-friendly).- Wired into four flagship guides so far:
choosing_did_estimator.md(did + callaway_santanna),choosing_iv_estimator.md(iv),choosing_rd_estimator.md(rdrobust),synth.md(synth). - Test guard
tests/test_agent_blocks_drift.pyfails CI if a doc falls out of sync with the registry.
Tests — agent-native infrastructure¶
tests/test_exceptions.py— hierarchy, payload, raise/catch,warn()helper, top-level exposure.tests/test_agent_schema.py— schema mechanics,agent_card/agent_cardsAPIs,FailureMode, parametrized flagship population.tests/test_agent_result_methods.py—violations()/to_agent_summary()on both result classes, JSON round-trip.tests/test_agent_docs.py— renderer output, pipe escaping, empty / non-empty cases.tests/test_agent_blocks_drift.py— CI guard for doc/registry sync.
Added — agent-native follow-up sprint¶
- Eight more flagship agent cards populated:
sp.dml,sp.causal_forest,sp.metalearner,sp.match,sp.tmle,sp.bayes_dml(extended),sp.bayes_did(new hand-register),sp.bayes_iv(new hand-register). Each carries pre-conditions, identifying assumptions, 3–4 failure modes with recovery hints, ranked alternatives, and a typical minimum-N rule of thumb. - Seven more guide AGENT-BLOCKs (13 total across 11 guides now):
choosing_matching_estimator.md(match),callaway_santanna.md/cs_report.md/mixtape_ch09_did.md(callaway_santanna),honest_did.md/repeated_cross_sections.md(did),synth_experimental.md(synth). sp.recommendnow consumes agent cards: every recommendation getsagent_card/pre_conditions/failure_modes/alternatives/typical_n_minfields merged in from the registry. Whenn_obs < typical_n_min, a dedicated warning lands in the top-levelwarningslist pointing tosp.agent_card(name). Hand-codedassumptions/reason/codeare never overwritten — only empty fields are promoted from the card.- First call-site migrations to the typed taxonomy, with
recovery_hint+diagnostics+alternative_functionsattached: sp.did_2x2treat/time cardinality →MethodIncompatibilitysp.did_analysis(method='cs'/'sa')missingid→MethodIncompatibilitysp.misclassified_didno cohorts / no never-treated →DataInsufficient- IV under-identification (all 3 k-class paths) →
MethodIncompatibility - IV singular k-class matrix →
NumericalInstability sp.bayes_dmlnon-positive DML SE →NumericalInstability- Latent registry bug fixed —
_build_registry()usedif _REGISTRY: returnas its idempotence gate, which silently skipped hand-written specs whenever any caller ranregister()first (e.g. test fixtures). Replaced with a dedicated_BASE_REGISTRY_BUILTsentinel so flagship agent-native fields survive arbitrary registration order. - New tests:
tests/test_recommend_agent_cards.py(5 tests),tests/test_exception_migrations.py(7 tests). All existing registry / help / DID / IV / synth / matching / DML / meta-learner / Bayesian-DID / TMLE / causal-forest / agent-native suites continue to pass.
Added — agent-native round 3 (v1.6 sprint)¶
- Nine more flagship agent cards:
sp.dml_panel(v1.7 long panel DML),sp.proximal(+ bidirectional/fortified PCI alternatives exposed),sp.mr(dispatcher for the full MR family),sp.qdid,sp.qte,sp.dose_response,sp.spillover,sp.multi_treatment,sp.network_exposure.sp.agent_cards()now returns 30 populated entries (was 19 after the prior sprint). - Thirteen more guide
## For Agentsblocks (26 total across 19 guides):proximal_family.md,mendelian_family.md,qte_family.md(qte + qdid),interference_family.md(spillover + network_exposure),harvest_did.md(did + callaway_santanna),causal_text_family.md(text_treatment_effect + llm_annotator_correct),llm_dag_family.md(llm_dag_constrained + llm_dag_validate),paper_pipeline.md(paper). paperspec cleanup —alternativesentries now use bare function names ("causal","recommend") instead of prose strings, so the renderer emitssp.causalrather thansp.sp.causal: ....- Six more call-site exception migrations with recovery hints:
sp.matchnon-binary treatment →MethodIncompatibilitypointing atsp.multi_treatment/sp.dose_responsesp.matchall-same treatment →DataInsufficientsp.ebalance< 2 treated-or-control →DataInsufficientsp.dml(model='irm')non-binary D →MethodIncompatibilitysp.dml(model='irm')constant D →IdentificationFailuresp.conformal_synth/sp.augsynthinsufficient pre/post periods →DataInsufficient- 6 new migration tests added to
tests/test_exception_migrations.py(13 total now). All existing DID / IV / matching / DML / meta-learners / TMLE / synth / Bayesian family suites (363 tests total) continue to pass.
Added — agent-native round 4 (v1.6 closed-loop)¶
- Seven more flagship agent cards:
sp.principal_strat(extended),sp.mediate,sp.bartik,sp.bayes_rd,sp.bayes_fuzzy_rd,sp.bayes_mte,sp.conformal(extended).sp.agent_cards()now returns 36 populated entries (30 → 36). - Two more guide
## For Agentsblocks (28 total across 21 guides):conformal_family.md(conformal),shift_share_political_panel.md(bartik). Drift-check passes. - Six more exception migrations with recovery hints:
sp.gsynth< 3 pre-periods →DataInsufficientpointing atsp.synth/sp.didsp.gsynth< 1 post-period →DataInsufficientsp.sbwnon-binary treatment →MethodIncompatibilitypointing atsp.multi_treatment/sp.dose_responsesp.optimal_matchmissing control arm →DataInsufficientsp.synth_survivalno donor →DataInsufficient- Closed-loop
sp.diagnose_result: the diagnostic battery output now also carries: violations— the structured output ofresult.violations()(already surfaces pre-trend / first-stage F / McCrary / rhat / ESS / divergences / overlap / SMD with severity + recovery_hint),next_steps— the output ofresult.next_steps(print_result=False). The printed version includes a new "Structured violations (agent-native)" section below the family battery so humans and agents see the same triage picture. Backwards compatible: the existingmethod_type/checkskeys are untouched.- 3 new migration tests + 8 new closed-loop tests added to
tests/test_exception_migrations.pyandtests/test_diagnose_result_closed_loop.py. - Self-audit fix: the
rdrobustcard's alternatives list usedrd_donut(not exposed as a top-level function); replaced withrdrbounds. Doc block re-synced; drift-check green.
Final tally (rounds 1 – 4 combined)¶
- 36 populated agent cards covering: regression / IV / DID / RD / synth / matching / DML / meta-learners / TMLE / Bayesian (DID/IV/DML/RD/fuzzy-RD/MTE) / proximal / MR / principal strat / mediation / Bartik / QTE / QDID / dose-response / spillover / multi-treatment / network exposure / conformal / DML panel / paper / causal text / LLM-DAG.
- 28
## For Agentsblocks across 21 guides, rendered bypython scripts/sync_agent_blocks.pywith a CI drift guard. - 19 call-site exception migrations to the typed taxonomy
(
MethodIncompatibility,DataInsufficient,IdentificationFailure,NumericalInstability) across DID / IV / DML / matching / synth / Bayes. All still inherit fromValueError/RuntimeError, so existingexceptblocks work unchanged. - Closed-loop
sp.diagnose_resultbridges fit → violations → next_steps in one call, merging the family battery with the structured agent-native view.
Migration notes¶
This release is purely additive. Existing call sites that catch
ValueError continue to catch AssumptionViolation /
DataInsufficient / MethodIncompatibility /
IdentificationFailure; catching RuntimeError continues to catch
ConvergenceFailure and NumericalInstability. New code in
StatsPAI should prefer the specific subclasses and attach a
recovery_hint so agents can act on failures without parsing
error strings.
[1.5.0] — 2026-04-21 — Interference / Conformal / Mendelian family consolidation¶
Minor release. Three concurrent improvements to the interference,
conformal causal inference, and Mendelian Randomization families:
full-family documentation guides, unified dispatchers matching the
sp.synth / sp.decompose / sp.dml pattern, and a targeted
correctness audit that surfaced and fixed two silent-wrong-numbers
issues.
Added — three new family guides (interference / conformal / MR)¶
docs/guides/interference_family.md— complete walkthrough ofsp.spillover,sp.network_exposure,sp.peer_effects,sp.network_hte,sp.inward_outward_spillover,sp.cluster_matched_pair,sp.cluster_cross_interference,sp.cluster_staggered_rollout,sp.dnc_gnn_did. Decision tree covering partial / network / cluster-RCT designs with the 5 diagnostics every interference analysis should report (exposure balance, identification check for peer_effects, overlap for network_hte, parallel trends for staggered-cluster, sensitivity to exposure function).docs/guides/conformal_family.md— complete walkthrough ofsp.conformal_cate,sp.weighted_conformal_prediction,sp.conformal_counterfactual,sp.conformal_ite_interval,sp.conformal_density_ite,sp.conformal_ite_multidp,sp.conformal_debiased_ml,sp.conformal_fair_ite,sp.conformal_continuous,sp.conformal_interference. Clarifies the distinction between marginal and conditional coverage, with per-tool "when to use it" + how-to-read-disagreement guidance.docs/guides/mendelian_family.md— complete walkthrough of all 17 MR functions (4 point estimators + 6 diagnostics + 3 multi-exposure extensions + instrument-strength F + 2 plots), organised around the IV1 / IV2 / IV3 assumption hierarchy. Ships the 4 sanity checks every MR analysis should report and a worked BMI → T2D example.
Each guide is linked from mkdocs.yml under Guides and surfaces via
sp.search_functions().
Added — unified family dispatchers¶
Three new top-level dispatchers mirroring the style of sp.synth /
sp.decompose / sp.dml:
-
sp.mr(method=..., ...)— single entry point for the 17-function Mendelian Randomization family. Supportsmethod ∈ {"ivw", "egger", "median", "penalized_median", "mode", "simple_mode", "all", "mvmr", "mediation", "bma", "presso", "radial", "leave_one_out", "steiger", "heterogeneity", "pleiotropy_egger", "f_statistic", ...}with aliases. kwargs pass through to the target function.sp.mr_available_methods()lists all aliases. -
sp.conformal(kind=..., ...)— single entry point for the 10-function conformal causal inference family. Supportskind ∈ {"cate", "counterfactual", "ite", "weighted", "density", "multidp", "debiased", "fair", "continuous", "interference", ...}.sp.conformal_available_kinds()lists all aliases. -
sp.interference(design=..., ...)— single entry point for the 9-function interference / spillover family. Supportsdesign ∈ {"partial", "network_exposure", "peer_effects", "network_hte", "inward_outward", "cluster_matched_pair", "cluster_cross", "cluster_staggered", "dnc_gnn", ...}.sp.interference_available_designs()lists all aliases.
All three dispatchers are registered with hand-written schemas so
sp.describe_function("mr") / "conformal" / "interference" return
agent-readable descriptions. 30 new tests in
tests/test_dispatchers_v150.py guarantee the dispatcher path and the
direct-call path produce byte-for-byte identical results.
⚠️ Breaking — sp.mr is now a function, not a module alias¶
Prior to v1.5.0 sp.mr was a reference to the statspai.mendelian
submodule (from . import mendelian as mr), so sp.mr.mr_ivw(...)
worked. v1.5.0 replaces this with the new dispatcher function
sp.mr(method=..., ...).
Migration: code that previously wrote sp.mr.mr_ivw(bx, by, sx, sy)
should use the top-level sp.mr_ivw(bx, by, sx, sy) (already exported
in every prior version) or the new sp.mr("ivw", beta_exposure=bx, ...)
dispatcher. The module is still accessible as sp.mendelian for users
who were doing submodule-level introspection.
Updated references: the only in-repo consumer of the old
sp.mr.mr_ivw form was tests/reference_parity/test_mr_parity.py,
which has been migrated to top-level calls. All external user code
that already uses sp.mr_ivw / sp.mendelian_randomization / etc
continues to work unchanged.
Fixed — silent wrong numbers (correctness audit)¶
-
sp.mr_egger— slope inference used Normal, not t(n−2). The companionsp.mr_pleiotropy_eggercorrectly usedt(n−2)for the Egger intercept p-value, butmr_eggeritself usedstats.norm.cdffor both the slope p-value and the slope CI's critical value. This was anti-conservative at smalln_snps: e.g. forn_snps = 5and a t-stat of 1.5, the Normal-based two-sided p is 0.134 whereas the correct t(3)-based p is 0.231.mendelian_randomization(..., methods=["egger"])inherited the bug through its internal call. The fix switches both the p-value and the CI critical value tot(n−2). Regression guard intests/test_correctness_v150.py::TestMREggerUsesTDistribution. Forn_snps ≥ 100the change is numerically invisible (< 1e-3 in p). -
sp.mr_presso— MC p-value could equal exactly 0. Both the global test p-value and the per-SNP outlier p-values used the rawmean(null >= obs)form, which collapses to0.0when the observed statistic exceeds every simulated null. An MC-estimated p-value cannot be zero — its true lower bound is1 / (B + 1). The fix switches to the standard(k + 1) / (B + 1)convention (matching R'sMR-PRESSOpackage). Downstream effect: reported p-values are now always strictly positive and in[1/(B+1), 1], which prevents log-transforms and sensitivity analyses from silently producing-inf. Regression guard intests/test_correctness_v150.py::TestMRPressoMCPvalueConvention.
Fixed — dead code¶
sp.network_exposure._ht_estimatecontained a dimensionally inconsistentvar = ...expression that was immediately overwritten by the conservative Aronow-Samii Theorem 1 boundvar_as = .... The dead line is removed; the reported SE is unchanged.
Fixed — registry coverage¶
Five previously-exposed-but-unregistered family functions now surface
in sp.list_functions() and have agent-readable schemas via
sp.describe_function():
sp.network_exposure(Aronow-Samii HT)sp.peer_effects(Bramoullé-Djebbari-Fortin 2SLS)sp.weighted_conformal_prediction(TBCR 2019 primitive)sp.conformal_counterfactual(Lei-Candès Theorem 1)sp.conformal_ite_interval(Lei-Candès Eq. 3.4 nested bound)
No other API changes¶
Every other public signature is byte-for-byte identical to v1.4.2.
Existing user code keeps working; upgrades reveal slightly wider Egger
CIs at small n_snps and strictly positive mr_presso p-values.
[1.4.2] — 2026-04-21 — correctness patches + family guides¶
Patch release. No breaking changes; two silent-wrong-numbers bug
fixes in dml_model_averaging and gardner_did, plus three new
family guides (Proximal / QTE / Causal RL) closing the last gaps
between the v3 reference document and the documentation.
Fixed — silent wrong numbers¶
sp.dml_model_averaging— √n SE scaling bug. The cross-candidate variance aggregator treated the sample-mean influence-function outer product asVar(θ̂_avg)directly, missing a final/ n. Net effect: reported SEs were√ntimes too large; on the canonical n=400 DGP the 95% CI width was 4.20 (nominal ≈ 0.21) and empirical coverage was 100% (nominal 95%). After the fix, CI width is 0.21 and coverage is 82% (≈ nominal, with the remaining gap explained by a 4% small-sample bias in the point estimate — a nuisance-tuning issue, not a variance-formula issue). Regression guard added totests/test_dml_model_averaging.py::test_se_on_correct_scale.sp.gardner_did— event-study reference-category contamination. The Stage-2 dummy regression pooled never-treated units and treated units outside the event-study horizon into a single baseline, dragging every event-time coefficient toward the mean of that pool. On a synthetic panel with true τ=2 and strict parallel trends, pre- trends came out ≈ -0.30 (should be 0) and post ≈ +1.72 (should be 2.0). Replaced the Stage-2 regression in event-study mode with direct Borusyak-Jaravel-Spiess-style within-(cohort × relative-time) averaging of the imputed gap. After the fix: pre-trends ≈ +0.01, post ≈ +2.02. Non-event-study path (single ATT) was already correct and is unchanged.
Added — family guides¶
docs/guides/proximal_family.md— complete walkthrough of the Proximal Causal Inference family:sp.proximal,sp.fortified_pci,sp.bidirectional_pci,sp.pci_mtp,sp.double_negative_control,sp.proximal_surrogate_index,sp.select_pci_proxies. Includes a decision tree ("got 1 Z + 1 W / bridges sensitive to spec / unsure which is Z vs W / continuous treatment + shift policy / only have negative controls / want long-term from short-term experiment / have candidate proxies") and the four diagnostics every PCI analysis should report.docs/guides/qte_family.md— the three granularity levels (mean → quantile → whole distribution), with cross-sectional / DiD / IV / panel-with-many-controls decision paths coveringsp.qte,sp.qdid,sp.cic,sp.distributional_te,sp.dist_iv,sp.kan_dlate,sp.beyond_average_late, andsp.qte_hd_panel.docs/guides/causal_rl_family.md— when to use causal RL vs classical causal inference, withsp.causal_bandit,sp.causal_dqn,sp.offline_safe_policy,sp.counterfactual_policy_optimization,sp.structural_mdp,sp.causal_rl_benchmark. Ships the 4 causal-RL-specific sanity checks.
Each guide is linked from mkdocs.yml under Guides and surfaces via
sp.search_functions() since all referenced functions have
hand-written registry specs.
Added — tests + docs hooks (from v1.4.1 cherry-picks now formally shipped)¶
tests/test_bridge_full.py: 10 end-to-end smoke + correctness tests for the sixsp.bridge(kind=...)bridging theorems — dispatches, finite outputs, agreement property on correctly-specified DGPs.docs/guides/bridging_theorems.md: full walkthrough of the six bridges with when-to-use and how-to-read-disagreement.
No API changes¶
Every public signature is byte-for-byte identical to v1.4.1. Existing
user code keeps working; upgrades reveal narrower CIs for
dml_model_averaging and cleaner event-study coefs for gardner_did.
[1.4.1] — 2026-04-21 — v3-frontier sprint 3 (AKM SE + Claude thinking + parity suites + docs)¶
Additive follow-up to v1.4.0. All v1.4.0 APIs remain stable; new functionality is exposed through additive kwargs on existing entry points.
Added — shock-clustered SE for panel shift-share¶
sp.shift_share_political_panel(..., cluster='shock')— new option computes the panel-extended Adão-Kolesár-Morales (2019) variance estimator recommended by Park-Xu (2026) §4.2:
Typically 3× tighter than unit-clustered SEs in settings with 10–100
industries. diagnostics['akm_se'] exposes the value alongside the
chosen cluster type, and diagnostics['cluster'] is now a
human-readable label ("shock (AKM 2019)" when the shock estimator
is active).
[bartik/political.py]
Added — Claude extended-thinking support for Causal MAS¶
sp.causal_llm.anthropic_client(..., thinking_budget=N)— opt into the Claude 4.5 / Opus 4.7 extended-thinking API. The reasoning trace is captured onclient.history[-1]['thinking']for auditability but is NOT included in the public answer parsed bycausal_mas. Compatible with Anthropic'sthinking/redacted_thinkingcontent blocks; both are handled cleanly. Validatesthinking_budget >= 1024and< max_tokenseagerly, so misconfiguration fails loudly before the first API call. [causal_llm/llm_clients.py]
Added — parity + integration test suites¶
tests/reference_parity/test_assimilation_parity.py— 10 checks on the Kalman / particle backends:- static-effect posterior recovery (both backends)
- Kalman ↔ particle agreement on three seeds (point + SD within 15%)
- monotone posterior variance under
process_var = 0 - particle-filter ESS stays above threshold after resampling
- Student-t particle beats Kalman on a contaminated stream
- drift tracking without variance blow-up
-
assimilative_causal(backend=...)matches direct-backend calls -
tests/integration/test_causal_mas_with_fake_llm.py— 11 end-to-end integration tests using the deterministicecho_clientto drive the proposer / critic / domain-expert / synthesiser loop: proposer parsing (newlines + bullets), critic rejection, domain-expert endorsement lifting confidence, transcript auditability, confidence scaling with rounds, role overrides, DAG interop viasp.dag(...), plus three Claude-thinking content-block splitter tests that mock Anthropic responses without requiring theanthropicSDK at test time.
Documentation¶
Two new MkDocs guides, wired into mkdocs.yml nav under
DID & Panel Methods / guides:
docs/guides/shift_share_political_panel.md— full panel-IV recipe including AKM shock-cluster guidance and pretrend workflow.docs/guides/causal_mas.md— multi-agent LLM causal discovery, real-SDK integration, Claude thinking-mode walkthrough, and end-to-end pipe intosp.dag/sp.identify.
Fixed¶
- Integration test used
dag.edges()butDAG.edgesis a list-of- tuples attribute (not a method). Corrected todag.edges.
Backwards compatibility¶
- All v1.4.0 APIs remain stable. The only new surface is additive kwargs:
sp.shift_share_political_panel(cluster='shock')sp.causal_llm.anthropic_client(thinking_budget=N)
[1.4.0] — 2026-04-21 — v3-frontier sprint 2 (extensions + LLM SDK + docs)¶
Follow-up to v1.3.0 covering the four secondary items flagged at the end of Sprint 1.
Added — panel-shift-share extension¶
sp.shift_share_political_panel— multi-period extension ofsp.shift_share_politicalper Park & Xu (2026) §4.2. Handles time-varying shares and time-varying shocks, runs pooled 2SLS with unit / time / two-way fixed effects, and reports a per-period event-study table plus aggregate Rotemberg top-K weights. Recovers τ = 0.30 within 0.003 on a 30 × 4 synthetic panel. [bartik/political.py]
Added — real-LLM adapters for Causal MAS¶
sp.causal_llm.openai_client— adapter over theopenai>=1.0Python SDK; supports custombase_urlfor Azure / vLLM / Ollama.sp.causal_llm.anthropic_client— adapter over theanthropic>=0.30Messages API; defaults toclaude-opus-4-7.sp.causal_llm.echo_client— deterministic scripted-response client for offline unit testing.- All three implement a single-method
LLMClientprotocol and integrate withsp.causal_llm.causal_mas(client=...)via the existingchat(role, prompt)interface. Lazy-imports the SDKs so the core package has zero new runtime dependencies. [causal_llm/llm_clients.py]
Added — particle-filter assimilation backend¶
sp.assimilation.particle_filter— bootstrap-SIR particle filter with systematic resampling (Gordon-Salmond-Smith 1993; Douc-Cappé 2005). Handles non-Gaussian priors, heavy-tailed observation noise, and nonlinear dynamics via pluggableprior_sampler/transition_sampler/observation_log_pdfcallbacks. Agrees with the exact Kalman filter to ~0.003 under Gaussian DGPs.sp.assimilative_causal(..., backend='particle')— the end-to-end wrapper now routes to the particle filter whenbackend='particle'. [assimilation/particle.py]
Documentation¶
Three new MkDocs guides covering the v3-frontier estimators:
docs/guides/synth_experimental.md— Abadie-Zhao inverse-SC workflow.docs/guides/harvest_did.md— Borusyak-Hull-Jaravel harvesting DID.docs/guides/assimilative_ci.md— Nature Comms 2026 streaming CI with both Kalman and particle backends.
All three are wired into mkdocs.yml nav under the DID & Panel
Methods / guides section.
Registry + agent schema¶
- 5 new hand-written
FunctionSpecentries:shift_share_political_panel,particle_filter,openai_client,anthropic_client,echo_client.
Code-quality pass (Sprint 1 audit)¶
- Removed 20 unused imports / shadow variables across the Sprint 1
modules identified by
pyflakes(did/harvest.py,bcf/ordinal.py,bcf/factor_exposure.py,causal_llm/causal_mas.py,bartik/political.py,assimilation/kalman.py,target_trial/report.py).
Fixed¶
tests/external_parity/test_causalml_book.py::test_forest_ate_recovers_average_tauwas flaking onubuntu-latest + Python 3.10because only the data-generating RNG was seeded — the causal forest's bootstrap + honest-split sampling was unseeded, so the ATE estimate varied by ±0.3 between OS / Python matrix entries and the|ATE - 0.5| < 0.3tolerance occasionally failed. Fixed by passingrandom_state=0+n_estimators=300+ bumpingnto 1 500 so the test is fully deterministic across the matrix.
[1.3.0] — 2026-04-21 — v3-frontier sprint (Sprint 1 of the 知识地图 v3 roadmap)¶
Builds on top of the v1.2.0 doc-alignment work by implementing the
eleven highest-leverage frontier methods identified in the 2026-04-20
Causal-Inference Method Family 万字剖析 v3 gap analysis. Every new
public function is wired into the registry + agent schema so it
surfaces through sp.list_functions, sp.describe_function, and
sp.all_schemas for LLM agents.
Added — P0 frontier (4 methods, within-sprint week 1)¶
-
sp.synth_experimental_design— Abadie & Zhao (2025/2026) inverse synthetic controls: picks the bestkcandidate units to treat by minimising the sum of per-unit pre-period SC MSPEs. Produces a ranking table, recommended treatment assignment, and a variance-gain benchmark against random allocation. [synth/experimental_design.py] -
sp.rdrobust(..., bootstrap='rbc', n_boot=999, random_state=...)— Cavaliere, Gonçalves, Nielsen & Zanelli (arXiv:2512.00566, 2025) robust-bias-corrected studentised percentile bootstrap. Empirically delivers CIs ~3–15% shorter than the analytic robust CI without sacrificing coverage. Newmodel_info['rbc_bootstrap']block exposes the CI, p-value, length-ratio, and effective replicate count. -
sp.fairness.evidence_without_injustice— Loi, Di Bello & Cangiotti (arXiv:2510.12822, 2025) counterfactual-fairness test that freezes admissible-evidence features at their factual values and tests whether predictions still change underdo(A = a'). Returns a bootstrap CI, p-value, and per-alternative breakdown. [fairness/evidence_test.py] -
sp.target_trial.to_paper(..., fmt='jama' | 'bmj')— renders a JAMA / BMJ-ready manuscript with all 21 TARGET Statement (JAMA/BMJ 2025-09) items auto-filled where derivable plus(supply text)placeholders elsewhere. Supportsauthors,funding,registration,data_availability,background,limitationskeyword arguments.
Added — P1 frontier (4 methods, within-sprint week 2)¶
-
sp.harvest_did— Abadie, Angrist, Frandsen & Pischke, NBER WP 34550 (2025) Harvesting DID + event-study framework: extracts every valid 2×2 DID comparison from a staggered panel, combines them via inverse-variance weights, and reports event-study + pretrend Wald tests. Uses a not-yet-treated-at-max(t₁, t₂) clean-control filter that correctly handles placebo horizons. [did/harvest.py] -
sp.bcf_ordinal— Zorzetto et al. (2026) BCF for ordered / dose treatments. Chains pairwise binary BCF between consecutive levels to yield cumulative dose-response CATEs with per-level ATEs. [bcf/ordinal.py] -
sp.bcf_factor_exposure— arXiv:2601.16595 (2026) BCF on PCA-factor scores of a high-dimensional exposure vector. SVD or user-supplied loadings compress the exposure toKfactors; one BCF is fit per factor. Returns per-factor ATEs, loadings, scores, and an aggregate mixture-ATE with CI. [bcf/factor_exposure.py] -
sp.causal_llm.causal_mas— arXiv:2509.00987 (2025/09) multi- agent causal discovery framework. Runs proposer / critic / domain-expert / synthesiser agents over several debate rounds with per-edge confidence scores and a full auditable transcript. Offline heuristic backend by default; accepts anychat(role, prompt)/complete(prompt)LLM client. [causal_llm/causal_mas.py] -
sp.shift_share_political— Park & Xu (arXiv:2603.00135, 2026) political-science variant of the Bartik IV. Long-difference 2SLS with AKM shock-cluster SEs, Rotemberg top-K diagnostic, and share-balance F-test against pre-treatment covariates. [bartik/political.py]
Added — P2 frontier + testing (2 methods + 2 test suites)¶
-
sp.assimilation.causal_kalman,sp.assimilation.assimilative_causal— Assimilative Causal Inference (Nature Communications 2026): a Kalman filter over streaming causal-effect estimates. Produces a running posterior with effective-sample-size diagnostics, pluggable dynamics (static or random-walk), and an end-to-end wrapper that runs a user-supplied per-batch estimator. New subpackage [assimilation/]. -
tests/reference_parity/test_mr_parity.py— 7 analytic-truth checks over the MR suite (IVW consistency, Egger intercept under balanced pleiotropy, Egger directional-pleiotropy detection, weighted-median robustness, PRESSO outlier flag, LOO stability, Radial-Wald exact agreement). All 7 pass. -
tests/external_parity/test_causalml_book.py— 7 CausalMLBook (Chernozhukov et al. 2024–2025) canonical-DGP checks: DML-PLR, Causal Forest, T-learner, 2SLS, Callaway–Sant'Anna DID, rdrobust, and rbc-bootstrap vs analytic parity. All 7 pass.
Registry + agent schema¶
- 9 hand-written
FunctionSpecentries for every new public function:synth_experimental_design,evidence_without_injustice,harvest_did,bcf_ordinal,bcf_factor_exposure,causal_mas,shift_share_political,causal_kalman,assimilative_causal. Each entry ships with NumPy-style parameter docs, examples, tags, and paper references for LLM-agent consumption.
Backwards compatibility¶
- All v1.2.x public APIs remain stable. The only changes to existing signatures are additive kwargs:
sp.rdrobust—bootstrap,n_boot,random_statesp.target_trial.to_paper—journal,authors,funding,registration,data_availability,background,limitations
[1.2.0] — 2026-04-21 — Doc-alignment sprint (v3 reference document)¶
Closes the remaining gaps between the Causal-Inference Method Family 万字剖析 v3 (2026-04-20) reference document and the StatsPAI public API. Most v3 frontier methods were already implemented in v1.0.x but lived in sub-packages without top-level exposure or curated registry specs. This release wires them up, adds the eight genuinely missing classical/frontier methods, and upgrades 14 frontier estimators from auto-generated to hand-written registry specifications so that LLM agents see proper parameter docs, examples, references, and tags.
Added — new estimators¶
Staggered DID
sp.gardner_did/sp.did_2stage— Gardner (2021) two-stage DID estimator (the Statadid2sanalogue). Stage-1 fits two-way fixed effects on untreated rows; Stage-2 regresses the residualised outcome on treatment dummies (overall ATT or event study) with cluster-robust SEs. Numerically agrees withdid_imputationto within ~2% on synthetic staggered panels.
DML
sp.dml_model_averaging/sp.model_averaging_dml— Ahrens, Hansen, Kurz, Schaffer & Wiemann (2025, JAE 40(3):381-402) model-averaging DML-PLR. Fits DML under multiple candidate nuisance learners and reports a risk-weighted (or equal/single-best) average θ with a cross-score-covariance-adjusted SE. Default candidate roster: Lasso / Ridge / RandomForest / GradientBoosting.
IV
sp.kernel_iv(top-level alias ofsp.iv.kernel_iv) — Lob et al. (2025, arXiv:2511.21603) kernel IV regression with wild-bootstrap uniform confidence band over the structural functionh*(d).sp.continuous_iv_late(top-level alias) — Zeng et al. (2025, arXiv:2504.03063) LATE on the maximal complier class for continuous instruments via quantile-bin Wald estimator. (Also fixed a summary formatting bug — see below.)
TMLE
sp.hal_tmle+sp.HALRegressor/sp.HALClassifier— TMLE with Highly Adaptive Lasso nuisance learners (Li, Qiu, Wang & van der Laan, 2025, arXiv:2506.17214). Two variants:"delta"(plug HAL into standard TMLE) and"projection"(apply tangent-space shrinkage to the targeting epsilon). Recovers ATE within ~3% on n=400 with rich nuisance.
Synthetic Control
sp.synth_survival— Synthetic Survival Control (Han & Shah, 2025, arXiv:2511.14133). Donor convex combination on the complementary log-log scale matches the treated arm's pre-treatment Kaplan-Meier, then projects forward and reports the survival gap with a placebo-permutation uniform band. Pre-treat fit RMSE typically < 0.01 on synthetic Cox data.
RDD aliases
sp.multi_cutoff_rd(alias forsp.rdmc),sp.geographic_rd(alias forsp.rdms),sp.boundary_rd(alias forsp.rd2d),sp.multi_score_rd(alias forsp.rd_multi_score) — user-friendly aliases mirroring the v3 document terminology.
Added — registry / agent surface¶
- 14 frontier estimators promoted from auto-generated to hand-written
registry specs with curated parameter descriptions, examples, tags,
and references:
gardner_did,dml_model_averaging,kernel_iv,continuous_iv_late,hal_tmle,synth_survival,bridge,causal_dqn,fortified_pci,bidirectional_pci,pci_mtp,cluster_cross_interference,beyond_average_late,conformal_fair_ite. This is whatsp.describe_function(...)andsp.function_schema(...)now return for these names. - Total registered functions: 836 → 860.
__all__repaired so previously-imported-but-not-exported symbols surface insp.list_functions():fci/FCIResult,spatial_did/SpatialDiDResult,spatial_iv,notears,pc_algorithm.
Fixed¶
iv.continuous_late.ContinuousLATEResult.summary— header line was being multiplied 42× by an implicit string-concat ×"=" * 42precedence bug ("title\n" "=" * 42parsed as("title\n" + "=") * 42). Replaced with explicit f-string concatenation.question.CausalQuestion.save— addedTYPE_CHECKINGimport forpathlib.Pathso the stringified return annotation stops trippingflake8 F821in CI.- Added
tabulate>=0.9.0to core dependencies.pandas.to_markdown()dispatches totabulate, which was previously a pandas-optional dep; user-facingsp.causal(...).report('markdown' | 'html')triggered anImportErroron systems (Windows, fresh envs) that didn't happen to transitively installtabulate.
Test coverage¶
35 new test cases across 7 new test modules:
test_gardner_2s.py (7), test_dml_model_averaging.py (5),
test_kernel_iv.py (5), test_continuous_iv_late.py (4),
test_hal_tmle.py (5), test_synth_survival.py (6),
test_rd_aliases.py (3). All pass on Python 3.13.
[1.0.1] - 2026-04-21 — Post-review correctness pass + deferred-item closeout¶
Bugfix release closing every Critical / High / Medium finding from the
independent code-review-expert pass on the v1.0.0 frontier modules,
plus resolution of the two # NEEDS_VERIFICATION items that had been
deferred in v1.0.0.
Fixed — post-review correctness pass¶
Critical (silent wrong numbers)
pcmci.partial_corr_pvalue: Fisher-z SE now uses the effective sample sizesqrt(n - |Z| - 3)instead of the off-by-onesqrt(df - 1). The previous formula systematically missed edges in PCMCI by making partial-correlation p-values too large.cohort_anchored_event_study: theclusterargument was silently dropped — the bootstrap resampled cohort ATTs instead of the user- supplied cluster level. Fixed to resample at the requested cluster and re-compute ATT(c, k) per draw.ltmle_survivaltargeting step: the TMLE one-step update appliedlogit(h_hat_regime)inline instead of using the pre-computedoffsetvariable, leaving the regime-counterfactual hazard untargeted. Reboundoffset_regime = logit(clip(h_hat_regime)).
High (wrong formula / silent tautology)
conformal_density_ite: previously fell back to split-conformal on Gaussian-residual quantiles, with the KDE bandwidth computed but unused. Now builds a proper KDE of the ITE-residual convolution and returns the Hyndman (1996) highest-density region via a shortest- window sweep over sorted smoothed samples.bridge.ewm_cate: Path A and Path B shared the same CATE-plug-in DR score, making the agreement test tautological. Path A now uses the Kitagawa-Tetenov (2018) pure-IPW welfare score so that the two paths have genuinely different failure modes, giving a real bridge.mr_multivariableconditional F-stat (Sanderson-Windmeijer): the partitionss_full - ss_residused raw (uncentred) sum of squares and unweighted OLS. Replaced with centred SS over WLS residuals, matching the MVMR weighting scheme.bcf_longitudinal.average_ate: point estimate and CI were computed on different sampling distributions (per-time-point mean vs. bootstrap quantiles). Headline now uses the bootstrap mean.
Medium
conformal_fair_ite: small protected-group fallback no longer mixes arms (which destroyed per-group coverage). Falls back to the conservative MAX per-group quantile across well-covered groups, or a pooled quantile with an explicit warning when all groups are small.causal_rl.structural_mdp: theA/Bmatrix slices were numerically verified correct, but shape assertions were added so any future refactor that flips the slice semantics fails loudly.causal_llm.llm_dag_propose: user-provideddomainandvariablesare now sanitized (non-printable and newline characters stripped; length capped) before interpolation into the LLM prompt, closing the prompt-injection vector.
Dead-variable cleanup
- Removed stale
bM,fe_cols,avg,rngnames acrossmendelian/multivariable.py,did/design_robust.py,bcf/longitudinal.py, andqte/hd_panel.py.
Changed — deferred-item closeout¶
beyond_average_late: replaced the ad-hoc quantile-range rescaling with an Abadie (2002) κ-weighted complier-CDF construction that inverts the CDF difference on the complier subpopulation only. The result is a proper complier quantile treatment effect.bridge.surrogate_pci: path A (surrogate index) and path B (PCI bridge) now use genuinely different identifying assumptions — path A relies on surrogacy (no direct D→Y path given S), path B relies on proxy completeness (D is a valid IV for itself under the bridge function). The old OLS-on-(D, S, X) construction for path B is replaced with a 2SLS that uses S and X as exogenous controls while leaving D as the treatment of interest.
Tests¶
tests/test_v100_review_fixes.py: 8 pinning regression tests, each corresponding 1:1 to a review finding.- Full-suite regression: 2 515+ tests passing, zero regressions.
[1.0.0+] - 2026-04-21 — v3 frontier sweep (12-module / 38-estimator pass)¶
Round-out pass triggered by the v3 全景图 doc (2026-04-20), filling the
remaining 2025-2026 frontier gaps that Stata / R / EconML / DoWhy /
CausalML still lack. 38 new public estimators across 12 modules,
all routed through sp.* and registered in sp.list_functions().
Added — v3 frontier estimators¶
- DiD frontier (
sp.did_*):did_bcf(Forests for Differences, Wüthrich-Zhu 2025),cohort_anchored_event_study(arXiv 2509.01829),design_robust_event_study(Wright 2026, arXiv 2601.18801),did_misclassified(arXiv 2507.20415). - Conformal frontier (
sp.conformal_*):conformal_density_ite(arXiv 2501.14933),conformal_ite_multidp(arXiv 2512.08828),conformal_debiased_ml(arXiv 2604.03772),conformal_fair_ite(arXiv 2510.08724). - Proximal frontier (
sp.fortified_pci,sp.bidirectional_pci,sp.pci_mtp,sp.select_pci_proxies): doubly-robust, bidirectional, modified-treatment-policies, plus a heuristic proxy selector (arXiv 2506.13152 / 2507.13965 / 2512.12038 / 2512.24413). - Distributional / panel QTE (
sp.dist_iv,sp.kan_dlate,sp.qte_hd_panel,sp.beyond_average_late): full distributional- layer LATE + HD-panel QTE + complier-distribution LATE (arXiv 2502.07641 / 2506.12765 / 2504.00785 / 2509.15594). - RDD frontier (
sp.rd_interference,sp.rd_multi_score,sp.rd_distribution,sp.rd_bayes_hte,sp.rd_distributional_design): five new 2025–2026 supports (arXiv 2410.02727 / 2508.15692 / 2504.03992 / 2504.10652 / 2602.19290). sp.causal_llm(NEW namespace):llm_dag_propose,llm_unobserved_confounders,llm_sensitivity_priors— all with deterministic heuristic backends (no API key required); accept aclientarg for real LLM injection.sp.causal_rl(NEW namespace):causal_dqn(Li-Zhang-Bareinboim confounding-robust Deep Q, arXiv 2510.21110),causal_rl_benchmark(5 benchmarks per Cunha-Liu-French-Mian, arXiv 2512.18135),offline_safe_policy(Chemingui et al., arXiv 2510.22027).- Cluster RCT × interference (
sp.cluster_*,sp.dnc_gnn_did): matched-pair, cross-cluster, staggered-rollout, DNC+GNN+DiD (arXiv 2211.14903 / 2310.18836 / 2502.10939 / 2601.00603). - IV frontier (
sp.iv.kernel_iv,sp.iv.continuous_iv_late,sp.iv.ivdml): kernel IV uniform CI + continuous-instrument maximal-complier LATE + LASSO-efficient instrument × DML (arXiv 2511.21603 / 2504.03063 / 2503.03530). - Meta-learner frontier (
sp.focal_cate,sp.cluster_cate): functional CATE (FOCaL, arXiv 2602.11118) + K-means cluster CATE (arXiv 2409.08773). - Bunching unification (
sp.general_bunching,sp.kink_unified): high-order bias correction (Song 2025, arXiv 2411.03625) + RDD/RKD/Bunching joint estimator (Lu-Wang-Xie 2025).
Tests (v3 sweep)¶
- 55 new smoke tests added under
tests/test_*_frontiers.py,tests/test_causal_llm.py,tests/test_causal_rl.py,tests/test_cluster_rct.py,tests/test_metalearner_frontiers.py,tests/test_bunching_unified.py. All pass; no regressions in the 153 core tests for did / iv / rd / dml / proximal / metalearners.
Registry (v3 sweep)¶
- Total registered functions: 794 → 831 (37 new symbols + 1 result class auto-discovered).
- All 38 surfaced via
sp.list_functions(),sp.help(),sp.function_schema(), and the OpenAI-compatible JSON schema export.
[1.0.0] - 2026-04-21 — Research-frontier capstone: bridging theorems, fairness, surrogates, MVMR, PCMCI, beyond-average QTE¶
StatsPAI 1.0 is the capstone release that integrates three years of
development into one coherent toolkit. On top of the v0.9.17
three-school completion, v1.0 ships the 2025-2026 research-frontier
modules that Stata / R have not yet caught up with, wires every
scaffolded subpackage into the top-level sp.* namespace, and
upgrades the target-trial reporting layer to the JAMA/BMJ 2025
TARGET Statement.
Added — v1.0 research-frontier modules¶
Bridging theorems (sp.bridge) — dual-path doubly-robust
identification. Each theorem pairs two seemingly different estimators
on the same target parameter: if either assumption holds, the
estimate is consistent.
bridge(..., kind="did_sc")— DiD ≡ Synthetic Control (Shi-Athey 2025)bridge(..., kind="ewm_cate")— EWM ≡ CATE → policy (Ferman et al. 2025)bridge(..., kind="cb_ipw")— Covariate balancing ≡ IPW × DR (Zhao-Percival 2025)bridge(..., kind="kink_rdd")— Kink-bunching ≡ RDD (Lu-Wang-Xie 2025)bridge(..., kind="dr_calib")— DR via calibration (Zhang 2025)bridge(..., kind="surrogate_pci")— Long-term surrogate ≡ PCI (Kallus-Mao 2026)BridgeResultreports both path estimates, their agreement test, and the recommended doubly-robust point estimate.
Fairness (sp.fairness) — counterfactual fairness as causal
inference, not pure statistics.
counterfactual_fairness— Kusner et al. (2018) Level-2/3 predictor evaluation on a user-supplied SCM.orthogonal_to_bias— Marchesin & Zhang (2025) residualization pre-processing that removes the component of non-protected features correlated with the protected attribute.demographic_parity,equalized_odds,fairness_audit— statistical fairness metrics + one-shot dashboard.
Long-term surrogates (sp.surrogate) — extrapolate short-term
experiments to long-term outcomes.
surrogate_index— Athey, Chetty, Imbens, Pollmann & Taubinsky (2019).long_term_from_short— Ghassami, Yang, Shpitser, Tchetgen Tchetgen (2024).proximal_surrogate_index— Imbens, Kallus, Mao (2026): proximal identification when unobserved confounders link surrogate and long-term outcome.
Multivariable MR (sp.mendelian extended)
mr_multivariable— MVMR on multiple correlated exposures.mr_mediation— causal-pathway decomposition for two-sample MR.mr_bma— Bayesian Model Averaging for MR with many candidate exposures (Yao et al. 2026 roadmap).
DiD frontiers (sp.did extended)
cohort_anchored_event_study— cohort-robust event-study weights.design_robust_event_study— design-robust dynamic ATT.did_misclassified— treatment-misclassification-robust DiD.did_bcf— Bayesian Causal Forest wrapper for DiD.
Conformal-inference frontiers (sp.conformal_causal extended)
conformal_debiased_ml— debiased-ML-aligned conformal intervals.conformal_density_ite— density-valued ITE conformal bounds.conformal_fair_ite— fairness-constrained ITE conformal.conformal_ite_multidp— multi-stage differentially-private ITE conformal bounds.
Proximal causal frontiers (sp.proximal extended)
bidirectional_pci— two-sided proxy-based causal inference.fortified_pci— variance-fortified PCI.pci_mtp— multiple-testing-corrected PCI.select_pci_proxies— automated proxy-variable selector.
Quantile / distributional-IV frontiers (sp.qte extended)
beyond_average_late— beyond-mean LATE for heterogeneous quantile treatment effects.qte_hd_panel— high-dimensional panel QTE.
RD frontiers (sp.rd extended)
rd_distribution— distribution-valued (functional) RD.rd_multi_score,rd_interference— already shipped.
Time-series causal discovery (sp.causal_discovery extended)
pcmci/lpcmci/dynotears— Peter-Clark-MCI family for observational + latent-confounder time-series DAG discovery.
LTMLE survival + BCF longitudinal (sp.tmle / sp.bcf extended)
ltmle_survival— LTMLE for survival outcomes with time-varying treatments.bcf_longitudinal— BCF for longitudinal panel settings.
Target Trial 2025 upgrade (sp.target_trial extended)
target_checklist(result)+to_paper(..., fmt="target")— render the JAMA/BMJ September-2025 TARGET Statement 21-item reporting checklist as a completed table, with[AUTO]/[TODO]tags for items that can be filled from the protocol + result vs. need author-supplied narrative.
Synthetic control frontier
sequential_sdid— sequential synthetic difference-in-differences.
ML bounds
ml_bounds— partial-identification bounds with ML nuisance estimation.
Added — MCP server + bridge layer¶
sp.agent.mcp_server— Model Context Protocol server scaffold so external LLMs (Claude, GPT-4, local models) can call every registered StatsPAI function via natural-language tool-calling.
Changed¶
statspai/__init__.py: 80+ new names in__all__; v1.0 total registered functions ≈ 729+.- Registry now includes rich FunctionSpec entries for the core new frontier APIs (bridge, fairness, surrogate, mr_multivariable, etc.).
Stability & scope¶
- All 229 tests added in the v0.9.17 + v1.0 window pass.
- Zero regressions in the 2158-test existing suite.
- Three-school completion from v0.9.17 carries forward intact
(
sp.epi,sp.longitudinal,sp.question, unified sensitivity, DAG recommender, preregistration).
Versioning¶
- This is a major release (breaking-change policy starts here). The
public API surface is the set of names in
statspai.__all__as of v1.0.0; anything outside that list remains unstable.
[0.9.17] - 2026-04-21 — Modern-weighting + MC g-formula + weakrobust panel + three-school completion¶
Two-pronged release. First, a surgical pass targeting four of the most-
requested gaps from the v1.0 gap-analysis: a Stata-style unified
weak-IV-robust diagnostic panel, the Zubizarreta (2015) stable-balancing-
weights estimator, the Robins (1986) Monte-Carlo g-formula (complementing
the existing Bang-Robins ICE), and a truly end-to-end sp.causal()
orchestrator. Second, a three-school completion pass mapping the
Econometrics ↔ Epidemiology ↔ ML knowledge-map article onto StatsPAI:
epidemiology primitives, MR full suite, longitudinal dispatcher, DAG-to-
estimator recommender, estimand-first DSL, and a unified sensitivity
dashboard attached to every Result object.
Added¶
-
sp.weakrobust(data, y, endog, instruments, exog)— one-call diagnostic panel that bundles Anderson-Rubin (1949), Moreira (2003) Conditional LR, Kleibergen (2002) K score test, Kleibergen-Paap (2006) rk LM + Wald F, Olea-Pflueger (2013) effective F, and Lee-McCrary-Moreira-Porter (2022) tF critical values.WeakRobustResultexposes.summary(),.to_frame(), and dict-style lookup. This is the Python analogue of Stata 19'sestat weakrobust, unifying functionality scattered acrossivmodel(R),linearmodels(Python), and the Stata user-writtenweakiv/rivtestpackages. -
sp.sbw(data, treat, covariates, y=..., estimand='att')— Stable Balancing Weights (Zubizarreta 2015 JASA). Minimises variance (or KL) of the weights subject to per-covariate SMD balance tolerances solved via SLSQP. Supports ATT / ATC / ATE. Reports an effective sample size and before/after balance table. Complementssp.ebalance(exact balance) andsp.cbps(CBPS). -
sp.gformula_mc(data, treatment_cols, confounder_cols, outcome_col)— Monte-Carlo parametric g-formula (Robins 1986). Fits per-timepoint conditional models for confounders (binary logit / Gaussian OLS) and simulates counterfactual trajectories under user-supplied static or dynamic (callable) treatment strategies. Non-parametric bootstrap CI. Complements the existingsp.gformula.ice(Bang-Robins 2005 ICE). -
Enhanced
sp.causal()workflow — three new stages auto-run afterestimate/robustness: .compare_estimators()— design-aware multi-estimator panel: CS + SA + BJS + Wooldridge for staggered DiD; 2SLS + LIML for IV; OLS + EB + CBPS + SBW + DML-PLR for observational..sensitivity_panel()— E-value + Oster δ* + Rosenbaum Γ in one DataFrame, matching the modern "sensitivity triad" expected by top-5 econ journals..cate()— X-Learner and Causal Forest heterogeneity summary (per-unit CATE mean, SD, q10/q50/q90).- Report output gains sections 4b / 4c / 4d.
- Opt-out via
CausalWorkflow.run(full=False);_extract_effecthelper unifiesCausalResultandEconometricResultsextraction.
Reviewer-identified fixes (v0.9.17 internal review)¶
SBWResult.__init__now forwardsmodel_info+_citation_keyto theCausalResultparent, wiring it into the citation registry.MCGFormulaResult._is_binarynow requires both 0 and 1 levels present — a degenerate column (all-0 or all-1) no longer triggers the logistic Newton-Raphson loop._extract_effectinCausalWorkflownow returns NaN when the treatment column is missing from the fitted params, rather than silently surfacing the intercept coefficient.- SBW docstring clarified: reported SE is conditional-on-weights;
users who need full parameter-uncertainty propagation should
bootstrap
sp.sbwexternally.
Deferred to a separate sprint¶
The original gap analysis also flagged TMLE dynamic regimes + censoring, Conformal counterfactual / weighted variants, PCMCI time-series causal discovery, Partial-ID + ML bounds, and the Agent-MCP server integration. Each is substantial enough to warrant its own focused sprint rather than being shipped half-finished here.
Added — three-school completion (2026-04-21 sub-release)¶
Driven by a cross-reference audit against the article "Causal Inference Knowledge Map — Econometrics, Epidemiology, ML", which pinpointed Layer-4 (What If longitudinal), epidemiology entry-level primitives, Mendelian randomization diagnostic depth, DAG-to-estimator UX, and estimand-first workflow as the remaining gaps vs. Stata / R dominance.
Epidemiology primitives (sp.epi) — NEW subpackage
odds_ratio,relative_risk,risk_difference,attributable_risk(Levin PAF),incidence_rate_ratio(exact Poisson CI via Clopper-Pearson),number_needed_to_treat,prevalence_ratio— Woolf / Fisher-exact / Katz / Wald / Newcombe intervals; Haldane-Anscombe correction for zero cells.mantel_haenszel(OR / RR with Robins-Breslow-Greenland variance),breslow_day_test(homogeneity of OR with Tarone correction).direct_standardize,indirect_standardize— direct-standardized rates + SMR with Garwood exact Poisson CI.bradford_hill— structured 9-viewpoint causal-assessment rubric with prerequisite check (no causality claim without temporality).
Mendelian randomization full suite (sp.mr / sp.mendelian)
mr_heterogeneity— Cochran's Q (IVW) or Rücker's Q' (Egger) + I².mr_pleiotropy_egger— formal MR-Egger intercept test for directional horizontal pleiotropy (Bowden 2015).mr_leave_one_out— per-SNP drop-one IVW sensitivity.mr_steiger— Hemani (2017) directionality test using Fisher-z of per-trait R² contributions.mr_presso— Verbanck (2018) global outlier test + per-SNP outlier detection + distortion test for raw-vs-corrected comparison.mr_radial— Bowden (2018) radial reparameterization + Bonferroni- thresholded outlier flagging.
Target trial emulation — structured report
TargetTrialResult.to_paper(fmt=...)/sp.target_trial.to_paper— render STROBE-compatible Methods + Results block in Markdown / LaTeX / plain-text for direct inclusion in manuscripts. Table structure tracks the JAMA 2022 7-component TTE spec exactly.
Longitudinal causal dispatcher (sp.longitudinal) — NEW subpackage
sp.longitudinal.analyze— unified entry point that auto-routes to IPW (no time-varying confounders) / MSM (dynamic regime with time-varying confounders) / parametric g-formula ICE (static regime) based on data shape and the supplied regime object.sp.longitudinal.contrast— plug-in estimator ofE[Y(regime_a)] - E[Y(regime_b)]with delta-method SE.sp.regime,sp.always_treat,sp.never_treat— dynamic-treatment- regime DSL supporting static sequences, callables, and a safe"if cd4 < 200 then 1 else 0"string DSL. The string DSL is parsed into a whitelisted AST and interpreted by a tiny tree-walker — no dynamic code execution is ever invoked, and disallowed constructs are rejected at regime-construction time.
Estimand-first causal-question DSL (sp.causal_question) — NEW subpackage
sp.causal_question(treatment=, outcome=, estimand=, design=, ...)declares a causal question up front..identify()picks an estimator + lists the identifying assumptions the user must defend;.estimate()runs the analysis;.report()produces a Markdown Methods + Results paragraph.- Auto-design selects IV when instruments are present, RD when running variable + cutoff given, DiD when panel + time, longitudinal when repeated measures, else selection-on-observables.
- Dispatches internally to
sp.regress/sp.aipw/sp.iv/sp.did/sp.rdrobust/sp.synth/sp.longitudinal.analyze/sp.event_study.
DAG → estimator recommender (sp.dag.recommend_estimator)
DAG.recommend_estimator(exposure, outcome)— inspects the declared graph and suggests a StatsPAI estimator with a plain-English identification story. Priority order: backdoor adjustment (OLS / IPW / matching) → IV (heuristic relevance + exclusion check) → frontdoor → not-identifiable (with sensitivity-analysis fallbacks).- Detects mediators on the causal path automatically.
Unified sensitivity dashboard (sp.unified_sensitivity)
result.sensitivity()— method added to bothCausalResultandEconometricResults. Single call runs E-value (always), Oster δ (when R² inputs supplied), Rosenbaum Γ (when a matched structure is exposed), Sensemakr (regression models), and a breakdown-frontier bias estimate.
Changed (three-school completion)¶
__init__.py: 40+ new names exposed at top level includingsp.epi,sp.longitudinal,sp.question,sp.tte/sp.mrshort aliases.
Fixed (three-school completion)¶
- Regime DSL: AST validation moved from evaluate-time to compile-time
so unsafe expressions are rejected immediately at
sp.regime(...)construction, before any history is supplied.
[0.9.16] - 2026-04-20 — v1.0 breadth expansion + Bayesian family polish + Rust Phase-2 CI¶
The largest release since the v1.0 breadth pass. Maps StatsPAI onto the full Mixtape + What If + Elements of Causal Inference curriculum: Hernan-Robins target-trial emulation, Pearl-Bareinboim SCM machinery, modern off-policy / neural-causal estimators, plus three additions that close long-standing gaps in the Bayesian family, plus a CI scaffold for the Rust HDFE spike.
Added (0.9.16) — v1.0 breadth expansion (27+ new modules)¶
Target trial emulation & censoring (sp.target_trial, sp.ipcw)
target_trial_protocol,target_trial_emulate,clone_censor_weight,immortal_time_check— JAMA 2022 7-component TTE framework with explicit eligibility / time-zero / per-protocol contrast support.ipcw— Robins-Finkelstein inverse probability of censoring weights (pooled-logistic or Cox hazard) with stabilization + truncation.
SCM / DAG machinery (sp.dag extended)
identify— Shpitser-Pearl ID algorithm; returns do-free estimand when identifiable, witness hedge(F, F')otherwise.do_rule1 / do_rule2 / do_rule3,do_calculus_apply— mechanized do-calculus with d-separation on mutilated graphsG_{bar X},G_{underline Z}, andG_{bar Z(W)}.swig— Richardson-Robins Single-World Intervention Graphs via node-splitting of intervened variables.SCM— abduction-action-prediction counterfactual runner with rejection sampling fallback for non-Gaussian structural equations.llm_dag— LLM-backed DAG extraction from free-form descriptions.
Causal discovery with latents (sp.causal_discovery)
fci— FCI for PAGs with unobserved confounders (Zhang 2008): skeleton + v-structures + FCI rules R1-R4.icp,nonlinear_icp— Peters-Bühlmann-Meinshausen invariant causal prediction; linear F-test / K-S nonlinear invariance.
Transportability (sp.transport)
transport_weights_fn/transport_generalize— Stuart / Dahabreh density-ratio transport with inverse odds of sampling weighting.identify_transport— Bareinboim-Pearl s-admissibility; enumerates adjustment sets on selection diagrams, returns transport formula.
Off-policy evaluation (sp.ope)
ips,snips,doubly_robust,switch_dr,direct_method,evaluate— Dudik-Langford-Li DR family plus Swaminathan-Joachims SNIPS and Wang-Agarwal-Dudík Switch-DR for bandits / RL.
Deep causal & latent-confounder models (sp.neural_causal)
cevae— Louizos et al. CEVAE with PyTorch path + numpy variational fallback so import never fails.
Longitudinal / G-methods (sp.gformula, sp.tmle, sp.dtr)
gformula_ice_fn— Bang-Robins iterative conditional expectation parametric g-formula; sequential backward regression with recursive strategy plug-in. Supports static / scalar / callable strategies.ltmle— van der Laan-Gruber longitudinal TMLE.q_learning,a_learning,snmm— dynamic treatment regime estimators.
Additional estimators across the stack
- Causal forests:
multi_arm_forest,iv_forest,survival/causal_forest(Cui-Kosorok 2023). - Proximal:
negative_controls,pci_regression(Miao-Shi-Tchetgen). - Interference:
network_exposure(Aronow-Samii 2017),peer_effects. - Dose-response:
vcnet+scigan(Nie-Brunskill-Wager 2021). - Matching:
genmatch(Diamond-Sekhon 2013). - Sensitivity:
rosenbaum_bounds. - Spatial:
spatial_did,spatial_iv(Kelejian-Prucha 1998). - Time series:
its(interrupted time series). - Bounds:
balke_pearl. - Mediation:
four_way_decomposition(VanderWeele 2014).
Registry / agent surface
- 11 hand-written
FunctionSpecentries for the new flagship APIs, each with parameter schemas, tags, and canonical references. sp.list_functions()now reports 664 entries.sp.search_functions("target trial")/"invariance"/"transport"all resolve correctly.
Added (0.9.16) — Bayesian family gap-closing¶
-
bayes_mte(mte_method='bivariate_normal')— full textbook Heckman-Vytlacil trivariate-normal model(U_0, U_1, V) ~ N(0, Σ)withD = 1{Z'π > V}. Identifies the structural gapβ_D = μ_1 - μ_0and the two selection covariancesσ_0V, σ_1Vvia inverse-Mills-ratio corrections in the structural equation, soMTE(v) = β_D + (σ_1V - σ_0V)·vis closed-form linear on V scale. Requiresselection='normal'andfirst_stage='joint';poly_uis overridden to 1 with aUserWarningif the user set something else. Exposesb_mteas a 2-vector Deterministic[β_D, σ_1V - σ_0V]so every downstream code path (mte_curve, ATT/ATU integrator,policy_effect) works unchanged. This is the last missing piece of the Heckman-Vytlacil pipeline thatselection='uniform'/'normal'+mte_method='polynomial'/'hv_latent'started. -
bayes_did(cohort=...)+BayesianDIDResult— when the user supplies acohortcolumn (typically first-treatment period in a staggered design), the scalartauis replaced with a vectortau_cohortof lengthn_cohortsunder the sameNormal(prior_ate)prior. The result carriescohort_summaries: Dict[str, dict]andcohort_labels; the top-level pooled ATT is the treated-size-weighted mean of the per-cohort τ posteriors.result.tidy(terms='per_cohort')returns one row per cohort withterm='cohort:<label>'; explicitterms=['att', 'cohort:2019', ...]selection is supported for modelsummary / gt pipelines. Back-compat: calling withoutcohort=...returns aBayesianDIDResultthat behaves byte- identically to the v0.9.15BayesianCausalResult. -
bayes_iv(per_instrument=True)+BayesianIVResult— on a multi-instrument fit, additionally runs one just-identified Bayesian IV sub-fit perZ_jand stores per-instrument posteriors asinstrument_summaries: Dict[str, dict]. Surface mirrors the DID extension:tidy(terms='per_instrument')emits one row perZwithterm='instrument:<name>'. The top-level pooled LATE remains the joint over-identified fit; per-instrument rows are an add-on diagnostic. Sub-fit priors and sampler controls mirror the pooled fit, so runtime scales roughly(K+1)×. -
.github/workflows/build-wheels.yml— Rust Phase-2 cibuildwheel matrix workflow (macOS arm64 + x86_64, manylinux_2_17 x86_64 + aarch64, musllinux_1_2 x86_64, Windows x86_64) with acheck_rust_presentguard job that makes the workflow a no-op whenrust/statspai_hdfe/Cargo.tomlis absent (the state onmain). The workflow activates automatically onfeat/rust-hdfe/feat/rust-phase2and on PRs touchingrust/**, so the Rust spike's CI lights up the moment the branch is ready — no second PR for CI scaffolding.
Tests (0.9.16)¶
tests/test_bayes_mte_bivariate_normal.py— 7 tests covering API validation (selection + first_stage gates, poly_u override), structural-param presence in posterior, method label contents, and slope recovery on a genuine trivariate-normal DGP at n=800.tests/test_bayes_did_cohort.py— 9 tests covering back-compat (no cohort → single-row tidy identical to v0.9.15), cohort fit populates summaries, multi-row tidy viaper_cohort+ explicit list, unknown-term raises, τ ordering recovered on a two-cohort staggered DGP with heterogeneous true ATTs (2.0 vs 0.5), and cohort weights recorded in model_info.tests/test_bayes_iv_per_instrument.py— 8 tests covering back-compat, per-instrument summary population,per_instrumenttidy, explicit-list tidy, unknown-term raises, error path when asking forper_instrumenttidy without the sub-fit, and each sub-fit's HDI covers the true LATE on a two-Z DGP.
Not in this release¶
- Round-trip testing of the cibuildwheel matrix on real runner
hardware — this must happen on
feat/rust-hdfe, where the crate lives. The workflow onmainis inert by design.
[0.9.15] - 2026-04-20 — Multi-term tidy(terms=[...]) + ATT/ATU prob_positive¶
Completes the broom-pipeline integration of v0.9.13's per-population
ATT/ATU uncertainty. Users can now pd.concat ATE/ATT/ATU rows
across fits in one call.
Added (0.9.15)¶
BayesianMTEResult.tidy(conf_level=None, terms=None)override:terms=None(default) — unchanged, single ATE row.terms='ate' | 'att' | 'atu'— single row of that term.terms=['ate', 'att', 'atu']— multi-row DataFrame.-
Invalid names → clear
ValueError. -
Two new result fields:
att_prob_positive,atu_prob_positive(NaN-defaulted for pre-v0.9.15 snapshot compatibility). Populated by_integrated_effectfrom per-draw ATT/ATU posteriors. -
_integrated_effectreturns 5-tuple(mean, sd, hdi_lower, hdi_upper, prob_positive). Caller unpacks + passes to the result.
Round-B review found 1 HIGH; Round-C fixed¶
-
HIGH-1 — label divergence: default
tidy()emitsterm='ate (integrated mte)'(via parentestimand.lower()), buttidy(terms='ate')emitted the short literal'ate'. Byte- compat broken when a user mixed both call styles insidepd.concat. Fixed —_row('ate')now also usesself.estimand.lower()so both paths produce identical rows. ATT / ATU rows keep their short labels (no parent-default precedent; short is the natural broom shape for new terms). -
Round C: 0 blockers.
Tests (0.9.15)¶
tests/test_bayes_mte_tidy.py(13 tests) — back-compat default schema, single-term paths for all three labels, multi-row order preservation, concat workflow, invalid-term + mixed-valid rejection, NaN prob_positive stub back-compat, prob_positive scalars populated on real fits, default-vs-explicit label byte-parity (Round-C regression).- Bayesian family suite: 101/101 focused tests green.
Design spec (0.9.15)¶
docs/superpowers/specs/2026-04-20-v0915-tidy-multiterm.md
Non-goals (0.9.15)¶
- Multi-term
.tidy()on other Bayesian estimators — DID/RD/IV have no ATT/ATU concept; the primary-estimand row is already what they emit. - Full bivariate-normal HV model.
- Rust Phase 2.
[0.9.14] - 2026-04-20 — Summary rendering completes v0.9.13 spec §3.3¶
Tiny patch release. Completes the "ATT/ATU in summary()" promise
from v0.9.13 spec §3.3 that was not actually wired at ship time
(the six uncertainty fields landed but summary() never printed
them).
Added (0.9.14)¶
-
BayesianMTEResult.summary()override. ExtendsBayesianCausalResult.summarywith aPopulation-integrated effectsblock:ATT: 0.2407 (sd 0.0370, 95% HDI [0.1693, 0.3136]) ATU: 0.2147 (sd 0.0435, 95% HDI [0.1341, 0.2947])
Rendered inside the framing = ruler for visual coherence.
Silently skipped when either SD is NaN (empty subpopulation or
pre-v0.9.13 deserialised result).
Round-B review: no blockers¶
Reviewer confirmed:
1. base.endswith('=' * 70) is exact — parent summary() returns
'\n'.join(lines) with the rule as the final element.
2. Block splicing preserves the closing ruler visually.
3. NaN stub path is safe; fallback branch is defensive.
4. 'ATT:' / 'ATU:' are unique substrings — no collision with
parent output.
5. Pure reader; thread-safe.
Tests (0.9.14)¶
tests/test_bayes_mte_uncertainty.pynow has:test_summary_shows_att_atu_uncertainty— after fit, string contains'ATT:','ATU:','sd ','HDI ['.test_summary_skips_att_atu_when_nan— NaN-SD stub → no'ATT:'/'ATU:'in output.- Full Bayesian suite: 88/88 focused MTE + sibling green in 1:55.
Non-goals (0.9.14)¶
.tidy()multi-row variant with ATE/ATT/ATU as separate rows — queued for v0.9.15+.- Full bivariate-normal HV model.
- Rust Phase 2.
[0.9.13] - 2026-04-20 — ArviZ HDI compat shim + ATT/ATU uncertainty¶
Small-but-load-bearing cleanup release. Closes two items deferred across the v0.9.10 / v0.9.11 / v0.9.12 code reviews.
Added (0.9.13)¶
-
_az_hdi_compat(samples, hdi_prob)instatspai.bayes._base— callsaz.hdi(samples, hdi_prob=...)first, falls back toaz.hdi(samples, prob=...)onTypeError. Routes everyaz.hdi(...)call site in the Bayesian sub-package through one place so the inevitable arviz ≥ 0.18 kwarg rename is a one-line change. Previously identified as time-bomb by v0.9.12 round-C review. -
ATT / ATU uncertainty on
BayesianMTEResult: att_sd,att_hdi_lower,att_hdi_upperatu_sd,atu_hdi_lower,atu_hdi_upper
_integrated_effect now returns (mean, sd, hdi_lower,
hdi_upper) instead of (mean, sd). posterior_sd on the parent
result already covers ATE uncertainty — no redundant ate_sd.
- Appended-at-end field order on
BayesianMTEResult— all six new fields are NaN-defaulted and positioned after the v0.9.12 schema (selection). Serialised results from earlier releases deserialise cleanly.
Round-B code review found no blockers¶
Reviewer confirmed:
1. _az_hdi_compat fallback shape correct for any future arviz
kwarg rename.
2. Dataclass field order verified via live introspection.
3. No __hash__ risk on NaN fields; broom-style .tidy() /
.glance() intentionally do not surface the new SD/HDI fields
(opt-in access).
4. Imports clean in mte.py + hte_iv.py.
5. Empty-population NaN guardrail is defensive-only; unreachable
from bayes_mte because _logit_propensity enforces 2-class
requirement upstream. Test renamed to reflect this honestly.
One MEDIUM item (test-docstring mislabel) fixed inline.
Incident log¶
A mass regex rewrite from az.hdi(...) to _az_hdi_compat(...)
accidentally matched the helper's own body, creating a
_az_hdi_compat → _az_hdi_compat self-recursion. Caught by running
the Bayesian focused suite (would have been a stack-overflow the
moment any Bayesian estimator shipped). Reverted + re-applied
manually in the same session before tests ever ran outside dev.
Tests (0.9.13)¶
tests/test_bayes_hdi_compat.py(4 tests) — forwards on current arviz, falls back on monkey-patched future arviz, returns length-2 array, propagatesTypeErrorwhen both kwargs rejected (no silent success).tests/test_bayes_mte_uncertainty.py(4 tests) — ATT/ATU SD populated + > 0, HDI brackets mean, no redundantate_sd, realistic- DGP both-finite.- Bayesian family suite: 145/145 focused MTE + sibling tests green.
Design spec¶
docs/superpowers/specs/2026-04-20-v0913-hdi-compat-and-att-sd.md
Non-goals (0.9.13)¶
- Full bivariate-normal HV
(U_0, U_1, V) ~ N(0, Σ)— stays queued. - Rust Phase 2.
- Expose ATT/ATU HDI on
.tidy()— today.tidy()describes the primary estimand (ATE); adding a multi-row variant for ATT/ATU is a v0.9.14+ API question.
[0.9.12] - 2026-04-20 — Probit-scale MTE (Heckman selection frame)¶
Adds the third orthogonal axis to sp.bayes_mte: the MTE polynomial
can now be fit on either the uniform scale U_D ∈ [0, 1]
(v0.9.11 default) or the probit / V scale
V = Φ^{-1}(U_D) ∈ ℝ — the conventional Heckman (1979) / HV 2005
frame. All (first_stage, mte_method, selection) combinations fit.
Added (0.9.12)¶
sp.bayes_mte(..., selection='uniform' | 'normal')— new kwarg.'uniform'(default) preserves v0.9.11 behaviour: polynomial inU_D ∈ [0, 1].-
'normal'reinterprets the abscissa asV = Φ^{-1}(U_D)viapt.sqrt(2) * pt.erfinv(2a-1)on the tensor side andscipy.stats.norm.ppfon numpy side. Under strict HV + bivariate- normal,poly_u=1 + selection='normal' + mte_method='hv_latent'exactly recovers the linear Heckman MTE slope. -
mte_curveexposesvcolumn underselection='normal'(empty otherwise) so users can plot on the scale their model was fit on. -
Shared
PROBIT_CLIPconstant instatspai.bayes._base— fit-time, ATT/ATU integrator, andpolicy_effectall read the same clip so the three paths stay numerically consistent.
Empirical recovery on Heckman DGP (true (b_0, b_1) = (0.5, 1.5))¶
| combo | b_0 |
b_1 |
|---|---|---|
| plugin × polynomial × V | -0.73 | 0.82 |
| plugin × hv_latent × V | 0.42 | 1.37 ✓ |
| joint × polynomial × V | -0.73 | 0.81 |
| joint × hv_latent × V | 0.46 | 1.40 ✓ |
Same story as earlier releases: hv_latent recovers truth;
polynomial fits g(v) not MTE(v) and is biased.
Round-B review found 2 BLOCKERS + 2 HIGHs; Round-C fixed all¶
- BLOCKER-1:
_integrated_effect(ATT/ATU) was raisingU_populationto polynomial powers directly, even under'normal'where the posterior is on V scale. Fixed — transforms toΦ^{-1}(U_population)first. - BLOCKER-2:
BayesianMTEResult.policy_effectcomputedu_pow = [u^k ...]instead of[v^k ...]under'normal', silently integrating a V-scale polynomial against u-scale powers. Fixed —BayesianMTEResultnow carries aselectionfield, andpolicy_effecttransforms the grid to V scale when needed. Regression test assertspolicy_effect(policy_weight_ate())matches.ateto 1e-8 under'normal'. - HIGH-1:
mte_curvelacked avcolumn — added. - Round-C follow-up: extracted
PROBIT_CLIP = 1e-6to a shared module constant consumed by bothmte.pyand_base.pyso the three-site fit/summary/policy paths cannot drift.
Tests (0.9.12)¶
tests/test_bayes_mte_selection.py(NEW, 12 tests) — back-compat, method-label, Heckman DGP recovery, all-8-combo orthogonality, input validation,vcolumn presence/absence, ATT/ATU V-scale correctness (Round-C regression),policy_effectV-scale parity with.ate(Round-C regression), uniform-vs-normal non-trivial disagreement.- 78 focused MTE tests green.
Non-goals (0.9.12)¶
- Full bivariate-normal error covariance
(U_0, U_1, V) ~ N(0, Σ)with freeρ_{0V},ρ_{1V}— convergence-intensive MvNormal mixture, queued for 0.9.13+. - Rust Phase 2 — separate branch.
[0.9.11] - 2026-04-20 — Multi-instrument MTE + true CHV-2011 PRTE weights¶
Closes two long-standing API gaps plus an empirical math debt.
Added (0.9.11)¶
sp.bayes_mte(instrument: str | Sequence[str], ...)— MTE now accepts multiple instruments, matchingsp.bayes_iv/sp.bayes_hte_iv. Scalar calls unchanged.sp.policy_weight_observed_prte(propensity_sample, shift)— true Carneiro-Heckman-Vytlacil (2011) PRTE weights from the observed propensity distribution viakde.integrate_box_1d(u-Δ, u) / Δ(CDF difference). Closes the v0.9.9 docstring gap wherepolicy_weight_prtewas flagged stylised.
Round-B review found 2 HIGH + 3 MEDIUM; all fixed¶
- CHV sign bug — my original
(kde(u) - kde(u-Δ))/ΔAND the reviewer's proposed swap were both wrong (both compute derivative of density, not CDF difference). Self-sweep verified CHV-2011 Theorem 1 is a CDF difference. Fixed viaintegrate_box_1d. Empirical: uniform propensity + Δ=0.2 now gives the textbook trapezoid; previously gave a spurious boundary spike. - Unconditional
np.clip(w, 0, None)silently altered the estimand. Dropped — contraction policies now yield signed negative weights, matching CHV convention. gaussian_kdethread safety — forced covariance precomputation inside the builder.model_info['instrument']type varied — dropped the raw key; onlyinstruments(list) +n_instrumentsremain.- Back-compat test uses relative-to-posterior-SD tolerance.
Tests (0.9.11)¶
tests/test_bayes_mte_multi_iv.py(9 tests).tests/test_bayes_mte_policy.py(+7 tests).- 61 focused MTE tests green.
Code review¶
- Round B agent: 5 items. Self-sweep caught one HIGH the agent got wrong. All 5 fixed.
- Round C agent: zero ship-blockers.
Design spec (0.9.11)¶
docs/superpowers/specs/2026-04-20-v0911-multi-iv-mte-observed-prte.md
[0.9.10] - 2026-04-20 — HV-latent MTE (textbook Heckman-Vytlacil via latent U_D)¶
Closes the semantic debt v0.9.9 flagged but did not pay: the
previous releases fitted a polynomial in the propensity p_i
(g(p) = LATE-at-propensity), which coincides with the textbook
MTE only under HV-2005 linear-separable + bivariate-normal errors.
v0.9.10 adds an opt-in fully HV-faithful model that samples a
latent U_D_i ~ Uniform(0, 1) per unit via the truncated-uniform
reparameterisation trick, making the fitted polynomial a genuine
posterior over tau(u) = E[Y_1 - Y_0 | U_D = u].
Added (0.9.10)¶
sp.bayes_mte(..., mte_method='polynomial' | 'hv_latent')— new kwarg, orthogonal to the existingfirst_stagekwarg.'polynomial'(default) — v0.9.9 behaviour; polynomial in propensity.-
'hv_latent'— textbook HV. For each unit, sampleraw_U_i ~ Uniform(0, 1), then transform deterministically:D_i = 1 ⇒ U_D_i = raw_U_i · p_i ∈ [0, p_i] D_i = 0 ⇒ U_D_i = p_i + raw_U_i·(1 - p_i) ∈ [p_i, 1]The polynomial is then evaluated at
U_D_i(notp_i). Structural equation:Y_i = α + β_X' X_i + D_i · τ(U_D_i) + ε_i.
Orthogonal to first_stage: all four
(plugin|joint) × (polynomial|hv_latent) combinations run.
- Memory-warning guard —
hv_latentregisters a shape-(n,) latent stored as(chains, draws, n)in the posterior. The function emits aUserWarningwhenn × draws × chains > 50,000,000(~400 MB at f64), mentioningdraws,chains, andmte_method='polynomial'as mitigations.
HV-augmentation factorisation (documented in docstring)¶
bayes_mte uses the standard Form-2 data-augmentation
factorisation:
p(Y, D, U_D | p, θ) = p(Y | U_D, D, θ) · p(U_D | D, p) · p(D | p)
where the truncated-uniform transform gives p(U_D | D, p) and
pm.Bernoulli(D | p) gives the marginal p(D | p). Both are
needed — dropping the Bernoulli in a counter-factual experiment
made piZ flip sign (true 0.8 → posterior -1.01) and biased the
MTE polynomial to [0.81, 1.25] vs true [2, -2]. This test is
documented in the v0.9.10 round-B code review.
Empirical recovery evidence¶
Decreasing-MTE DGP with truth (b_0, b_1) = (2.0, -2.0):
| combo | b_0 posterior | b_1 posterior | recovers? |
|---|---|---|---|
| plugin × polynomial | 1.73 | -0.43 | biased |
| plugin × hv_latent | 2.03 | -2.13 | ✓ |
| joint × polynomial | 1.73 | -0.44 | biased |
| joint × hv_latent | 2.05 | -2.16 | ✓ |
The polynomial modes are systematically biased on HV DGPs — the honesty caveat v0.9.9 added is empirically validated; hv_latent is the mathematical fix.
Method label¶
polynomial→"Bayesian treatment-effect-at-propensity (...)"(v0.9.9 label retained).hv_latent→"Bayesian HV-latent MTE (...)".
Tests (0.9.10)¶
tests/test_bayes_mte_hv_latent.py(10 tests) — API, recovery of true(b_0, b_1) = (2, -2)on an HV DGP, disagreement with polynomial mode on same DGP, orthogonality withfirst_stage='joint', input validation, memory-warning fires above threshold (unittest.mock), memory-warning stays silent below threshold,policy_effectstill works on hv_latent results.
Code review (two rounds)¶
- Round B (agent) raised 3 HIGH items:
- "Double-counting Bernoulli" — rejected after math + counter- factual. Form-2 factorisation is correct; dropping Bernoulli wildly biased the result. Defended in docstring.
- "Marginal U_D not Uniform(0,1)" — rejected after algebra.
p(U_D|p) = p·U(0,p) + (1-p)·U(p,1) = Uniform(0,1)holds. - "Memory blow-up" — accepted; added
UserWarning. - Round C (agent) on the round-B resolutions: no ship-blockers. One cosmetic nit on the integration notation in the docstring fixed inline.
Design spec¶
docs/superpowers/specs/2026-04-20-v0910-hv-latent-mte.md
Non-goals (0.9.10)¶
- Full bivariate-normal error structure on
(U_0, U_1, U_D)— linear-separable only. Natural 0.9.11+ extension. - Multi-instrument HV MTE.
- GP over
u(still polynomial of orderpoly_u). - Rust Phase 2 — branch work.
Article-surface round-2: namespace fixes + kwarg alignment¶
Completes the API-cleanup thread started by v0.9.9's first alias pass.
The 2026-04-20 survey post advertises sp.matrix_completion,
sp.causal_discovery, sp.mediation, sp.evalue_rr, plus
article-style kwargs on sp.policy_tree / sp.dml — all of which
either resolved to the submodule or rejected the blog-post kwargs
before this round.
Added — article-facing aliases¶
sp.matrix_completion(df, y, d, unit, time)— thin wrapper oversp.mc_panel, renamesd → treat. Shadows the former module binding.sp.causal_discovery(df, method='notears'|'pc'|'ges'|'lingam', variables=None)— dispatcher. Handles each backend's native signature (notears/pc acceptvariables=; ges/lingam do not, so the dispatcher subsets the frame upfront).sp.mediation(df, y, d, m, X)— article wrapper oversp.mediate; shadows the former module binding.sp.evalue_rr(rr, rr_lower=None, rr_upper=None)— risk-ratio convenience for the shape documented in the blog post.sp.policy_treeaccepts eitherd=/treat=,X=/covariates=, anddepth=/max_depth=. Conflicting values raiseTypeError.sp.dmlacceptsmodel_y=/model_d=as aliases forml_g/ml_m, and the same dual-convention naming.
Hardened¶
sp.auto_didnow fails fast withTypeErrorwhengis a non-numeric cohort label (BJS branch silently misbehaves otherwise).AutoDIDResult.__repr__/AutoIVResult.__repr__now return a one-line summary (Jupyter list-of-results display); call.summary()for the full leaderboard.statspai.agent.tools._default_serializeris now scalar-safe (new_scalar_or_nonehelper) — handles Series-valued result fields without crashing JSON serialisation.
Reverted — deliberate non-goal¶
- An experimental addition of
.estimate/.se/.pvalue/.ciproperties toEconometricResultswas reverted when regression testing showed it brokeagent/tools.pyandcausal_workflow.pywhich usehasattr(r, 'estimate')to dispatch between scalarCausalResultand multi-coefEconometricResults. A NOTE incore/results.pydocuments why the aliases are intentionally absent; use.tidy()for cross-estimator code.
Tests (article-surface round-2)¶
tests/test_article_aliases_round2.py adds 25 tests covering all of
the above, including the conflict-detection and backend-signature
branches flagged by the round-2 code review.
[0.9.9] - 2026-04-20 — Joint first-stage MTE + policy-relevant weights + honesty pass¶
Closes v0.9.8's two explicit follow-ons (joint first stage, policy-relevant weights) and ships a semantic correction on the MTE labelling that survived two rounds of code review.
Added (0.9.9)¶
-
sp.bayes_mte(..., first_stage='plugin' | 'joint')— new kwarg.'plugin'(default) preserves v0.9.8 behaviour: logit MLE computes propensity as a fixed constant.'joint'puts the first-stage logit coefficients inside the PyMC graph (pi_intercept,pi_Z, optionalpi_X), modelsD ~ Bernoulli(sigmoid(pi'W)), and evaluates the effect polynomial at the random propensity — so first-stage uncertainty propagates into the returned curve. 2-4× slower than plug-in but honest about identification noise. -
BayesianMTEResult.policy_effect(weight_fn, label, rope=None)(src/statspai/bayes/_base.py) — posterior summary ofint w(u) g(u) du / int w(u) duusing trapezoidal integration on the fit'su_grid. Withpolicy_weight_ate()it is now numerically identical to.ate(both trapezoid on the same grid) — test asserts< 1e-8parity. -
sp.policy_weight_*— four weight-function builders (src/statspai/bayes/policy_weights.py): policy_weight_ate()— uniform weight = 1.policy_weight_subsidy(u_lo, u_hi)— indicator on[u_lo, u_hi].policy_weight_prte(shift)— stylised rectangle around the mean propensity. The docstring leads with "NOT the textbook Carneiro-Heckman-Vytlacil 2011 PRTE" and shows a workedscipy.stats.gaussian_kdesnippet users can adapt for the true CHV PRTE with their observed propensity sample.policy_weight_marginal(u_star, bandwidth)— marginal PRTE at a specific propensity via a narrow band.
Semantic correction (honesty pass)¶
- Labelling fix: v0.9.8's fit was described as the "MTE curve",
but the structural model fits
g(p) = E[Y|D=1,P=p] - E[Y|D=0,P=p]— the treatment-effect-at-propensity function. Under the Heckman-Vytlacil (2005) linear-separable + bivariate-normal assumption,g(p) = MTE(p); under arbitrary heterogeneity,g(p)is a LATE summary at propensityp, not the textbook MTE(u). The module docstring now leads with this caveat and the method label reads"Bayesian treatment-effect-at-propensity"rather than"Bayesian MTE". Function name, result class name, and themte_curvefield are unchanged for API continuity — the "MTE" naming is retained because applied users expect it and search for it.
Performance¶
- Removed
pm.Deterministic('p', ...)from joint mode. Under largen, storing per-unit propensity per draw wasO(chains × draws × n)memory (e.g. 64MB at n=1000, draws=2000, chains=4). Post-hoc ATT/ATU propensity is now recomputed from the posterior means ofpi_intercept/pi_Z/pi_X.
Tests (0.9.9)¶
tests/test_bayes_mte_policy.py(NEW, 14 tests) — builders' input validation (bad bounds rejected, FP-safe grids), joint mode runs + agrees with plug-in on well-specified DGPs, policy_effect contract, trapezoid parity with.ateat 1e-8, top-level export of all four weight builders.
Code review¶
- Round-A (agent) found 4 items: B1 (semantic mislabel), H1 (normalisation inconsistency), H2 (memory blow-up under joint+ADVI), M1 (PRTE-builder naming).
- Round-B (agent) on the fixes confirmed no remaining blockers; one follow-up (test tolerance too loose after the H1 fix) was applied inline before shipping.
Design spec (0.9.9)¶
docs/superpowers/specs/2026-04-20-v099-mte-joint-policy-weights.md
Non-goals (0.9.9)¶
- Fully H-V-faithful joint model (sampling latent
U_Dper unit) — still a future release. Documented as the natural 0.9.10+ extension. - Multi-instrument MTE with per-instrument PRTE weights.
- Gaussian-process surfaces on
u(current release is polynomial). - Rust Phase 2 — branch work.
[0.9.8] - 2026-04-20 — Bayesian Marginal Treatment Effects + Pathfinder / SMC backends¶
Closes the two explicit next-batch items from v0.9.7's non-goals list. Ships the first Bayesian Marginal Treatment Effect estimator in the Python causal-inference stack and extends the sampler dispatch with two new backends.
Added (0.9.8)¶
sp.bayes_mte(data, y, treat, instrument, covariates=None, u_grid=..., poly_u=2, ...)(src/statspai/bayes/mte.py) — Heckman-Vytlacil (2005) Marginal Treatment Effects via PyMC. Returns aBayesianMTEResultwith:.mte_curve— DataFrame on the user-supplied (or default 19-point) grid of propensity-to-be-treated valuesU_D: columnsu, posterior_mean, posterior_sd, hdi_low, hdi_high, prob_positive..ate,.att,.atu— integrated MTE over the population / treated / untreated regions..plot_mte()— quick matplotlib visualisation of the MTE curve with an HDI ribbon.
Uses a plug-in logit first stage (same pragmatic shortcut as
bayes_iv): the Bayesian layer lies over the MTE polynomial
coefficients only. Asymptotically correct under correctly
specified first stage; explicit non-goal is full joint
first-stage-+-MTE posterior (queued for 0.9.9+).
-
inference='pathfinder'— new sampler backend routing to PyMC'spm.fit(method='fullrank_advi'). Captures pairwise covariance between parameters (mean-field ADVI misses this) at similar speed. Placeholder for when PyMC'spmx.fitstabilises; full-rank ADVI is the same spirit. -
inference='smc'— new sampler backend routing to PyMC'spm.sample_smc. Sequential Monte Carlo; slower than NUTS on unimodal posteriors but robust to multi-modal ones where NUTS gets stuck. Unlike ADVI / Pathfinder, SMC returns a multi-chain trace so R-hat stays meaningful. -
BayesianMTEResult— top-level export (sp.BayesianMTEResult). InheritsBayesianCausalResultand addsmte_curve,u_grid,ate,att,atu,.plot_mte(). -
Summary output now recognises the full sampler menu:
- NUTS / SMC: R-hat is meaningful; flagged on > 1.01.
- ADVI / Pathfinder: R-hat is variational and flagged as such with a concrete "use NUTS or SMC for calibrated uncertainty" caveat.
Design spec (0.9.8)¶
docs/superpowers/specs/2026-04-20-v098-bayes-mte-samplers.md
Tests (0.9.8)¶
tests/test_bayes_mte.py(9 tests) — API surface, flat-MTE recovery, monotone-MTE slope recovery, customu_grid,poly_u=1path, covariate plumbing, top-level export, missing-column and non-binary-treat validation.tests/test_bayes_advi.py(+5 tests) — Pathfinder on bayes_iv and bayes_did, SMC on bayes_iv and bayes_did, Pathfinder summary() caveat.
Non-goals (0.9.8, explicit)¶
- Full joint first-stage + MTE posterior (propagating first-stage
uncertainty into
tau(u)). Plug-in propensity is the v0.9.8 choice — correct asymptotically under correctly specified first stage; next release can add a joint model. - Multi-instrument MTE — requires policy-relevant weighting (Carneiro-Heckman-Vytlacil 2011) and is out of scope.
- Non-linear MTE surfaces (GP over
u) — polynomial of orderpoly_uis what this release supports. - Rust Phase 2 — stays on
feat/rust-hdfebranch.
[0.9.7] - 2026-04-20 — Heterogeneous-effect Bayesian IV + ADVI toggle¶
Closes two of the three items queued at v0.9.6's "诚实汇报" list. The third (Bayesian bunching) is explicitly declined — see the "Non-goals" section below.
Added (0.9.7)¶
sp.bayes_hte_iv(data, y, treat, instrument, effect_modifiers, ...)(src/statspai/bayes/hte_iv.py) — Bayesian IV with a linear CATE-by-covariate model. Returns aBayesianHTEIVResultcarrying:- Average LATE (
tau_0, at modifier means) with posterior + HDI. .cate_slopesDataFrame — one row per effect modifier with posterior mean, SD, HDI, andprob_positive..predict_cate(values: dict) -> dict— posterior summary of the CATE at user-specified modifier values.
Model:
D = pi_0 + pi_Z' Z + pi_X' X + v
tau(M) = tau_0 + tau_hte' (M - M_bar)
Y = alpha + tau(M) * D + beta_X' X + rho * v_hat + eps
Control-function formulation keeps NUTS sampling tractable. Multiple instruments + multiple modifiers + exogenous controls all supported.
inference='nuts' | 'advi'parameter on every Bayesian estimator —bayes_did,bayes_rd,bayes_iv,bayes_fuzzy_rd, and the newbayes_hte_iv. Under'advi'the estimator goes throughpm.fit(method='advi')for a 10-50× speedup at the cost of mean-field calibration.rhatis reported asNaNin ADVI mode (no meaning for variational approximations).
A shared _sample_model helper now owns sampling dispatch, so
future backends ('smc', 'pathfinder') plug in trivially.
BayesianHTEIVResult— top-level export (sp.BayesianHTEIVResult). ExtendsBayesianCausalResultwithcate_slopes,effect_modifiers, andpredict_cate(...).
Design spec (0.9.7)¶
docs/superpowers/specs/2026-04-20-v097-bayes-hte-iv-advi.md
Tests (0.9.7)¶
tests/test_bayes_hte_iv.py(8 tests) — API surface, avg-LATE recovery on heterogeneous DGP, slope recovery, null-slope coverage on homogeneous DGP,predict_cateschema, multi-modifier fit, input validation.tests/test_bayes_advi.py(10 tests) — ADVI runs on all five Bayesian estimators, posterior means finite,model_info['inference']reports correctly, invalid inference modes raise across the parametrised five-function set.
Non-goals (0.9.7, explicitly declined)¶
-
Bayesian bunching (
sp.bayes_bunching) — after review we decline. Kleven / Saez / Chetty bunching estimators are structural public-finance models whose identification depends on utility / optimisation parameterisations that don't generalise across kink types, priors on taste heterogeneity that are domain-specific and hard to default well, and model fits only as interpretable as the structural model itself. This defeats the package's "agent-native one-liner" thesis. The frequentistsp.bunchingstays where it is. We revisit only on a concrete user use-case that fits the agent-native workflow. -
MTE / complier-heterogeneity IV — queued for 0.9.8+.
- Extra VI backends beyond ADVI (Pathfinder, SMC) —
_sample_modelis now extensible but the backends stay out of this release. - Rust Phase 2 — on
feat/rust-hdfebranch until the cibuildwheel matrix is green.
[0.9.6] - 2026-04-20 — Bayesian IV + fuzzy RD + per-learner Optuna + Rust branch + g-methods family¶
This release bundles two independent sprints that landed the same day:
Sprint A — Bayesian depth + tuning granularity + Rust branch¶
- Bayesian 口袋深度 — adds
sp.bayes_ivandsp.bayes_fuzzy_rd. - Optuna 粒度 —
sp.auto_cate_tunednow supportstune='nuisance'(v0.9.5 behaviour),tune='per_learner', andtune='both'. - Rust 工作流 —
feat/rust-hdfebranch opened with Cargo crate scaffold;mainstays maturin-free.
Sprint B — G-methods family, Proximal, Principal Stratification¶
Closes a causal-inference-coverage audit against the 2026-04-20 gap
table: ships DML IIVM, g-computation, front-door estimator, MSM,
interventional mediation, plus two new top-level modules
Proximal Causal Inference and Principal Stratification. After
self-review, a second pass re-polished weight-semantics, bootstrap
diagnostics, MC vectorisation, and did a full DML internal refactor
(four per-model files sharing _DoubleMLBase).
Added¶
-
sp.bayes_iv(data, y, treat, instrument, covariates=None, ...)(src/statspai/bayes/iv.py) — Bayesian linear IV via a control-function formulation. First-stage OLS residuals enter the structural equation as an exogeneity correction, so the posterior on the LATE equals 2SLS asymptotically while remaining trivially sampleable in PyMC. Accepts a single instrument or a list. The HDI widens naturally as the instrument gets weaker (no "F < 10" cliff — the posterior prices identification automatically). -
sp.bayes_fuzzy_rd(data, y, treat, running, cutoff, ...)(src/statspai/bayes/fuzzy_rd.py) — Bayesian fuzzy RD via joint ITT-on-Y and ITT-on-D local polynomials with a deterministic ratio for the LATE. Under partial compliance the posterior inherits both noise channels (Wald-ratio posterior); under full compliance it collapses to the sharp RD result. Non-binary uptake is rejected with a clear error.model_inforeportsfirst_stage_mean/first_stage_sdso users can eyeball compliance. -
sp.auto_cate_tuned(..., tune='nuisance' | 'per_learner' | 'both')— newtuneflag toggles between three regimes: -
'nuisance'(default, v0.9.5 behaviour): shared outcome / propensity GBMs tuned against held-out R-loss. 'per_learner': each learner's final-stage CATE model is tuned independently against held-out R-loss; nuisance stays at defaults.model_info['per_learner_params']and['per_learner_r_loss']are populated; the best learner's tuned CATE model is fed toauto_cateas a hint.'both': tune the nuisance first, then per-learner CATE on top of that nuisance.
Also adds n_trials_per_learner (defaults to max(5, n_trials//3))
and per_learner_search_space. Selection-rule text now records
which tuning regime ran.
feat/rust-hdfebranch (pushed, not merged) — Cargo crate scaffold plus PyO3 stub for the eventualgroup_demeankernel.mainstays maturin-free sopip install statspaiis unaffected.
Design spec¶
docs/superpowers/specs/2026-04-20-v096-bayes-iv-fuzzyrd-perlearner.md
Tests¶
tests/test_bayes_iv.py(8 tests) — API, top-level export, strong-IV recovery, weak-IV HDI widens, multi-instrument fit, covariate plumbing, input validation, tidy/glance shape.tests/test_bayes_fuzzy_rd.py(7 tests) — API, recovery under partial compliance, sharp-equivalence under full compliance, bandwidth shrinks sample, first-stage diagnostics reported, non-binary uptake rejected.tests/test_auto_cate_tuned.py(+5 tests) — invalidtunemode rejected,'per_learner'populates params, no nuisance metadata leaks in per_learner mode,'both'mode covers both channels, selection_rule mentions per-learner tuning.
Non-goals (deferred)¶
- Bunching Bayesian estimator (Kleven-style is structural / macro-flavoured; poor fit for the agent-native API). Queue for 0.9.7.
- Heterogeneous-effect Bayesian IV — LATE only in this release.
- VI sampler (ADVI) — NUTS only.
- Rust kernel merged to
main— stays onfeat/rust-hdfeuntil the cibuildwheel matrix is green.
Added (Sprint B)¶
-
sp.dml(..., model='iivm', instrument=Z)(src/statspai/dml/iivm.py) — Interactive IV (binary D, binary Z) DML estimator for LATE. Uses the efficient-influence-function ratio of two doubly-robust scores(ψ_a, ψ_b)with Neyman-orthogonal cross-fitting; SE via delta-method on the ratio. Weak-instrument guard raisesRuntimeErrorwhen|E[ψ_b]| ≈ 0. Class form:sp.DoubleMLIIVM. -
sp.DoubleMLPLR / DoubleMLIRM / DoubleMLPLIV / DoubleMLIIVM(src/statspai/dml/*.py) — each DML model family now lives in its own file with a shared_DoubleMLBaseindml/_base.pythat handles validation, default learners (auto-selecting classifier vs regressor per model), cross-fitting, andCausalResultconstruction. The legacysp.DoubleML(model=...)façade still works. -
sp.g_computation(data, y, treat, covariates, estimand='ATE'|'ATT'|'dose_response', ...)(src/statspai/inference/g_computation.py) — Robins' (1986) parametric g-formula / standardisation estimator. Supports binary treatment (ATE, ATT) and continuous treatment dose-response grids. Default OLS outcome model or any sklearn-compatible learner viaml_Q=. Nonparametric bootstrap SE with NaN-based failure tracking (model_info['n_boot_failed']) — replaces silent point-estimate fallback that would shrink SE. -
sp.front_door(data, y, treat, mediator, covariates=None, mediator_type='auto', integrate_by='marginal'|'conditional', ...)(src/statspai/inference/front_door.py) — Pearl (1995) front-door adjustment estimator. Closed-form sums for binary mediator; Monte Carlo integration over a Gaussian conditional density for continuous mediator. Two identification variants exposed:integrate_by='marginal'(Pearl 95 aggregate formulation) and'conditional'(Fulcher et al. 2020 generalised front-door). Bootstrap SE with NaN-based failure tracking. -
sp.msm(data, y, treat, id, time, time_varying, baseline=None, exposure='cumulative'|'current'|'ever', family='gaussian'|'binomial', trim=0.01, ...)(src/statspai/msm/) — Robins-Hernán-Brumback (2000) Marginal Structural Models via stabilised IPTW. Handles time-varying treatment + time-varying confounders (binary or continuous). Weighted pooled regression of outcome on exposure history with cluster-robust CR1 sandwich at the unit level.sp.stabilized_weights(...)is exposed as a standalone helper for users who want the weights without fitting the outcome model. -
sp.mediate_interventional(data, y, treat, mediator, covariates=None, tv_confounders=None, ...)(src/statspai/mediation/mediate.py) — VanderWeele, Vansteelandt & Robins (2014) interventional (in)direct effects. Identified in the presence of a treatment-induced mediator-outcome confounder (tv_confounders=[...]) where natural (in)direct effects are not. Fully vectorised MC integration (~100× faster than naïve per-observation loop). -
sp.proximal(data, y, treat, proxy_z, proxy_w, covariates=None, n_boot=0, ...)(src/statspai/proximal/) — Proximal Causal Inference (Tchetgen Tchetgen et al. 2020; Miao, Geng & Tchetgen Tchetgen 2018) via linear 2SLS on the outcome bridge function. Handles ATE identification with an unobserved confounder when two proxies (treatment-sideZand outcome-sideW) are available. Reports a first-stage F-stat for the proxy equation and warns when F < 10. Optional nonparametric bootstrap SE vian_boot=. -
sp.principal_strat(data, y, treat, strata, covariates=None, method='monotonicity'|'principal_score', ...)(src/statspai/principal_strat/) — Principal Stratification (Frangakis & Rubin 2002).method='monotonicity'applies the Angrist-Imbens-Rubin compliance decomposition to identify the complier PCE (= LATE) and returns Zhang-Rubin (2003) sharp bounds for the always-survivor SACE.method='principal_score'implements Ding & Lu (2017) principal-score weighting to point-identify always-taker / complier / never-taker PCEs under principal ignorability. Returns a dedicatedPrincipalStratResultwithstrata_proportions,effects,bounds. -
sp.survivor_average_causal_effect(data, y, treat, survival, ...)— friendly wrapper aroundprincipal_strat(method='monotonicity')for the classical truncation-by-death problem. Reports SACE midpoint + endpoint-union confidence interval.
Changed (Sprint B)¶
-
MSM binomial outcome family:
_weighted_logit_clusterreplaced the previousstatsmodels.GLM(freq_weights=w)call (which treats weights as integer replication counts) with a hand-rolled IRLS that uses probability-weight semantics. Matches Cole & Hernán (2008) and Stata'spweightconvention for IPTW. -
Bootstrap failure reporting:
g_computation,mediate_interventional,front_door, andproximalnow leave failed bootstrap replications asNaN, emit aRuntimeWarningwith the failure count and first error message, and recordn_boot_failed/n_boot_success/first_bootstrap_errorinmodel_info. If fewer than two replications succeed, a cleanRuntimeErroris raised rather than silently under-estimating SE. -
mediate_interventionalMC loop: the previousO(n × n_mc)Python comprehension is replaced by a closed-form vectorisation that exploits OLS linearity of the outcome model in the treatment-induced-confounder block (X_tv). The outer expectation over units collapses toβ_tv · mean(X_tv), reducing runtime toO(n_mc + n)and giving a measured ~100× speed-up on the reference configuration (n=800, n_boot=200, n_mc=300 drops from ~4 s to ~0.04 s). -
sp.dmlinternal layout: the 466-line single-classdml/double_ml.pyis split into five files (_base.py+plr.py+irm.py+pliv.py+iivm.py) each owning a single Neyman-orthogonal score and its validation. The publicdml()function andDoubleMLclass are unchanged; new per-model classes are now directly importable. -
sp.front_doorwith covariates and continuous mediator gainedintegrate_by(see Added).
Tests (Sprint B)¶
tests/test_dml_iivm.py(5 tests) — LATE recovery on one-sided-noncompliance DGP, significance, binary-D/binary-Z validation,model_infofields.tests/test_dml_split.py(5 tests) — direct-class API equals dispatcher, legacyDoubleMLfaçade, PLIV rejects multi-instrument list.tests/test_g_computation.py(5 tests) — ATE / ATT / dose-response curves recovered within tolerance, validation errors.tests/test_front_door.py(4 tests) — continuous-M and binary-M ATE recovery on DGP with unobserved confounder, strictly closer to truth than naïve OLS.tests/test_front_door_integrate_by.py(3 tests) — marginal and conditional variants both recover truth, invalid values rejected.tests/test_msm.py(5 tests) — cumulative-exposure slope recovery, stabilised-weight shape / mean,exposure='ever'requires binary treatment, weight diagnostics exposed.tests/test_mediate_interventional.py(4 tests) — IIE + IDE decomposition additivity, total-effect sign, binary-D validation.tests/test_proximal.py(6 tests) — linear-bridge ATE recovery, strictly-better-than-OLS, order-condition check, covariate compatibility, bootstrap SE path, first-stage F reported.tests/test_principal_strat.py(7 tests) — monotonicity LATE- stratum proportions, valid SACE bounds, principal-score method with informative X, input validation, SACE helper.
Notes (Sprint B)¶
- No new required dependency. All additions use NumPy / pandas / scipy / scikit-learn only (statsmodels optional).
- Full new-module suite: 44 new tests pass; the existing 28 DML + mediation regression tests still pass; full collection reports 1960 tests, zero import errors introduced by this sprint.
[0.9.5] - 2026-04-20 — Bayesian causal + Optuna-tuned CATE + Rust spike¶
This release closes three items from the v0.9.4 post-release retrospective (Section 8 "认怂" list):
- Bayesian causal —
sp.bayes_did+sp.bayes_rd(PyMC). - ML CATE調参 —
sp.auto_cate_tuned(Optuna). - Rust HDFE kernel — spec + benchmark harness shipped;
actual Rust crate deferred to 1.0 on a dedicated branch (any
maturinchange topip installis postponed until a full cross-platform wheel matrix is green).
Added¶
-
sp.bayes_did(data, y, treat, post, unit=None, time=None, ...)(src/statspai/bayes/did.py) — Bayesian difference-in-differences via PyMC. 2×2 for no panel indices, hierarchical Gaussian random effects whenunitand/ortimeare supplied. NUTS sampler, configurable priors,rope=(lo, hi)for "practical equivalence" posterior probabilities. Returns aBayesianCausalResultwith posterior mean/median/SD, 95 % HDI,prob_positive,rhat,ess, and the full ArviZInferenceDataon.tracefor downstream plotting. -
sp.bayes_rd(data, y, running, cutoff, bandwidth=None, poly=1, ...)(src/statspai/bayes/rd.py) — Bayesian sharp regression discontinuity with local polynomial (order ≥ 1) and Normal prior on the jump. Bandwidth defaults to0.5 * std(running). -
sp.BayesianCausalResult— sibling ofCausalResultwith broom-style.tidy()/.glance()/.summary()and Bayesian-native fields (hdi_lower,hdi_upper,prob_positive,prob_rope,rhat,ess). Slots into the same agent-nativepd.concat([r.tidy() for r in results])workflow as the frequentist estimators. -
sp.auto_cate_tuned(..., n_trials=25, timeout=None, search_space=None)(src/statspai/metalearners/auto_cate_tuned.py) — Optuna'sTPESamplersearches over the nuisance GBM hyperparameters (outcome and propensity model separately), scoring each trial by shared-nuisance held-out R-loss. Best trial's models are handed tosp.auto_cate; the winner'smodel_info['tuned_params']records the chosen HP and['n_trials']the search budget. Closes the econml "nuisance cross-validation before CATE" ergonomic gap. -
sp.fast.hdfe_bench(n_list, n_groups, repeat, seed, atol)(src/statspai/fast/bench.py) — benchmark harness for HDFE group-demean kernels. Times NumPy, Numba, and (future) Rust paths on the same DGPs and asserts correctness to ≤ 1 × 10⁻¹⁰ vs the NumPy reference. Unavailable backends are recorded, not crashed, so the same harness runs on CI environments that lack Numba and on dev boxes with a future Rust wheel installed. -
Optional install extras:
pip install "statspai[bayes]"pullspymc >= 5+arviz >= 0.15.pip install "statspai[tune]"pullsoptuna >= 3. Coreimport statspaiworks in either's absence; the estimators raise a cleanImportErrorat call time with the install recipe.
Design docs¶
docs/superpowers/specs/2026-04-20-v095-bayes-optuna-rust-spike.md— full spec for this release.docs/superpowers/specs/2026-04-20-v095-rust-hdfe-spike.md— the phased plan for the Rust HDFE port (crate layout, PyO3 FFI surface, cibuildwheel matrix, graceful-degradation contract).
Tests¶
tests/test_bayes_did.py(11 tests) — 2×2 + panel recovery, prob_positive calibration, HDI coverage, input validation, ROPE, tidy/glance shape.tests/test_bayes_rd.py(9 tests) — sharp recovery, null-effect HDI straddles 0, bandwidth shrinks local sample, poly=2 runs, validation errors.tests/test_auto_cate_tuned.py(7 tests) — API,n_trialsrespected, ATE recovery, custom search space honoured, invalid treatment rejected.tests/test_fast_bench.py(5 tests) — harness returnsHDFEBenchResult, dry-run <5 s, Numba/NumPy agree to 1e-10, unavailable paths recorded not crashed, summary string.
Non-goals (explicit)¶
- Variational inference (
pymc.fitADVI) — NUTS only for 0.9.5. - Bayesian fuzzy RD, IV, bunching — deferred to 0.9.6+.
- Rust crate itself — ships on a dedicated branch with a full
cibuildwheelmatrix; addingmaturintopyproject.tomlwithout that matrix would breakpip installfor some users.
[0.9.4] - 2026-04-20 — sp.auto_cate + strict identification¶
This release closes two concrete commitments from the 0.9.3 post-release
retrospective (社媒文档/4.20-升级说明/StatsPAI-0.9.3之后的一周…):
- Section 5 promise: "下一步打算加
strict_mode=True" onsp.check_identification. Delivered asstrict=Trueplus thesp.IdentificationErrorexception. - Section 8 gap: "ML CATE scheduling isn't as good as econml."
Delivered as
sp.auto_cate()— one-line multi-learner race with honest Nie-Wager R-loss scoring and BLP calibration.
Added¶
sp.auto_cate(data, y, treat, covariates, learners=('s','t','x','r','dr'))(src/statspai/metalearners/auto_cate.py, +400 LOC) — races the five meta-learners on shared cross-fitted nuisances, scores each on held-out predictions via the Nie-Wager R-loss, runs the Chernozhukov-Demirer-Duflo-Fernández-Val BLP calibration test on each, and returns anAutoCATEResultwith:.leaderboard— sorted by R-loss, with ATE, SE, CI, BLP β₁/β₂, CATE std/IQR per learner;.best_learner/.best_result— winner selected by lowest held-out Nie-Wager R-loss; BLP β₁/β₂ are reported in the leaderboard as diagnostics, not selection gates (β₁ equals the ATE in units of Y in this parametrization, so there is no natural "β₁ ≈ 1" gate);.results— the full fittedCausalResultfor every learner;.agreement— Pearson-ρ matrix of in-sample CATE vectors across learners (quick sanity check for model dependence);.summary()— a printable leaderboard + agreement table.
A bundled CATE learner race with honest held-out scoring. econml's
multi-metalearner pipeline is not bundled into
a single call; causalml's BaseMetaLearner comparison doesn't run
BLP calibration per learner.
-
sp.check_identification(..., strict=True)raisessp.IdentificationErrorwhen the report's verdict is'BLOCKERS'. The exception carries the complete report on.reportfor post-mortem inspection. Default remainsstrict=False(non-breaking). -
sp.IdentificationError— new exception type, exported at the top level. -
IV first-stage strength check in
sp.check_identification(_check_iv_strength) — computed from a first-stage OLStreatment ~ intercept + covariates + instrument(covariates partialled out before computing the instrument's F, so the reported F matches the Staiger-Stock definition when controls are present). Flags F < 5 asblocker, F < 10 aswarning(Staiger-Stock 1997), F ∈ [10, 30) asinfo. Fires only wheninstrumentis supplied.
Tests¶
tests/test_auto_cate.py(13 tests) — API surface, leaderboard shape, ATE recovery on constant-effect DGP, all-positive ATE on positive DGP, learner subset, invalid learner rejection, selection rule string, agreement matrix,CausalResultdelegation (.tidy(),.glance()), custom model override, summary string, top-levelsp.*availability, heterogeneous-DGP CATE dispersion.tests/test_check_identification.py(+5 tests) —strict=Trueraises on blockers, tolerates warnings, default non-strict behaviour unchanged,sp.IdentificationErrortop-level export, weak-instrument flagged, strong-instrument not flagged.
Design¶
- Published spec at
docs/superpowers/specs/2026-04-20-v094-auto-cate-strict-id-design.md.
Non-goals (deferred to 0.9.5+)¶
- Optuna hyperparameter search inside
auto_cate— for now the user either accepts the boosted-tree defaults or passes pre-tuned estimators viaoutcome_model=/propensity_model=/cate_model=. - Bayesian
sp.bayes_did/sp.bayes_rd— announced as a 0.9.5 preview line. - Rust HDFE inner kernel — remains Section 8's open item.
[0.9.3.post] — 0.9.3 post-release bugfixes (rolled into a later patch)¶
Four user-reported bugs surfaced during the 0.9.3 end-to-end smoke test.
All are fixed on main without a version bump (pending a later patch release).
Fixed¶
-
sp.use_chinese()failed on Linux (plots/themes.py) — the auto-detect candidate list only covered macOS fonts plusNoto Sans CJK SCandWenQuanYi Micro Hei, so a Linux/Docker host withfonts-noto-cjk(which shipsNoto Sans CJK JP/TC/KRby default) orfonts-wqy-zenhei(WenQuanYi Zen Hei) installed got an empty return plus a "no Chinese font" warning. Priority lists are now segmented by platform (macOS → Windows → Linux → cross-platform Source Han), all four Noto CJK regional variants are listed, and a substring fallback (CJK,Han Sans,Han Serif,WenQuanYi,Heiti,Ming) picks up custom/renamed builds. Warning message now includes the exactapt install fonts-noto-cjk fonts-wqy-zenheirecipe. -
sp.regtable(...)printed the table twice in REPL/Jupyter (output/regression_table.py,output/estimates.py) —regtable(),mean_comparison()andesttab()each calledprint(result)internally and then returned the result, which REPL/Jupyter re-displayed via__repr__/_repr_html_. All three internal prints are removed; display now flows through the standard Python display protocol.
Behaviour change: scripts that relied on the auto-print side-effect
must switch to print(sp.regtable(...)). Jupyter and interactive REPLs
are unaffected.
-
sp.regtable(..., output="latex")was silently ignored (output/regression_table.py) — theoutput=parameter previously controlled only the Word/Excel warning branch;__str__always rendered text.RegtableResultandMeanComparisonResultnow store_outputand dispatch in__str__/__repr__through_render(fmt)over{text, latex, tex, html, markdown, md}. Jupyter's_repr_html_still always returns HTML. Invalidoutput=values now raiseValueErrorinstead of falling back silently. -
sp.did()treat=column semantics were easy to mis-specify (did/__init__.py) — for staggered designs the column must hold each unit's first-treatment period (never-treated =0, not1), but users with a pre-existing 0/1treatedcolumn consistently passed it straight through and got nonsense estimates. Docstring now carries an explicit callout and a verified pandas idiom for constructingfirst_treat(.loc[treated==1].groupby('id')['year'].min()+.map+.fillna(0)) that broadcasts correctly to pre-treatment rows.
Added¶
- Documentation clarifies that
regtable(output=...)controlsstr(result)whileregtable(filename=...)dispatches on the file extension — they can diverge, and users should pass matching values. - Input validation on
regtable()/mean_comparison()rejects unknownoutput=values with a helpfulValueErrorlisting valid choices.
Tests¶
tests/test_v093_bugfixes.py — 15 regression tests covering all four bugs
plus the new validation. Full suite: 1655 passed, 4 skipped, 0 regressions.
[0.9.3] - 2026-04-19 — Stochastic Frontier + Multilevel + GLMM + Econometric Trinity¶
Overview. This release bundles four simultaneous deep overhauls plus an author-metadata correction:
- Stochastic Frontier Analysis —
sp.frontier/sp.xtfrontierrewritten to Stata/R-grade, with a critical correctness bug fix. - Multilevel / Mixed-Effects —
sp.multilevelrewritten to lme4/Stata-grade. - GLMM hardening — AGHQ (
nAGQ>1) plus three new families (Gamma, Negative Binomial, Ordinal Logit) and cross-family AIC comparability. - Econometric Trinity — three new P0 pillars: DML-PLIV, Mixed Logit, IV-QR.
- Author attribution corrected to
Biaoyue Wang.
⚠️ Critical correctness fix — sp.frontier carried a latent Jondrow posterior
sign error in all prior versions (0.9.2 and earlier). Efficiency scores were
systematically biased; the normal-exponential path additionally returned NaN
for unit efficiency. Re-run any prior frontier analyses. Detail below.
Stochastic Frontier Analysis Overhaul¶
Release focus: statspai.frontier. The prior implementation was a
270-line single file with one function covering cross-sectional
half-normal / exponential / truncated-normal frontiers, no panel
support, no heteroskedasticity, no inefficiency determinants, and —
critically — a sign error in the Jondrow posterior that silently
produced wrong efficiency scores, plus a wrong ε-coefficient in the
exponential log-likelihood that the old test never exercised. The
module has been rewritten (~1,300 LOC across _core.py, sfa.py,
panel.py, te_tools.py) to match or exceed Stata's
frontier / xtfrontier and R's frontier / sfaR.
Correctness fixes¶
- Jondrow posterior μ*: corrected
signconvention in all three distributions — the old code'sμ* = -sign·ε·σ_u²/σ²has been replaced by the derivation-verifiedμ* = sign·ε·σ_u²/σ²(and the analogous correction for truncated-normal). Efficiency scores from the old implementation were systematically biased; re-run any prior analyses. - Normal-exponential log-density: fixed the ε-coefficient and
Φ argument (the old form was
+ sign·ε/σ_u + log Φ((-sign·ε - σ_v²/σ_u)/σ_v); correct per Greene 2008 eq. 2.39 is- sign·ε/σ_u + log Φ(sign·ε/σ_v - σ_v/σ_u)). The old exponential path never produced efficiency scores (returned NaN) — now returns correct Battese-Coelli scores. - Truncated-normal density: fixed the
centeredoffset in the φ factor from(ε + sign·μ)/σto(ε - sign·μ)/σ. - Monte-Carlo density-integration tests (
∫ f(ε) dε = 1) now guard against regressions for all three distributions.
New cross-sectional sp.frontier¶
- Heteroskedastic inefficiency via
usigma=[...]— parameterisesln σ_u_i = γ_u' [1, w_i](Caudill-Ford-Gropper 1995, Hadri 1999). - Heteroskedastic noise via
vsigma=[...]— parameterisesln σ_v_i = γ_v' [1, r_i](Wang 2002). - Inefficiency determinants via
emean=[...]— the Battese-Coelli (1995) / Kumbhakar-Ghosh-McGuckin (1991) modelμ_i = δ' [1, z_i]fordist='truncated-normal'. - Battese-Coelli (1988) TE:
result.efficiency(method='bc')returnsE[exp(-u)|ε](the Stata default) in addition to the JLMS approximationexp(-E[u|ε])(method='jlms'). - LR test for absence of inefficiency: one-sided mixed χ̄²
(Kodde-Palm 1986) via
result.lr_test_no_inefficiency(). - Bootstrap CI for unit efficiency: parametric-bootstrap bounds
via
result.efficiency_ci(alpha=.05, B=500). - Residual skewness diagnostic stored at
result.diagnostics['residual_skewness']. - Optimiser now has hard bounds on
ln σand guards against σ → 0 / σ → ∞ excursions that previously caused truncated-normal fits to diverge.
New panel sp.xtfrontier¶
- Pitt-Lee (1981) time-invariant (
model='ti'):u_it = u_i, half-normal or truncated-normal. Closed-form group log-likelihood derived from the per-unit integration; unit-level TE stored atresult.diagnostics['efficiency_bc_unit']. - Battese-Coelli (1992) time-varying decay (
model='tvd'):u_it = exp(-η(t - T_i)) · u_iwith η estimated jointly. The obs-level efficiency usesE[exp(-a_it u_i)|e_i]under the posterioru_i ~ N⁺(μ*, σ*²)(MGF form). - Battese-Coelli (1995) inefficiency effects (
model='bc95'):u_it ~ N⁺(z_it' δ, σ_u²)independently; returned with unit-mean efficiency roll-up.
Helpers¶
sp.te_summary(result)— Stata-style descriptive table of TE scores (n, mean, sd, quartiles, share > 0.9, share < 0.5).sp.te_rank(result, with_ci=True)— efficiency ranking with optional bootstrap CIs for benchmarking.
Tests¶
- 33 new tests covering: parameter recovery for all three cross-sectional distributions, cost vs production sign handling, heteroskedastic σ_u / σ_v, BC95 determinants, LR specification tests, TE-score bounds and internal consistency, bootstrap CI structure, Pitt-Lee / BC92 / BC95 panel recovery, and density-integrates-to-1 kernel sanity checks.
Advanced frontier extensions¶
Three frontier extensions shipped after the initial overhaul (commit e876937):
sp.zisf— Zero-Inefficiency SFA mixture (Kumbhakar-Parmeter-Tsionas 2013). Mixture of fully-efficient (u=0, pure noise) and standard composed-error regimes; mixing probabilityp_iparameterised via logit on optionalzprobcovariates. PosteriorP(efficient|ε)exposed indiagnostics['p_efficient_posterior']. Recovery test: true efficient share 0.30 → estimated 0.286 onn=2000.sp.lcsf— 2-class Latent-Class SFA (Orea-Kumbhakar 2004; Greene 2005). Two separate frontiers with their ownβ_kand variance parameters; class-membership logit on optionalz_classcovariates. Direct MLE with perturbed starts to break label symmetry.xtfrontier(..., model='tfe', bias_correct=True)— Dhaene-Jochmans (2015) split-panel jackknife for TFE:β_BC = 2·β_full − (β_first_half + β_second_half)/2. Cuts theO(1/T)incidental-parameters bias. Guards against degenerate halves by skipping σ corrections with an annotation inmodel_info. Verified atT=30,N=25: rawσ_u=0.374→ BCσ_u=0.359(true 0.35).
Productivity helpers¶
Shipped in commit be59260:
sp.malmquist— Färe-Grosskopf-Lindgren-Roos (1994) Malmquist TFP index via period-by-period parametric frontier fits. Returns per- transition decompositionM = EC × TC(efficiency change × technical change). Row-wise identityM == EC·TCverified tortol=1e-8. Cost frontiers supported via reciprocal distance convention. Validated on 3-period DGP with 5%/year intercept growth: mean TC ≈ 1.07–1.09, mean EC ≈ 1.0.sp.translog_design— Cobb-Douglas → Translog design-matrix helper. Appends0.5·log(x_k)²squares andlog(x_k)·log(x_l)interactions; thetranslog_termslist is stored indf.attrsfor one-line feed tofrontier()/xtfrontier(). Toggleable squares and interactions.
Migration¶
- Old:
frontier(df, y='y', x=['x1'])still works (same required args). - New keyword-only args:
usigma,vsigma,emean,te_method,start. - Existing efficiency scores should be recomputed — prior values were systematically biased by the Jondrow sign error.
Multilevel / Mixed-Effects Overhaul¶
Release focus: statspai.multilevel. The previous implementation was a
400-line single file covering only the two-level linear mixed model
with a diagonal random-effect covariance. It has been rewritten as a
proper sub-package (~2,000 LOC across _core.py, lmm.py, glmm.py,
diagnostics.py, comparison.py) with feature parity against
lme4/Stata mixed and additions on top.
New in sp.mixed¶
- Unstructured covariance
Gfor random effects is now the default (cov_type='unstructured', Cholesky-parameterised so the optimiser is unconstrained).diagonalandidentityremain available for nested-model comparisons. - Three-level nested models via
group=['school', 'class']— fits school- and class-level random intercepts jointly (verified to matchstatsmodels.MixedLM(..., re_formula="1", vc_formula={...})to four decimals on the variance components and fixed effects). - BLUP posterior standard errors (
result.ranef(conditional_se= True)) — exposesVar(u|y) = G − GZ'V⁻¹ZG + GZ'V⁻¹X Cov(β̂) X'V⁻¹ZGfor use in caterpillar plots. predict(new_data, include_random=…)— population-marginal and group-conditional predictions, with zeroed-out BLUPs for unseen groups.- Nakagawa-Schielzeth marginal & conditional R² via
result.r_squared(). - AIC / BIC,
wald_test()for linear restrictions,to_markdown()/to_latex()/_repr_html_()/cite(), andplot(kind='caterpillar' | 'residuals').
New functions¶
sp.melogit/sp.mepoisson/sp.meglm— Generalised linear mixed models (binomial logit, Poisson log, Gaussian identity) fitted by Laplace approximation with canonical-link observed information. Supports random intercepts and random slopes,cov_typeas forsp.mixed, binomialtrials=and Poissonoffset=. Results exposeodds_ratios()/incidence_rate_ratios()and apredict(type= 'response'|'linear')method.sp.icc(result)— intra-class correlation with a delta-method (logit-scale) 95% CI.sp.lrtest(restricted, full)— likelihood-ratio test between two nested mixed-model fits with automatic Self-Liang χ̄² boundary correction when variance components are being tested.
Validation¶
- Linear mixed models: fixed effects and variance components agree
with
statsmodels.MixedLMto 4 decimal places on both random- intercept and unstructured random-slope specifications (test_multilevel.py::TestRandomSlopeUnstructured:: test_matches_statsmodels). - Three-level nested: variance components identified jointly and match
the reference implementation to 2 decimal places
(
TestThreeLevelNested::test_separates_variance_components). - GLMM recovery tests on 2,000-observation synthetic panels confirm slope and random-intercept variance within expected sampling ranges.
Behavioural changes¶
- The default
cov_typeforsp.mixedis now'unstructured'(previously effectively diagonal). Passcov_type='diagonal'explicitly for the old behaviour. LR test vs. pooled OLSnow uses the ML-converted likelihood (previously a mix of REML and ML that could produce inconsistent values whenmethod='reml').
Post-review hardening (post oracle + code-reviewer audit)¶
- [BLOCKER fix]
MixedResult.predict(data=None)previously returned predictions in group-iteration order rather than the original row order._GroupBlocknow carries the training row indices andpredict()scatters the output back to the correct positions. Regression test:tests/test_multilevel.py::TestRandomIntercept:: test_predict_is_row_aligned_with_training_frame. - [BLOCKER fix] GLMM inner Newton (
_find_mode) now damps large steps and returns a convergence flag.meglmaggregates per-cluster failures and emits aRuntimeWarningwhen any cluster fails to converge — a previously silent failure mode. - [HIGH fix]
MEGLMResultgainsto_latex()andplot()so it matches the unified StatsPAI result contract. - [HIGH fix]
lrtestnow raisesValueErroron cross-family comparisons and on REML fits whose fixed-effect design differs, preventing invalid LR statistics. Multi-component boundary corrections emit aRuntimeWarningexplaining the conservative upper bound (Stram–Lee 1994 mixture not implemented). - [HIGH fix]
mixed()/meglm()reject non-hashable group values with a descriptiveTypeErrorinstead of producing a silently corrupted BLUP dict. - [MED fix]
icc(result, n_boot>0)raisesNotImplementedErrorinstead of silently returning the delta-method CI.icc()warns whenn_groups < 30(delta-method CI unreliable). - [MED fix] Three-level nested fit emits a warning when any
outer group has only one inner group (class variance then not
identified), and exposes both school and class ICCs via
variance_components['icc(outer)']/icc(outer+inner).
GLMM hardening — AGHQ + Gamma / NegBin / Ordinal¶
Closes the three GLMM gaps flagged in the multilevel self-audit. All
changes are additive (no API breaks); existing meglm / melogit /
mepoisson calls produce numerically identical fits.
Adaptive Gauss-Hermite quadrature (AGHQ) — nAGQ parameter.
Previously meglm only offered the Laplace approximation (nAGQ=1),
which is known to underestimate random-effect variances on small
clusters with binary or other non-Gaussian outcomes. The new nAGQ
argument selects the number of adaptive quadrature points per scalar
random effect:
sp.melogit(df, "y", ["x"], "g", nAGQ=7) # matches Stata intpoints(7)
sp.megamma(df, "y", ["x"], "g", nAGQ=15) # converged-grade quadrature
nAGQ=1 reduces exactly to the Laplace formula (verified to 1e-10).
nAGQ>1 is restricted to single-scalar random-effect models (no random
slopes), matching the same restriction lme4::glmer imposes — full
tensor-product AGHQ over q>1 random effects is deferred because cost
scales as nAGQ^q. AGHQ is wired into all five families
(Gaussian / Binomial / Poisson / Gamma / NegBin) plus meologit.
New families:
sp.megamma— Gamma GLMM with log link and dispersionφestimated by ML, packed aslog φfor unconstrained optimisation. IRLS weight uses Fisher information1/φ(Fisher scoring) for PSD Hessian regardless of fitted means.sp.menbreg— Negative-binomial NB-2 GLMM (Var = μ + α μ²) with log link, dispersionα(aliasfamily='negbin'accepted). Reduces analytically to Poisson asα → 0; verified.sp.meologit— Random-effects ordinal logit (Statameologit, Rordinal::clmm). K−1 thresholds reparameterised asκ_1, log(κ_2−κ_1), ...so strict ordering is enforced unconditionally. ReturnsMEGLMResultwith newthresholdsattribute. SupportsnAGQ>1.
Cross-family AIC comparability. Poisson and Binomial log-
likelihoods now include the full normalisation constants (-log(y!)
for Poisson, log-binomial-coefficient for Binomial). Previously these
constants were dropped, which made mepoisson vs menbreg AIC
comparisons biased by ~Σ log(y!). β and variance estimates are
unchanged; only log_likelihood and aic / bic absolute values
shift — relative comparisons within a family are unaffected.
Tests (multilevel). tests/test_multilevel.py grows from 35 to 53
tests:
TestAGHQ(7 tests) — nAGQ=1↔Laplace identity, AGHQ improves vs Laplace on small clusters, convergence in nAGQ, random-slope rejection.TestMEGamma(3) — truth recovery, dispersion accounting, summary.TestMENegBin(3) — truth recovery, IRR availability, alias resolution.TestMEOLogit(5) — truth recovery, threshold ordering, no intercept, summary, K≥3 enforcement.
Backwards compatibility: all 35 prior multilevel tests pass unchanged.
Synth API-drift fixes (post-0.9.3-initial)¶
SyntheticControl._solve_weightssignature migration — three stale call sites insynth/power.pyandsynth/sensitivity.pymigrated to the new (Y_treated_pre, Y_donors_pre, X_treated, X_donors, run_nested) signature (fixes 8 test failures intests/test_synth_advanced.pyandtests/test_synth_extras.py).- Placebo alignment —
synth/power.pyplacebo builder now followsscm.py:888exactly so LOO ↔ main placebo results stay consistent. - numpy 2.x compatibility —
tests/test_frontier.pyswitchesnp.trapz→np.trapezoid(removed in numpy 2.x).
Econometric Trinity — P0 Pillars (DML-PLIV, Mixed Logit, IV-QR)¶
Three foundational econometric estimators identified as the highest-ROI gaps
vs. Stata, R, and existing Python packages are now first-class sp.* APIs
(~1,170 new LOC, 10 tests in test_econ_trinity.py).
sp.dml(model='pliv', instrument=…)— DML-PLIV (Partially Linear IV). Chernozhukov et al. (2018, §4.2) Neyman-orthogonal score with cross-fitted nuisance functionsg(X)=E[Y|X],m(X)=E[D|X],r(X)=E[Z|X]. Returns the LATE with influence-function-based standard errors. Closes the IV gap in the existingDoubleML(previously only PLM + IRM).sp.mixlogit— Mixed Logit. Random-coefficient multinomial logit via simulated maximum likelihood with Halton quasi-random draws. Supports: fixed + random coefficients, normal / log-normal / triangular mixing distributions, diagonal or full Cholesky covariance, panel (repeated-choice) data, OPG-sandwich robust SEs. Benchmarked against Statamixlogitand Rmlogit.sp.ivqreg— IV Quantile Regression. Chernozhukov-Hansen (2005, 2006, 2008) instrumental-variable quantile regression via inverse-QR profile. Scalar endogenous case uses grid + Brent refinement; multi-dim uses BFGS on theb̂(α)criterion. Multiple quantiles return a tidy DataFrame; single quantile returnsEconometricResults. Optional pairs-bootstrap SEs.
All three reuse _qreg_fit, CausalResult, EconometricResults for API
consistency with the rest of StatsPAI.
Post-self-audit hardening¶
Self-audit + code-reviewer agent surfaced and fixed 4 BLOCKER + 7 HIGH bugs
in the first-cut implementation (see commit 2aa709b). Parameter-recovery
tests now pass against controlled DGPs.
Smart Workflow — Posterior Verification¶
Shipped in commit be59260:
sp.verify/sp.verify_benchmark— posterior verification engine forsp.recommend()outputs. Runs bootstrap stability, placebo pass rate, and subsample agreement, aggregated intoverify_score ∈ [0, 100]. Opt-in viasp.recommend(verify=True); zero overhead when disabled.- Calibration card shows top-method
verify_score85–95 on clean DGPs (RD lower at ≈ 74 due to local-polynomial bootstrap variance). - 18/18 smart tests pass.
Meta — Author Attribution¶
- Author metadata corrected from
Bryce WangtoBiaoyue Wangin:pyproject.toml(authors+maintainers),src/statspai/__init__.py(__author__),README.md/README_CN.md(team line + BibTeX),docs/index.md(BibTeX), andmkdocs.yml(site_author). Software-journal submission (paper.md) was already correct.
[0.9.2] - 2026-04-16¶
Decomposition Analysis — Broad Decomposition Toolkit in Python¶
Release focus: statspai.decomposition. 18 first-class decomposition methods across 13 modules (~6,200 LOC, 54 tests) spanning mean, distributional, inequality, demographic, and causal decomposition. The release consolidated a broad Python API surface for workflows that are often split across Stata commands and R packages; numerical claims remain tied to the method-level tests and validation metadata.
What's in sp.decompose (18 methods, 30 aliases)¶
Mean decomposition
| Function | Method / Paper |
|---|---|
sp.oaxaca(df, ...) |
Blinder-Oaxaca threefold with 5 reference coefficients (Blinder 1973; Oaxaca 1973; Neumark 1988; Cotton 1988; Reimers 1983) |
sp.gelbach(df, ...) |
Sequential orthogonal decomposition of omitted-variable bias (Gelbach 2016, JoLE) |
sp.fairlie(df, ...) |
Nonlinear logit/probit decomposition (Fairlie 1999, 2005) |
sp.bauer_sinning(df, ...) / sp.yun_nonlinear(df, ...) |
Bauer-Sinning (2008) + Yun (2004, 2005) detailed nonlinear |
Distributional decomposition
| Function | Method / Paper |
|---|---|
sp.rifreg(df, ...) / sp.rif_decomposition(...) |
Recentered Influence Function regression + OB (Firpo, Fortin & Lemieux 2009, Econometrica) |
sp.ffl_decompose(df, ...) |
Two-step detailed decomposition (Firpo, Fortin & Lemieux 2018, Econometrics) |
sp.dfl_decompose(df, ...) |
Reweighting counterfactual distributions (DiNardo, Fortin & Lemieux 1996, Econometrica) |
sp.machado_mata(df, ...) |
Simulation-based quantile regression decomposition (Machado & Mata 2005, JAE) |
sp.melly_decompose(df, ...) |
Analytical quantile regression decomposition (Melly 2005, Labour Economics) |
sp.cfm_decompose(df, ...) |
Distribution regression counterfactuals (Chernozhukov, Fernández-Val & Melly 2013, Econometrica) |
Inequality decomposition
| Function | Method / Paper |
|---|---|
sp.subgroup_decompose(df, ...) |
Between/within for Theil T, Theil L, GE(α), Gini (Dagum 1997), Atkinson, CV² (Shorrocks 1984) |
sp.shapley_inequality(df, ...) |
Shorrocks-Shapley allocation of inequality to covariates (Shorrocks 2013, JoEI) |
sp.source_decompose(df, ...) |
Gini source decomposition (Lerman & Yitzhaki 1985, ReStat) |
Demographic standardization
| Function | Method / Paper |
|---|---|
sp.kitagawa_decompose(df, ...) |
Two-factor rate decomposition (Kitagawa 1955, JASA) |
sp.das_gupta(df_a, df_b, ...) |
Multi-factor symmetric decomposition (Das Gupta 1993) |
Causal decomposition
| Function | Method / Paper |
|---|---|
sp.gap_closing(df, method=...) (regression / IPW / AIPW) |
Gap-closing estimator (Lundberg 2021, Sociol. Methods Res.) |
sp.mediation_decompose(df, ...) |
Natural direct/indirect effects (VanderWeele 2014, Epidemiology) |
sp.disparity_decompose(df, ...) |
Causal disparity decomposition (Jackson & VanderWeele 2018, Epidemiology) |
Unified entry point
import statspai as sp
result = sp.decompose(method='ffl', data=df, y='log_wage',
group='female', x=['education', 'experience'],
stat='quantile', tau=0.5)
result.summary(); result.plot(); result.to_latex()
30 aliases supported ('mm' → machado_mata, 'dinardo_fortin_lemieux' → dfl, etc.).
Why this matters¶
- Stata has it scattered across 6+ packages (
oaxaca,ddecompose,cdeco,rifhdreg,mvdcmp,fairlie) with no unified API. - R has
ddecompose,Counterfactual,dineq— three different authors, three different conventions. - Python previously had only one 2018-vintage unmaintained PyPI package (basic Oaxaca).
- StatsPAI 0.9.2: one API, one result-class contract (
.summary()/.plot()/.to_latex()/._repr_html_()), three inference modes (analytical / bootstrap / none), all numpy/scipy/pandas.
Quality bar¶
- 54 tests including cross-method consistency (
test_dfl_ffl_mean_agree,test_mm_melly_cfm_aligned_reference,test_dfl_mm_reference_convention_opposite) and numerical identity checks (FFL four-part sum, weighted Gini RIF E_w[RIF]=G). - Closed-form influence functions for Theil T / Theil L / Atkinson (no O(n²) numerical fallback).
- Weighted O(n log n) Dagum Gini via sorted-ECDF pairwise-MAD identity.
- Logit non-convergence surfaces as RuntimeWarning; bootstrap failure rate >5% warns.
[0.9.1] - 2026-04-16¶
Regression Discontinuity — Broad RD Toolkit¶
Release focus: statspai.rd. 18+ RD estimators, diagnostics, and inference methods across 14 modules (~10,300 LOC). The machinery behind Calonico-Cattaneo-Titiunik (CCT), Cattaneo-Jansson-Ma density tests, Armstrong-Kolesar honest CIs, Cattaneo-Titiunik-Vazquez-Bare local randomization, Cattaneo-Titiunik-Yu boundary (2D) RD, and Angrist-Rokkanen external validity is exposed through one import statspai as sp; validation status is method-specific.
What's in sp.rd (14 modules)¶
Core estimation
| Function | Method / Paper |
|---|---|
sp.rdrobust(df, ...) |
Sharp / Fuzzy / Kink RD with bias-corrected robust inference (Calonico, Cattaneo & Titiunik 2014, Econometrica; 2020, Stata Journal) |
sp.rdrobust(..., covs=...) |
Covariate-adjusted local polynomial (Calonico, Cattaneo, Farrell & Titiunik 2019, ReStat) |
sp.rd2d(df, x1, x2, ...) |
Boundary discontinuity / 2D RD designs (Cattaneo, Titiunik & Yu 2025) |
sp.rkd(df, ...) |
Regression Kink Design (Card, Lee, Pei & Weber 2015, Econometrica) |
sp.rdit(df, time, ...) |
Regression Discontinuity in Time (Hausman & Rapson 2018, Annual Review) |
sp.rdmc(df, cutoffs=[...]) |
Multi-cutoff RD (Cattaneo, Titiunik, Vazquez-Bare & Keele 2016) |
sp.rdms(df, scores=[...]) |
Multi-score RD (Cattaneo, Idrobo & Titiunik 2024) |
Bandwidth selection
| Function | Selector |
|---|---|
sp.rdbwselect(df, bwselect='mserd') |
MSE-optimal (Imbens-Kalyanaraman 2012) |
sp.rdbwselect(..., bwselect='msetwo') |
Two-bandwidth MSE |
sp.rdbwselect(..., bwselect='cerrd'/'cercomb1'/'cercomb2') |
CER-optimal coverage-error-rate (Calonico, Cattaneo & Farrell 2020, Econometrics Journal) |
Inference
| Function | Method |
|---|---|
sp.rd_honest(df, ...) |
Honest CIs with worst-case bias bound (Armstrong & Kolesar 2018, Econometrica; 2020, QE) |
sp.rdrandinf(df, ...) |
Local randomization inference via Fisher exact tests (Cattaneo, Frandsen & Titiunik 2015) |
sp.rdwinselect(df, ...) |
Data-driven window selection for local randomization |
sp.rdsensitivity(df, ...) |
Sensitivity analysis across windows |
sp.rdrbounds(df, ...) |
Rosenbaum sensitivity bounds for hidden selection |
Heterogeneous treatment effects
| Function | Method |
|---|---|
sp.rdhte(df, covs=[...]) |
CATE via fully interacted local linear (Calonico et al. 2025) |
sp.rdbwhte(df, ...) |
HTE-optimal bandwidth |
sp.rd_forest(df, ...) |
Causal forest + RD |
sp.rd_boost(df, ...) |
Gradient boosting + RD |
sp.rd_lasso(df, ...) |
LASSO-assisted RD with covariate selection |
External validity & extrapolation
| Function | Method |
|---|---|
sp.rd_extrapolate(df, ...) |
Away-from-cutoff extrapolation (Angrist & Rokkanen 2015, JASA) |
sp.rd_multi_extrapolate(df, cutoffs=[...]) |
Multi-cutoff extrapolation (Cattaneo, Keele, Titiunik & Vazquez-Bare 2024) |
Diagnostics & visualization
| Function | Purpose |
|---|---|
sp.rdsummary(df, ...) |
Single-call dashboard — rdrobust + density test + bandwidth sensitivity + placebo cutoffs + covariate balance |
sp.rdplot(df, ...) |
IMSE-optimal binned scatter with pointwise CI bands (Calonico, Cattaneo & Titiunik 2015, JASA) |
sp.rddensity(df, ...) |
Cattaneo-Jansson-Ma (2020, JASA) manipulation test |
sp.rdbalance(df, covs=[...]) |
Covariate balance tests at cutoff |
sp.rdplacebo(df, cutoffs=[...]) |
Placebo cutoff tests |
Power analysis
| Function | Purpose |
|---|---|
sp.rdpower(df, effect_sizes=[...]) |
Power curves for RD designs |
sp.rdsampsi(df, target_power=0.8) |
Required sample size |
Refactor — rd/_core.py consolidation¶
A 5-sprint refactor (commit 44f7529) centralized shared low-level primitives that had been duplicated across 9 RD files into a single private module rd/_core.py (191 lines):
_kernel_fn— triangular / epanechnikov / uniform / gaussian (previously 4 duplicate definitions)_kernel_constants/_kernel_mse_constant— MSE-optimal bandwidth constants_local_poly_wls— WLS local polynomial fit with HC1 / cluster-robust variance + optional covariate augmentation_sandwich_variance— HC1 / cluster sandwich for arbitrary design matrices
Net effect: 253 lines of duplicated math consolidated into 191 lines of canonical implementation. 97 RD tests pass with zero regression.
Bug fixes (since 0.9.0)¶
- RDD extrapolation:
_ols_fitsingular matrix fallback (commit 052594a) - 3 critical + 3 high-priority bugs from comprehensive RD code review (commit 6489270)
- Density test: bug in CJM (2020) implementation + DGP helper fixes + validation tests (commit b66f312)
Tests¶
- 97 RD tests + 1 skipped, 0 failed across 5 test files.
Also in 0.9.1¶
synth/_core.py— simplex weight solver consolidated from 6 duplicate implementations (commit a4036a2). Analytic Jacobian now available to all six callers for ~3-5x speedup.decomposition/_common.py— newinfluence_function(y, stat, tau, w)is the canonical 9-stat RIF kernel.rif.rif_valuespublic API expands from 3 to 9 statistics (commits 0789223, 5569fd0).
[0.9.0] - 2026-04-16¶
Synthetic Control — Broad SCM Toolkit¶
Release focus: statspai.synth. 20 SCM methods + 6 inference strategies + analysis workflow (compare / power / sensitivity / reports), all behind the unified sp.synth(method=...) dispatcher. This is an API-breadth statement; exact validation evidence is recorded by each function's validation metadata and the parity artifacts.
Seven new SCM estimators¶
| Method | Reference |
|---|---|
bayesian_synth |
Dirichlet-prior MCMC with full posterior credible intervals (Vives & Martinez 2024) |
bsts_synth / causal_impact |
Bayesian Structural Time Series via Kalman filter/smoother (Brodersen et al. 2015) |
penalized_synth (penscm) |
Pairwise discrepancy penalty (Abadie & L'Hour 2021, JASA) |
fdid |
Forward DID with optimal donor subset selection (Li 2024) |
cluster_synth |
K-means / spectral / hierarchical donor clustering (Rho 2024) |
sparse_synth |
L1 / constrained-LASSO / joint V+W (Amjad, Shah & Shen 2018, JMLR) |
kernel_synth + kernel_ridge_synth |
RKHS / MMD-based nonlinear matching |
Previous methods — classic, penalized, demeaned, unconstrained, augmented, SDID, gsynth, staggered, MC, discos, multi-outcome, scpi — remain with bug fixes (see below).
Research workflow¶
synth_compare(df, ...)— run every method at once, tabular + graphical comparisonsynth_recommend(df, ...)— auto-select best estimator by pre-fit + robustnesssynth_report(result, format='markdown'|'latex'|'text')— one-command structured reportsynth_power(df, effect_sizes=[...])— power-analysis helper for SCM designssynth_mde(df, target_power=0.8)— minimum detectable effectsynth_sensitivity(result)— LOO + time placebos + donor sensitivity + RMSPE filtering- Three canonical datasets shipped:
california_tobacco(),german_reunification(),basque_terrorism()
Critical fixes from comprehensive module review¶
Following a 5-parallel-agent code review (correctness / numerics / API / perf / docs), nine critical review findings were fixed:
- ASCM correction formula —
augsynthnow follows Ben-Michael, Feller & Rothstein (2021) Eq. 3 per-period ridge bias(Y1_pre − Y0'γ) @ β(T0, T1), replacing the scalar mean-residual placeholder._ridge_fitRHS bug also fixed. - Bayesian likelihood scale — covariate rows are now z-scored to the pooled pre-outcome SD before concatenation, preventing scale mismatch from dominating the Gaussian
σ²posterior. - Bayesian MCMC Jacobian — missing
log(σ′/σ)correction for the log-normal random-walk proposal on σ has been added to the MH acceptance ratio. - BSTS Kalman filter — innovation variance floored at
1e-12(preventslog(0)on constant outcome series); RTS smootherinv → solve + pinvfallback on near-singular predicted covariance. - gsynth factor estimation — four
np.linalg.invcalls (loadings + placebo loop) replaced withnp.linalg.lstsq(robust to rank-deficientF'F/L'L). - Dispatcher
**kwargsleakage —augsynthgains**kwargs + placebo=True;sp.synth(method='augmented', placebo=False)no longer raisesTypeError. - Dispatcher
kernel_ridgeplacebo bypass —placebo=now forwarded correctly. - Cross-method API consistency —
sdid()now accepts canonicaloutcome / treated_unit / treatment_time(legacyy / treat_unit / treat_timealiases retained for backwards compatibility). - Documentation accuracy —
synth_comparedocstring reflects 20 methods (was 12);synth()Returns section enumerates allCausalResultfields.
Tests & validation¶
- 144 synth tests passing (new: 12-method cross-method consistency benchmark verifying the benchmarked synth methods recover a known ATT within 1.5 units on a clean DGP).
- Full suite: 1481 passed, 4 skipped, 0 failed (5m42s).
- New guide:
docs/guides/synth.md— complete tutorial covering all 20 methods with a method-choice decision table.
API migration notes¶
sdid(y=, treat_unit=, treat_time=) still works but outcome=, treated_unit=, treatment_time= is preferred for consistency with every other sp.synth.* function. A deprecation of the legacy names is planned for v1.0.
Other Modules¶
Decomposition and Regression Discontinuity modules received significant upgrades in this release cycle (tier-C decomposition expansion to 18 methods + unified sp.decompose(); RD _core.py primitive centralization + bug fixes from code review). These will be highlighted in a dedicated follow-up release note.
[0.8.0] - 2026-04-16¶
Spatial Econometrics + 10-Domain Breadth Upgrade¶
Largest release in StatsPAI history. 60+ new functions across 10 domains.
Spatial Econometrics (NEW — 38 API symbols)¶
From 3 functions / 419 LOC to 38 functions / 3,178 LOC / 69 tests. A unified spatial econometrics API for Python users.
- Weights (L1):
W(sparse CSR),queen_weights,rook_weights,knn_weights,distance_band,kernel_weights,block_weights - ESDA (L2):
moran(global + local),geary,getis_ord_g,getis_ord_local,join_counts,moran_plot,lisa_cluster_map - ML Regression (L3):
sar,sem,sdm,slx,sac— sparse-backed, dual log-det path (exact + Barry-Pace), scales to N=100K - GMM (L3):
sar_gmm,sem_gmm,sarar_gmm— Kelejian-Prucha (1998/1999), heteroskedasticity-robust - Diagnostics:
lm_tests(Anselin 1988 full battery),moran_residuals - Effects:
impacts(LeSage-Pace 2009 direct/indirect/total + simulated SE) - GWR (L4):
gwr,mgwr(Multiscale GWR),gwr_bandwidth(AICc/CV golden-section) - Spatial Panel (L5):
spatial_panel(SAR-FE / SEM-FE / SDM-FE, entity + twoways) - Cross-validated: Columbus rtol<1e-7 vs PySAL spreg 1.9.0; Georgia GWR bit-identical vs mgwr 2.2.1; GMM rtol<1e-4 vs spreg GM_*
Time Series¶
local_projections— Jordà (2005) IRF with Newey-West HACgarch— GARCH(p,q) MLE with multi-step forecastarima— ARIMA/SARIMAX with auto (p,d,q) AICc grid searchbvar— Bayesian VAR with Minnesota (Litterman) prior
Causal Discovery¶
lingam— DirectLiNGAM (Shimizu 2011), bit-identical vs lingam packageges— Greedy Equivalence Search (Chickering 2002)
Matching¶
optimal_match— Hungarian 1:1 matching (min total Mahalanobis distance)cardinality_match— Zubizarreta (2014) LP-based matching with balance constraints
Decomposition & Mediation¶
rifreg— RIF regression (Firpo-Fortin-Lemieux 2009)rif_decomposition— RIF Oaxaca-Blinder for distributional statisticsmediate_sensitivity— Imai-Keele-Yamamoto (2010) ρ-sensitivity
RD & Survey¶
rdpower,rdsampsi— power/sample-size for RD designsrake,linear_calibration— survey calibration (Deville-Särndal 1992)
Survival¶
cox_frailty— Cox with shared gamma frailty (Therneau-Grambsch)aft— Accelerated Failure Time (exponential/Weibull/lognormal/loglogistic)
ML-Causal (GRF)¶
CausalForest.variable_importance(),.best_linear_projection(),.ate(),.att()- Bugfix: honest leaf values now correctly vary per-leaf
Infrastructure¶
- OLS/IV
predict(data, what='confidence'|'prediction')with intervals - Pre-release code review: 3 critical + 2 high-priority bugs fixed
[0.7.1] - 2026-04-15¶
DID-focused polish release. Brings the Wooldridge (2021) ETWFE
implementation to full feature parity with the R etwfe package,
adds a one-call method-robustness workflow, and closes 12 issues
uncovered by an internal code review round. All 27 new / updated
DID tests pass (pytest tests/test_did_summary.py).
Added — ETWFE full parity with R etwfe¶
sp.etwfe()explicit API aligned with Retwfe(McDermott 2023) naming. Thin alias overwooldridge_did()with a full argument- mapping table in the docstring.xvar=covariate heterogeneity (single string or list of names). Adds per-cohort × post ×(x_j − mean(x_j))interactions;detailgainsslope_<x>/slope_<x>_se/slope_<x>_pvaluecolumns. Baseline ATT is reported at the sample means of every covariate.panel=Falserepeated cross-section mode — replaces unit FE with cohort + time dummies (Retwfe(ivar=NULL)equivalent).cgroup='nevertreated'— per-cohort regressions restricted to (cohort g) ∪ (never-treated); cohort-size-weighted aggregation (Retwfe(cgroup='never')equivalent). Default'notyet'preserves prior ETWFE behaviour.sp.etwfe_emfx(result, type=…)— Retwfe::emfx-equivalent four aggregations:'simple','group','event','calendar'.include_leads=Truereturns full event-time output including pre- treatment leads for pre-trend inspection (rel_time = -1is the reference category).
Added — one-call DID method-robustness workflow¶
sp.did_summary()— fits five modern staggered-DID estimators (CS, SA, BJS, ETWFE, Stacked) to the same data and returns a tidy comparison table with per-method (estimate, SE, p, 95 % CI). Mean across methods + cross-method SD flag method-sensitivity of results.include_sensitivity=True— attaches the Rambachan-Roth (2023) breakdownM*to the CS row, giving a three-way robustness readout in a single call.sp.did_summary_plot()— forest plot of per-method estimates with cross-method mean line;sort_by='estimate'supported.sp.did_summary_to_markdown()/_to_latex()— publication- ready exports (GFM tables / booktabs LaTeX with auto-escaped ampersands).sp.did_report(save_to=dir)— one-call bundle that writesdid_summary.txt/.md/.tex/.png/.jsonto a folder.
Fixed — 12 issues from the internal code review¶
Blockers (C-severity):
etwfe(xvar=…)now raises a clearValueErrorwhen the covariate is all-NaN or (near-)constant. Previously returnedn_obs = 0, estimate = 0silently.etwfe(panel=False, cgroup='nevertreated')now raises a crispNotImplementedErrorinstead of silently falling back to'notyet'.did_summarynow validates column names up front (raisesKeyErrorlisting missing columns) and only catches narrow estimator-side exceptions inside the fit loop; user typos incontrols=/cluster=surface as proper errors.did_summaryresults round-trip cleanly through stdlib serialisation (DIDSummaryResult(CausalResult)subclass with a real.summary()method, replacing the prior closure-bound instance attribute).
High-severity:
etwfe_emfx(type='event'/'calendar')now computes SEs via the delta method on the stored event-study vcov instead of the independent-coefficient approximation.model_info['se_method']advertises which path was used.etwfe_emfx(type='group')headlinese/pvalue/ciare now populated (match the underlying fit's overall ATT exactly).- Validation for
did_summary_plot/_to_markdown/_to_latexaligned on a single sentinelmodel_info['_did_summary_marker']. _etwfe_never_onlyno longer leaves a_ft_cachehelper column on the caller's DataFrame.- Slope indexing in
_etwfe_with_xvaris now name-keyed (coef_indexdict); regression test verifies swappingxvar=['x1','x2']vs['x2','x1']produces identical slopes per name. etwfe(panel=False)with rank-deficient designs emits aRuntimeWarningpointing at concrete remedies (previously fell through topinvsilently).
Tests¶
- New test module
tests/test_did_summary.py— 27 cases covering consistency with direct estimator calls, export formats, forest plot rendering,etwfe_emfxround-trips, xvar / panel / cgroup options, the 12 review fixes, and theinclude_leadsmode.
[0.7.0] - 2026-04-14¶
Focused release reaching feature parity with the R did / HonestDiD
packages and the Python csdid / differences packages for staggered
Difference-in-Differences. All core algorithms are reimplemented from
the original papers — no wrappers, no runtime dependencies on upstream
DID packages. Full DiD test suite: 47 → 170+ (including three rounds
of post-implementation audit that surfaced and fixed 9 bugs before
release).
Added — Core estimation¶
sp.aggte(result, type=...)— unified aggregation layer forcallaway_santanna()results. Four aggregation schemes (simple,dynamic,group,calendar) backed by a single weighted- influence-function engine. Callaway & Sant'Anna (2021) Section 4.- Mammen (1993) multiplier bootstrap — IQR-rescaled pointwise
standard errors and simultaneous (uniform / sup-t) confidence
bands over the aggregation dimension. Matches the uniform-band
behaviour of the R
did::aggtefunction. balance_e/min_e/max_e— event-study cohort balancing and window truncation (CS2021 eq. 3.8).anticipation=δparameter oncallaway_santanna()— shifts the base period back by δ periods per CS2021 §3.2.- Repeated cross-sections support via
callaway_santanna(panel=False)— unconditional 2×2 cell-mean DID with observation-level influence functions (CS2021 eq. 2.4, RCS version). Optional covariate residualisation withx=[...]for regression adjustment. All downstream modules (aggte,cs_report,ggdid,honest_did) work on RCS results with no code changes. - dCDH joint inference (
did_multiplegt) —joint_placebo_test(Wald χ² across placebo lags with bootstrap covariance, dCDH 2024 §3.3) andavg_cumulative_effect(mean of dynamic[0..L] with SE preserving cross-horizon covariance, dCDH 2024 §3.4). sp.bjs_pretrend_joint()— cluster-bootstrap joint Wald pre- trend test for BJS imputation results. Upgrades the default sum-of-z² test (which assumes pre-period independence) to a full covariance-aware statistic.
Added — Reporting & visualisation¶
sp.cs_report(data, ...)— one-call report card. Runs the full pipeline (ATT(g,t) → four aggregations with uniform bands → pre-trend Wald → Rambachan–Roth breakdown M* for every post event time) under a single bootstrap seed and pretty-prints the result. Returns a structuredCSReportdataclass.sp.ggdid(result)— plot routine foraggte()output, mirroring Rdid::ggdid. Auto-dispatches on aggregation type; uniform band overlaid on pointwise CI.CSReport.plot()— one-call 2×2 summary figure: event study with uniform band (top-left), θ(g) per-cohort (top-right), θ(t) per-calendar-time (bottom-left), Rambachan–Roth breakdown M* bars (bottom-right).CSReport.to_markdown()— GitHub-flavoured Markdown export with proper integer-column rendering and a configurablefloat_format.CSReport.to_latex()— formatted booktabs fragment wrapped in atablefloat. Zerojinja2dependency (hand-rolled booktabs renderer); auto-escapes LaTeX special characters.CSReport.to_excel()— six-sheet workbook (Summary,Dynamic,Group,Calendar,Breakdown,Meta). Engine autoselect (openpyxl → xlsxwriter) with a clear ImportError when neither is installed.cs_report(..., save_to='prefix')— one-call dump of the full export matrix: writes<prefix>.{txt,md,tex,xlsx,png}in a single invocation, auto-creating missing parent directories. Optional dependencies (openpyxl, matplotlib) are skipped silently so a minimal install still produces text + md + tex.sp.did(..., aggregation='dynamic', n_boot=..., random_state=...)— the top-level dispatcher now forwards CS-style arguments (aggregation,panel,anticipation) and can pipe a CS result straight throughaggte()in a single call.
Changed¶
sun_abraham()inference layer rewritten — replaces the former ad-hoc√(σ²/(total·T))approximation with a Liang–Zeger cluster-robust sandwich(X'X)⁻¹ Σ_c X_c' u_c u_c' X_c (X'X)⁻¹(small-sample adjusted), delta-method IW aggregation SEsw' V_β w, iterative two-way within transformation (correct on unbalanced panels), and optionalcontrol_group='lastcohort'per SA 2021 §6.sp.honest_did()/sp.breakdown_m()made polymorphic — now accept the legacycallaway_santanna()/sun_abraham()format (event study inmodel_info) and the newaggte(type='dynamic')format (event study indetailwith Mammen uniform bands). The idiomatic pipelinecs → aggte → honest_did → breakdown_mnow runs end-to-end with no manual plumbing.- README DiD parity matrix added, comparing StatsPAI against
csdid,differences, and Rdid+HonestDiDacross 15 capabilities.
Fixed (from pre-release audit rounds)¶
- Critical —
aggte(type='dynamic').estimatepreviously averaged pre- and post-treatment event times into the overall ATT, polluting the headline number with placebo signal. Now averages only e ≥ 0, matching Rdid::aggte's print convention. On a typical DGP the bug shifted the reported overall by nearly a factor of 2. - LaTeX escape non-idempotence in
CSReport.to_latex():\→\textbackslash{}followed by{→\{mangled the just-inserted braces. Fixed with a single-passre.sub. cs_report(save_to='~/study/…')did not expand~; fixed viaos.path.expanduser.cs_report(sa_result)/aggte(sa_result)raised crypticKeyError: 'group'; both entry points now detect non-CS input up-front and raise a clearValueError.cs_report(pre_fitted_cs, estimator=…)silently ignored the override; now emits aUserWarninglisting every shadowed arg.sp.did(method='2x2', aggregation='dynamic')silently ignored CS-only arguments; now raises an informativeValueError.bjs_pretrend_jointswallowed all exceptions as "bootstrap failed"; now narrows to expected failure modes and re-raises unexpected errors with context.matplotlib.use('Agg')in_save_report_bundleno longer switches the backend unconditionally (respects Jupyter sessions).
References¶
- Callaway, B. and Sant'Anna, P.H.C. (2021). J. of Econometrics 225(2).
- Sun, L. and Abraham, S. (2021). J. of Econometrics 225(2).
- Mammen, E. (1993). Ann. Statist. 21(1).
- Liang, K.-Y. and Zeger, S.L. (1986). Biometrika 73(1).
- de Chaisemartin, C. and D'Haultfoeuille, X. (2020). AER 110(9).
- de Chaisemartin, C. and D'Haultfoeuille, X. (2024). RESt, forthcoming.
- Rambachan, A. and Roth, J. (2023). Rev. Econ. Studies 90(5).
- Borusyak, K., Jaravel, X. and Spiess, J. (2024). ReStud 91(6).
[0.6.2] - 2026-04-12¶
Added¶
- OLS
predict():result.predict(newdata=)for out-of-sample prediction on OLS results balance_panel(): Utility to keep only units observed in every time period (sp.balance_panel())- Panel
balance=True: Convenience flag insp.panel()to auto-balance before estimation - Analytical weights for DID:
weights=parameter added todid(),ddd(), andevent_study()for population-weighted estimation (Stata[aweight=...]equivalent) - Matching
ps_poly=: Polynomial propensity score specification (ps_poly=2adds interactions/squares, following Cunningham 2021 Ch. 5) - Synth
rmspeplot: Post/pre RMSPE ratio histogram (synthplot(result, type='rmspe')) per Abadie et al. (2010) - Synth placebo gap plot: Full spaghetti placebo gap paths with
rmspe_thresholdfilter (Abadie et al. 2010, Figure 4) - Graddy (2006) replication: Fulton Fish Market IV example added to
sp.replicate()(Mixtape Ch. 7) - Numerical validation tests: early selected Stata/R reference checks
with humanized error messages; current package-wide evidence is reported
through
validation_status, not a blanket validation claim
Fixed¶
outreg2format auto-detection: Correctly infers.xlsx/.csv/.texfrom filename extension- Synth placebo p-value: Now uses RMSPE ratio (√post/√pre) instead of squared ratio, matching Abadie et al. (2010) convention
Improved¶
- DID/DDD/Event Study: Weights propagation through WLS with proper normalization and validation
- Synth placebos: Store full placebo gap trajectories, per-unit RMSPE ratios, and unit labels for richer post-estimation analysis
- Matching tests: Added comprehensive test suite for PSM, Mahalanobis, CEM, and stratification methods
[0.6.1] - 2026-04-07¶
Fixed¶
- Interactive Editor — Theme switching: Themes now fully reset before applying, so switching between themes (e.g. ggplot → academic) correctly updates all visual properties instead of leaking stale settings
- Interactive Editor — Apply button: Fixed Apply button being clipped/hidden on the Layout tab due to panel overflow
- Interactive Editor — Panel layout: Fixed panel content disappearing when using flex layout for bottom-pinned Apply button
- Interactive Editor — Style tab: Fixed Style tab stuck on "Loading" after Theme tab was reordered to first position
- Interactive Editor — Error visibility: Widget callback errors now surface in the status bar instead of being silently swallowed
Improved¶
- Interactive Editor — Auto mode: Clicking Auto now always refreshes the preview, giving immediate visual feedback
- Interactive Editor — Auto/Manual toggle: Compact toggle button moved to panel header with sticky positioning
- Interactive Editor — Apply button: Separated from Auto toggle and placed at panel bottom-right for better UX
- Interactive Editor — Theme tab: Moved to first position for better discoverability
- Interactive Editor — Color pickers: Added visual confirmation feedback on all color changes
- Interactive Editor — Code generation: Auto-generate reproducible code with text selection support in the editor
- Smart recommendations: Enhanced compare and recommend logic
- Registry: Expanded module support in the registry
[0.1.0] - 2024-07-26¶
Added¶
- Core Regression Framework
- OLS (Ordinary Least Squares) regression with formula interface
- Robust standard errors (HC0, HC1, HC2, HC3)
- Clustered standard errors
-
Weighted Least Squares (WLS) support
-
Causal Inference Module
- Causal Forest implementation inspired by Wager & Athey (2018)
- Honest estimation for unbiased treatment effect estimation
- Bootstrap confidence intervals for treatment effects
-
Formula interface:
"outcome ~ treatment | features | controls" -
Output Management (outreg2)
- Excel export functionality similar to Stata's outreg2
- Support for multiple regression models in single output
- Customizable formatting options
-
Professional table layout
-
Unified API Design
- Consistent
reg()function interface - Formula parsing: R/Stata-style syntax
"y ~ x1 + x2" - Type hints throughout the codebase
- Comprehensive documentation
Technical Details¶
- Python 3.8+ support
- Dependencies: numpy, scipy, pandas, scikit-learn, openpyxl
- MIT License
- Comprehensive test suite