Skip to content

Commit a8693bf

Browse files
test: Update reference parity tests for all estimators
1 parent 73c1476 commit a8693bf

184 files changed

Lines changed: 9061 additions & 5036 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

tests/r_parity/03_hdfe.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def main() -> None:
4444
df = make_panel()
4545
dump_csv(df, MODULE)
4646

47-
fit = sp.fast.feols(FORMULA, data=df, vcov="iid")
47+
fit = sp.fast.feols(FORMULA, data=df, vcov="iid", ssc="fixest")
4848

4949
# FeolsResult exposes .coef() and .se() as methods returning a
5050
# pandas Series indexed by the non-absorbed regressor names.
@@ -74,17 +74,11 @@ def main() -> None:
7474
"n_years": int(df["year"].nunique()),
7575
"df_resid": int(fit.df_resid),
7676
"rss": float(fit.rss),
77-
"df_convention": (
78-
"sp.fast.feols uses df_resid = N - sum(FE_levels). "
79-
"fixest::feols uses df_resid = N - sum(FE_levels) - 1, "
80-
"subtracting one extra for the cross-FE collinearity. "
81-
"The point estimates are identical to ~1e-15; the IID "
82-
"SE ratio fixest/sp.fast is exactly "
83-
"sqrt(sp_df / fixest_df) = sqrt(9730 / 9729) = "
84-
"1.0000514, so the rel SE diff is 5.14e-5. Reviewers "
85-
"should treat this as a small-sample-correction "
86-
"convention difference, not as a numerical bug; "
87-
"fixest's SE = sp.fast's SE * sqrt(9730/9729)."
77+
"ssc": "fixest",
78+
"parity_note": (
79+
"sp.fast.feols uses ssc='fixest' so the residual FE "
80+
"rank and IID standard errors match fixest::feols and "
81+
"reghdfe on this two-way HDFE fixture."
8882
),
8983
},
9084
)

tests/r_parity/05_sunab.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""StatsPAI Sun-Abraham event-study parity (Python side) -- Module 05.
22
3-
Runs sp.sun_abraham on the mpdta replica and emits the simple
4-
weighted average ATT and the dynamic event-study coefficients at
5-
relative times -3, -2, 0, 1, 2 (post-period anchor reference). The
3+
Runs sp.sun_abraham(..., aggregation="fixest_att") on the mpdta
4+
replica and emits the R/Stata-compatible weighted average ATT plus
5+
the dynamic event-study coefficients at relative times -3, -2, 0, 1,
6+
2 (post-period anchor reference). The
67
companion R script runs fixest::feols(... | sunab(g, t)) on the
78
same CSV.
89
@@ -23,7 +24,10 @@ def main() -> None:
2324
df = sp.datasets.mpdta()
2425
dump_csv(df, MODULE)
2526

26-
fit = sp.sun_abraham(df, y="lemp", g="first_treat", t="year", i="countyreal")
27+
fit = sp.sun_abraham(
28+
df, y="lemp", g="first_treat", t="year", i="countyreal",
29+
aggregation="fixest_att",
30+
)
2731

2832
rows: list[ParityRecord] = [
2933
ParityRecord(
@@ -35,6 +39,14 @@ def main() -> None:
3539
n=int(len(df)),
3640
)
3741
]
42+
rows.append(
43+
ParityRecord(
44+
module=MODULE, side="py", statistic="event_time_avg_ATT",
45+
estimate=float(fit.model_info["att_event_time"]),
46+
se=float(fit.model_info["se_event_time"]),
47+
n=int(len(df)),
48+
)
49+
)
3850

3951
# Per-relative-time event-study coefficients.
4052
es = fit.model_info.get("event_study")
@@ -58,15 +70,14 @@ def main() -> None:
5870
extra={
5971
"control_group": "nevertreated",
6072
"method": fit.method,
61-
"aggregation_note": (
62-
"sp.sun_abraham reports the unweighted mean of the "
63-
"post-treatment per-relative-time ATTs as its summary "
64-
"estimate. fixest::feols with agg='att' uses a "
65-
"cohort-share-weighted average that recovers the "
66-
"Callaway-Sant'Anna simple ATT (-0.03298). On this "
67-
"DGP the two summaries differ by ~2.4% (sp: -0.0338; "
68-
"fixest: -0.0330). Per-relative-time event-study "
69-
"coefficients agree at rel < 1e-11."
73+
"aggregation": fit.model_info["summary_aggregation"],
74+
"aggregation_parity_note": (
75+
"The weighted_avg_ATT row uses "
76+
"sp.sun_abraham(..., aggregation='fixest_att'), which "
77+
"weights post-treatment cohort-time cells by treated "
78+
"cohort size and matches fixest::summary(..., agg='att') "
79+
"on the mpdta fixture. The historical equal-weighted "
80+
"post-event-time summary is retained as event_time_avg_ATT."
7081
),
7182
},
7283
)

