LWDiD — Lee & Wooldridge Rolling Transformation DiD#

A simple transformation approach to Difference-in-Differences estimation that converts panel data into cross-sectional regressions (Lee & Wooldridge 2025, 2026).

The key insight from the Lee & Wooldridge papers is that, under parallel trends and no anticipation, a unit-specific time-series transformation of the outcome eliminates the need for two-way fixed effects entirely. For each unit i with treatment onset at period S, Procedure 2.1 (LW 2026) computes the pre-treatment mean:

\[\bar{Y}_{i,\text{pre}} = \frac{1}{S-1} \sum_{t=1}^{S-1} Y_{it}\]

and forms the transformed outcome:

\[\dot{Y}_{it} = Y_{it} - \bar{Y}_{i,\text{pre}}, \quad t = S, \ldots, T \qquad \text{(Equation 2.12, LW 2026)}\]

Under Assumption CPTC (Conditional Parallel Trends, Common Timing; Equation 2.10, LW 2025), this transformation removes unit-specific fixed effects, and the ATT is identified as the coefficient on the treatment indicator in a cross-sectional regression of \(\dot{Y}_{it}\) on \(D_i\) and covariates. Because the panel problem is reduced to a cross section, any treatment effect estimator — regression adjustment (RA), inverse probability weighting (IPW), doubly robust IPWRA, or propensity-score matching — can be applied without negative weighting, heterogeneity bias, or “bad comparisons” between already-treated cohorts.

A second contribution (LW 2026) demonstrates that this representation enables exact small-sample inference: under homoskedastic normality of the cross-sectional error, the t-statistic follows an exact Student-t distribution with the fitted design’s residual degrees of freedom — \(\mathcal{T}_{N-2}\) without covariates, \(\mathcal{T}_{N-K-2}\) for the plain covariate design, and \(\mathcal{T}_{N-2K-2}\) when the automatically selected treatment-covariate interaction is active — valid even with a single treated unit (\(N_1 = 1\)). When \(T_0\) or \(T_1\) is large, the central limit theorem across time justifies the normality assumption without requiring a large cross section.

Note

Why rolling transformation works. The parallel trends assumption (Equation 2.15, LW 2026) implies that \(\Delta\bar{Y}_i(0)\) — the difference between post-treatment and pre-treatment means of control potential outcomes — is mean-independent of the treatment indicator \(D_i\). This is precisely the unconfoundedness condition needed for cross-sectional treatment effect estimation. The transformation eliminates both unit-specific levels (via demeaning) and unit-specific linear trends (via detrending), weakening the standard parallel trends assumption to one that allows heterogeneous pre-intervention dynamics.

Methodology#

Procedure 2.1 — Unit-Specific Demeaning (LW 2026, Section 2)

For common timing with intervention at period S:

  1. Compute the pre-treatment mean for each unit: \(\bar{Y}_{i,\text{pre}} = \frac{1}{S-1}\sum_{t=1}^{S-1} Y_{it}\)

  2. Obtain the transformed outcome (out-of-sample residuals):

    \[\dot{Y}_{it} = Y_{it} - \bar{Y}_{i,\text{pre}}, \quad t = S, \ldots, T\]
  3. Estimate the ATT from the cross-sectional regression (Equation 2.13, LW 2026):

    \[\dot{Y}_{it} \text{ on } 1,\; D_i, \quad i = 1, \ldots, N\]

The coefficient on \(D_i\) identifies the ATT for period t.

Procedure 3.1 — Unit-Specific Detrending (LW 2025, Section 5; LW 2026, Section 3)

When parallel trends may fail but unit-specific linear trends capture the pre-intervention dynamics (Assumption CHT, LW 2025):

  1. For each unit i, regress on a constant and time over pre-treatment periods:

    \[Y_{it} \text{ on } 1,\; t, \quad t = 1, \ldots, S-1 \qquad \text{(Equation 3.1, LW 2026)}\]

    obtaining fitted values \(\hat{A}_i + \hat{B}_i \cdot t\).

  2. Compute the detrended outcome:

    \[\ddot{Y}_{it} = Y_{it} - \hat{A}_i - \hat{B}_i \cdot t, \quad t = S, \ldots, T \qquad \text{(Equation 3.2, LW 2026)}\]
  3. Estimate the ATT from:

    \[\ddot{Y}_{it} \text{ on } 1,\; D_i, \quad i = 1, \ldots, N \qquad \text{(Equation 3.4, LW 2026)}\]

Detrending removes unit-specific intercepts \(\alpha_i\) and linear trends \(\beta_i t\), thus relaxing the parallel trends assumption to allow differential pre-intervention growth rates across units (Procedure 5.1, LW 2025). This is the key advantage over Callaway & Sant’Anna (2021), who do not accommodate heterogeneous trends.

Procedure 4.1 — Staggered Interventions (LW 2025, Section 4)

