feat: add LWDiD estimator (Lee & Wooldridge 2025, 2026) - #588
feat: add LWDiD estimator (Lee & Wooldridge 2025, 2026)#588gorgeousfish wants to merge 35 commits into
Conversation
9712477 to
8c5ccce
Compare
|
@gorgeousfish Thanks for this - it's a serious, well-prepared contribution, and the method is a great That said, before I do a full review, one thing to clear first: licensing. This implementation
|
|
Thanks for the quick response and the positive signal. On licensing: Yes, I hold the copyright to all code in this PR. It's my own independent implementation, not derived from Lee & Wooldridge's Stata lwdid package or any third-party source. I'm contributing it under diff-diff's MIT license. On datasets: smoking.csv: Abadie, Diamond & Hainmueller (2010), California tobacco control program. Publicly available, widely redistributed in academic packages. castle.csv: Cheng & Hoekstra (2013) / Cunningham (2021), Castle Doctrine laws. Publicly available. walmart.csv: county-level panel from Brown & Butts (2025, Journal of Econometrics), constructed from County Business Patterns (CBP) data. Publicly available government statistical data. |
|
@gorgeousfish Thanks for the licensing confirmation - that closes the question. Since then we've completed a full evaluation on our side: fresh reviews of both papers against their current SSRN revisions, an independent replication of your headline results, and a code review against the papers and this library's conventions. The short version: this is a strong contribution and we want to merge it. We reproduced your Prop 99 numbers ourselves (demeaning ATT -0.4222, SE 0.1208; detrending -0.2270, SE 0.0941, with the exact-inference p-value matching the paper), and the core is exactly right: the transformation reproduces our Getting it merged takes real work on both sides, so below is the full plan - split, sequenced, and complete (no surprise rounds later). None of it starts until you confirm you're on board. If any part looks wrong to you, push back and we'll discuss. First, one decision from you: the event studyThe current revision's Appendix D event study (placebo/dynamic WATT(r)) plus the Algorithm 1 influence-function multiplier bootstrap (simultaneous sup-t bands) is the one substantial piece of the papers not yet implemented - and it's also this estimator's proper pre-trends diagnostic. Your call:
Both options are genuinely fine with us. The planStep 0 - you, now. Reply confirming (a) you're good with this plan and (b) your event-study choice above. Nothing below starts until then. Step 1 - us. We open a maintainer PR to main with (a) our methodology notes for both papers, written against the current SSRN revisions (June 8 and February 3, 2026 - both papers were revised after you wrote your implementation notes, and the June revision reworked the event-study and inference appendices), and (b) Step 2 - you. Work the fix list below, flipping xfails as you go. One consequence of step 1 to fold into your work: our methodology notes supersede the two review docs currently in this PR - please drop those during the rebase and treat the maintainer versions as canonical (the file paths collide anyway). Step 3 - us. Wild-bootstrap dedupe into the existing The step 2 fix list
Thanks again - the core here is exactly right, which is the hard part. The rest is the normal cost of moving a standalone package into a library with strong invariants, and we're glad to carry our share of it. |
|
Thanks for the thorough plan - I'm on board. On the event study: Option A - I'd like to include the Appendix D event study (WATT(r) + Algorithm 1 multiplier bootstrap) in this PR so LWDiD ships as a standard estimator. Ready for Step 1 whenever you are. I'll start looking into the IPW centering issue in the meantime. |
…on rubric) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZK3FD9jxWGxBrPDw5APSg
…D REGISTRY entry + references (precursor to #588) Loaders download the MIT-licensed ancillary datasets of the authors' Stata lwdid package (SSC): pinned SHA-256 verification of every byte-load (HTTP-only host), stale-cache re-download, structural validation against source invariants, loud UserWarning + df.attrs['source'] marker on synthetic fallback, seeded local-RNG fallback constructors. REGISTRY.md gains the maintainer-authored LWDiD section (E.1 contributing-unit WATT weights; provenance pinned via reviewed-PDF SHA-256 + live-verified SSRN metadata). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZK3FD9jxWGxBrPDw5APSg
…il PR #588 lands) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZK3FD9jxWGxBrPDw5APSg
8c5ccce to
886b106
Compare
Maintainer rebase onto current main (igerber, 2026-07-17), per plan agreed in PR igerber#588: dropped committed datasets (tutorial now uses the checksummed load_prop99()/load_walmart() loaders on main), kept the maintainer-authored paper reviews and references entries from igerber#685, removed the lwdid dev dependency (external reference implementations stay environmental, importorskip-gated), renumbered tutorial 26 -> 27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@gorgeousfish - as promised, here is the detailed file:line review. Three pieces of news first:
What replicates today, verified first-hand on your rebased head: all three Prop 99 donor-pool tables (Table 3: demean -0.4222/0.1208, detrend -0.2270/0.0941; Table 4 Southern; Table A1 Midwestern), per-period effects, and the exact-t p-value (0.0209 vs the paper's printed 0.021). The transformation core is genuinely solid. The items below are ordered by how much they matter. 1. IPW influence function is un-centered (confirmed bug)
The control term is linear in raw Acceptance: 2. Staggered path: per-(g, r) cells + the composite-outcome regression (7.18)/(7.19)This is the deepest item, with two coupled halves. (a) Cell construction. (b) Aggregation and SE. Please also add a hand-built staggered test in your test files asserting period-specific control eligibility directly (e.g. a panel where one later cohort is a valid control at r = 3 but not at r = 5, with the cell samples asserted explicitly) - our golden-based suite catches the SE divergence but not the eligibility semantics on its own. Two sub-points discovered while calibrating:
Acceptance: 3. Event study (your Option A): Appendix D + Algorithm 1The API spec we agreed to is written as normative docstrings in Full per-period goldens for both Walmart outcomes are committed at Related guard: Acceptance: 4 4. Randomization inference convention (discussion item, not necessarily a bug)
5. Silent-failure sites (library policy: never drop/alter user data without a warning)
5b. Design validation gaps
Related: 6. Exceptions: fold into house style
7. API surface: trim top-level exportsKeep 8. Wild cluster bootstrap: we will fold this into house machinery (our take-on)FYI only: 9. Diagnostics triage (as agreed)
10. Typing and tests
Sequencing from here is as agreed in the plan comment: you work down this list on the rebased branch (small commits are easier for us to sync into the mirror for CI review rounds); the xfail suite tracks progress objectively; once it is green-with-no-xfails we do the final REGISTRY/experimental-surface pass and the merge mechanics on our side. The two of the seven step-2 items already taken off your plate: packaging (done in the rebase) and the wild-bootstrap dedupe (our step 3). |
|
I have opened a focused dependent PR for #733: gorgeousfish#1. It targets this branch and preserves the overall ATT and inference already computed by It also adds a slow strict-xfail bootstrap concordance test for the separate covariance-aware inference follow-up, #735. The default not-yet-treated identification defect in #734 remains the merge-blocking item; I have not attempted to fold that larger |
c2694ff to
10c900a
Compare
Maintainer rebase onto current main (igerber, 2026-07-17), per plan agreed in PR igerber#588: dropped committed datasets (tutorial now uses the checksummed load_prop99()/load_walmart() loaders on main), kept the maintainer-authored paper reviews and references entries from igerber#685, removed the lwdid dev dependency (external reference implementations stay environmental, importorskip-gated), renumbered tutorial 26 -> 27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This round addresses the four issues @shawcharles filed against this branch. Rebased onto Commits
#734 — staggered control eligibility (merge-blocking)The old path applied control eligibility as a unit-level filter and then averaged whatever calendar window each cohort happened to retain, so the cohort means were taken over different calendar periods and a common time trend entered the contrast directly. On a panel whose only signal is that trend it returned ATT ≈ 1.0 with SE ≈ 5e-16. Estimation now runs one cell per #735 — overall staggered standard errorCohort effects that share control units are correlated, so the previous The basis is now reported explicitly in
The two NaN cases fail closed rather than falling back to an independence assumption. #733 —
|
10c900a to
b58787d
Compare
Maintainer rebase onto current main (igerber, 2026-07-17), per plan agreed in PR igerber#588: dropped committed datasets (tutorial now uses the checksummed load_prop99()/load_walmart() loaders on main), kept the maintainer-authored paper reviews and references entries from igerber#685, removed the lwdid dev dependency (external reference implementations stay environmental, importorskip-gated), renumbered tutorial 26 -> 27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@igerber — first off, apologies for the wait; this round took longer than it Review checklist reconciliationStatus tags: Done (this round), Done earlier (7/30) (re-verified green on §1 IPW influence-function centering — Done earlier (7/30). Re-verified on §2 Staggered estimation — Done earlier (7/30). Cells are built per (g, t) §3 Event study — Point-estimate goldens (LW 2025 Appendix F, Tables A4/A5) §4 Randomization inference — The test validates against the paper value §5 Silent-failure warnings — Done earlier (7/30). All seven sites warn §5b Design-validation gaps — Done this round. A single vectorized §6 Exceptions — Done, with one caveat. All exception types are now plain §7 API surface — Done. Top-level exports are §8 Wild cluster bootstrap — Maintainer-side. Untouched per agreement; §9 Diagnostics triage — Done. Sensitivity is scoped down to T0-robustness §10 Tutorial and lint — Done. Tutorial 27 is fully re-executed with Rebase adaptation (new work outside the checklist)The v4 rebase required a few items your review predates: Test evidenceThe full lwdid suite plus the naming and docs-IA guards: 333 passed, Requests
|
|
I did a narrow pass over the new 1. Native time-scale staggered cohorts still fail in the estimator path
Minimal reproduction on the PR head: import pandas as pd
from diff_diff.lwdid import LWDiD
periods = pd.date_range("2020-01-01", periods=6, freq="YS")
rows = []
for unit, cohort in [("t1", periods[2]), ("t2", periods[2]), ("c1", pd.NaT), ("c2", pd.NaT)]:
for i, period in enumerate(periods):
rows.append({
"unit": unit,
"time": period,
"cohort": cohort,
"treat": int(pd.notna(cohort) and period >= cohort),
"y": float(i),
})
df = pd.DataFrame(rows)
LWDiD(rolling="demean", estimator="ra", control_group="never_treated").fit(
df, outcome="y", unit="unit", time="time", treatment="treat", first_treat="cohort"
)This raises Concrete fix: normalise the ordered time support once, map cohort/time labels to integer positions for design checks, event-time labels, reference-period exclusion, and not-yet-treated eligibility. Keep the original calendar labels in metadata if useful, but do not compare datetime/period cohorts to numeric 2. Public LWDiD examples are stale after the stricter
|
Full follow-up review of
|
Maintainer rebase onto current main (igerber, 2026-07-17), per plan agreed in PR igerber#588: dropped committed datasets (tutorial now uses the checksummed load_prop99()/load_walmart() loaders on main), kept the maintainer-authored paper reviews and references entries from igerber#685, removed the lwdid dev dependency (external reference implementations stay environmental, importorskip-gated), renumbered tutorial 26 -> 27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Fix IPW SE centering (translation-invariant influence function) - Implement composite regression (Eq 7.18/7.19) for staggered ATT - Add N_infinity >= 2 guard for never-treated controls - Implement event study (Appendix D): WATT(r) + Algorithm 1 sup-t bands - Fix randomization inference p-value convention (strict > comparison) - Add design validation (absorbing treatment, time-invariant cohort) - Migrate custom exceptions to ValueError - Trim top-level exports to LWDiD/LWDiDResults/LW Acceptance: 39 pass / 9 xfail (IPWRA event-study variants pending). Remaining xfails are IPWRA-specific event-study cases for Step 3.
- Execute all 30 code cells with full outputs (text + plots) - Add missing top-level exports to __init__.py: randomization_inference, wild_cluster_bootstrap, test_parallel_trends, sensitivity_analysis, recommend_transformation
…puts - 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.
…ication note REGISTRY.md records why the staggered replication goldens pass control_group='never_treated' explicitly. The implementation default is 'not_yet_treated', matching OVLS (eq. 4.10) as the text states, but the paper's printed staggered results are computed against the never-treated pool only. That is a sample-definition choice the text leaves to the analyst while the applications fix it, not a discrepancy in either direction; a default-pool fit gives different and equally valid estimates because the (g,t) cells draw on a strictly larger control sample. doc-deps.yaml adds diff_diff/lwdid_staggered.py to the lwdid group and gives it the same drift-risk and doc targets as lwdid.py, so the new module is not invisible to the drift check. The 27_lwdid tutorial is re-executed against the (g,t) path so its outputs match what the estimator now returns. Co-authored-by: Cursor <cursoragent@cursor.com>
…uard surfaces fit/get_transformation_diagnostics take first_treat (M-032 canonical, 12-estimator precedent) and covariates (M-034 majority spelling); LWDiDResults.wild_cluster_bootstrap/randomization_test take covariates. The inert fit-time aggregate= param is dropped: staggered fits already compute every cell jointly and aggregation is post-fit (M-020..M-027 direction), so an unreleased estimator should not ship a born-deprecated kwarg. Register LWDiD's rule-1 time surfaces, the overall_att/ period_effects result dicts and the canonical consumer files with the naming guard.
The direct EventStudyResults construction skipped the scalar-df provenance that results_base's _resolve_scalar_df_survey gives every shared-builder producer: LWDiD has no survey notion, so df_survey carries the bare df_inference. The other provenance fields stay None by design (no base_period regime, no anticipation window, ATT estimand, reference rows already carried in-band).
…ariance The classical branch of _ols_treatment_influence() returned sigma * basis, which reproduces the textbook homoskedastic SE within a single cell but carries no per-unit residual information. When fit_staggered() combined those contributions across cohort-time cells, every shared control unit produced a non-zero cross-cell product regardless of its outcome draw, fabricating correlation between cells and inflating the classical joint (cohort/overall/event-study) SE roughly two-fold against a unit-level bootstrap (0.146 vs 0.079 on the shared-control test panel). The contributions now keep the residual-based direction psi = basis * residuals, rescaled to the classical magnitude so a single cell still reproduces the textbook SE exactly. Common-timing classical SEs, the composite-regression staggered path, and all HC/cluster branches are unchanged; only staggered joint inference under vce='classical' moves, which is the point of the fix (0.146 -> 0.084, bootstrap 0.079). Flagged by shawcharles in the PR igerber#588 review.
With control_group='not_yet_treated' and no never-treated units, the
final-period cohort-time cells (e.g. (3, 5) and (5, 5) for cohorts {3, 5}
over T = 5) have an empty control pool under threshold = max(g, t), so
they were silently dropped and the reported estimand lost its latest
event times entirely. fit_staggered() now raises ValueError up front:
such designs need at least one never-treated unit (an explicit reference
cohort is not supported).
Cells with an empty pool on panels that do have never-treated units keep
the existing skip-and-warn behaviour; the issue igerber#734 trend-only fixtures
gain two never-treated units observed only through t = 4 so they still
exercise exactly that path.
Flagged by shawcharles in the PR igerber#588 review.
The staggered path read covariate values at each cell's calendar times, so editing post-treatment covariate values silently changed the ATT. LW staggered estimation is defined for unit-level covariates X_i (LW 2026 Sec. 7), matching the lwdid reference implementation, which validates time-invariance up front. Check every controls column for within-unit constancy before building cohort-time cells and raise a ValueError naming the offending column. The common-timing path is unchanged: it takes each unit's first row of covariates and is not exposed to post-treatment values, though it does not validate constancy either (out of scope for this fix).
Use the non-strict comparison |ATT*| >= |ATT_obs| in _compute_pvalue(), the standard 'at least as extreme' randomization-test convention under the Phipson-Smyth plus-one formula. A degenerate all-tie permutation distribution (e.g. a constant outcome) now yields p = 1.0 instead of the previous ~0.001 from the strict inequality.
Previously _fit_single_spec() swallowed every exception and _compute_sensitivity_ratio() mapped a NaN baseline or <=1 finite estimate to ratio 0.0, so entirely failed analyses were classified highly_robust. Now: - non-finite baseline ATT or fewer than two finite estimates yield a NaN ratio, classified as a new 'not_estimable' robustness level (surfaced with a SensitivityWarning in both public entry points) - _fit_single_spec() validates required columns up front and raises ValueError on missing columns instead of silently absorbing them; the except block now only wraps the fit itself The sensitivity API is retained (rather than removed as suggested in review) per the maintainers' July diagnostics triage decision.
_safe_lwdid_fit() never forwarded a first_treat column, so recommend_transformation(cohort=...) silently ignored the argument (any string, including nonexistent columns, gave identical results). Now: - _safe_lwdid_fit() accepts and forwards first_treat - _placebo_pre_trends() builds a pseudo-cohort column from the placebo onset so cohort-aware fits go through the staggered path - recommend_transformation() raises ValueError for a cohort column missing from the data, validated before the diagnostic try/except (DiagnosticError aliases ValueError and would swallow it) The joint-statistic independence assumption flagged in review is left unchanged for the maintainers to decide.
to_dict() leaked np.int64/np.float64/np.bool_ scalars, numpy array values, and numpy dict keys inside nested effect dicts, so json.dumps(result.to_dict()) raised TypeError. Add a recursive converter that maps numpy scalars to native Python types, ndarrays to lists, and converts dict keys as well; NaN/inf stay floats with their usual json.dumps semantics.
- Widen the Results-mapping table rules in the LWDiD docstring so 'result.cluster_var' / 'result.cluster_name' fit (sphinx -W failed with 'Malformed table'); verified with a full 'sphinx -W -b html' site build - Fix the class docstring staggered example: generate_staggered_data yields heterogeneous onsets, so fit() must receive first_treat= - docs/api/lwdid.rst: replace the wrong 'cohort=' keyword with the actual 'first_treat=' contract, pass first_treat= in every example that fits staggered data, drop the 'treated' re-derivation that mislabeled never-treated units, and define the 'state' cluster column the IPWRA example clusters on; all examples executed - Correct the lwdid-py parameter table (gvar maps to first_treat)
Staggered estimation compares cohorts against the never-treated sentinel 0 and builds event times as t - g, which raised TypeError for datetime64/Period time or first_treat columns (design validation, cohort discovery, eligibility comparisons, event-time construction). fit() and get_transformation_diagnostics() now detect date-like dtypes and re-encode both columns on the ordered time support as 1-based integer positions (NaT cohort -> sentinel 0; cohorts between observed periods -> next observed position; beyond the window -> T + 1, staying vacuously consistent). After estimation the cohort and calendar-time labels are mapped back to the user's original values; relative event times remain integer position differences. Mixed scales (one column date-like, one numeric) raise ValueError. Numeric panels bypass the encoding entirely, so their behavior and output labels are unchanged. The all-eventually-treated rejection and covariate-constancy checks operate on the encoded positions and keep working on datetime panels.
In the staggered engine the unit column is consumed by set_index, so looking up cluster as a regular column raised KeyError whenever the user passed cluster=<unit column> (the common by-unit clustering spelling). Read cluster ids from the index in that case, and avoid duplicating the column in the per-cell selection. Adds a regression test asserting cluster==unit matches an explicit copied cluster column exactly.
Pass aggregate="overall" to lwdid-py so both sides use the pooled cross-section regression basis; the default aggregate="cohort" combines per-cohort SEs assuming independence and understates the overall SE. Staggered SE assertions move to rtol=0.01 (0.05 for IPW-family) to absorb where the HC1 dof correction is applied.
_to_json_native/_json_native_key now convert datetime.date/datetime (incl. pd.Timestamp) and np.datetime64 to ISO-8601 strings, pd.Period to str (preserving frequency semantics), and pd.NaT/NaT-valued datetime64 to None, so json.dumps(result.to_dict()) works for datetime/Period staggered results as the docstring promises. Adds roundtrip regression tests.
Input Contract gains the all-eventually-treated rejection under not_yet_treated controls and the unit-constant staggered covariate requirement, plus notes on recommend_transformation cohort validation and JSON-native to_dict output. CHANGELOG condenses the review-round LWDiD fixes under Unreleased.
Appends one markdown sentence (via nbformat, markdown source only) clarifying that not_estimable means the sensitivity ratio could not be computed and robustness cannot be assessed.
b58787d to
556a5d6
Compare
|
@igerber @shawcharles — all items from the Aug 8 review rounds are now resolved on the branch, which has also been rebased onto latest main (post-#759, 54df914). Point-by-point below. High
Medium
Additional fixes found while validating
Contract disclosuresThe Input Contract in docs/api/lwdid.rst now lists five enforced requirements (adding the all-eventually-treated rejection and unit-constant covariates), plus notes on recommend_transformation validation and the JSON-native to_dict output. CHANGELOG has a consolidated entry for the round under Unreleased. Validation
Standing asksUnchanged from before: your decision on the Walmart SE goldens (§3 — agreed that xfail should not be the final representation), the RI p-value convention against Stata (§4 — degenerate all-tie case fixed above, convention choice still open), §8 wild-bootstrap integration stays untouched on my side, and when you get a chance: the ready-for-ci label plus a re-sync of mirror #690. |
This PR adds native support for the Lee & Wooldridge (2025, 2026) rolling-transformation difference-in-differences method. The approach works by applying unit-specific time-series transformations (demeaning or detrending) to panel outcomes before treatment, converting the panel DiD problem into a standard cross-sectional one. Once transformed, any treatment-effect estimator — regression adjustment, inverse probability weighting, doubly robust, or propensity-score matching — can be applied directly to the cross-section. The method handles both common-timing and staggered-adoption designs with flexible control-group selection.
The implementation lives entirely within
diff_diff/lwdid*.py(9 modules) and introduces zero new runtime dependencies — it reuses the existingsolve_ols,solve_logit, andsafe_inferenceinfrastructure. The IPW and IPWRA standard errors use the full semiparametric influence function with propensity-score and outcome-model correction terms, matching the variance formula in the authors' Stata package.Numerical correctness has been validated against the reference lwdid Python package across all supported configurations. The RA path achieves machine-precision agreement (≤1e-10), and IPW/IPWRA paths agree to within 1%. The California Proposition 99 results from Table 3 of LW (2026) are reproduced exactly: demeaning ATT = −0.422, detrending ATT = −0.227.
Beyond core estimation, the PR includes wild cluster bootstrap inference (Rademacher/Mammen/Webb), Fisher randomization inference, parallel-trends pre-testing, sensitivity analysis, clustering-level diagnostics, and visualization methods. A tutorial notebook walks through the full workflow on the papers' empirical datasets (California smoking data, Castle Doctrine laws, Walmart county-level entry).
Methodology references (required if estimator / math changes)
Validation
docs/tutorials/26_lwdid.ipynb) reproduces Tables 3–4 from LW (2026) on California Proposition 99 data. ATT values match published results to 0.04% precision.Security / privacy
Fixes #732
Fixes #733
Fixes #734
Fixes #735