Skip to content

Commit 93f8586

Browse files
igerberclaude
andcommitted
feat(estimators): iterative alternating-projection demeaning for N-way absorbed FE
N>1 absorbed fixed effects used single-pass sequential demeaning, which is the exact (weighted) Frisch-Waugh-Lovell residualization only on balanced orthogonal-FE panels; on unbalanced panels it was a biased approximation (coefficients off by ~1e-2 in tested cases). Add an N-way method-of-alternating-projections engine demean_by_groups() in utils.py; route the DiD/MultiPeriodDiD absorb= paths and the shared two-way within_transform() through it, fixing TWFE / SunAbraham / BaconDecomposition on unbalanced unweighted panels too. Lift the weighted-multi-absorb rejection (now supported via weighted MAP). Single-absorb and balanced-panel results are byte-stable; the weighted within_transform output is bit-identical; R-parity goldens unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4f1a0a3 commit 93f8586

9 files changed

Lines changed: 674 additions & 164 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
exit-event dynamics, R-package parity (PR-C2), and survey-design support remain follow-ups.
2121
Pure-Python validation covers the absorbing reduction, the re-entry mechanism, pre-trend
2222
placebos, non-negative weighting, stabilized-control admission, and DGP recovery.
23+
- **Weighted multiple absorbed fixed effects (`absorb=[a, b, ...]`) now supported in
24+
`DifferenceInDifferences` / `MultiPeriodDiD`.** The prior `ValueError` rejecting multi-absorb
25+
with survey weights is lifted: the absorb path now uses the method of alternating projections
26+
(`diff_diff.utils.demean_by_groups`), the exact weighted Frisch-Waugh-Lovell residualization for
27+
N > 1 dimensions. New `demean_by_groups()` N-way helper; the two-way `within_transform()` now
28+
delegates to it. Single-absorb and balanced-panel results are byte-stable (weighted
29+
`within_transform` output is bit-identical; balanced multi-way matches the prior closed-form
30+
demean to machine precision).
2331
- **`LPDiD` R-parity validation (absorbing).** `tests/test_methodology_lpdid.py` pins the
2432
estimator against the method authors' own R recipes (`danielegirardi/lpdid` event-study /
2533
reweight / premean / pooled `fixest::feols` specifications) with an `alexCardazzi/lpdid`
@@ -174,6 +182,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
174182
numerical, or public-API change.
175183

