Skip to content

Commit bcd00f6

Browse files
authored
perf(imputation,two_stage): route demeaning through shared MAP engine + fix zero-weight replicate bug (#615)
1 parent 0faa01b commit bcd00f6

11 files changed

Lines changed: 739 additions & 342 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
`survey_design=SurveyDesign(psu=<cluster_col>)`. No behavior change for unclustered fits.
2121

2222
### Fixed
23+
- **ImputationDiD/TwoStageDiD covariate fits with zero-weight replicate designs (JK1/plain
24+
BRR) now produce finite SEs.** Replicate weights that zero out whole PSUs reach Step 1
25+
unmasked; the previous per-estimator pandas demeaning loops divided 0/0 on zero-total-weight
26+
groups, NaN-poisoning the demeaned design so EVERY replicate refit failed inside
27+
`solve_ols` ("All replicate refits failed. Returning NaN variance." after a
28+
non-convergence warning storm — measured: 198 warnings and SE=NaN on a 350k-row panel
29+
with 16 JK1 replicates, now 0 warnings and a finite SE). A main fit combining zero-weight
30+
rows with covariates previously raised an opaque `ValueError`; it now fits, with the
31+
zero-weight unit's FE surfacing as NaN (spillover convention — keys retained for the
32+
rank-condition check, never a silent finite 0.0) and its unidentified cells NaN across
33+
all inference fields. TwoStageDiD's per-replicate "non-finite imputed outcomes" warning
34+
is suppressed inside replicate-refit closures only (`warn_nan=False`); the main-fit
35+
warning is unchanged.
2336
- **Dube, Girardi, Jordà & Taylor (2025) citation corrected to *J. Applied Econometrics*
2437
40(**7**):741-758** (was cited as issue 5) across `docs/references.rst`,
2538
`docs/methodology/REGISTRY.md`, `docs/methodology/papers/dube-2025-review.md`,
@@ -29,6 +42,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2942
original attribution as a record of what was claimed at release time; this entry
3043
supersedes it.
3144

45+
### Changed
46+
- **ImputationDiD/TwoStageDiD demeaning modernized onto the shared MAP engine.** The
47+
private per-estimator pandas `_iterative_demean` loops (rebuilt a
48+
`pd.Series.groupby().transform()` hash table every alternating-projection iteration)
49+
are deleted; the covariate and pre-trend-lead within-transformations now route through
50+
`demean_by_groups` (factorize-once + `np.bincount`, optional Rust kernel, one dispatch
51+
for all columns), and both `_iterative_fe` FE solvers route through a new shared
52+
bincount Gauss-Seidel helper (`diff_diff.utils._iterative_fe_solve`, modeled on
53+
SpilloverDiD's). `max_iter` modernized 100 → 10,000 (the R `fixest`/`pyfixest`
54+
convention already used by the shared engine). Estimates are preserved: measured ATT
55+
deltas ≤ 2e-15 and SE deltas ≤ 2e-12 relative across a 5-scenario before/after grid
56+
(2.35M-row panels, R-parity suites unchanged at 1e-6/1e-7). Measured speedups (median
57+
of 3): ImputationDiD covariate fit 4.18s → 1.72s (2.4x) and no-covariate 1.40x at
58+
2.35M rows; replicate-weight survey variance 20.3s → 3.6s (5.7x, 32 replicates,
59+
350k rows) and 30.3s → 1.8s on zeroed-PSU designs (17x — the old loop burned
60+
`max_iter` futile iterations per replicate). TwoStageDiD fit time unchanged
61+
(GMM-variance-dominated). Accumulation-order numerics documented in
62+
`docs/methodology/REGISTRY.md` (both estimator sections + "Absorbed Fixed Effects").
63+
3264
## [3.6.2] - 2026-07-03
3365