For staggered adoption with cohort g (first treatment period) and calendar time r:

  1. Compute the cohort-specific transformed outcome:

    \[\dot{Y}_{irg} = Y_{ir} - \frac{1}{g-1}\sum_{s=1}^{g-1} Y_{is} \equiv Y_{ir} - \bar{Y}_{i,\text{pre}(g)} \qquad \text{(Equation 4.11, LW 2025)}\]
  2. Select the control group: units not yet treated by period r, i.e., cohorts \(\{r+1, \ldots, T, \infty\}\).

  3. Apply any TE estimator (RA, IPW, IPWRA, matching) to the cross section \(\{(\dot{Y}_{irg}, D_{ig}, \mathbf{X}_i)\}\) restricted to the treated cohort g plus control units.

Under Assumptions CNAS (conditional no anticipation, Equation 4.4) and CPTS (conditional parallel trends, Equation 4.6), the cohort assignment is unconfounded with respect to the transformed outcome (Theorem 4.1).

Regression Adjustment with Interactions (Equation 3.3, LW 2025)

When both \(N_0\) and \(N_1\) are sufficiently large, full regression adjustment includes covariate interactions:

\[\dot{Y}_{ir} = \beta_0 + \beta_1 D_i + \beta_2' \mathbf{X}_i + \beta_3' D_i(\mathbf{X}_i - \bar{\mathbf{X}}_1) + u_i\]

where \(\bar{\mathbf{X}}_1 = N_1^{-1}\sum_{i} D_i \mathbf{X}_i\) is the mean of covariates over treated units. The ATT is \(\hat{\beta}_1\). This is equivalent to separate regressions for treated and control groups (Equation 3.3, LW 2025).

Key Assumptions#

Important

The LWDiD estimator requires the following assumptions for identification:

Assumption CPTC — Conditional Parallel Trends, Common Timing (Equation 2.10, LW 2025):

\[E[Y_{it}(0) - Y_{i1}(0) \mid D_i, \mathbf{X}_i] = E[Y_{it}(0) - Y_{i1}(0) \mid \mathbf{X}_i], \quad t = 2, \ldots, T\]

The trend in control potential outcomes is independent of treatment assignment conditional on covariates. Note this is weaker than unconditional parallel trends — assignment can be correlated with levels \(Y_{i1}(0)\), but not with trends.

Assumption NAC — No Anticipation, Common Timing (Equation 2.7, LW 2025):

\[E[Y_{it}(1) - Y_{it}(0) \mid D_i = 1] = 0, \quad t = 1, \ldots, S-1\]

Treatment effects are zero on average before the intervention.

Assumption CPTS — Conditional PT, Staggered (Equation 4.6, LW 2025):

\[E[Y_t(\infty) - Y_1(\infty) \mid \mathbf{D}, \mathbf{X}] = E[Y_t(\infty) - Y_1(\infty) \mid \mathbf{X}], \quad t = 2, \ldots, T\]

Trends in the never-treated state are independent of the full vector of cohort assignments, enabling use of not-yet-treated units as controls.

Conditional Heterogeneous Trends (Assumption CHT, Equation 5.3, LW 2025): When using detrend, the parallel trends assumption is relaxed to allow unit-specific linear trends \(\eta_g \cdot t\) that vary by cohort. Detrending removes these heterogeneous trends, restoring unconfoundedness. Under CHT, demeaning is inconsistent — rolling='detrend' is the consistent route.

Overlap (Assumption OVLC, Equations 2.14–2.15, common timing; Assumption OVLS, Equation 4.10, staggered): each treated cohort must have comparison support in its control pool — covariate patterns of treated units must occur among controls with positive probability. Full-rank and residual-df guards do not substitute for overlap.

Small-Sample Inference#

A distinctive feature of the LW approach (LW 2026, Section 2) is the availability of exact inference. Under the classical linear model assumptions on the cross-sectional regression:

\[U_i \mid D_i \sim \text{Normal}(0, \sigma_U^2) \qquad \text{(Equation 2.9, LW 2026)}\]

the t-statistic follows an exact Student-t distribution:

\[\frac{\hat{\tau}_{DD} - \tau}{\text{se}(\hat{\tau}_{DD})} \sim \mathcal{T}_{N-2} \qquad \text{(Equation 2.10, LW 2026)}\]

This holds even with \(N_1 = 1\) (single treated unit), where the t-statistic is interpretable as a studentized residual — testing whether the treated unit is an “outlier” relative to the controls (LW 2026, Section 2.1).

When \(N\) is not too small, the HC3 heteroskedasticity-robust standard error (Davidson & MacKinnon, 1993) provides reliable inference without the homoskedasticity assumption, as shown by Simonsohn (2021).

Randomization inference is also supported: under the sharp null of zero treatment effects, permutation of \(D_i\) yields Monte Carlo p-values without requiring normality (LW 2026, the small-sample inference paper). Validity is conditional on the assignment mechanism the permutation encodes — complete randomization of the treatment labels (the treated count is held fixed); the implementation follows the authors’ package convention (inclusive Phipson-Smyth counting; see the methodology registry’s RI Note).

HC3 caveat — HC3 requires the leverage of every observation to be bounded away from one; a perfectly-leveraged design (e.g. a single treated unit) has no defined HC3 variance and fails closed with a warning and NaN inference. Use classical exact inference there.