tests/r_parity/06_rd.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Reads data/06_rd.csv (the StatsPAI Lee 2008 senate replica) and
44
# runs rdrobust::rdrobust with package defaults (kernel = triangular,
5-
# p = 1, q = p + 1 = 2, bwselect = mserd). Tolerance: rel < 1e-3.
5+
# p = 1, q = p + 1 = 2, bwselect = mserd). Tolerance: rel < 1e-6.
66

77
.args <- commandArgs(trailingOnly = FALSE)
88
.file_arg <- grep("^--file=", .args, value = TRUE)

tests/r_parity/06_rd.py

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""StatsPAI RD CCT bias-corrected parity (Python side) -- Module 06.
22
3-
Runs sp.rdrobust on the Lee 2008 senate replica with the package
4-
defaults (kernel = triangular, p = 1, q = 2, bwselect = mserd) and
5-
emits both the conventional and the robust bias-corrected estimates.
6-
The companion R script runs rdrobust::rdrobust with identical
7-
defaults.
3+
Runs sp.rdrobust(..., bwselect="cct") on the Lee 2008 senate replica
4+
so the Track A row exercises the same Calonico-Cattaneo-Titiunik
5+
``rdrobust`` bandwidth selector used by R and Stata. The legacy
6+
StatsPAI internal ``mserd`` selector is still recorded as a diagnostic
7+
row, but the parity headline is the canonical CCT path.
88
9-
Tolerance: rel < 1e-3 (iterative bandwidth selection).
9+
Tolerance: rel < 1e-6 against R/Stata CCT defaults.
1010
"""
1111
from __future__ import annotations
1212

@@ -22,8 +22,9 @@ def main() -> None:
2222
df = sp.datasets.lee_2008_senate()
2323
dump_csv(df, MODULE)
2424

25-
# Default mserd bandwidth selector.
26-
fit = sp.rdrobust(df, y="voteshare_next", x="margin", c=0.0)
25+
# Canonical R/Stata rdrobust bandwidth selector via the official
26+
# rdrobust Python port.
27+
fit = sp.rdrobust(df, y="voteshare_next", x="margin", c=0.0, bwselect="cct")
2728

2829
rows: list[ParityRecord] = []
2930
for label in ("conventional", "robust"):
@@ -52,11 +53,31 @@ def main() -> None:
5253
)
5354
)
5455

55-
# Forced-bandwidth replicate at h = b = 0.042287 so the
56-
# bandwidth-selector convention difference is isolated from the
57-
# local-polynomial estimator math itself. R-side mirrors this
58-
# with rdrobust(..., h = 0.042287, b = 0.042287).
59-
H_FORCED = 0.042287
56+
# Legacy internal-selector diagnostic. This keeps the old default
57+
# visible without mixing it with the R/Stata default-h parity rows.
58+
legacy = sp.rdrobust(df, y="voteshare_next", x="margin", c=0.0)
59+
rows.append(
60+
ParityRecord(
61+
module=MODULE, side="py",
62+
statistic="legacy_internal_mserd_bandwidth_h",
63+
estimate=float(legacy.model_info["bandwidth_h"]), n=int(len(df)),
64+
)
65+
)
66+
rows.append(
67+
ParityRecord(
68+
module=MODULE, side="py",
69+
statistic="legacy_internal_mserd_robust_est",
70+
estimate=float(legacy.model_info["robust"]["estimate"]),
71+
se=float(legacy.model_info["robust"]["se"]),
72+
ci_lo=float(legacy.model_info["robust"]["ci"][0]),
73+
ci_hi=float(legacy.model_info["robust"]["ci"][1]),
74+
n=int(len(df)),
75+
)
76+
)
77+
78+
# Forced-bandwidth replicate at the legacy h = b = 0.042287 so the
79+
# local-polynomial estimator math remains separately pinned.
80+
H_FORCED = float(legacy.model_info["bandwidth_h"])
6081
fit_forced = sp.rdrobust(df, y="voteshare_next", x="margin", c=0.0,
6182
h=H_FORCED, b=H_FORCED)
6283
for label in ("conventional", "robust"):
@@ -80,23 +101,14 @@ def main() -> None:
80101
"p": fit.model_info["polynomial_p"],
81102
"q": fit.model_info["polynomial_q"],
82103
"bwselect": fit.model_info["bwselect"],
83-
"bandwidth_selector_gap": (
84-
"On this Lee-2008 replica, sp.rdrobust's MSE-RD "
85-
"bandwidth selector returns h=0.042 while "
86-
"rdrobust::rdrobust returns h=0.176 -- a ~4x "
87-
"difference that propagates into the headline "
88-
"estimate (sp default ~0.062; R default ~0.076). "
89-
"When BOTH implementations are forced to the same "
90-
"bandwidth h=0.042287 the bias-corrected point "
91-
"estimate matches at rel ~ 1e-6 and the conventional "
92-
"SE at ~6%. The discrepancy is in the regularisation "
93-
"term of the optimal-bandwidth formula and is "
94-
"documented in tests/reference_parity/test_rd_parity.py "
95-
"as a known gap; reviewers should treat the bandwidth "
96-
"selector as a calibrated package-default choice that "
97-
"differs across implementations, not as evidence of a "
98-
"numerical bug in the underlying local-polynomial "
99-
"estimator."
104+
"bandwidth_parity_note": (
105+
"Track A uses sp.rdrobust(..., bwselect='cct'), which "
106+
"delegates to the official rdrobust Python port and "
107+
"matches R/Stata rdrobust default mserd bandwidths on "
108+
"the Lee-2008 fixture. The legacy StatsPAI internal "
109+
"mserd selector is retained as legacy_internal_mserd_* "
110+
"diagnostic rows and should not be used as the "
111+
"cross-language default-h parity claim."
100112
),
101113
},
102114
)

tests/r_parity/07_scm.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
52_scm_unique; users who need exact R numbers can call the optional
1616
``backend='synth'`` bridge.
1717
"""
18+
1819
from __future__ import annotations
1920

