Skip to content

Commit cfea355

Browse files
igerberclaude
andcommitted
feat(callaway): allow_unbalanced_panel — RC-on-panel parity with R did
Adds CallawaySantAnna(allow_unbalanced_panel=True) matching R did::att_gt(allow_unbalanced_panel=TRUE) on unbalanced panels. When set and the input panel is unbalanced, the pooled observations route through the repeated-cross-section levels estimator (DRDID::reg_did_rc) instead of the default within-cell panel differencing (a different estimand on unbalanced data), and the per-observation influence function is clustered by the original unit. ATT matches R bit-for-bit (cells AND dynamic aggregation): the RC cohort mass and the aggregation pg use fixed UNIT cohort mass (not observation count), and the per-unit WIF divides the per-observation weight-influence-function by each unit's observation count so the unit-clustered sum is not over-counted. The analytical SE matches R up to the documented CR1 sqrt(G/(G-1)) finite-sample factor (diff-diff's cluster path applies the Bessel correction R's att_gt getSE omits). The bootstrap resamples by unit via the same PSU. Inert on balanced panels (byte-identical to the default). Independently, the default path now emits a UserWarning on unbalanced input (previously silent). survey_design= with the flag raises NotImplementedError (deferred). Routing engages only when the panel is actually unbalanced (route-as-RC via an internal flag threaded through the six self.panel sites; the unique-unit guard is naturally bypassed since panel stays True). Verified vs R did 2.5.1 (benchmarks/data/cs_unbalanced_golden.json + TestAllowUnbalancedPanel); 499 CS tests pass (RC / survey / panel paths unchanged). The TODO's "weighting" framing was a mis-diagnosis: the dominant unbalanced divergence is the estimator choice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178r8ZK6VRiXbaBiknrWjQB
1 parent 9208007 commit cfea355

10 files changed

Lines changed: 926 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5050
`UserWarning`. `leave_one_out` is recorded on `ImputationDiDResults` (and `to_dict()` / `summary()`);
5151
replicate-weight survey designs raise `NotImplementedError` (their variance bypasses the
5252
influence-function path). Default `False` preserves R `didimputation` parity.
53+
- **`CallawaySantAnna(allow_unbalanced_panel=True)`** — parity with R
54+
`did::att_gt(allow_unbalanced_panel=TRUE)` on unbalanced panels. When set and the input panel is
55+
unbalanced (some units unobserved in some periods), the pooled observations are routed through the
56+
repeated-cross-section levels estimator (`DRDID::reg_did_rc`), replacing the default within-cell
57+
panel differencing (a different estimand on unbalanced data), and the per-observation influence
58+
function is clustered by the original unit. **ATT matches R bit-for-bit** — per-cell AND dynamic
59+
aggregation (fixed unit-cohort-mass `pg` reweighting + a per-unit WIF correction); the analytical
60+
SE matches up to the documented CR1 `sqrt(G/(G-1))` finite-sample factor. **Inert on balanced
61+
panels** (byte-identical to the default). Independently, the default path now emits a `UserWarning`
62+
on unbalanced input (previously silent) pointing to the flag. `survey_design=` with the flag raises
63+
`NotImplementedError` (deferred). Verified against R `did` 2.5.1
64+
(`benchmarks/data/cs_unbalanced_golden.json`).
5365
- **`ContinuousDiD` lowest-dose-as-control** (`control_group="lowest_dose"`, CGBS 2024 Remark 3.1) for
5466
settings with no untreated group (`P(D=0) = 0`): the lowest-dose group `d_L` becomes the comparison
5567
and the estimand is `ATT(d) − ATT(d_L)` (with `ATT(d_L) = 0` the omitted reference). It is a

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ Doable in principle, but no current caller and/or explicitly out of paper scope.
128128