PSM inference — estimation_method='psm' reports the matched ATT point estimate with NaN inference: no valid matching variance estimator is currently implemented (the naive matched-pairs formula ignores matched-control reuse and first-stage matching uncertainty; an Abadie-Imbens variance is tracked in DEFERRED.md). The contract is enforced on every route: PSM requires covariates (there is no propensity score without them), rejects n_bootstrap > 0 in BOTH common-timing and staggered designs (the standard bootstrap is invalid for nearest-neighbor matching estimators — Abadie & Imbens 2008), and a propensity-model failure falls back to a regression-adjustment POINT with NaN inference rather than finite OLS standard errors. Use estimation_method='dr' for valid inference.

LWDiD#

Main estimator class.

class diff_diff.LWDiD[source]

Bases: BaseEstimator

Lee & Wooldridge rolling-transformation DiD estimator.

Parameters:
  • rolling ({'demean', 'detrend', 'demeanq', 'detrendq'}, default 'demean') – Unit-specific transformation method. ‘demean’: subtract pre-treatment mean ‘detrend’: subtract pre-treatment linear trend ‘demeanq’: subtract unit-specific seasonal (quarterly) means ‘detrendq’: subtract unit-specific linear trend + seasonal effects

  • estimation_method ({'reg', 'ipw', 'dr', 'psm'}, default 'reg') – Treatment effect estimation method. ‘reg’: regression adjustment (OLS) ‘ipw’: inverse probability weighting ‘dr’: doubly robust (augmented IPW) ‘psm’: propensity score matching (1:n_neighbors nearest-neighbor, 1:1 by default); POINT ESTIMATES ONLY - inference is NaN pending an Abadie-Imbens matching variance (see DEFERRED.md)

  • vcov_type ({'classical', 'hc1', 'hc2', 'hc3'}, default 'hc1') – Variance-covariance estimator. ‘hc2’: leverage-corrected (u_i^2 / (1-h_ii)) ‘hc3’: jackknife-style leverage correction (u_i^2 / (1-h_ii)^2) The full set applies to estimation_method='reg' only: ‘ipw’/’dr’/’psm’ accept ‘hc1’ alone (the influence-function variance on ipw/dr; psm reports NaN inference - see below). Cluster-robust (CR1) inference activates via the cluster= parameter, not through a vcov_type value, composes only with ‘hc1’, and is rejected for ‘psm’.

  • cluster (str or None, default None) – Column name for cluster-robust (CR1) standard errors. When set, clustered inference is active for the whole fit.

  • control_group ({'never_treated', 'not_yet_treated'}, default 'not_yet_treated') – Control group definition for staggered designs. Both options require never-treated units: ‘never_treated’ needs at least two, and ‘not_yet_treated’ needs at least one so that every cohort-time cell keeps a valid control pool. Panels where all units are eventually treated are rejected with a ValueError.

  • alpha (float, default 0.05) – Significance level for confidence intervals.

  • n_bootstrap (int, default 0) – Number of bootstrap replications (0 = analytical inference).

  • seed (int or None, default None) – Random seed for bootstrap inference.

  • pscore_trim (float, default 0.01) – Propensity score trimming threshold. Scores below this value or above (1 - pscore_trim) are clipped. Used by IPW/DR/PSM. Must be in (0, 0.5) and large enough that 1 - pscore_trim < 1 in float64 (a sub-ulp trim would disable the upper clip).

  • n_neighbors (int, default 1) – Number of nearest neighbors for PSM matching.

  • caliper (float or None, default None) – Maximum allowable distance for PSM matches. Unmatched treated units (no control within caliper) receive NaN.

  • with_replacement (bool, default True) – Whether PSM matching is done with replacement.

  • n_jobs (int, default 1) – Execution parallelism for the common-timing bootstrap (ThreadPoolExecutor when > 1; experimental). Purely an execution setting: seeded bootstrap draws are identical for every value (per-replicate SeedSequence streams), so it never affects any reported number and is not stored in result provenance.

Notes

Parameter mapping from lwdid-py to diff-diff:

The standalone lwdid-py package (from lwdid import lwdid) uses a functional interface with separate d (ever-treated indicator) and post (post-period indicator) columns. In diff-diff, the treatment column is the time-varying binary indicator D_i * post_t—i.e., the product of the two lwdid-py columns.

# lwdid-py (functional API):
lwdid(data, y='y', d='d', ivar='unit', tvar='time', post='post',
      rolling='demean', estimator='ra', vce=None)

# Equivalent in diff-diff (class-based API):
LWDiD(rolling='demean', estimation_method='reg',
      vcov_type='classical').fit(
    data, outcome='y', unit='unit', time='time', treatment='treat')
# where data['treat'] == data['d'] * data['post']

Parameter correspondence:

lwdid-py

diff-diff

Notes

y

outcome

Outcome column name

d + post

treatment

Binary D_it (ever-treated × post)

ivar

unit

Unit identifier

tvar

time

Time variable

gvar

first_treat

Cohort (first treatment period)

rolling

rolling

Same values

estimator=’ra’

estimation_method

‘ra’ -> ‘reg’, ‘ipwra’ -> ‘dr’

vce=None

vcov_type

Homoskedastic == ‘classical’

vce=’hc1’

vcov_type=’hc1’

Heteroskedasticity-robust

vce=’cluster’

cluster=<column>

Constructor cluster= parameter

cluster_var

cluster

Cluster variable name

controls

covariates

fit() covariates= parameter

