Skip to content

Commit 54bea41

Browse files
authored
Merge pull request #216 from wenddymacro/feature/wooldridge-did
Feature/wooldridge did
2 parents 8262853 + 2102b3d commit 54bea41

16 files changed

Lines changed: 3862 additions & 31 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **WooldridgeDiD (ETWFE)** estimator — Extended Two-Way Fixed Effects from Wooldridge (2025, 2023). Supports OLS, logit, and Poisson QMLE paths with ASF-based ATT and delta-method SEs. Four aggregation types (simple, group, calendar, event) matching Stata `jwdid_estat`. Alias: `ETWFE`. (PR #216, thanks @wenddymacro)
1112
- **Survey real-data validation** (Phase 9) — 15 cross-validation tests against R's `survey` package using three real federal survey datasets:
1213
- **API** (R `survey` package): TSL variance with strata, FPC, subpopulations, covariates, and Fay's BRR replicates
1314
- **NHANES** (CDC/NCHS): TSL variance with strata + PSU + nest=TRUE, validating the ACA young adult coverage provision DiD

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Detailed guide: [`docs/llms-practitioner.txt`](docs/llms-practitioner.txt)
8484
- **Wild cluster bootstrap**: Valid inference with few clusters (<50) using Rademacher, Webb, or Mammen weights
8585
- **Panel data support**: Two-way fixed effects estimator for panel designs
8686
- **Multi-period analysis**: Event-study style DiD with period-specific treatment effects
87-
- **Staggered adoption**: Callaway-Sant'Anna (2021), Sun-Abraham (2021), Borusyak-Jaravel-Spiess (2024) imputation, Two-Stage DiD (Gardner 2022), Stacked DiD (Wing, Freedman & Hollingsworth 2024), and Efficient DiD (Chen, Sant'Anna & Xie 2025) estimators for heterogeneous treatment timing
87+
- **Staggered adoption**: Callaway-Sant'Anna (2021), Sun-Abraham (2021), Borusyak-Jaravel-Spiess (2024) imputation, Two-Stage DiD (Gardner 2022), Stacked DiD (Wing, Freedman & Hollingsworth 2024), Efficient DiD (Chen, Sant'Anna & Xie 2025), and Wooldridge ETWFE (2021/2023) estimators for heterogeneous treatment timing
8888
- **Triple Difference (DDD)**: Ortiz-Villavicencio & Sant'Anna (2025) estimators with proper covariate handling
8989
- **Synthetic DiD**: Combined DiD with synthetic control for improved robustness
9090
- **Triply Robust Panel (TROP)**: Factor-adjusted DiD with synthetic weights (Athey et al. 2025)
@@ -117,6 +117,7 @@ All estimators have short aliases for convenience:
117117
| `Stacked` | `StackedDiD` | Stacked DiD |
118118
| `Bacon` | `BaconDecomposition` | Goodman-Bacon decomposition |
119119
| `EDiD` | `EfficientDiD` | Efficient DiD |
120+
| `ETWFE` | `WooldridgeDiD` | Wooldridge ETWFE (2021/2023) |
120121

121122
`TROP` already uses its short canonical name and needs no alias.
122123

ROADMAP.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,7 @@ Implements local projections for dynamic treatment effects. Doesn't require spec
8383

8484
### Nonlinear DiD
8585

86-
For outcomes where linear models are inappropriate (binary, count, bounded).
87-
88-
- Logit/probit DiD for binary outcomes
89-
- Poisson DiD for count outcomes
90-
- Proper handling of incidence rate ratios and odds ratios
86+
Implemented in `WooldridgeDiD` (alias `ETWFE`) — OLS, Poisson QMLE, and logit paths with ASF-based ATT. See [Tutorial 16](docs/tutorials/16_wooldridge_etwfe.ipynb).
9187

