Skip to content

Commit 61b30bb

Browse files
igerberclaude
andcommitted
BaconDecomposition methodology audit (Goodman-Bacon 2021)
Promotes BaconDecomposition from In Progress → Complete (R parity pending) in METHODOLOGY_REVIEW.md. Operationalizes the paper review landed in PR #451 against diff_diff/bacon.py. Audit findings and corrections: 1. Theorem 1 exact-weights rewrite (bacon.py:_recompute_exact_weights). The prior "exact" mode did not actually compute Eqs. 7-9 / 10e-g — it was missing the (1 - n_kU) factor in the within-subsample treatment variance, did not square the sample share, and added an extraneous unit_share factor not present in the paper. The post-hoc sum-to-1 normalization masked the relative-weight error but produced ~0.3% decomposition error vs TWFE (0.007 absolute on a 3-cohort + never- treated DGP). Rewrote the function to compute exact numerators of Eqs. 10e/f/g and let post-hoc normalization handle the V_hat^D denominator (Theorem 1 guarantees V_hat^D = sum(numerators)). Now matches TWFE at atol=1e-10 on noisy and hand-calculable DGPs. 2. Default `weights` flipped from "approximate" to "exact" at 3 entry points: BaconDecomposition.__init__() (bacon.py:397), bacon_decompose() (bacon.py:1064), TwoWayFixedEffects.decompose() (twfe.py:684). The approximate path remains opt-in for speed-sensitive diagnostic loops. diff_diff/diagnostic_report.py:1740 updated to pass explicit weights="exact". The existing test_weighted_sum_equals_twfe tolerance was tightened from < 0.1 to < 1e-10 to lock the Theorem 1 algebraic identity contract. **Survey-design behavior change**: weights="exact" routes through _validate_unit_constant_survey, which rejects survey designs whose weights / strata / PSU / FPC columns vary within a unit across periods. The previous approximate default tolerated time-varying within-unit survey weights via observation-level weighted means. Migration: pass weights="approximate" explicitly to retain the legacy path. Documented in CHANGELOG Changed entry and the new bacon_decompose() docstring survey_design parameter block. 3. Always-treated warn+remap per paper footnote 11 (bacon.py:fit()). Units with first_treat <= min(time) (excluding never-treated sentinels 0 and np.inf) are auto-remapped to U via an internal column (__bacon_first_treat_internal__), preserving the user's first_treat column unchanged. The count is exposed on the result via a new BaconDecompositionResults.n_always_treated_remapped field and rendered in summary() when nonzero. **n_never_treated reports TRUE never-treated only**, computed from the original user column before remap — remapped always-treated units appear separately as n_always_treated_remapped, no double- counting. **Loop gate uses POST-remap U count**: the treated_vs_never comparison loop gates on n_units_in_U_bucket (post-remap) so panels whose U is composed entirely of remapped always-treated units still emit beta_kU^{2x2} terms. Without this distinction the loop would silently drop those terms and break the Theorem 1 identity. **Ordered-time logic**: detection uses first_treat <= min(time) (not positive-sign restriction), so event-time panels with negative or zero-crossing period labels (e.g. time ∈ [-2,..,3]) work correctly. A cohort at first_treat=-1 on such a panel is a valid timing group; a cohort at first_treat=-3 is remapped to U. Both timing_groups filters updated to exclude only the U sentinels, not positive values. REGISTRY.md replacement: - Replaced ## BaconDecomposition block with paper-review-sourced content plus four sub-notes (weight modes, always-treated remap with ordered- time logic, R parity status, unbalanced-panel deviation). - Explicitly removed the prior block's "Weights may be negative for later-vs-earlier comparisons" claim. Theorem 1 weights are strictly positive and sum to 1 (positivity is the headline of the theorem); negative weights are an estimand-level phenomenon (Borusyak-Jaravel 2017, de Chaisemartin-D'Haultfoeuille 2020) at the ATT level, not estimator-level. - Narrowed the machine-precision claim to balanced panels only; the unbalanced-panel library extension is documented as an explicit Deviation block (Goodman-Bacon Appendix A's proof assumes balanced panels; under unbalance, the Theorem 1 identity holds only approximately, though outputs remain finite). New artifacts: - tests/test_methodology_bacon.py (~1050 lines, ~28 tests across 6 classes): - TestBaconHandCalculation: hand-checks Eqs. 7-9 + 10b-d at atol=1e-10 on a minimal hand-derived balanced panel (weights {0.3, 0.4, 0.1, 0.2} hand-computed from sample shares and treatment-share variances). - TestBaconParityR: skips on missing R goldens. - TestBaconAlwaysTreatedRemap: regression-tests warn+remap mechanics including user-data-preservation, U-bucket-only-from- remap (the bug from the loop-gate fix), negative first_treat as a valid cohort (event-time encoding), and remap of negative first_treat below min(time). - TestBaconEdgeCases: no-untreated, single-cohort, unbalanced panel (finite but NOT machine precision), constant-ATT recovery. - TestBaconWeightModes: locks exact-is-default contract. - TestBaconSurveyDesignNarrowing: survey_design composes with exact mode + warn+remap; defaulted BaconDecomposition(), bacon_decompose(), and the survey-time-varying-weights rejection contract are pinned. - benchmarks/R/generate_bacon_golden.R (234 lines): R generator script for bacondecomp::bacon() parity goldens across 3 DGP fixtures. JSON goldens deferred until bacondecomp R package is installed in the local R library (TODO.md follow-up row). CHANGELOG entries: ### Changed (default flip + survey behavior change), TODO.md: prior BaconDecomposition row replaced by narrower R-parity goldens deferral row. Test results: 96 passed, 3 skipped (R parity) across all bacon/decompose callers (test_bacon.py, test_methodology_bacon.py, test_business_report, test_methodology_twfe, test_practitioner, test_target_parameter, test_survey_phase3, test_survey_phase6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b926a10 commit 61b30bb