control_group

control_group

Same values

Results mapping:

lwdid-py

diff-diff

Notes

result.att

result.att

ATT point estimate

result.se_att

result.se

Standard error

result.t_stat

result.t_stat

t-statistic

result.pvalue

result.p_value

p-value (note underscore)

result.ci_lower

result.conf_int[0]

CI lower bound

result.ci_upper

result.conf_int[1]

CI upper bound

result.nobs

result.n_obs

Number of observations

result.n_treated

result.n_treated

Treated units

result.n_control

result.n_control

Control units

result.vce_type

result.vcov_type

Variance family

result.cluster_var

result.cluster_name

Cluster variable name

result.n_clusters

result.n_clusters

Number of clusters

Examples

>>> import numpy as np, pandas as pd
>>> from diff_diff.lwdid import LWDiD
>>> from diff_diff import generate_staggered_data
>>> data = generate_staggered_data(n_units=100, n_periods=8, seed=0)
>>> model = LWDiD(rolling='demean', estimation_method='reg')
>>> result = model.fit(data, outcome='outcome', unit='unit',
...                    time='period', treatment='treated',
...                    first_treat='first_treat')
>>> result.att != 0
True

Methods

fit(data, outcome, unit, time, treatment[, ...])

Fit the LWDiD estimator.

get_params([deep])

Get estimator parameters (sklearn-compatible).

set_params(**params)

Set estimator parameters (sklearn-compatible, transactional).

__init__(rolling='demean', estimation_method='reg', vcov_type='hc1', cluster=None, control_group='not_yet_treated', alpha=0.05, n_bootstrap=0, seed=None, pscore_trim=0.01, n_neighbors=1, caliper=None, with_replacement=True, n_jobs=1)[source]
Parameters:
  • rolling (str)

  • estimation_method (str)

  • vcov_type (str)

  • cluster (str | None)

  • control_group (str)

  • alpha (float)

  • n_bootstrap (int)

  • seed (int | None)

  • pscore_trim (float)

  • n_neighbors (int)

  • caliper (float | None)

  • with_replacement (bool)

  • n_jobs (int)

Return type:

None

fit(data, outcome, unit, time, treatment, first_treat=None, covariates=None)[source]

Fit the LWDiD estimator.

Parameters:
  • data (pd.DataFrame) – Panel dataset in long format.

  • outcome (str) – Column name of the outcome variable.

  • unit (str) – Column name of the unit identifier.

  • time (str) – Column name of the time period variable.

  • treatment (str) – Column name of the binary treatment indicator (0/1).

  • first_treat (str, optional) – Column name of the first-treatment-time (cohort) variable. If None, assumes common timing (all treated units adopt treatment simultaneously).

  • covariates (list of str, optional) – Column names for control variables (covariates). Every LWDiD path requires unit-constant (time-invariant) covariates; time-varying columns raise a ValueError. The same unit-constancy contract applies to the constructor’s cluster= column.

Returns:

Object containing ATT estimates, standard errors, and inference results.

Return type:

LWDiDResults

Raises:

ValueError – If required columns are missing, treatment is not binary, or panel structure is invalid.

get_transformation_diagnostics(data, outcome, unit, time, treatment, first_treat=None)[source]

Run the transformation step and return diagnostics without full estimation.

This is useful for inspecting pre-treatment fit quality before running the full estimator.

Parameters:
  • data (pd.DataFrame) – Panel data.

  • outcome (str) – Name of the outcome column.

  • unit (str) – Name of the unit identifier column.

  • time (str) – Name of the time period column.

  • treatment (str) – Name of the treatment indicator column.

  • first_treat (str or None, default None) – Name of the first-treatment-time (cohort) column, for staggered designs.

Returns:

Common timing: transformation diagnostics (see _transform_* docstrings). Staggered: per-cohort diagnostics organized as {'method': ..., 'design': 'staggered', 'by_cohort': {g: diagnostics_g}} where each cohort g uses its own pre-period definition time < g and the same unit subset as estimation (cohort-g treated units plus the control superset implied by control_group).

Return type:

dict

__repr__()[source]

Return string representation of the estimator.

Return type:

str

get_params(deep=True)

Get estimator parameters (sklearn-compatible).

Parameters:

