Core Module (core)
Main entry point for difference-in-differences estimation with panel data.
This module provides the main user-facing function lwdid() for estimating
average treatment effects on the treated (ATT) using the Lee and Wooldridge
transformation-based approach. The function supports both common timing designs
(where all treated units receive treatment simultaneously) and staggered
adoption designs (where units are treated at different times).
The method transforms panel data via unit-specific time-series operations (demeaning or detrending), then applies cross-sectional regression to the transformed outcomes. Under classical linear model assumptions, exact t-based inference is available for small samples. For large samples, asymptotic inference with robust standard errors or doubly robust estimators (IPW, IPWRA, PSM) is supported.
Main Function
- lwdid.lwdid(data, y, d=None, ivar=None, tvar=None, post=None, rolling='demean', *, gvar=None, control_group='not_yet_treated', estimator='ra', aggregate='cohort', balanced_panel='warn', ps_controls=None, trim_threshold=0.01, return_diagnostics=False, n_neighbors=1, caliper=None, with_replacement=True, match_order='data', vce=None, controls=None, cluster_var=None, alpha=0.05, ri=False, rireps=1000, seed=None, ri_method='bootstrap', graph=False, gid=None, graph_options=None, season_var=None, Q=4, auto_detect_frequency=False, include_pretreatment=False, pretreatment_test=True, pretreatment_alpha=0.05, exclude_pre_periods=0, **kwargs)[source]
Difference-in-differences estimator with unit-specific transformations.
Implements the rolling transformation approach for DiD estimation, supporting three methodological scenarios:
Small-sample common timing: Exact t-based inference under classical linear model assumptions.
Large-sample common timing: Asymptotic inference with heteroskedasticity-robust standard errors.
Staggered adoption: Cohort-time specific effect estimation with flexible control group strategies.
The transformation removes unit-specific pre-treatment patterns, converting panel DiD into a cross-sectional treatment effects problem.
- Parameters:
data (pd.DataFrame) – Panel data in long format with one row per unit-time observation. Each (unit, time) combination must be unique. Requires at least 3 units.
y (str) – Column name of the outcome variable.
d (str, optional) – Column name of the unit-level treatment indicator (required for common timing mode). Must be time-invariant: non-zero for treated units, zero for control units. Ignored in staggered mode.
ivar (str) – Column name of the unit identifier.
tvar (str or list of str) – Time variable specification. For annual data, a single column name. For quarterly data, a list of two column names [year_var, quarter_var] where quarter_var contains values in {1, 2, 3, 4}.
post (str, optional) – Column name of the post-treatment indicator (required for common timing mode). Internally binarized: non-zero values indicate post-treatment periods. Must be monotone non-decreasing in time (no treatment reversals).
rolling ({'demean', 'detrend', 'demeanq', 'detrendq'}, default='demean') –
Transformation method (case-insensitive):
’demean’: Remove unit-specific pre-treatment mean.
’detrend’: Remove unit-specific linear time trend.
’demeanq’: Demeaning with seasonal fixed effects. Requires
season_varandQparameters. Supports quarterly (Q=4), monthly (Q=12), or weekly (Q=52) data.’detrendq’: Detrending with seasonal fixed effects. Requires
season_varandQparameters. Supports quarterly (Q=4), monthly (Q=12), or weekly (Q=52) data.
All four transformation methods are supported for both common timing and staggered adoption designs.
gvar (str, optional) – Column name indicating first treatment period for staggered adoption. If specified, activates staggered mode and ignores
dandpost. Valid values: positive integers (treatment cohort), 0/inf/NaN (never-treated).control_group ({'not_yet_treated', 'never_treated', 'all_others'}, default='not_yet_treated') –
Control group composition for staggered adoption:
’not_yet_treated’: Never-treated plus not-yet-treated units.
’never_treated’: Only never-treated units.
’all_others’: All units not in the treated cohort (including already-treated units). This option is mainly intended for replication/diagnostics and may introduce forbidden comparisons under no-anticipation.
Auto-switched to ‘never_treated’ for cohort/overall aggregation.
estimator ({'ra', 'ipw', 'ipwra', 'psm'}, default='ra') –
Estimation method (case-insensitive):
’ra’: Regression adjustment via OLS on transformed outcomes.
’ipw’: Inverse probability weighting. Requires
controls.’ipwra’: Doubly robust combining IPW with RA. Requires
controls.’psm’: Propensity score matching. Requires
controls.
aggregate ({'none', 'cohort', 'overall'}, default='cohort') –
Aggregation level for staggered adoption:
’none’: Return cohort-time specific effects only.
’cohort’: Aggregate to cohort-specific effects.
’overall’: Aggregate to a single weighted overall effect.
balanced_panel ({'warn', 'error', 'ignore'}, default='warn') –
How to handle unbalanced panels (units with different observation counts):
’warn’: Issue a warning with selection mechanism diagnostics (default).
’error’: Raise UnbalancedPanelError if panel is unbalanced.
’ignore’: Silently proceed without warnings.
Selection may depend on time-invariant heterogeneity but not on shocks to the untreated potential outcome. Use
diagnose_selection_mechanism()for detailed diagnostics.ps_controls (list of str, optional) – Control variables for propensity score model. If None, uses
controls.trim_threshold (float, default=0.01) – Propensity score trimming threshold. Observations with propensity scores outside [trim_threshold, 1 - trim_threshold] are excluded.
return_diagnostics (bool, default=False) – Whether to include propensity score diagnostics in results.
n_neighbors (int, default=1) – Number of nearest neighbors for PSM matching.
caliper (float, optional) – Maximum propensity score distance for PSM, in units of PS standard deviation. Treated units without valid matches are dropped.
with_replacement (bool, default=True) – Whether PSM allows control units to be matched multiple times.
match_order ({'data', 'random', 'largest', 'smallest'}, default='data') –
Order for processing treated units in without-replacement PSM:
’data’: Original data order.
’random’: Randomized order (use
seedfor reproducibility).’largest’: Prioritize units with extreme propensity scores.
’smallest’: Prioritize units with propensity scores near 0.5.
vce ({None, 'robust', 'hc0', 'hc1', 'hc2', 'hc3', 'hc4', 'cluster'}, optional) –
Variance estimator (case-insensitive):
None: Homoskedastic OLS standard errors.
’hc0’: White heteroskedasticity-robust.
’robust’/’hc1’: HC1 with degrees-of-freedom correction N/(N-K).
’hc2’: Leverage-adjusted using (1 - h_ii)^{-1}.
’hc3’: Small-sample adjusted using (1 - h_ii)^{-2}.
’hc4’: Adaptive leverage correction.
’cluster’: Cluster-robust (requires
cluster_var).
controls (list of str, optional) – Time-invariant control variables for outcome regression.
cluster_var (str, optional) – Column name for clustering (required when vce=’cluster’).
alpha (float, default=0.05) – Significance level for confidence intervals.
ri (bool, default=False) – Whether to perform randomization inference for the null H0: ATT=0.
rireps (int, default=1000) – Number of randomization inference replications.
seed (int, optional) – Random seed for reproducibility in randomization inference.
ri_method ({'bootstrap', 'permutation'}, default='bootstrap') –
Resampling method for randomization inference:
’bootstrap’: With-replacement resampling.
’permutation’: Fisher’s exact permutation test.
graph (bool, default=False) – Whether to generate a plot of transformed outcomes over time.
gid (str or int, optional) – Specific unit identifier to highlight in the plot.
graph_options (dict, optional) – Additional plotting options passed to the visualization function.
season_var (str, optional) – Column name of seasonal indicator variable for seasonal transformations (demeanq, detrendq). Values should be integers from 1 to Q representing seasonal periods (e.g., quarters 1-4, months 1-12, or weeks 1-52). This parameter is preferred over the legacy
quarterparameter intvarfor non-quarterly seasonal data.Q (int, default=4) –
Number of seasonal periods per cycle. Used with seasonal transformations (demeanq, detrendq). Common values:
4: Quarterly data (default)
12: Monthly data
52: Weekly data
Must match the range of values in
season_var(1 to Q).auto_detect_frequency (bool, default=False) –
Whether to automatically detect data frequency and set Q accordingly. When True, the function analyzes the time variable to infer whether data is quarterly (Q=4), monthly (Q=12), or weekly (Q=52).
If detection succeeds with high confidence, Q is set automatically.
If detection fails or has low confidence, a warning is issued and the explicit Q value is used.
An explicit Q value always overrides auto-detection when both are specified (Q != 4 and auto_detect_frequency=True).
This parameter is useful when working with datasets of unknown frequency or when building generic analysis pipelines.
include_pretreatment (bool, default=False) –
Whether to compute pre-treatment transformed outcomes and ATT estimates for parallel trends assessment. Only applicable in staggered mode.
When True:
Applies rolling transformations to pre-treatment periods using future pre-treatment periods {t+1, …, g-1} as reference.
Estimates pre-treatment ATT for each (cohort, period) pair.
Stores results in
att_pre_treatmentattribute of LWDIDResults.Enables extended event study visualization with pre-treatment effects.
Under the parallel trends assumption, pre-treatment ATT estimates should be statistically indistinguishable from zero.
pretreatment_test (bool, default=True) –
Whether to perform parallel trends statistical test when
include_pretreatment=True. The test includes:Individual t-tests for each pre-treatment period ATT.
Joint F-test for H0: all pre-treatment ATT = 0.
Results are stored in
parallel_trends_testattribute.pretreatment_alpha (float, default=0.05) – Significance level for parallel trends test. Used for determining
reject_nullin the test results.exclude_pre_periods (int, default=0) –
Number of pre-treatment periods to exclude immediately before treatment. Used to address potential anticipation effects when the no-anticipation assumption may be violated.
When
exclude_pre_periods > 0:The specified number of periods immediately before treatment are excluded from the pre-treatment sample used for transformation.
For common timing: excludes the last k pre-treatment periods.
For staggered adoption: excludes k periods before each cohort’s treatment date.
This implements a robustness check for testing sensitivity to anticipation effects.
Example: If treatment occurs at t=6 and
exclude_pre_periods=2, periods t=4 and t=5 are excluded from the pre-treatment sample.
- Returns:
Results object with the following key attributes:
att : Average treatment effect on the treated.
se_att : Standard error of ATT.
t_stat : t-statistic for H0: ATT=0.
pvalue : Two-sided p-value.
ci_lower, ci_upper : Confidence interval bounds.
df_inference : Degrees of freedom for inference.
nobs : Number of observations in estimation sample.
n_treated, n_control : Unit counts by treatment status.
att_by_period : Period-specific ATT estimates (DataFrame).
ri_pvalue : Randomization inference p-value (if ri=True).
att_pre_treatment : Pre-treatment ATT estimates (if include_pretreatment=True).
parallel_trends_test : Parallel trends test results (if include_pretreatment=True).
include_pretreatment : Whether pre-treatment dynamics were computed.
Key methods: summary(), plot(), to_excel(), to_csv(), to_latex(), get_diagnostics(), plot_event_study().
- Return type:
LWDIDResults
- Raises:
MissingRequiredColumnError – Required columns not found in data.
InvalidRollingMethodError – Invalid rolling method specified.
InsufficientDataError – Insufficient sample size or pre-/post-treatment observations.
NoTreatedUnitsError – No treated units in data.
NoControlUnitsError – No control units in data.
InsufficientPrePeriodsError – Insufficient pre-treatment periods for the chosen transformation.
NoNeverTreatedError – Cohort/overall aggregation requested but no never-treated units exist.
Notes
Mode selection:
Common timing (gvar=None): Requires
d,post,rolling.Staggered adoption (gvar specified): Requires
gvar,rolling.
Confidence intervals use t-distribution critical values with degrees of freedom N-k (homoskedastic) or G-1 (cluster-robust).
See also
LWDIDResultsDetailed documentation of the results container.
Examples
Basic Usage
Simplest DiD estimation with default settings:
from lwdid import lwdid
import pandas as pd
# Load data
data = pd.read_csv('smoking.csv')
# Run estimation
results = lwdid(
data,
y='lcigsale', # Outcome variable
d='d', # Treatment indicator
ivar='state', # Unit ID
tvar='year', # Time variable
post='post', # Post-treatment indicator
rolling='demean' # Transformation method
)
# View results
print(results.summary())
print(f"ATT: {results.att:.4f}")
print(f"SE: {results.se_att:.4f}")
print(f"p-value: {results.pvalue:.4f}")
With Robust Standard Errors
Using HC3 heteroskedasticity-robust standard errors:
results = lwdid(
data, 'lcigsale', 'd', 'state', 'year', 'post', 'detrend',
vce='hc3' # HC3 robust standard errors
)
With Randomization Inference
Adding randomization inference for non-parametric testing:
results = lwdid(
data, 'lcigsale', 'd', 'state', 'year', 'post', 'demean',
ri=True, # Enable randomization inference
rireps=1000, # Number of permutations
ri_method='permutation', # Use permutation (recommended)
seed=42 # For reproducibility
)
print(f"t-based p-value: {results.pvalue:.4f}")
print(f"RI p-value: {results.ri_pvalue:.4f}")
With Control Variables
Including time-invariant control variables:
# Prepare time-invariant controls
# Use pre-treatment mean for time-varying variables
data_prep = data.copy()
for var in ['retprice', 'beer']:
pre_mean = data[data['post']==0].groupby('state')[var].mean()
data_prep[f'{var}_pre'] = data_prep['state'].map(pre_mean)
results = lwdid(
data_prep, 'lcigsale', 'd', 'state', 'year', 'post', 'detrend',
controls=['retprice_pre', 'beer_pre'], # Time-invariant controls
vce='hc3'
)
Warning
Control variables must be time-invariant (constant within each unit). Time-varying variables must first be aggregated to create unit-level constants. Common approaches:
Pre-treatment mean:
data[data['post']==0].groupby('unit')[var].mean()First period value:
data.groupby('unit')[var].first()Overall mean:
data.groupby('unit')[var].mean()
Cluster-Robust Standard Errors
When errors are correlated within clusters:
results = lwdid(
data, 'outcome', 'd', 'unit', 'year', 'post', 'demean',
vce='cluster',
cluster_var='state' # Cluster by state
)
Quarterly Data
Handling quarterly data with seasonal patterns:
# Data has columns: unit, year, quarter, outcome, d, post
results = lwdid(
data_q,
y='sales',
d='d',
ivar='store',
tvar=['year', 'quarter'], # Composite time variable
post='post',
rolling='detrendq' # Quarterly detrending with seasonality
)
Complete Example with All Options
results = lwdid(
data,
y='outcome',
d='d',
ivar='unit',
tvar='year',
post='post_treatment',
rolling='detrend',
controls=['baseline_x1', 'baseline_x2'],
vce='hc3',
ri=True,
rireps=2000,
ri_method='permutation',
seed=12345
)
# Access results
print(f"ATT: {results.att:.3f} ({results.ci_lower:.3f}, {results.ci_upper:.3f})")
print(f"t-stat: {results.t_stat:.3f}, p-value: {results.pvalue:.4f}")
print(f"RI p-value: {results.ri_pvalue:.4f}")
print(f"N = {results.nobs}, df = {results.df_inference}")
# Export results
results.to_excel('results.xlsx')
results.plot()
Doubly Robust Estimation (Large-Sample Common Timing)
For large cross-sectional samples in common timing designs, multiple estimators are available beyond regression adjustment (RA). Lee and Wooldridge (2025) shows that the rolling transformation approach enables application of doubly robust estimators.
# IPWRA (doubly robust) estimator
results = lwdid(
data, 'outcome', 'd', 'unit', 'year', 'post', 'demean',
estimator='ipwra', # Doubly robust estimator
controls=['x1', 'x2'], # Controls for outcome and propensity score
vce='hc3'
)
# IPW (inverse probability weighting) estimator
results_ipw = lwdid(
data, 'outcome', 'd', 'unit', 'year', 'post', 'demean',
estimator='ipw',
controls=['x1', 'x2'],
vce='hc3'
)
# PSM (propensity score matching) estimator
results_psm = lwdid(
data, 'outcome', 'd', 'unit', 'year', 'post', 'demean',
estimator='psm',
controls=['x1', 'x2'],
n_neighbors=3, # 3 nearest neighbors
caliper=0.1 # Caliper in PS standard deviations
)
Note
Estimator Selection Guidelines:
RA (default): Efficient under correct outcome model specification.
IPWRA: Doubly robust; consistent if either outcome or propensity score model is correctly specified. Recommended when functional form is uncertain.
IPW/PSM: Use when propensity score methods are preferred for substantive reasons.
For detailed guidance on estimator selection, see Estimation Module (estimation) and User Guide.
Staggered DiD
For staggered treatment adoption (different units treated at different times):
# Castle Law example - states adopted Castle Doctrine at different years
data = pd.read_csv('castle.csv')
data['gvar'] = data['effyear'].fillna(0).astype(int)
# Overall effect across all cohorts
results = lwdid(
data=data,
y='lhomicide', # Log homicide rate
ivar='sid', # State ID (must be integer)
tvar='year', # Year
gvar='gvar', # First treatment year (0 = never treated)
rolling='demean', # Transformation method
control_group='never_treated',
aggregate='overall', # Get weighted average effect
vce='hc3'
)
print(f"Overall ATT: {results.att_overall:.4f}")
print(f"95% CI: [{results.ci_overall_lower:.4f}, {results.ci_overall_upper:.4f}]")
# Cohort-specific effects
results = lwdid(
data=data,
y='lhomicide',
ivar='sid',
tvar='year',
gvar='gvar',
aggregate='cohort', # Average within each cohort
)
print(results.att_by_cohort) # DataFrame with cohort-level estimates
# All (cohort, time) specific effects for event study
results = lwdid(
data=data,
y='lhomicide',
ivar='sid',
tvar='year',
gvar='gvar',
aggregate='none', # No aggregation
)
# Plot event study
results.plot_event_study(title='Castle Doctrine Effect')
Note
When using aggregate='cohort' or aggregate='overall', the control
group is automatically switched to never_treated if not_yet_treated
was specified. This is required by the theoretical framework (see Lee and
Wooldridge, 2025).
Pre-treatment Dynamics and Parallel Trends Testing
For parallel trends assessment in staggered designs:
results = lwdid(
data=data,
y='lhomicide',
ivar='sid',
tvar='year',
gvar='gvar',
rolling='demean',
aggregate='cohort',
include_pretreatment=True, # Compute pre-treatment ATT
pretreatment_test=True, # Run parallel trends test
)
# Access parallel trends test results
pt = results.parallel_trends_test
print(f"Joint F-stat: {pt.joint_f_stat:.4f}, p-value: {pt.joint_pvalue:.4f}")
# Plot with pre-treatment effects
results.plot_event_study(include_pre_treatment=True)
Handling Unbalanced Panels
For panels with missing observations:
from lwdid import lwdid, diagnose_selection_mechanism
# Run selection diagnostics first
diagnostics = diagnose_selection_mechanism(
data=data, ivar='unit', tvar='year', gvar='gvar'
)
print(f"Selection risk: {diagnostics.risk_level}")
# Estimation with unbalanced panel handling
results = lwdid(
data=data,
y='outcome',
ivar='unit',
tvar='year',
gvar='gvar',
rolling='detrend', # Detrending is more robust to selection
balanced_panel='warn' # Issue warning with diagnostics (default)
)
Excluding Pre-treatment Periods for Anticipation
When no-anticipation assumption may be violated:
# Exclude 2 periods before treatment to test for anticipation effects
results = lwdid(
data=data,
y='outcome',
d='treated',
ivar='unit',
tvar='year',
post='post',
rolling='demean',
exclude_pre_periods=2 # Exclude t-1 and t-2 from transformation
)
See Also
lwdid.LWDIDResults- Results object returned by lwdid()lwdid.staggered- Low-level staggered estimation functionsUser Guide - Comprehensive usage guide
Quick Start - Quick start tutorial