3466
### Added

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
5151
| `SpilloverDiD` sparse cKDTree path for the staggered nearest-treated-distance helper (mirrors the static helper's sparse branch). `_compute_nearest_treated_distance_staggered` always builds dense `(n_units, n_treated_by_onset)` matrices per cohort; add a sparse branch gated on `n > _CONLEY_SPARSE_N_THRESHOLD`. | `spillover.py` | Wave B | Mid | Low |
5252
| `HeterogeneousAdoptionDiD` Phase 3 Stute: Appendix-D vectorized form replaces the per-iteration OLS refit with a single precomputed `M = I - X(X'X)^{-1}X'` applied to `eps*eta` (~2× faster, functionally identical). Shipped the literal-refit form to match paper text. | `had_pretests.py::stute_test` | Phase 3 | Mid | Low |
5353
| Multiplier-bootstrap weight chunking (CallawaySantAnna, EfficientDiD, and HAD — all wired through `diff_diff/bootstrap_chunking.py`) covers the **unstratified** survey-PSU generation (the default unit-level bootstrap — `cluster=None`, equivalently `cluster="unit"` — the large-`n_units` OOM case). Remaining gap: the **stratified** survey-PSU generator (`generate_survey_multiplier_weights_batch`, per-stratum + lonely-PSU pooling + FPC) still materializes the full `(n_bootstrap × n_psu)` matrix (consumed via sliced blocks). Stratified designs have few PSUs so this rarely OOMs; tile per-stratum generation over draws (each stratum's draws are independent → contiguous draw-blocks reproduce the stream bit-identically) if a large-PSU stratified design hits memory. | `diff_diff/bootstrap_chunking.py::iter_survey_multiplier_weight_blocks` | follow-up | Mid | Low |
54-
| `ImputationDiD._iterative_demean` (`imputation.py:1064`) and its near-identical twin `TwoStageDiD._iterative_demean` (`two_stage.py:2201`) rebuild a `pd.Series`+`groupby().transform()` every alternating-projection iteration. Precompute the unit/time group codes once and use `np.bincount` for the per-iteration group means. **Not bit-identical** on pandas 3.0 (`np.bincount` is naive accumulation; pandas' `group_mean` is Kahan-compensated → ~5.8e-11, the same order as the demean's `tol=1e-10`), so this needs a tolerance/REGISTRY-Note treatment + golden re-validation. Modest peak effect (the per-iteration Series are transient), analytical-path-only (the bootstrap reuses #562's cached projection). Optimize both twins together (cross-surface-twins). | `imputation.py`, `two_stage.py` | PR-C deferral | Mid | Low |
54+
| Migrate `spillover._iterative_fe_subset` onto the shared `diff_diff.utils._iterative_fe_solve` (same Gauss-Seidel-on-codes recursion, specialized to a masked Butts subsample; ImputationDiD/TwoStageDiD already route through the shared helper — this takes the FE-solver copy count from 2 to 1). Preserve the SpilloverDiD positive-weight/NaN-FE REGISTRY contract and `_FE_ITER_MAX=100` budget (or align it to 10k with a REGISTRY note). | `spillover.py` | demean-modernization | Mid | Low |
55+
| Adopt `snap_absorbed_regressors` (FE-spanned regressor two-stage snap + LSMR confirmation) on the ImputationDiD lead-indicator path — lead columns are the most plausible FE-spanned regressors, and the truncated-MAP-iterate exposure documented at `utils.py` applies; today only `solve_ols` rank detection guards it. Behavior change beyond the demean-modernization refactor, so deferred from that PR. | `imputation.py::_compute_lead_coefficients` | demean-modernization | Mid | Low |
5556

5657
### Testing / docs
5758

diff_diff/imputation.py

Lines changed: 66 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
ImputationDiDResults,
3838
)
3939
from diff_diff.linalg import solve_ols
40-
from diff_diff.utils import safe_inference, warn_if_not_converged
40+
from diff_diff.utils import _iterative_fe_solve, demean_by_groups, safe_inference
4141

4242

4343
class _UntreatedProjection(NamedTuple):
@@ -983,22 +983,28 @@ def _iterative_fe(
983983
unit_vals: np.ndarray,
984984
time_vals: np.ndarray,
985985
idx: pd.Index,
986-
max_iter: int = 100,
986+
max_iter: int = 10_000,
987987
tol: float = 1e-10,
988988
weights: Optional[np.ndarray] = None,
989989
) -> Tuple[Dict[Any, float], Dict[Any, float]]:
990990
"""
991991
Estimate unit and time FE via iterative alternating projection (Gauss-Seidel).
992992
993-
Converges to the exact OLS solution for both balanced and unbalanced panels.
994-
For balanced panels, converges in 1-2 iterations (identical to one-pass).
995-
For unbalanced panels, typically 5-20 iterations.
993+
Thin wrapper over the shared bincount solver
994+
(``diff_diff.utils._iterative_fe_solve``): factorize unit/time once,
995+
solve on integer codes, map the level arrays back to dicts.
996+
Converges to the exact (W)LS solution for balanced and unbalanced
997+
panels; balanced panels converge in 1-2 iterations.
996998
997999
Parameters
9981000
----------
1001+
idx : pd.Index
1002+
Unused; retained for call-site stability.
9991003
weights : np.ndarray, optional
1000-
Survey weights. When provided, uses weighted group means
1001-
(sum(w*x)/sum(w)) instead of unweighted means.
1004+
Survey weights (weighted group means ``sum(w*x)/sum(w)``). A
1005+
unit/period whose observations ALL carry zero weight has no
1006+
identifying contribution and gets ``NaN`` FE (its key is kept so
1007+
the rank-condition membership check still sees the group).
10021008
10031009
Returns
10041010
-------
@@ -1007,118 +1013,28 @@ def _iterative_fe(
10071013
time_fe : dict
10081014
Mapping from time -> time fixed effect.
10091015
"""
1010-
n = len(y)
1011-
alpha = np.zeros(n) # unit FE broadcast to obs level
1012-
beta = np.zeros(n) # time FE broadcast to obs level
1013-
1014-
# Precompute per-group weight sums (invariant across iterations)
1015-
if weights is not None:
1016-
w_series = pd.Series(weights, index=idx)
1017-
wsum_t = w_series.groupby(time_vals).transform("sum").values
1018-
wsum_u = w_series.groupby(unit_vals).transform("sum").values
1019-
1020-
converged = False
1021-
with np.errstate(invalid="ignore", divide="ignore"):
1022-
for iteration in range(max_iter):
1023-
resid_after_alpha = y - alpha
1024-
if weights is not None:
1025-
wr_t = pd.Series(resid_after_alpha * weights, index=idx)
1026-
beta_new = wr_t.groupby(time_vals).transform("sum").values / wsum_t
1027-
else:
1028-
beta_new = (
1029-
pd.Series(resid_after_alpha, index=idx)
1030-
.groupby(time_vals)
1031-
.transform("mean")
1032-
.values
1033-
)
1034-
1035-
resid_after_beta = y - beta_new
1036-
if weights is not None:
1037-
wr_u = pd.Series(resid_after_beta * weights, index=idx)
1038-
alpha_new = wr_u.groupby(unit_vals).transform("sum").values / wsum_u
1039-
else:
1040-
alpha_new = (
1041-
pd.Series(resid_after_beta, index=idx)
1042-
.groupby(unit_vals)
1043-
.transform("mean")
1044-
.values
1045-
)
1046-
1047-
# Check convergence on FE changes
1048-
max_change = max(
1049-
np.max(np.abs(alpha_new - alpha)),
1050-
np.max(np.abs(beta_new - beta)),
1051-
)
1052-
alpha = alpha_new
1053-
beta = beta_new
1054-
if max_change < tol:
1055-
converged = True
1056-
break
1057-
warn_if_not_converged(converged, "ImputationDiD iterative FE solver", max_iter, tol)
1058-
1059-
unit_fe = pd.Series(alpha, index=idx).groupby(unit_vals).first().to_dict()
1060-
time_fe = pd.Series(beta, index=idx).groupby(time_vals).first().to_dict()
1016+
unit_codes, unit_uniques = pd.factorize(unit_vals, sort=False)
1017+
time_codes, time_uniques = pd.factorize(time_vals, sort=False)
1018+
if (unit_codes < 0).any() or (time_codes < 0).any():
1019+
raise ValueError(
1020+
"ImputationDiD: unit or time column contains NaN. Drop or "
1021+
"impute missing group keys before fitting."
1022+
)
1023+
unit_fe_arr, time_fe_arr = _iterative_fe_solve(
1024+
np.asarray(y, dtype=np.float64),
1025+
unit_codes.astype(np.intp, copy=False),
1026+
time_codes.astype(np.intp, copy=False),
1027+
len(unit_uniques),
1028+
len(time_uniques),
1029+
weights=weights,
1030+
max_iter=max_iter,
1031+
tol=tol,
1032+
method_name="ImputationDiD iterative FE solver",
1033+
)
1034+
unit_fe = dict(zip(unit_uniques, unit_fe_arr))
1035+
time_fe = dict(zip(time_uniques, time_fe_arr))
10611036
return unit_fe, time_fe
10621037

1063-
@staticmethod
1064-
def _iterative_demean(
1065-
vals: np.ndarray,
1066-
unit_vals: np.ndarray,
1067-
time_vals: np.ndarray,
1068-
idx: pd.Index,
1069-
max_iter: int = 100,
1070-
tol: float = 1e-10,
1071-
weights: Optional[np.ndarray] = None,
1072-
) -> np.ndarray:
1073-
"""Demean a vector by iterative alternating projection (unit + time FE removal).
1074-
1075-
Converges to the exact within-transformation for both balanced and
1076-
unbalanced panels. For balanced panels, converges in 1-2 iterations.
1077-
1078-
Parameters
1079-
----------
1080-
weights : np.ndarray, optional
1081-
Survey weights. When provided, uses weighted group means
1082-
(sum(w*x)/sum(w)) instead of unweighted means.
1083-
"""
1084-
result = vals.copy()
1085-
1086-
# Precompute per-group weight sums (invariant across iterations)
1087-
if weights is not None:
1088-
w_series = pd.Series(weights, index=idx)
1089-
wsum_t = w_series.groupby(time_vals).transform("sum").values
1090-
wsum_u = w_series.groupby(unit_vals).transform("sum").values
1091-
1092-
converged = False
1093-
with np.errstate(invalid="ignore", divide="ignore"):
1094-
for _ in range(max_iter):
1095-
if weights is not None:
1096-
wr_t = pd.Series(result * weights, index=idx)
1097-
time_means = wr_t.groupby(time_vals).transform("sum").values / wsum_t
1098-
else:
1099-
time_means = (
1100-
pd.Series(result, index=idx).groupby(time_vals).transform("mean").values
1101-
)
1102-
result_after_time = result - time_means
1103-
if weights is not None:
1104-
wr_u = pd.Series(result_after_time * weights, index=idx)
1105-
unit_means = wr_u.groupby(unit_vals).transform("sum").values / wsum_u
1106-
else:
1107-
unit_means = (
1108-
pd.Series(result_after_time, index=idx)
1109-
.groupby(unit_vals)
1110-
.transform("mean")
1111-
.values
1112-
)
1113-
result_new = result_after_time - unit_means
1114-
if np.max(np.abs(result_new - result)) < tol:
1115-
result = result_new
1116-
converged = True
1117-
break
1118-
result = result_new
1119-
warn_if_not_converged(converged, "ImputationDiD iterative demean", max_iter, tol)
1120-
return result
1121-
11221038
@staticmethod
11231039
def _compute_balanced_cohort_mask(
11241040
df_treated: pd.DataFrame,
@@ -1240,16 +1156,24 @@ def _fit_untreated_model(
12401156
X_raw = df_0[covariates].values.copy()
12411157
units = df_0[unit].values
12421158
times = df_0[time].values
1243-
n_cov = len(covariates)
1244-
1245-
# Step A: Iteratively demean Y and all X columns to remove unit+time FE
1246-
y_dm = self._iterative_demean(y, units, times, df_0.index, weights=w_0)
1247-
X_dm = np.column_stack(
1248-
[
1249-
self._iterative_demean(X_raw[:, j], units, times, df_0.index, weights=w_0)
1250-
for j in range(n_cov)
1251-
]
1159+
1160+
# Step A: within-transform Y and all X columns through the shared
1161+
# MAP engine (factorize-once + bincount + optional Rust kernel),
1162+
# one dispatch for every column. within_transform pins
1163+
# [unit, time]; [time, unit] here preserves the historical
1164+
# time-then-unit sweep order of the per-estimator loops.
1165+
narrow = df_0[[outcome, *covariates, time, unit]].copy()
1166+
demeaned, _ = demean_by_groups(
1167+
narrow,
1168+
[outcome, *covariates],
1169+
[time, unit],
1170+
inplace=True,
1171+
weights=w_0,
1172+
max_iter=10_000,
1173+
tol=1e-10,
12521174
)
1175+
y_dm = demeaned[outcome].to_numpy(dtype=np.float64)
1176+
X_dm = demeaned[covariates].to_numpy(dtype=np.float64)
12531177

12541178
# Step B: OLS for covariate coefficients on demeaned data
12551179
result = solve_ols(
@@ -2283,31 +2207,27 @@ def _compute_lead_coefficients(
22832207
df_0[col_name] = indicator
22842208
lead_cols.append(col_name)
22852209

2286-
# Within-transform via iterative demeaning (survey-weighted when present)
2287-
y_dm = self._iterative_demean(
2288-
df_0[outcome].values,
2289-
df_0[unit].values,
2290-
df_0[time].values,
2291-
df_0.index,
2292-
weights=survey_weights_0,
2293-
)
2294-
22952210
all_x_cols = lead_cols[:]
22962211
if covariates:
22972212
all_x_cols.extend(covariates)
22982213

2299-
X_dm = np.column_stack(
2300-
[
2301-
self._iterative_demean(
2302-
df_0[col].values,
2303-
df_0[unit].values,
2304-
df_0[time].values,
2305-
df_0.index,
2306-
weights=survey_weights_0,
2307-
)
2308-
for col in all_x_cols
2309-
]
2214+
# Within-transform through the shared MAP engine (survey-weighted when
2215+
# present), one dispatch for outcome + leads + covariates. Demean into
2216+
# a narrow copy: df_0's raw lead indicators must survive for the
2217+
# per-horizon n_obs counts below. within_transform pins [unit, time];
2218+
# [time, unit] here preserves the historical time-then-unit sweep order.
2219+
narrow = df_0[[outcome, *all_x_cols, time, unit]].copy()
2220+
demeaned, _ = demean_by_groups(
2221+
narrow,
2222+
[outcome, *all_x_cols],
2223+
[time, unit],
2224+
inplace=True,
2225+
weights=survey_weights_0,
2226+
max_iter=10_000,
2227+
tol=1e-10,
23102228
)
2229+
y_dm = demeaned[outcome].to_numpy(dtype=np.float64)
2230+
X_dm = demeaned[all_x_cols].to_numpy(dtype=np.float64)
23112231

23122232
# OLS for point estimates + VCV. When survey VCV will replace the
23132233
# cluster-robust VCV, skip cluster_ids to avoid errors on domains

diff_diff/spillover.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,7 +1287,8 @@ def _convert_treatment_to_first_treat(
12871287
# =============================================================================
12881288

12891289
# Convergence tolerance for the iterative alternating-projection FE solver
1290-
# (Gauss-Seidel style; mirrors `TwoStageDiD._iterative_fe`).
1290+
# (Gauss-Seidel style; same recursion as the shared
1291+
# `diff_diff.utils._iterative_fe_solve` used by ImputationDiD/TwoStageDiD).
12911292
_FE_ITER_MAX = 100
12921293
_FE_ITER_TOL = 1e-10
12931294

@@ -1408,8 +1409,9 @@ def _iterative_fe_subset(
14081409
``NaN`` at positions whose unit / time is not represented in the
14091410
subsample (rank-deficient cells).
14101411
1411-
Mirrors ``TwoStageDiD._iterative_fe`` structurally but operates on
1412-
integer-coded factors via ``np.bincount`` for speed.
1412+
Same Gauss-Seidel-on-integer-codes recursion as the shared
1413+
``diff_diff.utils._iterative_fe_solve`` (which ImputationDiD/TwoStageDiD
1414+
now route through), specialized here to a masked Butts subsample.
14131415
14141416
**Wave E.1 weighted path** — when ``weights`` is supplied, the solver
14151417
minimizes ``sum_i w_i * (y_i - mu_i - lambda_t)^2`` (WLS-FE under

0 commit comments

Comments
 (0)