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:
and forms the transformed outcome:
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:
Compute the pre-treatment mean for each unit: \(\bar{Y}_{i,\text{pre}} = \frac{1}{S-1}\sum_{t=1}^{S-1} Y_{it}\)
Obtain the transformed outcome (out-of-sample residuals):
\[\dot{Y}_{it} = Y_{it} - \bar{Y}_{i,\text{pre}}, \quad t = S, \ldots, T\]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):
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\).
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)}\]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:
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)}\]Select the control group: units not yet treated by period r, i.e., cohorts \(\{r+1, \ldots, T, \infty\}\).
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:
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):
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):
Treatment effects are zero on average before the intervention.
Assumption CPTS — Conditional PT, Staggered (Equation 4.6, LW 2025):
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:
the t-statistic follows an exact Student-t distribution:
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:
BaseEstimatorLee & 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 thecluster=parameter, not through avcov_typevalue, 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 that1 - pscore_trim < 1in 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-pypackage (from lwdid import lwdid) uses a functional interface with separated(ever-treated indicator) andpost(post-period indicator) columns. In diff-diff, thetreatmentcolumn is the time-varying binary indicatorD_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]
- 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:
- 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 definitiontime < gand the same unit subset as estimation (cohort-g treated units plus the control superset implied bycontrol_group).- Return type:
- 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
LWDiDResults#
Results container returned by fit().
- class diff_diff.lwdid_results.LWDiDResults[source]
Bases:
BaseResults,AggregationMixinResults 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
- n_obs: int
- n_treated: int
- n_control: int
- rolling: str
- estimation_method: str
- vcov_type: str
- alpha: float
- n_bootstrap: int = 0
- 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’}
- 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
- property pvalue: float
Alias for p_value (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:
- 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:
- 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
.attbefore caching. Requires a clustered (cluster=), common-timing,estimation_method='reg'fit.alpha=Noneinherits the fitted confidence level. Result is cached and accessible via thebootstrap_pvalueproperty.
- 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.attbefore caching. Requires a common-timingestimation_method='reg'fit. Result is cached and accessible via theri_pvalueproperty.
- __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:
att (float)
se (float)
t_stat (float)
p_value (float)
n_obs (int)
n_treated (int)
n_control (int)
rolling (str)
estimation_method (str)
vcov_type (str)
alpha (float)
df_inference (int | None)
cluster_name (str | None)
n_clusters (int | None)
control_group (str | None)
n_bootstrap (int)
seed (int | None)
pscore_trim (float | None)
inference_basis (str | None)
att_tau_omega_complete_case (float | None)
n_composite_treated_dropped (int)
n_composite_controls_dropped (int)
event_study_vcov (ndarray | None)
event_study_vcov_index (ndarray | None)
cband_method (str | None)
cband_crit_value (float | None)
cband_n_bootstrap (int | None)
params (ndarray | None)
bse (ndarray | None)
vcov (ndarray | None)
_ri_result (Any | None)
_wcb_result (Any | None)
- 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
treatmentindicator must be non-decreasing over time: once a unit switches from 0 to 1 it must remain treated. Units that revert to 0 raiseValueError.Common timing — when
first_treatis not supplied, all treated units must first switch on in the same period. Heterogeneous onsets are rejected with aValueErrorpointing to the staggered interface (passfirst_treat).Staggered consistency — when
first_treatis supplied, thetreatmentindicator 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_treatcoded0,NaN/NaT, ornp.infmeans never-treated;infand 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'acceptsvcov_typein{'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_treatis supplied andcontrol_group='not_yet_treated', at least one never-treated unit (first_treatcoded NaN or 0) must be present. A panel in which every unit is eventually treated raisesValueErrorrather than silently truncating the estimation sample.Unit-constant covariates —
covariates(and a non-unitcluster=column) must be constant within each unit on BOTH timing paths; time-varying columns raiseValueError(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#
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
detrendtransformation 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 aUserWarning.Binary absorbing treatment — the
treatmentcolumn must be a binary indicator that switches from 0 to 1 and stays on. Non-binary or non-absorbing treatment raisesValueError.PSM matching — when
estimation_method='psm', unmatched treated units (no control withincaliper) receive NaN and are excluded from the ATT. AUserWarningreports 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 fittedLWDiDResults.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
CallawaySantAnnaPropensity-score reweighting using long differences (Equation 4.13, LW 2025).
WooldridgeDiDMundlak-style saturated regression — equivalent to RA under LWDiD for common timing (Theorem 3.1, LW 2025).
ImputationDiDFE imputation approach (Borusyak, Jaravel & Spiess 2024).