10 files changed

Lines changed: 1738 additions & 189 deletions

File tree

CHANGELOG.md

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

METHODOLOGY_REVIEW.md

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ The catalog grew incrementally over several quarters, so formats vary across the
7878

7979
| Tool | Module | R Reference | Status | Last Review |
8080
|------|--------|-------------|--------|-------------|
81-
| BaconDecomposition | `bacon.py` | `bacondecomp::bacon()` | **In Progress** | |
81+
| BaconDecomposition | `bacon.py` | `bacondecomp::bacon()` | **Complete** (R parity pending) | 2026-05-16 |
8282
| HonestDiD | `honest_did.py` | `HonestDiD` package | **Complete** | 2026-04-01 |
8383
| PreTrendsPower | `pretrends.py` | `pretrends` package | **In Progress** ||
8484
| PowerAnalysis | `power.py` | `pwr` / `DeclareDesign` | **In Progress** ||
@@ -909,18 +909,41 @@ and covariate-adjusted specifications.)
909909
| Module | `bacon.py` |
910910
| Primary Reference | Goodman-Bacon (2021), *Difference-in-differences with variation in treatment timing*, J. Econometrics 225(2), 254-277 |
911911
| R Reference | `bacondecomp::bacon()` |
912-
| Status | **In Progress** |
913-
| Last Review | |
912+
| Status | **Complete** (R parity goldens pending) |
913+
| Last Review | 2026-05-16 |
914914