129129
| Issue | Location | PR | Priority |
130130
|-------|----------|----|----------|
131-
| CallawaySantAnna **unbalanced-panel event-study weighting**: a dynamic horizon pooling >1 (g,t) cell weights each cell by its per-cell aggregation weight (valid `n_treated` / `agg_weight`), but R `did::aggte` weights by the fixed cohort probability `pg = n_g/N` (or survey mass) from the group column. Balanced panels coincide exactly (verified ~1e-5, incl. universal zero-reference dilution); unbalanced panels can differ at multi-cell horizons (e.g. a two-real-cell horizon `-0.065` vs R `-0.136`). Pre-existing + general (independent of universal reference cells; documented Deviation from R in REGISTRY). Fix = weight every event-study cell by fixed cohort mass (touches `_aggregate_event_study` + the bootstrap bucket + WIF `pg` consistency); gate on balanced byte-identity + fresh unbalanced R parity. | `staggered_aggregation.py`, `staggered_bootstrap.py` | SE-audit D3 | Low |
131+
| CallawaySantAnna **unbalanced-panel R parity — LANDED** via `allow_unbalanced_panel=True` (matches R `did::att_gt(allow_unbalanced_panel=TRUE)` / `DRDID::reg_did_rc`: ATT bit-exact on cells AND dynamic aggregation via fixed unit-cohort-mass `pg` + a per-unit WIF; SE up to the documented CR1 `sqrt(G/(G-1))` factor). The earlier "weighting" framing was a mis-diagnosis — on unbalanced panels the dominant divergence from R is the *estimator* (within-cell differencing vs RC-on-pooled-obs), not only the weighting; both are resolved by the flag. The DEFAULT path keeps within-cell differencing as a documented design choice and now emits a `UserWarning` on unbalanced input (no-silent-failures). **Remaining deferred:** `survey_design=` × `allow_unbalanced_panel=` (per-obs vs per-unit weight resolution — currently fail-closed `NotImplementedError`); and covariate / ipw / dr × the flag R-parity verification (the RC path supports them; the committed golden covers `reg` no-cov). | `staggered.py`, `staggered_aggregation.py` | SE-audit D3 | Low |
132132
| CallawaySantAnna event-study bucket/weight construction is duplicated between the analytical aggregator (`staggered_aggregation.py::_aggregate_event_study`) and the multiplier bootstrap (`staggered_bootstrap.py`): both group (g,t) by `e = t - g`, apply the finite/NaN/reference masks, and read cohort weights. Both already consume the same source-materialized universal reference cells (so they agree), but the bucket logic is copy-pasted. Extract one shared helper returning per-event-time buckets (finite cells, NaN cells, reference flags, cohort weights, combined-IF inputs) used by both. Pure refactor; gate on byte-identical analytical + bootstrap output. | `staggered_aggregation.py`, `staggered_bootstrap.py` | SE-audit D3 | Low |
133133
| `StackedDiD` survey re-resolution intra-file dedup (raw-weight extraction ×3, compose-normalize ×3, resolve-on-stacked ×2). The cross-estimator ContinuousDiD/EfficientDiD panel-to-unit collapse consolidation LANDED (#226 shared helpers `ResolvedSurveyDesign.subset_to_units_by_row_idx` / `build_unit_first_row_index`); StackedDiD is deliberately NOT on that path (control units are duplicated across sub-experiments, so it re-resolves at stacked granularity rather than collapsing to one row per unit). The residual is stacked-specific, low value, and touches the numerically-sensitive composed-weight renormalization. Post-filter re-resolution / metadata-recompute unification across the three estimators was assessed and is not warranted — they use genuinely different mechanisms and already delegate to shared `_resolve_survey_for_fit` / `compute_survey_metadata`. | `stacked_did.py` | #226 | Low |
134134
| `solve_ols` residual memory floor is inherent to SVD-based lstsq after the PR-E marshalling slim (rust-side allocator high-water 15.32 -> 7.81 GB on the 2.4M x 130 clustered solve; remaining = one fused equilibrated input copy + thin-SVD U transient + vcov scores block + LAPACK gelsd internals on the python path). Further reduction requires the tall-skinny-QR algorithm change - same trade-off as the parked QR-reuse row below (library-wide last-digit perturbation + loses the second independent collinearity detector); the two land together if ever reopened. Diagnosis + measurements in docs/performance-plan.md. | `rust/src/linalg.rs::solve_ols`, `diff_diff/linalg.py` | PR-E | Low |
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env Rscript
2+
# Golden values for CallawaySantAnna(allow_unbalanced_panel=True) parity vs
3+
# R did::att_gt(allow_unbalanced_panel=TRUE) + aggte(type="dynamic").
4+
#
5+
# Usage: Rscript benchmarks/R/generate_cs_unbalanced_golden.R
6+
# Output: benchmarks/data/cs_unbalanced_golden.json
7+
#
8+
# R's allow_unbalanced_panel=TRUE sets panel=FALSE and runs the repeated-cross-
9+
# section levels estimator (DRDID::reg_did_rc) on the pooled observations.
10+
# diff-diff matches the ATT bit-for-bit; the analytical SE matches up to the
11+
# CR1 G/(G-1) finite-sample factor diff-diff's cluster path applies (see the
12+
# test + REGISTRY note). The panel is deliberately UNBALANCED (attrition in
13+
# cohort 3 at t>=4) so a cell's valid-unit count < its cohort mass, exercising
14+
# the obs-vs-unit pg weighting and the RC-on-panel estimator.
15+
suppressMessages({
16+
library(did)
17+
library(jsonlite)
18+
})
19+
20+
set.seed(20260706)
21+
22+
make_panel <- function() {
23+
rows <- list()
24+
k <- 1L
25+
add_unit <- function(u, g, drop_late) {
26+
ufe <- rnorm(1)
27+
for (t in 1:5) {
28+
if (drop_late && t >= 4 && runif(1) < 0.40) next
29+
post <- if (g != 0 && t >= g) 1 else 0
30+
eff <- if (g == 3 && post) 1.0 * (t - g + 1) else if (g == 4 && post) 2.0 * (t - g + 1) else 0
31+
rows[[k]] <<- data.frame(unit = u, period = t, g = g, y = ufe + 0.3 * t + eff + rnorm(1, 0, 0.5))
32+
k <<- k + 1L
33+
}
34+
}
35+
u <- 0L
36+
for (i in 1:50) { add_unit(u, 3, TRUE); u <- u + 1L }
37+
for (i in 1:50) { add_unit(u, 4, FALSE); u <- u + 1L }
38+
for (i in 1:100) { add_unit(u, 0, FALSE); u <- u + 1L }
39+
do.call(rbind, rows)
40+
}
41+
42+
df <- make_panel()
43+
44+
out <- att_gt(
45+
yname = "y", tname = "period", idname = "unit", gname = "g", data = df,
46+
control_group = "nevertreated", est_method = "reg",
47+
allow_unbalanced_panel = TRUE, bstrap = FALSE, cband = FALSE
48+
)
49+
agg <- aggte(out, type = "dynamic", na.rm = TRUE, bstrap = FALSE, cband = FALSE)
50+
agg_simple <- aggte(out, type = "simple", na.rm = TRUE, bstrap = FALSE, cband = FALSE)
51+
agg_group <- aggte(out, type = "group", na.rm = TRUE, bstrap = FALSE, cband = FALSE)
52+
53+
golden <- list(
54+
meta = list(
55+
did_version = as.character(packageVersion("did")),
56+
n_units = length(unique(df$unit)),
57+
note = paste0(
58+
"allow_unbalanced_panel=TRUE (panel=FALSE -> DRDID::reg_did_rc on pooled ",
59+
"obs). ATT bit-exact vs diff-diff; SE parity up to the CR1 G/(G-1) factor."
60+
)
61+
),
62+
data = list(
63+
unit = as.numeric(df$unit),
64+
period = as.numeric(df$period),
65+
first_treat = as.numeric(df$g),
66+
outcome = as.numeric(df$y)
67+
),
68+
cells = list(
69+
g = as.numeric(out$group),
70+
t = as.numeric(out$t),
71+
att = as.numeric(out$att),
72+
se = as.numeric(out$se)
73+
),
74+
event_study = list(
75+
egt = as.numeric(agg$egt),
76+
att = as.numeric(agg$att.egt),
77+
se = as.numeric(agg$se.egt),
78+
overall_att = as.numeric(agg$overall.att),
79+
overall_se = as.numeric(agg$overall.se)
80+
),
81+
simple = list(
82+
overall_att = as.numeric(agg_simple$overall.att),
83+
overall_se = as.numeric(agg_simple$overall.se)
84+
),
85+
group = list(
86+
egt = as.numeric(agg_group$egt),
87+
att = as.numeric(agg_group$att.egt),
88+
se = as.numeric(agg_group$se.egt),
89+
overall_att = as.numeric(agg_group$overall.att),
90+
overall_se = as.numeric(agg_group$overall.se)
91+
)
92+
)
93+
94+
out_path <- file.path("benchmarks", "data", "cs_unbalanced_golden.json")
95+
writeLines(toJSON(golden, auto_unbox = TRUE, digits = 16, pretty = TRUE), out_path)
96+
cat("Wrote", out_path, "\n")

benchmarks/data/cs_unbalanced_golden.json

Lines changed: 37 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)