Skip to content

Commit c78b68f

Browse files
fix(did): gardner_did event-study reference-category contamination
The Stage-2 dummy regression pooled all non-event rows (never-treated + treated units outside the event-study horizon) into a single baseline category, dragging every event-time coefficient toward the mean of that pool. On a synthetic panel with true τ=2 and strict parallel trends, this produced: before: pre-trends ≈ -0.30 (should be 0), post ≈ +1.72 (should be 2.0) after: pre-trends ≈ +0.01 (≈0), post ≈ +2.02 (≈2.0) The fix replaces the Stage-2 dummy regression in event-study mode with a direct Borusyak-Jaravel-Spiess-style within-(cohort × relative-time) averaging of the imputed gap ỹ = Y - Ŷ(0). The non-event-study path (single ATT) already used the correct regression and is unchanged. Cluster-robust SEs in event-study mode now use the standard cluster sum-of-residuals formula on each bin separately. Also tightens test_gardner_event_study_support to require np.mean(post) > 1.6 and |pre_trend| < 0.3 (previously 1.0 / 0.8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 14056b6 commit c78b68f

2 files changed

Lines changed: 55 additions & 23 deletions

File tree

src/statspai/did/gardner_2s.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,16 @@ def gardner_did(
220220
y_hat_0 = A_full @ coefs
221221
y_tilde = y_all_arr - y_hat_0
222222

223-
# ── Stage 2: regress residualised outcome on treatment ──────── #
223+
# ── Stage 2: recover treatment effects from the imputed gap ──── #
224+
# Overall ATT: clustered OLS of ỹ on the treatment indicator, with
225+
# an intercept to absorb any mean residual in the untreated rows.
226+
# Event study: direct within-(cohort × relative-time) averaging of ỹ
227+
# — the Borusyak-Jaravel-Spiess style — to avoid the reference-
228+
# category contamination bias that a Stage-2 dummy regression would
229+
# introduce (the "baseline" in a dummy regression lumps never-treated
230+
# units together with treated units outside the event-study horizon,
231+
# pulling every coefficient toward the residual mean).
232+
cl = df[cluster].to_numpy()
224233
if event_study:
225234
rel_time = np.where(
226235
np.isfinite(ft) & (ft > 0),
@@ -234,28 +243,51 @@ def gardner_did(
234243
horizon.append(0)
235244
horizon = sorted(set(horizon))
236245

237-
reg_cols = {}
246+
names, est_list, se_list = [], [], []
238247
for k in horizon:
239-
reg_cols[f"D_k{int(k):+d}"] = (rel_time == k).astype(float)
240-
X2 = np.column_stack(list(reg_cols.values()))
241-
names = list(reg_cols.keys())
248+
key = f"D_k{int(k):+d}"
249+
names.append(key)
250+
mask = rel_time == k
251+
if mask.sum() == 0:
252+
est_list.append(float("nan"))
253+
se_list.append(float("nan"))
254+
continue
255+
y_k = y_tilde[mask]
256+
coef_k = float(np.mean(y_k))
257+
# Cluster-robust SE of the within-bin mean
258+
cl_k = cl[mask]
259+
uniq = np.unique(cl_k)
260+
G = len(uniq)
261+
if G > 1:
262+
group_means = np.array([y_k[cl_k == g].mean() for g in uniq])
263+
# SE of the unweighted mean over n rows, allowing cluster
264+
# correlation: Var(mean) ≈ (1/n²) Σ_g (Σ_{i∈g} (y_ki - coef))²
265+
sq = 0.0
266+
for g in uniq:
267+
idx = cl_k == g
268+
sq += float(np.sum(y_k[idx] - coef_k)) ** 2
269+
var_k = sq / (len(y_k) ** 2)
270+
se_k = float(np.sqrt(max(var_k, 0.0)))
271+
else:
272+
se_k = float(np.std(y_k, ddof=1) / np.sqrt(len(y_k)))
273+
est_list.append(coef_k)
274+
se_list.append(se_k)
275+
est = np.array(est_list)
276+
se = np.array(se_list)
277+
coef_dict = dict(zip(names, est))
278+
se_dict = dict(zip(names, se))
242279
else:
243280
X2 = df["_D"].to_numpy(dtype=float).reshape(-1, 1)
244281
names = ["ATT"]
245-
246-
# Stage-2 includes intercept (absorbs any post-residualisation mean shift)
247-
design2 = np.column_stack([np.ones(len(y_tilde)), X2])
248-
cl = df[cluster].to_numpy()
249-
250-
coef2, *_ = np.linalg.lstsq(design2, y_tilde, rcond=None)
251-
resid2 = y_tilde - design2 @ coef2
252-
V = _cluster_vcov(design2, resid2, cl)
253-
se_full = np.sqrt(np.clip(np.diag(V), 0, None))
254-
# Slice off intercept
255-
est = coef2[1:]
256-
se = se_full[1:]
257-
coef_dict = dict(zip(names, est))
258-
se_dict = dict(zip(names, se))
282+
design2 = np.column_stack([np.ones(len(y_tilde)), X2])
283+
coef2, *_ = np.linalg.lstsq(design2, y_tilde, rcond=None)
284+
resid2 = y_tilde - design2 @ coef2
285+
V = _cluster_vcov(design2, resid2, cl)
286+
se_full = np.sqrt(np.clip(np.diag(V), 0, None))
287+
est = coef2[1:]
288+
se = se_full[1:]
289+
coef_dict = dict(zip(names, est))
290+
se_dict = dict(zip(names, se))
259291

260292
z = sp_stats.norm.ppf(1 - alpha / 2)
261293
ci = {

tests/test_gardner_2s.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ def test_gardner_event_study_support():
5353
assert set(es["horizon"]) == {"D_k-2", "D_k-1", "D_k+0", "D_k+1", "D_k+2"}
5454
# Post-treatment coefs should be ≈ 2.0, pre-treatment ≈ 0
5555
post = [es["coef"][k] for k in ("D_k+0", "D_k+1", "D_k+2")]
56-
# Event-study coefs may shift because Stage-1 fit depends on which cohorts
57-
# are still untreated at each horizon. Demand only the right sign + scale.
58-
assert np.mean(post) > 1.0
56+
# After the v1.5.1 reference-category fix, pre-trend should be ~0 and
57+
# post-treatment should closely track the truth.
58+
assert np.mean(post) > 1.6
5959
for k in ("D_k-2", "D_k-1"):
60-
assert abs(es["coef"][k]) < 0.8 # pre-trend small relative to ATT
60+
assert abs(es["coef"][k]) < 0.3 # pre-trend tight relative to ATT
6161

6262

6363
def test_gardner_alias_did_2stage():

0 commit comments

Comments
 (0)