915-
**Documentation in place:**
916-
- REGISTRY.md section: `## BaconDecomposition` (three comparison types — treated_vs_never, earlier_vs_later, later_vs_earlier; weight construction; TWFE reconstitution; weighted survey path under Phase 3)
917-
- Implementation: `tests/test_bacon.py` (basic decomposition, weight properties, integration with `TwoWayFixedEffects.decompose()`)
915+
**Verified Components:**
916+
- [x] Theorem 1 decomposition identity: `β̂^DD = Σ s · β̂^{2x2}` at `atol=1e-10` (hand-calculable + noisy DGPs)
917+
- [x] Weight sum-to-1: `Σ s = 1.0` at `atol=1e-10` under `weights="exact"`
918+
- [x] Three comparison types correctly classified: `treated_vs_never`, `earlier_vs_later`, `later_vs_earlier`
919+
- [x] Eq. 7 hand-checked: `V̂_{kU}^D = n_{kU}(1-n_{kU}) · D̄_k(1-D̄_k)` (via weight-ratio test, `atol=1e-10`)
920+
- [x] Eq. 8 hand-checked: `V̂_{kℓ}^{D,k} = n_{kℓ}(1-n_{kℓ}) · (D̄_k-D̄_ℓ)/(1-D̄_ℓ) · (1-D̄_k)/(1-D̄_ℓ)`
921+
- [x] Eq. 9 hand-checked: `V̂_{kℓ}^{D,ℓ} = n_{kℓ}(1-n_{kℓ}) · D̄_ℓ/D̄_k · (D̄_k-D̄_ℓ)/D̄_k`
922+
- [x] Eq. 10b 2x2 estimator value: hand-calculable panel → β̂_{kU}^{2x2} = ATT exactly
923+
- [x] Always-treated remap to U (paper footnote 11): `0 < first_treat <= min(time)` units auto-remapped via internal column, user's data preserved, count exposed on result
924+
- [x] `weights="exact"` is the default (PR-B 2026-05-16); `weights="approximate"` retained as opt-in
925+
- [x] Unbalanced panel: accepted with `UserWarning` (paper assumes balanced; library extension)
926+
- [x] No untreated group: `s_{kU}` terms drop, weights renormalize, sum-to-1 still holds
927+
- [x] Single timing group with U: only `treated_vs_never` comparisons
928+
- [x] Survey design composes cleanly with exact mode and warn+remap
929+
- [ ] R `bacondecomp::bacon()` parity at `atol=1e-6` — R generator script committed; JSON goldens pending follow-up R install (see TODO.md)
918930

