Skip to content

Commit 0f4fce8

Browse files
igerberclaude
andcommitted
perf(staggered): O(n_units) CS aggregation IF assembly + R did 2.5.1 yardstick
Rewrite CallawaySantAnna's combined influence-function assembly (also inherited by StaggeredTripleDifference aggregation) - previously 56-85% of analytical fit time at scale: - Per-fit cohort tables (np.unique + np.bincount) cached on the precomputed structures with array-identity validation (safe against triple-diff's shallow-copy + zeroed-cohort aggregation pattern) - Closed-form WIF (wif_i = w_i * (E(c_i)/S - K(c_i)*d/S^2)) replacing dense (n_units x n_gt) indicator/outer-product matrices - algebraically identical, documented in REGISTRY - Fancy-index scatter replacing np.add.at (per-cell index arrays are duplicate-free by construction; invariant documented at all IF producers) - Pre-rewrite general path preserved verbatim as fallback for direct callers Point estimates bit-identical; aggregated SEs <=5e-16 relative (drift-bound frozen-copy tests at rtol=0 atol=1e-9 across panel/survey/RCS/unbalanced/ triple-diff/inf-coded fixtures). Analytical fits 2.3-6.3x faster at 2-5M rows, bootstrap fits 1.4-1.6x, RCS peak memory -55 to -68%. 3-11x faster than R did 2.5.1 at equal work (single- and multi-core R; benchmarks/R/benchmark_did.R extended with bootstrap/covariate/parallelism flags for the yardstick). Golden aggregated-SE assertions enabled for dr scenarios (match R fixtures at 1e-12..7e-6). Pre-existing reg-method aggregated-SE gap vs R fixtures (3-20%, ATTs exact) documented in TODO.md for follow-up investigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4AzrFUMqJxcUumSH31mSr
1 parent a0a63c2 commit 0f4fce8

10 files changed

Lines changed: 1059 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5353
supersedes it.
5454

5555
### Changed
56+
- **CallawaySantAnna aggregation SE assembly rewritten to O(n_units)** (also inherited by
57+
`StaggeredTripleDifference` aggregation). The combined influence-function assembly behind
58+
simple/event-study/group aggregated SEs — previously ~56-85% of analytical fit time at
59+
scale — replaces its per-aggregation-target full-DataFrame cohort scans, per-unit Python
60+
loops, and dense `(n_units × n_gt)` weight-influence-function matrices with per-fit cohort
61+
tables and a closed-form WIF (algebraically identical; details in REGISTRY). Measured
62+
(medians of 3, Apple M4 Max, Rust backend): analytical fits at 100k units × 20 periods
63+
(2M rows) 1.32→0.21s no-covariate (6.3x), 2.49→1.08s 5-covariate DR (2.3x); long panels
64+
40p×10 cohorts 9.09→3.91s and 60p×15 cohorts 9.86→4.34s (2.3x); bootstrap-999 fits
65+
1.4-1.6x (the remaining draw-loop matmul is unchanged); repeated cross-sections
66+
4.17→1.50s no-covariate (2.8x) with peak memory 6.4→2.1 GB (-67%; the dense WIF matrices
67+
were observation-scale in RCS mode). Point estimates are bit-identical; aggregated SEs
68+
move only at floating-point reassociation level (measured ≤5e-16 relative, drift-bound
69+
tested at 1e-9 against a frozen copy of the prior implementation). For scale context,
70+
a full `fit(aggregate="all")` at 2M rows now runs 3-11x faster than R `did` 2.5.1
71+
(equal work, analytical or bootstrap-999, single- or multi-core R; R's `pl`/`cores`
72+
parallelism is BLAS-bound on this path so both R arms time identically).
5673
- **ImputationDiD/TwoStageDiD demeaning modernized onto the shared MAP engine.** The
5774
private per-estimator pandas `_iterative_demean` loops (rebuilt a
5875
`pd.Series.groupby().transform()` hash table every alternating-projection iteration)

TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ The `Origin` column (Actionable tables) and the `PR` column (Deferred tables) bo
3232
| `ContinuousDiD` CGBS-2024 extensions. (a) `covariates=` kwarg — **DONE (reg/dr)**; remaining: (b) discrete-treatment saturated regression (integer dose currently warned, not routed to per-level coefficients); (c) lowest-dose-as-control per Remark 3.1 when `P(D=0)=0`. Also deferred from the covariate work: `estimation_method="ipw"` on the dose curve (scalar-adjustment / degenerate — documented `NotImplementedError`), and `covariates=` × `survey_design=` (weighted OR + weighted nuisance IF). | `continuous_did.py` | CGBS-2024 | Heavy | Low |
3333
| `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 |
3434
| `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 |
35+
| `CallawaySantAnna` reg-method aggregated SEs sit 3-20% from the R golden fixtures (`two_period` simple/group/dynamic, `dynamic_effects` dynamic) while the ATTs match at 1e-11 and the dr-method aggregated SEs match at ≤7e-6. Pre-existing (byte-identical deltas on the pre-fast-path tree); golden aggregated-SE assertions were therefore enabled for dr scenarios only (`test_golden_simple_aggregation_se`, `test_golden_event_study_aggregation_se`). Investigate the reg-path per-cell IF vs R `DRDID::reg_did_panel`'s. | `staggered.py::_outcome_regression`, `tests/test_csdid_ported.py` | CS-scaling | Mid | Medium |
3536
| 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 |
3637

3738
### Performance
@@ -53,6 +54,9 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
5354
| 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 |
5455
| 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 |
5556
| 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 |
57+
| `_cluster_robust_se_from_per_gt_if` scatters per-cell IFs with `np.add.at` (unbuffered ufunc, 5-20x slower than fancy `+=`; same slow-scatter class the aggregation fast path fixed). Index arrays come from `np.where` over boolean masks (duplicate-free), so per-cell `psi[idx] += vals` is exact. Runs once per (g,t) cell when `cluster=` is set — noticeable at many-cell fits. | `staggered.py::_cluster_robust_se_from_per_gt_if` | CS-scaling | Quick | Low |
58+
| Per-cell `treated_units`/`control_units` label arrays (`all_units[positions]`, ~O(n_control) alloc per (g,t) cell) are consumed only by the precomputed-None fallback of the combined-IF assembly, which no in-package caller reaches — build them lazily (or drop from the IF-info dict) to cut per-cell allocation at high cell counts. | `staggered.py::_compute_att_gt_fast` | CS-scaling | Mid | Low |
59+
| `_compute_aggregated_se` is dead code (zero in-package callers; superseded by `_compute_aggregated_se_with_wif`) — remove it, or fold its docstring into the WIF variant. Its `np.add.at` scatter also predates the fancy-`+=` convention. | `staggered_aggregation.py::_compute_aggregated_se` | CS-scaling | Quick | Low |
5660

5761
### Testing / docs
5862

benchmarks/R/benchmark_did.R

Lines changed: 100 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
# Benchmark: Callaway-Sant'Anna Estimator (R `did` package)
33
#
44
# Usage:
5-
# Rscript benchmark_did.R --data path/to/data.csv --output path/to/results.json
5+
# Rscript benchmark_did.R --data path/to/data.csv --output path/to/results.json \
6+
# [--method dr|ipw|reg] [--control-group nevertreated|notyettreated] \
7+
# [--xformla "~ x1 + x2"] [--bstrap true|false] [--biters N] \
8+
# [--cband true|false] [--pl true|false] [--cores N] \
9+
# [--faster-mode true|false]
10+
#
11+
# Defaults reproduce the historical behavior (analytical SEs, no covariates,
12+
# single core), so existing callers (benchmarks/run_benchmarks.py) are
13+
# unaffected. The optional flags exist for the R-yardstick arms: bootstrap
14+
# inference (bstrap/biters/cband applied at BOTH att_gt and aggte), covariate
15+
# formulas, and did's parallel processing (pl/cores).
616

717
library(did)
818
library(jsonlite)
@@ -11,35 +21,73 @@ library(data.table)
1121
# Parse command line arguments
1222
args <- commandArgs(trailingOnly = TRUE)
1323

