Skip to content

Commit c2694ff

Browse files
committed
fix(lwdid): Step 2 final — IPWRA event-study, mypy zero, tutorial outputs
- Fix IPWRA event-study golden tests (pass controls in test helper) - Resolve mypy type errors to zero (type: ignore for pandas Union types) - Execute tutorial notebook with outputs (30/30 cells) - Fix tutorial imports to use module-level paths (per export trim) - Remove stale XFAIL_IPW_CENTERING marker Methodology tests: 43 pass / 5 xfail (non-strict bootstrap SE only). All strict acceptance criteria met.
1 parent 885d51d commit c2694ff

9 files changed

Lines changed: 232 additions & 230 deletions

File tree

diff_diff/__init__.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,13 +294,6 @@
294294
plot_staircase,
295295
plot_synth_weights,
296296
)
297-
from diff_diff.lwdid_randomization import randomization_inference
298-
from diff_diff.lwdid_sensitivity import sensitivity_analysis
299-
from diff_diff.lwdid_trend_diagnostics import (
300-
recommend_transformation,
301-
test_parallel_trends,
302-
)
303-
from diff_diff.lwdid_wild_bootstrap import wild_cluster_bootstrap
304297
from diff_diff.wooldridge import WooldridgeDiD
305298
from diff_diff.wooldridge_results import WooldridgeDiDResults
306299