deep (bool, default True) – Accepted for sklearn compatibility (sklearn.base.clone calls get_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 that type(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 ValueError before any validation or mutation.

  • self (TSelf)

  • **params

Return type:

self

LWDiDResults#

Results container returned by fit().

class diff_diff.lwdid_results.LWDiDResults[source]

Bases: BaseResults, AggregationMixin

Results from LWDiD.fit().

Follows the diff-diff standard results interface. Holds the headline ATT estimate and inference for the common-timing case, or per-cohort effects and an overall weighted ATT for the staggered case.

Parameters:
  • att (float) – Average treatment effect on the treated.

  • se (float) – Standard error of the ATT estimate.

  • t_stat (float) – t-statistic (att / se).

  • p_value (float) – Two-sided p-value.

  • conf_int (tuple of float) – (lower, upper) confidence interval at level 1 - alpha.

  • n_obs (int) – Total observations used in estimation.

  • n_treated (int) – Number of treated units.

  • n_control (int) – Number of control units.

  • rolling (str) – Transformation method used (‘demean’, ‘detrend’, ‘demeanq’, or ‘detrendq’).

  • estimation_method (str) – Estimation method (‘reg’, ‘ipw’, ‘dr’, or ‘psm’).

  • vcov_type (str) – Variance family (‘classical’, ‘hc1’, ‘hc2’, or ‘hc3’).

  • alpha (float) – Significance level used for confidence intervals.

  • df_inference (int or None) – Degrees of freedom used for t-distribution inference.

  • cluster_name (str or None) – Name of the cluster variable, if clustered.

  • n_clusters (int or None) – Number of clusters, if clustered.

  • cohort_effects (dict or None) – Per-cohort ATT results for staggered designs.

  • params (ndarray or None) – All coefficient estimates from the regression.

  • bse (ndarray or None) – All standard errors from the regression.

  • vcov (ndarray or None) – Variance-covariance matrix.

Methods

summary()

Formatted text summary of results.

print_summary()

Print the formatted summary to stdout.

to_dataframe()

Convert results to a pandas DataFrame.

to_dict()

Convert results to a JSON-serializable dictionary.

att: float
se: float
t_stat: float
p_value: float
conf_int: Tuple[float, float]
n_obs: int
n_treated: int
n_control: int
rolling: str
estimation_method: str
vcov_type: str
alpha: float
df_inference: int | None = None
cluster_name: str | None = None
n_clusters: int | None = None
control_group: str | None = None
n_bootstrap: int = 0
seed: int | None = None
pscore_trim: float | None = None

Propensity-score trim bound used by the ipw/dr/psm paths (None for estimation_method=’reg’, where no propensity model is fitted).

psm_config: Dict[str, Any] | None = None

PSM matching settings (None unless estimation_method=’psm’): {‘pscore_trim’, ‘n_neighbors’, ‘caliper’, ‘with_replacement’}

cohort_effects: Dict[Any, Dict] | None = None
cohort_time_effects: Dict[Tuple[Any, Any], Dict] | None = None
inference_basis: str | None = None
att_tau_omega_complete_case: float | None = None

Complete-case tau_omega composite point, exposed as a diagnostic when complete-case drops prevented it from being .att (None otherwise).

n_composite_treated_dropped: int = 0

Treated / control units dropped by the tau_omega complete-case resolution (0 when the composite path did not run or dropped none).

n_composite_controls_dropped: int = 0
event_study_effects: Dict[int, Dict] | None = None
event_study_vcov: ndarray | None = None
event_study_vcov_index: ndarray | None = None
event_study_df: Dict[int, float] | None = None
reference_periods: Tuple[int, ...]
cband_method: str | None = None
cband_crit_value: float | None = None
cband_n_bootstrap: int | None = None
params: ndarray | None = None
bse: ndarray | None = None
vcov: ndarray | None = None
property pvalue: float

Alias for p_value (diff-diff API convention).

property ci: Tuple[float, float]

Alias for conf_int (diff-diff API convention).

property is_staggered: bool

Whether this result comes from a staggered adoption design.

to_dataframe()[source]

Convert results to a pandas DataFrame.

Returns:

For common timing: a single-row DataFrame. For staggered: one row per cohort plus an “Overall” row.

Return type:

pd.DataFrame

to_dict()[source]

Convert results to a JSON-serializable dictionary.

Returns:

All scalar results and metadata. Arrays are converted to lists and numpy scalars (including nested dict values and keys) to native Python types, so json.dumps(result.to_dict()) works directly.

Return type:

dict

to_csv(path)[source]

Export results to CSV file.

Parameters:

path (str) – File path for the CSV output.

Return type:

None

summary()[source]

Formatted text summary of results.

Returns:

Human-readable summary table.

Return type:

str

print_summary()[source]

Print the formatted summary to stdout.

Return type:

None

property ri_pvalue

Randomization inference p-value (None if not computed).

property bootstrap_pvalue

Wild cluster bootstrap p-value (None if not computed).

wild_cluster_bootstrap(*, n_bootstrap=999, weight_type='rademacher', alpha=None, seed=None)[source]

Run wild cluster bootstrap inference on the fitted estimation sample.

Replays the fit-time collapsed cross-section and the exact fitted RA design (intercept, treatment, covariates, and the treatment- centered interactions) through the house WCR engine (test-inversion CI, CR1 se, strict-exceedance p-value); the observed coefficient is asserted equal to .att before caching. Requires a clustered (cluster=), common-timing, estimation_method='reg' fit. alpha=None inherits the fitted confidence level. Result is cached and accessible via the bootstrap_pvalue property.

randomization_test(*, n_reps=1000, method='permutation', seed=None)[source]

Run Fisher randomization inference on the fitted estimation sample.

Replays the fit-time collapsed cross-section; with covariates the RA design’s treated covariate mean and interaction columns are RECOMPUTED for every permuted assignment (design='ra_interacted'), so each permutation tests the same estimator the fit reported. The observed statistic is asserted equal to .att before caching. Requires a common-timing estimation_method='reg' fit. Result is cached and accessible via the ri_pvalue property.

__init__(att, se, t_stat, p_value, conf_int, n_obs, n_treated, n_control, rolling, estimation_method, vcov_type, alpha, df_inference=None, cluster_name=None, n_clusters=None, control_group=None, n_bootstrap=0, seed=None, pscore_trim=None, psm_config=None, cohort_effects=None, cohort_time_effects=None, inference_basis=None, att_tau_omega_complete_case=None, n_composite_treated_dropped=0, n_composite_controls_dropped=0, event_study_effects=None, event_study_vcov=None, event_study_vcov_index=None, event_study_df=None, reference_periods=<factory>, cband_method=None, cband_crit_value=None, cband_n_bootstrap=None, params=None, bse=None, vcov=None, _ri_result=None, _wcb_result=None)
Parameters:
Return type:

None

Input Contract#

fit() validates the treatment design before any transformation is applied. Eight requirements are enforced:

  • Absorbing treatment — within each unit the treatment indicator must be non-decreasing over time: once a unit switches from 0 to 1 it must remain treated. Units that revert to 0 raise ValueError.

  • Common timing — when first_treat is not supplied, all treated units must first switch on in the same period. Heterogeneous onsets are rejected with a ValueError pointing to the staggered interface (pass first_treat).

  • Staggered consistency — when first_treat is supplied, the treatment indicator must satisfy \(D_{it} = 1[t \ge g_i]\) over each unit’s OBSERVED rows, where \(g_i\) is the unit’s first-treatment period; the row at \(t = g_i\) itself may be unobserved (unbalanced panels with a missing onset row are accepted). Units that are never treated must have no treated rows.

  • Never-treated encodings — first_treat coded 0, NaN/ NaT, or np.inf means never-treated; inf and finite cohorts BEYOND the last observed period are recoded to never-treated with a warning (beyond-window units never switch on inside the sample). Negative cohorts raise. NUMERIC cohorts strictly between observed periods are rejected; datetime/Period cohorts map to the next observed period — a dtype-dependent contract documented in the methodology registry.

  • Variance configuration — estimation_method='reg' accepts vcov_type in {'classical', 'hc1', 'hc2', 'hc3'}; 'ipw'/ 'dr'/'psm' accept 'hc1' only (the influence-function / matching variance is always used on those paths); cluster= composes only with 'hc1' (CR1) and is rejected for 'psm'.

  • Never-treated units under not-yet-treated control — when first_treat is supplied and control_group='not_yet_treated', at least one never-treated unit (first_treat coded NaN or 0) must be present. A panel in which every unit is eventually treated raises ValueError rather than silently truncating the estimation sample.

  • Unit-constant covariates — covariates (and a non-unit cluster= column) must be constant within each unit on BOTH timing paths; time-varying columns raise ValueError (LWDiD collapses the panel to one row per unit, so a time-varying value would make the estimate depend on row order).

  • Distinct, non-reserved column names — the core role columns (outcome/unit/time/treatment/first_treat) must be pairwise distinct, covariates may not repeat a core role, and no role column may use an LWDiD-internal working name (_treat, _ydot, _ydot_avg, _ever_treated, _boot_unit, _lwdid_time_pos, _lwdid_cohort_pos, _lwdid_season) — a collision would silently overwrite the internal column (e.g. cluster='_treat' previously reported the cluster labels’ coefficient as the ATT). cluster= equal to the unit column remains supported.

Note

Scope limitations (structural exclusions, distinct from the validated requirements above). (1) Panel data only — the unit-specific rolling transformation requires repeated observation of each unit, so there is no direct repeated-cross-section API (contrast CallawaySantAnna’s panel=False mode). LW 2026 does permit repeated cross-sections after aggregating microdata to assignment-unit-by-period cell means; such a pre-aggregated panel can be fitted here, but the library does not propagate within-cell sampling uncertainty — the route is only appropriate when cell sizes justify treating cell means as population means. (2) No sampling weights and no survey_design= parameter on any path — passing survey_design= raises TypeError; see the Survey Design Support matrix in Choosing an Estimator. (3) No anticipation= parameter — the no-anticipation assumption is maintained at estimation time; diff_diff.lwdid_sensitivity.sensitivity_no_anticipation is the post-hoc diagnostic (single treated cohort only), whereas CallawaySantAnna offers an estimation-time offset. (4) compute_honest_did does not accept LWDiD event studies — admitting the transformation-based pre-period placebos as Rambachan–Roth inputs is a deferred methodology decision (see DEFERRED.md).

Note

Bootstrap scope and reproducibility. In common-timing fits n_bootstrap activates a unit-resampling (or cluster-resampling, under cluster=) bootstrap for the overall ATT; the headline se/p-value/CI then come from the bootstrap while params/vcov remain the analytical regression quantities, recorded via inference_basis ('unit_bootstrap'/'cluster_bootstrap') and rendered by summary(). The per-replicate RNG streams are SeedSequence-spawned identically for every n_jobs, so a seeded fit reproduces exactly across serial and parallel execution. In STAGGERED fits n_bootstrap governs the event-study multiplier bootstrap only (sup-t simultaneous bands); the overall and cohort aggregates keep analytical influence-function inference, with the per-surface basis recorded on the results object (cband_method, cband_n_bootstrap, inference_basis). Event cells whose multiplier draws are degenerate fail closed (point retained, NaN inference) rather than silently reverting to analytical standard errors. The common-timing event-study surface covers post periods only; pre-treatment placebo cells are produced by the staggered path (pass first_treat=, which for a single cohort matches the common-timing regression on single-post-period panels).

Note

to_dict() returns only JSON-native types: numpy scalars and arrays are converted to Python ints/floats/bools and lists, and datetime-like labels (Timestamp, Period) become strings, so json.dumps(result.to_dict()) works directly.

Example Usage#

Basic demeaning with regression adjustment (Procedure 2.1):

import pandas as pd
from diff_diff import LWDiD, generate_staggered_data

# Generate staggered panel data; the 'treated' column is the binary
# indicator D_it = 1[period >= first_treat] (0 for never-treated units)
data = generate_staggered_data(n_units=200, n_periods=10,
                               cohort_periods=[4, 7], seed=42)

# Procedure 2.1: demean + reg estimates the ATT via cross-sectional OLS
# on the transformed outcome Y_dot = Y_post - Y_bar_pre
lw = LWDiD(rolling="demean", estimation_method="reg", vcov_type="hc1")
results = lw.fit(data, outcome="outcome", unit="unit",
                 time="period", treatment="treated",
                 first_treat="first_treat")
results.print_summary()

Doubly-robust IPWRA estimation (Procedure 3.1, Step 2):

# DR (IPWRA) combines propensity score weighting with regression adjustment
# on the transformed outcome — doubly robust as in Wooldridge (2007).
# DR needs unit-level covariates for the propensity model; without
# covariates it warns and reduces to regression adjustment.
# Cluster-robust inference activates via the constructor's cluster= parameter.
data["state"] = data["unit"] % 40  # cluster identifier
data["size"] = data["unit"] % 5  # unit-constant covariate (e.g. firm size class)
lw_dr = LWDiD(rolling="demean", estimation_method="dr", cluster="state")
results_dr = lw_dr.fit(data, outcome="outcome", unit="unit",
                       time="period", treatment="treated",
                       first_treat="first_treat",
                       covariates=["size"])
print(f"ATT: {results_dr.att:.4f} (SE={results_dr.se:.4f})")

Staggered adoption with detrending (Procedure 4.1 + 5.1):

# Detrending removes unit-specific linear trends before estimation,
# relaxing parallel trends to allow heterogeneous pre-intervention dynamics
lw_stag = LWDiD(rolling="detrend", control_group="never_treated")
results_stag = lw_stag.fit(data, outcome="outcome", unit="unit",
                           time="period", treatment="treated",
                           first_treat="first_treat")
# Cohort-specific ATT(g) estimates (Equations 7.2/7.10, LW 2026).
# Aggregation convention: each cohort's estimable cells weight by
# their contributing treated mass (cell-mass, matching the WATT(r)
# axis); this equals the eq. 7.10 unit-average estimand on balanced
# never-treated designs and deviates on unbalanced panels (see the
# methodology registry's within-cohort aggregation Note).
df_cohorts = results_stag.to_dataframe()
print(df_cohorts)

Robustness check — demean vs detrend (informal pre-test for trend sensitivity):

# Comparing demean vs detrend provides a specification robustness check.
# If results differ substantially, it suggests unit-specific trends matter
# (see LW 2025, Section 6 — Walmart application, Figure 1 panels b vs c)
data["size"] = data["unit"] % 5  # unit-constant covariate for the DR propensity model
for transform in ("demean", "detrend"):
    lw_check = LWDiD(rolling=transform, estimation_method="dr", vcov_type="hc1")
    res = lw_check.fit(data, outcome="outcome", unit="unit",
                       time="period", treatment="treated",
                       first_treat="first_treat",
                       covariates=["size"])
    print(f"{transform}: ATT={res.att:.4f} (SE={res.se:.4f})")

Wild cluster bootstrap#

diff_diff.lwdid_wild_bootstrap.wild_cluster_bootstrap(y, treatment, cluster_ids, controls=None, *, n_bootstrap=999, weight_type='rademacher', alpha=0.05, seed=None) provides few-cluster inference on a collapsed cross-section. It delegates to the house Wild Cluster Restricted engine (diff_diff.wild_bootstrap_se(), matched to R’s fwildclusterboot::boottest): the null is imposed by dropping the treatment column while keeping the controls, the confidence interval is obtained by test inversion, and Rademacher weights are fully enumerated automatically when \(2^G \le\) n_bootstrap. The result carries att, the analytical CR1 se, t_stat_original, p_value (strict-exceedance house convention), the test-inversion ci_lower/ci_upper, n_clusters, n_bootstrap, weight_type, alpha, the finite-filtered bootstrap_distribution (None when the degenerate-design guard fires), and n_dropped (non-finite outcome rows dropped with a warning). Exactly-identified designs (cluster-invariant treatment with zero cluster scores) fail closed: the point estimate is retained with NaN inference.

The RESULT-LEVEL methods LWDiDResults.wild_cluster_bootstrap() and LWDiDResults.randomization_test() take no data arguments: they REPLAY the fit-time collapsed cross-section and the exact fitted RA design (including the treatment-centered covariate interactions; randomization draws recompute the treated covariate mean per assignment), and assert the replayed statistic equals .att before caching a p-value — so bootstrap_pvalue/ri_pvalue always describe the fitted estimand. They are defined for common-timing estimation_method='reg' fits (wild_cluster_bootstrap additionally requires a cluster= fit); use the standalone module functions above for generic arrays.

Empirical Applications#

The Lee & Wooldridge papers validate the methodology with two empirical studies:

  • California Proposition 99 (LW 2026, Section 6): With a single treated state (\(N_1 = 1\)) and 38 control states, Procedure 3.1 (unit-specific detrending) achieves an excellent pre-treatment fit and yields a per-period treatment trajectory that grows over time — from \(\hat{\tau}_{1989} = -0.043\) (SE = 0.059) to \(\hat{\tau}_{2000} = -0.403\) (SE = 0.152). The exact-inference p-value (0.021) is valid under the conditional-normality and homoskedasticity assumptions — it tests the treatment-effect null, not those assumptions themselves; randomization inference (below) is a robustness check that does not require normality, conditional on the complete-randomization assignment mechanism. (The paper’s printed randomization-inference p-value of 0.020 is not reproducible with the authors’ own package, which implements the inclusive Phipson-Smyth rule and converges to ~0.051 at 100k replications — see the methodology registry’s RI Note; the implementation follows the package convention.) This demonstrates the method works with as few as one treated unit.

  • Walmart minimum-wage study (LW 2025, Section 6): A balanced panel of 1,280 counties over 23 years, with staggered Walmart openings. The rolling IPWRA estimator with detrending (Procedure 5.1) reveals that county-level linear trends are critical: the CS (2021) estimate of 5.4% employment increase shrinks to 3.2% (SE = 0.5%) once heterogeneous trends are removed — the latter consistent with Basker’s (2005) estimate of 150–300 new retail jobs per Walmart store.

  • Castle doctrine laws (LW 2026, Section 7.2): A staggered rollout across 21 states (2005–2009), with 29 never-treated controls. The aggregated ATT \(\hat{\tau}_\omega = 0.092\) (9.2% increase in homicides) is obtained from a single cross-sectional regression (Equation 7.19, LW 2026), with the HC3 t-statistic of 1.50.

Estimator Comparison#

LWDiD vs. CallawaySantAnna vs. WooldridgeDiD#

Feature

LWDiD

CallawaySantAnna

WooldridgeDiD

Approach

Unit-specific transform → cross-sectional TE estimation

Long-difference \(Y_{it} - Y_{i,g-1}\) (Eq. 4.13, LW 2025)

Single saturated POLS/TWFE regression

Pre-treatment info

All periods \(\{1,\ldots,g-1\}\) (rolling average)

Only period \(g-1\) (long difference)

All periods (full regression)

Key identification

Unconfoundedness of \(D_i\) w.r.t. \(\dot{Y}(0)\) (Thm 4.1)

PT on first differences

Mundlak-style cohort×time interactions

Estimators

RA, IPW, IPWRA, PSM, matching

OR, IPW, DR

OLS, Poisson, Logit

Heterogeneous trends

Yes (detrend, Procedure 5.1)

No

No

Exact small-N inference

Yes (\(\mathcal{T}_{N-2}\) under CLM, Eq. 2.10 LW 2026)

No (requires large N)

No (requires large N)

Doubly robust

Yes (IPWRA)

Yes (DR)

No (single equation)

Efficiency (common timing)

BLUE + asymptotically efficient (Theorem 3.1, LW 2025)

Less efficient (uses only \(g-1\))

Equivalent to LW RA (Theorem 3.1)

Restrictions#

Warning

The following restrictions apply to the current implementation:

  • At least 2 pre-treatment observations per unit for detrend — the detrend transformation fits a unit-specific linear trend on pre-treatment observations; units with fewer than 2 pre-treatment periods cannot be detrended and are dropped with a UserWarning.

  • Binary absorbing treatment — the treatment column must be a binary indicator that switches from 0 to 1 and stays on. Non-binary or non-absorbing treatment raises ValueError.

  • PSM matching — when estimation_method='psm', unmatched treated units (no control within caliper) receive NaN and are excluded from the ATT. A UserWarning reports the count of dropped treated units.

  • Propensity score trimming — IPW/DR clip estimated propensity scores to [pscore_trim, 1 - pscore_trim] (default 0.01/0.99) for numerical stability. Extreme scores indicate poor overlap (violation of Assumption OVLS, Equation 4.10, LW 2025).

  • Per-period effects — per-period (event-study) effects live on the unified post-fit surface: call results.aggregate('event_study') on the fitted LWDiDResults.

  • Not-yet-treated control — when control_group='not_yet_treated', the set of valid controls for cohort g at time r comprises units with \(D_{i,r+1} + \cdots + D_{iT} + D_{i\infty} = 1\) (Equation 4.12, LW 2025). This excludes already-treated cohorts, preventing “bad comparisons.”

See also

CallawaySantAnna

Propensity-score reweighting using long differences (Equation 4.13, LW 2025).

WooldridgeDiD

Mundlak-style saturated regression — equivalent to RA under LWDiD for common timing (Theorem 3.1, LW 2025).

ImputationDiD

FE imputation approach (Borusyak, Jaravel & Spiess 2024).