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:

  1. Achieves the semiparametric efficiency bound for ATT(g,t) estimation on the no-covariate path

  2. Optimally weights across comparison groups and baselines via the inverse covariance matrix Ω*

  3. Supports two PT assumptions: PT-All (overidentified, tighter SEs) and PT-Post (just-identified, matches CS for post-treatment effects)

  4. Uses EIF-based inference for analytical standard errors and multiplier bootstrap

Note

EfficientDiD supports a doubly-robust covariate path: sieve-based propensity score ratios combined with a linear OLS outcome regression. The DR property ensures consistency if either the OR or the PS ratio is correctly specified, but the linear OLS working model for the outcome regression does not generically attain the semiparametric efficiency bound unless the conditional mean is linear in the covariates. The unqualified efficiency-bound claim applies to the no-covariate path only. 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 but does not generically attain the efficiency bound under the shipped linear OLS outcome regression.

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

Efficient 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), OLS outcome regression, 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 OLS outcome model or the sieve propensity ratio is correctly specified. The OLS working model for outcome regressions does not generically guarantee the semiparametric efficiency bound (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).

  • 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 at t >= last_g - anticipation so 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). When combined with control_group="last_cohort", also trims the pseudo-control period set at t >= last_g - anticipation (see REGISTRY.md).

  • sieve_k_max (int or None) – Maximum polynomial degree for sieve ratio estimation. None = auto (min(floor(n_gp^{1/5}), 5)). Only used with covariates.

  • sieve_criterion (str, default "bic") – Information criterion for sieve degree selection: "aic" or "bic".

  • 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).

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", aggregate="all")
>>> results.print_summary()

Methods

fit(data, outcome, unit, time, first_treat)

Fit the Efficient DiD estimator.

get_params()

Get estimator parameters (sklearn-compatible).

set_params(**params)

Set estimator parameters (sklearn-compatible).

__init__(pt_assumption='all', alpha=0.05, cluster=None, 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)[source]
Parameters:
  • pt_assumption (str)

  • alpha (float)

  • cluster (str | None)

  • control_group (str)

  • n_bootstrap (int)

  • bootstrap_weights (str)

  • seed (int | None)

  • anticipation (int)

  • sieve_k_max (int | None)

  • sieve_criterion (str)

  • ratio_clip (float)

  • kernel_bandwidth (float | None)

alpha: float
n_bootstrap: int
bootstrap_weights: str
seed: int | None
anticipation: int
results_: EfficientDiDResults | None
get_params()[source]

Get estimator parameters (sklearn-compatible).

Return type:

Dict[str, Any]

set_params(**params)[source]

Set estimator parameters (sklearn-compatible).

Parameters:

params (Any)

Return type:

EfficientDiD

fit(data, outcome, unit, time, first_treat, covariates=None, aggregate=None, balance_e=None, 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.inf for 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) – None, "simple", "event_study", "group", or "all".

  • balance_e (int, optional) – Balance event study at this relative period.

  • 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) – Store per-(g,t) EIF vectors in the results object. Used internally by hausman_pretest(); not needed for normal usage.

Return type:

EfficientDiDResults

Raises:

ValueError – Missing columns, unbalanced panel, non-absorbing treatment, or PT-Post without a never-treated group.

summary()[source]

Get summary of estimation results.

Return type:

str

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().

  • covariates (List[str] | None) – Same as fit().

  • cluster (str, optional) – Cluster column for cluster-robust covariance.

  • anticipation (int) – Anticipation periods.

  • 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).

Return type:

HausmanPretestResult

EfficientDiDResults#

Results container for Efficient DiD estimation.

class diff_diff.efficient_did_results.EfficientDiDResults[source]

Bases: object

Results 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:

dict

overall_att

Overall ATT (cohort-size weighted average of post-treatment group-time effects, matching CallawaySantAnna convention).

Type:

float

overall_se

Standard error of overall ATT.

Type:

float

overall_t_stat

t-statistic for overall ATT.

Type:

float

overall_p_value

p-value for overall ATT.

Type:

float

overall_conf_int

Confidence interval for overall ATT.

Type:

tuple

groups

Treatment cohort identifiers.

Type:

list

time_periods

All time periods.

Type:

list

