Efficient Difference-in-Differences#
Semiparametrically efficient ATT estimator for staggered adoption designs from Chen, Sant’Anna & Xie (2025).
This module implements the efficiency-bound-attaining estimator that:
Achieves the semiparametric efficiency bound for ATT(g,t) estimation on the no-covariate path
Optimally weights across comparison groups and baselines via the inverse covariance matrix Ω*
Supports two PT assumptions: PT-All (overidentified, tighter SEs) and PT-Post (just-identified, matches CS for post-treatment effects)
Uses EIF-based inference for analytical standard errors and multiplier bootstrap
Note
EfficientDiD supports a doubly-robust covariate path with all nuisances
estimated nonparametrically: sieve-based propensity score ratios and a
sieve outcome regression (polynomial basis, AIC/BIC order selection),
plus the kernel-smoothed conditional covariance. The DR property ensures
consistency if either the outcome regression or the PS ratio is correctly
specified, and because the nuisances are growing sieves/kernels the
covariate path attains the semiparametric efficiency bound asymptotically
under the paper’s regularity conditions (degree 1 reproduces a linear
working model, and sieve_k_max=1 forces all covariate-path sieves to
degree 1). Pass column names to the covariates
parameter on fit(). See docs/methodology/REGISTRY.md for the full
contract.
When to use EfficientDiD:
Staggered adoption design where you want maximum efficiency on the no-covariate path
You believe parallel trends holds across all pre-treatment periods (PT-All)
You want tighter confidence intervals than Callaway-Sant’Anna
You need a formal efficiency benchmark for comparing estimators
For covariate-adjusted designs, the doubly-robust path is consistent under either outcome-regression or propensity-ratio correctness and attains the efficiency bound under the paper’s regularity conditions, with all nuisances (sieve propensity ratio, sieve outcome regression, kernel covariance) estimated nonparametrically.
Reference: Chen, X., Sant’Anna, P. H. C., & Xie, H. (2025). Efficient Difference-in-Differences and Event Study Estimators.
EfficientDiD#
Main estimator class for Efficient Difference-in-Differences.
- class diff_diff.EfficientDiD[source]
Bases:
EfficientDiDBootstrapMixin,_EfficientAggregationMixin,BaseEstimatorEfficient DiD estimator (Chen, Sant’Anna & Xie 2025).
Without covariates, achieves the semiparametric efficiency bound for ATT(g,t) using a closed-form estimator based on within-group sample means and covariances.
With covariates, uses a doubly robust path: sieve-based propensity score ratios (Eq 4.1-4.2), sieve outcome regressions (polynomial basis, AIC/BIC order selection), sieve-estimated inverse propensities (algorithm step 4), and kernel-smoothed conditional Omega*(X) with per-unit efficient weights (Eq 3.12). The DR property ensures consistency if either the outcome regression or the sieve propensity ratio is correctly specified; because all nuisances are sieves / kernel smoothers (the paper’s flexible-nuisance specification), the covariate path attains the semiparametric efficiency bound under the paper’s regularity conditions (see REGISTRY.md).
- Parameters:
pt_assumption (str, default
"all") – Parallel trends variant:"all"(overidentified, uses all pre-treatment periods and comparison groups) or"post"(just-identified, single baseline, equivalent to CS).alpha (float, default 0.05) – Significance level.
cluster (str or None) – Column name for cluster-robust SEs. When set, analytical SEs use the Liang-Zeger clustered sandwich estimator on EIF values. With
n_bootstrap > 0, bootstrap weights are generated at the cluster level (all units in a cluster share the same weight).vcov_type (str, default
"hc1") – Variance-estimator family. Permanently narrow to{"hc1"}per the Chen-Sant’Anna-Xie (2025) IF-based variance — analytical-sandwich families{classical, hc2, hc2_bm}andconleyare rejected at__init__/set_params. See REGISTRY.md for the methodology rationale (no single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined). Usecluster=<col>for Liang-Zeger CR1 on cluster-aggregated EIF; usesurvey_design=for Taylor Series Linearization on the combined IF.control_group (str, default
"never_treated") – Which units serve as the comparison group:"never_treated"requires a never-treated cohort (raises if none exist);"last_cohort"reclassifies the latest treatment cohort as pseudo-never-treated and drops periods att >= last_g - anticipationso the pseudo-control’s pre-treatment window excludes anticipation-contaminated periods. Distinct from CallawaySantAnna’s"not_yet_treated"— see REGISTRY.md for details.n_bootstrap (int, default 0) – Number of multiplier bootstrap iterations (0 = analytical only).
bootstrap_weights (str, default
"rademacher") – Bootstrap weight distribution.seed (int or None) – Random seed for reproducibility.
anticipation (int, default 0) – Number of anticipation periods (shifts the effective treatment boundary forward by this amount). Must be a non-negative integer;
boolis rejected. When combined withcontrol_group="last_cohort", also trims the pseudo-control period set att >= last_g - anticipation(see REGISTRY.md).sieve_k_max (int or None) – Maximum polynomial degree for the covariate-path sieves — the propensity-ratio, inverse-propensity, AND outcome-regression fits all use it. None = auto (
floor(n_pos^{1/5})over each group’s positive-weight supportn_pos— the raw group size when unweighted — a growing sieve with no fixed ceiling, bounded byn_basis < n_pos; zero-weight survey rows do not affect order selection). Only used with covariates.sieve_k_max=1forces every covariate-path sieve (outcome regression and both propensity sieves) to degree 1: it recovers the pre-sieve linear-OLS outcome regression but also degree-1-constrains the propensity sieves, so it does not reproduce the exact pre-sieve estimator.sieve_criterion (str, default
"bic") – Information criterion ("aic"or"bic") for the order selection of all covariate-path sieves (propensity ratio, inverse propensity, and outcome regression).ratio_clip (float, default 20.0) – Clip sieve propensity ratios to
[1/ratio_clip, ratio_clip].kernel_bandwidth (float or None) – Bandwidth for Gaussian kernel in conditional Omega* estimation. None = Silverman’s rule-of-thumb (automatic).
omega_ridge (float, default
OMEGA_RIDGE_DEFAULT(1e-6)) – Relative ridge for the Omega* inversion behind the efficient weights: solves(Omega* + omega_ridge * max(trace/H, 0) * I) x = 1instead of inverting the numerically singular Omega* that PT-All’s telescoping overidentified moments produce. Stabilizes per-cell ATT(g,t) against floating-point-level input/BLAS changes (1-ulp stability ~1e-9 vs ~1e-4 for the legacy pseudoinverse) without changing overall-ATT bias/RMSE/coverage (see REGISTRY.md).omega_ridge=0restores the exact legacy inv/pinv code path bit-for-bit - including its per-cell condition-number warnings and the slow O(n^2 H^2) conditional-Omega* loops, so expect the legacy runtime as well.
Examples
>>> from diff_diff import EfficientDiD >>> edid = EfficientDiD(pt_assumption="all") >>> results = edid.fit(data, outcome="y", unit="id", time="t", ... first_treat="first_treat") >>> results.print_summary() >>> results.aggregate("event_study").summary() # post-fit aggregation
Methods
fit(data, outcome, unit, time, first_treat)Fit the Efficient DiD estimator.
get_params([deep])Get estimator parameters (sklearn-compatible).
set_params(**params)Set estimator parameters (sklearn-compatible, transactional).
- __init__(pt_assumption='all', alpha=0.05, cluster=None, vcov_type='hc1', control_group='never_treated', n_bootstrap=0, bootstrap_weights='rademacher', seed=None, anticipation=0, sieve_k_max=None, sieve_criterion='bic', ratio_clip=20.0, kernel_bandwidth=None, omega_ridge=1e-06)[source]
- alpha
- n_bootstrap
- bootstrap_weights
- seed
- anticipation
- results_: EfficientDiDResults | None
- fit(data, outcome, unit, time, first_treat, covariates=None, aggregate=<not supplied>, balance_e=<not supplied>, survey_design=None, store_eif=False)[source]
Fit the Efficient DiD estimator.
- Parameters:
data (DataFrame) – Balanced panel data.
outcome (str) – Outcome variable column name.
unit (str) – Unit identifier column name.
time (str) – Time period column name.
first_treat (str) – Column indicating first treatment period. Use 0 or
np.inffor never-treated units.covariates (list of str, optional) – Column names for time-invariant unit-level covariates. When provided, uses the doubly robust path (outcome regression + propensity score ratios).
aggregate (str, optional) – DEPRECATED (3.9, removed in 4.0, row M-023): supplying any value (
Noneincluded) emits a FutureWarning; the deprecated path still computes and stores the requested surfaces exactly as before (None,"simple","event_study","group", or"all"; unknown strings act likeNone, unchanged). Aggregate as a post-fit step instead:results.aggregate('event_study')/.aggregate('group')/.aggregate('simple')/.aggregate('total'). On bootstrapped fits (n_bootstrap > 0) the post-fit RECOMPUTE levels ('event_study'/'group') REPLAY the fit-time multiplier bootstrap from the kit-retained RNG state (percentile inference, allclose to a fit-time aggregation; no refit needed), whileaggregate('simple')and, where supported,aggregate('total')relay the stored bootstrap inference (the per-level policy converged with row M-027).balance_e (int, optional) – DEPRECATED (3.9, removed in 4.0, row M-120): moves onto post-fit
aggregate()—results.aggregate('event_study', balance_e=2). EDiD’s balance rule is the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon), the same rule shape CallawaySantAnna uses — with one keying-granularity difference: EDiD anchors on theint(t - g)bucket while CS keys rawt - g(identical on integer-period panels; see the REGISTRY truncation Note).survey_design (SurveyDesign, optional) – Survey design specification for design-based inference. Applies survey weights to all means, covariances, and cohort fractions, and uses Taylor Series Linearization for SE estimation. Cannot be combined with
cluster.store_eif (bool, default False) – Expose per-(g,t) EIF vectors on the PUBLIC
influence_functionsresults field. Used internally byhausman_pretest(). Since 3.9 (row M-023) the private aggregation kit ALWAYS retains the per-(g,t) EIF dict to power post-fitresults.aggregate()—store_eifgoverns only the public field, no longer the retention itself.
- Return type:
- Raises:
ValueError – Missing columns, unbalanced panel, non-absorbing treatment, or PT-Post without a never-treated group.
- print_summary()[source]
Print summary to stdout.
- Return type:
None
- classmethod hausman_pretest(data, outcome, unit, time, first_treat, covariates=None, cluster=None, anticipation=0, control_group='never_treated', alpha=0.05, **nuisance_kwargs)[source]
Hausman pretest for PT-All vs PT-Post (Theorem A.1).
Fits the estimator under both parallel trends assumptions and compares the results. Under H0 (PT-All holds), both are consistent but PT-All is more efficient. Rejection suggests PT-All is too strong; use PT-Post instead.
- Parameters:
data (DataFrame) – Same as
fit().outcome (str) – Same as
fit().unit (str) – Same as
fit().time (str) – Same as
fit().first_treat (str) – Same as
fit().cluster (str, optional) – Cluster column for cluster-robust covariance.
anticipation (int) – Anticipation periods. Must be a non-negative integer;
boolis rejected.control_group (str) –
"never_treated"or"last_cohort".alpha (float) – Significance level for the test.
**nuisance_kwargs – Passed to both fits (e.g.
sieve_k_max,ratio_clip,omega_ridge).
- Return type:
HausmanPretestResult
- get_params(deep=True)
Get estimator parameters (sklearn-compatible).
- Parameters:
deep (bool, default True) – Accepted for sklearn compatibility (
sklearn.base.clonecallsget_params(deep=False)) and ignored: no diff-diff estimator nests another estimator.- Returns:
Estimator parameters suitable for passing to
__init__. Keys follow the__init__signature order. Where a class stores a parameter’s raw value under a different attribute (_PARAM_ATTR_ALIASES), the raw value is returned so thattype(est)(**est.get_params())reconstructs the same configuration.- Return type:
Dict[str, Any]
- set_params(**params)
Set estimator parameters (sklearn-compatible, transactional).
Validates the merged configuration by constructing a throwaway instance of
type(self)- so a rejected call raises the same error__init__would and leaves this estimator unchanged - then adopts the probe’s parameter attributes and the class’s declared derived-config attributes. Fitted state is never touched.- Parameters:
**params (Any) – Estimator parameters. Unknown names raise
ValueErrorbefore any validation or mutation.self (TSelf)
**params
- Return type:
self
EfficientDiDResults#
Results container for Efficient DiD estimation.
- class diff_diff.efficient_did_results.EfficientDiDResults[source]
Bases:
BaseResults,AggregationMixinResults from Efficient DiD (Chen, Sant’Anna & Xie 2025) estimation.
Stores group-time ATT(g,t) estimates with efficient weights, plus optional aggregations (overall ATT, event study, group effects).
- group_time_effects
{(g, t): {'effect', 'se', 't_stat', 'p_value', 'conf_int', 'n_treated', 'n_control'}}- Type:
- overall_att
Overall ATT (cohort-size weighted average of post-treatment group-time effects, matching CallawaySantAnna convention).
- Type:
- overall_se
Standard error of overall ATT.
- Type:
- overall_t_stat
t-statistic for overall ATT.
- Type:
- overall_p_value
p-value for overall ATT.
- Type:
- overall_conf_int
Confidence interval for overall ATT.
- Type:
- groups
Treatment cohort identifiers.
- Type:
- time_periods
All time periods.
- Type:
- n_obs
Total observations (units x periods).
- Type:
- n_treated_units
Number of ever-treated units.
- Type:
- n_control_units
Number of never-treated units.
- Type:
- alpha
Significance level.
- Type:
- pt_assumption
"all"or"post".- Type:
- anticipation
Number of anticipation periods used.
- Type:
- n_bootstrap
Number of bootstrap iterations (0 = analytical only).
- Type:
- bootstrap_weights
Bootstrap weight distribution (
"rademacher","mammen","webb").- Type:
- seed
Random seed used for bootstrap.
- Type:
int or None
- event_study_effects
{relative_time: effect_dict}- Type:
dict, optional
- group_effects
{group: effect_dict}- Type:
dict, optional
- efficient_weights
{(g, t): ndarray}— diagnostic: weight vector per target.- Type:
dict, optional
- omega_condition_numbers
{(g, t): float}— diagnostic: Omega* condition numbers.- Type:
dict, optional
- cluster_name
Cluster column used at fit time (None for unclustered fits; suppressed under any survey design). Populated when
cluster=is passed tofit().- Type:
str or None
- n_clusters
Number of clusters at fit time (None for unclustered or survey fits). Renders as
G=<n>in the variance-estimator summary line.- Type:
int or None
- vcov_type
Variance-estimator family. Permanently
"hc1"per the Chen-Sant’Anna-Xie (2025) IF-based variance; see REGISTRY.md.- Type:
- influence_functions
{(g, t): ndarray(n_units,)}— per-unit EIF values for each group-time cell. Only populated whenstore_eif=Trueinfit()(used internally byhausman_pretest). Since 3.9 (row M-023) the private aggregation kit ALWAYS retains the same per-(g,t) EIF dict to power post-fitaggregate()—store_eifgoverns only this public field.- Type:
dict, optional
- bootstrap_results
Bootstrap inference results.
- Type:
EDiDBootstrapResults, optional
- estimation_path
"nocov"or"dr"— which estimation path was used.- Type:
- sieve_k_max
Maximum polynomial degree for the covariate-path sieves (propensity ratio, inverse propensity, and outcome regression);
1forces a linear outcome-regression working model.- Type:
int or None
- sieve_criterion
Information criterion used (
"aic"or"bic") for all covariate-path sieve order selection.- Type:
- ratio_clip
Clipping bound for sieve propensity ratios.
- Type:
- kernel_bandwidth
Bandwidth used for kernel-smoothed conditional Omega*.
- Type:
float or None
- omega_ridge
Relative ridge used for the Omega* inversion behind the efficient weights (0 = legacy exact-inverse/pseudoinverse path).
- Type:
- event_study_df
Scalar df governing the event-study rows’ t-inference (the fit’s survey df).
Noneon non-survey fits, on bootstrapped fits (percentile inference used no df), and when no fit-time event-study surface was built.- Type:
float or None
Methods
aggregate(type[, weights, balance_e])Re-aggregate this fit without refitting.
summary([alpha])Generate formatted summary of estimation results.
print_summary([alpha])Print summary to stdout.
to_dataframe([level])Convert results to DataFrame.
Attributes
reference_periodReference event time of the materialized PT-Post anchor, or None.
- overall_att: float
- overall_se: float
- overall_t_stat: float
- overall_p_value: float
- n_obs: int
- n_treated_units: int
- n_control_units: int
- alpha: float = 0.05
- pt_assumption: str = 'all'
- anticipation: int = 0
- n_bootstrap: int = 0
- bootstrap_weights: str = 'rademacher'
- control_group: str = 'never_treated'
- vcov_type: str = 'hc1'
- bootstrap_results: EDiDBootstrapResults | None = None
- estimation_path: str = 'nocov'
- sieve_criterion: str = 'bic'
- ratio_clip: float = 20.0
- omega_ridge: float = 0.0
- property att: float
- property se: float
- property p_value: float
- property t_stat: float
- property reference_period: int | None
Reference event time of the materialized PT-Post anchor, or None.
MEMBERSHIP-GATED (the SunAbraham rule): returns
-1 - anticipationonly when this fit ran underpt_assumption="post"AND that event time is actually present inevent_study_effects— under PT-Post the per-cohort baseline cell(g, g-1-anticipation)is estimated as a mechanical zero whenever it is not the panel’s first period, and the builder marks that materialized rowis_reference. When the anchor cell was never estimated (every surviving cohort baselined at the first period) the property is None, so no reference row is ever synthesized. Underpt_assumption="all"there is no reference row (universal first-period baseline; every row is a genuine estimate) and the property is None.
- __init__(group_time_effects, overall_att, overall_se, overall_t_stat, overall_p_value, overall_conf_int, groups, time_periods, n_obs, n_treated_units, n_control_units, alpha=0.05, pt_assumption='all', anticipation=0, n_bootstrap=0, bootstrap_weights='rademacher', seed=None, event_study_effects=None, group_effects=None, efficient_weights=None, omega_condition_numbers=None, control_group='never_treated', cluster_name=None, n_clusters=None, vcov_type='hc1', influence_functions=None, bootstrap_results=None, estimation_path='nocov', sieve_k_max=None, sieve_criterion='bic', ratio_clip=20.0, kernel_bandwidth=None, omega_ridge=0.0, survey_metadata=None, _aggregation_kit=None, event_study_df=None)
- Parameters:
overall_att (float)
overall_se (float)
overall_t_stat (float)
overall_p_value (float)
n_obs (int)
n_treated_units (int)
n_control_units (int)
alpha (float)
pt_assumption (str)
anticipation (int)
n_bootstrap (int)
bootstrap_weights (str)
seed (int | None)
efficient_weights (Dict[Tuple[Any, Any], np.ndarray] | None)
omega_condition_numbers (Dict[Tuple[Any, Any], float] | None)
control_group (str)
cluster_name (str | None)
n_clusters (int | None)
vcov_type (str)
influence_functions (Dict[Tuple[Any, Any], np.ndarray] | None)
bootstrap_results (EDiDBootstrapResults | None)
estimation_path (str)
sieve_k_max (int | None)
sieve_criterion (str)
ratio_clip (float)
kernel_bandwidth (float | None)
omega_ridge (float)
survey_metadata (Any | None)
_aggregation_kit (Any | None)
event_study_df (float | None)
- Return type:
None
- property coef_var: float
SE / abs(overall ATT). NaN when ATT is 0 or SE non-finite.
- Type:
Coefficient of variation
- summary(alpha=None)[source]
Generate formatted summary of estimation results.
alphais accepted for signature uniformity; a value different from the fit-timealpharaises ValueError (stored inference is never recomputed or relabeled - re-fit at the desired alpha).
- print_summary(alpha=None)[source]
Print summary to stdout.
- Parameters:
alpha (float | None)
- Return type:
None
- to_dataframe(level='group_time')[source]
Convert results to DataFrame.
- to_dict()[source]
Convert headline results to a flat dictionary.
Mirrors
TripleDifferenceResults.to_dict()andImputationDiDResults.to_dict()— surfaces variance metadata (vcov_type,cluster_name,n_clusters,n_bootstrap,inference_method) for external adapters that don’t render the full summary.
- property is_significant: bool
Check if overall ATT is significant.
- property significance_stars: str
Significance stars for overall ATT.
EDiDBootstrapResults#
Bootstrap inference results for Efficient DiD.
- class diff_diff.efficient_did_bootstrap.EDiDBootstrapResults[source]
Bases:
objectBootstrap inference results for EfficientDiD.
- n_bootstrap: int
- weight_type: str
- alpha: float
- overall_att_se: float
- overall_att_p_value: float
- __init__(n_bootstrap, weight_type, alpha, overall_att_se, overall_att_ci, overall_att_p_value, group_time_ses, group_time_cis, group_time_p_values, event_study_ses=None, event_study_cis=None, event_study_p_values=None, group_effect_ses=None, group_effect_cis=None, group_effect_p_values=None, bootstrap_distribution=None)
Example Usage#
Basic usage:
from diff_diff import EfficientDiD, generate_staggered_data
data = generate_staggered_data(n_units=300, n_periods=10,
cohort_periods=[4, 6, 8], seed=42)
edid = EfficientDiD(pt_assumption="all")
results = edid.fit(data, outcome='outcome', unit='unit',
time='period', first_treat='first_treat')
results.print_summary()
# Aggregate post-fit - recomputed from retained EIFs, no refit:
print(results.aggregate('event_study').to_dataframe())
print(results.aggregate('group').to_dataframe())
print(results.aggregate('total').to_dataframe()) # estimator-owned total (3.10)
PT-Post mode (matches CS for post-treatment ATT):
edid_post = EfficientDiD(pt_assumption="post")
results_post = edid_post.fit(data, outcome='outcome', unit='unit',
time='period', first_treat='first_treat')
print(f"PT-All ATT: {results.overall_att:.4f} (SE={results.overall_se:.4f})")
print(f"PT-Post ATT: {results_post.overall_att:.4f} (SE={results_post.overall_se:.4f})")
Bootstrap inference:
edid_boot = EfficientDiD(pt_assumption="all", n_bootstrap=999, seed=42)
results_boot = edid_boot.fit(data, outcome='outcome', unit='unit',
time='period', first_treat='first_treat')
print(f"Bootstrap SE: {results_boot.overall_se:.4f}")
print(f"Bootstrap CI: [{results_boot.overall_conf_int[0]:.4f}, "
f"{results_boot.overall_conf_int[1]:.4f}]")
Comparison with Other Staggered Estimators#
Feature |
EfficientDiD |
CallawaySantAnna |
ImputationDiD |
|---|---|---|---|
Approach |
Optimal EIF-based weighting |
Separate 2x2 DiD aggregation |
Impute Y(0) via FE model |
PT assumption |
PT-All (stronger) or PT-Post |
Conditional PT |
Strict exogeneity |
Efficiency |
Achieves the semiparametric bound on the no-covariate path; the doubly-robust covariate path attains it too (under regularity conditions) via sieve nuisances |
Not efficient |
Efficient under homogeneity |
Covariates |
Supported (doubly robust, sieve-based PS ratio + sieve outcome regression) |
Supported (OR, IPW, DR) |
Supported |
Bootstrap |
Multiplier bootstrap (EIF) |
Multiplier bootstrap |
Multiplier bootstrap |
PT-Post equivalence |
Matches CS post-treatment ATT(g,t) |
Baseline |
Different framework |