919-
**Outstanding for promotion:**
920-
- **Substantive review pass — first target chosen during the 2026-05-15 methodology-review refresh session.** Read Goodman-Bacon (2021), audit `bacon.py` against the paper's decomposition (Equation 11, weight construction in Section 3, three comparison types in Section 4), generate R parity fixtures via `bacondecomp::bacon()`, write `tests/test_methodology_bacon.py` with paper-equation-numbered assertions, populate Verified Components / Corrections Made / Deviations here.
921-
- Paper review under `docs/methodology/papers/goodman-bacon-2021-review.md`
922-
- R parity fixture against `bacondecomp::bacon()` covering treated_vs_never, earlier_vs_later, later_vs_earlier weight buckets and their relative shares
923-
- Verify the REGISTRY Implementation Checklist (all rows currently unchecked except the survey-design Phase 3 row)
931+
**Test Coverage:**
932+
- 24 methodology tests in `tests/test_methodology_bacon.py` across 6 classes (21 active + 3 R-parity tests that skip on missing goldens)
933+
- 32 existing tests in `tests/test_bacon.py` (basic decomposition, weight properties, weights-parameter API, TWFE integration, visualization, balanced-panel warnings, edge cases)
934+
935+
**R Comparison Results:**
936+
- **Pending**: `bacondecomp` R package not installed in the local R 4.5.2 library at PR-B authoring time. R generator script committed at `benchmarks/R/generate_bacon_golden.R`; running it requires `install.packages("bacondecomp")` + `install.packages("jsonlite")` then `cd benchmarks/R && Rscript generate_bacon_golden.R`. JSON goldens land at `benchmarks/data/r_bacondecomp_golden.json` once generated. `tests/test_methodology_bacon.py::TestBaconParityR` skips with a pointer until then. Tracked in TODO.md follow-up row.
937+
938+
**Corrections Made:**
939+
1. **Theorem 1 exact-weights rewrite** (`bacon.py:_recompute_exact_weights`, lines ~740-880). The previous "exact" mode implementation did not actually compute Eqs. 7-9 / 10e-g — it was missing the `(1 - n_kU)` factor in the within-subsample treatment variance, did not square the sample share, and added an extraneous `unit_share` factor not present in the paper. The post-hoc sum-to-1 normalization masked the relative-weight error but produced a decomposition error of ~0.3% (0.007 absolute) against TWFE on a 3-cohort + never-treated DGP. **Rewrote** the function to compute the exact numerators of Eqs. 10e/f/g (with proper Eqs. 7-9 variances) and let the post-hoc normalization handle the `V̂^D` denominator (Theorem 1 identity guarantees `V̂^D = Σ numerators`). Now matches TWFE at `atol=1e-10`. The existing `test_weighted_sum_equals_twfe` tolerance was tightened from `< 0.1` to `< 1e-10` to lock the contract.
940+
2. **Default `weights` flipped from `"approximate"` to `"exact"`** at three entry points: `BaconDecomposition.__init__()` (`bacon.py:397`), `bacon_decompose()` convenience function (`bacon.py:1064`), `TwoWayFixedEffects.decompose()` (`twfe.py:684`). The paper-faithful Theorem 1 weights are now the default; the simplified approximate path remains opt-in via explicit `weights="approximate"`. `diff_diff/diagnostic_report.py:1740` (production diagnostic surface) was updated to pass explicit `weights="exact"`.
941+
3. **Always-treated warn+remap via internal column** (`bacon.py:fit()`, lines ~487-525). Paper footnote 11 puts units with `t_i < 1` in `U`, but `bacon.py` previously only mapped `first_treat ∈ {0, np.inf}` into U. Added detection of `0 < first_treat <= min(time)` with `UserWarning` and automatic remap via an internal column (`__bacon_first_treat_internal__`), preserving the user's `first_treat` column unchanged. Count exposed via new `BaconDecompositionResults.n_always_treated_remapped` field.
942+
943+
**Deviations from R's `bacondecomp::bacon()`:**
944+
1. **Unbalanced panel acceptance** (library extension): R errors on unbalanced panels; Python emits a `UserWarning` and decomposes. The paper's Appendix A proof assumes balanced panels — decomposition on unbalanced panels is approximate to Theorem 1.
945+
2. **Approximate weight mode** (Python-only optimization): `weights="approximate"` is a library-only fast path with simplified variance computation, not present in R. Users who want Python-R numerical parity should pass `weights="exact"` (the new default).
946+
3. **NaN for invalid inference fields not applicable**: the decomposition is deterministic; there are no SE/p-value fields on the comparison output. The `decomposition_error` field is a finite float (zero in well-conditioned cases).
924947

925948
---
926949

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Deferred items from PR reviews that were not addressed before merge.
7474