176184
### Fixed
185+
- **Unbalanced-panel correctness: N > 1 absorbed fixed effects now use iterative alternating
186+
projections instead of single-pass sequential demeaning.** Affected `DifferenceInDifferences` /
187+
`MultiPeriodDiD` with `absorb=[a, b, ...]` and the shared unweighted two-way `within_transform`
188+
(used by `TwoWayFixedEffects`, `SunAbraham`, `BaconDecomposition`). A single sequential demean
189+
sweep is the exact Frisch-Waugh-Lovell residualization only when the fixed-effect subspaces are
190+
orthogonal (balanced fully-crossed panels); on unbalanced panels it was a biased approximation
191+
(coefficients off by ~1e-2 in tested cases). The within transformation now iterates to
192+
convergence (`diff_diff.utils.demean_by_groups`), matching R `fixest` / `reghdfe` / `lfe`.
193+
Balanced-panel and single-absorb results are unchanged to machine precision; the unweighted
194+
two-way path now also emits the non-convergence `UserWarning` (previously only the weighted path
195+
could).
177196
- **Structural (non-covariate) matrix inverses are now rank-guarded.** The internal design-Gram
178197
bread inversions in `ContinuousDiD` (ACRT-variance `Psi'WPsi`), `TwoStageDiD` (Stage-2
179198
`X_2'WX_2`, both the analytical and multiplier-bootstrap surfaces), `SpilloverDiD` (Wave D

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ The `Origin` column (Actionable tables) and the `PR` column (Deferred tables) bo
3636
| `EfficientDiD` survey-weighted Silverman bandwidth in conditional Omega*`_silverman_bandwidth()` uses unweighted mean/std; survey-weighted statistics better reflect the population distribution (second-order refinement). | `efficient_did_covariates.py` || Quick | Low |
3737
| Survey sandwich SE is not exactly invariant to zero-weight (subpopulation / padded) rows: `_compute_stratified_psu_meat`'s finite-sample correction counts zero-weight units as PSUs, so padding shifts the SE ~2e-4 relative. Point estimate is exactly invariant. Fix: count only positive-weight PSUs in the correction (cross-cutting across all survey-enabled estimators). | `survey.py` (`_compute_stratified_psu_meat`) | PR-B | Mid | Low |
3838
| `ImputationDiD` LOO conservative-variance refinement (BJS 2024 Supp. Appendix A.9) — a finite-sample improvement to the auxiliary-model residuals reducing overfit of `tau_tilde_g` to `epsilon`. Asymptotic Theorem-3 variance is implemented and matches R `didimputation` (which also omits LOO by default). | `imputation.py` | imputation-validation | Mid | Low |
39-
| Multi-absorb weighted demeaning needs iterative alternating projections for `N > 1` absorbed FE with survey weights; unweighted multi-absorb also uses single-pass (exact only for balanced panels). | `estimators.py` | #218 | Heavy | Medium |
4039
| `TwoWayFixedEffects(vcov_type in {hc2, hc2_bm})` with replicate-weight designs raises `NotImplementedError` (`twfe.py:~233`). The replicate path re-demeans per replicate, which doesn't compose with the full-dummy HC2/HC2-BM build — a correct impl needs per-replicate full-dummy refit. Workaround: `hc1` for replicate-weight CR1. | `twfe.py::fit` | follow-up | Heavy | Low |
4140
| TWFE's HC2/HC2-BM inline full-dummy build (`twfe.py:280-315`) duplicates the dummy-construction logic in `DifferenceInDifferences(fixed_effects=...)` (`estimators.py:478-486`). Extract a shared helper, or delegate TWFE's HC2/HC2-BM path to DiD's `fixed_effects=` branch (with TWFE-specific cluster-default threading), to reduce drift risk on FE naming / survey behavior / result-surface conventions. Substantive refactor — touches both estimators. | `twfe.py::fit`, `estimators.py::DifferenceInDifferences.fit` | follow-up | Heavy | Low |
4241
| Decide whether to formally deprecate `CallawaySantAnna.cluster=X` in favor of `survey_design=SurveyDesign(psu=X)` (the bare-cluster path already synthesizes a minimal SurveyDesign). Two equivalent paths = redundant surface. Mirrors the question for ImputationDiD / EfficientDiD / TwoStageDiD. | `staggered.py` | follow-up | Mid | Low |

diff_diff/estimators.py

Lines changed: 31 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect
2929
from diff_diff.utils import (
3030
WildBootstrapResults,
31-
demean_by_group,
31+
demean_by_groups,
3232
fe_dummy_names,
3333
safe_inference,
3434
validate_binary,
@@ -414,17 +414,9 @@ def fit(
414414
absorbed_vars = []
415415
n_absorbed_effects = 0
416416

417-
# Reject multi-absorb with survey weights (single-pass demeaning is
418-
# not the correct weighted FWL projection for N > 1 dimensions). Only
419-
# fires when absorb is still set — i.e., the auto-route above didn't
420-
# consume it.
421-
if absorb and len(absorb) > 1 and survey_weights is not None:
422-
raise ValueError(
423-
f"Multiple absorbed fixed effects (absorb={absorb}) with survey "
424-
"weights is not supported. Single-pass sequential demeaning is not "
425-
"the correct weighted FWL projection for multiple absorbed dimensions. "
426-
"Use absorb with a single variable, or use fixed_effects= instead."
427-
)
417+
# Weighted multiple absorbed FE is supported: the absorb path below uses
418+
# iterative alternating projections (demean_by_groups), the exact weighted
419+
# FWL projection for N > 1 dimensions on both balanced and unbalanced panels.
428420

429421
# Validate vcov_type="conley" wire-up. DiD.fit() accepts `unit`
430422
# as a fit-time arg (NOT on __init__) because cluster/unit
@@ -462,16 +454,18 @@ def fit(
462454
float
463455
) * working_data[time].values.astype(float)
464456
vars_to_demean = [outcome, treatment, time, "_treat_time"] + (covariates or [])
465-
for ab_var in absorb:
466-
working_data, n_fe = demean_by_group(
467-
working_data,
468-
vars_to_demean,
469-
ab_var,
470-
inplace=True,
471-
weights=survey_weights,
472-
)
473-
n_absorbed_effects += n_fe
474-
absorbed_vars.append(ab_var)
457+
# Method of alternating projections: for N > 1 absorbed dimensions a
458+
# single sequential sweep is only exact on balanced (orthogonal-FE)
459+
# panels; demean_by_groups iterates to the exact (W)LS-FWL residual.
460+
working_data, n_fe = demean_by_groups(
461+
working_data,
462+
vars_to_demean,
463+
list(absorb),
464+
inplace=True,
465+
weights=survey_weights,
466+
)
467+
n_absorbed_effects += n_fe
468+
absorbed_vars = list(absorb)
475469

476470
# Extract variables (may be demeaned if absorb was used)
477471
y = working_data[outcome].values.astype(float)
@@ -644,8 +638,7 @@ def _refit_did_absorb(w_r):
644638
float
645639
)
646640
vars_dm = [outcome, treatment, time, "_treat_time"] + (covariates or [])
647-
for ab_var in _absorb_list:
648-
wd, _ = demean_by_group(wd, vars_dm, ab_var, inplace=True, weights=w_nz)
641+
wd, _ = demean_by_groups(wd, vars_dm, _absorb_list, inplace=True, weights=w_nz)
649642
y_r = wd[outcome].values.astype(float)
650643
d_r = wd[treatment].values.astype(float)
651644
t_r = wd[time].values.astype(float)
@@ -1572,17 +1565,9 @@ def fit( # type: ignore[override]
15721565
absorb = None
15731566
n_absorbed_effects = 0
15741567

1575-
# Reject multi-absorb with survey weights (single-pass demeaning is
1576-
# not the correct weighted FWL projection for N > 1 dimensions).
1577-
# Only fires when absorb is still set — i.e., the auto-route above
1578-
# didn't consume it.
1579-
if absorb and len(absorb) > 1 and survey_weights is not None:
1580-
raise ValueError(
1581-
f"Multiple absorbed fixed effects (absorb={absorb}) with survey "
1582-
"weights is not supported. Single-pass sequential demeaning is not "
1583-
"the correct weighted FWL projection for multiple absorbed dimensions. "
1584-
"Use absorb with a single variable, or use fixed_effects= instead."
1585-
)
1568+
# Weighted multiple absorbed FE is supported: the absorb path below uses
1569+
# iterative alternating projections (demean_by_groups), the exact weighted
1570+
# FWL projection for N > 1 dimensions on both balanced and unbalanced panels.
15861571

15871572
# MultiPeriodDiD is intrinsically a multi-period panel estimator;
15881573
# Phase 2 panel block-decomposed Conley (matches R conleyreg) needs
@@ -1622,15 +1607,16 @@ def fit( # type: ignore[override]
16221607
+ [f"_did_interact_{p}" for p in non_ref_periods]
16231608
+ (covariates or [])
16241609
)
1625-
for ab_var in absorb:
1626-
working_data, n_fe = demean_by_group(
1627-
working_data,
1628-
vars_to_demean,
1629-
ab_var,
1630-
inplace=True,
1631-
weights=survey_weights,
1632-
)
1633-
n_absorbed_effects += n_fe
1610+
# Method of alternating projections (exact for unbalanced panels; a
1611+
# single sequential sweep is exact only on balanced orthogonal-FE panels).
1612+
working_data, n_fe = demean_by_groups(
1613+
working_data,
1614+
vars_to_demean,
1615+
list(absorb),
1616+
inplace=True,
1617+
weights=survey_weights,
1618+
)
1619+
n_absorbed_effects += n_fe
16341620

16351621
# Extract outcome and treatment (may be demeaned if absorb was used)
16361622
y = working_data[outcome].values.astype(float)
@@ -1854,8 +1840,7 @@ def _refit_mp_absorb(w_r):
18541840
+ [f"_did_interact_{p}" for p in non_ref_periods]
18551841
+ (covariates or [])
18561842
)
1857-
for ab_var_ in _absorb_list_mp:
1858-
wd, _ = demean_by_group(wd, vars_dm_, ab_var_, inplace=True, weights=w_nz)
1843+
wd, _ = demean_by_groups(wd, vars_dm_, _absorb_list_mp, inplace=True, weights=w_nz)
18591844
y_r = wd[outcome].values.astype(float)
18601845
d_r = wd["_did_treatment"].values.astype(float)
18611846
X_r = np.column_stack([np.ones(len(y_r)), d_r])

0 commit comments

Comments
 (0)