2021
import statspai as sp
2122

2223
from _common import ParityRecord, dump_csv, write_results
2324

24-
2525
MODULE = "07_scm"
2626

2727

@@ -53,13 +53,17 @@ def main() -> None:
5353

5454
rows: list[ParityRecord] = [
5555
ParityRecord(
56-
module=MODULE, side="py", statistic="avg_post_gap",
56+
module=MODULE,
57+
side="py",
58+
statistic="avg_post_gap",
5759
estimate=float(fit.estimate),
5860
se=float(fit.se),
5961
n=int(len(df)),
6062
),
6163
ParityRecord(
62-
module=MODULE, side="py", statistic="pre_treatment_rmse",
64+
module=MODULE,
65+
side="py",
66+
statistic="pre_treatment_rmse",
6367
estimate=float(fit.model_info["pre_treatment_rmse"]),
6468
n=int(len(df)),
6569
),
@@ -77,15 +81,18 @@ def main() -> None:
7781
for unit in donor_pool:
7882
rows.append(
7983
ParityRecord(
80-
module=MODULE, side="py",
84+
module=MODULE,
85+
side="py",
8186
statistic=f"weight_{unit}",
8287
estimate=float(weight_map.get(unit, 0.0)),
8388
n=int(len(df)),
8489
)
8590
)
8691

8792
write_results(
88-
MODULE, "py", rows,
93+
MODULE,
94+
"py",
95+
rows,
8996
extra={
9097
"method": "classic",
9198
"backend": fit.model_info.get("backend", "native"),
@@ -96,15 +103,29 @@ def main() -> None:
96103
"n_donors": int(fit.model_info["n_donors"]),
97104
"placebo": False,
98105
"tier": "T4",
106+
"solver_best_start": fit.model_info.get("solver_best_start"),
107+
"solver_near_best_start_count": int(
108+
fit.model_info.get("solver_near_best_start_count", 0)
109+
),
110+
"solver_near_best_weight_class_count": int(
111+
fit.model_info.get("solver_near_best_weight_class_count", 0)
112+
),
113+
"solver_near_best_weight_l1_max": float(
114+
fit.model_info.get("solver_near_best_weight_l1_max", 0.0)
115+
),
116+
"weight_solution_nonunique": bool(
117+
fit.model_info.get("weight_solution_nonunique", False)
118+
),
99119
"native_note": (
100120
"Headline row uses backend='native'. The Basque donor-weight "
101121
"solution is not unique under the ADH special-predictor "
102-
"nested-V specification: native tracks Stata synth on the "
103-
"same CSV, while R Synth and Stata synth choose measurably "
104-
"different local optima. Native correctness is separately "
105-
"certified on a uniquely identified DGP in module "
106-
"52_scm_unique; backend='synth' is available when exact R "
107-
"Synth numbers are required."
122+
"nested-V specification: deterministic multi-start diagnostics "
123+
"find multiple near-best donor-weight classes, native tracks "
124+
"Stata synth on the same CSV, and R Synth and Stata synth "
125+
"choose measurably different local optima. Native correctness "
126+
"is separately certified on a uniquely identified DGP in "
127+
"module 52_scm_unique; backend='synth' is available when exact "
128+
"R Synth numbers are required."
108129
),
109130
},
110131
)

tests/r_parity/08_dml.R

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
# DoubleML::DoubleMLPLR with mlr3::regr.lm nuisance learners. Five
55
# folds, single repetition. Tolerance: rel < 1e-3.
66
#
7-
# Note: cross-fitting fold splits are seeded so that R sklearn-style
8-
# KFold and mlr3 ResamplingCV(folds=5) DO NOT produce bit-equal
9-
# splits. The gap on theta_DML_PLR therefore reflects fold-noise
10-
# alone (the score function is identical on both sides).
7+
# The CSV carries a deterministic fold_id column written by the Python
8+
# side. Both implementations consume those exact folds through their
9+
# explicit sample-splitting APIs.
1110

1211
.args <- commandArgs(trailingOnly = FALSE)
1312
.file_arg <- grep("^--file=", .args, value = TRUE)
@@ -51,6 +50,11 @@ dml_obj <- DoubleML::DoubleMLPLR$new(
5150
score = "partialling out",
5251
dml_procedure = "dml2"
5352
)
53+
fold_levels <- sort(unique(dt$fold_id))
54+
test_ids <- lapply(fold_levels, function(k) which(dt$fold_id == k))
55+
train_ids <- lapply(fold_levels, function(k) which(dt$fold_id != k))
56+
smpls <- list(list(train_ids = train_ids, test_ids = test_ids))
57+
dml_obj$set_sample_splitting(smpls)
5458
dml_obj$fit()
5559

5660
theta <- as.numeric(dml_obj$coef["educ"])
@@ -74,5 +78,7 @@ write_results(MODULE, rows,
7478
n_folds = 5L,
7579
ml_g = "regr.lm",
7680
ml_m = "regr.lm",
81+
fold_source = "user",
82+
fold_column = "fold_id",
7783
score = "partialling out",
7884
dml_procedure = "dml2"))