7575
| Issue | Location | PR | Priority |
7676
|-------|----------|----|----------|
77-
| BaconDecomposition: substantive methodology audit pass against Goodman-Bacon (2021). Paper review on file at `docs/methodology/papers/goodman-bacon-2021-review.md` includes a proposed Methodology Registry Entry, but the `REGISTRY.md` `## BaconDecomposition` section (lines ~2598-2654) still carries the older contract. Audit pass needs to (a) verify `bacon.py` matches Theorem 1 / Eqs. 10a-g exactly, (b) decide how to handle genuinely always-treated units (`0 < first_treat <= min(time)`) — paper convention puts them in `U`, but `bacon.py` currently treats only `first_treat ∈ {0, np.inf}` as the `U` bucket, (c) generate R parity fixtures via `bacondecomp::bacon()`, (d) write `tests/test_methodology_bacon.py`, (e) replace the REGISTRY entry with the proposed text, (f) populate Verified Components / Corrections Made / Deviations in `METHODOLOGY_REVIEW.md` and flip status to Complete. | `diff_diff/bacon.py`, `docs/methodology/REGISTRY.md`, `tests/test_methodology_bacon.py`, `METHODOLOGY_REVIEW.md` | follow-up | Medium |
77+
| BaconDecomposition R parity goldens: `bacondecomp` R package not installed in the local R 4.5.2 library at PR-B authoring time (2026-05-16). R generator script committed at `benchmarks/R/generate_bacon_golden.R`; running it requires `install.packages("bacondecomp")` + `install.packages("jsonlite")` then `cd benchmarks/R && Rscript generate_bacon_golden.R`, writing `benchmarks/data/r_bacondecomp_golden.json`. `tests/test_methodology_bacon.py::TestBaconParityR` (3 tests) skips with a pointer until the JSON lands. The PR-B audit substantiates Theorem 1 (Eqs. 7-9 + 10e-g) via hand-calculable + machine-precision identity tests; R parity is desirable as a cross-language anchor but not the only substantiation. Mirrors StaggeredTripleDifference precedent (PR #245). | `benchmarks/R/generate_bacon_golden.R`, `benchmarks/data/r_bacondecomp_golden.json` (TBD), `tests/test_methodology_bacon.py::TestBaconParityR` | follow-up | Medium |
7878
| dCDH: Phase 1 per-period placebo DID_M^pl has NaN SE (no IF derivation for the per-period aggregation path). Multi-horizon placebos (L_max >= 1) have valid SE. | `chaisemartin_dhaultfoeuille.py` | #294 | Low |
7979
| dCDH: Survey cell-period allocator's post-period attribution is a library convention, not derived from the observation-level survey linearization. MC coverage is empirically close to nominal on the test DGP; a formal derivation (or a covariance-aware two-cell alternative) is deferred. Documented in REGISTRY.md survey IF expansion Note. | `chaisemartin_dhaultfoeuille.py`, `docs/methodology/REGISTRY.md` | #408 | Medium |
8080
| dCDH: Parity test SE/CI assertions only cover pure-direction scenarios; mixed-direction SE comparison is structurally apples-to-oranges (cell-count vs obs-count weighting). | `test_chaisemartin_dhaultfoeuille_parity.py` | #294 | Low |
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env Rscript
2+
# Generate R bacondecomp parity goldens for diff-diff BaconDecomposition.
3+
#
4+
# Requires: install.packages("bacondecomp") (CRAN; main function is bacon())
5+
# install.packages("jsonlite")
6+
# Output: ../data/r_bacondecomp_golden.json
7+
#
8+
# The diff-diff BaconDecomposition implementation (`diff_diff/bacon.py`) with
9+
# the default ``weights="exact"`` is expected to match the values in this JSON
10+
# to atol=1e-6 on the per-component (treated, control, type) tuples, and to
11+
# match the TWFE coefficient to the same tolerance. The ``weights="approximate"``
12+
# path is a library-only optimization and is NOT covered by this parity harness.
13+
#
14+
# Three fixtures:
15+
# 1. uniform_3groups_with_never_treated — 3 timing groups + never-treated U;
16+
# exercises all three comparison types (treated/never, earlier/later,
17+
# later/earlier).
18+
# 2. two_groups_no_never_treated — 2 timing groups only; tests the
19+
# timing-only decomposition where the s_{kU} terms drop.
20+
# 3. always_treated_remapped — 3 timing groups + 1 always-treated cohort
21+
# (first_treat = 1). Validates that Python's warn+remap of t_i < 1 into
22+
# U matches R bacondecomp's native behavior.
23+
#
24+
# Run:
25+
# cd benchmarks/R && Rscript generate_bacon_golden.R
26+
27+
suppressPackageStartupMessages({
28+
library(bacondecomp)
29+
library(jsonlite)
30+
})
31+
32+
stopifnot(packageVersion("bacondecomp") >= "0.1.0")
33+
34+
# ---------------------------------------------------------------------------
35+
# DGP helpers
36+
# ---------------------------------------------------------------------------
37+
38+
# Build a balanced panel with absorbing treatment.
39+
# n_units : units per timing group (excluding never-treated)
40+
# n_periods : panel length (1..T)
41+
# cohort_times : vector of first-treatment times, one per cohort
42+
# always_treated_count : optional cohort treated at first_treat = 1
43+
# (i.e., always-treated for the observable window)
44+
# never_treated_count : units with first_treat = 0
45+
# true_effect : constant ATT
46+
# seed : reproducibility
47+
build_panel <- function(n_units_per_cohort, n_periods, cohort_times,
48+
always_treated_count = 0L, never_treated_count = 0L,
49+
true_effect = 2.0, seed = 42L) {
50+
set.seed(seed)
51+
units <- list()
52+
uid <- 1L
53+
54+
# Always-treated cohort (first_treat = 1; treated in every observable period)
55+
if (always_treated_count > 0L) {
56+
for (i in seq_len(always_treated_count)) {
57+
units[[length(units) + 1L]] <- data.frame(
58+
unit = uid, time = seq_len(n_periods), first_treat = 1L
59+
)
60+
uid <- uid + 1L
61+
}
62+
}
63+
64+
# Never-treated U
65+
if (never_treated_count > 0L) {
66+
for (i in seq_len(never_treated_count)) {
67+
units[[length(units) + 1L]] <- data.frame(
68+
unit = uid, time = seq_len(n_periods), first_treat = 0L
69+
)
70+
uid <- uid + 1L
71+
}
72+
}
73+
74+
# Treated cohorts
75+
for (g in cohort_times) {
76+
for (i in seq_len(n_units_per_cohort)) {
77+
units[[length(units) + 1L]] <- data.frame(
78+
unit = uid, time = seq_len(n_periods), first_treat = as.integer(g)
79+
)
80+
uid <- uid + 1L
81+
}
82+
}
83+
84+
df <- do.call(rbind, units)
85+
86+
# Treatment indicator: D_{it} = 1 iff first_treat in {1,..,T} AND time >= first_treat.
87+
df$D <- as.integer(df$first_treat > 0L & df$time >= df$first_treat)
88+
89+
# Outcome: unit FE + linear time + constant treatment effect + noise.
90+
unit_fe <- rnorm(uid - 1L, sd = 2.0)
91+
df$y <- unit_fe[df$unit] +
92+
0.1 * df$time +
93+
true_effect * df$D +
94+
rnorm(nrow(df), sd = 0.5)
95+
96+
df
97+
}
98+
99+
# ---------------------------------------------------------------------------
100+
# Extract bacondecomp::bacon() output into a fixture-shaped list.
101+
# ---------------------------------------------------------------------------
102+
103+
extract_bacon <- function(df, fixture_name) {
104+
# bacondecomp::bacon takes the OUTCOME ~ TREATMENT formula plus id_var/time_var.
105+
# It returns a data.frame with columns: treated, untreated, estimate, weight,
106+
# plus a `type` column (e.g. "Both Treated", "Treated vs Untreated"), and an
107+
# attribute beta_hat_w (the weighted sum, which equals the TWFE coefficient).
108+
res <- bacondecomp::bacon(
109+
formula = y ~ D,
110+
data = df,
111+
id_var = "unit",
112+
time_var = "time"
113+
)
114+
115+
# When the data contains a never-treated group, bacon() returns a list with
116+
# $two_by_twos (the per-component table) and $Omega (the variance-weighted
117+
# contributions). Without never-treated, it returns the data.frame directly.
118+
if (is.list(res) && !is.data.frame(res)) {
119+
components_df <- res$two_by_twos
120+
twfe_coef <- as.numeric(attr(res, "beta_hat_w"))
121+
# Fallback: re-derive TWFE from the components if attr is missing.
122+
if (is.null(twfe_coef) || length(twfe_coef) == 0L) {
123+
twfe_coef <- sum(components_df$estimate * components_df$weight)
124+
}
125+
} else {
126+
components_df <- res
127+
twfe_coef <- sum(components_df$estimate * components_df$weight)
128+
}
129+
130+
# Components vary across bacondecomp versions; normalize the column names.
131+
cols <- names(components_df)
132+
treated_col <- if ("treated" %in% cols) "treated" else "g1"
133+
untreated_col <- if ("untreated" %in% cols) "untreated" else "g2"
134+
estimate_col <- if ("estimate" %in% cols) "estimate" else "Estimate"
135+
weight_col <- if ("weight" %in% cols) "weight" else "Weight"
136+
type_col <- if ("type" %in% cols) "type" else NA_character_
137+
138+
components <- lapply(seq_len(nrow(components_df)), function(i) {
139+
list(
140+
treated_group = as.numeric(components_df[[treated_col]][i]),
141+
control_group = as.numeric(components_df[[untreated_col]][i]),
142+
estimate = as.numeric(components_df[[estimate_col]][i]),
143+
weight = as.numeric(components_df[[weight_col]][i]),
144+
type = if (!is.na(type_col))
145+
as.character(components_df[[type_col]][i])
146+
else NA_character_
147+
)
148+
})
149+
150+
weights_sum <- sum(sapply(components, function(c) c$weight))
151+
152+
list(
153+
panel = list(
154+
unit = as.integer(df$unit),
155+
time = as.integer(df$time),
156+
y = as.numeric(df$y),
157+
first_treat = as.integer(df$first_treat),
158+
treated = as.integer(df$D)
159+
),
160+
r_twfe_coef = twfe_coef,
161+
r_components = components,
162+
r_weights_sum = weights_sum,
163+
n_components = length(components)
164+
)
165+
}
166+
167+
# ---------------------------------------------------------------------------
168+
# Fixtures
169+
# ---------------------------------------------------------------------------
170+
171+
cat("Building fixture 1: uniform_3groups_with_never_treated...\n")
172+
df1 <- build_panel(
173+
n_units_per_cohort = 30L,
174+
n_periods = 6L,
175+
cohort_times = c(3L, 4L, 5L),
176+
always_treated_count = 0L,
177+
never_treated_count = 30L,
178+
true_effect = 2.0,
179+
seed = 101L
180+
)
181+
fixture_1 <- extract_bacon(df1, "uniform_3groups_with_never_treated")
182+
183+
cat("Building fixture 2: two_groups_no_never_treated...\n")
184+
df2 <- build_panel(
185+
n_units_per_cohort = 30L,
186+
n_periods = 6L,
187+
cohort_times = c(3L, 5L),
188+
always_treated_count = 0L,
189+
never_treated_count = 0L,
190+
true_effect = 2.0,
191+
seed = 202L
192+
)
193+
fixture_2 <- extract_bacon(df2, "two_groups_no_never_treated")
194+
195+
cat("Building fixture 3: always_treated_remapped...\n")
196+
# 3 timing-cohorts + 5 always-treated units (first_treat = 1, i.e., treated
197+
# in every observable period) + 30 never-treated. R's bacondecomp natively
198+
# groups the first_treat=1 cohort with U (since they are treated throughout
199+
# every observable period and never serve as a within-window control), which
200+
# matches what diff-diff's warn+remap does in Python.
201+
df3 <- build_panel(
202+
n_units_per_cohort = 25L,
203+
n_periods = 6L,
204+
cohort_times = c(3L, 4L, 5L),
205+
always_treated_count = 5L,
206+
never_treated_count = 25L,
207+
true_effect = 2.0,
208+
seed = 303L
209+
)
210+
fixture_3 <- extract_bacon(df3, "always_treated_remapped")
211+
212+
# ---------------------------------------------------------------------------
213+
# Write JSON
214+
# ---------------------------------------------------------------------------
215+
216+
out <- list(
217+
meta = list(
218+
generated_at = format(Sys.Date()),
219+
bacondecomp_version = as.character(packageVersion("bacondecomp")),
220+
r_version = R.version.string,
221+
description = paste(
222+
"Goodman-Bacon (2021) decomposition parity goldens for diff-diff",
223+
"BaconDecomposition. Parity target: atol=1e-6 on per-component",
224+
"(treated, control, type) tuples plus the TWFE coefficient."
225+
)
226+
),
227+
uniform_3groups_with_never_treated = fixture_1,
228+
two_groups_no_never_treated = fixture_2,
229+
always_treated_remapped = fixture_3
230+
)
231+
232+
out_path <- "../data/r_bacondecomp_golden.json"
233+
write_json(out, out_path, pretty = TRUE, digits = NA, auto_unbox = TRUE)
234+
cat(sprintf("Wrote %s\n", out_path))

0 commit comments

Comments
 (0)