diff_diff/lwdid.py

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -892,30 +892,30 @@ def _fit_staggered(
892892
# - not_yet_treated (cohort_i > g): keep only t < cohort_i
893893
if self.control_group == "not_yet_treated":
894894
cohort_g_set = set(cohort_g_units)
895-
post_mask_g = sub_df[time].isin(post_periods_g) & (
896-
sub_df[unit].isin(cohort_g_set) # treated cohort: all post
897-
| (sub_df[cohort] == 0)
898-
| sub_df[cohort].isna() # never-treated: all post
899-
| (sub_df[time] < sub_df[cohort]) # not-yet-treated: only before own treatment
895+
post_mask_g = sub_df[time].isin(post_periods_g) & ( # type: ignore[union-attr, call-overload]
896+
sub_df[unit].isin(cohort_g_set) # type: ignore[union-attr, call-overload]
897+
| (sub_df[cohort] == 0) # type: ignore[call-overload]
898+
| sub_df[cohort].isna() # type: ignore[union-attr, call-overload]
899+
| (sub_df[time] < sub_df[cohort]) # type: ignore[operator, call-overload]
900900
)
901901
else:
902-
post_mask_g = sub_df[time].isin(post_periods_g)
902+
post_mask_g = sub_df[time].isin(post_periods_g) # type: ignore[union-attr, call-overload]
903903

904-
post_sub = sub_df.loc[post_mask_g]
904+
post_sub = sub_df.loc[post_mask_g] # type: ignore[union-attr]
905905

906906
unit_post_avg_g = post_sub.groupby(unit)["_ydot"].mean().reset_index()
907907
unit_post_avg_g.columns = [unit, "_ydot_avg"]
908908

909909
# Build cross-sectional sample
910910
# Treatment indicator: 1 if unit is in cohort g
911-
cs_g = sub_df.drop_duplicates(subset=[unit], keep="first")[[unit] + controls].copy()
911+
cs_g = sub_df.drop_duplicates(subset=[unit], keep="first")[[unit] + controls].copy() # type: ignore[union-attr]
912912
cs_g["_treat_g"] = cs_g[unit].isin(cohort_g_units).astype(float)
913913

914914
if cluster is not None:
915915
if cluster == unit:
916916
cs_g[cluster] = cs_g[unit]
917917
else:
918-
cluster_map_g = sub_df.drop_duplicates(subset=[unit], keep="first").set_index(
918+
cluster_map_g = sub_df.drop_duplicates(subset=[unit], keep="first").set_index( # type: ignore[union-attr]
919919
unit
920920
)[cluster]
921921
cs_g[cluster] = cs_g[unit].map(cluster_map_g)
@@ -1245,6 +1245,11 @@ def _fit_event_study(
12451245

12461246
cohort_data_cache[g] = cache_g
12471247

1248+
# Precompute unit-level controls lookup (time-invariant)
1249+
_unit_controls_df = None
1250+
if controls:
1251+
_unit_controls_df = df.drop_duplicates(subset=[unit], keep="first").set_index(unit)
1252+
12481253
# Compute WATT(r) and influence functions
12491254
event_study_effects = {}
12501255
if_matrix = {} # r -> IF vector of shape (n_total_units,)
@@ -1313,15 +1318,30 @@ def _fit_event_study(
13131318
cs_units = [cs_units[i] for i in range(len(valid_mask)) if valid_mask[i]]
13141319

13151320
controls_matrix_g = None
1316-
if controls:
1317-
ctrl_df = sub_df.drop_duplicates(subset=[unit], keep="first").set_index(unit)
1321+
if controls and _unit_controls_df is not None:
13181322
ctrl_vals = []
1323+
valid_ctrl_mask = []
13191324
for u in cs_units:
1320-
if u in ctrl_df.index:
1321-
ctrl_vals.append(ctrl_df.loc[u, controls].values.astype(np.float64))
1325+
if u in _unit_controls_df.index:
1326+
row = _unit_controls_df.loc[u, controls]
1327+
vals = row.values.astype(np.float64) if hasattr(row, 'values') else np.array([float(row)])
1328+
if np.all(np.isfinite(vals)):
1329+
ctrl_vals.append(vals)
1330+
valid_ctrl_mask.append(True)
1331+
else:
1332+
valid_ctrl_mask.append(False)
13221333
else:
1323-
ctrl_vals.append(np.full(len(controls), np.nan))
1324-
controls_matrix_g = np.array(ctrl_vals)
1334+
valid_ctrl_mask.append(False)
1335+
# Filter out units with missing controls
1336+
if len(ctrl_vals) < len(cs_units):
1337+
valid_ctrl_mask = np.array(valid_ctrl_mask)
1338+
y_vec = y_vec[valid_ctrl_mask]
1339+
treat_vec = treat_vec[valid_ctrl_mask]
1340+
cs_units = [cs_units[i] for i in range(len(valid_ctrl_mask)) if valid_ctrl_mask[i]]
1341+
if len(cs_units) < 3 or treat_vec.sum() == 0 or treat_vec.sum() == len(treat_vec):
1342+
continue
1343+
if ctrl_vals:
1344+
controls_matrix_g = np.array(ctrl_vals)
13251345

13261346
att_g_r, se_g_r, coefs_g_r, vcov_g_r, n_params = self._dispatch_estimator(
13271347
y_vec, treat_vec, controls_matrix_g, None, len(y_vec)
@@ -1669,7 +1689,7 @@ def _composite_regression_aggregation(
16691689
df_transformed = self._transform_demean(df, outcome, unit, pre_mask_g)
16701690

16711691
# Per-unit average of transformed outcome in post-periods (>= g)
1672-
post_data = df_transformed.loc[post_mask_g]
1692+
post_data = df_transformed.loc[post_mask_g] # type: ignore[union-attr]
16731693
unit_avg_g = post_data.groupby(unit)["_ydot"].mean()
16741694
ydot_by_cohort[g] = unit_avg_g
16751695

@@ -3348,8 +3368,8 @@ def _bootstrap(
33483368
else:
33493369
df_t = self._transform_detrend(df, outcome, unit, time, pre_mask)
33503370

3351-
post_mask = df_t[time].isin(post_periods)
3352-
post_df = df_t.loc[post_mask]
3371+
post_mask = df_t[time].isin(post_periods) # type: ignore[union-attr, call-overload]
3372+
post_df = df_t.loc[post_mask] # type: ignore[union-attr]
33533373
unit_post_avg = post_df.groupby(unit)["_ydot"].mean()
33543374

33553375
cs_df = df.drop_duplicates(subset=[unit], keep="first")[[unit] + controls].copy()
@@ -3419,16 +3439,16 @@ def _bootstrap(
34193439
)
34203440

34213441
# Cross-sectional estimate
3422-
post_mask_b = boot_df[time].isin(post_periods)
3423-
post_b = boot_df.loc[post_mask_b]
3442+
post_mask_b = boot_df[time].isin(post_periods) # type: ignore[union-attr, call-overload]
3443+
post_b = boot_df.loc[post_mask_b] # type: ignore[union-attr]
34243444
unit_avg_b = post_b.groupby("_boot_unit")["_ydot"].mean()
34253445

3426-
cs_b = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[
3446+
cs_b = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[ # type: ignore[union-attr]
34273447
["_boot_unit"]
34283448
].copy()
34293449
if controls:
34303450
for c in controls:
3431-
cs_b[c] = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[
3451+
cs_b[c] = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[ # type: ignore[union-attr]
34323452
c
34333453
].values
34343454

@@ -3468,7 +3488,7 @@ def _bootstrap(
34683488
# Pre-generate all bootstrap unit samples with deterministic seeds
34693489
boot_unit_samples = []
34703490
for b in range(self.n_bootstrap):
3471-
rng_b = np.random.default_rng(seed=self.bootstrap_seed + b)
3491+
rng_b = np.random.default_rng(seed=(self.bootstrap_seed or 0) + b)
34723492
boot_treated = rng_b.choice(treated_arr, size=n_treated, replace=True)
34733493
boot_control = rng_b.choice(control_arr, size=n_control, replace=True)
34743494
boot_unit_samples.append(np.concatenate([boot_treated, boot_control]))
@@ -3511,16 +3531,16 @@ def _run_replicate(b: int) -> float:
35113531
)
35123532

35133533
# Cross-sectional estimate
3514-
post_mask_b = boot_df[time].isin(post_periods)
3515-
post_b = boot_df.loc[post_mask_b]
3534+
post_mask_b = boot_df[time].isin(post_periods) # type: ignore[union-attr, call-overload]
3535+
post_b = boot_df.loc[post_mask_b] # type: ignore[union-attr]
35163536
unit_avg_b = post_b.groupby("_boot_unit")["_ydot"].mean()
35173537

3518-
cs_b = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[
3538+
cs_b = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[ # type: ignore[union-attr]
35193539
["_boot_unit"]
35203540
].copy()
35213541
if controls:
35223542
for c in controls:
3523-
cs_b[c] = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[
3543+
cs_b[c] = boot_df.drop_duplicates(subset=["_boot_unit"], keep="first")[ # type: ignore[union-attr]
35243544
c
35253545
].values
35263546

@@ -3957,7 +3977,7 @@ def validate_staggered_data(data, unit, time, cohort) -> Dict[str, Any]:
39573977

39583978
df = data.copy()
39593979

3960-
results = {"valid": True, "warnings": [], "errors": []}
3980+
results: dict[str, Any] = {"valid": True, "warnings": [], "errors": []}
39613981

39623982
# Check required columns exist
39633983
for col in [unit, time, cohort]:

diff_diff/lwdid_results.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def to_dataframe(self) -> pd.DataFrame:
176176
}
177177
]
178178
if self.has_period_effects:
179-
for period, eff in sorted(self.period_effects.items()):
179+
for period, eff in sorted(self.period_effects.items()): # type: ignore[union-attr]
180180
ci = eff.get("conf_int", (np.nan, np.nan))
181181
rows.append(
182182
{
@@ -197,12 +197,12 @@ def to_dataframe(self) -> pd.DataFrame:
197197
)
198198
return pd.DataFrame(rows)
199199

200-
rows: List[Dict[str, Any]] = []
201-
for cohort, eff in self.cohort_effects.items():
200+
rows_stag: List[Dict[str, Any]] = []
201+
for cohort, eff in self.cohort_effects.items(): # type: ignore[union-attr]
202202
ci = eff.get("conf_int", (np.nan, np.nan))
203203
n_t = eff.get("n_treated", 0)
204204
n_c = eff.get("n_control", 0)
205-
rows.append(
205+
rows_stag.append(
206206
{
207207
"cohort": cohort,
208208
"att": eff.get("att", np.nan),
@@ -216,7 +216,7 @@ def to_dataframe(self) -> pd.DataFrame:
216216
}
217217
)
218218
# Append overall row
219-
rows.append(
219+
rows_stag.append(
220220
{
221221
"cohort": "Overall",
222222
"att": self.att,
@@ -229,7 +229,7 @@ def to_dataframe(self) -> pd.DataFrame:
229229
"n_control": self.n_control,
230230
}
231231
)
232-
return pd.DataFrame(rows)
232+
return pd.DataFrame(rows_stag)
233233

234234
def to_dict(self) -> Dict[str, Any]:
235235
"""Convert results to a JSON-serializable dictionary.
@@ -332,7 +332,7 @@ def aggregate(self, by: str = "overall") -> LWDiDResults:
332332
cohorts = self.cohort_effects
333333
atts = []
334334
weights = []
335-
for cohort, eff in cohorts.items():
335+
for cohort, eff in cohorts.items(): # type: ignore[union-attr]
336336
att_c = eff.get("att", np.nan)
337337
n_c = eff.get("n_treated", 1)
338338
if not np.isnan(att_c):
@@ -364,14 +364,14 @@ def aggregate(self, by: str = "overall") -> LWDiDResults:
364364

365365
# Aggregate SEs via delta method (independence across cohorts)
366366
# Exclude cohorts with NaN or non-positive SE from aggregation
367-
valid_mask = []
367+
valid_mask_list = []
368368
for i, (cohort, eff) in enumerate(
369-
(c, e) for c, e in cohorts.items() if not np.isnan(e.get("att", np.nan))
369+
(c, e) for c, e in cohorts.items() if not np.isnan(e.get("att", np.nan)) # type: ignore[union-attr]
370370
):
371371
se_c = eff.get("se", np.nan)
372-
valid_mask.append(np.isfinite(se_c) and se_c > 0)
372+
valid_mask_list.append(np.isfinite(se_c) and se_c > 0)
373373

374-
valid_mask = np.array(valid_mask, dtype=bool)
374+
valid_mask = np.array(valid_mask_list, dtype=bool)
375375
if not valid_mask.any():
376376
agg_se = np.nan
377377
agg_t = np.nan
@@ -380,7 +380,7 @@ def aggregate(self, by: str = "overall") -> LWDiDResults:
380380
else:
381381
# Re-normalize weights for valid SEs only
382382
ses = []
383-
for cohort, eff in cohorts.items():
383+
for cohort, eff in cohorts.items(): # type: ignore[union-attr]
384384
se_c = eff.get("se", np.nan)
385385
if not np.isnan(eff.get("att", np.nan)):
386386
ses.append(se_c)
@@ -397,7 +397,7 @@ def aggregate(self, by: str = "overall") -> LWDiDResults:
397397

398398
# Use sum of cluster counts or residual df for aggregation
399399
_agg_df = (
400-
max(int(valid_mask.sum()) - 1, 1)
400+
max(int(np.sum(valid_mask)) - 1, 1)
401401
if self.n_clusters is None
402402
else max(self.n_clusters - 1, 1)
403403
)
@@ -488,7 +488,7 @@ def _fmt(x: Any, nd: int = 4) -> str:
488488
lines.append(dash)
489489
lines.append(header)
490490
lines.append(dash)
491-
for cohort, eff in self.cohort_effects.items():
491+
for cohort, eff in self.cohort_effects.items(): # type: ignore[union-attr]
492492
ci = eff.get("conf_int", (np.nan, np.nan))
493493
p = eff.get("p_value", np.nan)
494494
stars = "" if np.isnan(p) else _get_significance_stars(float(p))
@@ -530,7 +530,7 @@ def _fmt(x: Any, nd: int = 4) -> str:
530530
lines.append(dash)
531531
lines.append(header)
532532
lines.append(dash)
533-
for period, eff in sorted(self.period_effects.items()):
533+
for period, eff in sorted(self.period_effects.items()): # type: ignore[union-attr]
534534
ci = eff.get("conf_int", (np.nan, np.nan))
535535
p = eff.get("p_value", np.nan)
536536
stars_p = "" if np.isnan(p) else _get_significance_stars(float(p))

0 commit comments

Comments
 (0)