24+
parse_bool <- function(x, flag) {
25+
v <- tolower(x)
26+
if (v %in% c("true", "t", "1", "yes")) return(TRUE)
27+
if (v %in% c("false", "f", "0", "no")) return(FALSE)
28+
stop(sprintf("Invalid boolean for %s: '%s' (use true/false)", flag, x))
29+
}
30+
1431
parse_args <- function(args) {
1532
result <- list(
1633
data = NULL,
1734
output = NULL,
1835
method = "dr",
19-
control_group = "nevertreated"
36+
control_group = "nevertreated",
37+
xformla = NULL,
38+
bstrap = FALSE,
39+
biters = 1000L,
40+
cband = FALSE,
41+
pl = FALSE,
42+
cores = 1L,
43+
faster_mode = NULL # NULL -> use did's own default for this version
2044
)
2145

2246
i <- 1
2347
while (i <= length(args)) {
24-
if (args[i] == "--data") {
25-
result$data <- args[i + 1]
26-
i <- i + 2
27-
} else if (args[i] == "--output") {
28-
result$output <- args[i + 1]
29-
i <- i + 2
30-
} else if (args[i] == "--method") {
31-
result$method <- args[i + 1]
32-
i <- i + 2
33-
} else if (args[i] == "--control-group") {
34-
result$control_group <- args[i + 1]
35-
i <- i + 2
48+
flag <- args[i]
49+
if (i + 1 > length(args) && flag != "") {
50+
stop(sprintf("Missing value for flag: %s", flag))
51+
}
52+
val <- args[i + 1]
53+
if (flag == "--data") {
54+
result$data <- val
55+
} else if (flag == "--output") {
56+
result$output <- val
57+
} else if (flag == "--method") {
58+
result$method <- val
59+
} else if (flag == "--control-group") {
60+
result$control_group <- val
61+
} else if (flag == "--xformla") {
62+
result$xformla <- as.formula(val)
63+
} else if (flag == "--bstrap") {
64+
result$bstrap <- parse_bool(val, flag)
65+
} else if (flag == "--biters") {
66+
result$biters <- as.integer(val)
67+
} else if (flag == "--cband") {
68+
result$cband <- parse_bool(val, flag)
69+
} else if (flag == "--pl") {
70+
result$pl <- parse_bool(val, flag)
71+
} else if (flag == "--cores") {
72+
result$cores <- as.integer(val)
73+
} else if (flag == "--faster-mode") {
74+
result$faster_mode <- parse_bool(val, flag)
3675
} else {
37-
i <- i + 1
76+
# Unknown flags used to be silently skipped, which turned typos into
77+
# silent default runs. Fail loudly instead.
78+
stop(sprintf("Unknown flag: %s", flag))
3879
}
80+
i <- i + 2
3981
}
4082

4183
if (is.null(result$data) || is.null(result$output)) {
42-
stop("Usage: Rscript benchmark_did.R --data <path> --output <path> [--method dr|ipw|reg] [--control-group nevertreated|notyettreated]")
84+
stop("Usage: Rscript benchmark_did.R --data <path> --output <path> [--method dr|ipw|reg] [--control-group nevertreated|notyettreated] [--xformla '~ x1 + x2'] [--bstrap true|false] [--biters N] [--cband true|false] [--pl true|false] [--cores N] [--faster-mode true|false]")
85+
}
86+
if (is.na(result$biters) || result$biters <= 0) {
87+
stop(sprintf("--biters must be a positive integer, got '%s'", result$biters))
88+
}
89+
if (is.na(result$cores) || result$cores <= 0) {
90+
stop(sprintf("--cores must be a positive integer, got '%s'", result$cores))
4391
}
4492

4593
return(result)
@@ -66,28 +114,45 @@ message(sprintf("Never-treated units (first_treat=Inf): %d", sum(is.infinite(dat
66114
message("Running Callaway-Sant'Anna estimation...")
67115
start_time <- Sys.time()
68116

69-
out <- att_gt(
117+
att_gt_args <- list(
70118
yname = "outcome",
71119
tname = "time",
72120
idname = "unit",
73121
gname = "first_treat",
74-
xformla = NULL,
122+
xformla = config$xformla,
75123
data = data,
76124
est_method = config$method,
77125
control_group = config$control_group,
78-
bstrap = FALSE, # Use analytical SEs for speed
79-
cband = FALSE
126+
bstrap = config$bstrap,
127+
biters = config$biters,
128+
cband = config$cband,
129+
pl = config$pl,
130+
cores = config$cores
80131
)
132+
# faster_mode exists in recent did releases only; pass it only when both
133+
# requested and supported, so the script still runs on older installs.
134+
if (!is.null(config$faster_mode)) {
135+
if ("faster_mode" %in% names(formals(att_gt))) {
136+
att_gt_args$faster_mode <- config$faster_mode
137+
} else {
138+
message("faster_mode not supported by this did version; ignoring flag")
139+
}
140+
}
141+
out <- do.call(att_gt, att_gt_args)
81142

82143
estimation_time <- as.numeric(difftime(Sys.time(), start_time, units = "secs"))
83144

84-
# Aggregate results
145+
# Aggregate results (same inference mode as att_gt so the timing arm does
146+
# equal work end-to-end)
85147
message("Aggregating results...")
86148
agg_start <- Sys.time()
87149

88-
agg_simple <- aggte(out, type = "simple", bstrap = FALSE, cband = FALSE)
89-
agg_dynamic <- aggte(out, type = "dynamic", bstrap = FALSE, cband = FALSE)
90-
agg_group <- aggte(out, type = "group", bstrap = FALSE, cband = FALSE)
150+
agg_simple <- aggte(out, type = "simple", bstrap = config$bstrap,
151+
biters = config$biters, cband = FALSE)
152+
agg_dynamic <- aggte(out, type = "dynamic", bstrap = config$bstrap,
153+
biters = config$biters, cband = config$cband)
154+
agg_group <- aggte(out, type = "group", bstrap = config$bstrap,
155+
biters = config$biters, cband = FALSE)
91156

92157
aggregation_time <- as.numeric(difftime(Sys.time(), agg_start, units = "secs"))
93158
total_time <- estimation_time + aggregation_time
@@ -131,13 +196,22 @@ results <- list(
131196
total_seconds = total_time
132197
),
133198

134-
# Metadata
199+
# Metadata (records the full inference/parallelism config so every
200+
# yardstick number is reproducible from its own artifact)
135201
metadata = list(
136202
r_version = R.version.string,
137203
did_version = as.character(packageVersion("did")),
138204
n_units = length(unique(data$unit)),
139205
n_periods = length(unique(data$time)),
140-
n_obs = nrow(data)
206+
n_obs = nrow(data),
207+
xformla = if (is.null(config$xformla)) NULL else deparse(config$xformla),
208+
bstrap = config$bstrap,
209+
biters = if (config$bstrap) config$biters else NULL,
210+
cband = config$cband,
211+
pl = config$pl,
212+
cores = config$cores,
213+
faster_mode = if (is.null(config$faster_mode)) "did-default" else config$faster_mode,
214+
blas = tryCatch(sessionInfo()$BLAS, error = function(e) NULL)
141215
)
142216
)
143217

diff_diff/staggered.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,10 @@ def _compute_att_gt_fast(
10081008
# Package influence function info with index arrays (positions into
10091009
# precomputed['all_units']) for O(1) downstream lookups instead of
10101010
# O(n) Python dict lookups.
1011+
# INVARIANT: treated_idx/control_idx are np.where over boolean masks,
1012+
# so each is duplicate-free - the aggregation fast path relies on this
1013+
# to scatter with fancy `psi[idx] += vals` (see
1014+
# staggered_aggregation._combined_if_fast).
10111015
n_t = int(n_treated)
10121016
all_units = precomputed["all_units"]
10131017
treated_positions = np.where(treated_valid)[0]
@@ -1576,6 +1580,9 @@ def _compute_all_att_gt_covariate_reg(
15761580
"skip_reason": None,
15771581
}
15781582

1583+
# INVARIANT: np.where over boolean masks -> duplicate-free
1584+
# index arrays (fancy-+= scatter contract, see
1585+
# staggered_aggregation._combined_if_fast).
15791586
all_units = precomputed["all_units"]
15801587
treated_positions = np.where(treated_valid)[0]
15811588
control_positions = np.where(control_valid)[0]
@@ -3511,7 +3518,11 @@ def _compute_att_gt_rc(
35113518
)
35123519

35133520
# Build influence function info
3514-
# For RCS, treated_idx/control_idx combine obs from BOTH periods
3521+
# For RCS, treated_idx/control_idx combine obs from BOTH periods.
3522+
# INVARIANT: the two period masks are disjoint (obs_time == t vs
3523+
# == base period), so the concatenated index arrays stay
3524+
# duplicate-free (fancy-+= scatter contract, see
3525+
# staggered_aggregation._combined_if_fast).
35153526
treated_idx = np.concatenate([np.where(treated_t)[0], np.where(treated_s)[0]])
35163527
control_idx = np.concatenate([np.where(control_t)[0], np.where(control_s)[0]])
35173528

0 commit comments

Comments
 (0)