n_obs

Total observations (units x periods).

Type:

int

n_treated_units

Number of ever-treated units.

Type:

int

n_control_units

Number of never-treated units.

Type:

int

alpha

Significance level.

Type:

float

pt_assumption

"all" or "post".

Type:

str

anticipation

Number of anticipation periods used.

Type:

int

n_bootstrap

Number of bootstrap iterations (0 = analytical only).

Type:

int

bootstrap_weights

Bootstrap weight distribution ("rademacher", "mammen", "webb").

Type:

str

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

influence_functions

{(g, t): ndarray(n_units,)} — per-unit EIF values for each group-time cell. Only populated when store_eif=True in fit() (used internally by hausman_pretest).

Type:

dict, optional

bootstrap_results

Bootstrap inference results.

Type:

EDiDBootstrapResults, optional

estimation_path

"nocov" or "dr" — which estimation path was used.

Type:

str

sieve_k_max

Maximum polynomial degree for sieve ratio estimation.

Type:

int or None

sieve_criterion

Information criterion used ("aic" or "bic").

Type:

str

ratio_clip

Clipping bound for sieve propensity ratios.

Type:

float

kernel_bandwidth

Bandwidth used for kernel-smoothed conditional Omega*.

Type:

float or None

Methods

summary([alpha])

Generate formatted summary of estimation results.

print_summary([alpha])

Print summary to stdout.

to_dataframe([level])

Convert results to DataFrame.

group_time_effects: Dict[Tuple[Any, Any], Dict[str, Any]]
overall_att: float
overall_se: float
overall_t_stat: float
overall_p_value: float
overall_conf_int: Tuple[float, float]
groups: List[Any]
time_periods: List[Any]
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'
seed: int | None = None
event_study_effects: Dict[int, Dict[str, Any]] | None = None
group_effects: Dict[Any, Dict[str, Any]] | None = None
efficient_weights: Dict[Tuple[Any, Any], np.ndarray] | None = None
omega_condition_numbers: Dict[Tuple[Any, Any], float] | None = None
control_group: str = 'never_treated'
cluster: str | None = None
influence_functions: Dict[Tuple[Any, Any], np.ndarray] | None = None
bootstrap_results: EDiDBootstrapResults | None = None
estimation_path: str = 'nocov'
sieve_k_max: int | None = None
sieve_criterion: str = 'bic'
ratio_clip: float = 20.0
kernel_bandwidth: float | None = None
survey_metadata: Any | None = None
property att: float
property se: float
property conf_int: Tuple[float, float]
__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=None, influence_functions=None, bootstrap_results=None, estimation_path='nocov', sieve_k_max=None, sieve_criterion='bic', ratio_clip=20.0, kernel_bandwidth=None, survey_metadata=None)
Parameters:
Return type:

None

property p_value: float
property t_stat: float
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.

Parameters:

alpha (float | None)

Return type:

str

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.

Parameters:

level (str) – "group_time", "event_study", or "group".

Return type:

DataFrame

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: object

Bootstrap inference results for EfficientDiD.

n_bootstrap: int
weight_type: str
alpha: float
overall_att_se: float
overall_att_ci: Tuple[float, float]
overall_att_p_value: float
group_time_ses: Dict[Tuple[Any, Any], float]
group_time_cis: Dict[Tuple[Any, Any], Tuple[float, float]]
group_time_p_values: Dict[Tuple[Any, Any], float]
event_study_ses: Dict[int, float] | None = None
event_study_cis: Dict[int, Tuple[float, float]] | None = None
event_study_p_values: Dict[int, float] | None = None
group_effect_ses: Dict[Any, float] | None = None
group_effect_cis: Dict[Any, Tuple[float, float]] | None = None
group_effect_p_values: Dict[Any, float] | None = None
bootstrap_distribution: ndarray | None = None
__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)
Parameters:
Return type:

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',
                   aggregate='all')
results.print_summary()

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',
                              aggregate='all')
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',
                              aggregate='all')
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 semiparametric bound on the no-covariate path; DR covariate path is consistent but does not generically attain the bound under a linear OLS outcome regression

Not efficient

Efficient under homogeneity

Covariates

Supported (doubly robust, sieve-based PS + linear OLS OR)

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