Skip to content

Commit 8ffd344

Browse files
authored
Merge pull request #252 from igerber/imputation-preperiods
Add pre-period event study coefficients to ImputationDiD and TwoStageDiD
2 parents 55f5c23 + 0b65daa commit 8ffd344

6 files changed

Lines changed: 1079 additions & 58 deletions

File tree

diff_diff/imputation.py

Lines changed: 220 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ class ImputationDiD(ImputationDiDBootstrapMixin):
8181
- "cohort_horizon": Groups by cohort x relative time (tightest SEs)
8282
- "cohort": Groups by cohort only (more conservative)
8383
- "horizon": Groups by relative time only (more conservative)
84+
pretrends : bool, default=False
85+
If True, event study includes pre-treatment horizons for visual
86+
pre-trends assessment. Pre-period effects should be ~0 under
87+
parallel trends. Only affects event_study aggregation; overall
88+
ATT and group aggregation are unchanged.
8489
8590
Attributes
8691
----------
@@ -134,6 +139,7 @@ def __init__(
134139
rank_deficient_action: str = "warn",
135140
horizon_max: Optional[int] = None,
136141
aux_partition: str = "cohort_horizon",
142+
pretrends: bool = False,
137143
):
138144
if rank_deficient_action not in ("warn", "error", "silent"):
139145
raise ValueError(
@@ -160,6 +166,7 @@ def __init__(
160166
self.rank_deficient_action = rank_deficient_action
161167
self.horizon_max = horizon_max
162168
self.aux_partition = aux_partition
169+
self.pretrends = pretrends
163170

164171
self.is_fitted_ = False
165172
self.results_: Optional[ImputationDiDResults] = None
@@ -229,6 +236,14 @@ def fit(
229236
if missing:
230237
raise ValueError(f"Missing columns: {missing}")
231238

239+
if self.pretrends and survey_design is not None and aggregate in ("event_study", "all"):
240+
raise NotImplementedError(
241+
"pretrends=True is not yet compatible with survey_design. "
242+
"The pre-period lead regression uses unweighted demeaning, "
243+
"which does not account for survey weights. Use pretrends=False "
244+
"with survey_design for now."
245+
)
246+
232247
# Create working copy
233248
df = data.copy()
234249

@@ -1101,6 +1116,7 @@ def _compute_cluster_psi_sums(
11011116
# ---- Compute v_it for untreated observations ----
11021117
if covariates is None or len(covariates) == 0:
11031118
# FE-only case: closed-form
1119+
# Build w_by_unit, w_by_time, w_total from the target weights
11041120
treated_units = df_1[unit].values
11051121
treated_times = df_1[time].values
11061122

@@ -1116,6 +1132,9 @@ def _compute_cluster_psi_sums(
11161132

11171133
w_total = float(np.sum(weights))
11181134

1135+
untreated_units = df_0[unit].values
1136+
untreated_times = df_0[time].values
1137+
11191138
# Use survey-weighted sums for untreated denominators when present
11201139
if survey_weights_0 is not None:
11211140
sw0_series = pd.Series(survey_weights_0, index=df_0.index)
@@ -1127,8 +1146,6 @@ def _compute_cluster_psi_sums(
11271146
n0_by_time = df_0.groupby(time).size().to_dict()
11281147
n0_denom = n_0
11291148

1130-
untreated_units = df_0[unit].values
1131-
untreated_times = df_0[time].values
11321149
v_untreated = np.zeros(n_0)
11331150

11341151
for j in range(n_0):
@@ -1513,6 +1530,69 @@ def _aggregate_event_study(
15131530
"n_obs": 0,
15141531
}
15151532

1533+
# Pre-period coefficients via BJS Test 1 lead regression
1534+
if self.pretrends:
1535+
df_0 = df.loc[omega_0_mask].copy()
1536+
1537+
# Determine which cohorts' lead indicators to include.
1538+
# balance_e restricts which cohorts contribute lead dummies,
1539+
# but the full Omega_0 sample (including never-treated controls)
1540+
# is kept for the within-transformed OLS (BJS Test 1, Equation 9).
1541+
balanced_cohorts = None
1542+
skip_preperiods = False
1543+
if balance_e is not None:
1544+
cohort_rel_times_0 = self._build_cohort_rel_times(df, first_treat)
1545+
balanced_cohorts = set()
1546+
if all_horizons:
1547+
max_h = max(all_horizons)
1548+
required_range = set(range(-balance_e, max_h + 1))
1549+
for g, horizons in cohort_rel_times_0.items():
1550+
if required_range.issubset(horizons):
1551+
balanced_cohorts.add(g)
1552+
if not balanced_cohorts:
1553+
skip_preperiods = True # No cohorts qualify — skip entirely
1554+
1555+
if not skip_preperiods:
1556+
rel_time_0 = np.where(
1557+
~df_0["_never_treated"],
1558+
df_0[time] - df_0[first_treat],
1559+
np.nan,
1560+
)
1561+
1562+
# When balance_e is set, only include leads from balanced cohorts
1563+
if balanced_cohorts is not None:
1564+
is_balanced = df_0[first_treat].isin(balanced_cohorts).values
1565+
rel_time_for_leads = np.where(is_balanced, rel_time_0, np.nan)
1566+
else:
1567+
rel_time_for_leads = rel_time_0
1568+
1569+
pre_rel_times = sorted(
1570+
set(
1571+
int(h)
1572+
for h in rel_time_for_leads
1573+
if np.isfinite(h) and h < -self.anticipation
1574+
)
1575+
)
1576+
pre_rel_times = [h for h in pre_rel_times if h != ref_period]
1577+
if self.horizon_max is not None:
1578+
pre_rel_times = [
1579+
h for h in pre_rel_times if abs(h) <= self.horizon_max
1580+
]
1581+
if pre_rel_times:
1582+
pre_effects, _, _ = self._compute_lead_coefficients(
1583+
df_0,
1584+
outcome,
1585+
unit,
1586+
time,
1587+
first_treat,
1588+
covariates,
1589+
cluster_var,
1590+
pre_rel_times,
1591+
alpha=self.alpha,
1592+
balanced_cohorts=balanced_cohorts,
1593+
)
1594+
event_study_effects.update(pre_effects)
1595+
15161596
# Collect horizons with Proposition 5 violations
15171597
prop5_horizons = []
15181598

@@ -1748,9 +1828,138 @@ def _aggregate_group(
17481828
return group_effects
17491829

17501830
# =========================================================================
1751-
# Pre-trend test (Equation 9)
1831+
# Pre-trend test (Equation 9) & pre-period lead coefficients
17521832
# =========================================================================
17531833

1834+
def _compute_lead_coefficients(
1835+
self,
1836+
df_0: pd.DataFrame,
1837+
outcome: str,
1838+
unit: str,
1839+
time: str,
1840+
first_treat: str,
1841+
covariates: Optional[List[str]],
1842+
cluster_var: str,
1843+
pre_rel_times: List[int],
1844+
alpha: float = 0.05,
1845+
balanced_cohorts: Optional[set] = None,
1846+
) -> Tuple[Dict[int, Dict[str, Any]], np.ndarray, np.ndarray]:
1847+
"""
1848+
Compute pre-period lead coefficients via within-transformed OLS (Test 1).
1849+
1850+
Adds lead indicator dummies W_it(h) = 1[K_it = h] to the untreated
1851+
model and estimates their coefficients with cluster-robust SEs.
1852+
1853+
The full Omega_0 sample (including never-treated controls) is always
1854+
used for within-transformation. When balanced_cohorts is provided,
1855+
lead indicators are restricted to observations from those cohorts only.
1856+
1857+
Returns
1858+
-------
1859+
effects : dict
1860+
Per-horizon event_study_effects entries.
1861+
gamma : ndarray
1862+
Lead coefficient vector.
1863+
V_gamma : ndarray
1864+
Sub-VCV matrix for lead coefficients.
1865+
"""
1866+
rel_time_0 = np.where(
1867+
~df_0["_never_treated"],
1868+
df_0[time] - df_0[first_treat],
1869+
np.nan,
1870+
)
1871+
1872+
# Build lead indicators — restrict to balanced cohorts if specified
1873+
if balanced_cohorts is not None:
1874+
is_balanced = df_0[first_treat].isin(balanced_cohorts).values
1875+
else:
1876+
is_balanced = None
1877+
1878+
lead_cols = []
1879+
for h in pre_rel_times:
1880+
col_name = f"_lead_{h}"
1881+
indicator = (rel_time_0 == h).astype(float)
1882+
if is_balanced is not None:
1883+
indicator = indicator * is_balanced # zero out non-balanced cohorts
1884+
df_0[col_name] = indicator
1885+
lead_cols.append(col_name)
1886+
1887+
# Within-transform via iterative demeaning
1888+
y_dm = self._iterative_demean(
1889+
df_0[outcome].values, df_0[unit].values, df_0[time].values, df_0.index
1890+
)
1891+
1892+
all_x_cols = lead_cols[:]
1893+
if covariates:
1894+
all_x_cols.extend(covariates)
1895+
1896+
X_dm = np.column_stack(
1897+
[
1898+
self._iterative_demean(
1899+
df_0[col].values, df_0[unit].values, df_0[time].values, df_0.index
1900+
)
1901+
for col in all_x_cols
1902+
]
1903+
)
1904+
1905+
# OLS with cluster-robust SEs
1906+
cluster_ids = df_0[cluster_var].values
1907+
try:
1908+
result = solve_ols(
1909+
X_dm,
1910+
y_dm,
1911+
cluster_ids=cluster_ids,
1912+
return_vcov=True,
1913+
rank_deficient_action=self.rank_deficient_action,
1914+
column_names=all_x_cols,
1915+
)
1916+
except (IndexError, np.linalg.LinAlgError):
1917+
# All lead columns dropped (rank deficient after demeaning)
1918+
effects: Dict[int, Dict[str, Any]] = {}
1919+
for h in pre_rel_times:
1920+
n_obs = int(df_0[f"_lead_{h}"].sum())
1921+
effects[h] = {
1922+
"effect": np.nan, "se": np.nan, "t_stat": np.nan,
1923+
"p_value": np.nan, "conf_int": (np.nan, np.nan),
1924+
"n_obs": n_obs,
1925+
}
1926+
for col in lead_cols:
1927+
df_0.drop(columns=col, inplace=True)
1928+
return effects, np.full(len(pre_rel_times), np.nan), np.full(
1929+
(len(pre_rel_times), len(pre_rel_times)), np.nan
1930+
)
1931+
1932+
coefficients = result[0]
1933+
vcov = result[2]
1934+
assert vcov is not None
1935+
1936+
n_leads = len(lead_cols)
1937+
gamma = coefficients[:n_leads]
1938+
V_gamma = vcov[:n_leads, :n_leads]
1939+
1940+
# Build per-horizon effects
1941+
effects = {}
1942+
for j, h in enumerate(pre_rel_times):
1943+
effect = float(gamma[j])
1944+
se = float(np.sqrt(max(V_gamma[j, j], 0.0)))
1945+
# n_obs from the lead indicator (respects balanced_cohorts restriction)
1946+
n_obs = int(df_0[f"_lead_{h}"].sum())
1947+
t_stat, p_value, conf_int = safe_inference(effect, se, alpha=alpha)
1948+
effects[h] = {
1949+
"effect": effect,
1950+
"se": se,
1951+
"t_stat": t_stat,
1952+
"p_value": p_value,
1953+
"conf_int": conf_int,
1954+
"n_obs": n_obs,
1955+
}
1956+
1957+
# Clean up temporary columns
1958+
for col in lead_cols:
1959+
df_0.drop(columns=col, inplace=True)
1960+
1961+
return effects, gamma, V_gamma
1962+
17541963
def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
17551964
"""
17561965
Run pre-trend test (Equation 9).
@@ -1782,7 +1991,6 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
17821991
df_0 = df.loc[omega_0_mask].copy()
17831992

17841993
# Compute relative time for untreated obs
1785-
# For not-yet-treated units in their pre-treatment periods
17861994
rel_time_0 = np.where(
17871995
~df_0["_never_treated"],
17881996
df_0[time] - df_0[first_treat],
@@ -1808,7 +2016,6 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
18082016
pre_rel_times = [h for h in pre_rel_times if h != ref]
18092017

18102018
if n_leads is not None:
1811-
# Take the n_leads periods closest to treatment
18122019
pre_rel_times = sorted(pre_rel_times, reverse=True)[:n_leads]
18132020
pre_rel_times = sorted(pre_rel_times)
18142021

@@ -1821,49 +2028,13 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
18212028
"lead_coefficients": {},
18222029
}
18232030

1824-
# Build lead indicators
1825-
lead_cols = []
1826-
for h in pre_rel_times:
1827-
col_name = f"_lead_{h}"
1828-
df_0[col_name] = ((rel_time_0 == h)).astype(float)
1829-
lead_cols.append(col_name)
1830-
1831-
# Within-transform via iterative demeaning (exact for unbalanced panels)
1832-
y_dm = self._iterative_demean(
1833-
df_0[outcome].values, df_0[unit].values, df_0[time].values, df_0.index
2031+
# Use shared lead coefficient computation
2032+
effects, gamma, V_gamma = self._compute_lead_coefficients(
2033+
df_0, outcome, unit, time, first_treat, covariates,
2034+
cluster_var, pre_rel_times, alpha=self.alpha,
18342035
)
18352036

1836-
all_x_cols = lead_cols[:]
1837-
if covariates:
1838-
all_x_cols.extend(covariates)
1839-
1840-
X_dm = np.column_stack(
1841-
[
1842-
self._iterative_demean(
1843-
df_0[col].values, df_0[unit].values, df_0[time].values, df_0.index
1844-
)
1845-
for col in all_x_cols
1846-
]
1847-
)
1848-
1849-
# OLS with cluster-robust SEs
1850-
cluster_ids = df_0[cluster_var].values
1851-
result = solve_ols(
1852-
X_dm,
1853-
y_dm,
1854-
cluster_ids=cluster_ids,
1855-
return_vcov=True,
1856-
rank_deficient_action=self.rank_deficient_action,
1857-
column_names=all_x_cols,
1858-
)
1859-
coefficients = result[0]
1860-
vcov = result[2]
1861-
assert vcov is not None
1862-
1863-
# Extract lead coefficients and their sub-VCV
1864-
n_leads_actual = len(lead_cols)
1865-
gamma = coefficients[:n_leads_actual]
1866-
V_gamma = vcov[:n_leads_actual, :n_leads_actual]
2037+
n_leads_actual = len(pre_rel_times)
18672038

18682039
# Wald F-test: F = (gamma' V^{-1} gamma) / n_leads
18692040
try:
@@ -1874,17 +2045,15 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
18742045
f_stat = np.nan
18752046

18762047
# P-value from F distribution
2048+
cluster_ids = df_0[cluster_var].values
18772049
if np.isfinite(f_stat) and f_stat >= 0:
18782050
n_clusters = len(np.unique(cluster_ids))
18792051
df_denom = max(n_clusters - 1, 1)
18802052
p_value = float(stats.f.sf(f_stat, n_leads_actual, df_denom))
18812053
else:
18822054
p_value = np.nan
18832055

1884-
# Store lead coefficients
1885-
lead_coefficients = {}
1886-
for j, h in enumerate(pre_rel_times):
1887-
lead_coefficients[h] = float(gamma[j])
2056+
lead_coefficients = {h: effects[h]["effect"] for h in pre_rel_times}
18882057

18892058
return {
18902059
"f_stat": f_stat,
@@ -1910,6 +2079,7 @@ def get_params(self) -> Dict[str, Any]:
19102079
"rank_deficient_action": self.rank_deficient_action,
19112080
"horizon_max": self.horizon_max,
19122081
"aux_partition": self.aux_partition,
2082+
"pretrends": self.pretrends,
19132083
}
19142084

19152085
def set_params(self, **params) -> "ImputationDiD":

0 commit comments

Comments
 (0)