Local Projections Difference-in-Differences#

Local Projections DiD (LP-DiD) estimator for staggered, absorbing-treatment event studies, from Dube, Girardi, Jordà & Taylor (2025).

LP-DiD estimates a separate regression at each event-time horizon h of a long difference of the outcome (y_{i,t+h} - y_{i,t-1}) on the treatment-switch indicator, restricted to a flexible “clean control” sample of newly-treated observations and not-yet-treated controls. Excluding already-treated units from the control group removes the negative-weighting bias of naive two-way fixed effects, so the default (variance-weighted) estimand is a strictly non-negatively-weighted average of cohort effects.

Note

This release implements the absorbing-treatment main path: treatment is binary and, once switched on, stays on. The estimator rejects panels where a unit’s treatment turns off. Non-absorbing (switch on/off) treatment and survey-design support are planned follow-ups. Covariates and absorbed fixed effects are supported; under reweight=False they enter by direct inclusion, which preserves the non-negative weighting result only under homogeneous covariate effects (online Appendix B.2.2) — the regression-adjustment path (reweight=True) is preferred for covariate-adjusted designs (it does not auto-switch; the default remains reweight=False, which emits the warning). The time column must be numeric with integer-spaced periods (long differences use t-1 / t+h arithmetic on the labels); map irregular or datetime periods to consecutive integers first. See docs/methodology/REGISTRY.md for the full contract.

When to use LPDiD:

  • Staggered, absorbing adoption where you want a fast, transparent, regression-based event study free of negative weighting

  • You want both a dynamic event-study path and a single pooled pre/post ATT

  • You want to flexibly choose the pretreatment base period (first-lag or premean-differenced) or hold the post-treatment sample composition fixed across post horizons

  • You want an estimator that is numerically equivalent to Callaway-Sant’Anna (reweighted) or to a Cengiz et al. (2019)-style stacked regression (variance-weighted), but much faster

Reference: Dube, A., Girardi, D., Jordà, Ò., & Taylor, A. M. (2025). A Local Projections Approach to Difference-in-Differences. Journal of Applied Econometrics, 40(5), 741-758.

LPDiD#

Main estimator class for Local Projections Difference-in-Differences.

class diff_diff.LPDiD[source]

Bases: object

Methods

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

get_params()

set_params(**params)

__init__(pre_window=2, post_window=0, control_group='clean', reweight=False, no_composition=False, pmd=None, alpha=0.05, cluster=None, rank_deficient_action='warn')[source]
Parameters:
  • pre_window (int)

  • post_window (int)

  • control_group (str)

  • reweight (bool)

  • no_composition (bool)

  • pmd (str | int | None)

  • alpha (float)

  • cluster (str | None)

  • rank_deficient_action (str)

fit(data, outcome, unit, time, treatment, covariates=None, ylags=0, dylags=0, absorb=None, post_pooled=None, pre_pooled=None, only_event=False, only_pooled=False)[source]
get_params()[source]
Return type:

Dict[str, Any]

set_params(**params)[source]
Parameters:

params (Any)

Return type:

LPDiD

LPDiDResults#

Results container for LP-DiD estimation (event-study and pooled tables).

class diff_diff.lpdid_results.LPDiDResults[source]

Bases: object

Results container for the LPDiD estimator.

Holds the per-horizon event_study table and the pooled pre/post table (each a pandas.DataFrame with coefficient, se, t_stat, p_value, conf_low, conf_high, n_obs, n_clusters columns). The headline ATT is the pooled post row.

n_control_units counts never-treated units only (the library-wide field convention, surfaced as “Never-treated units” in summary()); under control_group="clean" the realized control pool at each horizon also includes not-yet-treated cohorts, whose per-horizon counts live in the n_obs / n_clusters columns of the tables.

Methods

summary()

print_summary()

to_dataframe([level])

to_dict()

event_study: DataFrame | None
pooled: DataFrame | None
n_obs: int
n_treated_units: int
n_control_units: int
pre_window: int
post_window: int
control_group: str
reweight: bool
no_composition: bool
pmd: str | int | None
alpha: float = 0.05
cluster_name: str | None = None
n_clusters: int | None = None
vcov_type: str = 'hc1'
rank_deficient_action: str = 'warn'
covariates: List[str] | None = None
absorb: List[str] | None = None
ylags: int = 0
dylags: int = 0
property estimand: str
property att: float
property se: float
property t_stat: float
property p_value: float
property conf_int: Tuple[float, float]
to_dataframe(level='event')[source]
Parameters:

level (str)

Return type:

DataFrame

to_dict()[source]
Return type:

Dict[str, Any]

summary()[source]
Return type:

str

print_summary()[source]
Return type:

None

__init__(event_study, pooled, n_obs, n_treated_units, n_control_units, pre_window, post_window, control_group, reweight, no_composition, pmd, alpha=0.05, cluster_name=None, n_clusters=None, vcov_type='hc1', rank_deficient_action='warn', covariates=None, absorb=None, ylags=0, dylags=0)
Parameters:
Return type:

None

Example Usage#

Basic usage (LP-DiD takes a binary, absorbing treatment indicator):

from diff_diff import LPDiD, generate_staggered_data

data = generate_staggered_data(n_units=300, n_periods=12,
                               cohort_periods=[4, 7, 10], seed=42)
# Binary absorbing indicator: 1 from a unit's first treated period onward.
data["treated"] = (data["period"] >= data["first_treat"]).astype(int)

lp = LPDiD(pre_window=5, post_window=4)
results = lp.fit(data, outcome="outcome", unit="unit",
                 time="period", treatment="treated")
results.print_summary()
print(results.event_study)   # per-horizon coefficients
print(results.pooled)        # pooled pre (placebo) / post (ATT) rows

Equally-weighted ATT (numerically equivalent to Callaway-Sant’Anna):

lp_rw = LPDiD(pre_window=5, post_window=4, reweight=True)
results_rw = lp_rw.fit(data, outcome="outcome", unit="unit",
                       time="period", treatment="treated")
print(f"Variance-weighted ATT: {results.att:.4f} (SE={results.se:.4f})")
print(f"Equally-weighted ATT:  {results_rw.att:.4f} (SE={results_rw.se:.4f})")

Premean-differenced base period and fixed-composition sample:

lp_pmd = LPDiD(pre_window=5, post_window=4, pmd="max", no_composition=True)
results_pmd = lp_pmd.fit(data, outcome="outcome", unit="unit",
                         time="period", treatment="treated")

Comparison with Other Staggered Estimators#

Feature

LPDiD

CallawaySantAnna

ImputationDiD

Approach

Per-horizon long-difference LP regression on clean controls

Separate 2x2 DiD aggregation

Impute Y(0) via FE model

Treatment

Binary, absorbing (this release)

Binary, absorbing

Binary, absorbing

Default estimand

Variance-weighted ATT (non-negative weights)

Equally-weighted ATT

Equally-weighted ATT

Equivalences

Reweighted == CS; variance-weighted == Cengiz (2019)-style stacking (not diff_diff.StackedDiD, which is Wing et al. 2024 Q-weights); PMD single-cohort == BJS

Baseline

== reweighted PMD LP-DiD (single cohort)

Covariates

Supported (regression adjustment preferred; direct inclusion under homogeneity)

Supported (OR, IPW, DR)

Supported

Inference

Cluster-robust at unit (default)

Multiplier bootstrap

Influence-function cluster variance

Speed

Very fast (stack of small OLS fits)

Slower (pairwise group-time)

Fast