9288
**Reference**: [Wooldridge (2023)](https://academic.oup.com/ectj/article/26/3/C31/7250479). *The Econometrics Journal*.
9389

TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ Deferred items from PR reviews that were not addressed before merge.
7777
| StaggeredTripleDifference: per-cohort group-effect SEs include WIF (conservative vs R's wif=NULL). Documented in REGISTRY. Could override mixin for exact R match. | `staggered_triple_diff.py` | #245 | Low |
7878
| HonestDiD Delta^RM: uses naive FLCI instead of paper's ARP conditional/hybrid confidence sets (Sections 3.2.1-3.2.2). ARP infrastructure exists but moment inequality transformation needs calibration. CIs are conservative (wider, valid coverage). | `honest_did.py` | #248 | Medium |
7979
| Replicate weight tests use Fay-like BRR perturbations (0.5/1.5), not true half-sample BRR. Add true BRR regressions per estimator family. Existing `test_survey_phase6.py` covers true BRR at the helper level. | `tests/test_replicate_weight_expansion.py` | #253 | Low |
80+
| WooldridgeDiD: QMLE sandwich uses `aweight` cluster-robust adjustment `(G/(G-1))*(n-1)/(n-k)` vs Stata's `G/(G-1)` only. Conservative (inflates SEs). Add `qmle` weight type if Stata golden values confirm material difference. | `wooldridge.py`, `linalg.py` | #216 | Medium |
81+
| WooldridgeDiD: aggregation weights use cell-level n_{g,t} counts. Paper (W2025 Eqs. 7.2-7.4) defines cohort-share weights. Add optional `weights="cohort_share"` parameter to `aggregate()`. | `wooldridge_results.py` | #216 | Medium |
82+
| WooldridgeDiD: canonical link requirement (W2023 Prop 3.1) not enforced — no warning if user applies wrong method to outcome type. Estimator is consistent regardless, but equivalence with imputation breaks. | `wooldridge.py` | #216 | Low |
83+
| WooldridgeDiD: Stata `jwdid` golden value tests — add R/Stata reference script and `TestReferenceValues` class. | `tests/test_wooldridge.py` | #216 | Medium |
8084

8185
#### Performance
8286

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Benchmark: WooldridgeDiD (ETWFE) Estimator (diff-diff WooldridgeDiD).
4+
5+
Validates OLS ETWFE ATT(g,t) against Callaway-Sant'Anna on mpdta data
6+
(Proposition 3.1 equivalence), and measures estimation timing.
7+
8+
Usage:
9+
python benchmark_wooldridge.py --data path/to/mpdta.csv --output path/to/results.json
10+
"""
11+
12+
import argparse
13+
import json
14+
import os
15+
import sys
16+
from pathlib import Path
17+
18+
# IMPORTANT: Parse --backend and set environment variable BEFORE importing diff_diff
19+
def _get_backend_from_args():
20+
"""Parse --backend argument without importing diff_diff."""
21+
parser = argparse.ArgumentParser(add_help=False)
22+
parser.add_argument("--backend", default="auto", choices=["auto", "python", "rust"])
23+
args, _ = parser.parse_known_args()
24+
return args.backend
25+
26+
_requested_backend = _get_backend_from_args()
27+
if _requested_backend in ("python", "rust"):
28+
os.environ["DIFF_DIFF_BACKEND"] = _requested_backend
29+
30+
# NOW import diff_diff and other dependencies (will see the env var)
31+
import pandas as pd
32+
33+
# Add parent to path for imports
34+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
35+
36+
from diff_diff import WooldridgeDiD, HAS_RUST_BACKEND
37+
from benchmarks.python.utils import Timer
38+
39+
40+
def parse_args():
41+
parser = argparse.ArgumentParser(
42+
description="Benchmark WooldridgeDiD (ETWFE) estimator"
43+
)
44+
parser.add_argument("--data", required=True, help="Path to input CSV data (mpdta format)")
45+
parser.add_argument("--output", required=True, help="Path to output JSON results")
46+
parser.add_argument(
47+
"--backend", default="auto", choices=["auto", "python", "rust"],
48+
help="Backend to use: auto (default), python (pure Python), rust (Rust backend)"
49+
)
50+
return parser.parse_args()
51+
52+
53+
def get_actual_backend() -> str:
54+
"""Return the actual backend being used based on HAS_RUST_BACKEND."""
55+
return "rust" if HAS_RUST_BACKEND else "python"
56+
57+
58+
def main():
59+
args = parse_args()
60+
61+
actual_backend = get_actual_backend()
62+
print(f"Using backend: {actual_backend}")
63+
64+
print(f"Loading data from: {args.data}")
65+
df = pd.read_csv(args.data)
66+
67+
# Run OLS ETWFE estimation
68+
print("Running WooldridgeDiD (OLS ETWFE) estimation...")
69+
est = WooldridgeDiD(method="ols", control_group="not_yet_treated")
70+
71+
with Timer() as estimation_timer:
72+
results = est.fit(
73+
df,
74+
outcome="lemp",
75+
unit="countyreal",
76+
time="year",
77+
cohort="first_treat",
78+
)
79+
80+
estimation_time = estimation_timer.elapsed
81+
82+
# Compute event study aggregation
83+
results.aggregate("event")
84+
total_time = estimation_timer.elapsed
85+
86+
# Store data info
87+
n_units = len(df["countyreal"].unique())
88+
n_periods = len(df["year"].unique())
89+
n_obs = len(df)
90+
91+
# Format ATT(g,t) effects
92+
gt_effects_out = []
93+
for (g, t), cell in sorted(results.group_time_effects.items()):
94+
gt_effects_out.append({
95+
"cohort": int(g),
96+
"time": int(t),
97+
"att": float(cell["att"]),
98+
"se": float(cell["se"]),
99+
})
100+
101+
# Format event study effects
102+
es_effects = []
103+
if results.event_study_effects:
104+
for rel_t, effect_data in sorted(results.event_study_effects.items()):
105+
es_effects.append({
106+
"event_time": int(rel_t),
107+
"att": float(effect_data["att"]),
108+
"se": float(effect_data["se"]),
109+
})
110+
111+
output = {
112+
"estimator": "diff_diff.WooldridgeDiD",
113+
"method": "ols",
114+
"control_group": "not_yet_treated",
115+
"backend": actual_backend,
116+
# Overall ATT
117+
"overall_att": float(results.overall_att),
118+
"overall_se": float(results.overall_se),
119+
# Group-time ATT(g,t)
120+
"group_time_effects": gt_effects_out,
121+
# Event study
122+
"event_study": es_effects,
123+
# Timing
124+
"timing": {
125+
"estimation_seconds": estimation_time,
126+
"total_seconds": total_time,
127+
},
128+
# Metadata
129+
"metadata": {
130+
"n_units": n_units,
131+
"n_periods": n_periods,
132+
"n_obs": n_obs,
133+
"n_cohorts": len(results.groups),
134+
},
135+
}
136+
137+
print(f"Writing results to: {args.output}")
138+
output_path = Path(args.output)
139+
output_path.parent.mkdir(parents=True, exist_ok=True)
140+
with open(output_path, "w") as f:
141+
json.dump(output, f, indent=2)
142+
143+
print(f"Overall ATT: {results.overall_att:.6f} (SE: {results.overall_se:.6f})")
144+
print(f"Completed in {total_time:.3f} seconds")
145+
return output
146+
147+
148+
if __name__ == "__main__":
149+
main()

diff_diff/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@
164164
TROPResults,
165165
trop,
166166
)
167+
from diff_diff.wooldridge import WooldridgeDiD
168+
from diff_diff.wooldridge_results import WooldridgeDiDResults
167169
from diff_diff.utils import (
168170
WildBootstrapResults,
169171
check_parallel_trends,
@@ -210,6 +212,7 @@
210212
Stacked = StackedDiD
211213
Bacon = BaconDecomposition
212214
EDiD = EfficientDiD
215+
ETWFE = WooldridgeDiD
213216

214217
__version__ = "2.8.4"
215218
__all__ = [
@@ -276,6 +279,10 @@
276279
"EfficientDiDResults",
277280
"EDiDBootstrapResults",
278281
"EDiD",
282+
# WooldridgeDiD (ETWFE)
283+
"WooldridgeDiD",
284+
"WooldridgeDiDResults",
285+
"ETWFE",
279286
# Visualization
280287
"plot_bacon",
281288
"plot_event_study",

0 commit comments

Comments
 (0)