tests/r_parity/08_dml.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
def main() -> None:
2626
df = sp.datasets.card_1995()
27+
df["fold_id"] = np.arange(len(df)) % 5
2728
dump_csv(df, MODULE)
2829

2930
# NOTE: pass numpy seed so sklearn's KFold split is deterministic;
@@ -34,6 +35,8 @@ def main() -> None:
3435
model="plr",
3536
model_y=LinearRegression(),
3637
model_d=LinearRegression(),
38+
n_folds=5,
39+
fold_indices=df["fold_id"].to_numpy(),
3740
)
3841

3942
rows: list[ParityRecord] = [
@@ -55,18 +58,12 @@ def main() -> None:
5558
"ml_g": "LinearRegression",
5659
"ml_m": "LinearRegression",
5760
"seed": PARITY_SEED,
58-
"fold_split_note": (
59-
"DML PLR with linear-regression nuisance learners is "
60-
"equivalent to OLS on the partialled-out residuals, so "
61-
"both implementations should converge to OLS beta(educ) "
62-
"= 0.10999 (Module 01) up to fold-split Monte Carlo "
63-
"noise. sklearn.KFold(shuffle=True, seed=42) and mlr3 "
64-
"ResamplingCV(folds=5, set.seed(42)) produce different "
65-
"partitions, so the residual gap (~2e-4 absolute, "
66-
"~2.4e-3 relative) is fold-split RNG drift, not a "
67-
"score-function or orthogonalisation bug. Forcing the "
68-
"same fold indices on both sides closes the gap to "
69-
"machine precision."
61+
"fold_source": str(fit.model_info["fold_source"]),
62+
"fold_column": "fold_id",
63+
"fold_parity_note": (
64+
"Python and R read the same fold_id column and use "
65+
"explicit sample-splitting APIs before fitting the "
66+
"linear-nuisance PLR score."
7067
),
7168
},
7269
)

tests/r_parity/10_honest_did.R

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
# runs HonestDiD::createSensitivityResults under "FLCI" (the
55
# finite-sample length-optimised CI under the smoothness restriction
66
# Delta^SD), which is the closest match to sp.honest_did's smoothness
7-
# restriction. Tolerance: abs < 0.05 on the CI bounds.
7+
# restriction. Tolerance: abs < 5e-4 on CI bounds; Python-R is
8+
# machine-equal through the R backend, while the Stata port is kept
9+
# inside this package-port envelope.
810

911
.args <- commandArgs(trailingOnly = FALSE)
1012
.file_arg <- grep("^--file=", .args, value = TRUE)

0 commit comments

Comments
 (0)