Skip to content

Commit 955aa4b

Browse files
authored
Merge pull request #475 from igerber/wls-cr2-clubsandwich-port
Lift hc2_bm + weights gates via clubSandwich WLS-CR2 port
2 parents 6c77137 + 4fe217e commit 955aa4b

9 files changed

Lines changed: 1841 additions & 207 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

TODO.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,14 @@ Deferred items from PR reviews that were not addressed before merge.
102102
| Thread `vcov_type` (classical / hc1 / hc2 / hc2_bm) through the 7 standalone estimators that expose `cluster=` but not yet `vcov_type=`: `CallawaySantAnna`, `ImputationDiD`, `TwoStageDiD`, `TripleDifference`, `StackedDiD`, `WooldridgeDiD`, `EfficientDiD`. Phase 1a added the chain to DiD/MPD/TWFE; Phase 1b PR 1/8 added `SunAbraham` (this row tracks the remaining 7). | multiple | Phase 1b | Medium |
103103
| Extend `SunAbraham` with `vcov_type="conley"` (Conley spatial-HAC) as a first-class feature: thread `conley_coords` / `conley_cutoff_km` / `conley_metric` / `conley_kernel` / `conley_time` / `conley_unit` / `conley_lag_cutoff` through `_fit_saturated_regression`. Phase 1b PR 1/8 deferred this; SA currently rejects `vcov_type="conley"` at `__init__` with a deferral message. | `diff_diff/sun_abraham.py` | follow-up | Medium |
104104
| Harmonize SunAbraham's HC1 within-transform finite-sample correction with `fixest::sunab()`. SA's `solve_ols` applies `n / (n - k_dm)` (within-transform columns only); fixest applies `n / (n - k_total)` (counts absorbed FE). SE values differ by ~1-2% on typical panel sizes (documented in REGISTRY.md "Deviation from R"; pinned at `atol=5e-3` in `tests/test_methodology_sun_abraham.py`). Either thread `df_adjustment` into the vcov scaling or document as an intentional difference. | `diff_diff/sun_abraham.py`, `diff_diff/linalg.py::compute_robust_vcov` | follow-up | Low |
105-
| Weighted one-way Bell-McCaffrey (`vcov_type="hc2_bm"` + `weights`, no cluster) currently raises `NotImplementedError`. `_compute_bm_dof_from_contrasts` builds its hat matrix from the unscaled design via `X (X'WX)^{-1} X' W`, but `solve_ols` solves the WLS problem by transforming to `X* = sqrt(w) X`, so the correct symmetric idempotent residual-maker is `M* = I - sqrt(W) X (X'WX)^{-1} X' sqrt(W)`. Rederive the Satterthwaite `(tr G)^2 / tr(G^2)` ratio on the transformed design and add weighted parity tests before lifting the guard. | `linalg.py::_compute_bm_dof_from_contrasts`, `linalg.py::_validate_vcov_args` | Phase 1a | Medium |
106-
| Weighted CR2 Bell-McCaffrey cluster-robust (`vcov_type="hc2_bm"` + `cluster_ids` + `weights`) currently raises `NotImplementedError`. Weighted hat matrix and residual rebalancing need threading per clubSandwich WLS handling. | `linalg.py::_compute_cr2_bm` | Phase 1a | Medium |
105+
<!-- Rows 104-105 LIFTED 2026-05-20 via the clubSandwich WLS-CR2 port. The diff-diff
106+
form matches clubSandwich's specific algebra (W not sqrt(W), W^2 in bias term,
107+
unweighted residuals) rather than the textbook PT2018 transform-once derivation
108+
which historically diverged by 0.5-30%. Pinned at atol=1e-10 on 6 weighted
109+
scenarios in benchmarks/data/clubsandwich_cr2_golden.json. -->
110+
| ~~Weighted one-way Bell-McCaffrey (`vcov_type="hc2_bm"` + `weights`, no cluster)~~ — LIFTED via clubSandwich WLS-CR2 port. Routes through `_compute_cr2_bm_contrast_dof` with singleton-cluster reduction (each obs is its own cluster). See REGISTRY.md Phase 1a row for the algebra. | `linalg.py` | LIFTED ||
111+
| ~~Weighted CR2 Bell-McCaffrey cluster-robust (`vcov_type="hc2_bm"` + `cluster_ids` + `weights`)~~ — LIFTED via clubSandwich WLS-CR2 port. `_compute_cr2_bm` now accepts `weights=` and threads through clubSandwich's specific WLS-CR2 algebra. | `linalg.py::_compute_cr2_bm` | LIFTED ||
112+
| `LinearRegression.fit()` pays the CR2 cost twice on the weighted `hc2_bm` path: once inside `solve_ols(..., return_vcov=True)` and again via `compute_robust_vcov(..., return_dof=True)` to populate `_bm_dof`. Correct but redundant. Fix: thread `return_dof` through `solve_ols` so the same CR2 computation produces both vcov + DOF, or cache the per-cluster `A_g` / `MUWTWUM` precomputes between calls. CI codex P3 on PR #475. | `linalg.py::LinearRegression.fit`, `linalg.py::solve_ols` | PR #475 follow-up | Low |
107113
| `TwoWayFixedEffects(vcov_type in {"hc2","hc2_bm"})` with replicate-weight survey designs raises `NotImplementedError` (`twfe.py:~233`). The replicate path re-demeans per replicate (re-demeaning depends on the per-replicate weight vector), which doesn't compose with the full-dummy HC2/HC2-BM build — a correct implementation would need per-replicate full-dummy refit. Workaround: use `vcov_type="hc1"` for replicate-weight CR1. | `twfe.py::fit` | follow-up | Low |
108114
| 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, and result-surface conventions. Substantive refactor — touches both estimators. | `twfe.py::fit`, `estimators.py::DifferenceInDifferences.fit` | follow-up | Low |
109115
| Unify Rust local-method `estimate_model` solver path to `solve_wls_svd` (the same SVD helper used by the global-method since PR #348) for sub-1e-14 bootstrap SE parity. Current local-method bootstrap parity test (`tests/test_rust_backend.py::TestTROPRustEdgeCaseParity::test_bootstrap_seed_reproducibility_local`) passes at `atol=1e-5` — the residual ~1e-7 gap is roundoff between Rust's `estimate_model` matrix factorization and numpy's `lstsq`, which accumulates differently across per-replicate bootstrap fits. Main-fit ATT parity is regime-dependent (`atol=1e-14` for `lambda_nn=inf`, `atol=1e-10` for finite `lambda_nn` — see `test_local_method_main_fit_parity`); the bootstrap gap is a same-solver-path roundoff concern and not a user-visible correctness bug. | `rust/src/trop.rs::estimate_model`, `rust/src/linalg.rs::solve_wls_svd` | follow-up | Low |
@@ -202,7 +208,7 @@ Ordered paydown view across the tables above. Tier A → D is by effort × risk,
202208
#### Tier C — Heavy / derivation required
203209

204210
- HonestDiD Δ^RM ARP conditional/hybrid confidence sets (`honest_did.py`)
205-
- Weighted one-way Bell-McCaffrey + weighted CR2 Bell-McCaffrey (linalg derivations + R parity harness) (`linalg.py::_compute_bm_dof_from_contrasts`, `linalg.py::_compute_cr2_bm`)
211+
- ~~Weighted one-way Bell-McCaffrey + weighted CR2 Bell-McCaffrey~~ — LIFTED via the clubSandwich WLS-CR2 port (PR #475); see rows 109-110 above for the closed rows.
206212
- Multi-absorb weighted demeaning: alternating-projection iteration for N>1 absorb + weights (`estimators.py`)
207213
- ImputationDiD dense `(A0'A0).toarray()` OOM: alternative dense fallback or richer sparse strategy (`imputation.py:1531`)
208214
- HAD mass-point `vcov_type ∈ {hc2, hc2_bm}`: 2SLS-specific leverage derivation (`had.py::_fit_mass_point_2sls`)

benchmarks/R/generate_clubsandwich_golden.R

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ suppressPackageStartupMessages({
2323
library(jsonlite)
2424
})
2525

26+
stopifnot(packageVersion("clubSandwich") >= "0.7.0")
2627
set.seed(20260420)
2728

2829
# --- Three deterministic datasets ---------------------------------------------
@@ -468,12 +469,242 @@ output$sun_abraham_two_cohort <- list(
468469
dof_bm_contrast_overall_unit = unname(sa_dof_bm_overall_unit)
469470
)
470471

472+
# =============================================================================
473+
# Weighted scenarios (clubSandwich WLS-CR2 port)
474+
# =============================================================================
475+
# Pin diff-diff's weighted CR2-BM port against clubSandwich's specific WLS-CR2
476+
# algebra (R/CR-adjustments.R::CR2 + R/get_arrays.R::get_GH + coef_test.R).
477+
# Verified algorithm uses W (not sqrt(W)) in the hat matrix, W^2 in the bias
478+
# correction term, and unweighted residuals in the score construction.
479+
480+
# ---- Scenario: weighted_one_way_no_cluster ----------------------------------
481+
# 12 observations, single "cluster" via vcovCR(cluster=1:n, type="CR2") for the
482+
# one-way HC2-BM reduction. Heteroskedastic weights.
483+
set.seed(50100)
484+
d_w_oneway <- data.frame(
485+
x = c(-1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, -0.8, 0.3, 1.2, -1.2),
486+
z = c(0.2, -0.3, 0.5, -0.1, 0.4, -0.5, 0.6, -0.2, 0.1, -0.4, 0.3, 0.5)
487+
)
488+
d_w_oneway$y <- 1 + 0.5 * d_w_oneway$x + 0.3 * d_w_oneway$z +
489+
rnorm(nrow(d_w_oneway), sd = 0.5)
490+
w_oneway <- c(0.5, 0.5, 1, 1, 1.5, 1.5, 2, 2, 2.5, 2.5, 3, 3)
491+
fit_w_oneway <- lm(y ~ x + z, data = d_w_oneway, weights = w_oneway)
492+
vcov_w_oneway <- vcovCR(fit_w_oneway,
493+
cluster = seq_len(nrow(d_w_oneway)), type = "CR2")
494+
ct_w_oneway <- coef_test(fit_w_oneway, vcov = vcov_w_oneway)
495+
output$weighted_one_way_no_cluster <- list(
496+
x = d_w_oneway$x,
497+
z = d_w_oneway$z,
498+
y = d_w_oneway$y,
499+
weights = w_oneway,
500+
coef = as.numeric(coef(fit_w_oneway)),
501+
coef_names = names(coef(fit_w_oneway)),
502+
vcov_hc2_bm = as.numeric(vcov_w_oneway),
503+
vcov_hc2_bm_shape = dim(vcov_w_oneway),
504+
dof_bm_one_way = as.numeric(ct_w_oneway$df_Satt),
505+
se_hc2_bm = as.numeric(ct_w_oneway$SE)
506+
)
507+
508+
# ---- Scenario: weighted_balanced_clusters -----------------------------------
509+
# 20 observations, 4 clusters of 5; weights vary within and across clusters.
510+
set.seed(50200)
511+
n_w_bal <- 20
512+
cluster_w_bal <- rep(1:4, each = 5)
513+
d_w_bal <- data.frame(
514+
cluster = cluster_w_bal,
515+
x = rnorm(n_w_bal),
516+
z = rnorm(n_w_bal)
517+
)
518+
d_w_bal$y <- 1 + 0.5 * d_w_bal$x + 0.3 * d_w_bal$z + rnorm(n_w_bal, sd = 0.5)
519+
w_bal <- runif(n_w_bal, min = 0.5, max = 3.0)
520+
fit_w_bal <- lm(y ~ x + z, data = d_w_bal, weights = w_bal)
521+
vcov_w_bal <- vcovCR(fit_w_bal, cluster = d_w_bal$cluster, type = "CR2")
522+
ct_w_bal <- coef_test(fit_w_bal, vcov = vcov_w_bal)
523+
output$weighted_balanced_clusters <- list(
524+
x = d_w_bal$x,
525+
z = d_w_bal$z,
526+
y = d_w_bal$y,
527+
weights = w_bal,
528+
cluster = d_w_bal$cluster,
529+
coef = as.numeric(coef(fit_w_bal)),
530+
coef_names = names(coef(fit_w_bal)),
531+
vcov_cr2 = as.numeric(vcov_w_bal),
532+
vcov_cr2_shape = dim(vcov_w_bal),
533+
dof_bm = as.numeric(ct_w_bal$df_Satt),
534+
se_cr2 = as.numeric(ct_w_bal$SE)
535+
)
536+
537+
# ---- Scenario: weighted_unbalanced_clusters ---------------------------------
538+
# 52 observations, 8 clusters of sizes 3-10. Heteroskedastic weights.
539+
set.seed(50300)
540+
cluster_sizes_unbal <- c(3, 4, 5, 6, 7, 8, 9, 10)
541+
cluster_w_unbal <- rep(1:8, times = cluster_sizes_unbal)
542+
n_w_unbal <- length(cluster_w_unbal)
543+
d_w_unbal <- data.frame(
544+
cluster = cluster_w_unbal,
545+
x = rnorm(n_w_unbal),
546+
z = rnorm(n_w_unbal)
547+
)
548+
shock_unbal <- rnorm(8, sd = 0.3)
549+
d_w_unbal$y <- 1 + 0.5 * d_w_unbal$x + 0.3 * d_w_unbal$z +
550+
shock_unbal[d_w_unbal$cluster] + rnorm(n_w_unbal, sd = 0.4)
551+
w_unbal <- runif(n_w_unbal, min = 0.3, max = 3.0)
552+
fit_w_unbal <- lm(y ~ x + z, data = d_w_unbal, weights = w_unbal)
553+
vcov_w_unbal <- vcovCR(fit_w_unbal, cluster = d_w_unbal$cluster, type = "CR2")
554+
ct_w_unbal <- coef_test(fit_w_unbal, vcov = vcov_w_unbal)
555+
output$weighted_unbalanced_clusters <- list(
556+
x = d_w_unbal$x,
557+
z = d_w_unbal$z,
558+
y = d_w_unbal$y,
559+
weights = w_unbal,
560+
cluster = d_w_unbal$cluster,
561+
coef = as.numeric(coef(fit_w_unbal)),
562+
coef_names = names(coef(fit_w_unbal)),
563+
vcov_cr2 = as.numeric(vcov_w_unbal),
564+
vcov_cr2_shape = dim(vcov_w_unbal),
565+
dof_bm = as.numeric(ct_w_unbal$df_Satt),
566+
se_cr2 = as.numeric(ct_w_unbal$SE),
567+
cluster_sizes = as.numeric(table(d_w_unbal$cluster))
568+
)
569+
570+
# ---- Scenario: weighted_singletons_present ----------------------------------
571+
# Adversarial: prior PT2018 transform-once derivation hit ~30% gap on
572+
# singleton-cluster scenarios. Verifies the clubSandwich port handles this.
573+
set.seed(50400)
574+
cluster_sizes_sing <- c(1, 1, 2, 3, 4, 5, 6, 6, 4, 3)
575+
cluster_w_sing <- rep(1:10, times = cluster_sizes_sing)
576+
n_w_sing <- length(cluster_w_sing)
577+
d_w_sing <- data.frame(
578+
cluster = cluster_w_sing,
579+
x = rnorm(n_w_sing),
580+
z = rnorm(n_w_sing)
581+
)
582+
shock_sing <- rnorm(10, sd = 0.3)
583+
d_w_sing$y <- 1 + 0.5 * d_w_sing$x + 0.3 * d_w_sing$z +
584+
shock_sing[d_w_sing$cluster] + rnorm(n_w_sing, sd = 0.4)
585+
w_sing <- runif(n_w_sing, min = 0.3, max = 3.0)
586+
fit_w_sing <- lm(y ~ x + z, data = d_w_sing, weights = w_sing)
587+
vcov_w_sing <- vcovCR(fit_w_sing, cluster = d_w_sing$cluster, type = "CR2")
588+
ct_w_sing <- coef_test(fit_w_sing, vcov = vcov_w_sing)
589+
output$weighted_singletons_present <- list(
590+
x = d_w_sing$x,
591+
z = d_w_sing$z,
592+
y = d_w_sing$y,
593+
weights = w_sing,
594+
cluster = d_w_sing$cluster,
595+
coef = as.numeric(coef(fit_w_sing)),
596+
coef_names = names(coef(fit_w_sing)),
597+
vcov_cr2 = as.numeric(vcov_w_sing),
598+
vcov_cr2_shape = dim(vcov_w_sing),
599+
dof_bm = as.numeric(ct_w_sing$df_Satt),
600+
dof_per_coef = as.numeric(ct_w_sing$df_Satt),
601+
se_cr2 = as.numeric(ct_w_sing$SE),
602+
cluster_sizes = as.numeric(table(d_w_sing$cluster))
603+
)
604+
605+
# ---- Scenario: weighted_did_absorbed_fe -------------------------------------
606+
# DiD-style analytical CR2 design-matrix parity fixture: 8 units x 4 periods,
607+
# treat_post + unit + period FE, analytics weights varying by unit. Pins the
608+
# clubSandwich WLS-CR2 analytical surface (compute_robust_vcov / solve_ols /
609+
# LinearRegression direct callers) on a DiD-shaped design. NOTE: this is NOT
610+
# a public-estimator `DifferenceInDifferences(survey_design=...)` parity
611+
# fixture — estimator-level survey designs route through the TSL survey
612+
# variance path, which takes precedence over the analytical CR2 sandwich.
613+
set.seed(50500)
614+
d_did_w <- make_did_panel(n_units = 8, n_periods = 4, treatment_period = 2,
615+
seed = 50501)
616+
# Unit-level weight (stratum-like): vary by unit, constant within unit-period.
617+
unit_w_did <- runif(8, min = 0.5, max = 2.5)
618+
d_did_w$weights <- unit_w_did[d_did_w$unit_int]
619+
fit_did_w <- lm(y ~ treat_post + unit + period, data = d_did_w,
620+
weights = d_did_w$weights)
621+
vcov_did_w_cr2 <- vcovCR(fit_did_w, cluster = d_did_w$unit_int, type = "CR2")
622+
ct_did_w_cr2 <- coef_test(fit_did_w, vcov = vcov_did_w_cr2)
623+
output$weighted_did_absorbed_fe <- list(
624+
unit = d_did_w$unit_int,
625+
period = d_did_w$period_int,
626+
treated = d_did_w$treated,
627+
post = d_did_w$post,
628+
treat_post = d_did_w$treat_post,
629+
y = d_did_w$y,
630+
weights = d_did_w$weights,
631+
coef = as.numeric(coef(fit_did_w)),
632+
coef_names = names(coef(fit_did_w)),
633+
vcov_cr2 = as.numeric(vcov_did_w_cr2),
634+
vcov_cr2_shape = dim(vcov_did_w_cr2),
635+
dof_cr2 = as.numeric(ct_did_w_cr2$df_Satt)
636+
)
637+
638+
# ---- Scenario: weighted_mpd_avg_att_dof -------------------------------------
639+
# MPD-style analytical CR2 contrast-DOF parity fixture: 15 units x 4 periods,
640+
# MPD parameterization with analytics weights + cluster=unit. Compound
641+
# contrast = post-period-average ATT. Pins the clubSandwich `Wald_test(test=
642+
# "HTZ")$df_denom` against `_compute_cr2_bm_contrast_dof(weights=)` on an
643+
# MPD-shaped design. NOTE: like `weighted_did_absorbed_fe`, this fixture
644+
# targets the analytical surface only — estimator-level
645+
# `MultiPeriodDiD(survey_design=...)` paths use TSL.
646+
set.seed(50600)
647+
d_mpd_w <- make_mpd_panel(n_total = 15, units_per_cohort = 5, n_periods = 4,
648+
seed = 50601)
649+
d_mpd_w$period_f <- relevel(factor(d_mpd_w$period), ref = "1")
650+
for (p in 2:4) {
651+
d_mpd_w[[paste0("treated_period_", p)]] <-
652+
d_mpd_w$treated * (d_mpd_w$period == p)
653+
}
654+
unit_w_mpd <- runif(15, min = 0.5, max = 2.5)
655+
d_mpd_w$weights <- unit_w_mpd[d_mpd_w$unit]
656+
fit_mpd_w <- lm(y ~ treated + period_f +
657+
treated_period_2 + treated_period_3 + treated_period_4 +
658+
factor(unit),
659+
data = d_mpd_w, weights = d_mpd_w$weights)
660+
vcov_mpd_w_cr2 <- vcovCR(fit_mpd_w, cluster = d_mpd_w$unit, type = "CR2")
661+
ct_mpd_w_cr2 <- coef_test(fit_mpd_w, vcov = vcov_mpd_w_cr2)
662+
# Compound contrast: post-period-average over treated_period_{2,3,4}.
663+
all_coef_names_w <- names(coef(fit_mpd_w))
664+
n_coef_w <- length(all_coef_names_w)
665+
c_avg_vec_w <- setNames(rep(0, n_coef_w), all_coef_names_w)
666+
post_names_w <- c("treated_period_2", "treated_period_3", "treated_period_4")
667+
c_avg_vec_w[post_names_w] <- 1 / length(post_names_w)
668+
finite_mask_w <- !is.na(coef(fit_mpd_w))
669+
c_avg_kept_w <- c_avg_vec_w[finite_mask_w]
670+
dof_avg_w <- Wald_test(
671+
fit_mpd_w,
672+
constraints = matrix(c_avg_kept_w, 1),
673+
vcov = vcov_mpd_w_cr2,
674+
test = "HTZ"
675+
)$df_denom
676+
output$weighted_mpd_avg_att_dof <- list(
677+
unit = d_mpd_w$unit,
678+
period = d_mpd_w$period,
679+
treated = d_mpd_w$treated,
680+
y = d_mpd_w$y,
681+
weights = d_mpd_w$weights,
682+
cluster = d_mpd_w$unit,
683+
coef = as.numeric(coef(fit_mpd_w)),
684+
coef_names = all_coef_names_w,
685+
finite_coef_names = all_coef_names_w[finite_mask_w],
686+
vcov_cr2 = as.numeric(vcov_mpd_w_cr2),
687+
vcov_cr2_shape = dim(vcov_mpd_w_cr2),
688+
dof_per_coef = as.numeric(ct_mpd_w_cr2$df_Satt),
689+
c_avg = as.numeric(c_avg_kept_w),
690+
dof_avg = unname(dof_avg_w),
691+
post_interaction_names = post_names_w,
692+
reference_period = 1L,
693+
n_post_periods = length(post_names_w)
694+
)
695+
471696
output$meta <- list(
472697
source = "clubSandwich",
473698
clubSandwich_version = as.character(packageVersion("clubSandwich")),
474699
R_version = R.version.string,
475700
generated_at = format(Sys.time(), tz = "UTC", usetz = TRUE),
476-
note = "CR2 Bell-McCaffrey cluster-robust parity target for diff_diff._compute_cr2_bm"
701+
note = paste0(
702+
"CR2 Bell-McCaffrey cluster-robust parity target for ",
703+
"diff_diff._compute_cr2_bm. Unweighted scenarios pin against ",
704+
"_compute_cr2_bm / _compute_bm_dof_oneway; weighted scenarios pin ",
705+
"the clubSandwich WLS-CR2 port (W not sqrt(W), W^2 bias term, ",
706+
"unweighted residuals)."
707+
)
477708
)
478709

479710
out_path <- file.path("benchmarks", "data", "clubsandwich_cr2_golden.json")

0 commit comments

Comments
 (0)