Methodology Registry#
This document provides the academic foundations and key implementation requirements for each estimator in diff-diff. It serves as a reference for contributors and users who want to understand the theoretical basis of the methods.
Result-class field naming. Headline scalar inference fields appear under one of four native naming patterns: flat att / se / conf_int / p_value / t_stat (DiDResults, SyntheticDiDResults, TROPResults, TripleDifferenceResults, HeterogeneousAdoptionDiDResults); overall_* (CallawaySantAnnaResults and the rest of the staggered family); overall_att_* (ContinuousDiDResults, where att and acrt are parallel response curves); and avg_* (MultiPeriodDiDResults). Result classes in the prefixed overall_* / overall_att_* / avg_* families additionally expose the flat att / se / conf_int / p_value / t_stat names as read-only @property aliases over their canonical fields, for adapter / external-consumer compatibility (see PR for v3.3.3, motivated by balance.interop.diff_diff). The flat-native classes (DiDResults, SyntheticDiDResults, TROPResults, TripleDifferenceResults, HeterogeneousAdoptionDiDResults) already carry these names as native dataclass fields and are unchanged by this contract. ContinuousDiDResults further exposes overall_* aliases pointing at the ATT side (so result.overall_se reads result.overall_att_se, etc.). The native field is canonical for documentation, semantics, and computation — aliases are pure read-throughs and inherit the safe_inference() joint-NaN consistency contract automatically. Because aliases are @property descriptors (not dataclass fields), they do NOT appear in dataclasses.fields() or dataclasses.asdict() output, and assignment to an alias raises AttributeError; serializers and field-walkers continue to see only the canonical field set.
Table of Contents#
Core DiD Estimators#
DifferenceInDifferences#
Primary source: Canonical econometrics textbooks
Wooldridge, J.M. (2010). Econometric Analysis of Cross Section and Panel Data, 2nd ed. MIT Press.
Angrist, J.D., & Pischke, J.-S. (2009). Mostly Harmless Econometrics. Princeton University Press.
Key implementation requirements:
Assumption checks / warnings:
Treatment and post indicators must be binary (0/1) with variation in both
Warns if no treated units in pre-period or no control units in post-period
Parallel trends assumption is untestable but can be assessed with pre-treatment data
Estimator equation (as implemented):
ATT = (Ȳ_{treated,post} - Ȳ_{treated,pre}) - (Ȳ_{control,post} - Ȳ_{control,pre})
= E[Y(1) - Y(0) | D=1]
Regression form:
Y_it = α + β₁(Treated_i) + β₂(Post_t) + τ(Treated_i × Post_t) + X'γ + ε_it
where τ is the ATT.
Standard errors:
Default: HC1 heteroskedasticity-robust
Optional: Cluster-robust (specify
clusterparameter)Optional: Wild cluster bootstrap for small number of clusters
Edge cases:
Empty cells (e.g., no treated-pre observations) cause rank deficiency, handled per
rank_deficient_actionsettingWith “warn” (default): emits warning, sets NaN for affected coefficients
With “error”: raises ValueError
With “silent”: continues silently with NaN coefficients
Singleton clusters (one observation): included in variance estimation; contribute to meat matrix via u_i² X_i X_i’ (same formula as larger clusters with n_g=1)
Rank-deficient design matrix (collinearity): warns and sets NA for dropped coefficients (R-style, matches
lm())Tolerance:
1e-07(matches R’sqr()default), relative to largest diagonal element of R in QR decompositionControllable via
rank_deficient_actionparameter: “warn” (default), “error”, or “silent”Note (scale invariance): the shared
diff_diff/linalg.pyrank check re-detects on column-equilibrated (unit 2-norm) columns when the raw pass reports a deficiency (adopting the higher equilibrated rank and its scale-corrected pivot selection only when a large-scale column inflated the threshold; genuine collinearity keeps the raw selection), and the least-squares solve equilibrates columns then unscales the coefficients — so rank detection and the fit are invariant to per-column scaling; for a well-scaled collinear design the dropped column is unchanged, while a scale-induced under-count adopts the scale-corrected equilibrated selection (which may differ from the raw choice but is guaranteed to retain an identified subset). This covers every covariate outcome-regression fit routed throughsolve_ols— DiD, TwoWayFixedEffects, MultiPeriodDiD, ImputationDiD, TwoStageDiD, TripleDifference, and (as of the OR scale-equilibration change) CallawaySantAnna’s_compute_all_att_gt_covariate_reg/_doubly_robustand StaggeredTripleDifference’s_compute_or— see the CallawaySantAnna rank-deficiency Note’s scope statement. Certification fast path (2026-07): the common full-rank case is short-circuited by a Gram/eigenvalue certification on the equilibrated Gram at the documented_rank_guarded_inv1e-10 Gram threshold — two orders stricter than the QR full-rank boundary, so it never reports full rank where the pivoted-QR path would report a deficiency; deficient or uncertifiable designs (including all n < k and non-finite inputs) fall through to the pivoted-QR path verbatim, leaving every drop decision, pivot selection, and rank count unchanged. Certification never SELECTS columns (it only confirms none need dropping), so the_rank_guarded_invnote about equilibrated-Gram column selection being reserved for the IF inverse still holds.
Note (covariate-name collision guard):
DifferenceInDifferences,MultiPeriodDiD, andTwoWayFixedEffectsbuild theircoefficientsdict by zipping a variable-name list (structural terms + user covariate column names, appended verbatim) with the coefficient vector. A covariate whose name equals a reserved structural name —const, the treatment/time column names, the{treatment}:{time}interaction (DiD), theperiod_{p}dummies /{treatment}:period_{p}interactions (MultiPeriodDiD),ATT(TwoWayFixedEffects), any fixed-effect / unit / time dummy name, or an internal_-prefixed working column (_treat_time,_did_treatment,_treatment_post) — would silently overwrite that structural coefficient (dict last-write-wins) with no error.fit()now callsutils.validate_covariate_names()before building the design, raisingValueErroron such a collision (case-sensitive, soConstis allowed) and on duplicate covariate names. Reserved fixed-effect/unit/time dummy names are taken from the samepd.get_dummies(..., drop_first=True)call used to build them (exact match, including forCategoricalcolumns). The TwoWayFixedEffects guard fires on both variance paths — the within-transform path exposes only{"ATT": att}, but a_treatment_post-named covariate would still clobber the internal interaction column. The influence-function estimators (CallawaySantAnna, etc.) key results by(g, t)and are unaffected.
Reference implementation(s):
R:
fixest::feols()with interaction termStata:
reghdfeor manual regression with interaction
Requirements checklist:
[x] Treatment and time indicators are binary 0/1 with variation
[x] ATT equals coefficient on interaction term
[x] Wild bootstrap supports Rademacher, Mammen, Webb weight distributions
[x] Formula interface parses
y ~ treated * postcorrectly
Wild cluster bootstrap (WCR)#
inference="wild_bootstrap" (with cluster=) runs the Wild Cluster Restricted (WCR)
bootstrap of Cameron, Gelbach & Miller (2008), matching the defaults of R’s
fwildclusterboot::boottest (Roodman, MacKinnon, Nielsen & Webb 2019). Implemented in
diff_diff.utils.wild_bootstrap_se; used by DifferenceInDifferences and (inherited)
TwoWayFixedEffects. (MultiPeriodDiD does not support it — it falls back to analytical
inference and the inherited p_val_type is inert there.)
Algorithm (test of H₀: τ = r, default r = 0):
Impose the null by dropping the interaction column and re-fitting the reduced model; the restricted residuals are
ũ(r) = M₋ⱼ y − r·M₋ⱼ xⱼ(linear inr, whereM₋ⱼis the annihilator of the design without columnj). Rank-deficient nuisance columns are dropped viasolve_olsso the identified ATT (and the bootstrap) stay finite.Cluster sign-vectors
w_g: for Rademacher weights with few clusters the full set of2**n_clusterssign-vectors is enumerated (deterministic) when2**n_clusters ≤ n_bootstrapandn_clusters ≤ 20(a guard against pathological memory use) — the same full-enumeration triggerboottestuses (it switches to enumeration oncen_bootstrapreaches the number of possible draws2**n_clusters; verified empirically — forG=10it samples atB=1023and enumerates atB=1024). Only2**(n_clusters-1)of those draws have distinct|t*|(each draw and its all-signs-flipped mirror share|t*|), but the full set is materialized andn_bootstrapis reported as2**n_clusters. Otherwise signs are sampled (seed-reproducible). Webb/Mammen always sample (the sign-flip symmetry is Rademacher-specific).Bootstrap statistic
t*(r) = (β*ⱼ − r) / se*, where each draw refits the full design ony* = (y − ũ(r)) + ũ(r)·wandse*is the CR1 cluster-robust SE ofβ*ⱼ. The observed statistic ist₀ = (τ̂ − r)/se_awith the analytical CR1 SEse_a.p-value: two-tailed
mean(|t*| > |t₀|)or equal-tailed2·min(mean(t*<t₀), mean(t*>t₀))— strict>, matching boottest (the all-(+1)/all-(−1) enumerated draws reproduce ±t₀ and are excluded as boundary ties). Floored at1/(n_valid+1)(see Deviation below).Confidence interval by test inversion: the set of nulls
rnot rejected atalpha, located by outward bracketing + bisection on the (monotone, step) rejection frequency. The CI is therefore exactly consistent with the p-value (0 ∈ CI ⟺ p ≥ alpha) and may be asymmetric.The reported
seisse_a(analytical CR1);p_val_type ∈ {"two-tailed" (default), "equal-tailed"}. CR1 uses the standard(G/(G−1))((N−1)/(N−k))correction, which cancels in|t*|vs|t₀|so it affects only the reported SE, not the p-value or CI.
Verification — R parity: validated against fwildclusterboot::boottest() defaults on a fixed
few-cluster golden (benchmarks/R/generate_wild_cluster_boot_golden.R →
benchmarks/data/wild_cluster_boot_golden.json), enumerated and deterministic on both sides. The
bootstrap t-distribution matches R to ~6e-14; se, t₀, and the (interior) p-value match exactly;
the inverted CI matches to ~1e-4 (bisection vs boottest’s grid search). Also pinned against an
independent full-refit enumeration in tests/test_wild_bootstrap.py::test_wcr_matches_independent_bruteforce.
Note: The reported quantities mix inference families by design:
t_stat_originalis the analytical Wald statisticτ̂/se_a, whilep_valueandconf_intcome from the bootstrap test inversion. This is intentional (it is exactly the boottest convention) and is not a deviation.Deviation from R: the p-value is floored at
1/(n_valid+1)to avoid reporting an exact0(which boottest can return under full enumeration of a strong effect) — but the floor is applied only when1/(n_valid+1) < alpha. With very few valid draws the floor can exceedalpha; applying it there would lift a bootstrap-significant p (0 outside the inverted CI) to “non-significant”, contradicting the CI, so in that regime the raw p (which boottest also reports, possibly 0) is returned. The significance verdict therefore always matches the inverted CI (0 ∈ CI ⟺ p ≥ alpha).
MultiPeriodDiD#
Primary source: Event study methodology
Freyaldenhoven, S., Hansen, C., Pérez, J.P., & Shapiro, J.M. (2021). Visualization, identification, and estimation in the linear panel event-study design. NBER Working Paper 29170.
Wooldridge, J.M. (2010). Econometric Analysis of Cross Section and Panel Data, 2nd ed. MIT Press, Ch. 10, 13.
Angrist, J.D., & Pischke, J.-S. (2009). Mostly Harmless Econometrics. Princeton University Press.
Scope: Simultaneous adoption event study. All treated units receive treatment at the same time. For staggered adoption (different units treated at different times), use CallawaySantAnna or SunAbraham instead.
Key implementation requirements:
Assumption checks / warnings:
Treatment indicator must be binary (0/1) with variation in both groups
Requires at least 1 pre-treatment and 1 post-treatment period
Warns when only 1 pre-period available (≥2 needed to test parallel trends; ATT is still valid but pre-trends assessment is not possible)
Reference period defaults to last pre-treatment period (e=-1 convention)
Treatment indicator should be time-invariant ever-treated (D_i); warns when time-varying D_it detected (requires
unitparameter)Warns if treatment timing varies across units when
unitis provided (suggests CallawaySantAnna or SunAbraham instead)Treatment must be an absorbing state (once treated, always treated)
Estimator equation (target specification):
With unit and time fixed effects absorbed:
Y_it = α_i + γ_t + Σ_{e≠-1} δ_e × D_i × 1(t = E + e) + X'β + ε_it
where:
α_i = unit fixed effects (absorbed)
γ_t = time fixed effects (absorbed)
E = common treatment time (same for all treated units)
D_i = treatment group indicator (1=treated, 0=control)
e = t - E = event time (relative periods to treatment)
δ_e = treatment effect at event time e
δ_{-1} = 0 (reference period, omitted for identification)
For simultaneous treatment, this is equivalent to interacting treatment with calendar-time indicators:
Y_it = α_i + γ_t + Σ_{t≠t_ref} δ_t × (D_i × Period_t) + X'β + ε_it
where interactions are included for ALL periods (pre and post), not just post-treatment.
Pre-treatment coefficients (e < -1) test the parallel trends assumption: under H0 of parallel trends, δ_e = 0 for all e < 0.
Post-treatment coefficients (e ≥ 0) estimate dynamic treatment effects.
Average ATT over post-treatment periods:
ATT_avg = (1/|post|) × Σ_{e≥0} δ_e
with SE computed from the sub-VCV matrix:
Var(ATT_avg) = 1'V1 / |post|²
where V is the VCV sub-matrix for post-treatment δ_e coefficients.
Standard errors:
Default: HC1 heteroskedasticity-robust (same as DifferenceInDifferences base class)
Alternative: Cluster-robust at unit level via
clusterparameter (recommended for panel data)vcov_type="hc2_bm"(one-way) computes HC2 + Imbens-Kolesar (2016) Satterthwaite DOF per coefficient and a contrast-aware DOF for the post-period-average ATT.cluster+vcov_type="hc2_bm"is now supported (PR for Gate 6 lift). Both per-period effects and the post-period-average ATT use a cluster-aware Bell-McCaffrey Satterthwaite DOF: the per-coefficient case continues via_compute_cr2_bm, and the compoundavg_attcontrast DOF goes through the new_compute_cr2_bm_contrast_dofhelper indiff_diff/linalg.py(Pustejovsky-Tipton 2018 §4 algebra generalized to arbitrary(k, m)contrast matrices). R parity verified at atol=1e-10 vs clubSandwich’sWald_test(constraints=matrix(c, 1), test="HTZ")$df_denom. Weighted CR2-BM (survey_design=paths) is still gated separately; see the rows inTODO.mdunder Methodology/Correctness.Note: the cluster-aware MPD
hc2_bmpath computes the CR2 hat matrix, per-cluster adjustment matrices, the sandwich vcov, AND the per-coefficient + post-period-average contrast DOF together from a single shared precompute build (_compute_cr2_bm_vcov_and_dofindiff_diff/linalg.py); the fit bypassessolve_ols’s separate vcov dispatch on this path so the O(n²) CR2 setup is built once per fit rather than twice._compute_cr2_bm(per-coefficient vcov + DOF) and_compute_cr2_bm_contrast_dof(DOF-only for arbitrary contrasts) are thin wrappers over that shared core, so every CR2 caller routes through one implementation. The consolidation is bit-identical to the prior two-call path (proven at atol=0).Note (unweighted per-coef DOF guard): the unweighted clustered CR2-BM per-coefficient DOF (
_cr2_bm_dof_inner, the simple(tr B)²/tr(B²)form) carries the same two-part reliability guard as the weighted P-array path. (1) Noise floor: for a high-leverage FE-dummy / collinear nuisance columntrace_B2 = Σ B_{g,h}²collapses to float64 accumulation noise whiletrace_Bstays O(1), inflating the ratio to a non-physical DOF (observed ~1e61 on the absorbed-FE golden); a contrast whosemax|B_{g,h}|sits below the batch-relative (1e-10×max, computed on the scale-normalizedmax|B|/‖c‖²sinceB ∝ ‖c‖²while the DOF is scale-invariant) or absolute ((EPS·n·k·bread_scale)²) floor is NaN’d. (2) Cluster-count bound: the Bell-McCaffrey Satterthwaite DOF is(tr B)²/tr(B²)withBPSD and cluster-structured, so it is bounded byrank(B) ≤ G(number of clusters); the simple unweighted form is numerically less faithful than clubSandwich’s P-array form on high-leverage columns and can return a finite-but-inflated DOF aboveG(observed ~32.7 and ~16.3 vs R’s 6 and 3,G=8), which is NaN’d as non-physical. The well-conditioned contrasts estimators consume — the treatment effect, event-study coefficients, and the compound post-period-average — are unaffected and match R clubSandwich; only the non-user-facing high-leverage nuisance DOFs are suppressed (exact P-array reproduction of those is deferred). AUserWarningfires per fit. Regression:tests/test_estimators_vcov_type.py::TestDiDAbsorbedFERParity::test_unweighted_cr2_bm_per_coef_dof_no_nonphysical.Note:
LinearRegression.get_se()/get_inference()clamp the vcov diagonal at 0 beforesqrt. A high-leverage / degenerate coefficient (an absorbed-FE dummy near-collinear with the treatment, whose Satterthwaite DOF already hits the noise-floor guard) has a CR2/HC variance of ~0 (≈1e-32) that can land just-below-zero under BLAS-dependent rounding; the clamp keeps the SE finite (0 for a genuinely-zero variance) and deterministic across BLAS implementations, neverNaN. No effect on any positive variance. Regression:tests/test_methodology_wls_cr2.py::TestLinearRegressionFENanGuardEndToEnd.Optional: Wild cluster bootstrap (complex for multi-coefficient testing; requires joint bootstrap distribution)
Degrees of freedom adjusted for absorbed fixed effects
Edge cases:
Reference period: omitted from design matrix; coefficient is zero by construction. Default is last pre-treatment period (e=-1). User can override via
reference_period.Post-period reference: raises ValueError. Post-period references would exclude a post-treatment period from estimation, biasing avg_att and breaking downstream inference.
Reference period default change: FutureWarning emitted when
reference_periodis not explicitly specified and ≥2 pre-periods exist, noting the default changed from first to last pre-period (e=-1 convention, matching fixest/did).Never-treated units: all event-time indicators are zero; they identify the time fixed effects and serve as comparison group.
Endpoint binning: distant event times (e.g., e < -K or e > K) should be binned into endpoint indicators to avoid sparse cells. This prevents imprecise estimates at extreme leads/lags.
Unbalanced panels: only uses observations where event-time is defined. Units not observed at all event times contribute to the periods they are present for.
Rank-deficient design matrix (collinearity): warns and sets NA for dropped coefficients (R-style, matches
lm())Note (scale invariance): shared
diff_diff/linalg.pybehavior — rank detection re-checks on column-equilibrated columns and the solve equilibrates/unscales, so detection and fit are invariant to per-column scaling. For a well-scaled collinear design the dropped column is unchanged; a scale-induced under-count adopts the scale-corrected equilibrated selection (which may differ from the raw choice but retains an identified subset). The common full-rank case is short-circuited by a strictly-stricter Gram certification (2026-07) that leaves all drop decisions unchanged. See the CallawaySantAnna rank-deficiency Note.Note (covariate-name collision guard): a covariate named like a reserved structural term (
const, the treatment column, aperiod_{p}dummy, a{treatment}:period_{p}interaction, a fixed-effect dummy, or an internal_did_*column) — or a duplicate covariate name — raisesValueError(would otherwise silently overwrite a structural coefficient in the coef dict). See the DifferenceInDifferences “covariate-name collision guard” Note.
Average ATT (
avg_att) is NA if any post-period effect is unidentified (R-style NA propagation)NaN inference for undefined statistics:
t_stat: Uses NaN (not 0.0) when SE is non-finite or zero
p_value and CI: Also NaN when t_stat is NaN
avg_se: Checked for finiteness before computing avg_t_stat
Note: Defensive enhancement matching CallawaySantAnna NaN convention
Treatment reversal: warns if any unit transitions from treated to untreated (non-absorbing treatment violates the simultaneous adoption assumption)
Time-varying treatment (D_it): warns when
unitparameter is provided and within-unit treatment variation is detected. Advises creating an ever-treated indicator. Without ever-treated D_i, pre-period interaction coefficients are unidentified.Pre-test of parallel trends: joint F-test on pre-treatment δ_e coefficients. Low power in pre-test does not validate parallel trends (Roth 2022).
Reference implementation(s):
R:
fixest::feols(y ~ i(time, treatment, ref=ref_period) | unit + time, data, cluster=~unit)or equivalentlyfeols(y ~ i(event_time, ref=-1) | unit + time, data, cluster=~unit)Stata:
reghdfe y ib(-1).event_time#1.treatment, absorb(unit time) cluster(unit)
Requirements checklist:
[x] Event-time indicators for ALL periods (pre and post), not just post-treatment
[x] Reference period coefficient is zero (normalized by omission from design matrix)
[x] Pre-period coefficients available for parallel trends assessment
[ ] Default cluster-robust SE at unit level (currently HC1; cluster-robust via
clusterparam)[ ] Supports unit and time FE via absorption
[ ] Endpoint binning for distant event times
[x] Average ATT correctly accounts for covariance between period effects
[x] Returns PeriodEffect objects with confidence intervals
[x] Supports both balanced and unbalanced panels
TwoWayFixedEffects#
Primary source: Panel data econometrics
Wooldridge, J.M. (2010). Econometric Analysis of Cross Section and Panel Data, 2nd ed. MIT Press, Chapter 10.
Key implementation requirements:
Assumption checks / warnings:
Staggered treatment warning: If treatment timing varies across units, warns about potential bias from negative weights (Goodman-Bacon 2021, de Chaisemartin & D’Haultfœuille 2020)
Requires sufficient within-unit and within-time variation
Warns if any fixed effect is perfectly collinear with treatment
Estimator equation (as implemented):
Y_it = α_i + γ_t + τ(D_it) + X'β + ε_it
Estimated via within-transformation (demeaning):
Ỹ_it = τD̃_it + X̃'β + ε̃_it
where tildes denote demeaned variables.
Note: The interaction term D_i × Post_t is within-transformed (demeaned) alongside the
outcome and covariates before regression. This is required by the Frisch-Waugh-Lovell theorem:
all regressors must be projected out of the same fixed effects space as the dependent variable.
This matches the behavior of R’s fixest::feols() with absorbed FE.
Standard errors:
Default: Cluster-robust at unit level (accounts for serial correlation)
Degrees of freedom adjusted for absorbed fixed effects:
df_adjustment = n_units + n_times - 2Note (absorbed-FE variance scale = fixest full-K): for the non-clustered
classicalandhc1(hetero) variance families, the finite-sample scale (sse/(n-k)/n/(n-k)) now counts the absorbed FE ink– i.e.K_full = k_visible + df_adjustment– matchingfixest::feols(vcov="iid"/"hetero")and the reported t-df(linalg._absorbed_fe_vcov_scale, a single scalar rescale of thek_visiblevcov, fail-closed whenn - K_full <= 0). Previously the within-transform SE usedk_visible, sitting ~6.5% below fixest even though the t-dfalready usedK_full(an internal inconsistency). Applies toTwoWayFixedEffects(vcov_type="classical"),DifferenceInDifferences(absorb=..., vcov_type in {classical,hc1}), andMultiPeriodDiD(absorb=..., vcov_type in {classical,hc1}). Clustered SEs are unchanged (this fix is gated oncluster_ids is None): the clustered CR1k_visiblescale matches fixest for absorbed FE nested in the cluster (e.g. unit FE with unit clustering, per fixest’ssscnested-FE convention, which does not count nested FE). Known limitation (deviation from fixest): when an absorbed FE is not nested in the cluster (e.g.absorb=["unit","time"]clustered byunit, where the time FE are non-nested), fixest counts the non-nested FE in the CR1 finite-sample denominator, but the current clustered path uses onlyk_visible– a small, pre-existing deviation left out of this D4 (non-clustered) scope and tracked inTODO.md. This is distinct from the SunAbraham / Wooldridgehc1deviations below (whose event-study / aggregation paths auto-cluster or use a different k-convention).hc2/hc2_bmuse leverage / Satterthwaite DOF and are unaffected. The full-dummy (fixed_effects=) idiom carriesdf_adjustment == 0and is unchanged (it already matched fixest).
Edge cases:
Singleton units/periods are automatically dropped
Treatment perfectly collinear with FE raises error with informative message listing dropped columns
Covariate collinearity emits warning but estimation continues (ATT still identified)
Rank-deficient design matrix: warns and sets NA for dropped coefficients (R-style, matches
lm())Note (scale invariance): shared
diff_diff/linalg.pybehavior — rank detection re-checks on column-equilibrated columns and the solve equilibrates/unscales, so detection and fit are invariant to per-column scaling. For a well-scaled collinear design the dropped column is unchanged; a scale-induced under-count adopts the scale-corrected equilibrated selection (which may differ from the raw choice but retains an identified subset). The common full-rank case is short-circuited by a strictly-stricter Gram certification (2026-07) that leaves all drop decisions unchanged. See the CallawaySantAnna rank-deficiency Note.Note (covariate-name collision guard): a covariate named
const,ATT,_treatment_post, or a unit/time fixed-effect dummy name — or a duplicate covariate name — raisesValueErroron both variance paths (would otherwise silently overwrite a structural coefficient on the full-dummy HC2/HC2-BM path). See the DifferenceInDifferences “covariate-name collision guard” Note.
Unbalanced panels handled via proper demeaning
Note (iterative demeaning): the two-way within transformation (
diff_diff.utils.within_transform) uses the method of alternating projections (iteratively demean by unit, then time, until convergence) for BOTH the weighted and unweighted paths. This is exact on unbalanced panels. The unweighted path previously used the closed-form additive demeany - ȳ_i - ȳ_t + ȳ, which is exact only for balanced fully-crossed panels; on unbalanced panels it was a biased approximation. Balanced-panel results are unchanged to machine precision.
Multi-period
timeparameter: only binary (0/1) post indicator is recommended; multi-period values producetreated × period_numberrather thantreated × post_indicator. AUserWarningis emitted whentimehas >2 unique values, advising users to create a binary post column. Non-{0,1} binary time (e.g., {2020, 2021}) also emits a warning, though the ATT is mathematically correct — the within-transformation absorbs the scaling.Staggered warning limitation: requires
timeto have actual period values (not binary 0/1) so that different cohort first-treatment times can be distinguished. With binarytime="post", all treated units appear to start attime=1, making staggering undetectable. Users with staggered designs should usedecompose()orCallawaySantAnnadirectly.
Reference implementation(s):
R:
fixest::feols(y ~ treat:post | unit + post, data, cluster = ~unit)Stata:
reghdfe y treat, absorb(unit time) cluster(unit)
Requirements checklist:
[ ] Staggered adoption detection warning (only fires when
timehas >2 unique values; with binarytime, staggering is undetectable)[x] Multi-period time warning (fires when
timehas >2 unique values)[x] Auto-clusters standard errors at unit level
[x]
decompose()method returns BaconDecompositionResults[x] Within-transformation correctly handles unbalanced panels
[x] Non-{0,1} binary time warning (fires when time has 2 unique values not in {0,1})
[x] ATT invariance to time encoding (verified by test)
Modern Staggered Estimators#
IF-based variance estimators vs analytical-sandwich estimators#
diff-diff houses two structural families for variance computation, and the
distinction governs which vcov_type values an estimator can accept:
Analytical-sandwich estimators fit a single (or per-cohort) linear
regression and derive variance via solve_ols(..., vcov_type=...), returning
a sandwich (X'X)^{-1} M (X'X)^{-1} whose meat M is parameterized by
vcov_type ∈ {classical, hc1, hc2, hc2_bm} (plus conley for spatial-HAC).
Examples: DifferenceInDifferences, MultiPeriodDiD, TwoWayFixedEffects,
SunAbraham, StackedDiD, WooldridgeDiD, LinearRegression. The full
vcov_type contract is methodologically applicable because every family has
a defined interpretation on the hat-matrix-bearing design (HC2 leverage
1/(1-h_ii), Bell-McCaffrey Satterthwaite DOF, etc.).
IF-based estimators derive variance from an asymptotic influence function
Var(θ̂) = (1/n) Σ_i ψ_i² per estimator-specific derivations (Callaway &
Sant’Anna 2021 for CallawaySantAnna; Borusyak-Jaravel-Spiess 2024 for
ImputationDiD; Sant’Anna & Zhao 2020 for EfficientDiD; Ortiz-Villavicencio
& Sant’Anna 2025 for TripleDifference, where the variance is built on the
3-pairwise-DiD decomposition inf = w3·IF_3 + w2·IF_2 - w1·IF_1). For these:
hc1withcluster=None≡ per-unit IF variance — the default (Williams 2000 form).hc1withcluster=X≡ CR1 Liang-Zeger on the IF:Var = (G/(G-1)) Σ_c (Σ_{i∈c} ψ_i)² / n². The activation path is estimator-specific:CallawaySantAnnasynthesizesSurveyDesign(psu=X)internally and routes through the shared PSU-meat machinery (_compute_stratified_psu_meat);TripleDifferencecomputes the algebraically equivalent CR1 directly from cluster-summed IFs inline attriple_diff.py(no SurveyDesign synthesis — the IF is already in scope at the SE call site). Both produce the same numerical result.classical,hc2,hc2_bmare N/A for IF-based estimators — hat-matrix leverage and Bell-McCaffrey Satterthwaite DOF are defined on a single regression’s design matrix, and IF-based estimators have no equivalent global hat matrix (they compose per-(g,t) or per-cohort fits with custom IF derivations). Rejected at__init__with methodology-rooted messages.conley(spatial-HAC) — could conceptually apply to the IF (spatial aggregation of per-unit IFs) but requires separate methodology work; deferred.
This split is a structural property of the estimator’s variance derivation,
not a missing feature. The vcov_type input contract for IF-based estimators
is permanently narrow at {"hc1"}. Enforced today on
CallawaySantAnna, TripleDifference, ImputationDiD, EfficientDiD, and
TwoStageDiD. TwoStageDiD is sandwich-class rather than pure-IF — its
variance is the Gardner (2022) two-stage GMM sandwich — but reaches the same
narrow {"hc1"} contract for a meat-specific reason: the GMM-corrected score
S_g = gamma_hat' c_g - X'_{2g} eps_{2g} folds first-stage FE uncertainty into
the meat, so no single hat matrix spans both stages and {classical, hc2, hc2_bm} have no derivation (see the TwoStageDiD section).
Note: This routing is a documented synthesis. The clustered-hc1
activation path is estimator-specific: CallawaySantAnna synthesizes
SurveyDesign(psu=X) internally and routes through the existing
PSU-meat machinery (_compute_stratified_psu_meat); TripleDifference
computes the algebraically equivalent CR1 directly from cluster-summed
IFs inline; ImputationDiD computes the Theorem 3 conservative variance
(sigma_sq = (cluster_psi_sums**2).sum()) directly from per-cluster
influence-function sums; EfficientDiD aggregates per-unit EIF within
clusters, centers, and applies the standard G/(G-1) correction
(_cluster_aggregate + _compute_se_from_eif at
diff_diff/efficient_did.py:79-127). The CR1 Liang-Zeger algebra on the
IF is Williams (2000) / Hansen (2007) in all four cases — no new
methodology is introduced.
CallawaySantAnna#
Primary source: Callaway, B., & Sant’Anna, P.H.C. (2021). Difference-in-Differences with multiple time periods. Journal of Econometrics, 225(2), 200-230.
Key implementation requirements:
Assumption checks / warnings:
Requires never-treated units as comparison group (identified by
first_treat=0ornever_treated=True)Warns if no never-treated units exist (suggests alternative comparison strategies)
Limited pre-treatment periods reduce ability to test parallel trends
Note (rank-guarded IF standard errors): The analytical SE paths invert the propensity-score Hessian (
H_psi) and outcome-regression bread (X'WX) across every(g, t)cell via_safe_inv().np.linalg.solve/invraiseLinAlgErroronly on exactly singular matrices; a near-singular Gram (a constant or collinear covariate) does not raise, so the prior code returned a garbage inverse (entries ~1e13) that flowed straight into the SE (overall_se~1e13 was reproduced forestimation_method="dr")._safe_inv()now delegates to the shared_rank_guarded_inv()(diff_diff/linalg.py): it symmetrically equilibratesA → D^{-1/2} A D^{-1/2}(D = diag(A)), then, when rank-deficient, inverts a column-dropped principal submatrix — keeping the most-independent columns via pivoted QR on the equilibrated Gram. This is a column-drop generalized inverse in the same family as the point estimate / R’slm()(drop redundant columns, not a minimum-norm pseudo-inverse), but the column selection is computed on the equilibrated Gram and is therefore scale-invariant by construction — so it need not pick the same member of a collinear set as the point estimate’s raw-pivot_detect_rank_deficiencyunder mixed-scale exact collinearity (see the selection caveat below); the resulting SE is well-defined and order-invariant regardless of which redundant member is dropped. Thercond = 1e-10relative-eigenvalue threshold on the equilibrated Gram sets the rank (chosen because a Gram squares the condition number ofX; matches EfficientDiD’stol/max_eigval). The result is a finite SE on the identified covariate subset; an all-NaN inverse (and NaN SE) is returned only on true rank-0. Column-drop equals the full-rank limit: using a column-drop generalized inverse (rather than a minimum-norm pseudo-inverse) makes the analytical SE equal the well-conditioned near-collinear limit — replacing an exactly-collinear covariate with a near-collinear (full-rank) one yields the same SE to working precision (verifiedse_ratio ≈ 1acrossreg/ipw/dr), for every per-cell bread (the PS Hessian — including unweighted ipw’s since v3.7 — and the control- and treated-side OR breads, including reg’s centered estimation-effect GramGsince v3.7). A minimum-norm pseudo-inverse would instead diverge sharply when the IF multiplier leavesrange(A)— e.g. a control (or treated-sub-cell) bread multiplied by a mean from a cell where the covariate is not collinear — so it is rejected. With column-drop there is no such divergence; a covariate that is rank-deficient only within one cell still legitimately enters the other cells’ full-rank fits, so the ATT and SE reflect that (poor) covariate specification, surfaced by the aggregate warning. Selection caveat (equilibrated vs raw pivot): because_rank_guarded_invpivots the equilibrated Gram, the member it drops from a collinear set can differ from the point estimate’s raw-pivot_detect_rank_deficiencychoice only under mixed-scale exact collinearity (e.g. a covariate and its1e8×duplicate). This selection difference does not change the SE: dropping either member of an exactly-collinear pair leaves the identified subspace — hence the variance — unchanged, and the SE is order-invariant (verified for both column orders and under survey weighting). It is a documented, intentional deviation in the generalized-inverse column choice (equilibrated, scale-invariant) from the point-estimate’s raw pivot; it is not a parity claim with the raw selection. Caveat (point-estimate side, NOT the rank-guard): CS/SDDD’s reg/DR point-estimate OR fit now routes through the scale-equilibratedsolve_ols(SVD/gelsd), so a covariate correlated with another regressor at a very large scale no longer perturbs the point-estimate ATT — or the IF SE that consistently follows it — underreg/dr; scale-invariance is pinned by tests (covariate + large constant offset → ATT(g,t) unchanged to ~1e-11). This is independent of the rank-guarded IF inverse, whose SE is invariant to which collinear member is dropped (see the selection caveat above). The well-conditioned fast path returnsnp.linalg.solve(A, I)unchanged (R-parity preserved).fit()emits ONE aggregateUserWarning(count + max condition number) reporting the dropped redundant direction(s); suppressed underrank_deficient_action="silent".rank_deficient_actionenforcement:"error"is enforced upstream at the point-estimate solve (solve_ols/solve_logit/ the covariate-regression fit), which raisesValueErrorwhen the covariate design is rank-deficient at its1e-7threshold (_detect_rank_deficiency) before any influence-function SE is computed — so for a genuinely (design-)rank-deficient covariate the rank-guarded IF inverse is reached only under"warn"/"silent". The IF guard truncates on a stricter relative threshold, though:1e-10on the equilibrated Gram vs1e-7on the design, and a Gram squaresX’s condition number, so the IF guard drops a direction onceX’s singular-value ratio falls below ~1e-5(= √1e-10), well above the design’s1e-7. A cell that is near-singular yet still full-rank by the upstream design check (singular-value ratio between ~1e-7and ~1e-5) therefore passes the"error"gate without an exception and is still column-dropped by the IF guard — the guard does not re-raise, and (under"error"/"warn", not"silent") the aggregate warning still fires. So"error"blocks design-rank-deficient covariates; it does not promise that every near-singular IF bread/Hessian raises. Sibling of axis-A finding #17 in the Phase 2 silent-failures audit.
Variance families (vcov_type, IF-based):
hc1(default, only accepted value) — per-unit IF variance per Callaway & Sant’Anna (2021) whencluster=None; cluster-robust CR1 Liang-Zeger on the IF whencluster=Xis set (synthesizesSurveyDesign(psu=X)internally, threading through the same PSU machinery as explicit survey designs). Whensurvey_design=SurveyDesign(psu=Y)is provided, the explicit PSU takes precedence; ifcluster=Xis also set with a different partition, emits aUserWarning(PSU wins).classical,hc2,hc2_bm,conley— REJECTED at__init__. The rejection is library-architectural, not paper-prescribed: analytical-sandwich variance families (classical,hc2,hc2_bm) are defined on a single regression’s hat matrix, and CS’s per-(g,t) doubly-robust / IPW / outcome-regression structure has no equivalent single design matrix to compute hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF on. Spatial-HAC (conley) likewise has no defined composition with per-unit IF aggregation today. See “IF-based variance estimators vs analytical-sandwich estimators” above for the structural taxonomy.
Cluster wiring:
Prior to the bare-cluster= wiring fix, CallawaySantAnna(cluster="X") was a silent no-op — the parameter was stored at __init__ but never consumed in the fit / aggregator / bootstrap pipeline (users got per-unit IF variance silently, even when they explicitly set cluster="state"). The fix synthesizes a minimal SurveyDesign(psu=X, weight_type="pweight") when bare cluster= is set without an explicit survey design, threading the synthesized PSU through the existing _compute_stratified_psu_meat aggregator (staggered_aggregation.py:735-749) and PSU-level multiplier bootstrap (staggered_bootstrap.py:334-437). Three-branch wiring at staggered.py:~1500:
Bare
cluster=X+ nosurvey_design→ synthesizeSurveyDesign(psu=X); refit_resolve_survey_for_fiton synthetic;effective_survey_design = syntheticso_validate_unit_constant_surveyruns on it (preventing first-value-wins collapse for movers on panel data).survey_designwithout PSU +cluster=X→ call_inject_cluster_as_psu(resolved_survey, cluster_ids).survey_designwith PSU +cluster=X→ PSU wins;_resolve_effective_clusteremitsUserWarningif partitions differ.
The cluster_name and n_clusters fields on CallawaySantAnnaResults report the effective clustering level: survey_design.psu (canonical column) when explicit PSU is provided, self.cluster when bare cluster synthesizes or injects.
Note (API decision —
cluster=retained, NOT deprecated): thecluster=→SurveyDesign(psu=cluster)synthesis above is an internal implementation detail, not a user-facing redundancy to be consolidated away.cluster=is the canonical ergonomic single-level clustering kwarg and is intentionally retained onCallawaySantAnna(and the sibling IF-based estimatorsEfficientDiD/ImputationDiD/TwoStageDiD): it matches the field’s universal convention (Rfixest::feols(..., cluster = ~unit), Statavce(cluster id), statsmodelscov_type="cluster"), so users reach forcluster=first.survey_design=SurveyDesign(psu=X, ...)is the advanced entry point (adds strata / FPC / replicate weights / explicit weights); a barecluster=is the shorthand for the common “just cluster at X” case and would be strictly less ergonomic if forced throughsurvey_design=. This mirrors the HAD survey-API consolidation, which deprecated only the redundantsurvey=/weights=entry points in favor ofsurvey_design=while deliberately keepingcluster=. Decision recorded 2026-07-04: do not deprecatecluster=; the former “decide whether to deprecateCallawaySantAnna.cluster=X”TODO.mdrow is closed as resolved (keep).
Estimator equation (as implemented):
Group-time average treatment effect:
ATT(g,t) = E[Y_t - Y_{g-1} | G_g=1] - E[Y_t - Y_{g-1} | C=1]
where G_g=1 indicates units first treated in period g, and C=1 indicates never-treated.
Note: This equation uses g-1 as the base period, which applies to post-treatment effects (t ≥ g) and base_period="universal". With base_period="varying" (default), pre-treatment effects use the immediately-preceding observed period as base. Base periods are selected positionally (nearest observed period), so on gapped grids the base is the nearest observed period rather than literal g-1 / t-1 (see Base period selection in Edge cases).
With covariates (doubly robust):
ATT(g,t) = E[((G_g - p̂_g(X))/(1-p̂_g(X))) × (Y_t - Y_{g-1} - m̂_{0,g,t}(X) + m̂_{0,g,g-1}(X))] / E[G_g]
Aggregations:
Simple:
ATT = Σ_{g,t} w_{g,t} × ATT(g,t)weighted by group sizeEvent-study:
ATT(e) = Σ_g w_g × ATT(g, g+e)for event-time eGroup:
ATT(g) = Σ_t ATT(g,t) / T_gaverage over post-periods
Standard errors:
Default: Analytical (influence function-based)
Note (v3.7 DRDID IF parity, panel reg/ipw): Every panel reg/ipw per-cell SE is derived from the SAME influence function that feeds aggregation, the bootstrap, the cluster override, and
event_study_vcov:se = sqrt(sum(phi^2))(DRDID convention; dr already worked this way). Two defects were fixed to get there. (1) The reg+cov IF omittedDRDID::reg_did_panel’s OLS estimation-effect term (asy.lin.rep.ols %*% M1) — mean-zero by the OLS normal equations, so ATTs matched R at ~1e-11 while per-cell SEs sat 4-13% and aggregated SEs 3-20% from the R golden fixtures (anti-conservative on some: 0.958x on the two-period golden). (2) The unweighted ipw+cov branch computed its per-cell SE assqrt(var_t/n_t + weighted_var_c)whereweighted_var_cwas a weighted POPULATION variance never scaled by an effective sample size (~7x inflated per-cell SEs), and its IF lackedstd_ipw_did_panel’s PS estimation-effect correction (asy.lin.rep.ps %*% M2; aggregated SEs ~2.4% off). The survey ipw branch already carried both (Phase 7a); the fix mirrors it. No-covariate reg/ipw per-cell SEs also switched from ddof=1 plug-ins (ipw’svar_c*(1-p)/(n_c*p)matched R only at p=0.5) to the IF-based form, which is R’s analytical SE exactly. Post-fix parity vs R: per-cell and aggregated ~5e-12 on the reg goldens, ~1e-10 vs fresh did 2.5.1 ipw aggregates (tests/test_csdid_ported.py,tests/test_methodology_callaway.py::TestDRDIDPanelIFParity). Point estimates are unchanged. Residual deviations kept: propensity scores are CLIPPED atpscore_trim(R drops attrim.level=0.995; differs only at extreme propensities); no-covariate ipw is treated as unconditional (R fits an intercept-only logit whose estimation effect is identically zero in the IF, so this is presentation-only). Note: decided document-only (2026-07-07) — the intercept-only logit is deliberately NOT mirrored structurally: its estimation-effect correction is identically zero at the MLE, so mirroring would add a per-cell IRLS solve (and its non-convergence failure surface) for zero numerical change. No-covariate ipw/reg/dr all reduce to the same difference-in-means IF, bit-identical per cell (locked bytests/test_methodology_callaway.py::TestDRNoCovariateSEUniformity::test_ipw_no_cov_per_cell_identical_to_reg). DR’s no-covariate per-cell SE now also uses the IF-basedsqrt(sum(phi^2))form (it had lagged on the ddof=1 plug-insqrt(var_t/n_t + var_c/n_c), O(1/n) from R): without covariates DR reduces to difference in means, so its per-cell SE is now bit-identical to the no-covariate reg path and matches R’s analytical SE — point estimates and aggregated SEs are unchanged, since the same IF already fed aggregation (tests/test_methodology_callaway.py::TestDRNoCovariateSEUniformity). Side effect: reg/ipw fits with collinear covariates now route their IF breads through the rank-guarded inverse and fire the same aggregate warning as dr. Rank-0 semantics of the reg CENTERED Gram: because the intercept direction is handled analytically by the1/sum(W_c)leading term, a rank-0 centered Gram (zero within-control covariate variation, e.g. a constant as the only covariate) is the benign identified-subset case — the estimation-effect correction is exactly zero and the fit collapses to the no-covariate reg fit with finite SEs (_centered_or_breadmaps_safe_inv’s all-NaN rank-0 sentinel to a zero correction; the aggregate rank-guard warning still fires). This differs from the[1, X]breads (ipw PS Hessian, dr), where all-NaN is a true pathology that NaN-propagates.All aggregation SEs (simple, event study) include the weight influence function (WIF) adjustment, matching R’s
did::aggte(). The WIF accounts for uncertainty in estimating group-size aggregation weights. Group aggregation uses equal time weights (deterministic), so WIF is zero.Unbalanced panels — default within-cell differencing vs
allow_unbalanced_panel=True: on an unbalanced panel (some units unobserved in some periods) the default path estimates each ATT(g,t) by within-cell panel differencing on the units observed at BOTH the base period and t, and weights a multi-cell event-study horizon by per-cell validn_treated. This is a valid but different estimand than Rdid::att_gt(allow_unbalanced_panel=TRUE), which setspanel=FALSEand runs the repeated-cross-section levels estimator (DRDID::reg_did_rc) on the pooled observations, weighting by the fixed cohort probabilitypg = n_g / Nover UNITS. Both the cell estimator AND the weighting differ from R on unbalanced data (the estimator choice dominates); on balanced panels they coincide exactly (each cell’s valid count equals the cohort mass), so the default path matches R. The default path emits aUserWarningon unbalanced input (no-silent-failures) pointing to the flag.allow_unbalanced_panel=True(RC-on-panel parity with R): routes an unbalanced panel’s pooled observations through diff-diff’s RC estimator (bit-exact vsreg_did_rc) and clusters the per-observation influence function by the original unit. ATT matches R bit-for-bit — cells AND dynamic aggregation, including the fixed unit-cohort-masspgreweighting and the per-unit WIF (the per-observation WIF is divided by each unit’s observation count so the unit-clustered sum is not over-counted). Inert on balanced panels (byte-identical to the default) — matching R, whosepre_process_didlikewise recomputes balance and keepspanel=TRUE(differencing) on balanced input, so the flag engages RC only when the panel is genuinely unbalanced. Panel structure is validated before routing (no duplicate(unit, period)rows, time-invariant treatment cohort and cluster per unit), matching R’s preprocessing — fail-closed since the RC precompute reads cohort/cluster per observation.survey_design=with the flag raisesNotImplementedError(per-obs vs per-unit weight resolution deferred). Verified vs Rdid2.5.1 intests/test_csdid_ported.py::TestAllowUnbalancedPanel(goldenbenchmarks/data/cs_unbalanced_golden.json).Deviation from R: the analytical SE equals R’s up to the CR1 finite-sample factor
sqrt(G/(G-1))(G = number of units): diff-diff’s cluster-robust variance applies theG/(G-1)Bessel correction that R’satt_gtgetSE(sqrt(mean(inf^2)/n)) omits. Exact factor, ~0.25% at G=200, vanishing as G → ∞ — the same convention class as the fixest cluster-SE band (G2).
Bootstrap: Multiplier bootstrap with Rademacher, Mammen, or Webb weights. Bootstrap perturbs the combined influence function (standard IF + WIF) directly, not just fixed-weight re-aggregation. This correctly propagates weight estimation uncertainty.
Note: The combined-IF assembly (
_compute_combined_influence_function) routes in-package callers through an O(n_units) fast path (v3.7): per-fit cohort tables (unit counts or survey-weighted masses vianp.unique+np.bincount, cached on the precomputed structures with array-identity validation) and a closed-form WIFwif_i = w_i * (E(c_i)/S - K(c_i) * d / S**2)— algebraically identical to the prior dense(n_units x n_gt)wif_matrix @ effects(E(c)/K(c) = per-cohort effect sums/keeper counts, S = sum of keeper pg, d = pg @ effects, w_i = survey weight or 1). Point estimates are unaffected (bit-identical); aggregated SEs agree with the dense form only to floating-point reassociation (measured ≤ 5e-16 relative; drift-bound frozen-copy tests pin ≤ 1e-9,tests/test_staggered_aggregation.py), not bit-for-bit — same accumulation-order posture as the shared demeaning engine (see “Absorbed Fixed Effects with Survey Weights”). Units whose cohort is not among the keeper (g,t) groups get an exact 0 WIF contribution (the dense form realized the same value through cancelling terms). The pre-rewrite general path remains the fallback for direct callers with foreign index maps, non-numeric cohort dtypes, or absent precomputed structures; its per-cell IF scatter uses fancy+=(bit-identical to the priornp.add.at— index arrays are duplicate-free by construction at every producer), same mathematical contract.Block structure preserves within-unit correlation
Simultaneous confidence bands (
cband=True, default): Uses sup-t bootstrap to compute a uniform critical value across event times, controlling family-wise error rate. Matches R’sdid::aggte(..., cband=TRUE)default. Requiresn_bootstrap > 0.
Bootstrap weight distributions:
The multiplier bootstrap uses random weights w_i with E[w]=0 and Var(w)=1:
Weight Type |
Values |
Probabilities |
Properties |
|---|---|---|---|
Rademacher |
±1 |
1/2 each |
Simplest; E[w³]=0 |
Mammen |
-(√5-1)/2, (√5+1)/2 |
(√5+1)/(2√5), (√5-1)/(2√5) |
E[w³]=1; better for skewed data |
Webb |
±√(3/2), ±1, ±√(1/2) |
1/6 each |
6-point; recommended for few clusters |
Webb distribution details:
Values: {-√(3/2), -1, -√(1/2), √(1/2), 1, √(3/2)} ≈ {-1.225, -1, -0.707, 0.707, 1, 1.225}
Equal probabilities (1/6 each) giving E[w]=0, Var(w)=1
Matches R’s
didpackage implementationVerification: Implementation matches
fwildclusterbootR package (C++ source) which uses identicalsqrt(1.5),1,sqrt(0.5)values with equal 1/6 probabilities. Some documentation shows simplified values (±1.5, ±1, ±0.5) but actual implementations use square root values to achieve unit variance.Reference: Webb, M.D. (2023). Reworking Wild Bootstrap Based Inference for Clustered Errors. Queen’s Economics Department Working Paper No. 1315. (Updated from Webb 2014)
Edge cases:
Groups with single observation: included but may have high variance
Non-estimable group-time cells: materialized as NaN entries in
group_time_effectswith a consolidated warning listing skip reasons and countsNote: Non-estimable cells (missing base/post period, zero treated/control, zero survey-weight mass, non-finite regression solve) are stored as NaN entries —
effect/se/t_stat/p_value/conf_intall NaN — carrying a machine-readableskip_reasoncode ("missing_period","zero_treated_control","zero_weight_mass","non_finite_regression"; estimable cells carryNone). This is uniform across ALL estimation paths (no-covariate regression, covariate regression, IPW/DR, repeated cross-section, survey-weighted). A consolidatedUserWarningis still emitted fromfit(). The NaN cells are excluded from every aggregation (simple/overall, group, event-study), frombalance_e, and from the bootstrap (they carry no influence-function entry, and all consumers finite-mask onnp.isfinite(effect)or filter to IF members), so all aggregate point estimates and SEs — andn_groups/n_periodsmetadata — are unchanged from the prior omit behavior and match Rdid’saggte()exactly. A fit where no cell is estimable (no finite effect) still raises aValueError.Deviation from R: R’s
did::att_gtomits non-estimable cells from its result table entirely; diff-diff materializes them as NaN rows (withskip_reason) so the(g,t)grid is inspectable viagroup_time_effects/to_dataframe("group_time"). This is a per-cell surface difference only — R’saggte()aggregation behavior is matched exactly (non-estimable cells contribute nothing to any aggregate).Note: When
balance_eis specified, cohorts with NaN effects at the anchor horizon are excluded from the balanced panel
Anticipation:
anticipationparameter shifts reference periodGroup aggregation includes periods t >= g - anticipation (not just t >= g)
Both analytical SE and bootstrap SE aggregation respect anticipation
Not-yet-treated + anticipation: control mask uses
G > max(t, base_period) + anticipationto exclude cohorts treated at either the evaluation period or the base period. This prevents control contamination whenbase_period="universal"and the base period is later than the evaluation period (e.g., pre-treatment ATT with universal base)
Rank-deficient design matrix (covariate collinearity):
Detection: Pivoted QR decomposition with tolerance
1e-07(R’sqr()default), with a column-equilibration re-check (unit 2-norm) that makes the rank count invariant to per-column scaling; the dropped-column selection is unchanged for well-scaled collinear designs (a scale-induced under-count instead adopts the scale-corrected equilibrated selection)Handling: Warns and drops linearly dependent columns, sets NA for dropped coefficients (R-style, matches
lm())Parameter:
rank_deficient_actioncontrols behavior: “warn” (default), “error”, or “silent”Note: Rank detection and the least-squares solve are invariant to per-column scaling in BOTH the Python and Rust backends. The rank threshold is anchored to the largest pivot/singular value, so a column on a large raw scale (e.g. an unstandardized covariate in raw population or currency units) previously inflated the threshold and false-dropped the intercept/treatment/interaction to NaN on an otherwise full-rank design — or truncated the small-scale direction in the solve, returning finite-but-wrong coefficients. Detection now runs a raw pivoted QR first and only re-checks on column-equilibrated (unit 2-norm) columns when the raw pass reports a deficiency. If the equilibrated rank is higher (a scale-induced false-drop), the equilibrated rank AND its pivot selection are adopted — its first
rankcolumns are independent under the scale-corrected criterion, so the retained design is guaranteed identified; otherwise (genuine collinearity, no scale disparity) the raw rank and pivot selection are kept unchanged. The solve equilibrates columns and unscales the coefficients. The Python (LAPACKgelsd) and Rust (faer thin-SVD) backends implement this same contract and agree at the parity-suite tolerances (coefficients ~1e-8, vcov ~1e-5), never bit-identically. This repairs the scale bug while leaving everything else unchanged: it is a no-op for full-rank well-conditioned designs (R-parity unaffected) and does not change which column is dropped in a well-scaled collinear design (the established raw pivot selection is preserved). A scale-induced under-count instead adopts the scale-corrected equilibrated selection — which may differ from the raw choice in a mixed scale+collinearity design but is guaranteed to retain an identified (full-rank) subset. This shareddiff_diff/linalg.pybehavior covers every covariate outcome-regression fit routed throughsolve_ols— DiD, TwoWayFixedEffects, MultiPeriodDiD, ImputationDiD, TwoStageDiD, and TripleDifference. Scope (now scale-equilibrated): CallawaySantAnna’s covariate outcome-regression (_compute_all_att_gt_covariate_reg) and doubly-robust (_doubly_robust) nuisance solves — and StaggeredTripleDifference’s per-cohort OR solve (_compute_or) — now route throughsolve_ols(column-equilibrated SVD/gelsd), matching TripleDifference’s already-solve_ols-routed OR fit (triple_diff.py:1438/1444) and R’slm()/QR. The prior estimator-localcho_solve(X'X)/scipy.linalg.lstsq(cond=1e-7)fast paths were not scale-equilibrated: a covariate correlated with another regressor at a very large scale (e.g. a large constant offset, near-collinear with the intercept) could perturb the point-estimate ATT — and the IF SE that follows it — because the normal-equations Cholesky squares the condition number (pure orthogonal ill-scaling was already safe). The equilibrated SVD is offset-invariant to ~1e-11 where the prior solve drifted; scale-invariance is now pinned by tests (covariate + 1e6 offset → ATT(g,t) unchanged). The change is not bit-identical (cho/normal-equations → SVD) but well-scaled designs move only ~1e-12. The separate DR/OR influence-function SE rank-guard — which previously returned garbage SEs (~1e13) when these local Gram matrices were near-singular — is also implemented for CS / TripleDifference / StaggeredTripleDifference via_rank_guarded_inv(see the “rank-guarded IF standard errors” Note above).
Non-finite inference values:
Analytic SE: Returns NaN to signal invalid inference (not biased via zeroing)
Bootstrap: Drops non-finite samples, warns, and adjusts p-value floor accordingly. SE, CI, and p-value are all NaN if the original point estimate is non-finite, SE is non-finite or zero (e.g., n_valid=1 with ddof=1, or identical samples)
Threshold: Returns NaN if <50% of bootstrap samples are valid
Per-effect t_stat: Uses NaN (not 0.0) when SE is non-finite or zero (consistent with overall_t_stat)
Note: This is a defensive enhancement over reference implementations (R’s
did::att_gt, Stata’scsdid) which may error or produce unhandled inf/nan in edge cases without informative warnings
No post-treatment effects (all treatment occurs after data ends):
Overall ATT set to NaN (no post-treatment periods to aggregate)
All overall inference fields (SE, t-stat, p-value, CI) also set to NaN
Warning emitted: “No post-treatment effects for aggregation”
Individual pre-treatment ATT(g,t) are computed (for parallel trends assessment)
Bootstrap runs for per-effect SEs even without post-treatment; only overall statistics are NaN
Principle: NaN propagates consistently through overall inference fields; pre-treatment effects get full bootstrap inference
Aggregated t_stat (event-study, group-level):
Uses NaN when SE is non-finite or zero (matches per-effect and overall t_stat behavior)
Previous behavior (0.0 default) was inconsistent and misleading
Base period selection (
base_periodparameter):Positional (sorted-index) selection (
_select_base_period): base periods are selected by position in the sorted list of observed periods, not by literal calendar arithmetic. On consecutive grids this reduces tot-1/g-1-anticipation; on gapped (non-consecutive) grids (e.g. biennial surveys, skipped years) the base is the nearest observed period, so R-estimable cells that literalt-1/g-1would NaN are estimated. The pre/post split is on the current period vs the cohort (t < g-> pre), independent of anticipation; only the post/universal base uses anticipation. This matches Rdid::att_gt()(verified against a deparse ofdid2.5.1compute.att_gt) and resolves the prior internal inconsistency with the library’s own dCDH estimator, which already used positional neighbors.“varying” (default): pre-treatment (
t < g) uses the immediately-preceding observed period as base (consecutive comparisons); post-treatment uses the last observed pre-treatment period (largest observedpwithp + anticipation < g) as a long difference. Pre-treatment tests check nearest-observed-period comparisons.“universal”: ALL effects (pre and post) are long differences from the last observed pre-treatment period. Pre-treatment coefficients test cumulative divergence from it.
Both produce identical post-treatment ATT(g,t); differ only pre-treatment
anticipationshifts the post/universal base to the last observedpwithp + anticipation < g, moving it further from treatment and strengthening the parallel trends assumption (it does not change the pre/post split).Matches R
did::att_gt()base_period parameter, including on gapped panels (base selection, estimable ATT/SE cells, zero reference cells, and all aggregations).Event study output: With “universal”, each cohort’s positional base contributes a zero reference row at
e = base - g(effect=0, se=NaN, conf_int=(NaN, NaN)); on a consecutive grid these coincide ate = -1-anticipation, on a gapped grid they can fall ate = -2, -3, …. Inference fields are NaN since this is a normalization constraint, not an estimated effect (see the full-R-parity note below).Universal-mode zero reference cells (full R parity): with
base_period="universal", Rdid::att_gt()materializes each cohort’s base reference period as a zero cell (att = 0,se = NA) in itsatt_gttable and includes it inaggte(type="dynamic"). diff-diff now does the same:fit()materializes each cohort’s positional base as a zero reference cell (att = 0,se = NaN, zero influence function, flaggedis_reference) ingroup_time_effects/to_dataframe("group_time")at its positional base event time (e = base - g, which on gapped grids can be-2,-3, … not just-1). Because the reference is a real cell, every consumer weights it uniformly — the event-study dynamic aggregation, the multiplier bootstrap, andbalance_e— matching R exactly, including the overlapping-reference case where a cohort’s zero base shares an event time with another cohort’s estimated pre-trend cell (the reference correctly dilutes that horizon; verified vsdid2.5.1 on gapped balanced universal panels to ~1e-5 for the analytical AND bootstrap paths). The reference weight is the fixed cohort mass (R’spgnumerator), so on unbalanced panels the general per-cell multi-cell-horizon weighting deviation documented above applies to the reference exactly as it does to any real cell (the reference is not special). Reference-only horizons reportatt = 0, se = NaN. The zero reference carries no influence function, so it adds nothing to any variance; thegroup/simpleaggregations use post-treatment cells only (t >= g - anticipation) and exclude it. Regression-guarded bytests/test_csdid_ported.py::TestCSDIDPositionalBasePeriod.
Base period interaction with Sun-Abraham comparison:
CS with
base_period="varying"produces different pre-treatment estimates than SAThis is expected: CS uses consecutive comparisons, SA uses fixed reference (e=-1-anticipation)
Use
base_period="universal"for methodologically comparable pre-treatment effectsPost-treatment effects match regardless of base_period setting
Propensity score estimation:
Algorithm: IRLS (Fisher scoring), matching R’s
glm(family=binomial)defaultNote: Uses IRLS (Fisher scoring) for propensity score estimation, consistent with R’s
did::att_gt()which usesglm(family=binomial)internallyNote (IRLS inner solver, 2026-07): each IRLS iteration’s weighted least-squares step is solved via equilibrated normal equations + Cholesky with an explicit reciprocal-condition guard (LAPACK
dpocon; guard 1e-6), falling back to the exact legacy tall-matrixlstsqsolve for any iteration whose normal matrix cannot be certified well-conditioned (near-separation fits, where working weights crush a column’s effective scale). The IRLS ALGORITHM — working weightsmu*(1-mu), working response,tol=1e-8on the raw-basis coefficient change,max_iter=25— is unchanged; both inner solvers converge to the same MLE, and measured coefficients move at the ~1e-15 level (tol-bounded worst case ~1e-8 if an iteration count ever shifts by one; the R-golden ipw SE pin at 1e-6 abs is unaffected). The per-fit fallback count is exposed viadiagnostics_out["irls_chol_fallback_iters"](0 on well-conditioned fits).Near-separation detection: Warns when predicted probabilities are within 1e-5 of 0 or 1, or when IRLS fails to converge
Trimming: Propensity scores clipped to
[pscore_trim, 1-pscore_trim](default 0.01) before weight computation. Warning emitted when scores are trimmed.Events Per Variable (EPV) diagnostics: Per-cohort EPV = min(n_treated, n_control) / n_covariates checked before IRLS. Default threshold: 10 (Peduzzi et al. 1996). Warns when EPV < threshold; errors when
rank_deficient_action="error". Pre-estimation check viadiagnose_propensity(). Results stored inresults.epv_diagnostics.Fallback: Controlled by
pscore_fallbackparameter (default"error"). If IRLS fails entirely (LinAlgError/ValueError) andpscore_fallback="error", the error is raised. Ifpscore_fallback="unconditional", falls back to unconditional propensity score with warning. For IPW, this effectively drops all covariates. For DR, the propensity model is unconditional but the outcome-regression component still uses covariates.Note:
pscore_fallbackdefault changed from unconditional to error. Setpscore_fallback="unconditional"for legacy behavior.Note: When
pscore_fallback="unconditional"triggers, the propensity- score influence function correction is skipped (constant pscore has zero estimation uncertainty). SEs reflect outcome-model uncertainty only.
Control group with
control_group="not_yet_treated":Always excludes cohort g from controls when computing ATT(g,t)
This applies to both pre-treatment (t < g) and post-treatment (t >= g) periods
For pre-treatment periods: even though cohort g hasn’t been treated yet at time t, they are the treated group for this ATT(g,t) and cannot serve as their own controls
Control mask:
never_treated OR (first_treat > max(t, base_period) + anticipation AND first_treat != g)The
max(t, base_period)ensures controls are untreated at both the evaluation period and the base period, preventing contamination whenbase_period="universal"uses a base period later thant(matching R’sdid::att_gt())Does not require never-treated units: when all units are eventually treated, not-yet-treated cohorts serve as controls for each other (requires ≥2 cohorts)
Note: CallawaySantAnna survey support: weights, strata, PSU, and FPC are all supported for all estimation methods (reg, ipw, dr) with or without covariates. Analytical (
n_bootstrap=0): aggregated SEs use design-based variance viacompute_survey_if_variance(). Bootstrap (n_bootstrap>0): PSU-level multiplier weights replace analytical SEs for aggregated quantities. ALL covariate methods carry the DRDID panel nuisance IF corrections, method-uniformly across survey and unweighted paths (Phase 7a added the PS IF correction via survey-weighted Hessian/score and the DR OR IF correction via WLS bread and gradient, Sant’Anna & Zhao 2020, Theorem 3.1; v3.7 extended the PS correction to unweighted ipw and the OR estimation-effect correction to reg on both paths). Survey weights compose with IPW weights multiplicatively. WIF in aggregation matches R’s did::wif() formula. Per-unit survey weights are extracted viagroupby(unit).first()from the panel-normalized pweight array; on unbalanced panels the pweight normalization (w * n_obs / sum(w)) preserves relative unit weights since all IF/WIF formulas use weight ratios (sw_i / sum(sw)) where the normalization constant cancels. Scale-invariance tests pass on both balanced and unbalanced panels.Note (deviation from R): Panel DR control augmentation is normalized by treated mass (
sw_t_sumorn_t) rather than control IPW mass (sum(w_cont)). R’sDRDID::drdid_panelusesmean(w.cont)as the control normalizer. Both are consistent asymptotically (under correct model specification,E[w_cont] = E[D]so the normalizers converge), but they differ in finite samples when IPW reweighting doesn’t perfectly balance. The treated-mass normalization is simpler and matches thedid::att_gtconvention where ATT is defined per treated unit. Aligning toDRDID::drdid_panel’s exactw.contnormalization is deferred.Note: PS nuisance IF corrections follow DRDID’s M-estimation convention:
asy_lin_rep_psiis computed on O(1) psi scale (matching R’sasy.lin.rep.ps = score %*% Hessian.ps), then the correctionasy_lin_rep_psi @ M2is converted to the library’s O(1/n) phi convention via a single/ndivision. OR corrections use the same phi-scale pattern viasolve(X'WX)(unnormalized Hessian).Note: CallawaySantAnna panel reg+covariates per-cell IF (survey AND unweighted) carries
DRDID::reg_did_panel’s full OLS estimation-effect term (v3.7). The treated IF is unchanged:inf_treated_i = (sw_i/sum(sw_treated)) * (resid_i - ATT)(unweighted collapse(resid-ATT)/n_t). The control IF isinf_control_i = -(sw_i * wls_resid_i) * proj_iwhereproj_i = 1/sum(sw_control) + (x_i - x̄_c)' G^{-1} (x̄_t - x̄_c)withGthe centered (weighted) control Gram andx̄_t/x̄_cthe (weighted) treated/control covariate means — algebraically identical to R’sasy.lin.rep.ols %*% M1 / mean(w.treat)but evaluated in the centered basis, which is offset-invariant (the raw Gram squares the design’s conditioning; a large constant covariate offset would otherwise push the equilibrated rank check below threshold and silently truncate a genuine direction). The intercept-only collapse is exactly-resid_c/n_c. HISTORY: the pre-v3.7 IF used the raw-residual plug-in (-sw_c_norm * wls_resid_i), documented at the time as “asymptotically valid but may be conservative” — that rationale was WRONG: the omitted term is mean-zero (WLS orthogonality), which is why point estimates always matched R at ~1e-11, but its variance contribution is first-order, leaving per-cell SEs 4-13% and aggregated SEs 3-20% from R, and ANTI-conservative on some fixtures (0.958x on the two-period golden). Per-cell parity vs R goldens is now ~5e-12 (tests/test_csdid_ported.py,tests/test_methodology_callaway.py::TestDRDIDPanelIFParity). SEs pass weight-scale-invariance tests (only weight ratios enter every term).Note (deviation from R): Per-cell ATT(g,t) SEs under survey weights use influence-function-based variance (matching R’s
did::att_gtanalytical SE path) rather than full Taylor-series linearization. When strata/PSU/FPC are present, analytical aggregated SEs (n_bootstrap=0) usecompute_survey_if_variance()on the combined IF/WIF; bootstrap aggregated SEs (n_bootstrap>0) use PSU-level multiplier weights.Note: Repeated cross-sections (
panel=False, Phase 7b): supports surveys like BRFSS, ACS annual, and CPS monthly where units are not followed over time. Uses cross-sectional DRDID (Sant’Anna & Zhao 2020, Section 4):regmatchesDRDID::reg_did_rc(Eq 2.2),drmatchesDRDID::drdid_rc(locally efficient, Eq 3.3+3.4 with 4 OLS fits),ipwmatchesDRDID::std_ipw_did_rc. Per-observation influence functions instead of per-unit. All three estimation methods support covariates and survey weights.Note: Panel and RCS influence functions use the library-wide
phi_i = psi_i / nconvention (SE =sqrt(sum(phi^2)), algebraically equivalent to R’ssd(psi)*sqrt(n-1)/n). Leading IF terms are computed on psi scale and divided by n; PS nuisance corrections are computed on psi scale (score @ solve(Hessian)) with a single/nconversion to phi.Note: Non-survey DR path also includes nuisance IF corrections (PS + OR), matching the survey path structure (Phase 7a). Previously used plug-in IF only. As of v3.7 the non-survey reg and ipw paths carry their corrections too (OR estimation-effect / PS score), so the nuisance-IF treatment is method-uniform.
Reference implementation(s):
R:
did::att_gt()(Callaway & Sant’Anna’s official package)Stata:
csdid
Requirements checklist:
[ ] Requires never-treated units when
control_group="never_treated"(default); not required for"not_yet_treated"[ ] Bootstrap weights support Rademacher, Mammen, Webb distributions
[ ] Aggregations: simple, event_study, group all implemented
[ ] Doubly robust estimation when covariates provided
[ ] Multiplier bootstrap preserves panel structure
[x] Repeated cross-sections (
panel=False) for non-panel surveys (Phase 7b)
ChaisemartinDHaultfoeuille#
Primary sources:
de Chaisemartin, C. & D’Haultfœuille, X. (2022, revised July 2023). Difference-in-Differences Estimators of Intertemporal Treatment Effects. NBER Working Paper 29873. — Web Appendix Section 3.7.3 contains the cohort-recentered plug-in variance formula implemented here.
Phase 1-2 scope: Ships the contemporaneous-switch estimator DID_M (= DID_1 at horizon l = 1) from the AER 2020 paper plus the full multi-horizon event study DID_l for l = 1..L_max from the dynamic companion paper. Phase 2 adds: per-group DID_{g,l} building block (Equation 3), dynamic placebos DID^{pl}_l, normalized estimator DID^n_l, cost-benefit aggregate delta, sup-t simultaneous confidence bands, and plot_event_study() integration. Phase 3 adds covariate adjustment (DID^X), group-specific linear trends (DID^{fd}), state-set-specific trends, and HonestDiD integration. Survey design supports pweight with strata/PSU/FPC via Taylor Series Linearization (analytical) or replicate-weight variance (BRR/Fay/JK1/JKn/SDR) across all IF sites, plus opt-in PSU-level Hall-Mammen wild bootstrap via n_bootstrap > 0 (see the full checklist + Notes below for the contract). This is the most general library estimator for non-absorbing (reversible) treatments - treatment can switch on AND off over time, switcher vs non-switcher is its primitive object, and it allows dynamic (carryover) effects with explicit joiner/leaver (DID_+ / DID_-) decomposition - making it the natural fit for marketing campaigns, seasonal promotions, on/off policy cycles. (LPDiD with non_absorbing="first_entry" / "effect_stabilization" and TROP with non_absorbing=True under a no-dynamic-effects assumption also accept non-absorbing treatment under stronger assumptions.)
Key implementation requirements:
Assumption checks / warnings:
Note: Treatment supports both binary
{0, 1}and non-binary (ordinal or continuous) values. Non-binary treatment requiresL_max >= 1because the per-period DID path uses binary joiner/leaver categorization; the multi-horizon per-group path (DID_{g,l}) handles non-binary correctly. The paper’s setup (Section 2 of the dynamic companion) defines treatment as a general variableD_{g,t}- the binary case is a special case. Under non-binary treatment: baselines areD_{g,1}(float), control pools match on exact baseline value, cohorts are defined by(D_{g,1}, F_g, S_g)whereS_g = sign(D_{g,F_g} - D_{g,1}), and groups with different dose magnitudes but same baseline/timing are pooled within a cohort for variance recentering.NaN values in
treatmentoroutcomecolumns raiseValueErrorearly infit()(no silent drops).Treatment must be constant within each
(g, t)cell. Within-cell-varying treatment (cell min != cell max) raisesValueError. Pre-aggregate your data to constant cell-level treatment before fitting. Fuzzy DiD is deferred to a separate dCDH 2018 paper.Note: Multi-switch groups (those with more than one treatment-change period) are dropped before estimation when
drop_larger_lower=True(the default, matching RDIDmultiplegtDYN). For binary treatment, >1 change means a reversal (e.g., 0->1->0). For non-binary, >1 change includes both reversals (0->2->1) and monotone multi-step paths (0->1->2); both are dropped because the per-groupDID_{g,l}building block attributes the full outcome change fromF_g-1toF_g-1+lto the first treatment change, and a second change would confound that attribution. A single jump of any magnitude (0->3->3->3) has 1 change period and is kept. Each drop emits a warning with the count and example group IDs.Singleton-baseline groups — groups whose
D_{g,1}value is unique in the post-drop dataset — are excluded from the variance computation only (per footnote 15 of the dynamic paper, they have no cohort peer). They are retained in the point-estimate sample as period-based stable controls. Each emits a warning. See the singleton-baseline Note below.Never-switching groups (
S_g = 0) participate in the variance computation when they serve as stable controls under the full influence function. Then_groups_dropped_never_switchingresults field is reported for backwards compatibility but the count no longer represents an actual exclusion.Balanced-baseline panel required (deviation from R
DIDmultiplegtDYN). Every group must have an observation at the first global period (the panel’s earliest time value); groups missing this baseline raiseValueErrorwith the offending group IDs. Groups with interior period gaps (missing observations between their first and last observed period) are dropped with aUserWarning. Terminal missingness (groups observed at the baseline but missing one or more later periods) is retained: the group contributes from its observed periods only, masked out of the missing transitions by the per-periodpresent = (N_mat[:, t] > 0) & (N_mat[:, t-1] > 0)guard. See the ragged-panel deviation Note below.Period-index semantics. The estimator operates on sorted period indices, not calendar dates. Per-period DIDs use
Y_{g,t} - Y_{g,t-1}wheret-1is the previous observed period in the sorted panel, not the previous calendar unit. A panel with periods[2000, 2001, 2003](missing year 2002 for ALL groups) is treated as a valid 3-period panel where 2003 is the immediate successor of 2001. The estimator does NOT validate that periods are evenly spaced or that calendar gaps have been imputed. This matches the AER 2020 paper’s Theorem 3, which defines transition sets by adjacent sorted periods without assuming calendar regularity, and is consistent with RDIDmultiplegtDYN’s behavior. If your data has calendar gaps that should be treated as missing periods rather than adjacent transitions, insert placeholder rows for the missing periods with the group’s lagged treatment value and a reasonable imputed outcome (e.g., the group’s last observed outcome), so the cell-aggregation step treats the gap as a stable-treatment period rather than a missing one. The validator rejects NaN in outcome and treatment columns, so placeholders must have finite values.Per-period Assumption 11 violations (joiners exist but no stable-untreated controls in some period, or leavers exist but no stable-treated controls) trigger zero-retention behavior with a consolidated warning. See the A11 Note below.
Estimator equations (Theorem 3 of AER 2020 / Section 3.7.2 of the dynamic paper):
Per-period DiDs at each switching period t >= 2:
DID_{+,t} = (1/N_{1,0,t}) * sum_{g in joiners(t)} (Y_{g,t} - Y_{g,t-1})
- (1/N_{0,0,t}) * sum_{g in stable_0(t)} (Y_{g,t} - Y_{g,t-1})
DID_{-,t} = (1/N_{1,1,t}) * sum_{g in stable_1(t)} (Y_{g,t} - Y_{g,t-1})
- (1/N_{0,1,t}) * sum_{g in leavers(t)} (Y_{g,t} - Y_{g,t-1})
where joiners(t) are groups switching from D_{g,t-1}=0 to D_{g,t}=1, leavers(t) are groups switching 1->0, stable_0(t) are groups with D_{g,t-1}=D_{g,t}=0, and stable_1(t) are groups with D_{g,t-1}=D_{g,t}=1. In the library’s implementation, N_{a,b,t} is the COUNT of (g, t) cells in each transition state, not the sum of within-cell observation counts. Each (g, t) cell contributes once to its transition’s count regardless of how many original observations fed into the cell mean. The cell mean Y_{g,t} is computed at the cell-aggregation step via groupby([group, time]).agg(y_gt=mean); the per-period DIDs use these cell means directly without further sample-size weighting. This is the library’s documented choice; the AER 2020 paper’s Equation 3 explicitly defines N_{d,d',t} = sum_{g} N_{g,t} (observation sums, cell-size weighting). See the new paper review at docs/methodology/papers/dechaisemartin-dhaultfoeuille-2020-review.md (L76-L88 + L278-L280) for the verbatim paper transcription. Note (deviation from R DIDmultiplegtDYN and from the paper’s main-text formulas): On individual-level inputs with uneven (group, time) cell sizes, Python gives each cell equal weight (cell-count weighting after up-front cell aggregation). R DIDmultiplegtDYN, absent an explicit weight variable, weights estimation by the number of observations in each cell (cell-size weighting), matching the paper’s main-text observation-sum formulas. The two agree exactly on cell-aggregated input where every cell has the same number of observations. The Python parity tests in tests/test_chaisemartin_dhaultfoeuille_parity.py use the generate_reversible_did_data() generator, which produces exactly one observation per cell, so parity holds. The regression test test_cell_count_weighting_unbalanced_input in tests/test_chaisemartin_dhaultfoeuille.py explicitly pins the equal-cell contract.
Aggregate DID_M:
N_S = sum_{t>=2} (N_{1,0,t} + N_{0,1,t})
DID_M = (1/N_S) * sum_{t>=2} (N_{1,0,t} * DID_{+,t} + N_{0,1,t} * DID_{-,t})
Joiners-only and leavers-only views (each weighted by its own switcher count):
DID_+ = sum_{t>=2} (N_{1,0,t} / sum_{t} N_{1,0,t}) * DID_{+,t}
DID_- = sum_{t>=2} (N_{0,1,t} / sum_{t} N_{0,1,t}) * DID_{-,t}
Single-lag placebo (AER 2020 placebo specification, same section as Theorem 3) — applies the same Theorem 3 logic to the pre-period first difference on cells with 3-period histories. Writing d_a(cells) = mean over cells of (Y_{g,t-1} - Y_{g,t-2}) for the pre-period forward first difference:
DID_M^pl = (1/N_S^pl) * sum_{t>=3} (
N_{1,0,t} * [ d_a(stable_0(t)) - d_a(joiners(t)) ] # joiners side, S=+1
+ N_{0,1,t} * [ d_a(leavers(t)) - d_a(stable_1(t)) ] # leavers side, S=-1
)
The per-side terms are the code’s placebo_plus_t = stable0_avg - joiner_avg and placebo_minus_t = leaver_avg - stable1_avg — the backward-difference × switch-direction convention of the sign Note below (equivalently, S_g · (Y_{g,t-2} - Y_{g,t-1})_switcher minus the same for its stable controls, matching _compute_multi_horizon_placebos and R).
Note (sign convention): the reported placebo_effect uses the backward-difference × switch-direction convention of the multi-horizon placebo path (_compute_multi_horizon_placebos: switcher_change - ctrl_avg with Y_bwd - Y_ref, times S_g = +1 joiners / -1 leavers) and R did_multiplegt_dyn. In the phase-1 (L_max=None) code this is placebo_plus_t = stable0_avg - joiner_avg (joiners) and placebo_minus_t = leaver_avg - stable1_avg (leavers). Prior to this the phase-1 path used the opposite (forward-difference) order, so placebo_effect was sign-flipped vs R on pure-direction panels (magnitude bit-identical); the multi-horizon path was always correct. Pinned by tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParity::test_parity_{joiners,leavers}_only. On mixed-direction panels the placebo magnitude additionally carries the documented period-vs-cohort stable-control-set / equal-cell-weighting deviation (see the **Note (deviation from R DIDmultiplegtDYN):** above), so it is not gate-asserted against R there.
Phase 2: Multi-horizon event study (Equation 3 and 5 of the dynamic companion paper):
When L_max >= 1, the estimator computes the per-group building block DID_{g,l} and the aggregate DID_l for each horizon. When L_max=1, overall_att holds DID_1 (the per-group estimand, not the per-period DID_M). When L_max >= 2, overall_att holds the cost-benefit delta. When L_max=None, the per-period DID_M path is used:
DID_{g,l} = Y_{g, F_g-1+l} - Y_{g, F_g-1}
- (1/N^g_{F_g-1+l}) * sum_{g': same baseline, F_{g'}>F_g-1+l}
(Y_{g', F_g-1+l} - Y_{g', F_g-1})
DID_l = (1/N_l) * sum_{g: F_g-1+l <= T_g} S_g * DID_{g,l}
Normalized estimator DID^n_l = DID_l / delta^D_l where delta^D_l = (1/N_l) * sum |delta^D_{g,l}| and delta^D_{g,l} = sum_{k=0}^{l-1} (D_{g,F_g+k} - D_{g,1}). For binary treatment: DID^n_l = DID_l / l.
Cost-benefit aggregate delta = sum_l w_l * DID_l (Lemma 4) where w_l are non-negative weights reflecting the cumulative dose at each horizon. When L_max > 1, overall_att holds this delta.
Dynamic placebos DID^{pl}_l look backward from each group’s reference period, with a dual eligibility condition: F_g - 1 - l >= 1 AND F_g - 1 + l <= T_g.
Note (Phase 2
DID_1vs Phase 1DID_M): WhenL_max >= 2,event_study_effects[1]uses the per-groupDID_{g,1}building block (Equation 3 of the dynamic paper) with cohort-based controls, which may differ slightly from the Phase 1DID_Mvalue (Theorem 3 of AER 2020 with period-based stable-control sets). The Phase 1DID_Mvalue remains accessible viafit(..., L_max=None).overall_att. The difference arises because the per-group path conditions on baseline treatmentD_{g,1}when selecting controls, while the per-period path does not. On pure-direction panels (all joiners or all leavers) the two agree; on mixed-direction panels they can differ by O(1%). This is the same period-vs-cohort control-set deviation documented in the Phase 1 Note above, extended to thel=1event-study entry.Note (Phase 2 equal-cell weighting, deviation from R
DIDmultiplegtDYN): The Phase 1 equal-cell weighting contract carries forward to all Phase 2 estimands (DID_l,DID^{pl}_l,DID^n_l,delta). Each(g, t)cell contributes equally regardless of within-cell observation count. On individual-level inputs with uneven cell sizes, this produces a different estimand than RDIDmultiplegtDYNwhich weights by cell size. The parity tests use one-observation-per-cell generators so parity holds. See the Phase 1 weighting Note above for the full rationale.Note (Phase 2
<50%switcher warning): When fewer than 50% of the l=1 switchers contribute at a far horizon l,fit()emits aUserWarning. The paper recommends not reporting such horizons (Favara-Imbs application, footnote 14).Note (Phase 2 Assumption 7 and cost-benefit delta): Assumption 7 (
D_{g,t} >= D_{g,1}) is required for the single-sign cost-benefit interpretation. When leavers are present (binary: 1->0 groups violate Assumption 7), the estimator emits aUserWarningand providesdelta_joiners/delta_leaversseparately onresults.cost_benefit_delta.Note (Phase 2 cost-benefit delta SE): When
L_max >= 2,overall_attholds the cost-benefitdelta. Its SE is computed via the delta method from per-horizon SEs:SE(delta) = sqrt(sum w_l^2 * SE(DID_l)^2), treating horizons as independent (conservative under Assumption 8). When bootstrap is enabled, per-horizon bootstrap SEs flow through the delta-method formula, sooverall_sereflects bootstrap-derived per-horizon uncertainty but the delta aggregation itself uses normal-theory (not bootstrap percentile). This is an intentional exception to the general bootstrap-inference-surface contract:overall_p_valueandoverall_conf_intfordeltausesafe_inference(delta, delta_se), not percentile bootstrap, because the delta is a derived aggregate rather than a directly bootstrapped estimand.Note (dynamic placebo SE - library extension): Dynamic placebos
DID^{pl}_l(negative horizons inplacebo_event_study) now have analytical SE and bootstrap SE whenL_max >= 1. The placebo IF uses the same cohort-recentered structure as positive horizons, applied to backward outcome differencesY_{g, F_g-1-l} - Y_{g, F_g-1}with the dual-eligibility control pool (forward + backward observation required). The paper’s Theorem 1 variance result is stated forDID_l, notDID^{pl}_l- this extension applies the same IF/variance structure to the placebo estimand as a library enhancement. The single-period placeboDID_M^pl(L_max=None) retains NaN SE because the per-period aggregation path has no IF derivation.
Standard errors (Web Appendix Section 3.7.3 of the dynamic companion paper):
Default: cohort-recentered analytical plug-in variance, evaluated at horizon l = 1. Cohorts are defined by the triple (D_{g,1}, F_g, S_g) (baseline treatment, first-switch period, switch direction). Each group’s per-period role weights (joiner, stable_0, leaver, stable_1) sum to a per-group U^G_g value via the full Lambda^G_{g,l=1} weight vector from Section 3.7.2 of the dynamic paper:
N_S * DID_M = sum_t [
sum_{g in joiners(t)} (Y_{g,t} - Y_{g,t-1})
- (N_{1,0,t} / N_{0,0,t}) * sum_{g in stable_0(t)} (Y_{g,t} - Y_{g,t-1})
+ (N_{0,1,t} / N_{1,1,t}) * sum_{g in stable_1(t)} (Y_{g,t} - Y_{g,t-1})
- sum_{g in leavers(t)} (Y_{g,t} - Y_{g,t-1})
]
Reading off the coefficient on each (Y_{g,t} - Y_{g,t-1}) gives the per-cell role weight, which sums across periods to:
U^G_g = sum_t lambda^G_{g,t} * (Y_{g,t} - Y_{g,t-1}) # full IF
U_bar_k = (1/|C_k|) * sum_{g in C_k} U^G_g # cohort-conditional mean
sigma_hat^2 = sum_g (U^G_g - U_bar_{cohort(g)})^2 / N_l
SE = sqrt(sigma_hat^2 / N_l)
Each switching group typically contributes from MULTIPLE periods: its own switch period plus every period where it serves as a stable control for another cohort’s switch. Never-switching groups can also have non-zero U^G_g when they serve as stable controls. Singleton-baseline groups (footnote 15 of dynamic paper) are excluded from this sum because they have no cohort peer.
The cohort recentering is critical: subtracting cohort-conditional means is not the same as subtracting a single grand mean. The implementation has a dedicated regression test (test_cohort_recentering_not_grand_mean) that computes both formulas on a designed DGP and asserts they differ materially.
Alternative: Multiplier bootstrap clustered at group via the n_bootstrap parameter. Available weight distributions: "rademacher" (default), "mammen", "webb". The bootstrap is a library extension beyond the original papers and is provided for consistency with CallawaySantAnna / ImputationDiD / TwoStageDiD.
Edge cases:
No switchers in data (after filtering): raises
ValueErrorwith a clear message indicating which filters dropped which groups.No joiners (only leavers in data):
joiners_available = False, alljoiners_*fields areNaN. Symmetric forleavers_available = False.T < 3: placebo cannot be computed;placebo_available = Falsewith aUserWarning.NaN inference:
safe_inference()produces NaN-consistent inference fields (t-stat, p-value, conf int) when SE is non-finite or zero.assert_nan_inference()is used in tests to enforce consistency.TWFE diagnostic with zero denominator: when
sum(d_gt - d_bar)^2 == 0(e.g., all cells have identical treatment), the diagnostic returns NaN forbeta_feandsigma_fewith aUserWarning. The diagnostic is non-fatal — it does not block the main estimation.placebo=False(gating): the results object still exposesplacebo_*fields, but withNaNvalues andplacebo_available = False. This keeps the API surface stable.Note: The analytical CI is conservative under Assumption 8 (independent groups) of the dynamic companion paper, and exact only under iid sampling. This is documented as a deliberate deviation from “default nominal coverage”. The bootstrap CI uses the same conservative weighting and is provided for users who want a non-asymptotic alternative.
Note (deviation from R DIDmultiplegtDYN - SE normalization): The analytical SE is ~4% smaller than R
did_multiplegt_dynon identical data. This is a normalization difference, not a bug. Python implements the paper’s Section 3.7.3 plug-in formula verbatim:SE = sigma-hat / sqrt(N_l)wheresigma-hat^2 = (1/N_l) * sum_g U^{G,2}_{g,l} - sum_k (#C_k^G / N_l) * U-bar_k^2andN_lis the number of eligible switcher groups at horizonl. R normalizes the influence function byG(total number of groups including never-switchers and stable controls) and computesSE = sqrt(sum(U_R^2)) / G. Both converge to the same asymptotic variance asG -> infinity. In finite samples R’s formula produces slightly larger (more conservative) SEs because theG-normalization interacts with cohort recentering differently than the paper’sN_l-normalization. Since the paper’s formula is already an upper bound on the true variance (Eq 54, Jensen’s inequality under Assumption 8), Python’s tighter SE remains conservative. The observed gap is consistent across horizons and scenarios (~3.5-5.1%), deterministic on identical data, and does not involve any randomization.Note: Placebo SE is
NaNfor the single-periodDID_M^pl(L_max=None). Multi-horizon placebos (L_max >= 1) have valid analytical SE and bootstrap SE via the placebo IF (see the dynamic placebo SE Note above).Note: When every variance-eligible group forms its own
(D_{g,1}, F_g, S_g)cohort (a degenerate small-panel case where the cohort framework has zero degrees of freedom), the cohort-recentered plug-in formula is unidentified: cohort recentering subtracts the cohort mean from each group’sU^G_g, and for singleton cohorts the centered value is exactly zero, so the centered influence function vector collapses to all zeros. The estimator returnsoverall_se = NaNwith aUserWarningrather than silently collapsing to0.0(which would falsely imply infinite precision). TheDID_Mpoint estimate remains well-defined. The bootstrap path inherits the same degeneracy on these panels — the multiplier weights act on an all-zero vector, so the bootstrap distribution is also degenerate. Deviation from RDIDmultiplegtDYN: R returns a non-zero SE on the canonical 4-group worked example via small-sample sandwich machinery that Python does not implement. Both responses are valid for a degenerate case; Python’sNaN+warning is the safer default. To get a non-degenerate SE, include more groups so cohorts have peers (real-world panels typically haveG >> K).Note (cluster contract):
ChaisemartinDHaultfoeuilleclusters at the group level by default. The analytical SE plug-in operates on per-group influence-function values (oneU^G_gper group) and, under the cell-period allocator, on their per-cell decompositionU[g, t]which telescopes back toU^G_gat the PSU-level sum. The multiplier bootstrap generates one weight per group. The user-facingcluster=kwarg is not supported: the constructor acceptscluster=None(the default and only supported value); passing any non-Nonevalue raisesNotImplementedErrorat construction time (and the same gate fires fromset_params) — custom user-specified clustering is reserved for a future phase. Automatic PSU-level clustering undersurvey_design: the analytical TSL path supports PSU labels that vary across cells of a group (within-cell constancy required); the multiplier bootstrap supports the same regime via the cell-level wild PSU bootstrap documented in the survey + bootstrap contract Note below. Under PSU-within-group-constant regimes (including the default auto-injectpsu=groupand strictly-coarser PSU with within-group constancy), the bootstrap dispatcher routes through the legacy group-level path so the SE is bit-identical to pre-cell-level releases via the identity-map fast path. The matching test for thecluster=gate istest_cluster_parameter_raises_not_implementedintests/test_chaisemartin_dhaultfoeuille.py::TestForwardCompatGates.Note (bootstrap inference surface): When
n_bootstrap > 0, the top-levelresults.overall_p_value/results.overall_conf_int(and joiners/leavers analogues) hold percentile-based bootstrap inference computed by the multiplier bootstrap, NOT normal-theory recomputations from the bootstrap SE. The t-stat (overall_t_stat, etc.) is computed from the SE viasafe_inference()[0]to satisfy the project’s anti-pattern rule (never computet = effect / seinline) — bootstrap does not define an alternative t-stat semantic for percentile bootstrap, so the SE-based t-stat is the natural choice.event_study_effects[1],summary(),to_dataframe(),is_significant, andsignificance_starsall read from these top-level fields and therefore reflect the bootstrap inference automatically. The library precedent for this propagation isimputation.py:790-805,two_stage.py:778-787, andefficient_did.py:1009-1013. The single-period placebo (L_max=None) still has NaN bootstrap fields; multi-horizon placebos (L_max >= 1) have valid bootstrap SE/CI/p viaplacebo_horizon_ses/cis/p_valueson the bootstrap results object. The matching test istest_bootstrap_p_value_and_ci_propagated_to_top_levelintests/test_chaisemartin_dhaultfoeuille.py::TestBootstrap.Note: Placebo Assumption 11 violations (placebo joiners exist but no 3-period stable_0 controls, or symmetric for leavers/stable_1) trigger zero-retention in the placebo numerator AND emit a consolidated
Placebo (DID_M^pl) Assumption 11 violationswarning fromfit(), mirroring the main DID path’s contract documented above. The zeroed placebo periods retain their switcher counts in the placeboN_S^pldenominator, biasingDID_M^pltoward zero in the offending direction (matching the placebo paper convention).Note: The TWFE diagnostic (
twfe_diagnostic=Trueinfit()and the standalonetwowayfeweights()) requires binary{0, 1}treatment. On non-binary data,fit()emits aUserWarningand skips the diagnostic (alltwfe_*fields areNone), whiletwowayfeweights()raisesValueError. The diagnostic usesd_gt == 1as the treated-cell mask per Theorem 1 of AER 2020, which is undefined for non-binary treatment.Note (TWFE diagnostic sample contract): The fitted
results.twfe_weights/results.twfe_fraction_negative/results.twfe_sigma_fe/results.twfe_beta_feare computed on the FULL pre-filter cell sample — the data the user passed in, after_validate_and_aggregate_to_cells()runs but before the ragged-panel validation (Step 5b) and the multi-switch filter (drop_larger_lower, Step 6). They do NOT describe the post-filter estimation sample used byoverall_att,results.groups, and the inference fields.fit()has three sample-shaping filters in total: (1) interior-gap drops in Step 5b, (2) multi-switch drops in Step 6, and (3) the singleton-baseline filter in Step 7. Filters (1) and (2) actually shrink the point-estimate sample, so when either fires, the fitted TWFE diagnostic andoverall_attdescribe different samples and the estimator emits aUserWarningexplaining the divergence with explicit counts. Filter (3) is variance-only — singleton-baseline groups remain in the point-estimate sample as period-based stable controls (see the singleton-baseline Note above) — so it does NOT create a fitted-vs-overall_attmismatch and does NOT trigger the divergence warning. Rationale for the pre-filter design: the TWFE diagnostic answers “what would the plain TWFE estimator say on the data you passed in?” — not “what would TWFE say on the data dCDH actually used after filtering?” — so users comparing TWFE vs dCDH on a fixed input can do so without an interaction effect from the dCDH-specific filters. The standalonetwowayfeweights()function uses the same pre-filter sample and accepts the samesurvey_designparameter asfit(), so the fitted and standalone APIs always produce identical numbers on the same input — including survey-weighted cell aggregation (twowayfeweights(data, ..., survey_design=sd)matchesfit(data, ..., survey_design=sd).twfe_*). To reproduce the dCDH estimation sample for an external TWFE comparison, pre-process your data to drop the multi-switch and interior-gap groups before fitting (the warning lists offending IDs). The matching tests aretest_twfe_pre_filter_contract_with_interior_gap_dropandtest_twfe_pre_filter_contract_with_multi_switch_dropintests/test_chaisemartin_dhaultfoeuille.py.Note: By default (
drop_larger_lower=True), the estimator drops groups whose treatment switches more than once before estimation. This matches RDIDmultiplegtDYN’s default and is required for the analytical variance formula (Web Appendix Section 3.7.3 of the dynamic paper, which assumes Assumption 5 / no-crossing) to be consistent with the AER 2020 Theorem 3 point estimate. Both formulas operate on the same post-drop dataset. Settingdrop_larger_lower=Falseis supported for diagnostic comparison but produces an inconsistent estimator-variance pairing for any multi-switch groups present, and emits an explicit warning.Note: When Assumption 11 (existence of stable controls) is violated for some period
t— i.e., joiners exist but no stable-untreated controls, or leavers exist but no stable-treated controls —DID_{+,t}(orDID_{-,t}) is set to zero by paper convention, and the period’s switcher count is retained in theN_Sdenominator. This means the affected period contributes a zero to the numerator with a non-zero weight in the denominator, biasingDID_Mtoward zero in the offending direction. Users can detect this by inspectingresults.per_period_effects[t]['did_plus_t_a11_zeroed'](ordid_minus_t_a11_zeroed) or the consolidatedfit()warning. This matches the AER 2020 Theorem 3 paper convention and the worked example arithmetic.Note: Groups whose baseline treatment value
D_{g,1}is unique in the post-drop panel (not shared by any other group) are excluded from the variance computation only per footnote 15 of the dynamic companion paper. They have no cohort peer for the cohort-recentered plug-in formula. They are retained in the point-estimate sample as period-based stable controls (Python’s documented period-vs-cohort interpretation). The dropped count is stored onresults.n_groups_dropped_singleton_baseline, a warning lists example group IDs, and the warning text explicitly states “VARIANCE computation only” so users know the filter does not changeDID_M.Note (deviation from R DIDmultiplegtDYN): Python uses period-based stable-control sets —
stable_0(t)is any cell withD_{g,t-1} = D_{g,t} = 0regardless of baselineD_{g,1}, and similarly forstable_1(t). RDIDmultiplegtDYNuses cohort-based stable-control sets that additionally requireD_{g,1}to match the side. Python’s definition matches the AER 2020 Theorem 3 transition-state notationN_{0,0,t}andN_{1,1,t}literally (treating those as the paper-defined sets of groups in each transition state, independent of the observation-sum-vs-cell-count weighting axis covered in the equal-cell Note above); R’s definition matches the dynamic companion paper’s cohort(D_{g,1}, F_g, S_g)framework. The two definitions agree exactly on (a) panels containing only joiners, (b) panels containing only leavers, (c) the hand-calculable 4-group worked example, or (d) any panel where no joiner’s post-switch state overlaps a period when leavers are switching. They disagree by O(1%) on the point estimate when both joiners and leavers exist AND some joiners’ post-switch cells could serve as leavers’ controls (or vice versa). After the Round 2 fix that implemented the fullLambda^G_{g,l=1}influence function, the standard error parity gap on pure-direction scenarios narrowed from ~18% to ~3%. The R parity tests intests/test_chaisemartin_dhaultfoeuille_parity.pyuse a tight1e-4tolerance for pure-direction point estimates, 10% rtol for multi-horizon SEs (15% for L_max=5 long panels where the cell-count weighting deviation compounds), 5% rtol for single-horizon SEs, and a 2.5% tolerance for mixed-direction point estimates (with the SE check skipped on mixed scenarios because the period-vs-cohort point-estimate deviation cascades into the variance).Note (deviation from R DIDmultiplegtDYN): Phase 1 requires panels with a balanced baseline (every group observed at the first global period) and no interior period gaps. The Step 5b validation in
fit()enforces this contract: groups missing the baseline raiseValueError; groups with interior gaps are dropped with aUserWarning; groups with terminal missingness (early exit / right-censoring — observed at the baseline but missing one or more later periods) are retained and contribute from their observed periods only. RDIDmultiplegtDYNaccepts unbalanced panels with documented missing-treatment-before-first-switch handling. Python’s restriction is a Phase 1 limitation: the cohort enumeration usesD_{g,1}as the canonical baseline (so the baseline observation must exist) and the first-switch detection walks adjacent observed periods (so interior gaps create ambiguous transition counts). Terminal missingness is supported at the POINT-ESTIMATE level because the per-periodpresent = (N_mat[:, t] > 0) & (N_mat[:, t-1] > 0)guard appears at three sites in the variance computation (_compute_per_period_dids,_compute_full_per_group_contributions,_compute_cohort_recentered_inputs) and cleanly masks out missing transitions without propagating NaN into the arithmetic. Scope limitation (terminal missingness under any cell-period-allocator path): under any survey variance path that uses the cell-period allocator, a targetedValueErroris raised when cohort-recentering leaks non-zero centered IF mass onto cells with no positive-weight observations. Affected paths:Binder TSL with within-group-varying PSU (
n_bootstrap=0, explicitpsu=<col>that varies within group).Rao-Wu replicate-weight ATT (
compute_replicate_if_variancealways reads the cell-allocatorpsi_obsper the Class A contract shipped in PR #323, regardless of PSU structure).Cell-level wild PSU bootstrap (
n_bootstrap > 0with within-group-varying PSU).
The guard is fired by _survey_se_from_group_if (analytical and replicate) and by _unroll_target_to_cells (bootstrap). Unaffected paths: Binder TSL under PSU-within-group-constant regimes (including PSU=group auto-inject) falls back to the legacy group-level allocator where the row-sum identity sum_{c in g} U_centered_per_period[g, t] == U_centered[g] makes the two statistically equivalent, and the bootstrap dispatcher routes the same regimes through the legacy group-level path. Workaround: pre-process the panel to remove terminal missingness (drop late-exit groups or trim to a balanced sub-panel). For Binder TSL, using an explicit psu=<group_col> routes through the legacy group allocator. For replicate ATT and within-group-varying-PSU bootstrap, there is no allocator fallback — the panel itself must be pre-processed. The broader unbalanced-panel workaround (back-fill the baseline or drop late-entry groups before fitting, or use R DIDmultiplegtDYN) also applies. The Step 5b ValueError and UserWarning messages name the offending group IDs so you can locate them quickly.
Note (Phase 3 DID^X covariate adjustment): When
controlsis set,per_period_effects(the Phase 1 per-period DID_M decomposition) remains unadjusted (computed on raw outcomes). The covariate residualization applies only to the per-groupDID_{g,l}path (L_max >= 1), which producesevent_study_effectsandoverall_att. This meansper_period_effectsandevent_study_effects[1]may diverge when controls are active - by design (the per-period path uses binary joiner/leaver categorization and is not part of the DID^X contract). Implements the residualization-style covariate adjustment from Web Appendix Section 1.2 (Assumption 11). For each baseline treatment valued, estimatestheta_hat_dvia OLS of first-differenced outcomes on first-differenced covariates with time FEs, restricted to not-yet-treated observations. Residualizes at levels:Y_tilde[g,t] = Y[g,t] - X[g,t] @ theta_hat_d. All downstream DID computations use residualized outcomes. This is NOT doubly-robust, NOT IPW, NOT Callaway-Sant’Anna-style. Plug-in IF (treatingtheta_hatas fixed) is valid by FWL theorem. Deviation from RDIDmultiplegtDYN: The first-stage OLS uses equal cell weights (one observation per(g,t)cell), consistent with the library’s cell-count weighting convention documented in Phase 1. R weights byN_gt(observation count per cell). On panels with 1 observation per cell (the common case), results are identical. When baseline-specific first stages fail (n_obs = 0orn_obs < n_params), the affected strata are excluded from the estimation (outcomes set to NaN) rather than retained unadjusted - matching R’s “drop failed strata” behavior. RequiresL_max >= 1. Activated viacontrols=["col1", "col2"]infit().Note (Phase 3 DID^{fd} linear trends): Implements group-specific linear trends from Web Appendix Section 1.3 (Assumption 12, Lemma 6). Uses the Z_mat transformation:
Z[g,t] = Y[g,t] - Y[g,t-1](first-differenced outcomes). SinceDID_{g,l}(Z) = DID^{fd}_{g,l}algebraically, the existing multi-horizon DID code produces trend-adjusted estimates when fed Z_mat. Requires F_g >= 3 (at least 2 pre-switch periods); groups with F_g < 3 are excluded with aUserWarning. Cumulated level effectsdelta^{fd}_l = sum_{l'=1}^l DID^{fd}_{l'}stored inresults.linear_trends_effects. Cumulated SE uses conservative upper bound (sum of per-horizon SEs); cross-horizon covariance from IF vectors is a library extension (paper proves Theorem 1 per-horizon, not cross-horizon). When combined with DID^X, residualization is applied first, then first-differencing (per paper assumption ordering). Suppressed surfaces undertrends_linear:normalized_effects(DID^n_l) andcost_benefit_deltaare suppressed because they would operate on second-differences rather than level effects. Users should access cumulated level effects vialinear_trends_effects. Activated viatrends_linear=Trueinfit().Note (Phase 3 state-set trends): Implements state-set-specific trends from Web Appendix Section 1.4 (Assumptions 13-14). Restricts the control pool for each switcher to groups in the same set (e.g., same state in county-level data). The restriction applies in all four DID/IF paths:
_compute_multi_horizon_dids(),_compute_per_group_if_multi_horizon(),_compute_multi_horizon_placebos(), and_compute_per_group_if_placebo_horizon(). Cohort structure stays as(D_{g,1}, F_g, S_g)triples (does not incorporate set membership). Set membership must be time-invariant per group. Note on Assumption 14 (common support): The paper requires a common last-untreated period across sets (T_u^sequal for alls). This implementation does NOT enforce Assumption 14 up front. Instead, when within-set controls are exhausted at a given horizon (because a set has shorter untreated support than others), the affected switcher/horizon pairs are silently excluded via the existing empty-control-pool mechanism. This meansN_lmay be smaller undertrends_nonparamthan without it, and the effective estimand is trimmed to the within-set support at each horizon. The existing multi-horizon A11 warning fires when exclusions occur. Activated viatrends_nonparam="state_column"infit().Note (Phase 3 heterogeneity testing - partial implementation): Partial implementation of the heterogeneity test from Web Appendix Section 1.5 (Assumption 15, Lemma 7). Computes post-treatment saturated OLS regressions of
S_g * (Y_{g, F_g-1+l} - Y_{g, F_g-1})on a time-invariant covariateX_gplus cohort indicator dummies. Standard OLS inference is valid (paper shows no DID error correction needed). Deviation from Rpredict_het: Python now matches R on per-horizon placebo regressions when the user setsplacebo=Truetogether withheterogeneity=(post-2026-05-15; see “Placebo predict_het” sub-note below for the full contract). The remaining gap is the joint null F-test that R aggregates across allpredict_hetrows — Python emits per-horizont_stat/p_value/conf_intonly and does NOT compute a joint Wald test across forward + placebo coefficients (tracked at REGISTRY note’s “Per-horizon regressions only (no joint F-test)” rendering line). R also disallows combination withcontrols, which Python continues to enforce as an explicitValueError. Rejected combinations:controls(matching R),trends_linear(heterogeneity test uses raw level changes, incompatible with second-differenced outcomes), andtrends_nonparam(heterogeneity test does not thread state-set control-pool restrictions). Results stored inresults.heterogeneity_effects. Activated viaheterogeneity="covariate_column"infit(). Note (survey support): Undersurvey_design, heterogeneity uses WLS with per-group weightsW_g = sum of obs-level survey weights in group g, and the group-level WLS coefficient influence function isψ_g[X] = inv(X'WX)[1,:] @ x_g * W_g * r_g. The group-level IF is then attributed to observation level via one of two allocators, chosen by variance helper so each path preserves byte-identity for its aggregation rule: (1) Binder TSL (compute_survey_if_variance) uses the cell-period single-cell allocator — at each horizonl_h,ψ_gis assigned in full to the post-period cell(g, out_idx)without_idx = first_switch_idx[g] - 1 + l_hand expanded asψ_i = ψ_g * (w_i / W_{g, out_idx})for obs in that cell, zero elsewhere (matches the DID_l post-period convention in the Survey IF expansion Note below). Under PSU=group per-observation distribution differs from the legacyψ_i = ψ_g * (w_i / W_g), but PSU-level aggregates telescope to the sameψ_g— so Binder TSL variance is byte-identical to the pre-cell-period release under PSU=group. Under within-group-varying PSU mass lands in the post-period PSU of the transition, which is what Binder TSL needs. An empty post-period cell under zero-weight obs (all obs at(g, out_idx)havew_i = 0despiteN > 0) drops the group’s contribution, matching the ATT cell allocator’s convention; the pre-cell-period path diverged here by redistributing mass to other cells of the group. (2) Rao-Wu replicate (compute_replicate_if_variance) uses the legacy group-level allocatorψ_i = ψ_g * (w_i / W_g). Replicate variance computesθ_r = sum_i ratio_ir * ψ_iat the observation level, so movingψ_gmass onto the post-period cell only would silently change the replicate SE whenever a replicate column’s ratios vary within group (the library accepts arbitrary per-row replicate matrices, not just PSU-aligned ones). Keeping the legacy allocator on this branch preserves byte-identity of replicate SE across every previously-supported fit; replicate + within-group-varying PSU is unreachable by construction (SurveyDesignrejectsreplicate_weightscombined with explicitstrata/psu/fpc). Inference uses the t-distribution withdf_surveywhen provided. Under rank deficiency (any regression coefficient dropped bysolve_ols’s R-style drop), all inference fields return NaN (conservative, matches the NaN-consistent contract). Library extension (replicate weights): Under a replicate-weight design (BRR/Fay/JK1/JKn/SDR), the heterogeneity regression dispatches tocompute_replicate_if_variance(Rao-Wu weight-ratio rescaling) instead of the Binder TSL formula. The effective df is the sharedmin(resolved_survey.df_survey, min(n_valid_across_sites) - 1)used by the rest of the dCDH surfaces; if the basedf_surveyis undefined (QR-rank ≤ 1), heterogeneity inference is NaN regardless of the localn_valid_het(matching the dCDH top-level contract — per-siten_validcannot rescue a rank-deficient design). Library extension: RDIDmultiplegtDYN::predict_hetdoes not natively support survey weights. Scope note (bootstrap): Heterogeneity inference is analytical (no bootstrap path). Whenn_bootstrap > 0is combined withheterogeneity=, the main ATT surfaces receive bootstrap SE/CI (via the cell-level wild PSU bootstrap described in the survey + bootstrap contract Note below) whileheterogeneity_effectscontinues to use the Binder TSL / Rao-Wu analytical SE described above. No gate; the two inference paths are independent.Note (HonestDiD integration): HonestDiD sensitivity analysis (Rambachan & Roth 2023) is available on the placebo + event study surface via
honest_did=Trueinfit()orcompute_honest_did(results)post-hoc. Library extension: dCDH HonestDiD usesDID^{pl}_lplacebo estimates as pre-period coefficients rather than standard event-study pre-treatment coefficients. The Rambachan-Roth restrictions bound violations of the parallel trends assumption underlying the dCDH placebo estimand; interpretation differs from canonical event-study HonestDiD. AUserWarningis emitted at runtime. Uses diagonal variance (no full VCV available for dCDH). Relative magnitudes (DeltaRM) with Mbar=1.0 is the default when called fromfit(), targeting the equal-weight average over all post-treatment horizons (l_vec=None). R’s HonestDiD defaults to the first post/on-impact effect; usecompute_honest_did(results, ...)with a customl_vecto match that behavior. Whentrends_linear=True, bounds apply to the second-differenced estimand (parallel trends in first differences). RequiresL_max >= 1for multi-horizon placebos. Gaps in the horizon grid fromtrends_nonparamsupport-trimming are handled by filtering to the largest consecutive block and warning.Note (Phase 3 Design-2 switch-in/switch-out): Convenience wrapper for Web Appendix Section 1.6 (Assumption 16). Identifies groups with exactly 2 treatment changes (join then leave), reports switch-in and switch-out mean effects. This is a descriptive summary, not a full re-estimation with specialized control pools as described in the paper. Always uses raw (unadjusted) outcomes regardless of active
controls,trends_linear, ortrends_nonparamoptions - those adjustments apply to the main estimator surface but not to the Design-2 descriptive block. For full adjusted Design-2 estimation with proper control pools, the paper recommends “running the command on a restricted subsample and usingtrends_nonparamfor the entry-timing grouping.” Activated viadesign2=Trueinfit(), requiresdrop_larger_lower=Falseto retain 2-switch groups.Note (Phase 3
by_pathper-path event-study disaggregation): Per-path disaggregation of the multi-horizon event study, mirroring Rdid_multiplegt_dyn(..., by_path=k). Activated viaChaisemartinDHaultfoeuille(by_path=k, drop_larger_lower=False)wherekis a positive integer (top-k most common observed paths by switcher-group frequency). Window convention: the path tuple for a switcher groupgis(D_{g, F_g-1}, D_{g, F_g}, ..., D_{g, F_g-1+L_max})— lengthL_max + 1, matching R’s window[F_{g-1}, F_{g-1+l}]. Ranking: paths are ranked by descending frequency; ties are broken lexicographically on the path tuple for deterministic ordering, so every selected path has a uniquefrequency_rank. Ifby_pathexceeds the number of observed paths, all observed paths are returned with aUserWarning. Per-path SE convention (joiners/leavers precedent): the per-path influence function follows the joiners-only / leavers-only IF construction atchaisemartin_dhaultfoeuille.py:5495-5504: the switcher-side contribution+S_g * (Y_{g,out} - Y_{g,ref})is zeroed for groups whose observed trajectory is NOT the selected path; control contributions and the full cohort structure(D_{g,1}, F_g, S_g)are unchanged. After applying the singleton-baseline eligible mask and cohort-recentering with the original cohort IDs, the plug-in SE uses the path-specific divisorN_l_path(count of path switchers eligible at horizonl) — same pattern asjoiners_seusingjoiner_total. This gives the within-path mean estimandDID_{path,l}as the within-path average ofDID_{g,l}. Degenerate-cohort behavior per path: when a path’s centered IF at some horizon is identically zero (every variance-eligible path switcher forms its own(D_{g,1}, F_g, S_g)cohort, or the path has a single contributing group), SE / t_stat / p_value / conf_int are NaN-consistent and aUserWarningis emitted scoped to(path, horizon). This mirrors the overall-path degenerate-cohort surface and is common for rare paths with few contributing groups. Empty-state contract:results.path_effectsdistinguishes “not requested” (None) from “requested but empty” ({}— all switchers have windows outside the panel or unobserved cells). The empty-dict case emits aUserWarningat fit-time and renders as an explicit “no observed paths” notice insummary();to_dataframe(level="by_path")returns an empty DataFrame with the canonical column set (mirrors thelinear_trendspattern whentrends_linear=Truebut no horizons survive). Requirements:drop_larger_lower=False(multi-switch groups are the object of interest; defaultTruefilters them out) andL_max >= 1(path window depends on the horizon). Scope: combinations withdesign2andhonest_didremain gated behind explicitNotImplementedError(deferred to follow-up wave PRs);heterogeneityis supported per-path — see the Per-path heterogeneity testing paragraph below.n_bootstrap > 0is now supported — see the Bootstrap SE paragraph below.survey_designis supported under analytical Binder TSL and replicate-weight bootstrap — see the Per-path survey-design SE paragraph below; multiplier bootstrap (n_bootstrap > 0) undersurvey_design + by_path/paths_of_interestremains gated.placebo=Trueis now supported per-path — see the Per-path placebos paragraph below. TWFE diagnostic remains a sample-level summary (not computed per path) in this release. Results are exposed onresults.path_effectsasDict[Tuple[int, ...], Dict[str, Any]]with nestedhorizonsdicts per horizonl, and onresults.to_dataframe(level="by_path")as a long-format table with columns[path, frequency_rank, n_groups, horizon, effect, se, t_stat, p_value, conf_int_lower, conf_int_upper, n_obs, cband_lower, cband_upper, cumulated_effect, cumulated_se, het_beta, het_se, het_t_stat, het_p_value, het_conf_int_lower, het_conf_int_upper](thecband_*columns are added by the joint sup-t Note below, populated for positive-horizon rows of paths with a finite sup-t crit and NaN otherwise; thecumulated_*columns are added by the per-path linear-trends Note below, populated for positive-horizon rows whentrends_linear=Trueis set and NaN otherwise). Gated tests live intests/test_chaisemartin_dhaultfoeuille.py::TestByPathGates/::TestByPathBehavior/::TestByPathEdgeCases. R-parity againstDIDmultiplegtDYN 2.3.3is confirmed attests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathvia two scenarios:mixed_single_switch_by_path(2 paths,by_path=2) andmulti_path_reversible_by_path(4 paths,by_path=3; path-assignment deterministic onF_gso each(D_{g,1}, F_g, S_g)cohort contains switchers from a single path). Per-path point estimates and per-path switcher counts match R exactly; per-path SE matches within the Phase 2 multi-horizon SE envelope (observed rtol ≤ 10.2% on the 2-path mixed scenario, ≤ 4.2% on the 4-path cohort-clean scenario). Deviation from R (cross-path cohort-sharing SE): our analytical SE is the marginal variance of the path-contribution estimator cohort-centered on the full-panel cohort structure (joiners/leavers precedent — non-path switchers contribute to cohort means via their zeroed switcher row). R’sdid_multiplegt_dyn(..., by_path=k)re-runs the estimator per path, so cohort means are computed over the path’s own switchers only. When a cohort(D_{g,1}, F_g, S_g)spans multiple observed paths, Python and R SE diverge materially (our empirical probes with random post-window toggling saw rtol > 100%); when every cohort is single-path (scenario 13 by design, scenario 14 by construction), the two approaches coincide up to the documented Phase 2 envelope. Practitioners with cohort structures that mix paths should interpret the per-path SE as a within-full-panel marginal variance, not a per-path conditional variance. Bootstrap SE: whenn_bootstrap > 0is set, the top-k paths are enumerated once on the observed data (R-faithful: matchesdid_multiplegt_dyn(..., by_path=k, bootstrap=B)’s path-stability convention — verified empirically against DIDmultiplegtDYN 2.3.3) and the multiplier bootstrap (bootstrap_weights ∈ {"rademacher", "mammen", "webb"}) runs per(path, horizon)target via the shared_bootstrap_one_target/compute_effect_bootstrap_statshelpers. Point estimates are unchanged from the analytical path. Bootstrap SE replaces the analytical SE inpath_effects[path]["horizons"][l]["se"], andp_value/conf_intare taken as the bootstrap percentile statistics, matching the Round-10 library convention for overall / joiners / leavers / multi-horizon bootstrap (see theNote (bootstrap inference surface)elsewhere in this file and the pinned regressiontest_bootstrap_p_value_and_ci_propagated_to_top_level).t_statis SE-derived viasafe_inferenceper the anti-pattern rule. Interpretation: inference is conditional on the observed path set. SE inherits the analytical cross-path cohort-sharing deviation: the bootstrap input is the exact same full-panel cohort-centered path IF that the analytical path computes (_collect_path_bootstrap_inputsreuses the same enumeration / cohort IDs / IF construction), so the bootstrap SE is a Monte Carlo analog of the analytical SE — it inherits the same cross-path cohort-sharing deviation from R’s per-path re-run convention documented above. On single-path-cohort panels (scenarios 13 and 14 of the R-parity fixture, and any DGP where(D_{g,1}, F_g, S_g)cohorts never span multiple observed paths), bootstrap SE tracks analytical SE up to Monte Carlo noise and both coincide with R up to the Phase 2 envelope. On cross-path cohort panels, bootstrap SE inherits the >100% rtol divergence from R that analytical already has. Deviation from R (CI method): R’s per-path CI is normal-theory around the bootstrap SE (half-width ≈1.96·se); ours is the bootstrap percentile CI, intentionally diverging from R to keep the dCDH inference surface internally consistent across all bootstrap targets. Practitioners who want unconditional inference capturing path-selection uncertainty need a pairs-bootstrap (deferred — no R precedent). Positive regressions live intests/test_chaisemartin_dhaultfoeuille.py::TestByPathBootstrap(gated@pytest.mark.slow): point-estimate invariance, finite positive SE on non-degenerate panels, SE-within-30%-rtol of analytical on cohort-clean fixtures, degenerate-cohort NaN propagation, Rademacher/Mammen/Webb parity, seed reproducibility, and percentile-vs-normal-theory CI pinning. Per-path placebos: whenplacebo=True(andL_max >= 1) is combined withby_path=k, per-path backward-horizon placebosDID^{pl}_{path, l}forl = 1..L_maxare computed using the same joiners/leavers IF precedent applied to_compute_per_group_if_placebo_horizon(with the newswitcher_subset_maskparameter): switcher contributions are zeroed for groups not in the path; the control pool and the variance-eligible cohort structure(D_{g,1}, F_g, S_g)are unchanged. Plug-in SE uses the path-specific divisorN^{pl}_{l, path}(count of path switchers eligible at backward lagl). Surfaced onresults.path_placebo_event_study[path][-l]with the same{effect, se, t_stat, p_value, conf_int, n_obs}shape asplacebo_event_study(negative-int inner keys parallel the existing per-path event-study positive-int keys, so a unified forward+backward view is well-formed). Inherits the cross-path cohort-sharing SE deviation from R documented above forpath_effects(same convention applied backward); tracks R within numerical tolerance on single-path-cohort panels and diverges on cohort-mixed panels. Multiplier bootstrap (whenn_bootstrap > 0) runs per(path, lag)target via the same_bootstrap_one_targetdispatch used for the per-path event-study, with the canonical NaN-on-invalid contract. The bootstrap SE is a Monte Carlo analog of the analytical placebo SE — same per-path centered IF input — and inherits the same deviation. Surfaced throughsummary()(negative-keyed rows rendered alongside positive-keyed event-study rows under each path block) andto_dataframe(level="by_path")(horizoncolumn takes negative ints for placebo rows). Empty-state contract:results.path_placebo_event_studymirrorspath_effects—Nonewhenby_path + placebowas not requested,{}when requested but no observed path has a complete window within the panel (same regime that returns{}forpath_effects, with the same fit-timeUserWarning). R-parity is confirmed attests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathPlaceboon themulti_path_reversible_by_path_placeboscenario; positive analytical + bootstrap invariants live intests/test_chaisemartin_dhaultfoeuille.py::TestByPathPlacebo(with the gated::TestByPathPlacebo::TestBootstrapsubclass). Per-path covariate residualization (DID^X): whencontrols=[...]is set withby_path=k, the per-baseline OLS residualization (Web Appendix Section 1.2) runs once on the first-differenced outcome BEFORE path enumeration. All four downstream surfaces — analytical per-path SE, bootstrap SE, per-path placebos, and per-path joint sup-t bands — consume the residualizedY_matautomatically (Frisch-Waugh-Lovell). Per-period effects remain unadjusted, consistent with the existingcontrols+ per-period DID contract (per-period DID does not support residualization). Failed-stratum baselines (rank-deficient X) zero outN_matfor affected groups, which the path enumeration treats as ineligible per its existing convention. Deviation from R on multi-baseline switcher panels (point estimates): Rdid_multiplegt_dyn(..., by_path, controls)re-runs the per-baseline residualization on each path’s restricted subsample (R/R/did_multiplegt_dyn.Rlines 401-405: rows of the path’s switchers OR rows whereyet_to_switch=1 AND baseline matches the path's baseline). The first-stage residualization sample R uses for path B equals: pre-switch rows of all switchers with matching baseline + all rows of never-switchers with matching baseline — bit-identical to our global first-stage sample under single-baseline switcher panels (every switcher shares the sameD_{g,1}, regardless of howF_gor path identity varies across switchers). Per-path point estimates therefore coincide with R on those panels up to the existing DID^X first-stage cell-weighting deviation documented above inNote (Phase 3 DID^X covariate adjustment)(Python’s first-stage OLS uses equal cell weights — one observation per(g, t)cell, consistent with the library’s cell-aggregated input convention; R weights byN_gt). On panels with one observation per(g, t)cell (the common case after the cell-aggregation step infit()), Python matches R bit-exactly: themulti_path_reversible_by_path_controlsparity fixture has 4 paths with switcherF_gvalues spanning [0..6] underD_{g,1}=0and Python matches R to rtol ~1e-11. On multi-baseline switcher panels (some switchers haveD_{g,1}=0, others haveD_{g,1}=1) R’s per-path subset drops switchers whose baseline differs from the path’s baseline, so the per-baseline regression coefficients diverge per path under R and point estimates can diverge between Python and R — aUserWarningis emitted at fit-time when this configuration is detected so practitioners do not silently consume estimates that disagree with R. The warning filters to switcher groups only; never-switchers (never-treated + always-treated controls) at multiple baseline values do NOT trigger the warning because they don’t affect R’s per-path subset construction. Inherits the cross-path cohort-sharing SE deviation from R documented above forpath_effects— bootstrap SE, placebo SE, and sup-t crit are Monte Carlo / joint-distribution analogs of the same residualized analytical IF and carry the same deviation. R-parity is confirmed againstdid_multiplegt_dyn(..., by_path=3, controls="X1")attests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathControlson themulti_path_reversible_by_path_controlsscenario (single-baseline DGP, exact point-estimate match measured rtol ~1e-11); cross-surface inheritance and the multi-baseline warning are regression-tested attests/test_chaisemartin_dhaultfoeuille.py::TestByPathControls(analytical + bootstrap + placebo + sup-t +to_dataframe(level="by_path")cband columns + multi-baselineUserWarning). Per-path linear-trends DID^{fd}: whentrends_linear=Trueis set withby_path=k, the first-differencing transform atchaisemartin_dhaultfoeuille.py:1599-1630runs once globally BEFORE path enumeration (replacesY_matwithZ_mat = Y_t - Y_{t-1}and shrinks the time axis by one), so per-path raw second-differencesDID^{fd}_{path, l}surface onpath_effects[path]["horizons"][l]automatically. Per-path cumulated level effectsdelta_{path, l} = sum_{l'=1..l} DID^{fd}_{path, l'}(the quantity R returns underdid_multiplegt_dyn(..., by_path, trends_lin)per the existing parity test pivot attests/test_chaisemartin_dhaultfoeuille_parity.py:403-409) surface on the newresults.path_cumulated_event_study[path][l]field — a per-group running sum ofDID^{fd}_{g, l'}averaged over the path’s switchers eligible at horizonl, mirroring the globallinear_trends_effectscumulation logic atchaisemartin_dhaultfoeuille.py:3340-3398. SE on the cumulated layer is the conservative upper bound (sum of per-horizon component SEs frompath_effects[path]["horizons"][l]["se"], NaN-consistent: any non-finite component yields a NaN cumulated SE). Post-bootstrap recomputation: the cumulated layer is built AFTER the bootstrap propagation block atchaisemartin_dhaultfoeuille.py:3034-3081so it reads the FINAL post-bootstrap per-horizon SEs (mirrors the globallinear_trends_effectsplacement). Whenn_bootstrap > 0, cumulated SE / t / p / CI are derived from bootstrap per-horizon SEs; when bootstrap produces non-finite SE (e.g.,n_bootstrap=1degenerate distribution), the cumulated layer’s full inference tuple is NaN per the library-wide NaN-on-invalid bootstrap contract.to_dataframe(level="by_path")exposescumulated_effectandcumulated_secolumns (always present, NaN-when-None — mirrors thecband_*always-present convention from PR #374).summary()renders aCumulated Level Effects (DID^{fd}, trends_linear)sub-section under each per-path block. Path enumeration uses the post-first-differencedN_mat_fd: switchers withF_g==2fail the window-eligibility check and are dropped from path enumeration entirely (the existing globalF_g >= 3warning at line 1620 surfaces the issue), so a path whose switchers all haveF_g < 3is silently absent frompath_effectsrather than present-with-NaN. F_g=3 boundary-case divergence (by_path + trends_linear):F_g=3switchers have exactly 2 pre-switch periods, which after first-differencing and thetime==1filter leaves only 1 valid pre-window Z value. R’s per-path full-pipeline call handles this single-pre-period regime differently from Python’s global-then-disaggregate architecture, producing 30%+ relative divergence on point estimates for paths whose switchers includeF_g=3(empirically observed on the parity fixture’s earlierF_g=3variant). A separateUserWarningfires at fit-time when the panel includes anyF_g=3switcher ANDby_path + trends_linearis set, mirroring theF_g < 3exclusion warning. The shipped parity fixture (single_baseline_multi_path_by_path_trends_lin) restricts toF_g >= 4exclusively to avoid this regime; per-path R parity is asserted only there. Placebo undertrends_linearreturns RAW per-horizon values (no per-path placebo cumulation surface) — verified empirically against the existingjoiners_only_trends_linparity fixture: R’s per-path Placebo_l matches Python’spath_placebo_event_study[path][-l](raw) bit-exactly under non-by_pathtrends_lin. Deviation from R on multi-baseline switcher panels (point estimates): Rdid_multiplegt_dyn(..., by_path, trends_lin)re-runs the full pipeline (including first-differencing) on each path’s restricted subsample, so it operates on different switcher samples per path when switchers have different baseline valuesD_{g,1}. Python first-differences once globally before path enumeration. On single-baseline switcher panels the two architectures coincide; on multi-baseline switcher panels per-path point estimates can diverge — aUserWarningis emitted at fit-time when this configuration is detected so practitioners do not silently consume estimates that disagree with R (mirroring the analogousby_path + controlswarning). Per-path R parity is confirmed againstdid_multiplegt_dyn(..., by_path=3, trends_lin=TRUE, placebo=1)attests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathTrendsLinearon thesingle_baseline_multi_path_by_path_trends_linscenario (single-baseline + cohort-single-path +F_g >= 4DGP designed to eliminate the multi-baseline divergence, the cross-path cohort-sharing deviation, and the F_g=3 boundary case under R’s per-path full-pipeline call). Per-path cumulated point estimates match R bit-exactly (rtol ~1e-9) on event horizons under those conditions; cumulated SE_RTOL is widened to0.20(vs0.12used for non-cumulated by_path parity) because the conservative upper-bound SE compounds the cross-path cohort-sharing deviation under summation. Placebo parity is intentionally skipped fortrends_linear: R’s per-path placebo computation re-runs on the path-restricted subsample with different control eligibility than Python’s global-then-disaggregate architecture surfaces, producing a sign-and-magnitude divergence on paths whose switchers have minimal pre-window depth (e.g.,F_g=4switchers). Placebo underby_path + trends_linearis exercised via internal regression intests/test_chaisemartin_dhaultfoeuille.py::TestByPathTrendsLinear(finite values, bootstrap inheritance) but not pinned to R bit-by-bit. Cross-surface invariants (analytical + bootstrap + placebo + sup-t +path_cumulated_event_study+to_dataframecolumns +summary()rendering) are regression-tested atTestByPathTrendsLinear. Per-path state-set trends: whentrends_nonparam="state_col"is set withby_path=k, the set membership column is validated and stored once globally asset_ids_arr(time-invariance, NaN rejection, partition-coarseness checks unchanged from the non-by_path path). Theset_idsparameter is threaded through the four per-path IF helpers (_compute_path_effects,_compute_path_placebos,_collect_path_bootstrap_inputs,_collect_path_placebo_bootstrap_inputs) so per-path analytical SE, bootstrap, placebos, and sup-t bands all consume the set-restricted control pool automatically. R does NOT first-difference and does NOT cumulate undertrends_nonparam(unliketrends_lin); per-horizonEffect_lis a normal DID with set-restricted controls. Per-path R parity is confirmed againstdid_multiplegt_dyn(..., by_path=3, trends_nonparam="state", placebo=1)attests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathTrendsNonparamon themulti_path_reversible_by_path_trends_nonparamscenario; per-path point estimates AND placebos match R bit-exactly (rtol ~1e-9), per-path SE matches within the Phase 2 envelope (~13% rtol observed). Cross-surface invariants are regression-tested attests/test_chaisemartin_dhaultfoeuille.py::TestByPathTrendsNonparam. Per-path non-binary treatment: integer-coded discrete treatment (D in Z, e.g. ordinal {0, 1, 2}) is supported underby_path=kandpaths_of_interest. Path tuples become integer-state tuples ((0, 2, 2, 2)) keyed bit-for-bit against R’s comma-separated path strings ("0,2,2,2") for D in {0..9}. Continuous D (e.g.1.5) raisesValueErrorat fit-time per the no-silent-failures contract — the existingint(round(float(v)))cast in_enumerate_treatment_pathsis now defensive (no-op for integer-coded D). Deviation from R for multi-character baseline states (D >= 10 or negative D): R’sdid_multiplegt_by_pathderives the per-path baseline viapath_index$baseline_XX <- substr(path_index$path, 1, 1)(extracted 2026-05-03 viaRscript -e 'cat(paste(deparse(DIDmultiplegtDYN:::did_multiplegt_by_path), collapse="\n"))'), capturing only the first character of the comma-separated path string. For multi-character baselines this drops the rest of the value: forpath = "12,12,..."it captures"1"instead of"12"; forpath = "-1,-1,..."it captures"-"instead of"-1". R’s per-path control-pool subset is mis-allocated in both regimes. Python’s tuple-key matching is correct in both — the per-path point estimates we compute are correct, R’s per-path subset for the same path is buggy. The shipped R-parity scenarios stay inD in {0, 1, 2}to avoid the R bug; R-parity is asserted on that set attests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathNonBinaryvia themulti_path_reversible_by_path_non_binaryscenario (78 switchers, 3 paths, single-baseline custom DGP, F_g >= 4). The string-encoding compatibility extends to all single-digit nonnegative D ({0..9}) since each value renders as a single character, but no R-parity scenario currently exercises D outside{0, 1, 2}— per-path point estimates match R bit-exactly (rtol ~1e-9 events; rtol+atol envelope for placebo near-zero values), SE inherits the documented cross-path cohort-sharing deviation (~5% rtol observed; SE_RTOL=0.15 envelope). Negative-integer treatment-state support is regression-tested in Python only (no R parity — R is the buggy side on multi-character baselines) at two sites:tests/test_chaisemartin_dhaultfoeuille.py::TestByPathNonBinary::test_negative_integer_D_supportedcovers paths with negative values in non-baseline positions (e.g.(0, -1, -1, -1)), and::test_negative_baseline_path_supportedcovers paths starting with a negative baselineD_{g,1} = -1(e.g.(-1, 0, 0, 0),(-1, 1, 1, 1)) — the exact regime that triggers R’ssubstrbug. Cross-surface invariants regression-tested attests/test_chaisemartin_dhaultfoeuille.py::TestByPathNonBinary. Per-path survey-design SE (analytical Binder TSL + replicate-weight bootstrap): underby_path/paths_of_interest+survey_design, the per-path per-horizon SE routes through_survey_se_from_group_ifusing the cell-period allocator. The per-path influence functionU_pp_l_pathis the per-period IF with non-path switcher-side contributions skipped — control contributions remain unchanged, matching the joiners/leavers IF convention from the Per-path SE convention paragraph above (theswitcher_subset_maskzeroes the switcher row of the per-group IF, which trivially zeroes the corresponding row of the per-cell IF, preserving the row-sum identityU_pp.sum(axis=1) == U). The IF is cohort-recentered via_cohort_recenter_per_periodand expanded to observations aspsi_i = U_pp[g_i, t_i] · (w_i / W_{g_i, t_i}). Replicate-weight designs unconditionally route through the cell allocator (Class A contract, PR #323). Multiplier bootstrap (n_bootstrap > 0) undersurvey_design + by_path/paths_of_interestraisesNotImplementedErrorat fit-time — the survey-aware perturbation pivot for path-restricted IFs is methodologically underived and deferred to a future wave; the global non-by_path TSL multiplier bootstrap is unaffected and continues to ship. Path-enumeration ranking is unweighted undersurvey_design: top-k selection uses group cardinality (path_to_count[p]= number of groups), not population-weight mass — survey weights do not affect which paths are selected as “top-k”. A weighted-ranking variant (sum of survey weights per path) is deferred until concrete demand.df_surveypropagation: under replicate weights, every per-path per-horizon fit contributes ann_validcount to the shared_replicate_n_valid_listaccumulator and the final_effective_df_survey = min(...) - 1reflects all per-path replicate fits. A post-call_refresh_path_inferencehelper re-runssafe_inferenceon every populated entry somulti_horizon_inference,placebo_horizon_inference,path_effects, andpath_placebosall use the same final df after per-path appends complete. Lonely-PSU policy is sample-wide, not per-path — thelonely_psupolicy (remove/certainty/adjust) operates on the full design-level PSU/strata structure, not on path-restricted subsamples. Telescope invariant: on a single-path panel where every switcher follows the same trajectory andeligible_groupsmatches between by_path and non-by_path, per-path SE equals the global non-by_path survey SE bit-exactly — pinned attests/test_chaisemartin_dhaultfoeuille.py::TestByPathSurveyDesignTelescope::test_telescope_analytical_TSL. Deviation from R: none — Rdid_multiplegt_dyndoes not support survey weighting, so this is a Python-only methodology extension (no R parity available; no R parity test class). Regression test anchor:tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSurveyDesignAnalyticalcovering analytical SE, replicate-weight SE, then_bootstrapgate, the global anti-regression, per-path placebos,trends_linearcomposition, and unobserved-path warnings under survey. Per-path heterogeneity testing (analytical OLS / WLS + survey-aware Binder TSL + replicate-weight): underby_path/paths_of_interest+heterogeneity="<col>", the per-path per-horizon coefficientbeta_X^path_lis computed by re-running_compute_heterogeneity_teston the path-restricted switcher subsample. The path filter (path_groups: Optional[Set[int]]) restricts eligibility to switchers ON pathpinside the inner regression; the variance machinery (HC1-robust OLS vcov for non-survey viasolve_ols(..., return_vcov=True)(vcov_type="hc1"default), WLS-on-pweights with cell-period IF allocator for analytical Binder TSL, group-level allocator for Rao-Wu replicate) is unchanged from the global heterogeneity path. Cohort dummies absorb baseline by construction — the cohort key(D_{g,1}, F_g, S_g)includes baseline, so multi-baseline switcher panels do not produce R-divergence (unlikecontrols/trends_linear); no parallelUserWarningis emitted. R parity: matchesdid_multiplegt_dyn(..., by_path, predict_het)per-by_level on themulti_path_reversible_by_path_predict_hetscenario forbeta,se,t_stat, andn_obs(BETA_RTOL = 1e-6onbeta,SE_RTOL = 1e-5onse/t_stat; the SE tolerance is one decade looser thanBETA_RTOLto absorb the small OLS denominator-and-cohort-recentering numerical drift observed on this fixture;n_obsmatches exactly). Inherits the same tolerances as the new globalmulti_path_reversible_predict_hetscenario (TestDCDHDynRParityHeterogeneity) since the per-path R call isdid_multiplegt_main(..., predict_het=...)per path-restricted subsample with no additional numerical loss. R parity (heterogeneity inference, post-2026-05-15 df threading): Python now passesdf = n_obs - rank(design)tosafe_inferenceon the non-survey OLS path atchaisemartin_dhaultfoeuille.py’s_compute_heterogeneity_test, matching R’s t-distribution with df from the OLS regression (DIDmultiplegtDYN:::did_multiplegt_maint_stat <- qt(0.975, df.residual(model))site). The numerical rank is computed via_detect_rank_deficiency(the same helpersolve_olscalls internally); the small-sample short-circuit also usesn_obs <= rankrather than the pre-PR pre-dropn_obs <= n_params, so boundary cases where alias dropping leavesn_obs > rank > 0fit correctly instead of NaN-filling. Parity tolerance isINFERENCE_RTOL = 1e-4onp_valueandconf_int;beta/se/t_statcontinue to useBETA_RTOL = 1e-6/SE_RTOL = 1e-5. Thet_stat = beta / sefield is distribution-invariant. Rank-deficient designs:df = n_obs - rank(design)uses the post-drop numerical rank via the same_detect_rank_deficiencyhelper thatsolve_olscalls internally. For full-rank designs (rank == n_params) behavior is bit-identical to the pre-PRn_obs - n_paramspath; for near-rank-deficient designs thatsolve_olsretains rather than NaN-out (e.g., cohort-collinearity at high horizons), the post-drop rank is strictly lower and the post-PRdfis strictly larger, matching R’slm()convention. Fully rank-deficient designs continue to NaN-fill via the rank-deficient short-circuit at_compute_heterogeneity_test. R’sdont_drop_larger_lower=TRUEis set in both fixture scenarios to match the Pythondrop_larger_lower=Falserequirement. Survey composition: inherits from the Per-path survey-design SE paragraph above — analytical Binder TSL routes through_survey_se_from_group_if’s cell-period allocator on the post-period of the transition; replicate-weights route through the group-level allocator. Multiplier bootstrap (n_bootstrap > 0) underby_path + heterogeneity + survey_designinherits the existing per-path multiplier-bootstrap-survey gate.df_surveypropagation: every per-(path, horizon) replicate-weight fit appendsn_validto the shared_replicate_n_valid_listaccumulator; per-path heterogeneity inference is refreshed with the FINAL_effective_df_survey(...)in the R2 P1b refresh block (separate dedicated loop because the schema shape is{path: {l: {...}}}rather than{path: {"horizons": {l: {...}}}}). Result schema:results.path_heterogeneity_effects: Dict[Tuple[int, ...], Dict[int, Dict[str, Any]]]keyed{path: {l: {beta, se, t_stat, p_value, conf_int, n_obs}}}. Empty-state contract mirrorspath_effects:Nonewhen not requested,{}when requested but no path has eligible switchers. DataFrame integration:to_dataframe(level="by_path")adds always-presenthet_*columns (het_beta,het_se,het_t_stat,het_p_value,het_conf_int_lower,het_conf_int_upper), populated for positive-horizon rows whenheterogeneityis set and NaN otherwise (mirrors thecband_*andcumulated_*always-present convention). Per-path placebo heterogeneity (placebo + predict_het + by_path, post-2026-05-15): R-verified —did_multiplegt_dyn(by_path, predict_het, placebo)emits per-path heterogeneity OLS results on backward (placebo) horizons via R’s per-by_level dispatcher (DIDmultiplegtDYN:::did_multiplegt_mainplacebo block at theeffect = matrix(-i, ...)rbind site). R’s predict_het syntax: passingpredict_het = list("X", c(-1))withplacebo > 0triggers “compute heterogeneity for ALL forward (1..effects) AND ALL placebo (1..placebo) positions”; forward rows have positiveeffectvalues, placebo rows negative. Python mirrors via_compute_heterogeneity_test(..., placebo=L_max)(set whenself.placebois truthy) — the function iterates forward (1..L_max) and backward (-1..-L_max) horizons in a single loop with an explicitout_idx < 0eligibility guard for backward horizons whoseF_gis too small (would otherwise silently misreadN_matvia numpy negative indexing). Placebo rows into_dataframe(level="by_path")have non-NaNhet_*columns whenplacebo=Trueandheterogeneity=are both set;path_heterogeneity_effectsuses negative-int keys for backward horizons, mirroring the existingpath_placebo_event_studyconvention. Survey gate (warn + skip):survey_design + placebo + heterogeneityemits aUserWarningat fit-time and falls back to forward-horizon-only heterogeneity (codex R1 P1 #1: the eager raise broke the previously-supported forward-horizon survey + predict_het path under the defaultplacebo=True) — the Binder TSL cell-period allocator’s justification (Survey IF expansion Note above) is tied to post-period attribution (out_idx = first_switch_idx[g] - 1 + l_hwithl_h > 0); backward-horizon attribution puts ψ_g mass on a pre-period cell, which is a separate library-extension claim that needs its own derivation. Forward-horizonpredict_het + survey_designcontinues to work unchanged on both global and per-path surfaces. The function-level_compute_heterogeneity_testkeeps a per-iteration backstop that raisesNotImplementedErrorif a direct caller bypasses fit() and passessurvey + placebo > 0(regression-tested attest_compute_heterogeneity_test_direct_call_raises_on_backward_survey). Pre-period allocator derivation is deferred to a follow-up methodology PR. R parity confirmed attests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathHeterogeneityWithPlaceboon themulti_path_reversible_predict_het_with_placebofixture (scenario 22,placebo=2, effects=3, by_path=3, predict_het=list("het_x", c(-1))) AND::TestDCDHDynRParityHeterogeneityWithPlaceboon the global anchor (multi_path_reversible_predict_het_with_placebo_global, scenario 23, same DGP without by_path) — both surfaces emit forward + backward heterogeneity rows in matching parity. Pinned atBETA_RTOL=1e-6/SE_RTOL=1e-5forbeta/se/t_stat/n_obs;INFERENCE_RTOL=1e-4forp_value/conf_int. Cross-surface invariants regression-tested attests/test_chaisemartin_dhaultfoeuille.py::TestByPathPredictHetPlacebo. Regression test anchors:tests/test_chaisemartin_dhaultfoeuille.py::TestByPathHeterogeneity(gate dispatch, behavior, telescope-to-global on single-path panel, zero-signal anti-regression, multi-baseline UserWarning anti-regression, DataFrame integration, edge cases) +tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityHeterogeneity(global anchor, FIRSTpredict_hetparity baseline) +::TestDCDHDynRParityByPathHeterogeneity(per-path).
Per-path user-specified path selection (paths_of_interest): Python-only API extension — R’s did_multiplegt_dyn(..., by_path=k) only accepts a positive int (top-k automatic ranking) or -1 (all observed paths) and provides no list-based selection. Activated via ChaisemartinDHaultfoeuille(paths_of_interest=[(0, 1, 1, 1), (0, 1, 0, 0)], drop_larger_lower=False) as an alternative to by_path=k; the two are mutually exclusive (setting both raises ValueError at __init__ and set_params time). Each path tuple must have length L_max + 1; the type / element / non-empty / length-uniformity checks fire at __init__, the length-vs-L_max check fires at fit-time. bool and np.bool_ are explicitly rejected; np.integer is accepted and canonicalized to Python int for tuple-key consistency. Duplicates emit a UserWarning and are deduplicated; paths not observed in the panel emit a UserWarning and are omitted from path_effects. Paths appear in results.path_effects in the user-specified order, modulo deduplication and unobserved-path filtering. Composes with non-binary D and all downstream by_path surfaces (bootstrap, per-path placebos, per-path joint sup-t bands, controls, trends_linear, trends_nonparam) — mechanical filter on observed paths, no methodology change. Behavior + cross-feature regressions live at tests/test_chaisemartin_dhaultfoeuille.py::TestPathsOfInterest.
Note (Phase 3
by_pathper-path joint sup-t bands): Whenn_bootstrap > 0is set withby_path=k, per-path joint sup-t simultaneous confidence bands are computed across horizons1..L_maxwithin each path. Methodology: a single(n_bootstrap, n_eligible)multiplier weight matrix (using the estimator’s configuredbootstrap_weights— Rademacher / Mammen / Webb) is drawn per path and broadcast across all horizons of that path, producing correlated bootstrap distributions across horizons within the path. The path-specific critical valuec_p = quantile(max_l |t_l|, 1 - α)is then used to construct symmetric joint bandseffect_l ± c_p · se_lper horizon, surfaced inpath_effects[path]["horizons"][l]["cband_conf_int"]and at top-levelresults.path_sup_t_bands[path] = {"crit_value", "alpha", "n_bootstrap", "method", "n_valid_horizons"}. Gates: a path must have>= 2valid horizons (finite bootstrap SE > 0) AND a strict majority (more than 50%) of finite sup-t draws to receive a band; otherwise the path is absent frompath_sup_t_bands. Both gates mirror the OVERALLevent_study_sup_t_bandssemantics atchaisemartin_dhaultfoeuille_bootstrap.py:605,612:len(valid_horizons) >= 2ANDfinite_mask.sum() > 0.5 * n_bootstrap. Exactly half-finite draws are NOT enough — the gate is strictly greater than half. Empty-state contract:path_sup_t_bands is Nonewhen not requested (no bootstrap, or bothby_pathandpaths_of_interestareNone);{}when requested but no path passes both gates.to_dataframe(level="by_path")integration: the table now includescband_lower/cband_uppercolumns for parity with OVERALLlevel="event_study"; populated for positive-horizon rows of paths with a finite sup-t crit, NaN for placebo rows / unbanded paths / the requested-but-empty fallback DataFrame. Methodology asymmetry vs OVERALL: OVERALL sup-t reuses the same multi-horizon shared-draw distribution for both the SE in the t-stat denominator and the bootstrap distribution in the numerator. The per-path sup-t draws a fresh shared weight matrix per path AFTER the per-path SE bootstrap block has already populatedresults.path_sesvia independent per-(path, horizon) draws — numerator: fresh shared draws, denominator: bootstrap SEs from the earlier independent draws. Asymptotically equivalent to OVERALL’s self-consistent reuse, but NOT bit-identical. The fresh draw is intentional: it preserves RNG-state isolation and keeps every existing per-path SE seed-reproducibility test bit-stable post-implementation. Inherited deviation from R: the bootstrap SE used as the t-stat denominator carries the cross-path cohort-sharing SE deviation from R documented forpath_effectsabove; the per-path sup-t crit therefore inherits the same deviation. Interpretation: the band covers joint inference within a single path across horizons; it does NOT provide simultaneous coverage across paths (a different inference target requiring apath × horizonre-derivation, deferred to a future wave). Deviation from R:did_multiplegt_dynprovides no joint / sup-t / simultaneous bands at any surface — this is a Python-only methodology extension, consistent with the existing OVERALLevent_study_sup_t_bands(also Python-only). Regression test anchor:tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSupTBands.
Reference implementation(s):
R:
DIDmultiplegtDYN(CRAN, maintained by the paper authors). The Python implementation matchesdid_multiplegt_dyn(..., effects=1)at horizonl = 1. Parity tests live intests/test_chaisemartin_dhaultfoeuille_parity.py.Stata:
did_multiplegt_dyn(SSC, also maintained by the paper authors).
Requirements checklist:
[x] Single class
ChaisemartinDHaultfoeuille(aliasDCDH); not a family[x] Forward-compat
fit()signature withNotImplementedErrorgate foraggregate; survey_design now supported (pweight + strata/PSU/FPC via TSL); Phase 3 gates lifted forcontrols,trends_linear,trends_nonparam,honest_did[x]
DID_Mpoint estimate with cohort-recentered analytical SE[x] Joiners-only
DID_+and leavers-onlyDID_-decompositions with their own inference[x] Single-lag placebo
DID_M^pl(point estimate; SE deferred to Phase 2)[x] TWFE decomposition diagnostic (Theorem 1 of AER 2020): per-cell weights, fraction negative,
sigma_fe,beta_fe[x] Standalone
twowayfeweights()helper for users who only want the TWFE diagnostic[x] Multiplier bootstrap with Rademacher / Mammen / Webb weights, clustered at group by default; automatically upgraded to PSU-level Hall-Mammen wild clustering under
survey_designwith strictly-coarser PSUs[x]
drop_larger_lower=Truedefault (matches RDIDmultiplegtDYN);Falseopt-in with explicit inconsistency warning[x] Singleton-baseline filter (footnote 15 of dynamic paper, variance computation only) with explicit warning
[x] Never-switching groups participate in the variance via stable-control roles after the Round 2 full-IF fix;
n_groups_dropped_never_switchingfield retained as backwards-compatibility metadata only[x] Balanced-baseline panel requirement: missing-baseline groups raise
ValueError; interior-gap groups dropped withUserWarning; terminal missingness retained (deviation from RDIDmultiplegtDYNdocumented as a Note)[x] A11 zero-retention convention with per-period boolean flags (
did_plus_t_a11_zeroed/did_minus_t_a11_zeroed) and consolidated warning[x] No silent failures: every drop / round / fallback emits a
warnings.warn()orValueError[x] Hand-calculable 4-group worked example:
DID_M = 2.5,DID_+ = 2.0,DID_- = 3.0exactly[x] R
DIDmultiplegtDYNparity tests atl = 1(fixture skips cleanly when R orDIDmultiplegtDYNis unavailable)[x] DID^X covariate residualization via per-baseline OLS (Web Appendix Section 1.2)
[x] DID^{fd} group-specific linear trends via Z_mat first-differencing (Web Appendix Section 1.3)
[x] State-set-specific trends via control-pool restriction (Web Appendix Section 1.4)
[x] Heterogeneity testing via saturated OLS (Web Appendix Section 1.5, Lemma 7)
[x] Design-2 switch-in/switch-out descriptive wrapper (Web Appendix Section 1.6)
[x]
by_pathper-path event-study disaggregation (binary or integer-coded discrete treatment, joiners/leavers IF precedent; mirrors Rdid_multiplegt_dyn(..., by_path=k)); pluspaths_of_interest=[(...), ...]for user-specified path subsets (Python-only API; mutex withby_path); composes withsurvey_designfor analytical Binder TSL and replicate-weight bootstrap SE (multiplier-bootstrap path under survey gated, deferred)[x] HonestDiD (Rambachan-Roth 2023) integration on placebo + event study surface
[x] Survey design support: pweight with strata/PSU/FPC via Taylor Series Linearization (analytical) or replicate-weight variance (BRR/Fay/JK1/JKn/SDR), covering the main ATT surface, covariate adjustment (DID^X), heterogeneity testing, the TWFE diagnostic (fit and standalone
twowayfeweights()helper), and HonestDiD bounds. Opt-in PSU-level Hall-Mammen wild bootstrap is also supported vian_bootstrap > 0.Note (Survey IF expansion — library convention): Survey IF expansion is a library extension not in the dCDH papers (the paper’s plug-in variance assumes iid sampling). The library convention builds observation-level
psi_iby proportionally distributing per-group IF mass within weight share: either at the group level (psi_i = U_centered[g] * w_i / W_g, the previous convention) or at the per-(g, t)cell level via the cell-period allocator shipped in this release. Cell-level expansion: decomposeU[g]into per-period attributionsU[g, t], cohort-center each column independently, then expand to observation level aspsi_i = U_centered_per_period[g_i, t_i] * (w_i / W_{g_i, t_i}). Binder (1983) stratified-PSU variance aggregates the resultingpsiat PSU level. Post-period attribution convention: each transition term in the IF sum (of the formrole_weight * (Y_{g, t} - Y_{g, t-1})for DID_M orS_g * (Y_{g, out} - Y_{g, ref})for DID_l) is attributed as a single difference to the POST-period cell, not split into a+Y_post/-Y_prepair across two cells. This is a library convention, not a theorem — adopted because it preserves the group-sum, PSU-sum, and cohort-sum identities of the previous group-level expansion (so Binder variance coincides with the group-level variance under the auto-injectedpsu=group) and because Monte Carlo coverage at nominal 95% is empirically close to nominal on a DGP where PSUs vary across the cells of each group (seetests/test_dcdh_cell_period_coverage.py). A covariance-aware two-cell allocator is a plausible alternative and may be worth exploring if future designs motivate an explicit observation-level IF derivation; the method currently in the library is not derived from the observation-level survey linearization of the contrast and makes no stronger claim than “coverage is approximately nominal under the tested DGPs and the group-sum identity holds exactly.” Under within-group-constant PSU (the pre-allocator accepted input), per-cell sums telescope toU_centered[g]and Binder variance is byte-identical (up to single-ULP floating-point noise) to the previous group-level expansion. Strata and PSU must be constant within each(g, t)cell (trivially satisfied in one-obs-per-cell panels — the canonical dCDH structure); variation across cells of a group is supported by the allocator. Within-group-varying weights are supported as before. Whensurvey_design.psuis not specified,fit()auto-injectspsu=<group column>so the TSL variance,df_survey, and t-based inference match the per-group PSU structure. Strata that vary across cells of a group require either an explicitpsu=<col>or the originalSurveyDesign(..., nest=True)flag — undernest=Truethe resolver combines(stratum, psu)into globally-unique labels, so the auto-injectedpsu=<group>is re-labeled per stratum and the cell allocator proceeds. Only thenest=False+ varying-strata + omitted-psu combination is rejected up front with a targetedValueErroratfit()time (the synthesized PSU column would reuse group labels across strata and trip the cross-stratum PSU uniqueness check inSurveyDesign.resolve()). Under replicate-weight designs, the same cell-levelpsi_iis aggregated via Rao-Wu weight-ratio rescaling (compute_replicate_if_varianceatdiff_diff/survey.py:1681) rather than the Binder TSL formula. All five methods (BRR/Fay/JK1/JKn/SDR) are supported method-agnostically through the unified helper; the effectivedf_surveyis reduced tomin(n_valid) - 1across IF sites when some replicate solves fail (matchingefficient_did.py:1133-1135andtriple_diff.py:676-686precedents). Under DID^X, the first-stage residualization coefficienttheta_hatis computed once on full-sample weights and treated as fixed (FWL plug-in IF convention) — per-replicate refits oftheta_hatare not performed. Post-period attribution extends to heterogeneity (Binder TSL branch only): the heterogeneity WLS coefficient IFψ_g = inv(X'WX)[1,:] @ x_g * W_g * r_gis attributed in full to the single post-period cell(g, out_idx)at each horizon (same single-cell convention as DID_l), then expanded asψ_i = ψ_g * (w_i / W_{g, out_idx}), and fed throughcompute_survey_if_variance. Under PSU=group the PSU-level aggregate telescopes toψ_g, so Binder variance is byte-identical relative to the pre-cell-period release; under within-group-varying PSU mass lands in the post-period PSU. Replicate-weight branch keeps the legacy group-level allocatorψ_i = ψ_g * (w_i / W_g)becausecompute_replicate_if_variancecomputesθ_r = sum_i ratio_ir * ψ_iat observation level and is therefore not PSU-telescoping: redistributing mass onto the post-period cell would silently change the replicate SE whenever a replicate column’s ratios vary within a group (the library accepts arbitrary per-row replicate matrices, not just PSU-aligned ones). The legacy allocator preserves byte-identity of the replicate SE for every previously-supported fit. Replicate + within-group-varying PSU is unreachable by construction (SurveyDesignrejectsreplicate_weightscombined with explicitstrata/psu/fpc).Note (survey + bootstrap contract): When
survey_designandn_bootstrap > 0are both active, the bootstrap uses Hall-Mammen wild multiplier weights (Rademacher/Mammen/Webb) at the PSU level. Under the default auto-injectedpsu=group, the PSU coincides with the group so the wild bootstrap is a clean group-level clustered bootstrap (identity-map fast path, bit-identical to the non-survey multiplier bootstrap). When the user passes an explicit strictly-coarser PSU (e.g.,psu=statewith groups at county level), the IF contributions of all groups within a PSU receive the same bootstrap multiplier — the standard Hall-Mammen wild PSU bootstrap. Strata do not participate in the bootstrap randomization (they contribute only through the analytical TSL variance); this is conservative when strata differ substantially in variance. AUserWarningfires only when PSU is strictly coarser than group. Cell-level wild PSU bootstrap under within-group-varying PSU: when the PSU varies across the cells of a group, the bootstrap switches to a cell-level allocator: each(g, t)cell draws its multiplier fromw[psu(cell)]via the per-cell PSU mappsu_codes_per_cell(shape(n_eligible_groups, n_periods), -1 sentinel for zero-weight cells). The bootstrap statistic becomestheta_r = sum_c w[psu(c)] * u_centered_pp[c] / divisorusing the cohort-recentered per-cell IFU_centered_per_period. Under PSU-within-group-constant regimes (including PSU=group and strictly-coarser PSU with within-group constancy), the per-cell sum telescopes to the group-level form via the row-sum identitysum_{c in g} U_centered_per_period[g, t] == U_centered[g](enforced by_cohort_recenter_per_period). A dispatcher in_compute_dcdh_bootstrapdetects within-group-constancy and routes those regimes through the legacy group-level bootstrap path so their SE is bit-identical to the pre-cell-level release (guarded primarily bytest_bootstrap_se_matches_pre_pr4_baselineand by the existingtest_auto_inject_bit_identical_to_group_level). Under within-group-varying PSU, a group contributing cells to PSUsp1, p2, ...receives independent multiplier draws per PSU — the correct Hall-Mammen wild PSU clustering at cell granularity. Multi-horizon bootstraps draw a single shared(n_bootstrap, n_psu)PSU-level weight matrix per block and broadcast per-horizon via each horizon’s cell-to-PSU map, so the sup-t simultaneous confidence band remains a valid joint distribution across horizons. Library extension — RDIDmultiplegtDYNdoes not support survey designs, so “deviation from R” does not apply. Scope note (terminal missingness + any cell-period-allocator path): see the balanced-baseline Note above for the full carve-out. In brief: when a terminally-missing group is in a cohort whose other groups still contribute at the missing period,_cohort_recenter_per_periodleaks non-zero centered IF mass onto cells with no positive-weight observations. The targetedValueErrorfires from every survey variance path that uses the cell-period allocator: Binder TSL with within-group-varying PSU, Rao-Wu replicate ATT (which always uses the cell allocator), and the cell-level wild PSU bootstrap. Pre-process the panel to remove terminal missingness, or (for Binder TSL only) use an explicitpsu=<group_col>so the analytical path routes through the legacy group-level allocator. Replicate-weight designs andn_bootstrap > 0are mutually exclusive (replicate variance is closed-form; bootstrap would double-count variance) — the combination raisesNotImplementedError, matchingefficient_did.py:989,staggered.py:1869,two_stage.py:251-253. For HonestDiD bounds under replicate weights, the replicate-effectivedf_survey = min(resolved_survey.df_survey, min(n_valid_across_sites) - 1)propagates to t-critical values — capped by the design’s QR-rank-based df so a rank-deficient replicate matrix never produces a larger effective df than the design supports. Whenresolved_survey.df_surveyis undefined (QR-rank ≤ 1), the effective df staysNoneand all inference fields (including HonestDiD bounds) are NaN — per-siten_validcannot rescue a rank-deficient design.
Deviations from the paper / from R / library extensions#
Notes #1, #2, #3, #4, #5, and #7 codify deviations from R DIDmultiplegtDYN (and from the paper’s Equation 3 in the case of #1). Note #6 codifies a library extension with no R correspondence. The original scattered **Note:** and **Note (deviation from R...):** entries throughout the section above remain in place — this Deviations block is the canonical AI-review surface per CLAUDE.md “Documenting Deviations (AI Review Compatibility)” labels. Cross-references back to the existing Notes use semantic anchors (Phase / section names) rather than line numbers because the DCDH section is liable to shift as new contracts land; test-file references retain line numbers / class names because test files are more stable.
Deviation from R / Deviation from the paper (Equation 3): Equal-cell weighting — each
(g,t)cell contributes equally regardless of within-cell observation count. AER 2020 Equation 3 prescribesN_{d,d',t} = sum_g N_{g,t}(observation sums); RDIDmultiplegtDYNweights by cell size. Phase 2 estimands (DID_l,DID^{pl}_l,DID^n_l, delta cost-benefit) inherit the same contract. Locked intests/test_chaisemartin_dhaultfoeuille.py::TestDropLargerLower::test_cell_count_weighting_unbalanced_input. Cross-references the Phase 1 Theorem 3 equation block above (whereN_{a,b,t}is documented as the count of(g, t)cells in each transition state) andMETHODOLOGY_REVIEW.md§ DCDH Deviations #1.Deviation from R: Period-based stable-control sets (
stable_0(t)= any cell withD_{g,t-1} = D_{g,t} = 0regardless of baselineD_{g,1}) — R uses cohort-based control sets that additionally require baselineD_{g,1}to match the side. Pure-direction panels agree exactly; ~1% point-estimate divergence on mixed-direction panels where joiners’ post-switch cells could serve as leavers’ controls. SE parity gap on pure-direction scenarios narrowed from ~18% to ~3% after the Round 2 full-IF fix. Cross-references the existing**Note (deviation from R DIDmultiplegtDYN):**in the period-vs-cohort discussion above andMETHODOLOGY_REVIEW.md§ DCDH Deviations #2.Deviation from R: Balanced-baseline panel required + interior-gap drops + terminal-missingness retention + cell-period-allocator targeted
ValueError— one composite deviation with four enforcement paths. Step 5b validation infit()enforces the contract viaValueError(missing baseline) /UserWarning(interior gaps) / silent retention (terminal missingness). R accepts unbalanced panels. The cell-period allocator paths (Binder TSL with within-group-varying PSU, Rao-Wu replicate ATT, cell-level wild PSU bootstrap) have a targetedValueErrorwhen cohort recentering would leak nonzero centered IF mass onto cells with no positive-weight observations. The four enforcement paths share a single underlying contract — “the panel must be balanced at baseline; terminal missingness is the only allowed unbalance; downstream variance machinery refuses to silently leak IF mass past the cell-period boundary”. Cross-references the existing**Note (deviation from R DIDmultiplegtDYN):**in the ragged-panel discussion above (which itself details the three affected cell-period-allocator sub-paths) andMETHODOLOGY_REVIEW.md§ DCDH Deviations #3.Deviation from R: SE normalization — Python uses paper Section 3.7.3 verbatim
SE = sigma-hat / sqrt(N_l); R normalizes byG(total groups). Analytical SE is ~4% smaller than R on identical data (deterministic; ~3.5-5.1% across horizons and scenarios). Both converge to the same asymptotic variance asG → ∞. Cross-references the**Note (deviation from R DIDmultiplegtDYN - SE normalization):**in the SE / variance discussion above andMETHODOLOGY_REVIEW.md§ DCDH Deviations #4.Deviation from R: Singleton-cohort degeneracy →
NaNwithUserWarning. R returns a non-zero SE via small-sample sandwich machinery that Python does not implement. Bootstrap inherits the same degeneracy. Cross-references the singleton-cohort**Note:**in the SE / variance discussion above andMETHODOLOGY_REVIEW.md§ DCDH Deviations #5.Library extension (no R correspondence):
<50%switcher warning at far horizons. Library convention is to warn but compute; the dynamic paper (NBER WP 29873) recommends not reporting such horizons (Favara-Imbs application, footnote 14). Cross-references the**Note (Phase 2 \<50%` switcher warning):**in the Phase 2 discussion above andMETHODOLOGY_REVIEW.md` § DCDH Deviations #6.Deviation from R: Phase 3
DID^Xcovariate adjustment uses equal cell weights in the first-stage OLS (consistent with the Phase 1 cell-count convention, deviation #1). R weights byN_{gt}. On one-observation-per-cell panels results are identical. When baseline-specific first stages fail (n_obs = 0orn_obs < n_params), both Python and R drop the affected strata. Cross-references the**Note (Phase 3 DID^X covariate adjustment):**in the Phase 3 discussion above andMETHODOLOGY_REVIEW.md§ DCDH Deviations #7.
ContinuousDiD#
Primary Source: Callaway, Goodman-Bacon & Sant’Anna (2024), “Difference-in-Differences with a Continuous Treatment,” NBER Working Paper 32117.
R Reference: contdid v0.1.0 (CRAN).
Note (rank-guarded ACRT-variance bread): The ACRT influence-function bread
(Psi'WPsi / mass)^{-1}(_compute_dose_response_gt,continuous_did.py) is inverted by the shared_rank_guarded_inv(diff_diff/linalg.py).np.linalg.invraises only on an exactly singular B-spline design Gram; a near-singular Gram (clustered doses / near-duplicate knots) previously returned a garbage inverse (~1e13), and the exact-singular fallback was a silent minimum-normpinv. The rank-guard truncates redundant directions on the equilibrated Gram → a finite SE on the identified subspace (the well-conditioned near-collinear limit, not minimum-norm; NaN only at rank 0), andfit()warns when a direction is dropped. See the CallawaySantAnna “rank-guarded IF standard errors” Note for the generalized-inverse semantics.
Identification#
Two levels of parallel trends (following CGBS 2024, Assumptions 1-2):
Parallel Trends (PT): for all doses d in D_+,
E[Y_t(0) - Y_{t-1}(0) | D = d] = E[Y_t(0) - Y_{t-1}(0) | D = 0].
Untreated potential outcome paths are the same across all dose groups and the
untreated group. Stronger than binary PT because it conditions on specific dose values.
Identifies: ATT(d|d), ATT^{loc}. Does NOT identify ATT(d), ACRT, or cross-dose comparisons.
Strong Parallel Trends (SPT): additionally, for all d in D,
E[Y_t(d) - Y_{t-1}(0) | D > 0] = E[Y_t(d) - Y_{t-1}(0) | D = d].
No selection into dose groups on the basis of treatment effects.
Implies ATT(d|d) = ATT(d) for all d.
Additionally identifies: ATT(d), ACRT(d), ACRT^{glob}, and cross-dose comparisons.
Conditional Parallel Trends (with covariates). When covariates= is passed the PT/SPT
assumptions are conditional on covariates X:
E[Y_t(0) - Y_{t-1}(0) | D = d, X] = E[Y_t(0) - Y_{t-1}(0) | D = 0, X]. The per-(g,t) cell’s
control counterfactual becomes a covariate-adjusted prediction instead of the unconditional control
mean (see Key Equations and the covariate Note below).
See docs/methodology/continuous-did.md Section 4 for full details.
Key Equations#
Target parameters:
ATT(d|d) = E[Y_t(d) - Y_t(0) | D = d]— effect of dose d on units who received dose d (PT)ATT(d) = E[Y_t(d) - Y_t(0) | D > 0]— dose-response curve (SPT required)ACRT(d) = dATT(d)/dd— average causal response / marginal effect (SPT required)ATT^{loc} = E[ATT(D|D) | D > 0] = E[Delta Y | D > 0] - E[Delta Y | D = 0]— binarized ATT (PT); equalsATT^{glob}under SPTATT^{glob} = E[ATT(D) | D > 0]— global average dose-response level (SPT required)ACRT^{glob} = E[ACRT(D_i) | D > 0]— plug-in average marginal effect (SPT required)Lowest-dose-as-control (Remark 3.1,
control_group="lowest_dose"): whenP(D=0) = 0(no untreated group) the lowest-dose groupd_Lis the comparison and the targets are rebased to it —ATT(d) - ATT(d_L)(withATT(d_L) = 0by construction, the omitted reference),ACRT(d)backward-differenced tod_L(ACRT(d_1) = ATT(d_1)/(d_1 - d_L)), andATT^{glob} = E[ΔY | D > d_L] - E[ΔY | D = d_L]. Requires a genuine lowest-dose group (P(D = d_L) > 0). See Note #7.
Estimation via B-spline OLS:
Compute
Delta_tilde_Y = (Y_t - Y_{t-1})_treated - mean((Y_t - Y_{t-1})_control)Build B-spline basis
Psi(D_i)from treated dosesOLS:
beta = (Psi'Psi)^{-1} Psi' Delta_tilde_YATT(d) = Psi(d)' beta,ACRT(d) = dPsi(d)/dd' beta
Covariate-adjusted Delta_tilde_Y (conditional PT). With covariates=, step 1’s scalar control
mean is replaced by a per-treated-unit covariate-adjusted counterfactual (X_i includes an intercept):
reg(outcome regression): fitgamma_hat = (X_C'X_C)^{-1} X_C' Delta_Y_Con controls;Delta_tilde_Y_i = Delta_Y_i - X_i' gamma_hat.dr(doubly-robust, DRDIDdrdid_panel): same OLSgamma_hat, plus a propensity model and a scalar augmentationeta_cont = odds_weighted_mean_C(Delta_Y - X' gamma_hat);Delta_tilde_Y_i = Delta_Y_i - X_i' gamma_hat - eta_cont. Steps 2-4 are unchanged. Because the augmentation is a constant,reganddrshare the sameACRT(d)for the continuous B-spline path (a constant only shifts the B-spline intercept, whichdPsiannihilates); they differ only in theATT(d)/ATT^{glob}level (by-eta_cont) and in the doubly-robust SE. (On the discrete saturated basis reg/dr shareACRT(d_j)forj >= 2but differ atACRT(d_1)— see below.)
Discrete treatment: saturated regression (treatment_type="discrete", CGBS 2024 Eq. 4.1). For a
dose taking distinct levels d_1 < ... < d_J, steps 2-4 swap the B-spline basis for a saturated
(indicator) basis:
2’. Psi(D_i) = indicator columns 1{D_i = d_j} (no intercept; a partition of unity among treated).
3’. beta = (Psi'Psi)^{-1} Psi' Delta_tilde_Y, so beta_j = mean_{D=d_j}(Delta_tilde_Y) = ATT(d_j)
(a per-level 2×2 DiD).
4’. ATT(d_j) = beta_j; ACRT(d_j) = (L beta)_j where L is the paper’s backward-difference
operator on the grid {d_0 = 0, d_1, ..., d_J} (ATT(0) = 0): [ATT(d_j) - ATT(d_{j-1})]/(d_j - d_{j-1}) for j >= 2, and at the lowest positive level ACRT(d_1) = [ATT(d_1) - 0]/(d_1 - 0) = ATT(d_1)/d_1 (so binary D in {0,1} gives ACRT = ATT). ACRT^{glob} = mean_i ACRT(D_i).
The B-spline and saturated paths share the same linear influence-function / bootstrap / covariate /
survey machinery (bread @ psi_bar = ones(J) makes the control-side IF reduce to the per-level 2×2
control variance; it cancels in the j >= 2 adjacent differences whose L-rows sum to 0). reg/dr
share ACRT(d_j) point AND SE for j >= 2 (the constant eta_cont cancels in those differences), but
differ at ACRT(d_1) by eta_cont/d_1 — the lowest-dose row references the fixed baseline
ATT(0) = 0, which the augmentation does not shift, so the dr IF carries the augmentation variance
there (analytical ACRT(d_1) SE matches the bootstrap). Otherwise the
ATT(d_j) levels differ uniformly by -eta_cont. See Notes #5-#6.
Edge Cases#
No untreated group:
control_group="lowest_dose"implements Remark 3.1 — the lowest-dose groupd_Lbecomes the comparison (estimandATT(d) - ATT(d_L)); requires a genuine lowest-dose group (P(D=d_L) > 0,>= 2units atd_L) and no never-treated units. Single-cohort only (multi-cohort andcovariates=raise; see Note #7). The defaultnever_treated/not_yet_treatedpaths still requireP(D=0) > 0.Discrete treatment:
treatment_type="discrete"fits the saturated per-dose-level regression (CGBS 2024 Eq. 4.1; see Note #6). On the defaulttreatment_type="continuous"path an integer-valued dose is detected and warns, pointing totreatment_type="discrete".All-same dose: B-spline basis collapses; ACRT(d) = 0 everywhere.
Rank deficiency: When n_treated <= n_basis, cell is skipped.
Balanced panel required: Matches R
contdidv0.1.0.Anticipation + not-yet-treated: Control mask uses
G > t + anticipation(not justG > t) to exclude cohorts in the anticipation window from not-yet-treated controls. Whenanticipation=0(default), behavior is unchanged.Boundary knots: Knots are built once from all treated doses (global, not per-cell) to ensure a common basis across (g,t) cells for aggregation. Evaluation grid is clamped to training-dose boundary knots (
range(dose)). R’scontdidv0.1.0 has an inconsistency wheresplines2::bSpline(dvals)usesrange(dvals)instead ofrange(dose), which can produce extrapolation artifacts at dose grid extremes. Our approach avoids extrapolation and is methodologically sound.Note:
bspline_derivative_design_matrixpreviously swallowedValueErrorfromscipy.interpolate.BSplinein the per-basis derivative loop, leaving affected columns of the derivative design matrix as zero with no user-facing signal. It now aggregates the failed basis indices and emits ONEUserWarningnaming them. Both ACRT point estimates and analytical/bootstrap inference read the samedPsimatrix (seecontinuous_did.py:1026-1046and the bootstrap ACRT path atcontinuous_did.py:1524-1561), so both are biased on a partial derivative-construction failure — the warning wording makes that explicit. The all-identical-knot degenerate case (single dose value) remains silently handled — derivatives there are mathematically zero. Axis-C finding #12 in the Phase 2 silent-failures audit.
Deviations from the paper / from R / library extensions#
Note #1 codifies a deviation from R contdid v0.1.0’s boundary-knot
choice (library extension toward methodological soundness — avoids
extrapolation that contdid exhibits). Notes #2-#4 codify library
extensions with NO R correspondence — Phase 2 silent-failures audit
fixes that surface previously silent behavior as UserWarning or
ValueError; contdid v0.1.0 absorbs the same conditions without a
signal. The original Edge Cases bullet (under § Edge Cases above) and
the two **Note:** entries (under § Implementation Checklist below)
remain in place — this Deviations block is the canonical AI-review
surface per CLAUDE.md “Documenting Deviations (AI Review Compatibility)”
labels.
Deviation from R:
range(dose)vsrange(dvals)boundary knots — the library usesrange(dose)(training-dose range) for B-spline boundary knots; R’scontdidv0.1.0 usesrange(dvals)viasplines2::bSpline(dvals), which can produce extrapolation artifacts at dose-grid extremes. Scope caveat: R cross-language coverage therefore runs at relative tolerance bands across two surfaces: (a) scalar parity with raw Rcont_did/pte_defaultat 1% relative on overall ATT for all 6 benchmarks and on overall ACRT for benchmarks 4-5; (b) harmonized boundary-knot-normalized curve parity with R-side ATT(d) / ACRT(d) reconstructed underBoundary.knots = range(treated_doses)(matching the library) on benchmarks 1-3 via the benchmark harness —_run_r_contdiddoes the R-side rebuild attests/test_methodology_continuous_did.py:333-367, and_compare_with_rorchestrates the Python-vs-R comparison at:395-459— max ATT(d) at 1% and max ACRT(d) at 2%. Benchmark 6 is event-study, scalaroverall_attonly. NOT bit-exact (atol=1e-8) like HAD. Library extension toward methodological soundness (avoids extrapolation). Cross-references the § Edge Cases “Boundary knots” bullet above andMETHODOLOGY_REVIEW.md§ ContinuousDiD Deviations #1.Note:
bspline_derivative_design_matrixderivative-failureUserWarning— Phase 2 axis-C #12 silent-failures audit fix. No R correspondence;contdidv0.1.0 does not implement an equivalent warning. Cross-references the § Edge Cases**Note:**bullet above (bspline_derivative_design_matrixentry) andMETHODOLOGY_REVIEW.md§ ContinuousDiD Deviations #2. Locked intests/test_continuous_did.py::TestBSplineDerivativeDegenerateBasis(3 tests); source-level aggregate-warning block atdiff_diff/continuous_did_bspline.py:150-187.Note:
+inf→0never-treated recoding emitsUserWarningreporting the affected row count; negativefirst_treat(including-inf) raisesValueError. Axis-E silent-coercion fix per Phase 2 audit. No R correspondence;contdidv0.1.0 silently absorbs+infwithout a signal. Cross-references the § Implementation Checklist**Note:**below andMETHODOLOGY_REVIEW.md§ ContinuousDiD Deviations #3.Note: Zero-
first_treatrows with nonzerodoseare force-zeroed withUserWarningreporting the affected row count (axis-E silent-coercion). No R correspondence;contdidv0.1.0 has the samefirst_treat = 0→D = 0invariant but silently coerces without a warning. Cross-references the § Implementation Checklist**Note:**below andMETHODOLOGY_REVIEW.md§ ContinuousDiD Deviations #4.Note (covariate support — library extension beyond
contdidv0.1.0):covariates=withestimation_method ∈ {reg, dr}adds conditional-parallel-trends adjustment. This is a library extension:contdidv0.1.0 hard-stops on any covariate (stop("covariates not currently supported…")), so there is no external R anchor for the covariate-adjusted dose curve. Validation instead: (a) the scalaroverall_att+ SE map exactly ontoDRDID::reg_did_panel(reg) /DRDID::drdid_panel(dr) — a tight (~1e-8) component anchor, skip-guarded since DRDID is not in CI (tests/test_methodology_continuous_did.py::TestCovariateReg); (b) an R-free NumPy reconstruction of the reg/dratt+SE runs in CI at p≥2 (test_dr_reg_numpy_crosscheck_p2) — the guard the p=1 reduction cannot provide (at p=1 the intercept-only propensity is constant, soeta_cont ≡ 0and dr collapses to reg); (c) DGP recovery + MC coverage (reg 96%, dr 95%).ipwrestricted:estimation_method="ipw"with covariates raisesNotImplementedError— pure IPW’s covariate adjustment is a single scalar (a propensity-reweighted control mean) that shifts only the ATT(d) level and leavesACRT(d)identical to the unconditional fit, so it cannot adjust the dose-response shape. Deviations from DRDID: unit weights are 1 (unweighted;covariates=+survey_design=raisesNotImplementedError, deferred); propensity trimming uses clip semantics (pscore_trim) rather than DRDID’s drop-trimming — identical on moderate-overlap data (the anchor regime), diverging only at extreme propensities. Fail-closed policies (no-silent-failures): (i) missing/non-finite covariate values raiseValueErrorup front — a per-cell fallback to unconditional estimation would silently mix conditional-PT and unconditional-PT cells in the aggregate; (ii)drpropensity-estimation failure raises by default (pscore_fallback="error") so adrfit never silently degrades to a non-DR estimate —pscore_fallback="unconditional"opts into the graceful (warned, reg-like) fallback.treatment_type="discrete"composability: on the intercept-free saturated basis the constant augmentationeta_contshifts everybeta_jequally (indicator partition of unity), so it cancels in the adjacent differences and reg/dr shareACRT(d_j)point AND SE forj >= 2— but the lowest-dose ACRT references the fixed baselineATT(0) = 0(backward-to-zero), so reg/dr differ atACRT(d_1)byeta_cont/d_1(the dr IF carries the augmentation variance there). See Note #6. Cross-referencesdocs/methodology/continuous-did.md§ Covariates.Note (discrete-treatment saturated regression — library extension beyond
contdidv0.1.0):treatment_type="discrete"estimates the dose-response by a saturated regression (CGBS 2024 Eq. 4.1) — one indicator per distinct dose level, sobeta_j = mean_{D=d_j}(ΔY − control) = ATT(d_j)(a per-level 2×2 DiD) — instead of the B-spline sieve.ACRT(d_j)is the paper’s backward difference on the grid{d_0 = 0, d_1, …, d_J}(Eq. 4.1 makesd_0 = 0the omitted category withATT(0) = 0):ACRT(d_j) = [ATT(d_j) − ATT(d_{j-1})]/(d_j − d_{j-1})forj ≥ 2, and at the lowest positive level it references the zero-dose baseline,ACRT(d_1) = [ATT(d_1) − 0]/(d_1 − 0) = ATT(d_1)/d_1. So a single positive dose (J = 1, e.g. binaryD ∈ {0,1}) yieldsACRT(d_1) = ATT(d_1)/d_1, and ford_1 = 1the documented binary identityACRT = ATTholds exactly. This is a library extension:contdidv0.1.0 acceptstreatment_typein its signature but does not implement the discrete path (documented “Discrete treatment not yet implemented”), so there is no external R anchor. It is instead an exact basis swap of the B-spline design/evaluation/derivative trio for an indicator/identity/finite-difference trio; every downstream quantity is linear inbeta, so the analytical-SE / multiplier-bootstrap / covariate (reg,dr) / survey machinery is reused unchanged and reduces analytically to the per-level 2×2 DiD (bread @ psi_bar = ones(J); the common control mean cancels in thej ≥ 2adjacent differences whoseL-rows sum to 0). reg vs dr: the constant DR augmentationη̄_contcancels in thej ≥ 2differences, soACRT(d_j)point AND SE are identical forreg/drthere; butACRT(d_1) = ATT(d_1)/d_1references the fixed baselineATT(0) = 0(not shifted byη̄_cont), soreganddrgenuinely differ atACRT(d_1)byη̄_cont/d_1(and correspondingly inACRT^globvia thed_1mass) — the dr influence function carries the augmentation variance atd_1(validated: analyticalACRT(d_1)SE matches the multiplier bootstrap). Validation (R-free, in CI): exact hand-calc ofATT(d_j)/ACRT/overall_attand the analytical SE against a direct per-level 2×2 reconstruction (~1e-12/~1e-10), DGP recovery, and MC coverage for analytical + bootstrap (tests/test_methodology_continuous_did.py::TestDiscreteSaturated,tests/test_continuous_did.py::TestDiscreteSaturatedAPI). Fail-closed policies (no-silent-failures): (i) multi-cohort fits with heterogeneous dose support across cohorts raiseNotImplementedError— an absent global level yields a dropped zero column (att_d[level]=0) that the plain-sum dose aggregation would bias toward zero (support-aware aggregation is deferred; single-cohort, 2-period, and shared-support multi-cohort are supported); (ii) a requesteddvalsvalue that is not an observed dose level raisesValueError(a saturated model cannot be evaluated off-support); (iii) an over-parameterized fit (< 2treated units per level, orJ > n_treated/2) warns (degenerate per-level SE); (iv) withsurvey_design=, any dose level with zero effective treated mass in a(g,t)cell raisesValueError— a per-cell check (not just the global positive-weight check), so a level that survey/subpopulation weights zero out for one cohort while another cohort keeps it cannot silently drop to a zero-coefficient saturated column. Cross-referencesdocs/methodology/continuous-did.md§ 5.1.Note (lowest-dose-as-control, Remark 3.1 — library extension beyond
contdidv0.1.0):control_group="lowest_dose"implements CGBS 2024 Remark 3.1 for settings with no untreated group (P(D=0) = 0): the lowest-dose groupd_Lbecomes the comparison and the estimand isATT(d) − ATT(d_L)(SPT), withATT(d_L) = 0the omitted reference. Mechanically it is a control-group swap — the D=0 control pool is replaced by thed_Lgroup; the entire linear influence-function / bootstrap / event-study / survey machinery is control-group-generic and reused unchanged (ee_controlalready carries the reference-group variance, so no new SE plumbing). On the discrete saturated basis the backward-difference operator’s reference shifts from0tod_L(ACRT(d_1) = ATT(d_1)/(d_1 − d_L)); on the continuous B-spline path the reference shifts onlyμ_0(the level), leavingACRT = spline'unchanged.contdidv0.1.0 does not implement Remark 3.1, so there is no external R anchor; validation (R-free, in CI): an exactd_L → 0equivalence anchor (relabelling anever_treatedpanel’s D=0 group as a tiny common dosed_L = εreproduces thenever_treatedATT and SE exactly, for any ε), a discrete hand-calc ofATT(d)−ATT(d_L)/ACRT/overall_att/overall_acrtand the per-level 2×2 SE (~1e-10), continuous mass-point DGP recovery, analytical-vs-bootstrap SE agreement, a pre-period placebo, and MC coverage (tests/test_methodology_continuous_did.py::TestLowestDose,tests/test_continuous_did.py::TestLowestDoseAPI). The continuous path requires a genuine mass point at the minimum dose (>= 2units atd_L, i.e.P(D=d_L) > 0) — the Remark 3.1 identification condition; a singleton minimum fails closed. Fail-closed policies (no-silent-failures): (i) never-treated units present withlowest_dose→ValueError(they would be silently dropped); (ii) singletond_L(no mass point) →ValueError; (iii) no treated dose aboved_L→ValueError; (iv) userdvals ≤ d_L→ValueError(d_Lis the omitted reference); (v) survey/subpopulation weighting that leaves thed_Lgroup with< 2positive-weight units →ValueError(a single positive-weight reference unit givesee_control = 0, i.e. zero control-side variance — the effective->= 2analogue of the raw mass-point guard, applied after weighting); (vi) a boundary gapd_1 − d_Lthat is a tiny fraction of the dose range warns (huge boundary ACRT/SE). Deferred (fail-closedNotImplementedError+ TODO): multi-cohortlowest_dose(needs a within-cohort reference + support-aware cross-cohort aggregation) andcovariates=×lowest_dose(conditional-PT-relative-to-d_Lestimand). Cross-referencesdocs/methodology/continuous-did.md§ 5.6.
Implementation Checklist#
[x] B-spline basis construction matching R’s
splines2::bSpline(global knots from all treated doses; boundary knots use training-dose range; see deviation note above)[x] Multi-period (g,t) cell iteration with base period selection
[x] Dose-response and event-study aggregation with group-proportional weights (n_treated/n_total per group, divided among post-treatment cells; R
ptetoolsconvention)[x] Multiplier bootstrap for inference
[x] Analytical SEs via influence functions
[x] Equation verification tests (linear, quadratic, multi-period)
[x] Covariate support (reg / dr) — conditional parallel trends. ipw restricted (see Note below); survey × covariate deferred; covariates × lowest_dose deferred.
[x] Discrete treatment saturated regression (
treatment_type="discrete") — CGBS 2024 Eq. 4.1; per-level ATT(d_j) + backward-difference ACRT on{0, d_1, …, d_J}(ACRT(d_1) = ATT(d_1)/d_1; binary → ACRT = ATT). Multi-cohort heterogeneous dose support deferred (see Note #6).[x] Lowest-dose-as-control (Remark 3.1,
control_group="lowest_dose") — estimandATT(d) - ATT(d_L)forP(D=0)=0; discrete + continuous (mass-point) paths; single-cohort. Multi-cohort and covariates × lowest_dose deferred (see Note #7).[x] Survey design support (Phase 3): weighted B-spline OLS, TSL on influence functions; bootstrap+survey supported (Phase 6)
Note: ContinuousDiD bootstrap with survey weights supported (Phase 6) via PSU-level multiplier weights
Note: The R-style convention of coding never-treated units as
first_treat=infis still accepted and normalized tofirst_treat=0internally, but the estimator now emits aUserWarningreporting the row count so the silent recategorization is surfaced (axis-E silent coercion under the Phase 2 audit). Only+infis recoded (matching the R convention). Any negativefirst_treatvalue (including-inf) raisesValueErrorwith the row count, since such units would otherwise silently fall out of both the treated (g > 0) and never-treated (g == 0) masks. Pass0directly for never-treated units to avoid the warning.Note: Rows where
first_treat=0(never-treated) carry a nonzerodoseare silently zeroed for internal consistency (never-treated cells must haveD=0in the dose response). The estimator now emits aUserWarningwith the affected row count before the zeroing, so unintended nonzero doses on never-treated rows are no longer absorbed without a signal (axis-E silent coercion).
EfficientDiD#
Primary source: Chen, X., Sant’Anna, P. H. C., & Xie, H. (2025). Efficient Difference-in-Differences and Event Study Estimators. arXiv:2506.17729v1. (Cowles Foundation Discussion Paper No. 2470). Paper review on file: docs/methodology/papers/chen-santanna-xie-2025-review.md (theorem/equation numbering pinned to arXiv v1, currently the only version).
Key implementation requirements:
Assumption checks / warnings:
Random Sampling (Assumption S): Data is a random sample of
(Y_{1}, ..., Y_{T}, X', G)'Overlap (Assumption O): For each group g, generalized propensity score
E[G_g | X]must be in(0, 1)a.s. Near-zero propensity scores cause ratiop_g(X)/p_{g'}(X)to explode; warn on finite-sample instabilityNo-anticipation (Assumption NA): For all treated groups g and pre-treatment periods t < g:
E[Y_t(g) | G=g, X] = E[Y_t(infinity) | G=g, X]a.s.Parallel Trends – two variants:
PT-Post (weaker): PT holds only in post-treatment periods, comparison group = never-treated only, baseline = period g-1 only. Estimator is just-identified and reduces to standard single-baseline DiD (Corollary 3.2)
PT-All (stronger): PT holds for all groups and all periods. Enables using any not-yet-treated cohort and any pre-treatment period as baseline. Model is overidentified (Lemma 2.1); paper derives optimal combination weights
Absorbing treatment: Binary treatment must be irreversible (once treated, stays treated)
Balanced panel: Short balanced panel required (“large-n, fixed-T” regime). Does not handle unbalanced panels or repeated cross-sections
Warn if treatment varies within units (non-absorbing treatment)
Warn if propensity score estimates are near boundary values
Note: Polynomial-sieve propensity fits now reject any K whose normal-equations matrix has condition number above
1/sqrt(eps)(≈ 6.7e7) — previously a near-singularnp.linalg.solvecould return numerically meaningless coefficients without raising. If at least one K succeeds but others were skipped via this precondition, aUserWarninglists the skipped K values. If every K is skipped, the existing “estimation failed for all K values” fallback warning still fires. Axis-A finding #18 in the Phase 2 silent-failures audit.
Estimator equation – single treatment date (Equations 3.2, 3.5):
Transformed outcome (Equation 3.2):
Y_tilde_{g,t,t_pre} = (1/pi_g) * (G_g - p_g(X)/p_inf(X) * G_inf) * (Y_t - Y_{t_pre} - m_{inf,t,t_pre}(X))
Efficient ATT estimand (Equation 3.5):
ATT(g, t) = E[ (1' V*_{gt}(X)^{-1} / (1' V*_{gt}(X)^{-1} 1)) * Y_tilde_{g,t} ]
where:
G_g = 1{G = g}= indicator for belonging to treatment cohort gG_inf = 1{G = infinity}= indicator for never-treatedpi_g = P(G = g)= population share of cohort gp_g(X) = E[G_g | X]= generalized propensity scorem_{inf,t,t_pre}(X) = E[Y_t - Y_{t_pre} | G = infinity, X]= conditional mean outcome change for never-treatedV*_{gt}(X)=(g-1) x (g-1)conditional covariance matrix with(j,k)-th element (Equation 3.4):(1/p_g(X)) Cov(Y_t - Y_j, Y_t - Y_k | G=g, X) + (1/(1-p_g(X))) Cov(Y_t - Y_j, Y_t - Y_k | G=inf, X)
Estimator equation – staggered adoption (Equations 3.9, 3.13, 4.3, 4.4):
Generated outcome for each (g', t_pre) pair (Equation 3.9 / sample analog 4.4):
Y_hat^{att(g,t)}_{g',t_pre} = (G_g / pi_hat_g) * (Y_t - Y_1 - m_hat_{inf,t,t_pre}(X) - m_hat_{g',t_pre,1}(X))
- r_hat_{g,inf}(X) * (G_inf / pi_hat_g) * (Y_t - Y_{t_pre} - m_hat_{inf,t,t_pre}(X))
- r_hat_{g,g'}(X) * (G_{g'} / pi_hat_g) * (Y_{t_pre} - Y_1 - m_hat_{g',t_pre,1}(X))
where:
r_hat_{g,g'}(X) = p_g(X)/p_{g'}(X)= estimated propensity score ratiom_hat_{g',t,t_pre}(X) = E[Y_t - Y_{t_pre} | G = g', X]= estimated conditional mean outcome change
Efficient ATT for staggered adoption (Equation 4.3):
ATT_hat_stg(g,t) = E_n[ (1' Omega_hat*_{gt}(X)^{-1}) / (1' Omega_hat*_{gt}(X)^{-1} 1) * Y_hat^{att(g,t)}_stg ]
where Omega*_{gt}(X) is the conditional covariance matrix with (j,k)-th element (Equation 3.12):
(1/p_g(X)) Cov(Y_t - Y_1, Y_t - Y_1 | G=g, X)
+ (1/p_inf(X)) Cov(Y_t - Y_{t'_j}, Y_t - Y_{t'_k} | G=inf, X)
- 1{g=g'_j}/p_g(X) * Cov(Y_t - Y_1, Y_{t'_j} - Y_1 | G=g, X)
- 1{g=g'_k}/p_g(X) * Cov(Y_t - Y_1, Y_{t'_k} - Y_1 | G=g, X)
+ 1{g_j=g'_k}/p_{g'_j}(X) * Cov(Y_{t'_j} - Y_1, Y_{t'_k} - Y_1 | G=g'_j, X)
Event study aggregation (Equations 3.8, 3.14, 4.5):
ES_hat(e) = sum_{g in G_{trt,e}} (pi_hat_g / sum_{g' in G_{trt,e}} pi_hat_{g'}) * ATT_hat_stg(g, g+e)
where G_{trt,e} = {g in G_trt : g + e <= T} and weights are cohort relative size weights.
Overall average event-study parameter (Equation 2.3):
ES_avg = (1/N_E) * sum_{e in E} ES(e)
With covariates / doubly robust:
The estimator is doubly robust by construction. Consistency requires correct specification of either:
Outcome regression:
m_{g',t,t_pre}(X) = E[Y_t - Y_{t_pre} | G = g', X], ORPropensity score ratio:
r_{g,g'}(X) = p_g(X)/p_{g'}(X)
The Neyman orthogonality property (Remark 4.2) permits modern ML estimators (random forests, lasso, ridge, neural nets, boosted trees) for nuisance parameters without loss of efficiency.
Without covariates (Section 4.1):
Estimator simplifies to closed-form expressions using only within-group sample means and sample covariances. No tuning parameters are needed. The covariance matrix Omega*_gt uses unconditional within-group covariances with pi_g replacing p_g(X).
Standard errors (Theorem 4.1, Section 4):
Default: Analytical SE computed as the square root of the sample variance of estimated EIF values divided by n:
SE_analytical = sqrt( (1/n^2) * sum_{i=1}^{n} EIF_hat_i^2 )
Alternative: Cluster-robust SE at cross-sectional unit level (used in empirical application, page 34-35)
Bootstrap: Nonparametric clustered bootstrap (resampling clusters with replacement); 300 replications recommended (page 23, footnote 16)
Small sample recommendation (Section 5.1): Use cluster bootstrap SEs rather than analytical SEs when n is small (n <= 50). Analytical SEs are anticonservative with n=50 (coverage ~0.80) but perform well with n >= 200 (coverage ~0.94)
Simultaneous confidence bands: Multiplier bootstrap procedure for multiple
(g,t)pairs (footnote 13, referencing Callaway and Sant’Anna 2021, Theorems 2-3, Algorithm 1)Implementation note: Phase 1 uses multiplier bootstrap on EIF values (Rademacher/Mammen/Webb weights) rather than nonparametric clustered bootstrap. This is asymptotically equivalent and computationally cheaper, consistent with the CallawaySantAnna implementation pattern. Clustered resampling bootstrap may be added in a future version
Efficient influence function for ATT(g,t) (Theorem 3.2):
EIF^{att(g,t)}_stg = (1' Omega*_{gt}(X)^{-1}) / (1' Omega*_{gt}(X)^{-1} 1) * IF^{att(g,t)}_stg
Efficient influence function for ES(e) (following Theorem 3.2, page 17):
EIF^{es(e)}_stg = sum_{g in G_{trt,e}} ( q_{g,e} * EIF^{att(g,g+e)}_stg
+ ATT(g,g+e) / (sum_{g' in G_{trt,e}} pi_{g'}) * (G_g - pi_g)
- q_{g,e} * sum_{s in G_{trt,e}} (G_s - pi_s) )
where q_{g,e} = pi_g / sum_{g' in G_{trt,e}} pi_{g'}.
Edge cases:
Single pre-treatment period (g=2):
V*_{gt}(X)is 1x1, efficient weights are trivially 1, estimator collapses to standard DiD with single baselineRank deficiency in
V*_{gt}(X)orOmega*_{gt}(X): Inverse does not exist if outcome changes are linearly dependent conditional on covariates. Under the defaultomega_ridge > 0the ridge-regularized solve (see the Omega* ridge Note below) handles this without a routing cliff; atomega_ridge=0the legacy behavior applies (detect via matrix condition number; fall back to pseudoinverse with a per-cell warning)Near-zero propensity scores: Ratio
p_g(X)/p_{g'}(X)explodes. Overlap assumption (O) rules this out in population; implement trimming or warn on finite-sample instabilityNote: When no sieve degree K succeeds for ratio estimation (basis dimension exceeds comparison group size, or all linear systems are singular), the estimator falls back to a constant ratio of 1 for all units with a UserWarning. The outcome regression adjustment remains active, so the generated outcomes (Eq 4.4) still incorporate covariate information via the m_hat terms. The DR property ensures consistency as long as the outcome regression is correctly specified.
Note: When no sieve degree K succeeds for inverse propensity estimation (algorithm step 4), the estimator falls back to unconditional n/n_group scaling with a UserWarning, which reduces to the unconditional Omega* approximation for the affected group.
All units eventually treated: Last cohort serves as “never-treated” by dropping time periods from
last_g - anticipationonward (wherelast_gis the latest treatment cohort). This excludes anticipation-contaminated periods from the pseudo-control’s pre-treatment window. Withanticipation=0(default), this equalslast_g. Usecontrol_group="last_cohort"to enable; default"never_treated"raises ValueError if no never-treated units existNegative weights: Explicitly stated as harmless for bias and beneficial for precision; arise from efficiency optimization under overidentification (Section 5.2)
PT-Post regime (just-identified): Under PT-Post, EDiD automatically reduces to standard single-baseline estimator (Corollary 3.2). No downside to using EDiD – it subsumes standard estimators
Duplicate rows: Duplicate
(unit, time)entries are rejected withValueError. The estimator requires exactly one observation per unit-periodNote: PT-All index set includes g’=∞ (never-treated) as a candidate comparison group and excludes period_1 for all g’. When g’=∞, the second and third Eq 3.9 terms telescope so all (∞, t_pre) moments produce the same 2x2 DiD value; these redundant moments are damped by the default Omega* ridge (see the Omega* ridge Note below; at
omega_ridge=0, by the legacy pseudoinverse). When t_pre = period_1, the third term degenerates to E[Y_1 - Y_1 | G=g’] = 0 for any g’, adding no information. Valid pairs require only t_pre < g’ (pre-treatment for comparison group), not t_pre < g. Same-group pairs (g’=g) are valid and contribute overidentifying moments (Equation 3.9) — except the degenerate PRE-treatment self-pair (g’=g, t_pre=t), which the default ridge path excludes (see the ridge Note).Note: Omega* ridge regularization (
omega_ridge, default 1e-6; v3.7) — a documented refinement of the Omega* inversion in Eq 3.5/3.13/4.3, in the space the paper leaves open (it assumes Omega* is invertible and does not prescribe finite-sample handling of singularity). Under PT-All the overidentified moment set makes the SAMPLE Omega* numerically singular by construction (the telescoping (∞, t_pre) moments above plus near-duplicate cross-cohort moments): measured cond 1e17–1e22 for 100% of units on realistic panels, with a spectrum of one exact-null direction (the degenerate self-pair, below) plus a cluster of statistically-null directions at relative eigenvalue 1e-5–1e-8, far below the ~1e-2 sampling noise of the covariance entries — and no clean spectral gap. The prior pseudoinverse fallback therefore sat on the rcond-cutoff cliff: ANY floating-point-level change (BLAS reordering, platform change, a 1-ulp data perturbation) redrew per-cell weights and moved per-cell ATT(g,t) at up to ~1e-2 relative (measured 1.2e-4 per-cell rel for a 1-ulp outcome perturbation on the pre-v3.7 code; overall ATT stable at ~1e-9 because the redraw averages out across units and cells). The ridge solves(Omega* + omega_ridge * max(trace/H, 0) * I) x = 1(trace-scaled: scale-equivariant, O(H), no SVD) — a smooth regularization with sensitivity bounded by 1/omega_ridge, not a cutoff. Why the deviation is safe: every moment individually identifies ATT(g,t), so any fixed weights summing to 1 keep the estimator consistent; the ridge trades a numerically ill-defined efficiency optimum for a stable one, and the plug-in EIF treatment of estimated weights (Remark 4.2) is unaffected. Calibration evidence (2026-07): default 1e-6 chosen by 1-ulp stability (per-cell rel <= 3e-9 vs 1.2e-4 legacy; candidates 1e-4/1e-6/1e-8 all pass the <=1e-6 target) with the HRS Table 6 anchors as a hard gate (all anchors within the published-value tolerance at every candidate; worst deviation 0.0257 SE, unchanged from legacy; shift <= 0.0001 SE); Monte Carlo on covariate-confounded DGPs shows bias/RMSE/SE-calibration/coverage statistically identical to legacy (ridge marginally better point metrics). One-time value shift: per-cell and event-study values on the covariate path move within the pre-existing indeterminacy band when upgrading (worst observed post-treatment cell shift ~0.6 of its own SE at n=500, shrinking with n: overall-ATT shift 1.6e-2 → 4e-3 → 1.1e-3 rel at n=500/1k/2k); the no-covariates path is essentially unchanged (~1e-7).omega_ridge=0restores the ENTIRE legacy code path bit-for-bit (both the omega constructions and the inv/pinv weights, including per-cell condition-number warnings and the legacy O(n^2 H^2) runtime). Degenerate self-pair: for PRE-treatment cells (t < g), the pair (g’=g, t_pre=t) telescopes to the identically-zero moment 0=0 (the exact-null Omega* direction). The legacy pseudoinverse truncated it, spreading weight over noisy moments (spurious pre-treatment placebos of ~5% of the effect size, pure noise amplification); a naive ridge would instead load all weight on the zero-variance moment, making placebos deterministically zero and silently disabling the pre-trend diagnostic. The default ridge path therefore drops this zero-information pair (fit-level filter; post-treatment cells never contain it), restoring honest data-driven placebos;omega_ridge=0keeps the legacy pair set. Warnings/diagnostics: the no-covariates path still computes per-cell condition numbers (cheap at (H,H)) forresults.omega_condition_numbersand consolidates cells with cond > 1e12 into ONE fit-level warning (count + max cond) instead of the legacy per-cell pseudoinverse warnings; the covariate path intentionally computes NO per-unit condition numbers — they would cost exactly the per-unit SVDs the v3.7 rewrite removes, near-singularity there is structural (~always true under PT-All, so a warning would be always-on noise rather than signal), and the ridge handles it by design (the legacy covariate path likewise had no per-unit diagnostics). The scalability warning threshold moved from n > 5000 (legacy per-cell warning) to n > 50,000 (one fit-level warning; the kernel stage is still intrinsically O(n^2) but with a ~100x lower constant and tile-bounded memory). Cross-cell table hoisting (2026-07 follow-up): the tiled implementation now builds the kernel-covariance tables once per comparison group per unit-tile instead of per (g, t) cell — every Omega* term iss_group * KCov(Y_u1 - Y_v1, Y_u2 - Y_v2 | group), keyed only by wide-outcome columns, so the per-cell H(H+1)/2-pair tables dedup to distinct product columns per group (~26x fewer kernel GEMM columns on a PT-All fit), and the kernel weight matrices are built and freed one group at a time (the tile memory budget is governed by the largest single group rather than the sum, giving proportionally fatter tiles at large n). Each cell’s Omega* is then gathered from the group tables in the same per-entry operation order as the per-cell construction (value-exact, locked by test); this is a pure implementation change — measured results move only at floating-point reassociation level (post-treatment cells ~1e-12 relative, overall ATT ~1e-13; the no-covariates path is byte-identical), andomega_ridge=0still routes the entire legacy path. Rust-backend batched-Cholesky ridge solve (2026-07 follow-up): on the Rust backend the batched ridge solve(Omega* + lam * max(trace/H, 0) * I) x = 1dispatches to a batched Cholesky kernel (the ridged Omega* is SPD by construction; a non-SPD row — measured zero on realistic panels — falls back to LU in-kernel, and any non-finite row is recomputed through the exact legacy numpy chain including its pseudoinverse backstop, so edge-case semantics are unchanged), parallelized over units. This too is a pure implementation change: Cholesky and LU solutions differ only at the condition-bounded floating-point level, and measured results move at reassociation level (post-treatment cells ~2e-12 relative, overall ATT ~1e-13); the pure-Python backend is byte-identical, andomega_ridge=0never reaches the kernel.Note: Bootstrap aggregation uses fixed cohort-size weights for overall/event-study reaggregation, matching the CallawaySantAnna bootstrap pattern (staggered_bootstrap.py:281 computes
bootstrap_overall = bootstrap_atts_gt[:, post_indices] @ weights; L297 uses the same fixed-weight pattern for event study). The analytical path includes a WIF correction; fixed-weight bootstrap captures the same sampling variability through per-cell EIF perturbation without re-estimating aggregation weights, consistent with both the library’s CS implementation and the Rdidpackage.Overall ATT convention: The library’s
overall_attuses cohort-size-weighted averaging of post-treatment (g,t) cells, matching the CallawaySantAnna simple aggregation. This differs from the paper’s ES_avg (Eq 2.3), which uniformly averages over event-time horizons. ES_avg can be computed from event study output asmean(event_study_effects[e]["effect"] for e >= 0)
Algorithm (two-step semiparametric estimation, Section 4):
Step 1: Estimate nuisance parameters
Estimate outcome regressions
m_hat_{g',t,t_pre}(X)using sieve regression, kernel smoothing, or ML methods (for each valid(g', t_pre)pair)Estimate propensity score ratios
r_hat_{g,g'}(X) = p_g(X)/p_{g'}(X)via convex minimization (Equation 4.1):r_{g,g'}(X) = arg min_{r} E[ r(X)^2 * G_{g'} - 2*r(X)*G_g ]
Sieve estimator (Equation 4.2):
beta_hat_K = arg min_{beta_K} E_n[ G_{g'} * (psi^K(X)' beta_K)^2 - 2*G_g * (psi^K(X)' beta_K) ]Select sieve index K via information criterion:
K_hat = arg min_K { 2*loss(K) + C_n * K / n }whereC_n = 2(AIC) orC_n = log(n)(BIC)Estimate
s_hat_{g'}(X) = 1/p_{g'}(X)via analogous convex minimizationEstimate conditional covariance
Omega_hat*_{gt}(X)using kernel smoothing with bandwidth h
Step 2: Construct efficient estimator
6. Compute generated outcomes Y_hat^{att(g,t)}_{g',t_pre} for each valid (g', t_pre) pair using Equation 4.4
7. Compute efficient weights w(X) = 1' Omega_hat*_{gt}(X)^{-1} / (1' Omega_hat*_{gt}(X)^{-1} 1)
8. Compute ATT_hat_stg(g,t) = E_n[ w(X_i) * Y_hat^{att(g,t)}_stg ] (Equation 4.3)
9. Aggregate to event-study: ES_hat(e) = sum_g (pi_hat_g / sum pi_hat) * ATT_hat_stg(g, g+e) (Equation 4.5)
10. Compute SE from sample variance of estimated EIF values
Without covariates: Steps 1-5 simplify to within-group sample means and sample covariances. No nuisance estimation or tuning needed.
Reference implementation(s):
No specific software package named in the paper for the EDiD estimator
Estimators compared against: Callaway-Sant’Anna (
didR package), de Chaisemartin-D’Haultfoeuille (DIDmultiplegtR package /did_multiplegtStata), Borusyak-Jaravel-Spiess / Gardner / Wooldridge imputation estimatorsEmpirical replication: HRS data from Dobkin et al. (2018) following Sun and Abraham (2021) sample selection
Requirements checklist:
[x] Implements two-step semiparametric estimator (Equation 4.3)
[x] Supports both PT-Post (just-identified) and PT-All (overidentified) regimes
[x] Computes efficient weights from conditional covariance matrix inverse
[x] Doubly robust: consistent if either outcome regression or propensity score ratio is correct
[x] No-covariates case uses closed-form sample means/covariances (no tuning)
[x] With covariates: sieve-based propensity ratio estimation with AIC/BIC selection
[x] Kernel-smoothed conditional covariance estimation
[x] Analytical SE from EIF sample variance
[x] Cluster-robust SE option (analytical from EIF + cluster-level multiplier bootstrap)
[x] Event-study aggregation ES(e) with cohort-size weights
[x] Hausman-type pre-test for PT-All vs PT-Post (Theorem A.1)
[x] Each ATT(g,t) can be estimated independently (parallelizable)
[x] Absorbing treatment validation
[x] Overlap diagnostics for propensity score ratios
[x] Survey design support (Phase 3): survey-weighted means/covariances in Omega*, TSL on EIF scores; bootstrap+survey supported (Phase 6)
Note: Sieve ratio estimation uses polynomial basis functions (total degree up to K) with AIC/BIC model selection. The paper describes sieve estimators generally without specifying a particular basis family; polynomial sieves are a standard choice (Section 4, Eq 4.2). Negative sieve ratio predictions are clipped to a small positive value since the population ratio p_g(X)/p_{g’}(X) is non-negative. The outcome regression m_hat(X) uses the same polynomial sieve basis family (see the outcome-regression Note below).
Note: Kernel-smoothed conditional covariance Omega*(X) uses Gaussian kernel with Silverman’s rule-of-thumb bandwidth by default. The paper specifies kernel smoothing (step 5, Section 4) without mandating a particular kernel or bandwidth selection method.
Note: Conditional covariance Omega*(X) scales each term by per-unit sieve-estimated inverse propensities s_hat_{g’}(X) = 1/p_{g’}(X) (algorithm step 4), matching Eq 3.12. The inverse propensity estimation uses the same polynomial sieve convex minimization as the ratio estimator. Estimated s_hat values are clipped to [1, n] with a UserWarning when clipping binds, mirroring the ratio path’s overlap diagnostics.
Note: Outcome regressions m_hat_{g’,t,tpre}(X) use a polynomial sieve (total degree up to K) with AIC/BIC order selection — the same basis family as the propensity-ratio sieve — matching the paper’s flexible nonparametric nuisance specification (Section 4). The sieve order is selected by an OLS information criterion
IC = n*ln(RSS/n) + c_n*p_K, wherep_K = comb(K+d, d)is the sieve basis dimension (the number of fitted coefficients;d= covariate count) andc_n = 2(AIC) /ln(n)(BIC), on the within-group (survey-weighted) residual sum of squares; the within-group positive-weight support count is used for bothnand the penalty (the raw row count when unweighted) so the selected order — and hence m_hat — is invariant both to the survey-weight scale and to zero-weight (survey-subpopulation / padded) rows. Zero-weight observations are inert in the weighted RSS, weighted Gram, and weighted loss totals; keying order selection (auto-k_max, then_basisadmissibility cap, and the IC sample-size terms) off the positive-weight support keeps them inert for selection too — otherwise padding the panel with zero-weight rows could pushfloor(n^{1/5})to a higher candidate degree and silently change the selected K (hence the DR estimate). This applies identically to the two sieve propensity nuisances. Degree 1 reproduces a linear working model up to floating point, so AIC/BIC degrades to linear when the conditional mean is linear and grows the order only when the data support it. There is no fixed order ceiling: the candidate maximum degree grows asfloor(n_pos^{1/5})with the (positive-weight) group supportn_pos, giving a sieve dimensionp_K = comb(K+d, d)bounded byn_basis = p_K < n_pos— a growing sieve. Assumption C.1’s regularity conditions are stated in terms of the sieve dimension (not the polynomial degree, which differ onced > 1): uniform consistency (C.1(5),||m_hat - m||_inf = o_p(1)) and theo_p(n^{-1/2})product rate (C.1(6)) requirep_K -> ∞withp_K = o(n). The growing sieve satisfies this for the low-dimensional covariate settings typical of DiD — with thefloor(n^{1/5})degree rule,p_K = comb(floor(n^{1/5})+d, d) = o(n)for smalld; for high-dimensionalXa polynomial sieve faces the curse of dimensionality (p_Kcan outpaceo(n)), where the paper’s ML-nuisance option (Remark 4.2) is preferable. Under C.1 the doubly robust covariate path attains the semiparametric efficiency bound asymptotically (Theorem 4.1); a frozen finite-order sieve (fixedp_K) would violate C.1(5)/(6). The DR property still ensures consistency, regardless ofd, if either the outcome regression or the propensity ratio is correctly specified.Note: If every sieve degree is rank-skipped for an outcome regression (a comparison group too small for even the linear basis, or a degenerate/constant covariate), the estimator falls back to the intercept-only within-group mean of
Y_t - Y_{tpre}(the unconditional outcome regression) with a UserWarning — distinct from the propensity-ratio sieve’s constant-ratio-1 fallback.Note: EfficientDiD bootstrap with survey weights supported (Phase 6) via PSU-level multiplier weights
Note: EfficientDiD covariates (DR path) with survey weights supported — WLS outcome regression, weighted sieve normal equations for propensity ratios/inverse propensities, survey-weighted Nadaraya-Watson kernel for conditional Omega*(X), and survey-weighted ATT averaging. The auto Silverman bandwidth for the conditional Omega*(X) kernel is evaluated on the positive-weight support (rows with
w > 0) with a survey-weighted dispersion:median_stdis the median across covariate dimensions of the weighted standard deviationsqrt(sum_i w_i (x_i - xbar_w)^2 / sum_i w_i)(weighted meanxbar_w = sum_i w_i x_i / sum_i w_i), so the bandwidth reflects the population covariate distribution the kernel targets rather than the unweighted sample. The rate termnstays the positive-weight support count (the dispersion is weighted; the sample-size term is not — a deliberately scoped refinement, not Kishn_eff). Because zero-weight rows drop from the support and contribute nothing to the weighted moments or the count, and because the weighted mean/std and the count are invariant to weight rescalingw -> c*w, the bandwidth — and hence Omega*(X) and the per-unit efficient weights it feeds in overidentified (H>1) cells — is invariant to both zero-weight (survey-subpopulation / padded) rows and weight scale; under uniform positive weights the weighted std reduces to the unweighted population std, matching the pre-refinement bandwidth up to floating point. Together with the positive-weight-support sieve order selection, this keeps the DR point estimate exactly invariant to zero-weight padding (a zero-weight row with an extreme covariate would otherwise move the dispersion and the bandwidth).Note: Cluster-robust SEs use the standard Liang-Zeger clustered sandwich estimator applied to EIF values: aggregate EIF within clusters, center, and compute variance with G/(G-1) small-sample correction. Cluster bootstrap generates multiplier weights at the cluster level (all units in a cluster share the same weight). Analytical clustered SEs are the default when
clusteris set; cluster bootstrap is opt-in vian_bootstrap > 0.Note: Hausman pretest operates on the post-treatment event-study vector ES(e) per Theorem A.1. Both PT-All and PT-Post fits are aggregated to ES(e) using cohort-size weights before computing the test statistic H = delta’ V^{-1} delta where delta = ES_post - ES_all and V = Cov(ES_post) - Cov(ES_all). Covariance is computed from aggregated ES(e)-level EIF values. The variance-difference matrix V is inverted via Moore-Penrose pseudoinverse to handle finite-sample non-positive-definiteness. Effective rank of V (number of positive eigenvalues) is used as degrees of freedom.
Note: Last-cohort-as-control (
control_group="last_cohort") reclassifies the latest treatment cohort as pseudo-never-treated and drops time periods att >= last_g - anticipation, excluding anticipation-contaminated periods from the pseudo-control’s pre-treatment window. This is distinct from CallawaySantAnna’snot_yet_treatedoption which dynamically selects not-yet-treated units per (g,t) pair.Note:
vcov_typeis permanently narrow to{"hc1"}per the Chen-Sant’Anna-Xie (2025) EIF-based variance achieving the semiparametric efficiency bound. Analytical-sandwich families{classical, hc2, hc2_bm}are rejected at__init__— the per-unit EIF aggregation has no equivalent single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined.cluster=invokes Liang-Zeger CR1 on cluster-aggregated EIF (_compute_se_from_eifwithcluster_indicesatdiff_diff/efficient_did.py:124-127);survey_design=invokes TSL on the combined IF (_compute_survey_eif_seatdiff_diff/efficient_did.py:1151-1176).vcov_type='conley'deferred to the EfficientDiD Conley follow-up row in TODO.md.Note: Default
cluster=None(no survey design) renders summary label “HC1 heteroskedasticity-robust” because the per-unit EIF SEsqrt(mean(EIF²)/n)is methodologically HC1-style (no Liang-Zeger G/(G-1) finite-sample correction).EfficientDiDResults.cluster_nameandn_clustersstay None under unclustered fits. This diverges fromImputationDiDwhich auto-clusters at unit per Borusyak-Jaravel-Spiess (2024) Theorem 3 — there the default summary renders the CR1 unit-clustered label.Note (deviation from sibling estimators):
EfficientDiD.set_params(vcov_type=bad)raises immediately rather than deferring tofit(). EfficientDiD’sset_paramscalls_validate_params()which invokes_validate_vcov_type, matching the existing eager-validation pattern forpt_assumption,control_group,bootstrap_weights, etc. This is intentional —ImputationDiD/TripleDifference/CallawaySantAnnause sklearn mutate-then-validate-at-use, so the same set_params + bad vcov_type sequence is silently accepted there untilfit()is called.
SunAbraham#
Key implementation requirements:
Assumption checks / warnings:
Requires never-treated units as control group
Warns if treatment effects may be heterogeneous across cohorts (which the method handles)
Reference period: e=-1-anticipation (defaults to e=-1 when anticipation=0)
Estimator equation (as implemented):
Saturated regression with cohort-specific effects:
Y_it = α_i + γ_t + Σ_{g∈G} Σ_{e≠-1} δ_{g,e} × 1(G_i=g) × D^e_{it} + ε_it
where G_i is unit i’s cohort (first treatment period), D^e_{it} = 1(t - G_i = e).
Interaction-weighted estimator:
δ̂_e = Σ_g ŵ_{g,e} × δ̂_{g,e}
where weights ŵ_{g,e} = n_{g,e} / Σ_g n_{g,e} (sample share of cohort g at event-time e).
Standard errors:
Default: Cluster-robust HC1 at unit level (
vcov_type="hc1")vcov_type ∈ {"classical","hc1","hc2","hc2_bm"}supported as of Phase 1b PR 1/8 (mirrors the DiD/MPD/TWFE chain established in Phase 1a):"hc1"(default): Eicker-Huber-White HC1 with cluster-at-unit default. Auto-clusters at unit unless an explicitcluster=is passed."classical": homoskedastic OLS standard errors. Auto-cluster is dropped (one-way only). Routes through the full-dummy saturated design (see Implementation note below) for R-parity."hc2": HC2 leverage correction. Auto-cluster is dropped (one-way only); the linalg validator rejectshc2 + cluster_ids. Routes through full-dummy."hc2_bm": HC2 + Bell-McCaffrey CR2 Satterthwaite DOF for cluster-robust inference. Auto-cluster fires at unit (or explicitcluster=); routes through full-dummy. R-parity matchesclubSandwich::vcovCR(..., type="CR2")+coef_test()$df_Sattat atol=1e-10."conley"(spatial-HAC, Conley 1999): threaded through the within-transform saturated regression viasolve_ols/conley.py— within-period-spatial-only (conley_lag_cutoff=0) and panel block-decomposed (conley_lag_cutoff>0: within-period spatial + within-unit Bartlett serial). Reuses the already-conleyreg-validated machinery (no new variance code). The unit auto-cluster is dropped on the conley path (an explicitcluster=enables the spatial+cluster product kernel);survey_design=/weights/n_bootstrap>0are rejected. Note: the FWL-demeaned conley sandwich equals the full-dummy conley SE (pinned intests/test_conley_vcov.py::TestConleySunAbraham::test_fwl_composability_vs_full_dummy).
Note (Phase 1b auto-route): When
vcov_type ∈ {"classical","hc2", "hc2_bm"},_fit_saturated_regressionbypasses the within-transform path and builds the full-dummy saturated design[intercept + cohort × event-time interactions + covariates + unit_dummies + time_dummies]directly. The FWL theorem preserves cohort coefficients and residuals but does NOT preserve the hat matrix, so HC2 leverage and Bell-McCaffrey Satterthwaite DOF must be computed on the full FE projection (matcheslm() + sandwich::vcovHC/clubSandwich::vcovCR). Classical SE also routes through full-dummy so the(n-k)finite- sample correction matches R’slm()interpretation at atol=1e-10.hc1stays on the within-transform path (cluster-robust HC1 doesn’t depend on the hat matrix); matchesfixest::sunab()event-study aggregates closely (see deviation note below).Note (Phase 1b aggregated BM contrast DOF): Under
vcov_type="hc2_bm", the user-facing aggregated inference (event_study_effects[e]['p_value']/['conf_int'],overall_p_value/overall_conf_int) uses CR2 Bell-McCaffrey Satterthwaite DOF per contrast — not the normal distribution. Per-event-time contrastc_e[full_idx(g,e)] = w_{g,e}(IW weight) and overall ATT contrastc_overall[full_idx(g,e)] = w_e × w_{g,e}are passed to_compute_cr2_bm_contrast_dof(the helper PR #465 added for MultiPeriodDiD’s post-period-average DOF). The resulting per-contrast DOF threads intosafe_inference(..., df=<contrast_dof>). MatchesclubSandwich::Wald_test(constraints=matrix(c, 1), test="HTZ")$df_denomat atol=1e-10 (pinned intests/test_methodology_sun_abraham.py). Cohort-level coefficients separately get per-coefficient BM DOF viaLinearRegression.get_inference()inside_fit_saturated_regression. If the linalg helper fails (rank-deficient design, singular bread), the aggregated inference falls back to the shared analytical df with an explicitUserWarning.Deviation from R (HC1 finite-sample correction): SA’s within-transform HC1 SE differs from
fixest::sunab(cluster=~unit)by ~1-2% on typical panel sizes. fixest’s correction counts the absorbed unit + time FE in the effective parameter count (n / (n - k_total)) whereas SA’ssolve_olscounts only the within-transformed design columns (n / (n - k_dm)). The IW aggregation step is otherwise identical. Tracked as a follow-up (harmonizing the correction or documenting it as an intentional difference).Survey designs (
survey_design=) +vcov_type ∈ {"classical","hc2", "hc2_bm"}are rejected at fit-time: the survey-design Taylor Series Linearization (or replicate-weight refit) variance overrides the analytical sandwich family, so the requested HC2/HC2-BM/classical family would be silently discarded. Additionally, the auto-cluster guard for one-way families (classical/hc2) would drop the unit auto-cluster before survey-PSU injection, downgrading the panel structure from unit-level to per-observation PSUs. Mirrors the TWFE Gate 1 + replicate-weight gate from PR #469 and thelinalg.py::_validate_vcov_argshc2_bm + weightsgate. Usevcov_type="hc1"(default) for survey designs; the survey TSL machinery computes the design-aware SE on the within-transform path.Delta method for aggregated coefficients
Optional: Pairs bootstrap for robustness
Edge cases:
Single cohort: reduces to standard event study
Cohorts with no observations at some event-times: weighted appropriately
Extrapolation beyond observed event-times: not estimated
Event-time range: no artificial cap (estimates all available relative times, matching R’s
fixest::sunab())No post-treatment effects: returns
(NaN, NaN)for overall ATT/SE; all inference fields (t_stat, p_value, conf_int) propagate NaN vianp.isfinite()guardsmin_pre_periods/min_post_periodsparameters: removed (previously deprecated withFutureWarning; callers passing these will now getTypeError)Variance fallback: when full weight vector cannot be constructed for overall ATT SE, uses simplified variance (ignores covariances between periods) with
UserWarningRank-deficient design matrix (covariate collinearity):
Detection: Pivoted QR decomposition with tolerance
1e-07(R’sqr()default), with a column-equilibration re-check (unit 2-norm) that makes the rank count invariant to per-column scaling; the dropped-column selection is unchanged for well-scaled collinear designs (a scale-induced under-count instead adopts the scale-corrected equilibrated selection)Handling: Warns and drops linearly dependent columns, sets NA for dropped coefficients (R-style, matches
lm())Parameter:
rank_deficient_actioncontrols behavior: “warn” (default), “error”, or “silent”
NaN inference for undefined statistics:
t_stat: Uses NaN (not 0.0) when SE is non-finite or zero
Analytical inference: p_value and CI also NaN when t_stat is NaN (NaN propagates through
compute_p_valueandcompute_confidence_interval)Bootstrap inference: p_value and CI computed from bootstrap distribution. SE, CI, and p-value are all NaN if the original point estimate is non-finite, SE is non-finite or zero, or if <50% of bootstrap samples are valid
Applies to overall ATT, per-effect event study, and aggregated event study
Note: Defensive enhancement matching CallawaySantAnna behavior; R’s
fixest::sunab()may produce Inf/NaN without warning
Inference distribution:
Cohort-level p-values: t-distribution (via
LinearRegression.get_inference())Aggregated event study and overall ATT p-values:
Under
vcov_type="hc2_bm": t-distribution with CR2 Bell-McCaffrey contrast DOF per aggregated effect (see “Phase 1b aggregated BM contrast DOF” Note above). MatchesclubSandwich::Wald_test( test="HTZ")$df_denom.Under
vcov_type ∈ {"classical","hc1","hc2"}(no replicate-weight survey): normal distribution (viacompute_p_value()), which is asymptotically equivalent and standard for delta-method-aggregated quantities.Under replicate-weight survey: t-distribution with replicate-derived DOF (
survey_metadata.df_survey).
Deviation from R: R’s fixest uses t-distribution at all levels under
vcov_type ∈ {"classical","hc1","hc2"}; aggregated p-values may differ slightly for small samples on those families. Thehc2_bmaggregated path matches clubSandwich exactly.
Reference implementation(s):
R:
fixest::sunab()(Laurent Bergé’s implementation)Stata:
eventstudyinteract
Requirements checklist:
[x] Never-treated units required as controls
[x] Interaction weights sum to 1 within each relative time period
[x] Reference period defaults to e=-1, coefficient normalized to zero
[x] Cohort-specific effects recoverable from results
[x] Cluster-robust SEs with delta method for aggregates
[x] R comparison: ATT matches within machine precision (<1e-11)
[x] R comparison: SE matches within 0.3% (well within 1% threshold)
[x] R comparison: Event study effects match perfectly (correlation 1.0)
[x] Survey design support (Phase 3): weighted within-transform, survey weights in LinearRegression with TSL vcov; bootstrap+survey supported (Phase 6) via Rao-Wu rescaled bootstrap. Replicate weights supported via estimator-level refit (see Replicate Weight Variance section); replicate+bootstrap rejected.
ImputationDiD#
Key implementation requirements:
Assumption checks / warnings:
Parallel trends (Assumption 1):
E[Y_it(0)] = alpha_i + beta_tfor all observations. General form allowsE[Y_it(0)] = alpha_i + beta_t + X'_it * deltawith time-varying covariates.No-anticipation effects (Assumption 2):
Y_it = Y_it(0)for all untreated observations. Adjustable viaanticipationparameter.Treatment must be absorbing:
D_itswitches from 0 to 1 and stays at 1.Covariate space of treated observations must be spanned by untreated observations (rank condition). For unit/period FE case: every treated unit must have ≥1 untreated period; every post-treatment period must have ≥1 untreated unit.
Without never-treated units, long-run effects at horizon
K_it >= H_bar(whereH_bar = max(first_treat) - min(first_treat)) are not identified (Proposition 5). Set to NaN with warning.
Estimator equation (Theorem 2, as implemented):
Step 1. Estimate counterfactual model on untreated observations only (it in Omega_0):
Y_it = alpha_i + beta_t [+ X'_it * delta] + epsilon_it
Step 2. For each treated observation (it in Omega_1), impute:
Y_hat_it(0) = alpha_hat_i + beta_hat_t [+ X'_it * delta_hat]
tau_hat_it = Y_it - Y_hat_it(0)
Step 3. Aggregate:
tau_hat_w = sum_{it in Omega_1} w_it * tau_hat_it
where:
Omega_0 = {it : D_it = 0}— all untreated observations (never-treated + not-yet-treated)Omega_1 = {it : D_it = 1}— all treated observationsw_it= pre-specified weights (overall ATT:w_it = 1/N_1)
Common estimation targets (weighting schemes):
Overall ATT:
w_it = 1/N_1for allit in Omega_1Horizon-specific:
w_it = 1[K_it = h] / |Omega_{1,h}|forK_it = t - E_iGroup-specific:
w_it = 1[G_i = g] / |Omega_{1,g}|
Standard errors (Theorem 3, Equation 7):
Conservative clustered variance estimator:
sigma_hat^2_w = sum_i ( sum_{t: it in Omega} v_it * epsilon_tilde_it )^2
Observation weights v_it:
For treated
(i,t) in Omega_1:v_it = w_it(the aggregation weight)For untreated
(i,t) in Omega_0(FE-only and covariate cases): the exact imputation projectionv_untreated = -A_0 (A_0' A_0)^{-1} A_1' w_treated(survey-weighted, with the left WLS weight factorW_0:-W_0 A_0 (A_0' W_0 A_0)^{-1} A_1' w_treated), whereA_0,A_1are the two-way-FE (all unit dummies + time dummies dropping the first; plus any covariates) design matrices for untreated/treated observations.
Note on v_it derivation: The paper’s Supplementary Proposition A3 gives the explicit v_it^* formula; it is not in the reviewed main-article PDF, so the projection is validated empirically against R didimputation (tests/test_methodology_imputation.py::TestImputationDiDParityR, SEs match to ~1e-10; the covariate branch — first stage y ~ x | unit + time on the untreated sample — is anchored separately by TestImputationDiDCovariateParityR on a time-varying-X panel, SEs ~2e-10). Deviation note (superseded closed form): the FE-only path previously used a closed form -(w_i./n_{0,i} + w_.t/n_{0,t} - w../N_0), which is exact only for a balanced untreated set; because Omega_0 is generically unbalanced in staggered designs (treated observations are removed), that form biased the SE (~27% on the parity panel) and was replaced by the exact projection above during the ImputationDiD methodology validation. A genuinely rank-deficient A_0' A_0 (e.g. an unidentified period FE) routes to a dense least-squares fallback with a UserWarning.
Auxiliary model residuals (Equation 8):
Partition
Omega_1into groupsG_g(default: cohort × horizon)Compute
tau_tilde_gfor each group (weighted average within group)epsilon_tilde_it = Y_it - alpha_hat_i - beta_hat_t [- X'delta_hat] - tau_tilde_g(treated)epsilon_tilde_it = Y_it - alpha_hat_i - beta_hat_t [- X'delta_hat](untreated, i.e., Step 1 residuals)
The aux_partition parameter controls the partition: "cohort_horizon" (default, tightest SEs), "cohort" (coarser, more conservative), "horizon" (groups by relative time only).
Pre-trend test (Test 1, Equation 9):
Y_it = alpha_i + beta_t [+ X'_it * delta] + W'_it * gamma + epsilon_it
Estimate on untreated observations only
Test
gamma = 0via cluster-robust Wald F-testIndependent of treatment effect estimation (Proposition 9)
Pre-period event study coefficients (pretrends=True, Test 1 / Equation 9):
Pre-period coefficients reuse the existing pre-trend test machinery (BJS Equation 9):
Y_it = alpha_i + beta_t [+ X'_it * delta] + sum_h gamma_h * W_it(h) + epsilon_it
where W_it(h) = 1[K_it = h] are lead indicators, estimated on Omega_0 only.
gamma_hare the pre-period event study coefficients (cluster-robust SEs by default; design-based survey VCV when analyticalsurvey_designis present)Under parallel trends (Assumption 1),
gamma_h = 0for allh < -anticipationReference period
h = -1 - anticipationis the omitted category (normalized to zero)SEs from cluster-robust Wald variance by default; design-based when survey present (consistent with
pretrend_test())Bootstrap does not update pre-period SEs (they are from the lead regression)
When
balance_eis set, lead indicators are restricted to balanced cohorts; the full Omega_0 sample (including never-treated) is kept for within-transformationOnly affects event study aggregation; overall ATT and group aggregation unchanged
Note:
pretrends=Truewith analyticalsurvey_design(strata/PSU/FPC) is supported. The lead regression uses survey-weighted demeaning, WLS point estimates, andcompute_survey_vcov()for design-based VCV. The full survey design is preserved (subpopulation approach): Omega_0 scores are zero-padded back to full-panel length so PSU/strata structure is maintained for variance estimation. The F-test inpretrend_test()uses the full-designdf_surveyas denominator df. Replicate-weight survey designs raiseNotImplementedErrorwithpretrends=Truebecause per-replicate lead regression refits are not yet implemented.
Edge cases:
Unbalanced panels: FE estimated via iterative alternating projection (Gauss-Seidel), equivalent to OLS with unit+time dummies. Converges in O(max_iter) passes; typically 5-20 iterations for unbalanced panels, 1-2 for balanced. One-pass demeaning is only exact for balanced panels.
No never-treated units (Proposition 5): Long-run effects at horizons
h >= H_barare not identified. Set to NaN with warning listing affected horizons.Rank condition failure: Every treated unit must have ≥1 untreated period; every post-treatment period must have ≥1 untreated unit. Behavior controlled by
rank_deficient_action: “warn” (default), “error”, or “silent”. Missing FE produce NaN treatment effects for affected observations.Always-treated units: Units with
first_treatat or before the earliest time period have no untreated observations. Warning emitted; these units are excluded from Step 1 OLS but their treated observations contribute to aggregation if imputation is possible.NaN propagation: If all
tau_hatvalues for a given horizon or group are NaN, the aggregated effect and all inference fields (SE, t-stat, p-value, CI) are set to NaN. NaN in v*eps product (from missing FE) is zeroed for variance computation (matching R’s did_imputation which drops unimputable obs).NaN inference for undefined statistics: t_stat uses NaN when SE is non-finite or zero; p_value and CI also NaN. Matches CallawaySantAnna NaN convention.
Pre-trend test: Uses iterative demeaning (same as Step 1 FE) for exact within-transformation on unbalanced panels. One-pass demeaning is only exact for balanced panels.
Overall ATT variance: Weights zero out non-finite tau_hat and renormalize, matching the ATT estimand (which averages only finite tau_hat).
_compute_conservative_variancereturns 0.0 for all-zeros weights, so the n_valid==0 guard is necessary to return NaN SE.balance_ecohort filtering: Whenbalance_eis set, cohort balance is checked against the full panel (pre + post treatment) via_build_cohort_rel_times(), requiring observations at every relative time in[-balance_e, max_h]. Both analytical aggregation and bootstrap inference use the same_compute_balanced_cohort_maskwith pre-computed cohort horizons.Bootstrap clustering: Multiplier bootstrap generates weights at
cluster_vargranularity (defaults tounitifclusternot specified). Invalid cluster column raises ValueError.Non-constant
first_treatwithin a unit: EmitsUserWarningidentifying the count and example unit. The estimator proceeds using the first observed value per unit (via.first()aggregation), but results may be unreliable.treatment_effects DataFrame weights:
weightcolumn uses1/n_validfor finite tau_hat and 0 for NaN tau_hat, consistent with the ATT estimand (unweighted), or normalized survey weightssw_i/sum(sw)whensurvey_designis active.Rank-deficient covariates in variance: Covariates with NaN coefficients (dropped for rank deficiency in Step 1) are excluded from the variance design matrices
A_0/A_1. Only covariates with finite coefficients participate in thev_itprojection.Sparse variance solver: the untreated projection
v_untreated = -A_0 (A_0'[W]A_0)^{-1} A_1'wfactorizes the normal-equations matrix(A_0'[W]A_0)once perfit()viascipy.sparse.linalg.factorizedand reuses the factorization across every estimand target (overall ATT, each event-study horizon, each group, and the bootstrap precompute), solving only the target-specific RHSA_1'wper target – factorize-once / solve-many (the design is target-invariant; onlyweightsvary). This is bit-identical to the prior per-targetscipy.sparse.linalg.spsolvefor a single dense RHS (both use the SuperLU simple driver with the same defaults), built once instead ofO(targets)times. Mirrors the TwoStageDiD GMM-sandwichfactorizedpattern. An exactly singular(A_0'[W]A_0)makesfactorizedraiseRuntimeError; the build falls back to denselstsqand emits aUserWarningonce per fit (silent-failure audit axis C). A defensive per-target non-finite solve likewise routes to denselstsqwith a per-targetUserWarning, so callers always know variance estimates came from the degraded path. The design is built/cached in_build_untreated_projectionand solved per target in_solve_untreated_v.Note: Survey weights enter ImputationDiD via weighted iterative FE (Step 1), survey-weighted ATT aggregation (Step 3), and design-based variance via
compute_survey_if_variance(). PSU clustering, stratification, and FPC are fully supported in the Theorem 3 variance path. Whenresolved_surveyis present, the observation-level influence function (v_it * epsilon_tilde_it) is passed tocompute_survey_if_variance()which applies the stratified PSU-level sandwich with FPC correction. Strata also enters survey df (n_PSU - n_strata) for t-distribution inference. Bootstrap + survey supported (Phase 6) via PSU-level multiplier weights.Bootstrap inference: Uses multiplier bootstrap on the Theorem 3 influence function:
psi_i = sum_t v_it * epsilon_tilde_it. Cluster-level psi sums are pre-computed for each aggregation target (overall, per-horizon, per-group), then perturbed with multiplier weights (Rademacher by default; configurable viabootstrap_weightsparameter to use Mammen or Webb weights, matching CallawaySantAnna). This is a library extension (not in the paper) consistent with CallawaySantAnna/SunAbraham bootstrap patterns.Auxiliary residuals (Equation 8): Implements the paper’s unit-clustered Equation 8 aggregator,
tau_tilde_g = sum_i (sum_{t in G_g,i} v_it)(sum_{t in G_g,i} v_it * tau_hat_it) / sum_i (sum_{t in G_g,i} v_it)^2(Borusyak-Jaravel-Spiess 2024, eq. 8, p. 3272; minimal-excess-variance derivation in Supplementary Appendix A.8): for each unit form the within-unit weight suma_{i,g}and weighted-effect sumb_{i,g}over the unit’s observations in groupg, then combine across units. Groups partitionOmega_1viaaux_partition(default"cohort_horizon"= cohort × event-time; also"cohort"/"horizon"). Unimputable (NaNtau_hat) and off-target observations carryv_it = 0and are excluded from the aggregation — exact for finitetau_hat(a zero-weight row adds 0 to bothaandb) and NaN-safe; a group with no contributing observations falls back to the unweighted group mean (a variance no-op, sincepsi_g = sum_t v_it * eps_tilde_it = 0there).Note (deviation from R): R
didimputation::did_imputationcomputes the auxiliary aggregator assum(v_it^2 * tau_hat_it) / sum(v_it^2)grouped by cohort × event-time only (no partition control is exposed). At that partition each unit contributes at most one observation per group, so the paper’s unit-clustered Equation 8 reduces exactly tosum(v^2 * tau)/sum(v^2)— i.e. diff-diff matches R at the defaultaux_partition="cohort_horizon"(pinned intests/test_methodology_imputation.py::TestImputationDiDParityR). diff-diff additionally offers the coarseraux_partition="cohort"/"horizon"groupings (where a unit may contribute several observations to a group), which have no R analogue and are validated by hand-calculation. Both implement the same paper Equation 8; only the available partition granularity differs. The earlier observation-level meansum(v*tau)/sum(v)(pre-3.5.x) coincided with this only under uniform within-group weights; it was corrected to the exact unit-clustered form during the ImputationDiD methodology validation.Note (leave-one-out variance refinement, Supp. App. A.9): the opt-in
leave_one_out=Trueapplies the Borusyak-Jaravel-Spiess (2024) Supplementary Appendix A.9 finite-sample refinement. The non-LOOtau_tilde_g(eq. 8) is built from the fittedtau_hat_it, which contain the noiseepsilon_it, so it partially overfits and the auxiliary residualsepsilon_tilde_itare too small, biasing the variance downward. LOO recomputes each unit’s group aggregate excluding that unit,tau_tilde_it^LO = sum_{j!=i} v_jg^2 T_jg / sum_{j!=i} v_jg^2, implemented efficiently (A.9) by rescaling each treated residualepsilon_tilde_it^LO = epsilon_tilde_it / (1 - v_ig^2 / sum_j v_jg^2)— wherev_ig = sum_{t in G_g,i} v_itandsum_j v_jg^2are already materialized in_compute_auxiliary_residuals_treatedasa_{i,g}andper_group['den']. That rescale reproduces the direct-LOO per-unit cluster sumpsi_i = sum_t v_it * epsilon_tilde_itexactly (psi_i^rescale = a_{i,g}(T_ig - tau_tilde_g) D/(D - a^2) = psi_i^direct-LOO; a machine-precision-verified identity — paper-fidelity to the sourcetau_tilde_it^LO, not merely internal consistency). At the default unit clustering LOO gives a larger, less-downward-biased SE (Prop. A8: unbiased for an upper bound on the true variance; an equal-weight K-unit group inflates residuals by exactlyK/(K-1)). Defaultleave_one_out=Falsepreserves Rdidimputationparity (which omits LOO). Edge (App. A.9 fn. 51): a group with a single positive-weight unit has an undefined LOO (rescale factor1/0); such groups keep the non-LOO residual and the fit emits one consolidatedUserWarning(a coarseraux_partitionreduces singletons); a genuinely unit-dominated>=2-unit group keeps its large finite factor (intended inflation). Composition scope: the rescale operates onepsilon_tilde, so it flows through the coarser-cluster=CR sum, the analytical survey PSU-TSL variance, and the multiplier bootstrap unchanged — but Prop. A8’s upper-bound guarantee and theLOO >= non-LOOdirection hold at the default unit clustering only (under a coarsercluster=, per-unitpsiinflation can partially cancel), so those compositions are a documented library extension, not paper-derived. Replicate-weight survey designs (BRR / Fay / JK1 / JKn / SDR) raiseNotImplementedErrorwithleave_one_out=True: replicate variance is computed by per-replicate point-estimate refits, bypassing the conservative-IF residual path where the LOO rescale lives, so LOO would silently no-op (fail-closed, no-silent-failures). Effective-singleton guard: a group’s singleton test counts units with positive squared weight (not raw rows), and the leave-one-out denominator is the exact sum of the other units’ squared weights (notD - v_ig^2, which can cancel to<= 0in float64 for an extremely dominated>=2-unit group and would silently revert it to non-LOO). Reference / validation: the authors’ Statadid_imputationships the same option; there is no CI-runnable anchor (Rdidimputationomits LOO, Stata is not in CI), so validation is the exact psi-identity + hand-calc + MC coverage (tests/test_methodology_imputation.py::TestB2024AppendixA9LeaveOneOut). Source: arXiv:2108.12419v5 App. A.9 (the REStud Supplementary Material is canonical); the main-article review’s A.9 GAP is now filled (see theborusyak-jaravel-spiess-2024-review.mdprovenance note). The stronger Prop. A8 variant that also leaves out for thedelta_hatcovariate estimation (exact-unbiased upper bound) is noted but not implemented — the Stata option is thetau_tilderesidual rescale only.Note: The Step-1 iterative FE solver (
_iterative_fe) routes through the shared bincount Gauss-Seidel helperdiff_diff.utils._iterative_fe_solve, and the covariate/pre-trend within-transformation routes through the shared MAP enginediff_diff.utils.demean_by_groups(factorize-once +np.bincount, optional Rust kernel; group order[time, unit]preserving the historical time-then-unit sweep) — the same convergence contract, accumulation-order numerics (~1e-10 vs the pre-3.7 pandas loops, not bit-for-bit), andmax_iter=10_000budget documented under “Absorbed Fixed Effects with Survey Weights”. Both surfaces emitUserWarningviadiff_diff.utils.warn_if_not_convergedwhenmax_iterexhausts without reachingtol(the demean warning now carries the shared-engine label naming the affected variables rather than the estimator name). Silent return of the current iterate was classified as a silent failure under the Phase 2 audit and replaced with an explicit signal to match the logistic/Poisson IRLS pattern inlinalg.py.Note: Zero-total-weight groups (e.g. whole PSUs zeroed by JK1/BRR replicate weights, which reach Step 1 unmasked —
keep_maskonly drops always-treated units): a unit/period whose observations ALL carry zero weight has no identifying contribution and surfaces asNaNFE (key retained for the rank-condition membership check; matches the SpilloverDiD_iterative_fe_subsetREGISTRY contract — never a silent finite0.0), and the shared demean engine’s inert-row guard leaves those rows un-demeaned instead of NaN-poisoning the column. Before 3.7 the pandas loops divided 0/0 there: the covariate replicate path NaN-poisonedy_dm/X_dm, failed EVERY replicate refit insidesolve_ols(check_finite=True), and returned NaN SEs after a non-convergence warning storm; a main fit with zero-weight rows + covariates raised the same opaqueValueError. Both now produce finite results.TwoStageDiD._mask_nan_ytilde’s “non-finite imputed outcomes”UserWarningis suppressed (viawarn_nan=False) ONLY inside the replicate-refit closures, where NaN FE for zeroed PSUs is expected mechanics — the main-fit warning is unchanged.Note:
vcov_typeis permanently narrow to{"hc1"}per the Theorem 3 IF-based variance decomposition. Analytical-sandwich families{classical, hc2, hc2_bm}are rejected at__init__— the per-unit influence function aggregation has no equivalent single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined.cluster=<col>invokes per-cluster IF summation (Theorem 3 equation 7 conservative variance,sigma_sq = (cluster_psi_sums**2).sum()— plain CR1 with no Stata-style(n-1)/(n-p)finite-sample factor because the IF has no design-matrixpin the OLS sense);cluster=None(the default) routes the SAME Theorem 3 cluster-summed IF variance withcluster_var = unit(the unit column passed tofit()), so the summary renders"CR1 cluster-robust at <unit>, G=<n_units>"rather than the generic"HC1"label;survey_design=invokes TSL on the combined IF. Under bootstrap (n_bootstrap > 0) the analytical variance-family label is suppressed insummary()becausefit()overwrites the reported SE/CI/p-value with bootstrap_results (mirrors the canonicalDiDResultsgate atresults.py:213-226).vcov_type='conley'is deferred to the ImputationDiD Conley follow-up row in TODO.md.Note:
cluster=<col>combined with a replicate-weightSurveyDesignraisesNotImplementedErroratfit(). Replicate-weight variance ignores PSU/cluster structure entirely (replicates encode the design implicitly), so honoringcluster=would silently no-op while populatingcluster_name/n_clusterson Results dishonestly. Either omitcluster=(the replicate weights encode the design structure implicitly) or use a non-replicate survey design (with explicit strata/psu/fpc). Mirrors theCallawaySantAnnaandTripleDifferencefail-closed guards.Note: Bootstrap path returns NaN SE when fewer than 2 independent clusters/PSUs are available (
n_clusters < 2for the analytical-cluster bootstrap path,n_psu < 2for the survey-PSU bootstrap path). Without this guard the multiplier bootstrap SE collapses to ≈0 from BLAS roundoff (NOT NaN), and downstream zero-SE guards check exact 0 and miss the degenerate-design case. NaN propagates to all inference fields (SE/CI/p-value) plus per-horizon and per-group bootstrap dicts.
Reference implementation(s):
Stata:
did_imputation(Borusyak, Jaravel, Spiess; available from SSC)R:
didimputationpackage (Kyle Butts)
Requirements checklist:
[x] Step 1: OLS on untreated observations only (never-treated + not-yet-treated)
[x] Step 2: Impute counterfactual
Y_hat_it(0)for treated observations[x] Step 3: Aggregate with researcher-chosen weights
w_it[x] Conservative clustered variance estimator (Theorem 3, Equation 7)
[x] Auxiliary model for treated residuals (unit-clustered Equation 8) with configurable partition (
aux_partition)[x] Leave-one-out finite-sample variance refinement (Supp. App. A.9, opt-in
leave_one_out, default off)[x] Supports unit FE, period FE, and time-varying covariates
[x] Refuses to estimate unidentified estimands (Proposition 5) — sets NaN with warning
[x] Pre-trend test uses only untreated observations (Test 1, Equation 9)
[x] Supports balanced and unbalanced panels (iterative Gauss-Seidel demeaning for exact FE)
[x] Event study and group aggregation
TwoStageDiD#
Primary source: Gardner, J. (2022). Two-stage differences in differences. arXiv:2207.05943.
Note (rank-guarded TSL-variance bread): The Stage-2 variance bread
(X_2'WX_2)^{-1}is inverted by the shared_rank_guarded_inv(diff_diff/linalg.py) on BOTH the analytical (two_stage.py) and multiplier-bootstrap (two_stage_bootstrap.py) surfaces.np.linalg.solveraised only on an exactly-singular bread (prior fallback: denselstsq); a near-singularX_2'WX_2would otherwise flow a garbage inverse (~1e13) into the SE. The rank-guard truncates redundant directions → finite SE on the identified subspace (NaN at rank 0) and warns. A dropped (unidentified) Stage-2 coefficient (event-time / group effect) is reported with NaN SE on both the analytical and bootstrap surfaces — not the zero-filled0.X_2is the Stage-2 indicator design (treatment/event-time/group dummies), not user covariates. See the CallawaySantAnna “rank-guarded IF standard errors” Note. Sibling of axis-A finding #17.
Key implementation requirements:
Assumption checks / warnings:
Parallel trends (same as ImputationDiD):
E[Y_it(0)] = alpha_i + beta_tfor all observations.No-anticipation effects:
Y_it = Y_it(0)for all untreated observations.Treatment must be absorbing:
D_itswitches from 0 to 1 and stays at 1.Always-treated units (treated in all periods) are excluded with a warning, since they have no untreated observations for Stage 1 FE estimation.
Estimator equation (two-stage procedure, as implemented):
Stage 1. Estimate unit + time fixed effects on untreated observations only (it in Omega_0):
Y_it = alpha_i + beta_t + epsilon_it
Compute residuals: y_tilde_it = Y_it - alpha_hat_i - beta_hat_t (for ALL observations)
Stage 2. Regress residualized outcomes on treatment indicators (on treated observations):
y_tilde_it = tau * D_it + eta_it
(or event-study specification with horizon indicators)
Point estimates are identical to ImputationDiD (Borusyak et al. 2024). The two-stage procedure is algebraically equivalent to the imputation approach: both estimate unit+time FE on untreated observations and recover treatment effects from the difference between observed and counterfactual outcomes.
Variance: GMM sandwich (Newey & McFadden 1994 Theorem 6.1):
The variance accounts for first-stage estimation error propagating into Stage 2, following the GMM framework:
V(tau_hat) = (D'D)^{-1} * Meat * (D'D)^{-1} [(D'D)^{-1} = GLOBAL GMM bread (Jacobian inverse)]
Meat = sum_c ( sum_{i in c} psi_i )( sum_{i in c} psi_i )' [score outer-product, clustered at unit]
where psi_i is the stacked influence function for unit i across all its observations, combining the Stage 2 score and the Stage 1 correction term.
Variance is faithful to the paper (global Jacobian inverse). Gardner (2022) §3.3 derives the variance by reading the two stages as a joint GMM estimator (Hansen 1982) and applying Newey & McFadden (1994) Theorem 6.1: v is the last element of E[∂f/∂(λ,γ,β)]^{-1} E[ff'] E[∂f/∂(λ,γ,β)]^{-1'} — the global Jacobian inverse (the (D'D)^{-1} bread above), with the score outer-product E[ff'] clustered at the unit per the reference Stata GMM vce(cluster id) (Appendix B). Our global (D'D)^{-1} bread + unit-clustered meat matches this and the R did2s implementation; there is no per-cluster inverse. (Equation (6) in the paper is the event-study regression specification, not a variance formula — an earlier “Equation 6 per-cluster inverse (D_c'D_c)^{-1}” note was a misattribution, corrected per docs/methodology/papers/gardner-2022-review.md.)
No finite-sample adjustments: The variance estimator uses the raw asymptotic sandwich without degrees-of-freedom corrections (no HC1-style n/(n-k) adjustment). This matches the R did2s implementation.
Bootstrap:
Our implementation uses multiplier bootstrap on the GMM influence function: cluster-level psi sums are pre-computed, then perturbed with multiplier weights (Rademacher by default; configurable via bootstrap_weights parameter to use Mammen or Webb weights, matching CallawaySantAnna). The R did2s package defaults to analytical corrected clustered SEs (bootstrap = FALSE, the same GMM sandwich); its block bootstrap is optional (bootstrap = TRUE, resampling clusters with replacement). All approaches are asymptotically valid; the multiplier bootstrap is computationally cheaper and consistent with the CallawaySantAnna/ImputationDiD bootstrap patterns in this library.
Edge cases:
Always-treated units: Units treated in all observed periods have no untreated observations for Stage 1 FE estimation. These are excluded with a warning listing the affected unit IDs. Their treated observations do NOT contribute to Stage 2.
Rank condition violations: If the Stage 1 design matrix (unit+time dummies on untreated obs) is rank-deficient, or if certain unit/time FE are unidentified (e.g., a unit with no untreated periods after excluding always-treated), the affected FE produce NaN. Behavior controlled by
rank_deficient_action: “warn” (default), “error”, or “silent”.NaN y_tilde handling: When Stage 1 FE are unidentified for some observations, the residualized outcome
y_tildeis NaN. These observations are zeroed out (excluded) from the Stage 2 regression and variance computation, matching the treatment of unimputable observations in ImputationDiD.NaN inference for undefined statistics: t_stat uses NaN when SE is non-finite or zero; p_value and CI also NaN. Matches CallawaySantAnna/ImputationDiD NaN convention.
Event study aggregation: Horizon-specific effects use the same two-stage procedure with horizon indicator dummies in Stage 2. Unidentified horizons (e.g., long-run effects without never-treated units, per Proposition 5 of Borusyak et al. 2024) produce NaN.
Pre-period event study coefficients (
pretrends=True): When enabled, the Stage 2 design matrixX_2includes pre-period relative-time dummies. Pre-period observations havey_tilde = Step 1 residualby construction. The GMM sandwich variance accounts for Stage 1 estimation error (Gardner 2022 §3.3; Newey-McFadden 1994, Theorem 6.1 — the paper has no numbered theorems). Only affects event study aggregation; overall ATT unchanged.balance_e with no qualifying cohorts: If no cohorts have sufficient pre/post coverage for the requested
balance_e, a warning is emitted and event study results contain only the reference period.No never-treated units (Proposition 5): When there are no never-treated units and multiple treatment cohorts, horizons h >= h_bar (where h_bar = max(groups) - min(groups)) are unidentified per Proposition 5 of Borusyak et al. (2024). These produce NaN inference with n_obs > 0 (treated observations exist but counterfactual is unidentified) and a warning listing affected horizons. Matches ImputationDiD behavior. Proposition 5 applies to event study horizons only, not cohort aggregation — a cohort whose treated obs all fall at Prop 5 horizons naturally gets n_obs=0 in group effects because all its y_tilde values are NaN.
Zero-observation horizons after filtering: When
balance_eor NaNy_tildefiltering results in zero observations for some non-Prop-5 event study horizons, those horizons produce NaN for all inference fields (effect, SE, t-stat, p-value, CI) with n_obs=0.Zero-observation cohorts in group effects: If all treated observations for a cohort have NaN
y_tilde(excluded from estimation), that cohort’s group effect is NaN with n_obs=0.Note: Survey weights in TwoStageDiD GMM sandwich via weighted cross-products: bread uses (X’2 W X_2)^{-1}, gamma_hat uses (X’{10} W X_{10})^{-1}(X’_1 W X_2), per-cluster scores multiply by survey weights. PSU clustering, stratification, and FPC are fully supported in the meat matrix via
_compute_stratified_meat_from_psu_scores(). When strata or FPC are present, the meat computation replacesS' Swith the stratified formulasum_h (1 - f_h) * (n_h/(n_h-1)) * centered_h' centered_h. Strata also enters survey df (n_PSU - n_strata) for t-distribution inference. Bootstrap + survey supported (Phase 6) via PSU-level multiplier weights.Note (documented synthesis — Wave E.3 parity, full-domain survey design under always-treated drop): when the always-treated handler drops units that lack untreated observations, TwoStageDiD preserves the FULL-DOMAIN resolved survey design (
n_psu,n_strata,df_survey,strata,fpc,psu) for variance estimation. Per-cluster stage-1 / stage-2 score aggregates are computed at the post-drop fit-sample length and then zero-padded onto the full-domain unique-PSU list viascore_pad_mask+cluster_ids_fullkwargs on_compute_gmm_variance; PSUs that contain only always-treated rows get zero score rows but still count towardG_fullforn_psu/df_surveyaccounting. Stage-1 / stage-2 OLS solve continues to operate on the post-drop sample (survey_weightssubsetted for OLS arithmetic; bread(X'_2 W X_2)^{-1}unchanged because dropped rows contribute zero score under zero-padded weights). Mirrors SpilloverDiD Wave E.3 (PR #482, merge 24de9062) and adopts the canonical “zero-pad scores to full panel + retain full-design resolved survey” convention from Rsurvey::svyrecvar(subset())(Lumley 2010 §2.5 “Domains and subpopulations”) and the in-library precedents atimputation.py:2175-2183(PreTrendsImputation) andprep.py:1401-1432(DCDH cell variance). Cluster-injection (_inject_cluster_as_psu) operates on the FULL-DOMAIN cluster column (sourced fromdatapre-drop, not the post-dropdf) soresolved_survey.strataand the injectedpsuarray stay length-aligned. Pre-PR, the always-treated drop physically subsettedresolved_survey.weights / strata / psu / fpc / replicate_weightsviareplace(resolved_survey, ...)and recomputedn_psu/n_strataon the post-drop sample, producing artificially-deflateddf_surveywhen a PSU contained only always-treated rows; tests attests/test_two_stage.py::TestTwoStageDiDWaveE3ParityAlwaysTreatedlock the parity contract.Note: The Stage-1 iterative FE solver (
_iterative_fe) routes through the shared bincount Gauss-Seidel helperdiff_diff.utils._iterative_fe_solve, and the covariate within-transformation routes through the shared MAP enginediff_diff.utils.demean_by_groups(factorize-once +np.bincount, optional Rust kernel; group order[time, unit]preserving the historical time-then-unit sweep) — the same convergence contract, accumulation-order numerics (~1e-10 vs the pre-3.7 pandas loops, not bit-for-bit), andmax_iter=10_000budget documented under “Absorbed Fixed Effects with Survey Weights”. Both surfaces emitUserWarningviadiff_diff.utils.warn_if_not_convergedwhenmax_iterexhausts without reachingtol(the demean warning now carries the shared-engine label naming the affected variables rather than the estimator name). Silent return of the current iterate was classified as a silent failure under the Phase 2 audit and replaced with an explicit signal to match the logistic/Poisson IRLS pattern inlinalg.py.Note: Zero-total-weight groups (e.g. whole PSUs zeroed by JK1/BRR replicate weights, which reach Stage 1 unmasked —
keep_maskonly drops always-treated units): a unit/period whose observations ALL carry zero weight surfaces asNaNFE (key retained for the rank-condition membership check; matches the SpilloverDiD_iterative_fe_subsetREGISTRY contract — never a silent finite0.0), and the shared demean engine’s inert-row guard leaves those rows un-demeaned instead of NaN-poisoning the column. Before 3.7 the pandas loops divided 0/0 there: the covariate replicate path NaN-poisonedy_dm/X_dm, failed EVERY replicate refit insidesolve_ols(check_finite=True), and returned NaN SEs after a non-convergence warning storm. It now produces finite replicate SEs._mask_nan_ytilde’s “non-finite imputed outcomes”UserWarningis suppressed (viawarn_nan=False) ONLY inside the replicate-refit closures, where NaN FE for zeroed PSUs is expected mechanics — the main-fit warning is unchanged.Note: When the Stage-2 bread
X'_2 W X_2is singular, both the analytical TSL variance (two_stage.py) and the multiplier-bootstrap bread (two_stage_bootstrap.py) now emit aUserWarningbefore falling back tonp.linalg.lstsq. Previously this fallback was silent. Sibling of axis-A finding #17 in the Phase 2 silent-failures audit; surfaced by the repo-wide lstsq-fallback pattern grep that accompanied the StaggeredTripleDifference fix.Note: The GMM sandwich and bootstrap paths both use
scipy.sparse.linalg.factorizedfor the Stage 1 normal-equations solve(X'_{10} W X_{10}) gamma = X'_1 W X_2and fall back to denselstsqwhen the sparse factorization raisesRuntimeErroron a near-singular matrix. Both fallback sites emit aUserWarning(silent-failure audit axis C) so callers know SE estimates came from the degraded path rather than the fast sparse path.Note: The GMM sandwich re-solves the Stage-1 unit+time fixed effects exactly (sparse OLS reusing the
scipy.sparse.linalg.factorizedfactorization of(X'_{10} W X_{10})already computed forgamma_hat), rather than reusing the iterative alternating-projection FE (_iterative_fe) that produces the point estimate. The iterative solver converges only to ~1e-7 on unbalanced untreated panels — negligible for the ATT, but enough to perturb the variance by ~1% relative to the analytical GMM sandwich. The exact re-solve makes the analytical GMM SE match Rdid2sto ~1e-7 (tests/test_methodology_two_stage.py::TestTwoStageDiDParityR), mirroring ImputationDiD’s exact-sparse variance path (imputation.py_build_A_sparse). It also required adding an intercept column to_build_fe_designso the first-stage column space spans the constant (the grand mean): the prior intercept-free[unit_1.., time_1..]layout (drop first unit + first time, no intercept) silently omitted the grand mean, which the exact residual is first-order sensitive to (the iterative point-estimate solver absorbs the grand mean into its mean-based FE, so the point estimate was unaffected). Obs whose unit or time FE are unidentified (NaN; rank-deficient / Proposition-5) fall back to the iterative residual, so those edge cases are unchanged. The reportedoverall_attstill uses the iterative FE (preserving point-estimate equivalence with ImputationDiD at 1e-10); only the variance uses the exact residuals.Note:
vcov_typeis permanently narrow to{"hc1"}(Phase 1b threading). TwoStageDiD’s variance is the Gardner (2022) two-stage GMM cluster-sandwichV = (X'_2 W X_2)^{-1} (S' S) (X'_2 W X_2)^{-1}with the per-cluster GMM-corrected scoreS_g = gamma_hat' c_g - X'_{2g} eps_{2g}. Analytical-sandwich families{classical, hc2, hc2_bm}are rejected at__init__/fit(): the GMM-corrected meat folds first-stage FE estimation uncertainty into the score via thegamma_hat' c_gterm, so there is no single hat matrix spanning both stages on which HC2 leverage or Bell-McCaffrey Satterthwaite DOF can be defined, and the Gardner first-stage correction has not been derived for the leverage-corrected or homoskedastic meat structures (no reference implementation —clubSandwichcovers single-equation WLS/OLS CR2, not two-stage GMM; mirrors the SpilloverDiDvcov_type="classical"rejection).cluster=<col>selects the cluster level;cluster=None(the default) still clusters at theunitcolumn (cluster_var = unit), so the summary renders"CR1 cluster-robust at <unit>, G=<n_units>"rather than the generic"HC1"label. Note (deviation from R): the did2s GMM sandwich uses NO finite-sample multiplier (meat= S' S), so the renderedCR1family label carries no Stata-style(n-1)/(n-p)orG/(G-1)factor (matches Rdid2s; same FSA-free convention as ImputationDiD’s Theorem 3 variance). Under bootstrap (n_bootstrap > 0) the analytical variance-family label is suppressed insummary()becausefit()overwrites the reported SE/CI/p-value withbootstrap_results(mirrorsDiDResultsatresults.py:213-226).cluster=<col>combined with a replicate-weight survey design raisesNotImplementedError(replicate-refit variance ignorescluster=).vcov_type='conley'is deferred to the TwoStageDiD Conley follow-up row in TODO.md.
Reference implementation(s):
R:
did2s::did2s()(Kyle Butts & John Gardner)
Requirements checklist:
[x] Stage 1: OLS on untreated observations only for unit+time FE
[x] Stage 2: Regress residualized outcomes on treatment indicators
[x] Point estimates match ImputationDiD
[x] GMM sandwich variance (Newey & McFadden 1994 Theorem 6.1)
[x] Global
(D'D)^{-1}in variance (faithful to Gardner §3.3 / Newey-McFadden GMM sandwich; matches Rdid2s)[x] No finite-sample adjustment (raw asymptotic sandwich)
[x] Analytical GMM SE matches R
did2sto ~1e-7 (exact Stage-1 re-solve;TestTwoStageDiDParityR)[x] Always-treated units excluded with warning
[x] Multiplier bootstrap on GMM influence function
[x] Event study and overall ATT aggregation
StackedDiD#
Primary source: Wing, C., Freedman, S. M., & Hollingsworth, A. (2024). Stacked Difference-in-Differences. NBER Working Paper 32054. http://www.nber.org/papers/w32054
Key implementation requirements:
Assumption checks / warnings:
Assumption 1 (No Anticipation): ATT(a, a+e) = 0 for all e < 0
Assumption 2 (Common Trends): E[Y_{s,a+e}(0) - Y_{s,a-1}(0) | A_s = a] = E[Y_{s,a+e}(0) - Y_{s,a-1}(0) | A_s > a + e]
Clean controls must exist for each sub-experiment (IC2)
Event window must fit within observed data range (IC1)
Target parameter (Equation 2):
theta_kappa^e = sum_{a in Omega_kappa} ATT(a, a+e) * (N_a^D / N_Omega_kappa^D)
where:
theta_kappa^e= trimmed aggregate ATT at event time eOmega_kappa= trimmed set of adoption events satisfying IC1 and IC2N_a^D= number of treated units in sub-experiment aN_Omega_kappa^D= total treated units across all sub-experiments in trimmed set
Estimator equation (Equation 3 — weighted saturated event study, recommended):
Y_sae = alpha_0 + alpha_1 * D_sa + sum_{h != -1} [lambda_h * 1(e=h) + delta_h * D_sa * 1(e=h)] + U_sae
Estimated via WLS with Q-weights. The delta_h coefficients identify theta_kappa^e.
Q-weights (Section 5.3, Table 1):
Q_sa = 1 if D_sa = 1 (treated)
Q_sa = (N_a^D / N^D) / (N_a^C / N^C) if D_sa = 0 (control, aggregate weighting)
Q_sa = (Pop_a^D / Pop^D) / (N_a^C / N^C) if D_sa = 0 (control, population weighting)
Q_sa = ((N_a + N_a^C)/(N^D+N^C)) / (N_a^C/N^C) if D_sa = 0 (control, sample share weighting)
Standard errors (Section 5.4):
Default: Cluster-robust standard errors at the group (unit) level
Alternative: Cluster at group x sub-experiment level
Both approaches yield approximately correct coverage when clusters > 100 (Table 2)
No special bootstrap procedure specified; standard cluster-robust SEs recommended
For post-period average: delta method or
lincom/marginaleffects
Variance families (vcov_type):
hc1(default) — CR1 Liang-Zeger cluster-robust viasolve_ols(weights=composed_weights, vcov_type="hc1")(Stata-styleG/(G-1) * (n-1)/(n-p)finite-sample correction). MatchesclubSandwich::vcovCR(lm(weights=Q,...), cluster=~unit, type="CR1S")at atol=1e-10 on the newbenchmarks/data/stacked_did_golden.jsonfixture (R-side target isCR1Snot plainCR1— the latter omits the(n-1)/(n-p)term and diverges by ~1.4% on a 325-row, 10-column stacked design). Bit-equal to the prior bake-Q-into-X output up to float64 multiplication ordering (atol=1e-13; WLS-CR1 score is algebraically invariant between the two forms).hc2_bm— CR2 Bell-McCaffrey viasolve_ols(weights=composed_weights, vcov_type="hc2_bm")routed through the clubSandwich WLS-CR2 port (PR #475). The diff-diff implementation matches clubSandwich’s specific algebra (W not √W in the hat matrix, W² in the bias-correction term, unweighted residuals in the score construction) — see Phase 1ahc2_bm + weightsregistry row for the full derivation. MatchesclubSandwich::vcovCR(lm(weights=Q,...), cluster=~unit, type="CR2") + coef_test()$df_Sattat atol=1e-10 (pinned intests/test_methodology_stacked_did.py). Bell-McCaffrey Satterthwaite DOF is threaded into the user-facing aggregated inference (event_study_effects[h]['p_value']/['conf_int']use the per-event-time contrast DOF from_compute_cr2_bm_contrast_dof;overall_p_value/overall_conf_intuse the post-period-average contrast DOF, matching RWald_test(constraints=row, vcov=CR2, test="HTZ")$df_denomat atol=1e-10). Mirrors the SunAbraham aggregated-inference pattern from PR #472.classical,hc2— REJECTED at__init__withValueError. StackedDiD clusters intrinsically at'unit'or'unit_subexp'(nocluster=Noneopt-out); the linalg validator rejects one-way families paired withcluster_ids. Usevcov_type='hc1'or'hc2_bm'.conley— REJECTED at__init__withValueError. Deferred for a methodology reason, NOT plumbing (unlike the SunAbraham / WooldridgeDiD-OLS conley threading): the stacked design replicates each control unit across every sub-experiment it qualifies for (_build_sub_experiment), so one geographic unit occupies many stacked rows. Conley’s pairwise distance matrix would see those same-unit copies at distance 0 (K(0)=1), conflating the stacking-replication device with real spatial correlation; a correct treatment needs a per-stack spatial identifier and has noconleyreganalogue to anchor parity. Paper-gated; tracked inTODO.md.survey_design=+vcov_type ∈ {classical, hc2, hc2_bm}— REJECTED atfit()withNotImplementedError; the survey TSL or replicate-weight refit variance overrides the analytical sandwich. Usevcov_type='hc1'(default) for survey designs. Reject order: the existing fweight/aweight check atstacked_did.py:309fires first (Q-weight ratio semantics), then the survey + non-hc1 check atstacked_did.py:~325— locked bytest_aweight_plus_hc2_bm_rejected_by_stacked_did_level_guard.
Note: This routing inherits the WLS-CR2 methodology choice from the Phase 1a clubSandwich port (PR #475 / REGISTRY Phase 1a hc2_bm + weights row); see that row for the full PT2018-§3.3-vs-clubSandwich algebra deviation derivation. No new methodology choice is introduced in this PR. The change is purely surface: switching from the prior bake-Q-into-X pattern (X_t = X * sqrt(Q), solve_ols(X_t, Y_t, cluster_ids=)) to solve_ols(weights=composed_weights, vcov_type=...) opens the hc2_bm path without modifying the small-sample WLS-CR2 algebra. The HC1 path is preserved bit-equal (up to float64 multiplication ordering at machine precision).
Edge cases:
All events trimmed:
len(Omega_kappa) == 0-> ValueError suggesting reduced kappaNo clean controls for event a: IC2 check fails -> Trim event, warn user
Single cohort in trimmed set: Valid — Q-weights simplify
Duplicate observations: Same (unit, time) appears in multiple sub-experiments -> handled by clustering at unit level
Constant treatment share across sub-exps: Unweighted FE recovers correct estimand (special case, Section 5.5)
Anticipation > 0: Reference period shifts to e = -1 - anticipation. Post-treatment includes anticipation periods (e >= -anticipation). Window expands by anticipation pre-periods.
Group aggregation: Not supported — pooled stacked regression cannot produce cohort-specific effects. Use CallawaySantAnna or ImputationDiD.
Algorithm (Section 5):
Choose kappa_pre, kappa_post event window
Apply IC1 (window fits in data) and IC2 (clean controls exist) to get Omega_kappa
For each a in Omega_kappa: build sub-experiment with treated (A_s = a), clean controls (A_s > a + kappa_post), time window [a - kappa_pre, a + kappa_post] (with anticipation: [a - kappa_pre - anticipation, a + kappa_post])
Stack all sub-experiments vertically
Compute Q-weights: aggregate weighting uses observation counts per (event_time, sub_exp), matching R reference. Population/sample_share use unit counts per sub_exp (paper notation).
Run WLS regression of Equation 3 with Q-weights
Extract delta_h coefficients as event-study ATTs
Compute cluster-robust SEs at unit level
IC1 (Adoption Event Window, Section 3):
IC1_a = 1[a - kappa_pre >= T_min AND a + kappa_post <= T_max]
Note: Matches R reference implementation (focalAdoptionTime - kappa_pre >= minTime).
The reference period a-1 is included in the window [a-kappa_pre, a+kappa_post] when kappa_pre >= 1.
The paper text states a stricter bound (T_min + 1) but the R code by the co-author uses T_min.
IC2 (Clean Controls Exist, Section 3):
IC2_a = 1[exists s with A_s > a + kappa_post] (not_yet_treated)
IC2_a = 1[exists s with A_s > a + kappa_post + kappa_pre] (strict)
IC2_a = 1[exists s with A_s = infinity] (never_treated)
Reference implementation(s):
R: https://github.com/hollina/stacked-did-weights (
create_sub_exp(),compute_weights())No Stata or Python package; Stata estimation via standard
reghdfewith Q-weight column
Requirements checklist:
[x] Sub-experiment construction with treated + clean controls + time window
[x] IC1 and IC2 trimming with warnings
[x] Q-weight computation for all three weighting schemes (Table 1)
[x] WLS via sqrt(w) transformation
[x] Event study regression (Equation 3) with reference period e=-1
[x] Cluster-robust SEs at unit or unit x sub-exp level
[x] Overall ATT as average of post-treatment delta_h with delta-method SE
[x] Anticipation parameter support
[x] Never-treated encoding (0 and inf)
[x] Survey design support (Phase 3): Q-weights compose multiplicatively with survey weights; TSL vcov on composed weights; survey design columns propagated through sub-experiments. Replicate weights supported via estimator-level refit with Q-weight composition (see Replicate Weight Variance section).
Note: Survey weights compose multiplicatively with Q-weights for StackedDiD; only
weight_type="pweight"(default) is supported —fweightandaweightare rejected because Q-weight composition changes weight semantics (non-integer for fweight, non-inverse-variance for aweight)
Covariate balancing (CBWSDID)#
Primary source: Ustyuzhanin, V. (2026). Covariate-Balanced Weighted Stacked Difference-in-Differences. arXiv:2604.02293v1. https://arxiv.org/abs/2604.02293 (in-repo paper review: docs/methodology/papers/ustyuzhanin-2026-review.md). Within-sub-experiment refinement via entropy balancing (Hainmueller 2012).
Optional balance="entropy" (constructor) + fit(..., covariates=[...]) adds a within-sub-experiment design stage that reweights the clean controls toward the treated cohort under conditional parallel trends, before the Wing et al. (2024) corrective aggregation.
Design weights (Ustyuzhanin 2026 §3.1):
For each sub-experiment a, entropy balancing produces nonnegative control weights b_{sa} matching the treated covariate means (covariates read at the last pre-treatment period t = a-1-anticipation; treated keep b=1). The final stacked weights compose b_{sa} with the corrective factor via the effective control mass Ñ^C_a = Σ_{s∈C_a} b_{sa}:
W_{sa} = b_{sa} · (N^D_a / N^D_Ω) / (Ñ^C_a / Ñ^C_Ω) for s ∈ C_a
W_{sa} = 1 for s ∈ D_a
The pooled estimator is DID^{CBWSDID}_e = Σ_a (N^D_a/N^D_Ω)(Δ̄^D_{a,e} − Δ̄^{C,b}_{a,e}), recovered by the existing Q-weighted WLS when W_{sa} is injected. Estimand preservation: because only controls are reweighted (treated cohorts and their shares N^D_a/N^D_Ω are unchanged), the target remains the trimmed aggregate ATT θ^e_κ — the refinement changes only how untreated trends are estimated. At b_{sa}=1 this reduces to the paper’s unit-count weighted stacked DID, which equals StackedDiD(weighting="aggregate") on balanced event windows (where unit and observation counts coincide). Validated end-to-end by tests/test_methodology_stacked_did.py::TestCBWSDIDCovariateBalance (closed-form DID^{CBWSDID}_e anchor at 1e-8), ::TestCBWSDIDEffectiveMass (effective-mass corrector is load-bearing vs a naive b·Q multiply), and ::TestCBWSDIDRParity (cross-language parity vs the R cbwsdid package, refinement.method="weightit" / method="ebal", at 1e-5 — golden in benchmarks/data/cbwsdid_golden.json, regenerate via benchmarks/R/generate_cbwsdid_golden.R).
Note: The effective-mass
W_{sa}is computed directly from cohort unit-counts +Ñ^C_a(a naiveb_{sa}·Q_aggregatemultiply is NOT equivalent — it aggregates control means with weights ∝(N^D_a/N^D_Ω)(Ñ^C_a/N^C_a)instead of the required∝ (N^D_a/N^D_Ω), biased unlessbis uniform).Note: Inference is conditional-on-the-estimated-weights cluster-robust (the existing
hc1/hc2_bmpath withW_{sa}as the WLS weights) — the paper’s default. The paper’s weight-re-estimating bootstrap is NOT implemented in v1 (deliberate scope; entropy balancing is smooth so the Abadie–Imbens (2008) nonsmooth-matching bootstrap caveat does not apply).clusteris orthogonal tob_{sa}(weights conditioned-on); defaultunitmatches the paper.Note: v1 scope — only
balance="entropy"withweighting="aggregate".balance+population/sample_shareandbalance+survey_design=raiseNotImplementedError; matching-based balancing and the repeated0→1/1→0episode extension are out of scope.
Covariate-balancing edge cases:
Infeasible cohort (treated covariate mean outside the clean-control hull → entropy balancing cannot match the moments): fail-closed
ValueErrornaming the cohort and worst covariate — NOT silently dropped (dropping a cohort would shift the estimand to an overlap-trimmed ATT, Ustyuzhanin 2026 §3.1).Degenerate design weights (
Ñ^C_a → 0/ highly concentrated weights): low effective sample size →UserWarningwith the per-cohort diagnostic.Missing pre-treatment row, or covariate absent /
balance↔covariatesmismatch:ValueErroratfit().Ragged / unbalanced event windows (a unit not observed at every event time in a sub-experiment): fail-closed
ValueError—balance="entropy"requires balanced windows. The paper assumes balanced event windows; off them the unit-count corrector and the observation-countaggregateQ diverge (the count-convention is unresolved, deferred).balance="none"continues to support unbalanced panels via observation-count Q.
WooldridgeDiD (ETWFE)#
Primary source: Wooldridge, J. M. (2025). Two-way fixed effects, the two-way Mundlak regression, and difference-in-differences estimators. Empirical Economics, 69(5), 2545–2587. (Published version of the 2021 SSRN working paper NBER WP 29154.)
Secondary source: Wooldridge, J. M. (2023). Simple approaches to nonlinear difference-in-differences with panel data. The Econometrics Journal, 26(3), C31–C66. https://doi.org/10.1093/ectj/utad016
Application reference: Nagengast, A. J., Rios-Avila, F., & Yotov, Y. V. (2026). The European single market and intra-EU trade: an assessment with heterogeneity-robust difference-in-differences methods. Economica, 93(369), 298–331.
Reference implementation: Stata: jwdid package (Rios-Avila, 2021). R: etwfe package (McDermott, 2023).
Key implementation requirements:
Core estimand:
ATT(g, t) = E[Y_it(g) - Y_it(0) | G_i = g, T = t] for t >= g
where g is cohort (first treatment period), t is calendar time.
OLS design matrix (Wooldridge 2025, Section 5):
The saturated ETWFE regression includes:
Unit fixed effects (absorbed via within-transformation or as dummies)
Time fixed effects (absorbed or as dummies)
Cohort×time treatment interactions:
I(G_i = g) * I(T = t)for each post-treatment (g, t) cellAdditional covariates X_it interacted with cohort×time indicators (optional)
The interaction coefficient δ_{g,t} identifies ATT(g, t) under parallel trends.
Note: OLS path uses iterative alternating-projection within-transformation (uniform weights) for exact FE absorption on both balanced and unbalanced panels. One-pass demeaning (
y - ȳ_i - ȳ_t + ȳ) is only exact for balanced panels.Note: The weighted within-transformation (
utils.within_transformwithweights) is invoked on every WooldridgeDiD fit (survey weights when provided,np.onesotherwise) and emits aUserWarningon non-convergence per the shared convention documented under Absorbed Fixed Effects with Survey Weights.Note: NaN values in the
cohortcolumn are filled with 0 (treated as never-treated), both in_filter_sampleand infit(). This recategorization now emits aUserWarningreporting the affected row count so it is no longer silent (axis-E silent coercion under the Phase 2 audit). Pass0directly for never-treated units to avoid the warning.
Nonlinear extensions (Wooldridge 2023):
For binary outcomes (logit) and count outcomes (Poisson), Wooldridge (2023) provides an Average Structural Function (ASF) approach. For each treated cell (g, t):
ATT(g, t) = mean_i[g(η_i + δ_{g,t}) - g(η_i)] over units i in cell (g, t)
where g(·) is the link inverse (logistic or exp), η_i is the individual linear predictor
(fixed effects + controls), and δ_{g,t} is the interaction coefficient from the nonlinear model.
Note (outcome-fit hint): When
method="ols"is used on a binary ({0,1}) or a non-negative integer count outcome,WooldridgeDiD.fit()emits aUserWarningnoting that a matching nonlinear model (method="logit"/method="poisson") is often the more appropriate specification for such outcomes. The framing is the paper’s, not an efficiency heuristic: the nonlinear paths impose parallel trends on the link/index scale (logit index / Poisson log-mean) rather than in levels, and Wooldridge (2023) states the linear-PT assumption is only valid for continuous/unbounded outcomes (Eq. 2.5 vs the Index-PT Eq. 2.6–2.7). In the paper’s Section 5 simulations the linear model is both biased (POLS −0.15 to −0.29 for binary; OLS >30%, >50% in places, for counts) and less precise (nonlinear SEs ~30–70% smaller) where the nonlinear mean holds, and the paper notes pre-trends tests often fail to detect the linear model’s misspecification. This is a different identifying assumption than linear OLS — which one fits depends on which parallel-trends restriction holds — so the warning frames the nonlinear model as a recommended comparison, not an automatic switch or a free upgrade. Per Table 1, Gaussian/OLS remains a valid QMLE for any response, so the hint never asserts OLS is mechanically wrong. Framing provenance (two-sided): the earlier “canonical link requirement tied to Prop 3.1” reading was incorrect (corrected in PR #453 R1); the warning is equally careful not to over-correct into framing the nonlinear path as a pure efficiency add-on, since the paper’s case is primarily about bias / appropriateness (codex R1, reconciled againstdocs/methodology/papers/wooldridge-2023-review.md§5). Detection is deliberately high-signal — binary requires support exactly{0,1}; the count branch suggests Poisson for non-negative integers with>2distinct values (Poisson is the natural unbounded nonnegative/count model per Table 1; bounded-support / binomial outcomes with a known upper bound are not separately detected — that routing would need user-supplied metadata). Fractional / bounded-continuous outcomes are not flagged by this heuristic (fractional responses are themselves a logistic/Bernoulli case in Table 1; the heuristic simply does not attempt to detect them — a coverage limit, not a claim that OLS is preferred for such outcomes). Detection sample: the hint reads the full outcome column passed tofit()— outcome type is a property of the variable, so the named column is the right signal. In this estimator that column is also identical to the estimation sample:WooldridgeDiD._filter_sampleexpresses the control-group choice through the design matrix (_build_interaction_matrix), not by dropping rows, so the full column and the fitted sample always share the same outcome support (invariant pinned bytests/test_wooldridge.py::TestOutcomeFitHint::test_filter_sample_preserves_outcome_support). Classifying on the variable keeps the suggestion methodologically correct (a count is a count regardless of which rows enter estimation) should that selection ever change. Always-on (suppress viawarnings.filterwarnings), implemented as the pure helper_suggest_nonlinear_method+ the OLS-only fit-time gate0g; it never alters the fit or raises.
Standard errors:
OLS: Cluster-robust sandwich estimator at the unit level (default)
Logit/Poisson: QMLE sandwich
(X'WX)^{-1} meat (X'WX)^{-1}viacompute_robust_vcov(..., weights=w, weight_type="aweight")wherew = p_i(1-p_i)for logit orw = μ_ifor PoissonDelta-method SEs for ATT(g,t) from nonlinear models:
Var(ATT) = ∇θ' Σ_β ∇θJoint delta method for overall ATT:
agg_grad = Σ_k (w_k/w_total) * ∇θ_kDeviation from R: R’s
etwfepackage usesfixestfor nonlinear paths; this implementation uses direct QMLE viacompute_robust_vcovto avoid a statsmodels/fixest dependency.Note: QMLE sandwich uses
weight_type="aweight"which applies(G/(G-1)) * ((n-1)/(n-k))small-sample adjustment. StatajwdidusesG/(G-1)only. The(n-1)/(n-k)term is conservative (inflates SEs slightly). For typical ETWFE panels where n >> k, the difference is negligible.
Variance families (vcov_type, OLS path only):
hc1(default) — CR1 Liang-Zeger cluster-robust on the within-transformed design. Bit-equal to prior behavior (FWL preserves the score). The natural R anchor isfixest::feols(y ~ <interactions> | unit + time, cluster=~unit)or Statajwdid(both within-transform). Deviation from Rlm + clubSandwich::vcovCR(type="CR1S"): the full-dummylmSE differs by a factor ofsqrt((n - k_within) / (n - k_total))because clubSandwich’s(n-1)/(n-p)finite-sample correction counts ALL columns (intercept + treatment + unit dummies + time dummies =k_total) while WooldridgeDiD’ssolve_olson the within-transformed design counts only the treatment-cell columns (k_within). On the 240-obs / 51-column R-parity fixture this is ~11%; on typical larger panels (n >> k_total) the gap shrinks to <2%. No public WooldridgeDiD code path exposes thelm + CR1S(CR1 cluster-robust on the full-dummy design) finite-sample correction —vcov_type="hc2_bm"routes to the CR2 Bell-McCaffrey sandwich on the full-dummy design (different variance estimator entirely), not CR1S. Users who need exactlm + clubSandwich::vcovCR(type="CR1S")parity must callsolve_olsdirectly on a full-dummy design or fit via R. Same deviation pattern as SunAbraham PR #472 (fixest::sunabvslm + clubSandwich).hc2_bm— CR2 Bell-McCaffrey via auto-route to full-dummy design ([intercept, X_design, unit_dummies, time_dummies]), thensolve_ols(..., vcov_type="hc2_bm")through the clubSandwich port (PR #475). FWL does NOT preserve the hat matrix; HC2 leverage + BM DOF require the full-projection design. Per-coefficient SE matchesclubSandwich::vcovCR(lm(...), cluster=~unit, type="CR2")at atol=1e-10. Per-cell(g, t)inference fields usecoef_test()$df_SattBell-McCaffrey DOF (pinned at atol=1e-6 from CI half-width inversion). Aggregated inference (overall ATT +.aggregate("group" | "calendar" | "event")) uses contrast-specific BM DOFs from_compute_cr2_bm_contrast_dof(matches RWald_test(constraints=matrix(w, 1), vcov=vcov_CR2, test="HTZ")$df_denom); the overall ATT contrast DOF is computed at fit time, the other three aggregations lazily on each.aggregate(...)call from BM artifacts (the REDUCED kept-columnX/cluster_ids/ bread matrix + the reduced-space coef-index map) stored on the Results object — using the reduced design after rank-deficient drops keeps the bread non-singular and matches the subspacesolve_olsactually estimated in. Fail-closed across all surfaces: when BM DOF is unavailable (helper raises or returns non-finite), the affected inference fields are NaN — not normal-theory fallback (perfeedback_bm_contrast_dof_fail_closed).classical,hc2— supported via auto-route to full-dummy AND auto-drop of the unit auto-cluster (one-way families don’t compose withcluster_idsper the linalg validator). Setself.cluster=None(default) for these; explicitcluster="state"+ one-way family raises at the linalg validator. SE matchessummary(lm(...))$coefficients(classical) andsandwich::vcovHC(type="HC2")respectively. Per-cell + aggregate p-values/CIs use the residual DOFn - rank(X)(matches Rlm()/coef_test()t-distribution under both classical OLS SE andsandwich::vcovHCdefaults) — not normal-theory, so inference is correct under small samples.conley(spatial-HAC, Conley 1999) — supported on the OLS path via the within-transform design (or the full-dummy design whencohort_trends=True, like the other full-dummy families — see the cohort-trends row below), threading theconley_*params throughsolve_ols/conley.py(conley_lag_cutoff=0= within-period spatial only;>0adds within-unit Bartlett serial — the panel-aware path, sinceconley_time/conley_unitare always supplied, not pooled cross-sectional). Reuses the already-conleyreg-validated machinery (no new variance code). The unit auto-cluster is dropped on the conley path (an explicitcluster=enables the spatial+cluster product kernel);survey_design=/weights/n_bootstrap>0are rejected, andmethod ∈ {logit, poisson}+ conley remains rejected (themethod != "ols"guard — a QMLE-on-pseudo-residuals Conley sandwich is a separate derivation). FWL-composability (the within-transform conley SE equals the full-dummy conley SE) is pinned intests/test_conley_vcov.py::TestConleyWooldridge::test_fwl_composability_vs_full_dummy.method ∈ {"logit","poisson"}+vcov_type != "hc1"— REJECTED at__init__. GLM QMLE sandwich with HC2 leverage on canonical-link pseudo-residuals (w = p(1-p)for logit,w = μ_ifor Poisson) needs CR2-BM-on-GLM derivation + R parity againstclubSandwich::vcovCR(glm(...)). Tracked in TODO.md (WooldridgeDiD logit/poisson follow-up row).survey_design=+vcov_type != "hc1"— REJECTED atfit()withNotImplementedError. Survey TSL/replicate-refit overrides analytical sandwich. Usevcov_type="hc1"(default) for survey designs.n_bootstrap > 0+vcov_type ∈ {"hc2","classical"}— REJECTED atfit()regardless ofself.clustersetting. The multiplier bootstrap is intrinsically clustered, but one-way vcov_type does not compose withcluster_ids: withcluster=Nonethe auto-cluster is dropped (bootstrap has no cluster to draw weights at); withcluster=Xthe linalg validator rejects one-way + cluster_ids downstream with a less-informative error. User must drop bootstrap (n_bootstrap=0) or pick a cluster-compatiblevcov_type(hc1orhc2_bm).Note: This routing is a documented synthesis of two existing methodology ingredients: the full-dummy auto-route from the Phase 1b PR 1/8 SunAbraham pattern (PR #472, which itself reused the Phase 1a Gate 1 TWFE lift from PR #469), and the clubSandwich WLS-CR2 algebra from the Phase 1a port (PR #475). The BM contrast DOF threading reuses
_compute_cr2_bm_contrast_doffrom PR #465 (MPD). No new methodology choice is introduced — the change is purely surface: extending the existing pattern from SA-OLS to WooldridgeDiD-OLS.Note: Bootstrap is supported only with
vcov_type ∈ {"hc1","hc2_bm"}(one-wayclassical/hc2+ bootstrap is rejected atfit()per the previous bullet). On the supported paths, the bootstrap clusters atself.cluster if self.cluster else unit— i.e., it matches the user’s explicit cluster column if set, falling back to unit otherwise (the panel’s natural unit of variation). The bootstrap SE overrides the analytical SE foroverall_*onn_bootstrap > 0paths; per-cell(g, t)SEs still come from the analytical vcov.
Aggregations (matching jwdid_estat):
simple: Weighted average across all post-treatment (g, t) cells. Defaultweights="cell"uses cell-countn_{g,t}:ATT_overall = Σ_{(g,t): t≥g} n_{g,t} · ATT(g,t) / Σ_{(g,t): t≥g} n_{g,t}Cell weight
n_{g,t}= count of obs in cohort g at time t in estimation sample.Note:
aggregate(type="simple", weights="cell")(default) matches Statajwdid_estatbehavior. The opt-inweights="cohort_share"exposes the paper W2025 Eq. 7.4 cohort-share formω̂_g ∝ N_g; the two coincide on balanced panels with uniform within-cohort cell counts (paper Section 7.5 footnote). The cohort-share path raisesValueErrorfortype="group"andtype="calendar"(no paper formula). Seedocs/methodology/papers/wooldridge-2025-review.md§ Section 7 for derivation.Note:
weights="cohort_share"inference contract: the analytical SE returned under cohort-share aggregation is conditional on the observed cohort shares ω̂_g / ω̂_{ge}, treating them as fixed. Per paper W2025 Section 7.5, unconditional inference should also account for sampling uncertainty in the cohort shares themselves (theN_gare random in a stochastic sample). The library fail-closes the t-stat / p-value / conf-int fields to NaN underweights="cohort_share"and emits aUserWarningdocumenting the limitation; the point estimate and the conditional-on-shares SE are still computed and returned for reference. Proper APE/GMM-style aggregate inference (Wooldridge 2023 Section 4 framework) is tracked as a deferred follow-up in TODO.md.Note:
weights="cohort_share"is NOT supported on survey-weighted fits (raisesValueErrorwhensurvey_designis supplied). The library populates_n_g_per_cohortfromunit.nunique()(raw counts); composing these with the design-weighted ATTs would target a mixed estimand inconsistent with paper W2025 Section 7’s design-population cohort-share form. Design-consistent cohort totals (survey-weighted unit totals per cohort) are tracked as a deferred follow-up in TODO.md.
group: Weighted average across t for each cohort g (cell-count weights only — paper W2025 has no closed-form cohort-share weights for this aggregation type)calendar: Weighted average across g for each calendar time t (cell-count weights only)event: Weighted average across (g, t) cells by relative period k = t - g. Defaultweights="cell"; opt-inweights="cohort_share"exposes paper Eq. 7.6 cohort-share-by-exposure formω̂_{ge} ∝ N_gwith per-event-time normalization across cohorts present at event-timee. The cohort-share event path is restricted tok >= 0(post-treatment exposure times only); paper Eq. 7.6 is defined for post-treatment exposure, and pre-treatment leads use a separate Eq. 7.7nw_it-based construction not yet exposed in the library. Under the defaultweights="cell", negative-kplacebo cells (e.g., from OLS +control_group="never_treated"oranticipation > 0) remain in the event aggregation for the placebo-test use case.
Covariates:
exovar: Time-invariant covariates, added without demeaning (corresponds to W2025 Eq. 5.2x_i)xtvar: Time-varying covariates, demeaned within cohort×period cells whendemean_covariates=True(corresponds to W2025 Eq. 10.2x_hat_itgs = x_it - x_bar_gs)xgvar: Covariates interacted with each cohort indicatorNote: Covariate-adjusted ETWFE includes the full W2025 Eq. 5.3 basis: raw X, cohort × X (D_g × X for treated cohorts, auto-generated for
exovar/xtvar), time × X (f_t × X, drop first period), and cell × demeaned X (D_{g,t} × X̃). Variables inxgvaralready contribute D_g × X via_prepare_covariates;exovar/xtvarget automatic D_g × X generation.Note:
xtvardemeaning operates at the cohort×period level (W2025 Eq. 10.2), not the cohort level (W2025 Eq. 5.2). These are identical for time-constant covariates but differ for time-varying covariates.
Control groups:
not_yet_treated(default): Control pool includes units not yet treated at time t (same as Callaway-Sant’Anna)never_treated: Control pool restricted to never-treated units only
Edge cases:
Single cohort (no staggered adoption): Reduces to standard 2×2 DiD
Missing cohorts: Only cohorts observed in the data are included in interactions
Anticipation: When
anticipation > 0, interactions include periodst >= g - anticipationNote: Aggregation (simple/group/calendar) uses
t >= gas the post-treatment threshold regardless ofanticipation. Anticipation-window cells (g - anticipation <= t < g) are estimated but treated as pre-treatment placebos in aggregation, not included in overall ATT. This matches the standard post-treatment ATT definition; users who want anticipation cells in the aggregate should compute custom weighted averages fromgroup_time_effects.Never-treated control only: Pre-treatment periods still estimable as placebo ATTs
Note: Poisson QMLE with cohort+time dummies (not unit dummies) is consistent even in short panels (Wooldridge 1999, JBES). The exponential mean function is unique in that incidental parameters from group dummies do not cause inconsistency.
Note: Logit path uses cohort×time additive dummies (not unit dummies) to avoid incidental parameters bias — a standard limitation of logit FE in short panels. This matches Stata
jwdid method(logit)which usesi.gvar i.tvar.Note: Nonlinear methods (logit, Poisson) with
control_group="never_treated"restrict the interaction matrix to post-treatment cells only. Pre-treatment placebo cells are OLS-only (where within-transformation absorbs FE). Including all (g,t) cells in the nonlinear design creates exact collinearity between cohort dummies and cell indicator sums, leading to a data-dependent normalization via QR dropping.
Algorithm:
Identify cohorts G and time periods T from data
Build within-transformed design matrix (absorb unit + time FE)
Append cohort×time interaction columns for all post-treatment cells
Fit OLS/logit/Poisson
For nonlinear: compute ASF-based ATT(g,t) and delta-method SEs per cell
For OLS: extract δ_{g,t} coefficients directly as ATT(g,t)
Compute overall ATT as weighted average; store full vcov for aggregate SEs
Optionally run multiplier bootstrap for overall SE
Requirements checklist:
[x] Saturated cohort×time interaction design matrix
[x] Unit + time FE absorption (within-transformation)
[x] OLS, logit (IRLS), and Poisson (IRLS) fitting methods
[x] Cluster-robust SEs at unit level for all methods
[x] ASF-based ATT for nonlinear methods with delta-method SEs
[x] Joint delta-method SE for aggregate ATT in nonlinear models
[x] Four aggregation types: simple, group, calendar, event
[x] Both control groups: not_yet_treated, never_treated
[x] Anticipation parameter support
[x] Multiplier bootstrap (Rademacher/Webb/Mammen) for OLS overall SE
[x] Survey design support (strata/PSU/FPC with TSL variance)
Survey design notes:
OLS path: Survey-weighted within-transformation + WLS via
solve_ols(weights=...)+ TSL vcov viacompute_survey_vcov().Logit/Poisson paths: Survey-weighted IRLS via
solve_logit(weights=...)/solve_poisson(weights=...)+ X_tilde linearization trick for TSL vcov:X_tilde = X * sqrt(V),r_tilde = (y - mu) / sqrt(V), thencompute_survey_vcov(X_tilde, r_tilde, resolved)gives correct QMLE sandwich. ASF means and gradients use survey-weighted averaging.Note: Only
pweight(probability weights) are supported;fweight/aweightraiseValueErrorbecause the composed survey/QMLE weighting changes their semantics.Note: Replicate-weight variance is not yet supported (
NotImplementedError). Use TSL (strata/PSU/FPC) instead.Note: Bootstrap inference (
n_bootstrap > 0) cannot be combined withsurvey_design— no survey-aware bootstrap variant is implemented.
Heterogeneous cohort trends (paper W2025 Section 8 / Eq. 8.1):
WooldridgeDiD(cohort_trends=True)adds lineardg_i · tinteractions to the design matrix for each treated cohort. Under the heterogeneous-trends DGPy = c_i + α_t + δ_g · t + τ · w_{it} + u_{it}, the parameter recoversτeven when parallel trends fails (paper Eq. 8.3 commentary on the Walmart application: “the estimated effects are much smaller than either the lags only or leads and lags estimates”).Identification (paper Section 8 / Eq. 8.1): each treated cohort must have at least 2 pre-treatment periods (
t < g - anticipation) fordg_i · tto be separately identified from cohort + time FE.fit()raisesValueErrorwhen the contract is violated.OLS-path only:
cohort_trends=Trueis rejected at__init__formethod ∈ {"logit", "poisson"}per paper Section 8’s OLS scope.NotImplementedErrorcites the paper section explicitly.Auto-routes to full-dummy mode regardless of
vcov_type(matching the absorb→fixed_effects auto-route pattern). Composingdg_i · twith the within-transformation yields(dg_i − mean(dg_i)) · (t − mean(t)), which is algebraically correct but non-trivial to verify on every panel shape; routing to the existing full-dummy auto-route used byvcov_type ∈ {classical, hc2, hc2_bm}keeps math closure verified against PR #483’s R-parity goldens. UX implication:cohort_trends=Trueis silently more expensive thancohort_trends=False(carries N unit dummies); for very high-cardinality panels, the design-size warning atwooldridge.pyfires.vcov_type="hc1"finite-sample correction undercohort_trends=True: the full-dummy auto-route changes the HC1 finite-sample factor from(n-1)/(n-k_within)(within-transform default) to(n-1)/(n-k_total)(full-dummy: counts intercept + treatment + unit + time + cohort-trend columns). On typical panels wheren >> k_totalthe gap is small (<2%); on small panels it can reach ~10%. This is a documented opt-in deviation specific tocohort_trends=True— users who need the within-transform HC1 finite-sample factor with cohort trends should usevcov_type="hc1"+cohort_trends=Falseand supply the cohort-trend interactions through a custom design (out-of-scope for the standard library surface).Result attribute:
WooldridgeDiDResults.cohort_trend_coefs: Dict[g → δ_g]populated undercohort_trends=True; empty dict otherwise.Note: Polynomial-trend extensions (
"quadratic","cubic"per paper p. 2572 footnote) are NOT yet exposed —cohort_trendsis a binaryTrue/Falseflag for lineardg_i · tonly.Note:
cohort_trends=True+survey_designis NOT yet supported (raisesNotImplementedErroratfit()). The full-dummy auto-route composed with the survey TSL variance has not been validated against R-parity goldens. Tracked in TODO follow-up.Note:
cohort_trends=True+control_group="never_treated"is NOT yet supported (raisesNotImplementedErroratfit()). The OLS + never_treated branch emits ALL(g, t)cells as treatment-cell indicator dummies (paper W2025 Section 4.4 placebo coverage); the appendeddg_i · ttrend columns are then linearly spanned by the per-cohort sum of those cell dummies, so the Section 8 trend specification is unidentified on this branch. Usecontrol_group="not_yet_treated"(the default) forcohort_trends=True. Tracked in TODO follow-up.Note: Identification + baseline normalization for
cohort_trend_coefson all-eventually-treated panels: when a never-treated cohort (g = 0) is present, allGtreated cohorts get adg_i · tinteraction column andcohort_trend_coefs[g]reports each cohort’s linear slope relative to the never-treated baseline (absorbed by time FE). When no never-treated cohort exists, the last cohort’s trend column is dropped deterministically per paper W2025 Section 5.4 (“all variables in regression (5.3) involvingdT_iget dropped”); that cohort serves as the trend baseline, andcohort_trend_coefssurfacesG - 1entries (the last cohort is absent — its slope is the baseline in deviation form). Mirrors the all-treated normalization the library applies to cohort × time interactions.
Deviations from the paper / from R / library extensions#
Consolidated list of substantive deviations from the W2025 paper and from R etwfe. Each is documented in the relevant section above with a labeled **Note:** or **Deviation from R:** line. AI PR reviewer recognizes these as documented (P3 informational) per the project’s documented-deviation convention.
Cell-count default for aggregation (vs paper Eq. 7.4 / 7.6 cohort-share).
aggregate(weights="cell")(default) matches Statajwdid_estat. The opt-inweights="cohort_share"exposes the paper-Eq. 7.4 / 7.6 forms. Cohort-share is supported only fortype="simple"andtype="event". See § Aggregations Note.HC1 finite-sample correction
(n-1)/(n-k_within)(vs Rlm + clubSandwich::vcovCR(type="CR1S")which uses(n-1)/(n-k_total)). On 240-obs / 51-col fixture ~11%; on typical panels <2%. See § Variance families Deviation from R.QMLE sandwich
(G/(G-1)) · ((n-1)/(n-k))(vs StatajwdidG/(G-1)only). Conservative; for typical panels n >> k the difference is negligible. Tracked in TODO row 94. See § Method Note.Nonlinear methods via direct QMLE (vs R
etwfefixest backend). Avoids statsmodels/fixest dependency. See § Method Deviation from R.Logit cohort+time additive dummies (not unit FE) to avoid incidental-parameters bias in short panels. Matches Stata
jwdid method(logit). See § Edge cases Note.Anticipation + aggregation:
aggregate(type="simple", weights="cell")usest >= gas the post-treatment threshold regardless ofanticipation. Anticipation-window leads are estimated as placebos but excluded fromoverall_att. See § Edge cases Note.Response-scale ATT vs R
etwfelog-link coefficients (Poisson + logit): diff-diff’sWooldridgeDiD(method="poisson" | "logit")returns ATT on the response scale (counterfactual mean difference per paper W2023 ASF / APE framework); Retwfe(family="poisson" | "logit")returns the cell-level log-link / log-odds coefficient. Numerical cell-level R-parity for nonlinear paths requires eitheremfx()-based APE extraction on the R side or link-function inversion with baseline-mean adjustment; deferred (TODO row added in PR-B). Seetests/test_methodology_wooldridge.py::TestWooldridgeParityRPoisson/TestWooldridgeParityRLogitfor the current surface-test scope.
LPDiD#
Primary source: Dube, A., Girardi, D., Jordà, Ò., & Taylor, A. M. (2025). “A Local Projections Approach to Difference-in-Differences.” Journal of Applied Econometrics, 40(7), 741-758. (Open Access; NBER Working Paper 31184; FRBSF Working Paper 2023-12.) Paper review on file: docs/methodology/papers/dube-2025-review.md (main article + official online appendix; equation/section numbering pinned to the JAE 2025 version).
Reference implementations: Stata lpdid (SSC s459273, the authors’ reference); R alexCardazzi/lpdid (third-party; absorbing + non-absorbing); authors’ example scripts danielegirardi/lpdid (R + Stata).
Identification#
Model-based DiD: untreated potential outcomes follow the two-way fixed-effects DGP E[y_it(0)|i,t] = alpha_i + delta_t (paper Eq. 1), under two assumptions:
No anticipation (Assumption 1):
E[y_it(p) - y_it(0)] = 0for allt < p.Parallel trends (Assumption 2):
E[y_it(0) - y_{i1}(0) | p_i = p] = E[y_it(0) - y_{i1}(0)]for allt in {2..T},p in {1..T, inf}- untreated potential-outcome trends are common across cohorts, stated relative to the first period (t = 1). The base period used for LP-DiD’s long difference (first-lagt-1vs premean) is a separate efficiency/robustness choice, NOT part of this identification assumption (see the PMD edge case).
Treatment is binary; the main path assumes absorbing treatment (D_{is} <= D_{it} for s < t). Target parameter: the cohort-specific dynamic ATT tau_h^g = E[y_{i,p_g+h}(p_g) - y_{i,p_g+h}(0) | p_i = p_g], h periods after group g enters at p_g. Treatment effects may be dynamic and heterogeneous across cohorts.
The key device is the clean-control restriction: each horizon-h regression keeps only newly-treated obs (Delta_D_it = 1) and not-yet-treated “clean” controls (D_{i,t+h} = 0, absorbing case). Excluding already-treated units from the control group is what eliminates the negative-weighting bias of naive TWFE/LP (paper Eqs. 6-7). Only the entry-period rows (t = p_g) identify each beta_h (online Appendix A.2).
Key Equations#
LP-DiD regression (paper Eq. 4 restricted by Eq. 8), run separately per horizon h in {-Q..H}, h != -1:
y_{i,t+h} - y_{i,t-1} = beta_h^{LP-DiD} * Delta_D_it + delta_t^h + e_it^h
sample: Delta_D_it = 1 (newly treated) OR D_{i,t+h} = 0 (clean control)
delta_t^h = calendar-time fixed effects; no unit FE (differenced out). h = -1 is the reference (coefficient fixed at 0); negative h give pre-trend placebos.
Estimand = variance-weighted ATT (paper Eqs. 9-10; online Appendix B):
E(beta_h^{LP-DiD}) = sum_{g != 0} omega_{g,h} * tau_h^g
omega_{g,h} = N_CCS_{g,h} * n_{g,h} * (1 - n_{g,h}) / sum_{g != 0}[...], n_{g,h} = N_g / N_CCS_{g,h}
Weights are always non-negative (the central result). Via Frisch-Waugh-Lovell, the residualized treatment dummy is the per-group constant Delta_D~_g = 1 - N_g/N_CCS_{g,h} (online Appendix Eqs. B.4-B.6) - the hook for the reweighting implementation.
Equally-weighted ATT (paper Section 3.3) - two equivalent routes:
reweight=True: weight each observation in the clean-control sampleCCS_{g,h}(the newly-treated obs and their clean controls) by(omega_{g,h}/N_g)^{-1}. Numerically equivalent to Callaway-Sant’Anna (2021).Regression adjustment (RA): fit the long difference on time FE using clean controls only, predict each treated obs’s counterfactual, average residuals:
beta_h^{RA} = N_TR^{-1} sum_{TR}[(y_{i,t+h}-y_{i,t-1}) - Ehat((y_{i,t+h}-y_{i,t-1}) | D_{i,t+h}=0)]. An imputation estimator in the BJS (2024) sense.
Covariates (paper Section 4.1): recommended RA path beta_{h,x}^{RA} = N_TR^{-1} sum_{TR}[(y_{i,t+h}-y_{i,t-1}) - gamma~^h x_i - delta~_t^h], with gamma~^h, delta~_t^h from a clean-control-only regression. PMD base period (Section 3.4): replace y_{i,t-1} with the mean of the last k pretreatment periods (k=t-1 = all); single-cohort k=t-1 == BJS. Pooled estimand (Section 3.5): posttreatment-mean long difference (1/(H+1)) sum_{h=0}^H y_{i,t+h} - y_{i,t-1} as the dependent variable.
Non-absorbing treatment (paper Section 4.2; non_absorbing=). Two distinct entry-effect estimands, both fitted by the same per-horizon LP-DiD regression with mode-specific clean-sample restrictions (Delta_D_it = D_it - D_{i,t-1}):
Eq. 12 (first-time entry, "first_entry"):
treated: D_{i,t+j} = 1 for 0 <= j <= h AND untreated before t (stays treated through t+h)
clean control: D_{i,t+j} = 0 for j <= h (== absorbing Eq. 8 control)
Eq. 13 (effect stabilization, "effect_stabilization", window L; Assumption 9):
treated: fresh entry at t, D = 0 on [t-L, t-1], no other change in (t, t+h]
clean control: no treatment change in [t-L, t+h] (admits stabilized already-treated units)
Eq. 12 reuses the absorbing clean control and only restricts the treated set (a unit exiting within the horizon drops out); on an absorbing panel it is numerically identical to the absorbing path. Eq. 13 lets units whose treatment has been stable for at least L periods serve as clean controls (Assumption 9: dynamic effects stabilize after L periods), making estimation feasible when there are few or no never-treated units (e.g. minimum wage). Non-negative weights (online Appendix C, omega'') carry over. Placebo horizons (h < 0) use the clean window [t - max(L, -h), t-1] so the long difference’s reach-back to t+h cannot be contaminated.
Standard Errors#
The paper specifies no SE formula - Section 1 defers to “standard, well-understood techniques.” The reference Stata uses cluster-robust SEs at the unit level (vce(cluster unit), footnote 9); pooled / joint tests stack the per-horizon regressions (suest). No bootstrap is discussed. Any analytical SE the library ships - and in particular an influence-function cluster variance for the RA path - is therefore an implementation choice validated against the reference package, not against the paper, and must be documented under Deviations once implemented (PR-B).
Edge Cases#
Composition effects (Section 3.6): the treated/clean-control set can change across horizons.
no_compositiontightens the clean-control condition toD_{i,t+H}=0at all horizons (and excludes cohorts withp_g > T-Hto fix the treated set). Costs statistical power. Implementation note: this fixes the post-treatment composition (every post horizon shares the same realized sample, even on unbalanced panels); pre-treatment placebo horizons use whatever pre-period data is available. Reweighting denominators and the regression-adjustment counterfactual are computed from the realized post-drop sample (not the pre-drop panel) so they stay consistent with the regression’s risk set on unbalanced panels.Bias-variance (Sections 3.3, 5.3): variance weighting (default) -> lower variance, some bias; equal weighting (
reweight) -> unbiased, higher variance. Variance won at short horizons, equal at long horizons in the paper’s simulation.PMD vs first-lag (Section 3.4): PMD gains efficiency under low autocorrelation but can amplify bias if PT holds only in some pretreatment periods; first-lag relies on weaker PT (Marcus & Sant’Anna 2021). Choose the base period ex-ante.
Covariate-weight positivity (online Appendix B.2): direct covariate inclusion keeps non-negative weights ONLY under linear + homogeneous covariate effects (B.2.1; main-text Assumption 6); in the general case (B.2.2) weights are not guaranteed positive -> prefer the RA covariate path (the direct path should carry a homogeneity-assumption warning).
Non-absorbing (Section 4.2, online Appendix C): implemented via
non_absorbing="first_entry"(Eq. 12) andnon_absorbing="effect_stabilization"(Eq. 13, requiresstabilization_window=L); the defaultnon_absorbing=Nonekeeps the absorbing path and still rejects non-absorbing input. Both modes are entry-effect estimands; the Appendix-C exit-event dynamics (eta_h^{g,n}, separate switch-off event-studies) are a deferred follow-up. Boundary convention: periods before a unit’s first observed period are treated as untreated with no change (extends Deviation 5), so window conditions clamp pre-min_toffsets to 0 - a unit genuinely treated before the panel starts could be misread as a fresh entry undereffect_stabilization(PR-C2 documented this as a known divergence fromalexCardazzi/lpdid, which NA-excludes such first-rows - see Deviation #4). Interior gaps make the[t-L, t+h]window conditions unverifiable, so non-absorbing modes require gap-free panels within each unit’s observed span and raise otherwise (the absorbing path’s interior-gap reindex is a deferred follow-up for non-absorbing).
Deviations from the paper / from R / library extensions#
The paper specifies no standard-error formula (Section 1 defers to “standard, well-understood techniques”); the reference Stata lpdid uses vce(cluster unit). The entries below document diff-diff’s inference and scope choices.
Note: Standard errors are cluster-robust at the unit level by default -
cluster=Noneauto-clusters at the unit identifier and the results recordcluster_name/n_clusters- with at(G-1)reference distribution (G = realized clusters in each horizon’s clean-control sample). Matches Statalpdidvce(cluster unit); the paper prescribes no SE.Note: The regression-adjustment (RA) covariate path (
reweight=Truewith covariates/absorb) reports an influence-function cluster variancesum_c (sum_{i in c} psi_i)^2 / n^2, in the same family asImputationDiD’s Theorem-3 / BJS variance (see “IF-based variance estimators vs analytical-sandwich estimators” above). Its single Gram inversion is routed throughlinalg._rank_guarded_inv(finite SE on the identified subspace under near-collinearity; NaN at rank 0). Unlike the default/weightedsolve_olshc1-cluster path - which applies the(G/(G-1))*((n-1)/(n-k))finite-sample factor - the RA IF variance carries no finite-sample factor, while both paths share thet(G-1)reference. PR-B2 validated this asymmetry as faithful to the authors’ own tooling, not a defect: the no-factor RA convention matches the canonical Statateffects ra ... atet vce(cluster)(the authors’lpdid_regression_adjustment.domargins/kmatchdegrees-of-freedom comments proveteffectsapplies neither factor), while the default path matchesfeols/reghdfe. The RA point estimate is R-anchored to ~1e-13 (full-interactioni.dtreat##(i.time c.x)==teffectspoint;tests/test_methodology_lpdid.py::test_ra_covariate_point). The RA standard error itself has no runnable R reference (no R package computes the RA IF variance -alexCardazziuses direct covariate inclusion, not RA; the canonical RA SE is Statateffectsonly), so it is pinned as a documented regression value (test_ra_covariate_se_regression_pin) and its calibration is validated by the ungated Monte-Carlo coverage studybenchmarks/python/coverage_lpdid_ra.py(~0.95 empirical coverage of the true effect at cluster counts G in {30, 100, 300}).Note: Direct covariate inclusion (
reweight=Falsewith covariates/absorb) emits aUserWarning: per online Appendix B.2.2 it preserves the non-negative LP-DiD weighting result only under linear and homogeneous covariate effects, so the regression-adjustment path (reweight=True) is preferred.Deviation from R: Scope - non-absorbing treatment (Section 4.2) implements the entry-effect estimands (
non_absorbing="first_entry"/"effect_stabilization", PR-C1). PR-C2 R-parity-validated both modes against an INDEPENDENTfixest::feolsreconstruction of the paper’s Eq. 12 / Eq. 13 clean-sample restrictions (point and SE match to ~1e-13/~1e-15 for the variance-weighted variants; theeffect_stabilizationreweighted point matches and its SE is pinned as a regression guard - a small weighted-cluster convention difference vs feols;tests/test_methodology_lpdid.py::TestLPDiDNonAbsorbingParityR). The recipe’s independence was demonstrated when an earlier draft’s Eq. 12 control off-by-one diverged from the already-correct library and was corrected against the paper, plus a hand-computed Python micro-check.alexCardazzi/lpdid’snonabsorbing_lagis NOT a faithful Eq. 13 (it clampstreat_diff[<0]<-0, so its clean-control window blocks only treatment turn-ons; it reuses a forward placebo window; and it NA-excludes pre-panel-treated rows where the library clamps pre-min_tto untreated): it diverges ~0.01-0.05 from Eq. 13 even on a monotone no-off-switch panel, so it is recorded in the goldenmetaas a divergent third-party reference, not a parity gate (the alexCardazzi-pooled precedent). The library’s “no treatment change” (both directions) and backward placebo window are the more paper-faithful choices.first_entry(Eq. 12) has no R-package analogue (anchored on the independent feols recipe only). Appendix-C exit-event dynamics and the Stata canonical SE remain deferred follow-ups.Note: LP-DiD’s per-unit quantities (outcome lags
ylags, first-difference lagsdylags, integer-pmdpremean baselines, treatment-entry detection) are calendar quantities (t-1,t-k), so the estimator requires integer-valued, globally consecutivetimelabels. A unit with an interior time gap is handled by reindexing that unit to its complete interior calendar grid[min_t, max_t], computing the features on the grid, then restricting back to the observed rows - so a lag/first-difference spanning a gap is NaN and the observation fails closed (never the previous-observed row), and no synthetic gap row enters a regression. A gap-free panel skips this entirely and is bit-identical. Entry = first OBSERVED treated period (min(t | D_it=1)): an unobserved pre-onset gap cannot move a cohort earlier, the only well-defined convention when the true switch falls in an unobserved period.Note (pooled estimand): The pooled pre/post ATT (the headline
results.attis the pooled-post row) is the unit-equal-weighted average of each unit-event-time’s mean long difference over the window -mean_h(y_{i,t+h}) - baseline_{i,t}, one observation per (unit, event-time), regressed on the treatment-switch indicator with event-time fixed effects on the fixed-composition sample (only units observing every pooled target, with clean controls required throughmax(h)). This equals the mean of the per-horizon event-study coefficients on a balanced panel. PR-B2 validated it against the authors’ runnable R reference: the pooled estimand matches the authors’ own R pooled recipe (danielegirardi/lpdid: asliderwindow-mean minusy_{t-1}on the clean-through-window-end sample) to ~1e-13 (tests/test_methodology_lpdid.py::test_pooled). A prior version of this note speculated the authors used a horizon-stacked pooled regression; the authors’ R reference in fact uses this same fixed-composition mean-long-difference, so that speculation was incorrect. Unlike the event-study variants (wherealexCardazziis a cross-check gate), pooled is anchored to the authors’ recipe only:alexCardazzi’s pooled uses a laxer clean-control window, so it differs and is recorded in the goldenmetafor transparency, not as a parity target.Deviation from R:
no_compositionis intentionally more faithful to the paper’s fixed-composition intent (Section 3.6) than the R packages: it fixes the realized sample across all post horizons (every post coefficient shares one sample, even on unbalanced panels) and excludes cohorts withp_g > T-H, whereasalexCardazzi/lpdiduses a looser per-horizon sample and a strictertreat_date < T-Hcutoff. It therefore has no exact R-package anchor and is validated by the pure-Python tests intests/test_lpdid.py(the R-parity golden omits it;alexCardazzi’s looser-semantics value is recorded in the goldenmeta).Note (survey design): Complex-survey support (
survey_design=SurveyDesign(...), PR-D1) covers the variance-weighted default path (reweight=False, with or without direct-inclusion covariates): each horizon’s long-difference regression is fit by WLS on the survey probability weights, and the SE is the stratified-PSU Taylor-linearization (Binder 1983 TSL) sandwichmeat = sum_h (1-f_h)*(n_h/(n_h-1))*sum_j (S_hj - S_h_bar)(S_hj - S_h_bar)'withdf = n_PSU - n_strata, reusing the shareddiff_diff/survey.pyhelpers (compute_survey_vcov/_compute_stratified_psu_meat). The design is re-resolved on each realized (post-clean-control) sample so weights/strata/PSU align with the regression rows; with no explicit PSU the unit (LP-DiD’s default cluster) is injected as the PSU. Supports pweight + strata + PSU + FPC + lonely-PSU handling. It rejectssurvey_designcombined withreweight=True(the equally-weighted / regression-adjustment IF path has no validated survey reference - the same gap as the RA SE in Deviation #2), replicate-weight designs, and non-pweight (fweight/aweight) types, each a deferred follow-up. The non-survey path is byte-for-byte unchanged (gated onsurvey_design is None). PR-D2 validated all three survey paths end-to-end againstsurvey::svyglm- per-horizon point/SE/df + pooled for the variance-weighted full design (strata+PSU+FPC), the weights-only unit-injected-PSU design, and the direct-covariate variant (tests/test_methodology_lpdid.py::TestLPDiDSurveyParityR; point ~1e-6, SE ~1e-5, df exact via the per-designn_PSU - n_strata/n_PSU - 1formula).svyglmis itself the reference implementation of the Binder TSL sandwich, so it anchors the variance directly (no third-party survey-package gate is needed); the clean-sample construction is independently cross-checked in the generator (the unweighted variance-weighted event study matchesalexCardazzi/lpdidto <1e-8, and selection is weight-independent). A dedicated survey panel (benchmarks/data/lpdid_survey_panel.csv, own seed) keeps the absorbing / non-absorbing goldens byte-identical.
Implementation Checklist#
[x] Per-horizon long-difference OLS with time FE, no unit FE;
h=-1reference fixed at 0 (PR-B1)[x] Clean-control sample restriction (absorbing:
D_{i,t+h}=0) (PR-B1)[x] Variance-weighted (default) + reweighted (equal-weight) estimands (PR-B1)
[x] Regression-adjustment covariate path (recommended) + direct-inclusion path with homogeneity warning (PR-B1)
[x] PMD base period; pooled pre/post estimands (PR-B1)
[x]
no_compositionoption (PR-B1)[x] Cluster-robust SE at unit level by default; NaN-consistent inference via
safe_inference(PR-B1)[x]
LPDiDResultswithsummary()/to_dict()/ cluster metadata (PR-B1)[x] doc-deps.yaml mapping for
diff_diff/lpdid.py+lpdid_results.py; llms.txt / llms-full.txt catalog entries (PR-B1, test-enforced)[x] B1 pure-Python tests: analytical DGPs + cross-estimator equivalence (CS / BJS / DiD; Cengiz-stacked dropped, documented) + unbalanced / interior-gap / RA-overlap / pmd-missing edge cases (PR-B1)
[x] B2: self-generated R-parity (authors’
danielegirardi/lpdidrecipes +alexCardazzi/lpdidcross-check; VW / reweight / pmd / direct / pooled / RA-point to ~1e-12; RA SE pinned + MC-coverage-validated;no_compositionmore paper-faithful than R, B1-tested) (PR-B2)[x] Non-absorbing extension (Section 4.2): entry-effect estimands - first-entry (Eq. 12) + effect-stabilization (Eq. 13, window
L) vianon_absorbing; mode-aware clean-sample masks,C=0-below-min_tboundary convention, gap-free requirement; pure-Python tests (absorbing reduction, re-entry mechanism, placebo, no-negative-weighting, stabilized-control, DGP recovery) (PR-C1)[x] Non-absorbing R-parity: both modes vs an independent
fixest::feolsEq. 12/13 reconstruction (point+SE ~1e-13/~1e-15 vw; reweighted point + pinned SE);alexCardazzi nonabsorbing_lagrecorded as a divergent reference (not a gate); absorbing B2 goldens byte-identical (PR-C2)[ ] Non-absorbing exit-event dynamics (Appendix C
eta_h) + the Stata canonical RA/SE - deferred[x] Survey-design support (PR-D1): pweight + stratified-PSU Taylor-linearization (Binder TSL) variance on the variance-weighted default path; per-sample design re-resolution + unit-as-PSU injection; reweight (incl. RA), replicate-weight, and non-pweight designs rejected; pure-Python invariants (reduction/unit-clustering, FPC-shrinks-SE, stratification, lonely-PSU, NaN-consistency, metadata) (PR-D1)
[x] Survey-design R-parity (PR-D2): all three survey paths (VW strata+PSU+FPC, weights-only inject, direct-covariate) validated end-to-end vs
survey::svyglm- per-horizon point/SE/df + pooled (point ~1e-6, SE ~1e-5, df exact); clean sample independentlyalexCardazzi-cross-checked (<1e-8); dedicated survey panel keeps the absorbing / non-absorbing goldens byte-identical (PR-D2)
Advanced Estimators#
SyntheticDiD#
Key implementation requirements:
Assumption checks / warnings:
Requires balanced panel (same units observed in all periods)
Warns if pre-treatment fit is poor (high RMSE)
Treatment must be “block” structure: all treated units treated at same time
Estimator equation (as implemented):
τ̂^sdid = Σ_t λ_t (Ȳ_{tr,t} - Σ_j ω_j Y_{j,t})
where Ȳ_{tr,t} is the mean treated outcome at time t, ω_j are unit weights, and λ_t are time weights.
Unit weights ω (Frank-Wolfe on collapsed form):
Build collapsed-form matrix Y_unit of shape (T_pre, N_co + 1), where the last column is the mean treated pre-period outcomes. Solve via Frank-Wolfe on the simplex:
min_{ω on simplex} ζ_ω² ||ω||₂² + (1/T_pre) ||A_centered ω - b_centered||₂²
where A = Y_unit[:, :N_co], b = Y_unit[:, N_co], and centering is column-wise (intercept=True).
Two-pass sparsification procedure (matches R’s synthdid::sc.weight.fw + sparsify_function):
First pass: Run Frank-Wolfe for 100 iterations (max_iter_pre_sparsify) from uniform initialization
Sparsify:
v[v <= max(v)/4] = 0; v = v / sum(v)(zero out small weights, renormalize)Second pass: Run Frank-Wolfe for 10000 iterations (max_iter) starting from sparsified weights
The sparsification step concentrates weights on the most important control units, improving interpretability and stability.
Time weights λ (Frank-Wolfe on collapsed form):
Build collapsed-form matrix Y_time of shape (N_co, T_pre + 1), where the last column is the per-control post-period mean (averaged across post-periods for each control unit). Solve:
min_{λ on simplex} ζ_λ² ||λ||₂² + (1/N_co) ||A_centered λ - b_centered||₂²
where A = Y_time[:, :T_pre], b = Y_time[:, T_pre], and centering is column-wise.
Auto-regularization (matching R’s synthdid):
noise_level = sd(first_differences of control outcomes) # pooled across units
zeta_omega = (N_treated × T_post)^(1/4) × noise_level
zeta_lambda = 1e-6 × noise_level
The noise level is computed as the standard deviation (ddof=1) of np.diff(Y_pre_control, axis=0),
which computes first-differences across time for each control unit and pools all values.
This matches R’s sd(apply(Y[1:N0, 1:T0], 1, diff)).
Frank-Wolfe step (matches R’s fw.step()):
half_grad = A' (A x - b) + η x # η = N × ζ²
i = argmin(half_grad) # vertex selection (simplex corner)
d_x = e_i - x # direction toward vertex i
step = -(half_grad · d_x) / (||A d_x||² + η ||d_x||²)
step = clip(step, 0, 1)
x_new = x + step × d_x
Convergence criterion: stop when objective decrease < min_decrease² (default min_decrease = 1e-5 × noise_level, max_iter = 10000, max_iter_pre_sparsify = 100).
Standard errors:
Default: Placebo variance estimator (Algorithm 4 in paper)
Randomly permute control unit indices
Split into pseudo-controls (first N_co - N_tr) and pseudo-treated (last N_tr)
Re-estimate unit weights (Frank-Wolfe) on pseudo-control/pseudo-treated data
Re-estimate time weights (Frank-Wolfe) on pseudo-control data
Compute SDID estimate with re-estimated weights
Repeat
replicationstimes (default 200)SE = sqrt((r-1)/r) × sd(placebo_estimates)where r = number of successful replications
This matches R’s
synthdid::vcov(method="placebo")which passesupdate.omega=TRUE, update.lambda=TRUEviaopts.Alternative: Bootstrap at unit level — paper-faithful refit (
variance_method="bootstrap") Arkhangelsky et al. (2021) Algorithm 2 step 2 verbatim, and also the behavior of R’s defaultsynthdid::vcov(method="bootstrap"). On each pairs-bootstrap draw:Resample ALL units (control + treated) with replacement.
Identify which resampled units are control vs treated.
Re-estimate
ω̂_b = compute_sdid_unit_weights(Y_boot_pre_c, Y_boot_pre_t_mean, zeta_omega=ζ_ω / Y_scale, init_weights=_sum_normalize(ω̂[boot_control_idx]))— two-pass sparsified Frank-Wolfe, warm-started from the fit-time ω renormalized over the resampled controls (matching R’ssum_normalize(weights$omega[sort(ind[ind <= N0])])shape).Re-estimate
λ̂_b = compute_time_weights(Y_boot_pre_c, Y_boot_post_c, zeta_lambda=ζ_λ / Y_scale, init_weights=λ̂)— warm-started from the fit-time λ unchanged (matching R’sweights.boot$lambda = weights$lambdashape).Compute SDID estimate with refit
ω̂_bandλ̂_b.SE = sqrt((r-1)/r) × sd(bootstrap_estimates, ddof=1)wherer = n_successful(equivalent to the paper’sσ̂² = (1/r) Σ (τ_b − τ̄)²).
R-parity rationale:
synthdid_estimate()(synthdid.R) storesupdate.omega = TRUEinattr(estimate, "opts"), andvcov.R::bootstrap_samplerebinds thoseoptsinside itsdo.callback intosynthdid_estimate, so the renormalized ω passed viaweights$omegais used as Frank-Wolfe initialization (thesum_normalizehelper in R’s source explicitly says so). The Python path threads the same warm-start viacompute_sdid_unit_weights(..., init_weights=...)andcompute_time_weights(..., init_weights=...). The FW objective is strictly convex on the simplex (quadratic loss + ζ² ridge on simplex), so warm- and cold-start converge to the same global minimum given enough iterations; warm-start matters in practice because the 100-iter first pass then sparsification is path-dependent on draws where the pre-sparsify budget is tight. Cross-language SE parity at bit tolerance is not claimed — different BLAS / RNG paths — but the procedure matches R’s default bootstrap shape at the algorithm level, and Python-only bit-identity on non-survey data is asserted viaTestScaleEquivariance::test_baseline_parity_small_scale[bootstrap]atrel=1e-14.Expected wall-clock ~5–30× slower per fit than placebo (panel-size dependent; Frank-Wolfe second-pass can hit its 10K-iter cap on larger panels; warm-start plumbing closes the gap vs cold-start, which would be closer to 10–30× on these DGPs). Per-draw Frank-Wolfe non-convergence UserWarnings are suppressed inside the loop and aggregated into a single summary warning emitted after the loop when the share of valid bootstrap draws with any non-convergence event (counted once per draw — each draw runs Frank-Wolfe once for ω and once for λ, and any of those calls firing a non-convergence warning trips the draw) exceeds 5% of
n_successful. Composed with survey designs (pweight-only OR strata/PSU/FPC) this path uses the weighted Frank-Wolfe kernel and the per-draw dispatch described in the “Note (survey + bootstrap composition)” below.Alternative: Jackknife variance (matching R’s
synthdid::vcov(method="jackknife")) Implements Algorithm 3 from Arkhangelsky et al. (2021):For each control unit j=1,…,N_co:
Remove unit j, renormalize omega:
ω_jk = _sum_normalize(ω[remaining])Keep λ unchanged, keep treated means unchanged
Compute SDID estimate τ_{(-j)}
For each treated unit k=1,…,N_tr:
Keep ω and λ unchanged
Recompute treated mean from remaining N_tr-1 treated units
Compute SDID estimate τ_{(-k)}
SE = sqrt( ((n-1)/n) × Σ (τ_{(-i)} - τ̄)² )where n = N_co + N_tr
Fixed weights: No Frank-Wolfe re-estimation (
update.omega=FALSE, update.lambda=FALSE). Returns NaN SE for single treated unit or single nonzero-weight control. Deterministic: exactly N_co + N_tr iterations, no replications parameter. P-value: analytical (normal distribution), not empirical.
Edge cases:
Frank-Wolfe non-convergence: Returns current weights after max_iter iterations when the convergence check
vals[t-1] - vals[t] < min_decrease²never triggers early exit. Onvariance_method="bootstrap"specifically,_bootstrap_seuses thesc_weight_fw_with_convergenceRust entry point (or_sc_weight_fw_numpy(return_convergence=True)on the pure-Python backend) to thread a convergence bool out of every per-draw FW pass; draws with any non-convergence are tallied and, if the rate exceeds 5% of valid draws, aggregated into a singleUserWarningafter the loop (avoids 200+ warnings per fit). Standalone calls to_sc_weight_fw/compute_sdid_unit_weights/compute_time_weightsthat do not passreturn_convergence=Truefollow the legacy behavior: the numpy path emits a per-callUserWarningviadiff_diff.utils.warn_if_not_convergedand the Rust path silently returns the final iterate._sparsifyall-zero input: Ifmax(v) <= 0, returns uniform weightsones(len(v)) / len(v).Single control unit:
compute_sdid_unit_weightsreturns[1.0]immediately (short-circuit before Frank-Wolfe).Zero control units:
compute_sdid_unit_weightsreturns empty array[].Single pre-period:
compute_time_weightsreturns[1.0]whenn_pre <= 1(Frank-Wolfe on a 1-element simplex is trivial).Bootstrap with 0 control or 0 treated in resample (or non-finite
τ_b): retry the draw. Matches R’ssynthdid::bootstrap_sample(while (count < replications) { ...; if (!is.na(est)) count = count + 1 }) and paper Algorithm 2 (B bootstrap replicates). A bounded attempt guard of20 × n_bootstrapprevents pathological-input hangs; normal fits finish far inside this budget because degenerate-draw probability scales as(N_co / N)^N + (N_tr / N)^N, which is small for any non-trivial split. If the budget is exhausted with 0 successful draws, raisesValueError. With 1 successful draw, warns and returnsSE = 0.0. With fewer thann_bootstrapvalid draws, warns that the attempt budget was exhausted and SE may be unreliable.Placebo with n_control <= n_treated: Warns that not enough control units for placebo variance estimation, returns SE=0.0 and empty placebo effects array. The check is
n_control - n_treated < 1.Note: Power analysis functions (
simulate_power,simulate_mde,simulate_sample_size) raiseValueErrorfor placebo variance whenn_control <= n_treated. The registry path checks pre-generation usingn_units * treatment_fraction; the custom-DGP path checks post-generation on the realized data (first iteration only, since treatment allocation is deterministic pern_units/treatment_fraction).Negative weights attempted: Frank-Wolfe operates on the simplex (non-negative, sum-to-1), so weights are always feasible by construction. The step size is clipped to [0, 1] and the move is toward a simplex vertex.
Perfect pre-treatment fit: Regularization (ζ² ||ω||²) prevents overfitting by penalizing weight concentration.
Single treated unit: Valid; placebo variance uses jackknife-style permutations of controls.
Noise level with < 2 pre-periods: Returns 0.0, which makes both zeta_omega and zeta_lambda equal to 0.0 (no regularization). Note (deviation from R):
min_decreaseuses a1e-5floor whennoise_level == 0to enable Frank-Wolfe early stopping. R would use0.0, causing FW to run allmax_iteriterations; the result is equivalent since zero-noise data has no variation to optimize.NaN inference for undefined statistics: t_stat uses NaN when SE is zero or non-finite; p_value and CI also NaN. Matches CallawaySantAnna NaN convention.
Placebo p-value floor:
p_value = max(empirical_p, 1/(n_replications + 1))to avoid reporting exactly zero.Varying treatment within unit: Raises
ValueError. SDID requires block treatment (constant within each unit). Suggests CallawaySantAnna or ImputationDiD for staggered adoption.Unbalanced panel: Raises
ValueError. SDID requires all units observed in all periods. Suggestsbalance_panel().Poor pre-treatment fit: Warns (
UserWarning) whenpre_fit_rmse > std(treated_pre_outcomes, ddof=1). Diagnostic only; estimation proceeds.Jackknife with single treated unit: Returns NaN SE. Cannot leave-one-out with N_tr=1; R returns NA for the same condition.
Jackknife with single nonzero-weight control: Returns NaN SE. Leaving out the only effective control is not meaningful.
Jackknife with non-finite LOO estimate: Returns NaN SE. Unlike bootstrap/placebo, jackknife is deterministic and cannot skip failed iterations; NaN propagates through
var()(matches R behavior).Jackknife with survey weights: Guards on effective positive support (omega * w_control > 0 and w_treated > 0) after composition, not raw FW counts. Returns NaN SE if fewer than 2 effective controls or 2 positive-weight treated units. Per-iteration zero-sum guards return NaN for individual LOO iterations when remaining composed weights sum to zero.
Note (survey support matrix):
variance_method
pweight-only
strata/PSU/FPC
bootstrap✓ weighted FW
✓ weighted FW + Rao-Wu rescaling (PR #355)
placebo✓
✓ stratified permutation + weighted FW
jackknife✓
✓ PSU-level LOO with stratum aggregation
Pweight-only path (placebo / jackknife / bootstrap): treated-side means are survey-weighted (Frank-Wolfe target and ATT formula); control-side synthetic weights are composed with survey weights post-optimization (ω_eff = ω * w_co, renormalized). Fit-time Frank-Wolfe is unweighted — survey importance enters after trajectory-matching. Covariate residualization uses WLS with survey weights.
Bootstrap survey path (PR #355): for pweight-only the per-draw FW uses constant
rw = w_control; for full design (strata/PSU/FPC) the per-drawrw = generate_rao_wu_weights(resolved_survey, rng)rescaling is composed with the same weighted-FW kernel. See “Note (survey + bootstrap composition)” below for the full objective and the argmin-set caveat.Placebo survey path: for pweight-only the existing Algorithm 4 flow applies with survey-weighted pseudo-treated means + post-hoc ω_eff composition. For designs with explicit
strataand/orpsuthe allocator switches to stratified permutation (Pesarin 2001): pseudo-treated indices are drawn within each stratum containing actual treated units; weighted-FW re-estimates ω and λ per draw with per-control survey weights threaded into both loss and regularization. See “Note (survey + placebo composition)” below. FPC is a documented no-op for placebo — permutation tests are conditional on the observed sample (Pesarin 2001 §1.5), so the sampling fraction does not enter Algorithm 4 or its survey extension; anfpc=column on a placebo fit emits aUserWarningand is preserved in the design metadata but never enters the variance computation. Routing is gated onstrata/psuonly — FPC alone does not flip dispatch from the non-survey to the survey placebo path.Jackknife survey path: for pweight-only the existing Algorithm 3 flow applies (unit-level LOO with subset + rw-composed-renormalized ω; λ fixed). For full design the allocator switches to PSU-level LOO with stratum aggregation (Rust & Rao 1996): leave out one PSU at a time within each stratum, aggregate as
SE² = Σ_h (1-f_h)·(n_h-1)/n_h·Σ_{j∈h}(τ̂_{(h,j)} - τ̄_h)². See “Note (survey + jackknife composition)” below.Allocator asymmetry (placebo ignores PSU axis; jackknife respects it): intentional. Placebo is a null-distribution test — within-stratum unit-level permutation is the classical stratified permutation test (Pesarin 2001 Ch. 3-4); PSU-level permutation on few PSUs (2-8 typical for survey designs) produces near-degenerate permutation support and poor power. Jackknife is a design-based variance approximation — PSU-level LOO within strata is the canonical survey jackknife (Rust & Rao 1996); unit-level LOO under clustering would underestimate SE. Both allocators respect strata (the primary survey-design axis). Neither is “right” in all dimensions; each is the defensible analog for its hypothesis-testing vs variance-approximation role.
Note (default variance_method deviation from R): R’s
synthdid::vcov()defaults tomethod="bootstrap"; ourSyntheticDiD.__init__defaults tovariance_method="placebo". Library deviation rationale: placebo avoids the ~5–30× per-draw Frank-Wolfe refit slowdown. All three variance methods (placebo, bootstrap, jackknife) now support both pweight-only and full strata/PSU/FPC survey designs (see the survey support matrix above); users can opt into R’s default withvariance_method="bootstrap", which is also the recommended choice on surveys with few PSUs per stratum (jackknife is anti-conservative in that regime per the “Note (survey + jackknife composition)” above). Placebo (Algorithm 4) and bootstrap (Algorithm 2 step 2) both track nominal calibration in the committed coverage MC; see the calibration table below.Note (survey + bootstrap composition — PR #355): Restored capability. The bootstrap survey path solves the weighted Frank-Wolfe variant of
_sc_weight_fwaccepting per-unit weights in loss and regularization. For unit weights:min_{ω simplex} Σ_t (Σ_i rw_i · ω_i · Y_i,pre[t] - treated_pre[t])² + ζ²·Σ_i rw_i · ω_i²Implementation: column-scales A by
rw_control(so the loss term reads||A·diag(rw)·ω - b||²) and passesreg_weights=rw_controlto the weighted Rust kernel for the diag(rw) penalty. The FW returns ω on the standard simplex;_bootstrap_secomposesω_eff = rw·ω / Σ(rw·ω)for the downstreamcompute_sdid_estimatorcall (which expects a normalized weight vector). For time weights, the loss becomes per-row weighted (||diag(√rw)·(A·λ - b)||²) and regularization on λ stays uniform — λ is per-period, rw is per-control, no alignment for per-λ reg weighting.Argmin-set caveat (deliberate): this objective is NOT the same as directly minimizing the standard SDID loss on
ω_eff(the scaling factor(Σ rw·ω)²enters the loss-on-θ reparameterization non-constantly). The chosen form mirrors the spirit of the pre-PR-#351 Rao-Wu fixed-weight composition (rescale + renormalize) but with ω re-estimated per draw under the weighted objective, so weight-estimation uncertainty propagates correctly. No external R/Julia parity anchor exists because neither package defines survey-weighted SDID FW; validation rests on the coverage MC calibration row below (stratified_survey × bootstrap, target rejection ∈ [0.02, 0.10] at α=0.05).Per-draw dispatch:
pweight-only →
rw = w_control[boot_idx_control](constant per draw, no Rao-Wu).full design →
rw = generate_rao_wu_weights(resolved_survey_unit, rng)per draw, sliced over the resampled units. Rao-Wu rescales weights by(n_h/m_h)·r_hiwithin each stratum; degenerate-retry on zero-mass control or treated draws.single-PSU short-circuit: unstratified single-PSU designs return NaN SE (resampling one PSU yields the same subset every draw — bootstrap distribution is unidentified).
Note (survey + placebo composition): Stratified-permutation allocator composed with the same weighted Frank-Wolfe kernel from the bootstrap survey path. Each placebo draw:
For each stratum
hcontaining actual treated units, drawsn_treated_hpseudo-treated indices uniformly without replacement fromcontrols_in_h. Non-treated strata contribute their controls unconditionally to the pseudo-control set.Pseudo-treated means are survey-weighted:
Y_pseudo_t = np.average(Y[:, pseudo_treated_idx], weights=w_control[pseudo_treated_idx]).Weighted Frank-Wolfe re-estimates ω and λ on the pseudo-panel using
compute_sdid_unit_weights_survey(rw_control=w_control[pseudo_control_idx], ...)andcompute_time_weights_survey(...). Post-optimization compositionω_eff = rw·ω/Σ(rw·ω)with zero-mass retry.SDID estimator on the pseudo-panel; Algorithm 4 SE
sqrt((r-1)/r)·std(placebo_estimates, ddof=1).
Fit-time feasibility guards (per
feedback_front_door_over_retry_swallow.md): four distinct failure cases are rejected before entering the retry loop, each with a targetedValueError:Case B (
n_controls_h == 0for some treated-containing stratum): the stratum has treated units but no controls — no pseudo-treated set can be drawn.Case C (
0 < n_controls_h < n_treated_h): the stratum has fewer controls than treated units, so exact-count without-replacement sampling is impossible.Case E (row-count guards passed but
n_positive_weight_controls_h < n_treated_h): the stratum has enough raw controls but too few have positive survey weight. Since the pseudo-treated mean usesnp.average(Y, weights=w_control[idx]), draws can pick all-zero-weight subsets (ZeroDivisionError on np.average) and the retry loop would swallow them as a genericn_successful=0warning +SE=0.0.Case D (effective single-support — every treated stratum collapses to one positive-mass mean): two shapes trigger this. (D-classical)
n_controls_h == n_treated_hso the without-replacement permutation has only one subset. (D-effective)n_c_h > n_t_h(raw count allows multiple subsets) butn_positive_weight_controls_h < 2— every successful pseudo-treated mean reduces to the unique positive-weight control’s outcome (zero-weight cohabitants contribute 0 to numerator and denominator). Both shapes give a degenerate null (SE = FP noise ~1e-16). Non-degeneracy requires bothn_c_h > n_t_hANDn_positive_weight_controls_h >= 2for at least one treated stratum.
Partial-permutation fallback is rejected for all four cases — it would silently change the null distribution and produce an incoherent test.
Scope note — what is NOT randomized: the stratum marginal is preserved exactly by construction (each draw pulls the same count per treated stratum). The PSU axis is not randomized (permutation is unit-level within strata). This is conservative under clustering (ignores within-stratum PSU correlation in the null) but aligns with the classical stratified permutation test literature. See Pesarin (2001) Multivariate Permutation Tests, Ch. 3-4; Pesarin & Salmaso (2010) Permutation Tests for Complex Data.
Validation: no external R/Julia parity anchor (neither package defines survey-weighted SDID placebo). Correctness rests on: (a) stratum-membership contract enforced by construction + per-draw
rng.choiceinterception regression that captures every actual sampledpseudo_treated_idxand asserts each sampled control’s stratum membership ⊆ treated-strata set, (b) Case B / C / D / E front-door guards with targeted-message regression tests, (c) SE-differs-from-pweight-only cross-surface sanity, (d) deterministic-dispatch regression.Note (survey + jackknife composition): PSU-level leave-one-out with stratum aggregation (Rust & Rao 1996). For a design with strata
h = 1..Hand PSUsj = 1..n_hwithin each stratum:SE² = Σ_h (1 - f_h) · (n_h - 1)/n_h · Σ_{j∈h} (τ̂_{(h,j)} - τ̄_h)²where
τ̂_{(h,j)}is the SDID estimator computed after leaving out all units in PSUjof stratumh;τ̄_his the stratum-level mean of successful LOO estimates;f_h = n_h_sampled / fpc[h]is the per-stratum sampling fraction. FPC is stored as a per-unit population-count array bySurveyDesign.resolve(seesurvey.py:338-356, wherefpc_h < n_psu_his the validation constraint), sof_his recovered byf_h = n_h / fpc[strata == h][0]. No FPC →f_h = 0.Fixed weights per LOO: matches Algorithm 3 of Arkhangelsky et al. (2021). ω is subsetted over kept controls, composed with kept
w_control, renormalized (ω_eff_kept = rw·ω / Σ(rw·ω)); λ is held at the fit-time value. Rationale: jackknife is a design-based variance approximation, not a refit-variance bootstrap. Re-estimating λ or ω per LOO would conflate weight-estimation uncertainty (bootstrap’s domain) with sampling uncertainty (jackknife’s domain).Undefined-replicate handling (return NaN, do NOT silently skip): the Rust & Rao formula requires
τ̂_{(h,j)}be defined for every PSUjin every contributing stratum. If any single LOO in a contributing stratum (n_h ≥ 2) is not computable — (a) deletion removes all treated units (e.g., all treated in one PSU), (b)ω_eff_kept.sum() ≤ 0after composition, (c)w_treated_kept.sum() ≤ 0, (d) the SDID estimator raises or returns non-finite τ̂ — the overall SE is undefined and the method returnsSE=NaNwith a targetedUserWarningnaming the stratum / PSU / reason. Silently skipping the missing LOO while still applying the(n_h-1)/n_hfactor would systematically under-scale variance (silently wrong SE). Users needing a variance estimator that accommodates PSU-deletion infeasibility should usevariance_method="bootstrap", whose pairs-bootstrap has no per-LOO feasibility constraint.Zero-variance vs undefined distinction: when every stratum contributes but
total_variance == 0.0by legitimate design — full-census FPC (f_h = 1→(1 - f_h) = 0zeros the contribution even when within-stratum dispersion is non-zero) or exact-zero within-stratum dispersion — the jackknife SE is zero, not undefined._jackknife_se_surveyreturnsSE = 0.0in that case.SE = NaNis reserved for the truly-undefined cases documented above (all strata skipped; any undefined delete-one replicate).lonely_psucontract:SurveyDesign(lonely_psu="remove")(default) and"certainty"are both accepted, but with different semantics when every stratum is singleton:"remove"silently skips singleton strata (matches Rsurvey::svyjkn— they’re dropped from the variance computation). If every stratum is skipped, returnsSE = NaNwith the “every stratum was skipped” warning (no contributing stratum, undefined)."certainty"treats singleton strata as explicit zero-variance contributors (sampled with certainty, no sampling variance). Singleton strata still contribute 0 to total variance, but the stratum counts as “contributing” to the overall design — so an all-singleton design returnsSE = 0.0(legitimate zero variance), not NaN. Mirrorscompute_survey_vcov’stest_all_certainty_psu_zero_vcovcontract for other estimators.
lonely_psu="adjust"(R’s overall-mean fallback) is not yet supported on the SDID jackknife path and raisesNotImplementedErrorat fit-time; users needing that semantic should pickvariance_method="bootstrap"(which supports all three modes via the weighted-FW + Rao-Wu path) or switch the design to"remove"/"certainty".Stratum-skip handling (silent, documented): strata with
n_h < 2are silently skipped (stratum-level variance unidentified — thelonely-PSUcase in Rsurvey::svyjkn). If every stratum is skipped, returnsSE=NaNwith a separateUserWarning. PSU-None designs: each unit is treated as its own PSU within its stratum (matches the implicit-PSU convention established in PR #355 R8 P1). Unstratified single-PSU short-circuits toSE=NaN.Scope note — what is NOT randomized: stratum membership and PSU composition are fixed by design. The formula only captures within-stratum variation; between-stratum variance is absorbed into the analytical-TSL / design assumption. This is canonical survey-jackknife behavior (Rust & Rao 1996) and matches R’s
survey::svyjknunder stratified designs.Known limitation — anti-conservatism with few PSUs per stratum: with
n_h = 2per stratum (the minimum for variance identifiability), within-stratum jackknife has only 1 effective DoF per stratum — a well-documented limitation of the stratified jackknife formula. On the coverage MCstratified_surveyDGP (2 PSUs × 2 strata),se_over_truesd ≈ 0.46at α=0.05. Users needing tight SE calibration with few PSUs should prefervariance_method="bootstrap", which validates at near-nominal calibration on the same DGP.Validation: (a) hand-computed 2-stratum FPC magnitude regression (
test_jackknife_full_design_fpc_reduces_se_magnitude— assertsSE_fpc == SE_nofpc · sqrt(1 - f)atrtol=1e-10), (b) self-consistency between the returned SE and the stratum-aggregation formula applied to the returned LOO estimates, (c) single-PSU-stratum skip, (d) all-strata-skipped UserWarning + NaN, (e) unstratified single-PSU short-circuit, (f) deterministic-dispatch regression.Note: P-value computation is variance-method dependent. Placebo (Algorithm 4) uses the empirical null formula
max(mean(|variance_effects| ≥ |att|), 1/(r+1))because permuting control indices generates draws from the null distribution (centered on 0). Bootstrap (Algorithm 2) and jackknife (Algorithm 3) use the analytical p-value fromsafe_inference(att, se)(normal-theory): bootstrap draws are centered onτ̂(sampling distribution of the estimator) and jackknife pseudo-values are not null draws, so the empirical null formula is invalid for them. This matches R’ssynthdid::vcov()convention, where variance is returned and inference is normal-theory from the SE.Note (coverage Monte Carlo calibration):
benchmarks/data/sdid_coverage.jsoncarries empirical rejection rates across the three variance methods on 4 representative null-panel DGPs (500 seeds × B=200, regenerable viabenchmarks/python/coverage_sdid.py). The fourth DGP (stratified_survey, added in PR #355) validates the survey-bootstrap calibration; jackknife is also reported with a documented anti-conservatism caveat; placebo is N/A on this DGP because its cohort packs into a single stratum with 0 never-treated units (stratified-permutation allocator is structurally infeasible — seetest_placebo_full_design_raises_on_zero_control_stratum/_undersupplied_stratumfor the enforced behavior). Under H0 the nominal rejection rate at each α equals α; rates substantially above α indicate anti-conservatism, rates below indicate over-coverage.DGP
method
α=0.01
α=0.05
α=0.10
mean SE / true SD
balanced (N_co=20, N_tr=3, T_pre=8, T_post=4)
placebo
0.016
0.060
0.086
1.05
balanced
bootstrap
0.028
0.078
0.116
1.05
balanced
jackknife
0.066
0.112
0.154
1.08
unbalanced (N_co=30, N_tr=8, heterogeneous unit-FE)
placebo
0.006
0.032
0.070
1.08
unbalanced
bootstrap
0.008
0.038
0.080
1.11
unbalanced
jackknife
0.024
0.076
0.120
0.99
AER §6.3 (N=100, N_tr=20, T=120, T_pre=115, rank=2, σ=2)
placebo
0.018
0.058
0.086
0.99
AER §6.3
bootstrap
0.010
0.040
0.078
1.05
AER §6.3
jackknife
0.030
0.080
0.150
0.90
stratified_survey (N=40, strata=2, PSU=2/stratum, ICC≈0.84)
bootstrap
0.024
0.058
0.094
1.13
stratified_survey
jackknife
0.358
0.450
0.512
0.46
Reading:
bootstrap(paper-faithful refit) andplaceboboth track nominal calibration across all three non-survey DGPs (rates within Monte Carlo noise at 500 seeds; 2σ MC band ≈ 0.02–0.05 at p ≈ 0.05–0.10).jackknifeis slightly anti-conservative on the smaller panels (balanced, AER §6.3) at α=0.05 (rejection 0.112 and 0.080 vs the 0.05 target). Arkhangelsky et al. (2021) §6.3 reports mixed jackknife evidence (98% coverage — slightly conservative — under iid, and 93% coverage — slightly anti-conservative — under AR(1) ρ=0.7), so the direction of our observation is consistent with the AR(1) branch of the paper’s evidence rather than the iid branch. Themean SE / true SDcolumn compares mean estimated SE to the empirical sampling SD of τ̂ across seeds.stratified_survey × bootstrap(PR #355): validates the weighted-FW + Rao-Wu composition added in that PR. Rejection at α=0.05 is 0.058 (inside the calibration gate [0.02, 0.10] widened from a 2σ band to accommodate the high ICC ≈ 0.84 induced bypsu_re_sd=1.5with only 4 PSUs total).mean SE / true SD = 1.13indicates the bootstrap is slightly conservative (overestimates the empirical sampling SD by ~13%) — the safer direction; expected under Rao-Wu rescaling with few PSUs because the per-draw weights inflate variance from the resampling structure on top of the fit-time uncertainty.stratified_survey × jackknife: reported with an anti-conservative caveat. Rejection at α=0.05 is 0.450 (far outside any reasonable calibration gate) andse_over_truesd ≈ 0.46. This is the documented limitation of the stratified PSU-level jackknife formula withn_h = 2PSUs per stratum: within-stratum variance has only 1 effective DoF per stratum, and between-stratum variation is absorbed into the design assumption rather than the SE. The bootstrap row on the same DGP demonstrates that the fix is to pickvariance_method="bootstrap"when the design has few PSUs per stratum. This row is committed for transparency; the methodology Note above (§”Note (survey + jackknife composition)”) explicitly flags this regime and recommends bootstrap.stratified_survey × placebo: N/A on this DGP by construction (its cohort packs all treated units into stratum 1, which has 0 never-treated units, so the stratified-permutation allocator raises Case B — treated-containing stratum with zero controls — at fit-time; see Case B / C definitions in “Note (survey + placebo composition)” above). The placebo survey path is exercised under feasible structures intests/test_survey_phase5.py::TestSDIDSurveyPlaceboFullDesign; calibration on a placebo-feasible DGP is a future MC extension.The schema smoke test is
TestCoverageMCArtifact::test_coverage_artifacts_present; regenerate the JSON viapython benchmarks/python/coverage_sdid.py --n-seeds 500 --n-bootstrap 200 --output benchmarks/data/sdid_coverage.json(~15–40 min on M-series Mac, Rust backend — warm-start convergence makes newer runs faster than the original cold-start one).Artifact cadence (documentation-grade, not pinned-regression-grade): this file is a documentation substrate — the table above transcribes from it, and the schema test just checks structure. It is not numerically pinned by any regression test, because MC noise at 500 seeds × B=200 (2σ ≈ 0.02–0.05 per cell) makes tight bounds fragile and loose bounds uninformative. The runtime characterization test that guards the one regression class worth catching (dispatch breakage → rejection rate ≈0 or ≈0.5, or SE-collapse) is
TestPValueSemantics::test_bootstrap_p_value_null_dispersion(slow; calibration-agnostic dispersion + loose rejection-rate band). Regenerate the JSON when a methodology change materially shifts per-draw numerics — SE formula, new variance method, FW solver swap, paper-procedure correction. Do not regenerate on refactors that preserve the FW global optimum (warm-start vs cold-start, backend migration, pure renames, docstring fixes) — those stay within MC noise of the committed numbers. Per-seed bit-identity on the captured fixture is the cheaper, stricter check: seeTestScaleEquivariance::test_baseline_parity_small_scale[bootstrap]atrel=1e-14.Note: Internal Y normalization. Before weight optimization, the estimator, and variance procedures,
fit()centers Y bymean(Y_pre_control)and scales bystd(Y_pre_control);Y_scalefalls back to1.0when std is non-finite or below1e-12 * max(|mean|, 1). Auto-regularization andnoise_levelare computed on normalized Y; user-suppliedzeta_omega/zeta_lambdaare divided byY_scaleinternally for Frank-Wolfe. τ, SE, CI, the placebo/bootstrap/jackknife effect vectors,results_.noise_level, andresults_.zeta_omega/results_.zeta_lambdaare all reported on the user’s original outcome scale (user-supplied zetas are echoed back exactly to avoid float roundoff). Mathematically a no-op — τ is location-invariant and scale-equivariant, and FW weights are invariant under(Y, ζ) → (Y/s, ζ/s)— but prevents catastrophic cancellation in the SDID double-difference when outcomes span millions-to-billions (see synth-inference/synthdid#71 for the R-package version of this issue). Normalization constants are derived from controls’ pre-period only so the reference is unaffected by treatment.in_time_placebo()andsensitivity_to_zeta_omega()reuse the exact sameY_shift/Y_scalecaptured on the fit snapshot: they normalize the re-sliced arrays before re-running Frank-Wolfe, passzeta / Y_scaleto the weight solvers, and rescale the returnedattandpre_fit_rmsebyY_scalebefore reporting; unit-weight diagnostics (max_unit_weight,effective_n) are scale-invariant and reported directly.
Validation diagnostics (post-fit methods on SyntheticDiDResults):
Trajectories (
synthetic_pre_trajectory,synthetic_post_trajectory,treated_pre_trajectory,treated_post_trajectory): retained on results to support plotting and custom fit metrics.synthetic_pre_trajectory = Y_pre_control @ ω_eff;treated_pre_trajectoryis the survey-weighted treated mean (matches the Frank-Wolfe target).pre_treatment_fitis recoverable asRMSE(treated_pre_trajectory, synthetic_pre_trajectory).get_loo_effects_df(): user-facing join of the jackknife leave-one-out pseudo-values (stored invariance_effects) to the underlying unit identities. Unit-level LOO only — available on the non-survey and pweight-only jackknife paths (classical Algorithm 3: one LOO per unit, firstn_controlpositions map tocontrol_unit_ids, nextn_treatedtotreated_unit_ids;att_loois NaN when the zero-sum composed-weight guard fired for that unit;delta_from_full = att_loo - att). Under the full-design survey jackknife path (PSU-level LOO with stratum aggregation, Rust & Rao 1996), the underlying replicates are PSU-level rather than unit-level — the accessor raisesNotImplementedErrorpointing toresult.variance_effectsfor the raw PSU-level replicate array. Dispatch is gated by an explicit_loo_granularityflag set at fit-time ("unit"vs"psu"). Requiresvariance_method='jackknife'; raisesValueErrorotherwise.get_weight_concentration(top_k=5): returnseffective_n = 1/Σω²(inverse Herfindahl),herfindahl,top_k_share,top_k. Operates onself.unit_weightswhich stores the composedω_eff; for survey-weighted fits the metrics reflect the population-weighted concentration, not the raw Frank-Wolfe solution.in_time_placebo(fake_treatment_periods=None, zeta_omega_override=None, zeta_lambda_override=None): re-slices the pre-window at each fake treatment period and re-fits both ω and λ via Frank-Wolfe. Default sweeps every feasible pre-period (position indexi ≥ 2so ≥2 pre-fake periods remain for weight estimation,i ≤ n_pre - 1so ≥1 post-fake period exists). Credible designs produce near-zero placebo ATTs; departures indicate pre-treatment dynamics the estimator is picking up.Note: Regularization reuses
self.zeta_omega/self.zeta_lambdafrom the original fit (matches Rsynthdidconvention of treating regularization as a property of the fit).*_overridere-fits with new values.Note: Infeasibility-only NaN — the method emits NaN for dimensional infeasibility (e.g., survey composition producing zero weight sum on the fake window); Frank-Wolfe non-convergence is not detectable mid-solver, so
pre_fit_rmseis the user-facing signal for poor refit quality. Passing afake_treatment_periodinpost_periodsraisesValueError(not a placebo).
sensitivity_to_zeta_omega(zeta_grid=None, multipliers=(0.25, 0.5, 1.0, 2.0, 4.0)): re-fits ω at each zeta value on the original pre-window. Default grid ismultipliers * self.zeta_omega— a 5-point grid spanning 16x from smallest to largest multiplier, symmetric in log space around 1.0. Returnsatt,pre_fit_rmse,max_unit_weight,effective_nper row.Note: Time weights are held fixed at the original Frank-Wolfe output (
self.time_weights_array), not re-fit. This isolates sensitivity tozeta_omegaspecifically; sensitivity tozeta_lambdais not currently exposed.Note: At
multiplier=1.0(orzeta_gridcontainingself.zeta_omega), the ATT reproducesself.attto machine precision with the same seeded draw.
Reference implementation(s):
R:
synthdid::synthdid_estimate()(Arkhangelsky et al.’s official package)Key R functions matched:
sc.weight.fw()(Frank-Wolfe),sparsify_function(sparsification),vcov.synthdid_estimate()(variance)
Requirements checklist:
[x] Unit weights: Frank-Wolfe on collapsed form (T_pre, N_co+1), two-pass sparsification (100 iters -> sparsify -> 10000 iters)
[x] Time weights: Frank-Wolfe on collapsed form (N_co, T_pre+1), last column = per-control post mean
[x] Unit and time weights: sum to 1, non-negative (simplex constraint)
[x] Auto-regularization: noise_level = sd(first_diffs), zeta_omega = (N1*T1)^0.25 * noise_level, zeta_lambda = 1e-6 * noise_level
[x] Sparsification: v[v <= max(v)/4] = 0; v = v/sum(v)
[x] Placebo SE formula: sqrt((r-1)/r) * sd(placebo_estimates)
[x] Placebo SE: re-estimates omega and lambda per replication (matching R’s update.omega=TRUE, update.lambda=TRUE), with R-default warm-start (
init_omega = sum_normalize(unit_weights[pseudo_control_idx])per draw;init_lambda = time_weights) matchingsynthdid:::placebo_se’sweights.boot$omega = sum_normalize(weights$omega[ind[1:N0_placebo]])pattern. R-parity verified at< 1e-8SE tolerance viatests/test_methodology_sdid.py::TestJackknifeSERParity::test_placebo_se_matches_rusing R’s exact permutation sequence threaded through the_placebo_indicestest seam.[x] Bootstrap: paper-faithful Algorithm 2 step 2 — re-estimates ω̂_b and λ̂_b per draw via two-pass sparsified Frank-Wolfe on the resampled panel using the fit-time normalized-scale zeta. Matches R’s default
synthdid::vcov(method="bootstrap")(which rebindsattr(estimate, "opts")so the renormalized ω serves only as Frank-Wolfe initialization). Survey designs (pweight-only AND strata/PSU/FPC) are supported via the weighted-FW + hybrid pairs-bootstrap + Rao-Wu rescaling composition described in the “Note (survey + bootstrap composition)” above (PR #355).[x] Placebo: Survey-weighted pseudo-treated means + weighted-FW re-estimation on pseudo-panel for both pweight-only and full-design paths. Full-design path (strata/PSU/FPC) uses stratified-permutation allocator — see “Note (survey + placebo composition)” above.
[x] Jackknife SE: fixed weights, LOO all units, formula
sqrt((n-1)/n * sum((u-ubar)^2)). Full-design path (strata/PSU/FPC) uses PSU-level LOO with stratum aggregation — see “Note (survey + jackknife composition)” above.[x] Jackknife: NaN SE for single treated or single nonzero-weight control
[x] Jackknife: analytical p-value (not empirical)
[x] Returns both unit and time weights for interpretation
[x] Column centering (intercept=True) in Frank-Wolfe optimization
SyntheticControl#
Primary source: Abadie, A., Diamond, A., & Hainmueller, J. (2010). “Synthetic Control Methods for Comparative Case Studies: Estimating the Effect of California’s Tobacco Control Program.” JASA, 105(490), 493–505. Method originates in Abadie & Gardeazabal (2003). Paper reviews on file: docs/methodology/papers/abadie-diamond-hainmueller-2010-review.md (primary), ...-2015-review.md, abadie-2021-review.md, chernozhukov-wuthrich-zhu-2021-review.md, firpo-possebom-2018-review.md (inference: sensitivity analysis + confidence sets by test inversion).
Classic synthetic control (donor/unit weights only) for a single treated unit, distinct from SyntheticDiD (Arkhangelsky et al. 2021), which adds time weights and ridge regularization. Equation (1) of ADH 2010 shows classic SCM generalizes the TWFE/DiD model (recovered when the factor loadings λ_t are constant in time).
Assumption checks / warnings:
One treated unit, block (absorbing) assignment after
T0; remaining units are the never-exposed donor pool (Section 2.2). Rejects >1 ever-treated unit (passtreated_unit=+ curatedonor_pool=); rejects an ever-treated unit in the donor pool (contamination).No anticipation / absorbing treatment.
post_periodsmust be a contiguous suffix of the time axis, cross-checked against the treated unit’sDcolumn (D==1in any pre period →ValueError), on both the inferred and explicit branches.Good pre-treatment fit is required, not assumed (journal p. 495). Emits a
UserWarningwhen pre-period RMSPE exceeds the SD of the treated unit’s pre-period outcomes.No interference / SUTVA across units; donor-pool curation (exclude units with their own intervention/large shocks) — analyst-supplied via
donor_pool=.
Estimator (Section 2.3):
Predictor matrices
X1(k×1 treated) /X0(k×J donors) = covariatesZ+ linear combinations of pre-period outcomes (predictorsaveraged overpredictor_window,special_predictors, and/or per-period outcome lagspre_period_outcomes). Canonical row order: predictor averages → special predictors → outcome lags (the row order matches RSynth::dataprep; aggregation semantics differ — see thena.rmdeviation below).Inner solve:
W*(V) = argmin_W (X1 − X0 W)' diag(V) (X1 − X0 W)s.t.w_j ≥ 0, Σ w_j = 1. Implemented by foldingV^½into the predictors (packed = [V^½·X0 | V^½·X1]) and calling the Frank-Wolfe simplex solverutils._sc_weight_fw(intercept=False, zeta=0).Vselection (v_method):"nested"chooses diagonal PSDVminimizing the pre-period outcome MSPEmean((Z1 − Z0·W*(V))²)over all pre periods;"cv"choosesVby out-of-sample cross-validation (ADH 2015 §; Abadie 2021 Eq. 9 — see the per-window re-aggregation Note);"inverse_variance"uses the closed-formv_h = 1/Var(X_{h·})(Abadie 2021 §3.2(a); no search);"custom"skips the search and uses a user-suppliedcustom_v(trace-normalized).mspe_vreports the selectedV’s objective value — the pre-period MSPE under"nested", the held-out validation-window MSPE under"cv", andNonefor the search-free"custom"/"inverse_variance"paths (not comparable across methods).Effect: gap path
α̂_1t = Y_1t − Σ_j w_j·Y_jt;att= mean post-period gap;pre_rmspe= pre-period fit diagnostic.
Inference: No analytical standard error (Section 2.4) — se/t_stat/p_value/conf_int are always NaN. Significance comes from in-space placebo permutation inference via SyntheticControlResults.in_space_placebo(): reassign treatment to each donor, refit a synthetic control for it, and rank the treated unit’s post/pre RMSPE ratio (rmspe_ratio = RMSPE_post / RMSPE_pre = sqrt(MSPE_post / MSPE_pre)) among all units; placebo_p_value = rank / (n_placebos + 1), where rank = 1 + #{placebos with ratio ≥ treated ratio} — an upper-tail rank test on the (unsigned) RMSPE-ratio statistic, ties counted conservatively via ≥. Because the ratio squares the gaps it is direction-agnostic: a large ratio signals an effect of either sign, so this is NOT a signed/one-directional (“one-sided”) hypothesis test on the treatment-effect direction. ADH 2010 §3.4 reports the MSPE ratio (the square of rmspe_ratio); the two are monotone-equivalent, so the rank and p-value are identical — only the reported statistic’s scale differs. rmspe_ratio (the treated statistic) is computed at fit time; placebo_p_value / n_placebos / n_failed / n_infeasible are populated by the opt-in in_space_placebo() call (n_failed = solver non-convergences, n_infeasible = structural cv exclusions — see the split Note below). get_placebo_df() returns the per-unit ratio table used for the rank (the per-period placebo gap paths for a “spaghetti” plot are retained internally, not in this summary).
ADH-2015 §4 robustness diagnostics (opt-in): Four further diagnostics from Abadie-Diamond-Hainmueller (2015, §4), each a thin re-run of the validated solver (the regression-weight diagnostic is pure linear algebra, no re-fit) — populated only when called and surfaced under estimator_native_diagnostics (the analytical inference contract is unchanged; se/t_stat/p_value/conf_int/is_significant stay bound to the NaN analytical p_value):
Leave-one-out donor robustness (
leave_one_out()): drops each reportably-weighted donor and re-fits the treated unit against the reduced pool, returning a per-drop ATT /delta_atttable (astatus="baseline"row first, then one row per dropped donor sorted by|delta_att|). A largedelta_attflags single-donor dependence (a single dominant donor is still dropped — the others absorb its mass — and its largedelta_attis the intended signal, not a failure). The reporting stack’s headline donor-sensitivity number ismax_abs_delta_att=max |delta_att|over the drops (baseline-relative, so a uniform shift of every drop away from the full-fit ATT is not masked the way a raw ATT range would be).get_leave_one_out_gaps()returns the per-drop trajectories for the overlay plot. Fails closed on a non-converged treated fit or< 2donors.In-time (backdating) placebo (
in_time_placebo()): reassigns the intervention to an earlier pre-datet_f, re-fits using ONLY pre-t_finformation (TRUNCATE convention — see Note), and reports the placebo “effect” over the held-out window[t_f, T0)— ~0 if there is no real pre-period effect (ADH 2015 Fig. 4, German reunification backdated to 1975). Sweeps every feasible interior pre-date by default (≥2 pre-fake + ≥1 post-fake); an explicit post-period / non-pre date raises, a valid-but-dimensionally-infeasible date yields astatus="infeasible"row (no raise).Regression-weight extrapolation diagnostic (
regression_weights(), journal pp. 498-499): computes the implied donor weightsW^reg = X0a'(X0a X0a')^{-1} X1aof the regression counterfactualB̂'X_1on the baseline fit’s predictor matrices with an intercept row prepended (soι'W^reg = 1under full row rank — matching ADH’s “if a constant is included”). Regression is then also a weighting estimator summing to one, but with UNRESTRICTED weights:w_reg < 0or> 1flags donors it extrapolates on, outside the donors’ convex hull the simplex-constrained SC never leaves (in ADH’s application regression assigned negative weights to Greece/Italy/Portugal/Spain). Returns a per-donorw_reg/w_sc/extrapolates/abs_extrapolationtable sorted by extrapolation magnitude; the reporting headline isn_extrapolating. Pure linear algebra (no re-fit), computed as the min-norm least-squares solution. At full row rank the system is exactly solvable, soW^regis invariant to per-predictor row scaling — identical across the standardized (nested/custom) and raw (inverse_variance) predictor spaces (differing only forcv, which matches on validation-window predictors). Fails closed (for consistency with the other diagnostics) on a non-converged treated fit or< 2donors. Note: the sum-to-one property AND the row-scaling invariance both hold only at full ROW rank; when the intercept-augmented predictor matrix is rank-deficient (k+1 > J— e.g. the default per-period outcome lags whenT0 > J— or collinear predictors) the reportedW^regis the MIN-NORM least-squares solution (aUserWarningfires;_regw_rank_deficientis set),Σ W^regneed not equal 1, and — because the inexact fit’s residual metric is reweighted by row scaling — the min-normW^regis computed in the captured fit space and can itself differ across predictor spaces (still an informative extrapolation witness; within a fixed space, uniqueness of the least-squares solution depends on the predictor COLUMN rank — an overdetermined full-column-rank case has a unique solution that merely need not sum to 1).Sparse-SC subset search (
sparse_synthetic_control(sizes=None, max_subsets=50000), journal pp. 506-507): for each target sizel < J, EXHAUSTIVELY searches allC(J, l)donor subsets holding V FIXED at the baseline fit’s V (ADH hold V fixed to make the combinatorial search tractable, footnote 20 — this reuses the captured fit-time predictor matrices + V, unlikeleave_one_out()/in_time_placebo()which re-search V), refits the inner simplex solve per subset, and reports the best size-lsynthetic (lowest pre-period outcome MSPE). Shows how the fit degrades and the ATT moves as the synthetic is forced sparse (l = 4, 3, 2degrade “moderately”,l = 1much worse — a single-match design close to DiD, footnote 23). Astatus="baseline"row (the full fit) first, then one row per size with the winning subset’s ids / weights /pre_rmspe/att/delta_att;get_sparse_synthetic_control_gaps()returns the per-size winner paths for the overlay.sizes=Nonesweeps[1, 2, 3](clipped tol < J); a DEFAULTED size whoseC(J, l)exceedsmax_subsetsis skipped with a warning (a defaulted call never raises), while an explicitly requestedlwithC(J, l) > max_subsetsraisesValueError(the user-facing “exhaustive or raise” contract — no silent approximation). Non-converged subset solves are excluded and counted (n_failed); a size with no converged subset getsstatus="all_subsets_failed". Fails closed (warn + baseline-only) on a non-converged treated fit or< 2donors. Note: pre-fit typically degrades aslshrinks but strict monotonicity is NOT guaranteed — subsets are ranked by the uniform-outcome pre-period MSPE while each subset’s weights are V-optimal on the predictor objective.
Confidence sets by test inversion (Firpo & Possebom 2018, §4, opt-in): A confidence set for the treatment-effect path, built ON TOP of the in-space placebo and surfaced under estimator_native_diagnostics (the analytical inference contract is unchanged — see the non-analytical Note below). Under a common-effect sharp null H_0^f: α_1t = f(t) (Eq 11) the donor synthetic controls and the pre-period MSPE denominators do not change — only the post-period gaps shift by f(t) — so the test is a pure re-ranking of the gap paths in_space_placebo() already computed (no synthetic-control refits):
test_sharp_null(effect, gamma=0.1)forms the modified RMSPE ratioRMSPE^f_j = sqrt(mean_post((α̂_jt − f(t))²) / pre_denom_j)for every unit (Eq 12) and the permutation p-valuep^f = (1 + #{converged placebos j : RMSPE^f_j ≥ RMSPE^f_1}) / (n_ref + 1)(Eq 13 atφ=0,v=(1,…,1)— the equal-weights benchmark).effectis a scalar (a constant-in-time effect) or a length-n_postarray (an arbitrary post-period pathf(t)— e.g. an intervention cost path or a theory-predicted shape). Atf≡0this is identically the in-spaceplacebo_p_value(Eq 5 = Eq 13), held bit-for-bit by reusing each unit’s floored pre-period denominator persisted from the placebo run (the pre window isf-free, so the denominator is grid-invariant; the floor scale is per-unitmax|Z1_j|).confidence_set(family="constant"|"linear", gamma=0.1, bounds=None, n_grid=200)inverts that test over a one-parameter family:"constant"→f(t)=c(Eq 15), a confidence interval for a constant effect (Eq 16);"linear"→f(t)=c̃·(t−T0)with the 1-based post-period index (Eq 17), a confidence set over the slopec̃(Eq 18). Membership is the paper’s strictp^c > γ(Eq 14 — see the boundary Note). Withbounds=Nonethe set is recovered EXACTLY:p^cis a piecewise-constant step function (each placebo’s indicator flips only at the real roots ofA_j(c)·D_1 = A_1(c)·D_j, a quadratic inc), so the placebo breakpoints partition the line andpis evaluated once per induced open interval AND at each breakpoint — where a tie under≥can liftpabove γ, so an isolated accepted singleton (a tangent / co-located root) is captured. The accepted set is the union of accepted intervals/points, with no centering or monotonicity assumption (a poor-pre-fit treated unit can have its accepted region in the tails, not around the point estimate).bounds=(lo,hi)instead scans a fixed grid (grid-limited membership). The summary is oneffect_confidence_set(status ∈ {"ran","empty","unbounded"}) and the full grid onget_confidence_set_df(). Fail-closed:γ < 1/(J+1)⇒p^c > γfor everyc⇒"unbounded"(±infendpoints + warning — the discrete-granularity point, fn 8); a treated unit lacking the best pre-fit can give a one-sided unbounded edge; if no interval or breakpoint is accepted the set is"empty"(NaN endpoints); a non-contiguous accepted region (disjoint components / an isolated singleton) reports the[lower, upper]hull withcontiguous=False+ a warning; and< 2donors / a non-converged treated fit / an unpickled result (no placebo reference set) raiseValueError. Scope: sensitivity weights (φ≠0, Eqs 7–9), the general test-statistic menuθ¹–θ⁵(Eq 19), one-sided (§7’s signed-tstatistic), and the multiple-outcome / multiple-treated extensions (§6) are deferred (flagged in the paper review checklist).
Conformal inference (Chernozhukov-Wüthrich-Zhu 2021, opt-in): Valid p-values for a hypothesized post-period effect trajectory and pointwise confidence intervals — what the placebo / test-inversion paths cannot give. Surfaced under estimator_native_diagnostics; the analytical se/t_stat/p_value/conf_int stay NaN (separate permutation object — see the non-analytical Note). Under a sharp null H0: θ = θ0 the counterfactual treated outcomes are imputed (Y^N_{1t} = Y_{1t} − θ0_t, t>T0; pre unchanged), a time-permutation-invariant proxy is fit UNDER THE NULL on all periods, and the statistic S_q(û) = ((1/√T*)·Σ_{t>T0}|û_t|^q)^{1/q} (CWZ §2.2) is referred to its permutation distribution (eq 2) by reshuffling residuals over time:
Proxy (CWZ §2.3, eqs 3–4): the canonical constrained-LS synthetic control — simplex weights
argmin_w Σ_{t=1}^T (Y^N_{1t} − Σ_j w_j Y^N_{jt})²s.t.w≥0, Σw=1, NO V-matrix, no intercept, outcomes-only, fit over ALL periods (footnote 9: “we estimate w under the null based on all the data”). Reuses the Frank-Wolfe simplex solverutils._sc_weight_fw(packed(T,J+1),zeta=0, intercept=False).conformal_test(effect, q=1, scheme="moving_block", n_iid=10000, seed=None)— joint sharp-null test (eqs 1–2).effectis a scalar (constant) or length-n_postpath;q ∈ {1,2,∞}(q=∞ismax|û_t|). The proxy is fit ONCE under the null and only residuals are permuted (footnote 7 — permuting residuals ≡ permuting data for a time-permutation-invariant proxy). Returnsp_value,S_observed,n_perms,proxy_converged.conformal_confidence_intervals(alpha=0.1, scheme="moving_block", n_iid=10000, bounds=None, n_grid=100, seed=None)— pointwise per-period CIs by test inversion (Algorithm 1). Per the paper (§2.2), each per-period CI for periodtusesZ = (Z_1,…,Z_{T0}, Z_t)— the pre-periods PLUS only periodt, the other post-periods dropped — a cleanT*=1conformal test (qis therefore inert:S_q = |û_t|). Returns one row per post period (lower/upper/point_estimate/status/contiguous); the full grid is onget_conformal_grid_df().conformal_average_effect(alpha=0.1, scheme="moving_block", n_iid=10000, bounds=None, n_grid=200, seed=None)— CI for the average effectT*^{-1}Σ_{t>T0}θ_tby test inversion (Appendix A.1): collapse the panel into non-overlappingT*-blocks (per-unit block averages), fit the proxy on the collapsedZ̄, permute theT/T*block residuals. The earliestT0 mod T*pre-periods are dropped to make the block count integral. RequiresT0 ≥ T*.Permutation schemes:
"moving_block"(Π_→,mcyclic shifts — valid under serially-dependent / stationary weakly-dependent errors, Assumption 2.2; the robust default) and"iid"(Π_all, sampled — valid under i.i.d. errors, Assumption 2.1, finer p-values). Both include the identity, so the permutation p-value is≥ 1/|Π|by construction.Fail-closed:
<1donor or an unpickled result (no fit snapshot) or non-finite panel cells /<2periods →ValueError; a single donor warns (degenerate proxyw=[1]);T*≥T0warns (the validity caveat — largeT0drives exactness); a non-converged grid point is indeterminate, not rejected — only a convergedp≤αrejects, so the set is the complement of the rejections (excluding non-converged points would understate the CI width); an accepted set touching a grid edge isstatus="grid_limited"; an empty accepted set isstatus="empty";α < 1/|Π|⇒ every value accepted, short-circuited tostatus="unbounded"with(−inf, +inf)endpoints (warned). Scope: one-sided / signed variants (§7), covariates folded into the proxy, and the AR/innovation-permutation path (Lemmas 5–7, for time-series proxies) are deferred (flagged in the paper review checklist).
Notes / deviations:
Note: The standardization divisor
divisor = sqrt(apply(cbind(X0,X1), 1, var))(per-predictor SD over donors+treated, ddof=1) and the inner/outer optimizer are not specified in ADH 2010 (which defers these numerics to Abadie & Gardeazabal 2003 App. B / theSynthsoftware). The divisor is pinned from the RSynth::synthsource;solution.vlives in this scaled predictor space, so the deterministic R-parity test feedscustom_vin the same scaled space.Note: The outer objective minimizes the pre-period outcome MSPE over all pre periods, whereas R
Synthuses atime.optimize.ssrwindow (1960–1969 in the Basque example). The nestedVtherefore differs from R by an efficiency-only choice (the paper notes inferential validity holds for anyV), so end-to-end nested parity is a tolerance band, not equality.Note:
Vis parametrized on the unit simplex via a softmax of an unconstrained vector (trace-normalization is identification-fixing, not a constraint loss); the multistart Nelder-Mead + derivative-free Powell polish approximates R’s best-of-optimxbehavior over the non-smooth outer objective.Note (out-of-sample CV
V-selection — per-window re-aggregation + fully-spanning precondition):v_method="cv"implements ADH 2015’s four-step out-of-sample procedure (Abadie 2021 Eq. 9): (1) split the pre-period positionally att0(v_cv_t0; defaultlen(pre)//2, Abadie 2021’st0 = T0/2) into a training windowpre[:t0]and a validation windowpre[t0:]; (2) for each candidateV, fit the training weightsW̃(V)on the training-window predictors; (3) pickV*minimizing the validation-window outcome MSPE ofW̃(V); (4) re-estimate the finalW* = W(V*)on the validation-window predictors. Each predictor spec is re-aggregated over each window — its op (mean/sum/identity) is recomputed over only the periods that fall in that window — exactly as ADH 2015’s CV re-runsdataprepseparately on each window (_truncate_specs_to_window+ a per-window_build_predictor_matrix+ a per-window_standardize, sinceV*is predictor-importance applied in each window’s own scaled space). The predictor dimensionkis preserved: re-aggregation changes each row’s values per window, not the row count, soVstaysk-dimensional throughout. Because each predictor is re-measured on the training window only for the V-search, the selection is genuinely out-of-sample for all predictor types — a spanning predictor’s validation-window observations never enter the training fit (the property a masked full-pre aggregate could not provide, since the full-pre value already bakes in the validation-window observations). The sameV*drives both fits with no zeroed coordinate, sov_weights(=V*) reproducedonor_weightson the validation-window predictors;predictor_balanceis reported on that same validation-window basis (each row’s values are the spec re-aggregated overpre[v_cv_t0:]; the row label remains the full spec identity so it stays aligned withv_weights).mspe_vreports the step-3 validation MSPE atV*. Deviation from R: RSynthhas no built-in CV function — ADH 2015’s CV is a manual two-dataprepre-run — so this re-aggregation reproduces that manual procedure for our absolute-period spec aggregates.Fully-spanning precondition (fail-closed): steps 2-3 re-aggregate each predictor on the training window and step 4 re-aggregates it on the validation window, so every predictor must be observed in both windows (have ≥1 period in each) to be re-measurable on each.
cvtherefore requires every predictor to span both windows; a violation raisesValueError. This holds for ADH 2015’s shared covariate / multi-period special predictors (which span the windows) but NOT for the default per-period outcome lags (each is single-period and lives in one window only) —cvwith the bare default predictors is rejected with guidance to pass spanningpredictors/ multi-periodspecial_predictors. Window-information gate (fail-closed): spanning is necessary but not sufficient —Wis identified by the DONORS being distinguishable (X0·Wis a convex combination of the donor columns), so if a window’s re-aggregated predictors are constant across donors (all donor columns identical) thenX0·Wis the same for every simplexW, the inner solve’s objective is flat, and_inner_solve_Wreturns arbitrary weights while reporting convergence — even when the treated unit differs (the treated unit is the matching target, not part ofW’s identification), and_standardizeonly warns on the zero-variance rows.cvtherefore also requires each window to have cross-DONOR variation in at least one predictor: the headline fit raisesValueError; an in-space placebo refit whose (pseudo-treated) donor pool is indistinguishable in a window is dropped from the permutation reference set; an in-time-truncated date whose window goes donor-flat is markedstatus="infeasible". Validated by deterministic equivalence to the R-anchoredcustom_vpath (the step-3 validation MSPE of the training-window fit and the step-4 validation-window weights each match acustom_v=V*fit on the correspondingly re-aggregated predictors) + cv self-consistency (in_time_placebounder cv == a fresh cv fit on the backdated panel) + a rejection test for non-spanning predictors. An explicitly pinnedv_cv_t0that no longer fits the truncated pre-fake window is nulled to the//2default for the placebo refit (a pinned value that still fits the truncated window is kept); in-time truncation that breaks the fully-spanning precondition for an otherwise-valid headline fit (a kept spec stops spanning both windows at the truncated split) marks that datestatus="infeasible".
Note (CV non-uniqueness — deterministic tie-break): Abadie 2021 footnote 7 warns the CV
V*“need not be unique” (the validation MSPE can be flat inV) and that “an implementation must pick a deterministic tie-break.” Among candidateVwhose validation MSPE ties to tolerance, this implementation selects the one closest to uniform (the densestV, mirroring footnote 7’s ridge-toward-dense remedy in spirit) — a principled choice that makes the selectedV*among equally-good optima independent of the multistart evaluation order. The cv fit is reproducible for a fixedseed(likenested); it is NOT seed-independent — the multistart fills any slots beyond the distinct heuristic starts (uniform, inverse-variance, univariate-fit — at most three, fewer if any coincide) with seed-dependent random Dirichlet draws, so different seeds can explore different optima. The tie-break removes only the start-order dependence among ties, not the seed dependence. A user-suppliedinnerridge penaltyγ·Σv_h²is a deferred extension (out of scope this release).Note (inverse-variance
V):v_method="inverse_variance"uses the closed formv_h = 1/Var(X_{h·})(Abadie 2021 §3.2(a)), variance taken over donors+treated on the unstandardized predictors, and applies thatVto the raw predictors so the effective objective is the unit-variance-rescaledΣ_h (X_{1h} − X_{0h}W)²/Var_h. Abadie 2021 describes inverse-varianceVprecisely as “rescal[ing] each predictor row to unit variance” — which is exactly whatstandardize="std"already does — so thestandardizepre-scaling is intentionally bypassed on this branch (it is equivalent to uniformVon the standardized predictors). Applying1/Varon top of the standardized rows would double-rescale toΣ_h diff_h²/Var_h²(inverse-variance squared), which is NOT the paper’s selector. Deterministic (no search;mspe_visNone). A zero-variance predictor row (no cross-unit information) gets 0Vweight; if every row is zero-variance the result falls back to uniformVwith aUserWarning.custom_vis rejected for this method (and for"cv") — fail-closed, never silently ignored.Note (single-donor degeneracy — uniform
Vfor all methods): with a single donor (J=1) the synthetic control is forced tow=[1], so the predictor-importanceVis unidentified — everyVyields the same synthetic.fit()short-circuits tow=[1]with a uniformv_weightsandmspe_v=Nonefor ALLv_methods, INCLUDING"cv"and"inverse_variance"(their selected / closed-formVwould be inert, so it is not computed or reported), emitting aUserWarning. The donor weights / gap path / ATT do not depend onVwhenJ=1, so they are unaffected; for"cv"thev_cv_t0resolution + spanning/variation preconditions still run (and can still raise) before this short-circuit. This is a deliberate, consistent degenerate contract — not a per-methodVmismatch.Note: The 1×SD poor-fit threshold is a defensive implementation choice in the spirit of the
SyntheticDiDconvention; ADH 2010 gives only the qualitative guidance “do not use SCM when the fit is poor” (no numeric cutoff). The warning fires whenever pre-period RMSPE exceeds the SD of the treated unit’s pre-period outcomes — including a flat treated pre-path (SD = 0) with non-trivial RMSPE (a scale-aware roundoff floor suppresses the warning on a near-perfect flat fit). This is slightly broader thanSyntheticDiD’sSD > 0-gated form, matching the literal RMSPE-exceeds-SD contract above.Deviation from R:
standardize="none"disables predictor standardization entirely; RSynthalways scales by the predictor SD. Provided for diagnostics; changes the geometry of theVobjective.Note: predictor rows support only equal-weight linear combinations of pre-period values —
mean(k_s = 1/T0),sum(k_s = 1), and per-period outcome lags (identity, a singlek_s = 1). ADH (2010) §2.3 defines the general formȲ_i^{K_m} = Σ_s k_s Y_iswith arbitrary weightsk_s; this release does NOT accept user-supplied non-uniformK_mweight vectors (andmedianand other non-linear aggregations are intentionally excluded). The supported set still spans the standardSynth::datapreppredictors.op+special.predictorsusage; arbitrary-weightK_mis a deferred extension.Deviation from R: predictor/outcome aggregation fails closed on any non-finite (NaN/inf) cell, whereas R
Synth::dataprephardwiresna.rm=TRUE(aggregating over the observed cells of a partially-missing window). The fail-closed contract is deliberate: na-dropping silently aggregates different period subsets across units, yielding incomparable predictors with no warning. The analyst must restrictpredictor_window/special_predictors/pre_period_outcomesperiods (and the outcome panel) to where each variable is observed; both partially- and fully-missing windows raiseValueError. Only the row ordering matchesdataprep, not the missing-data handling.Note (in-space placebo donor pool): the real treated unit is excluded from every placebo’s donor pool — when donor
jis pseudo-treated it is fit against the otherJ−1donors, never the real treated unit (whose post-period is treatment-contaminated). The ranking set is still theJ+1units {treated} ∪ {J placebos}. ADH 2010 §2.4 does not spell out placebo donor-pool composition; this matches the standardSCtools::generate.placebosconstruction (rotate the pseudo-treated identity through the donor pool; the original treated unit is never re-added as a donor).Note (placebo failure handling): a placebo is excluded from both the numerator and the denominator of the rank (never penalized into it) and tallied in
n_failedwhen its fit is not a valid optimum — EITHER its inner Frank-Wolfe weight solve did not converge (a truncatedWis unusable) OR its outerVsearch did not converge (an under-optimizedVfits the pre-period worse, shrinking the RMSPE ratio and biasing the p-value anti-conservatively, so it must not silently enter the rank). The reported p-value uses the effective countrank / (n_placebos + 1), wheren_placebosis the number of placebos that entered the reference set. Failed donors still appear inget_placebo_df()(status="failed", NaN metrics), so once a reference set is produced the table is the full treated + every-donor unit set (n_donors + 1rows). In the fail-closed cases the placebo loop does not run and only the treated row is returned:J < 2→placebo_p_valueis NaN with a warning (no placebo distribution;J == 2warns the distribution is coarse), and a treated fit whose own inner OR outer search did not converge also fails closed (ranking a truncated / under-optimized treated statistic would not be a valid permutation). Caveat: each placebo refit inherits the original fit’soptimizer_options/n_starts, so valid inference requires settings adequate for the outerVsearch to converge to a comparable-quality synthetic (production defaults do; cheap settings under-optimize placeboVand those placebos are dropped as failed — raisen_startsonin_space_placebo()or re-fit with a largeroptimizer_options['maxiter']).Note (in-space / leave-one-out infeasible vs failed split): an excluded
in_space_placebo()/leave_one_out()refit is attributed to one of two causes, mirroring the splitin_time_placeboalready reports. A solver non-convergence (a truncated innerWor outerVsearch) is tallied inn_failed(_loo_n_failed) with astatus="failed"row; a structural cv infeasibility — underv_method="cv", the pseudo-treated donor pool (in-space) or reduced pool (leave-one-out) is indistinguishable in a re-aggregated CV window, so the weights are unidentified (the window-information gate above;_outer_solve_V_cvreturns a structural sentinel that_placebo_fit_unitthreads out) — is tallied inn_infeasible(_loo_n_infeasible) with astatus="infeasible"row. Both are excluded from the rank / ATT range identically, soplacebo_p_value/n_placebosare unchanged by the attribution — only the diagnostic accounting is refined._placebo_status/_loo_statusgainall_placebos_infeasible/all_placebos_unusable(resp.all_refits_infeasible/all_refits_unusable) codes for a no-usable-refit run that is purely structural or a mix, andDiagnosticReportsurfaces the machine-readablereason_codealongsiden_failed/n_infeasible.n_infeasibleis 0 for the non-cvv_methods (no structural-identification gate). The remedy differs by cause: a structural infeasibility needs different predictors /v_cv_t0/ donor pool, NOT a largern_starts/inner_max_iter.Note (RMSPE-ratio floor): the reported
rmspe_ratio = sqrt(MSPE_post / MSPE_pre)floors the pre-period MSPE denominator at a scale-aware1e-8 · max(|pre-outcomes|, 1)²(before the square root) so a (near-)perfect pre-fit (pre-MSPE → 0) yields a large-but-FINITE ratio rather thaninf/nan(which would corrupt the rank). Ties (ratio_j ≥ treated_ratio) are counted, making the p-value conservative. Mirrors the_fit_tolpoor-fit guard.Note (placebo p-value is non-analytical):
placebo_p_valueis deliberately a SEPARATE field fromp_value(which stays NaN) — it is a permutation p-value with no SE / t-stat, so it does not flow throughsafe_inference.is_significantlikewise stays bound to the (NaN)p_value, NOTplacebo_p_value; a tool gating onis_significantwill seeFalseeven whenplacebo_p_valueis small. The reporting stack surfaces the placebo p-value throughestimator_native_diagnostics, never the analytical headline.Note (confidence set is non-analytical —
conf_intstays NaN): the Firpo & Possebom test-inversioneffect_confidence_setis a permutation set at level1−γ, kept DELIBERATELY SEPARATE from the analyticalconf_int(which stays(NaN, NaN)— classic SCM has no Wald interval). It is parametrized byγ(not the estimator’salpha, and granular in1/(J+1)), can be a set rather than an interval (the linear family) and can be unbounded or non-contiguous, so it cannot be coerced into the(lo, hi)conf_inttuple without breaking thesafe_inferenceNaN-consistency contract.se/t_stat/p_value/conf_int/is_significantall stay at their NaN state; the set is surfaced only viaconfidence_set()/get_confidence_set_df()/effect_confidence_set(andestimator_native_diagnostics) — mirroring howplacebo_p_valueis kept offp_value.Note (test-inversion boundary convention — strict
p^f > γ): Firpo & Possebom’s inequalities are non-uniform atp = γ— the RMSPE-based tests reject atp < γ(Eqs 5/9/13), the general-statistic test rejects atp ≤ γ(Eq 19), and the confidence set is the strictp^f > γ(Eq 14), so Eq 14’s set is NOT the exact complement of the Eq 13 rejection region (they differ atp^f = γ). Because the permutation p-value is discrete (a multiple of1/(J+1)),p = γis reachable, so this implementation pins Eq 14’s strictp^f > γfor set membership (ap = γvalue is excluded) and documents it (matches thefirpo-possebom-2018-review.md§4.2 boundary note).Note (test-inversion set construction is an implementation choice): Firpo & Possebom §4.2 gives the set definitions (Eqs 14/16/18) but does NOT prescribe how to enumerate the set. The default is exact piecewise-constant breakpoint inversion —
p^cis constant between the real roots of the placebo-vs-treated comparison quadratics, so evaluatingpper induced interval and at each breakpoint recovers the set exactly (no grid resolution / shape assumption); the optional fixedbounds=grid is the grid-limited alternative. Either is OUR choice (the paper leaves it unspecified) — a documented deviation. (n_gridcontrols only the returned inspection table, not membership, whenbounds=None.)Note (test-inversion validation — no R anchor): R
Synthhas no test-inversion confidence-set function, and the authors’ Code Ocean replication capsule was not consulted (paper-sourced only). The confidence sets are validated by (a) self-consistency —test_sharp_null(0)equals the in-spaceplacebo_p_valueexactly (transitively R-anchored via the Basque placebo parity), (b) a numpy oracle re-implementing Eqs 12–14 on hand-built gap paths (incl. the strictp = γboundary and the per-unit floor), and (c) a coverage simulation (a constant-effect DGP; the1−γset covers the truth at ≈1−γ).Note (in-time placebo windowing — TRUNCATE): ADH 2015 §4 says to re-estimate the in-time placebo “with the same predictors lagged accordingly.” Because
diff_diff’s predictor specs reference absolute periods, the in-time placebo re-cuts them by TRUNCATION: pre-period-outcome predictors become the pre-t_foutcomes, and covariate / special-predictor windows are intersected with the pre-t_fwindow; a window lying ENTIRELY in the held-out region[t_f, T0)is dropped (surfaced in then_dropped_specscolumn + an aggregated warning), andcustom_vis subset in lockstep with the surviving specs. For an outcome-predictor fit (the R-anchorable case) TRUNCATE is identical to ADH’s “lag” — both equal a manualSynth::synthre-run withtime.optimize.ssrcut att_f. The held-out window never enters the fit (the placebo’sall_periodsis the pre-fake + post-fake span; the true post-treatment periods are excluded entirely), so there is no “peeking.” This concrete convention is NOT spelled out in ADH 2015 (which gives only the qualitative “lag accordingly”).Note (in-time placebo requires ≥2 pre-fake periods): the in-time placebo treats a date with fewer than 2 pre-fake periods as
status="infeasible"(the default sweep starts at the 3rd pre-period). This is DELIBERATELY stricter than the base estimator’sT0 ≥ 1allowance (which permits a single-pre-period fit but warns that nested-Vselection is unreliable): an auto-swept placebo date with a single pre-fake period is a trivially-matchable, non-credible pre-fit, so it is dropped rather than surfaced as aranplacebo (mirrorsSyntheticDiD.in_time_placebo’si ≥ 2rule). A date whose survivingcustom_vhas zero mass after truncation is likewise infeasible (not a convergence failure).Note (leave-one-out weight floor): ADH 2015 §4 leave-one-out omits “each donor that received positive weight.” This implementation drops each donor with reportable weight — above the
1e-6interpretability floor (synthetic_control._MIN_REPORT_WEIGHT), i.e. exactly the donors indonor_weights— rather than every strictly-positive weight. A donor with0 < w ≤ 1e-6is numerical dust whose removal moves the ATT by ~its weight (itsdelta_attwould be ~0, an uninformative row), and the floor keeps the LOO table aligned with the reported donor support. The drop-set is frozen at fit time on the fit snapshot (weighted_donor_ids), soleave_one_out()is immune to post-fit mutation of the presentation-leveldonor_weightsdict.Note (ADH-2015 diagnostics validation): R
Synthhas no in-time-placebo or leave-one-out function (verified against its full CRAN function index;SCtoolsadds only the in-space placebo battery,scpionly prediction-interval uncertainty), so there is no canonical R output to match for these diagnostics — in R they are hand-rolled by re-runningdataprep()+synth(). They are validated instead by (a) the solver’s existing Basque R parity (above), and (b) deterministic self-consistency tests proving each diagnostic equals a from-scratchsynthetic_control()fit on the equivalent sub-problem —leave_one_out()drop-d== a fit on the donor pool minusd;in_time_placebo([t_f])== a fit on the backdated/truncated panel — both via a fixedcustom_v(match to 1e-7). The two §4-tail diagnostics are likewise R-anchor-free (RSynthhas neither):regression_weights()is validated by a numpy oracle re-implementingW^reg = X0a'(X0a X0a')^{-1}X1aon hand-built matrices (incl. the full-rank sum-to-one property, the rank-deficient min-norm branch, and row-scaling invariance acrossv_methodspaces);sparse_synthetic_control()by self-consistency — its exhaustive size-lwinner (and the winner’s weights) match an independent brute-force enumeration using the SAME fixed baselineV, which also confirmsVis held fixed rather than re-searched.Note (conformal proxy ≠ ADH V-matrix — deliberate): the CWZ conformal layer fits its OWN counterfactual proxy — the canonical constrained-LS synthetic control on raw outcomes over all periods (eqs 3–4, no V-matrix) — NOT the headline ADH V-matrix weights (which match on pre-period predictors only). This is required, not incidental: CWZ’s exactness theory (Lemma 1; Appendix D exchangeability under the null) holds for a time-permutation-invariant estimator, which the ADH pre-period V-fit is not (it treats pre and post asymmetrically). So the conformal counterfactual /
point_estimatecan differ from the headlineatt, and is reported as a separate object.Note (conformal permutation floor —
1/|Π|, distinct from Firpo’s1/(J+1)): the conformal p-value is(1/|Π|)·#{π : S(û_π) ≥ S(û)}(eq 2). The permutation setΠincludes the identity (S(û_id)=S(û), counted under≥), sop̂ ≥ 1/|Π|automatically — there is no extra+1(unlike the cross-unit placebo / Firpo(1+n)/(n+1), where the treated unit is not a member of the placebo reference set).|Π| = T(moving-block, joint),T0+1(pointwise sub-series), orT/T*(average-effect blocks), capped atn_iidfor the i.i.d. scheme.Note (conformal per-period CI drops the other post-periods — paper-sourced): for a pointwise CI of period
t, CWZ §2.2 defines the data under the null asZ = (Z_1,…,Z_{T0}, Z_t)— the pre-periods plus ONLY periodt. The other post-periods are dropped (not plugged in, not zeroed), making each per-period CI a cleanT*=1conformal test on the(T0+1)-length sub-series. (Confirmed against arXiv:1712.09089v10 §2.2 + Algorithm 1.) The grid is centred on a pre-only proxy fit (predict the post slot from the pre slots) — the unconditional all-slot fit would soak the effect into the weights and bias the naïve residual toward 0, so it is not used as the centre.Note (conformal is non-analytical —
conf_intstays NaN): the conformal p-value / CI is a permutation object kept DELIBERATELY SEPARATE from the analyticalse/t_stat/p_value/conf_int/is_significant(all NaN — classic SCM has no Wald interval). It can be a set (unbounded /grid_limited/ non-contiguous) parametrised byα(granular in1/|Π|), so it cannot be coerced into the(lo,hi)conf_inttuple. Surfaced only viaconformal_test()/conformal_confidence_intervals()/conformal_average_effect()/conformal_inference(andestimator_native_diagnostics) — mirroring howplacebo_p_valueandeffect_confidence_setare kept off the analytical headline.Note (conformal validity caveats — Remark 2; documented, not auto-corrected): CWZ requires a large
T0(drives exactness;T*may be short) and that{u_t}be stationary and conditionally heteroscedastic at most — unconditional heteroscedasticity / non-stationarity of the shocks invalidates the basic procedure (Remark 2), as does a change in the shock distribution under the intervention (Assumption 1 invariance). No standardizing filter is applied by default; the analyst must pre-filter if these are suspected. AT*≥T0fit emits a validity-caveatUserWarning.Note (conformal validation — no R anchor): the authors state “all computations in R” with no named package, and the replication capsule was not consulted (paper-sourced only). Validated by (a) a numpy oracle re-implementing
S_q(q=1,2,∞), the moving-block cyclic-shift set, i.i.d. identity-inclusion, and the eq-2 floor on hand-built residuals; (b) a brute-force permutation-equivalence test (independently materialising every shift and recomputingS_q) matching the production p-value bit-for-bit; (c)_cwz_proxy_fitvsscipySLSQP on the simplex-LS; and (d) a coverage simulation (the1−αaverage-effect CI covers a known constant effect at ≈1−α).
Reference implementation: authors’ Synth package for R/MATLAB/Stata (Synth::synth); in-space placebo construction follows SCtools::generate.placebos. R-parity anchor: the Basque Country study (Abadie-Gardeazabal 2003, data("basque")) — published synthetic = region 10 (Cataluña) 0.851 + region 14 (Madrid) 0.149, loss.v 0.0089. Two-tier test (tests/test_methodology_synthetic_control.py): Tier-1 feeds R’s solution.v via custom_v → donor weights match to atol 1e-3 (deterministic); Tier-2 checks the nested fit in a band.
Requirements checklist:
[x] Donor weights on the unit simplex; exactly one treated unit, block assignment.
[x] Predictors = covariates + linear combinations of pre-period outcomes (incl. “all pre-period outcomes” default).
[x] Inner simplex-constrained weighted LS via
_sc_weight_fwwith diagonal PSDV.[x] Outer nested
V(pre-period outcome MSPE) + user-suppliedcustom_v.[x] Out-of-sample cross-validation
V-selection (v_method="cv", ADH 2015 §; Abadie 2021 Eq. 9t0=T0/2): per-window re-aggregation train/validation split (fully-spanning precondition) + deterministic flat-MSPE tie-break (fn. 7); threaded through the placebo refits.[x] Inverse-variance
V(v_method="inverse_variance", Abadie 2021 §3.2(a)): closed-form1/Var(X), zero-variance-row + all-zero-uniform-fallback handling.[x] Gap path + pre-period RMSPE + predictor-balance table.
[x] No analytical SE (NaN inference); in-space placebo permutation inference (
in_space_placebo(),rank/(n_placebos+1)) with the real treated unit excluded from every placebo pool, effective-count denominator, and a scale-aware RMSPE-ratio floor.[x] Leave-one-out donor robustness (
leave_one_out(), ADH 2015 §4): per-drop ATT /delta_atttable + overlay gaps; fail-closed.[x] In-time (backdating) placebo (
in_time_placebo(), ADH 2015 §4): TRUNCATE windowing (drop held-out-window predictors + lockstepcustom_vsubset), feasible-date sweep, fail-closed.[x] Confidence sets by test inversion (
test_sharp_null()+confidence_set(), Firpo & Possebom 2018 §4): sharp-nullRMSPE^fre-ranking of the in-space placebo gaps (Eqs 12–13) + constant/linear one-parameter sets (Eqs 14/16/18) with the strictp^f > γboundary, EXACT piecewise-constant breakpoint inversion (no shape assumption; isolated/disjoint/unbounded sets handled), and fail-closed unbounded/empty/non-contiguous handling. Deferred: sensitivity weights (φ≠0), the general-θ menu (Eq 19), one-sided (§7), multiple-outcome/treated (§6).[x] Conformal inference (
conformal_test()+conformal_confidence_intervals()+conformal_average_effect(), Chernozhukov-Wüthrich-Zhu 2021): own constrained-LS proxy under the null on all periods (eqs 3–4, no V-matrix) +S_qstatistic (q=1,2,∞) + permutation p-value (eq 2,1/|Π|floor) over moving-block (Π_→) / i.i.d. (Π_all) schemes; joint sharp-null test (eqs 1–2), pointwise per-period CIs (Algorithm 1,Z=(pre,t)), and the average-effect block-collapse CI (Appendix A.1); fail-closed grid_limited/empty/indeterminate handling; analyticalconf_intstays NaN. Deferred: one-sided (§7), covariates folded into the proxy, AR/innovation-permutation path (Lemmas 5–7) — seeTODO.md.[x] Regression-weight
W^regextrapolation diagnostic (regression_weights(), ADH 2015 §4): intercept-augmentedW^reg = X0a'(X0a X0a')^{-1}X1a, flag donors outside[0,1]; min-norm + rank-deficient handling; pure linear algebra, analytical inference unchanged.[x] Sparse-SC subset search (
sparse_synthetic_control(), ADH 2015 §4): exhaustiveC(J,l)subset search holdingVfixed at the baseline; default-skip vs explicit-raisemax_subsetsguard; per-size winner table + overlay gaps.[x] Predictor-leakage, absorbing-suffix/no-anticipation, empty-window, duplicate-label, and inner-non-convergence validation gates.
TripleDifference#
Primary source: Ortiz-Villavicencio, M., & Sant’Anna, P.H.C. (2025). Better Understanding Triple Differences Estimators. arXiv:2505.09942v3.. Paper review on file: docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md.
Key implementation requirements:
Assumption checks / warnings:
Requires all 8 cells of the 2×2×2 design: Group(0/1) × Period(0/1) × Treatment(0/1)
Warns if any cell has fewer than threshold observations
Propensity score overlap required for IPW/DR methods
Estimator equation (as implemented):
Three-DiD decomposition (matching R’s triplediff::ddd()):
Subgroups: 4=G1P1, 3=G1P0, 2=G0P1, 1=G0P0
DDD = DiD_3 + DiD_2 - DiD_1
where DiD_j is a pairwise DiD comparing subgroup j vs subgroup 4 (reference).
Each pairwise DiD uses the selected estimation method (DR, IPW, or RA) with
repeated cross-section implementation (panel=FALSE in R).
Regression adjustment (RA): Separate OLS per subgroup-time cell within each pairwise comparison, imputed counterfactual means.
IPW: Propensity score P(subgroup=4|X) within {j, 4} subset, Hajek normalization.
Doubly robust (DR): Combines outcome regression and IPW with efficiency correction (OR bias correction term).
Standard errors (all methods):
Individual-level (default):
SE = std(w₃·IF₃ + w₂·IF₂ - w₁·IF₁, ddof=1) / sqrt(n)
where w_j = n / n_j, n_j = |{subgroup=j}| + |{subgroup=4}|, and IF_j is the
per-observation influence function for pairwise DiD j (padded to full n with zeros).
Cluster-robust (when cluster parameter is provided):
SE = sqrt( (G/(G-1)) * (1/n²) * Σ_c ψ_c² )
where G is the number of clusters, ψ_c = Σ_{i∈c} IF_i is the sum of the combined
influence function within cluster c, and the G/(G-1) factor is the Liang-Zeger
finite-sample adjustment.
Note: IF-based SEs are inherently heteroskedasticity-robust; the robust parameter
has no additional effect.
Note (panel-shaped input, generate_ddd_panel_data): TripleDifference is the
repeated-cross-section panel=FALSE implementation: the individual-level default SE
treats each row as an independent observation (df = n_obs - 8). When fitting against
panel-shaped data with repeated unit rows and within-unit serial correlation
(e.g., generate_ddd_panel_data output), unclustered SE understates sampling
variability and overstates power. Pass cluster="unit" to invoke the Liang-Zeger
CR1 path so the influence functions are aggregated within unit before variance
computation. The point estimate att is invariant to clustering, only the inference
contract changes.
Edge cases:
Propensity scores near 0/1: trimmed at
pscore_trim(default 0.01)Empty cells: raises ValueError with diagnostic message
Low cell counts: warns when any cell has fewer than 10 observations
Cluster-robust SE: requires at least 2 clusters (raises
ValueError)Cluster IDs: must not contain NaN (raises
ValueError)Overlap warning: emitted when >5% of observations are trimmed at pscore bounds (IPW/DR only)
Propensity score estimation failure: controlled by
pscore_fallbackparameter (default"error"). Ifpscore_fallback="error", the error is raised. Ifpscore_fallback="unconditional", falls back to unconditional probability P(subgroup=4), sets hessian=None (skipping PS correction in influence function), emits UserWarning. Whenrank_deficient_action="error", errors are always re-raised regardless ofpscore_fallback.Events Per Variable (EPV) diagnostics: Per-logit EPV = min(n_subgroup_j, n_subgroup_4) / n_covariates checked before IRLS. Default threshold: 10 (Peduzzi et al. 1996). Warns when EPV < threshold; errors when
rank_deficient_action="error".Note:
pscore_fallbackdefault changed from unconditional to error. Setpscore_fallback="unconditional"for legacy behavior.Collinear covariates: detected via pivoted QR in
solve_ols(), action controlled byrank_deficient_action(“warn”, “error”, “silent”)Non-finite influence function values (e.g., from extreme propensity scores in IPW/DR or near-singular design): warns and sets SE to NaN, propagated to t_stat/p_value/CI via safe_inference()
Note (rank-guarded IF standard errors): The per-comparison outcome-regression bread and propensity-score Hessian in the influence-function SE (
_compute_did_rc_reg/_dr,_estimate_ddd_decomposition) are inverted by the shared_rank_guarded_inv. A near-singular Gram from a constant/collinear covariate previously gave a garbagenp.linalg.inv(reproduced:se~1e17 forreg, ~43 fordr); redundant directions are now truncated, giving a finite SE on the identified covariate subset (see the CallawaySantAnna “rank-guarded IF standard errors” Note for the generalized-inverse equivalence argument and thercond=1e-10threshold).fit()emits ONE aggregateUserWarningacross the three DiD comparisons, suppressed underrank_deficient_action="silent".NaN inference for undefined statistics:
t_stat: Uses NaN (not 0.0) when SE is non-finite or zero
p_value and CI: Also NaN when t_stat is NaN
Note: Defensive enhancement; reference implementation behavior not yet documented
Reference implementation(s):
R
triplediff::ddd()(v0.2.1, CRAN) — official companion by paper authors
Requirements checklist:
[x] All 8 cells (G×P×T) must have observations
[x] Propensity scores clipped at
pscore_trimbounds[x] Doubly robust consistent if either propensity or outcome model correct
[x] Returns cell means for diagnostic inspection
[x] Supports RA, IPW, and DR estimation methods
[x] Three-DiD decomposition: DDD = DiD_3 + DiD_2 - DiD_1 (matching R)
[x] Influence function SE: std(w3·IF_3 + w2·IF_2 - w1·IF_1) / sqrt(n)
[x] Cluster-robust SE via Liang-Zeger variance on influence function
[x] ATT and SE match R within <0.001% for all methods and DGP types
[x] Survey design support: all methods (reg, IPW, DR) with weighted OLS/logit + TSL on combined influence functions. Weighted solve_logit() for propensity scores in IPW/DR paths.
Note: TripleDifference survey SE: for IPW/DR, pairwise IFs incorporate survey weights via weighted Riesz representers (
riesz *= weights), so the combined IF is divided by per-observation survey weights (inf / sw) before passing tocompute_survey_vcov()to prevent double-weighting. For regression (RA), pairwise IFs are already on the unweighted residual scale (WLS fits use weights internally but the IF is not Riesz-multiplied), so the combined IF passes directly to TSL without de-weighting. The OLS nuisance IF corrections in DR mode use weighted cross-products normalized by subgroup row countn(notsum(weights)).Note (vcov_type contract):
vcov_typeis permanently narrow to{"hc1"}per the IF-based variance decomposition. Analytical-sandwich families{classical, hc2, hc2_bm}are rejected at__init__with a methodology-rooted message citing Ortiz-Villavicencio & Sant’Anna (2025) — the 3-pairwise-DiD decomposition has no single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined.cluster=continues to invoke Liang-Zeger CR1 on the combined influence function ((G/(G-1)) · Σ_c (Σ_{i∈c} ψ_i)² / n², plain CR1 — no Stata-style(n-1)/(n-p)finite-sample factor because the IF has no design-matrixpin the OLS sense);survey_design=continues to invoke TSL on the combined IF.vcov_type='conley'is deferred to the TripleDifference Conley follow-up row inTODO.md. See “IF-based variance estimators vs analytical-sandwich estimators” above for the structural taxonomy.Note (
cluster=+ replicate-weight survey rejection):TripleDifference(cluster=X)+SurveyDesign(replicate_weights=[...], replicate_method=...)is rejected atfit()withNotImplementedError. Replicate-weight variance is computed by replicate reweighting (BRR / Fay / JK1 / JKn / SDR) and ignores PSU/cluster entirely (the survey-side gate atsurvey.py:104-109enforcesreplicate_weightsare mutually exclusive withstrata/psu/fpc); honoringcluster=here would silently have no effect on the variance estimate while populatingcluster_name/n_clusterson Results dishonestly. Mirrors theCallawaySantAnnaguard atstaggered.py:1705-1719. Either omitcluster=(the replicate weights encode the design structure implicitly) or use a non-replicate survey design with explicitstrata/psu/fpc.
StaggeredTripleDifference#
Primary source: Ortiz-Villavicencio, M., & Sant’Anna, P.H.C. (2025). Better Understanding Triple Differences Estimators. arXiv:2505.09942v3.. Paper review on file: docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md.
Key implementation requirements:
Assumption checks / warnings:
Requires balanced panel with enabling-group
S_i, binary eligibilityQ_i(time-invariant), and outcomeYEligibility must be binary (0/1) — raises
ValueErrorif notEligibility must be time-invariant within each unit — raises
ValueErrorif varyingRequires both eligible (Q=1) and ineligible (Q=0) units
Warns if any (S, Q) cell in a three-DiD comparison has < 5 units
Warns if no valid comparison groups exist for a (g, t) pair (skips that pair)
Propensity score overlap enforced by clipping at
pscore_trim(default 0.01)Events Per Variable (EPV) diagnostics: Per-DiD EPV = min(n_subgroup_j, n_subgroup_4) / n_covariates checked before IRLS. Default threshold: 10 (Peduzzi et al. 1996). Warns when EPV < threshold; errors when
rank_deficient_action="error".Note: When multiple comparison cohorts
g_ccontribute to the same ATT(g,t) cell,results.epv_diagnostics[(g,t)]retains the worst-case (minimum EPV) across all contributing propensity fits, rather than per-fit diagnostics. This is a conservative cell-level summary.Propensity score estimation failure: controlled by
pscore_fallbackparameter (default"error"). Ifpscore_fallback="error", the error is raised. Ifpscore_fallback="unconditional", falls back to unconditional propensity with warning. Whenrank_deficient_action="error", errors are always re-raised.Note:
pscore_fallbackdefault changed from unconditional to error. Setpscore_fallback="unconditional"for legacy behavior.Warns on singular GMM covariance matrix (falls back to pseudoinverse)
Note: Rank-deficient X’WX in the per-pair outcome-regression influence-function step (
_compute_did_panel) is inverted by the shared rank-guarded generalized inverse_rank_guarded_inv(see the CallawaySantAnna “rank-guarded IF standard errors” Note): a near-singular X’WX no longer returns a garbage inverse (the priornp.linalg.solveonly raised on exact singularity) — redundant directions are truncated, giving a finite SE on the identified covariate subset.fit()emits ONE aggregateUserWarning(counting affected (g, g_c, t) cells + max condition number), suppressed underrank_deficient_action="silent". Axis-A finding #17 in the Phase 2 silent-failures audit.Note (rank-guard column-drop): The rank-guarded IF inverse uses an equilibrated column-drop generalized inverse (pivoted QR on the equilibrated Gram) — the same column-drop family as the point estimate, with a scale-invariant column selection that may differ from the point estimate’s raw pivot only under mixed-scale exact collinearity (a documented deviation that leaves the SE unchanged). The analytical SE equals the well-conditioned near-collinear limit (
se_ratio ≈ 1) for StaggeredTripleDifference’s per-pair OR bread (_compute_did_panel) and PS Hessian (_compute_pscore), with no minimum-norm divergence — see the CallawaySantAnna “rank-guarded IF standard errors” Note (and its selection caveat) for the full statement.Note: The per-pair propensity-score Hessian inversion in
_compute_pscore(used underestimation_methodin{ipw, dr}) uses the same rank-guarded generalized inverse_rank_guarded_inv: the priornp.linalg.inv(X'WX)returned a garbage inverse on a near-singular (but not exactly singular) PS design, inflating IPW/DR influence-function corrections. Redundant directions are now truncated andfit()emits a sibling aggregateUserWarning(cell count + max condition number), suppressed underrank_deficient_action="silent". Sibling of axis-A finding #17, surfaced during PR #334 CI review.
Data structure:
Balanced panel. Key variables:
S_i(first_treat): enabling group — 0 or inf for never-enabledQ_i(eligibility): binary, time-invariant eligibility indicatorTreatment:
D_{i,t} = 1{t >= S_i AND Q_i = 1}(absorbing)Covariates
X_i: time-invariant (first observation per unit used)Note (scale-equilibrated OR solve): StaggeredTripleDifference’s per-cohort outcome-regression fit (
_compute_or) now routes through the shared scale-equilibratedsolve_ols(column-equilibrated SVD/gelsd; matches TripleDifference’s_fit_predict_muand R’slm()/QR), replacing the prior estimator-localcho_solve(X'X)cache fast path. A covariate on a very large raw scale (e.g. correlated with the intercept via a large constant offset) no longer perturbs the nuisance fit; the equilibrated SVD is offset-invariant to ~1e-11 where the prior normal-equations solve drifted (atests/test_methodology_staggered_triple_diff.pyscale-invariance test pins this — covariate + 1e6 offset leaves ATT(g,t) unchanged). The change is not bit-identical (cho/normal-equations → SVD) but well-scaled designs move only ~1e-12. (The separate DR/OR influence-function SE rank-guard is also implemented via_rank_guarded_inv— see the CallawaySantAnna “rank-guarded IF standard errors” Note.)Note:
first_treat=inf(R-style never-enabled marker) is accepted and normalized to0internally. The recoding now emits aUserWarningreporting the affected row count so the reclassification is not silent (axis-E silent coercion under the Phase 2 audit, mirroring the StaggeredDiD behavior). Passfirst_treat=0directly to avoid the warning.
Estimator equation (Equation 4.1 in paper, as implemented):
Three-DiD decomposition for each (g, g_c, t) triple:
DDD(g, g_c, t) = DiD_A + DiD_B - DiD_C
where each pairwise DiD operates on panel outcome changes delta_Y = Y_t - Y_b:
DiD_A: treated (S=g, Q=1) vs (S=g, Q=0) [+1, paper Term 1]
DiD_B: treated (S=g, Q=1) vs (S=g_c, Q=1) [+1, paper Term 2]
DiD_C: treated (S=g, Q=1) vs (S=g_c, Q=0) [-1, paper Term 3]
This sign convention matches both the paper’s Equation 4.1 and the existing
TripleDifference decomposition (DDD = DiD_3 + DiD_2 - DiD_1 with subgroups
4=G1P1, 3=G1P0, 2=G0P1, 1=G0P0).
Valid comparison groups: for control_group="nevertreated", only the never-enabled
cohort (S=0). For control_group="notyettreated", `G_c = {g_c : g_c > max(t, base_period)
anticipation}`, plus never-enabled.
Deviation from paper: The paper’s Section 4 defines admissible comparison cohorts as
g_c > max(g, t). The implementation follows the companion R packagetriplediffwhich usesg_c > max(t, base_period) + anticipation. These rules differ for pre-treatment cells (t < g) when a later cohort lies in(t, g): the paper would exclude it, while the R package (and this implementation) may include it depending on the base period. The R-matching rule correctly accounts for the anticipation parameter and base-period selection in the comparison-group filter.
With covariates / doubly robust (DR, recommended):
Each pairwise DiD uses the CallawaySantAnna DR estimator on outcome changes:
Fit outcome regression
E[delta_Y | X]on control units (OLS)Estimate propensity score
P(treated | X)within each 2-cell subset (logistic)Combine:
ATT = mean(treated_change - m_hat) + sum(w_ipw * (m_hat - control_change)) / n_t
GMM-optimal combination across comparison groups (Equations 4.11-4.12):
ATT_gmm(g,t) = w_gmm' @ [ATT_1, ..., ATT_k]
w_gmm = Omega^{-1} @ 1 / (1' @ Omega^{-1} @ 1)
where Omega[j,l] = (1/n) * sum_i IF_j[i] * IF_l[i] is estimated from influence
functions across comparison groups. Minimizes asymptotic variance subject to sum(w) = 1.
Aggregation:
Event study (Equation 4.13): cohort-share-weighted average across cohorts for each
relative time e = t - g. Reuses CallawaySantAnnaAggregationMixin._aggregate_event_study().
Overall ATT: two summaries are available. (1) The default overall_att is the
cohort-size-weighted average across post-treatment (g,t) pairs (reuses
CallawaySantAnnaAggregationMixin._aggregate_simple()) — the library-wide
Callaway-Sant’Anna “simple” convention, matching R agg_ddd(type="simple").
(2) The opt-in overall_att_es is the paper’s Equation 4.14 overall — the unweighted
mean of the post-treatment event-study effects ES(e) — populated when
aggregate="event_study" or "all" (see the labeled Note below).
Group effects: average across post-treatment time periods for each cohort.
Reuses CallawaySantAnnaAggregationMixin._aggregate_by_group().
All aggregation SEs include the WIF (Weight Influence Function) adjustment for uncertainty in cohort-share weights, inherited from the CallawaySantAnna mixin.
Deviation from R: Aggregation weights (and the WIF) use the eligible-treated population
P(S=g, Q=1)— matching the paper’s Eq 4.13, whereG_iis defined only forQ=1units (G_i = giffS_i = gandQ_i = 1), so the paper’sP(G=g)isP(S=g, Q=1). R’sagg_ddd()instead weights byP(S=g)(all units in the enabling group, including ineligible). Implemented by settingunit_cohorts=0for ineligible units before calling the aggregation mixin. Group-timeATT(g,t)values are identical to R; only the weighted average across (g,t) pairs differs — this is the source of the larger tolerance in the R cross-validation tests.Note: Per-cohort group-effect SEs include WIF via the inherited mixin. R’s
agg_ddd(type="group")useswif=NULLfor per-cohort aggregation since within-cohort weights are fixed. This makes our per-cohort group-effect SEs slightly conservative relative to R.Note: The default overall ATT (
overall_att) is the Callaway-Sant’Anna simple post-treatment (g,t) average — the library-wide convention across staggered estimators — and is NOT the paper’s Equation 4.14 overall (which averages the event-study effects). The paper’s Eq 4.14 form is exposed opt-in asoverall_att_es(populated only whenaggregateis"event_study"/"all"), computed as the unweighted mean of the post-treatment ES(e) overe >= -anticipation. Its analytical SE is the influence function of that mean (the average of the per-event-time combined IFs, routed through the same survey-aware variance estimator as the per-e effects); a multiplier-bootstrap SE replaces it whenn_bootstrap > 0. The two summaries answer different questions and generally differ;overall_att_esis cross-validated against Ragg_ddd(type="eventstudy")$overall.att/overall.se.
Standard errors:
Individual (g,t) level:
SE(g,t) = std(IF_gmm, ddof=1) / sqrt(n)
where IF_gmm = w_gmm' @ IF_matrix is the GMM-combined unit-level influence function
(length n_units, zero-padded for non-participating units). Inherently
heteroskedasticity-robust via the influence function approach.
Aggregation SEs: via WIF-adjusted combined influence functions from the CallawaySantAnna aggregation mixin.
Bootstrap: multiplier bootstrap (Algorithm 1 of Callaway & Sant’Anna 2021) via
CallawaySantAnnaBootstrapMixin._run_multiplier_bootstrap(). Supports
Rademacher, Mammen, and Webb weight distributions. Provides simultaneous
confidence bands (sup-t) for event study.
Note: Matches R
triplediffpackagecompute_did()formulation: Hajek-normalized Riesz representers, separate M1/M3 OR corrections on treated/control IF components, PS correction via logistic Hessian and score function, hessian = (X’WX)^{-1} * n_pair. Three-DiD IF combination weights usew_j = n_cell / n_pair_j(matching R’s att_dr). GMM Omega estimated via sample covariance (ddof=1). Per-(g,t) SE uses R’s GMM formulasqrt(1 / (n * sum(Omega_inv)))for multiple comparison groups, orsqrt(sum(IF^2) / n^2)for single comparison group.Deviation from R: Propensity scores are clipped to
[pscore_trim, 1-pscore_trim](default 0.01). R’striplediffuses hard exclusion (keep_ps) for control units withpscore >= 0.995but does not apply a lower bound. The soft-clipping approach retains all observations with bounded weights, which is more conservative under moderate overlap violations.Note: The
clusterparameter is accepted but not currently wired to the analytical SE computation. The multiplier bootstrap provides unit-level clustering. Full cluster-robust analytical SEs are deferred.Note: Full survey design support (pweight only). Survey weights enter propensity score estimation (weighted IRLS), outcome regression (WLS), and Riesz representer computation. IF combination weights (w1/w2/w3) use survey-weighted cell sizes. Aggregated SEs use
compute_survey_if_variance()(TSL) orcompute_replicate_if_variance()(replicate weights). Bootstrap uses PSU-level multiplier weights. The Rtriplediffpackage does not support survey weights.
Edge cases:
Single comparison group: GMM reduces to w=[1], no matrix inversion
Zero valid comparison groups for a (g,t): skipped with warning
Singular GMM covariance: falls back to pseudoinverse with warning
Small cells (< 5 units): warns but proceeds
Non-finite ATT from a comparison group: excluded from GMM combination
Never-enabled encoded as inf: normalized to 0 internally
No valid (g,t) pairs at all: raises
ValueError
Reference implementation(s):
R
triplediff(companion package by paper authors) — cross-validated intests/test_methodology_staggered_triple_diff.py(group-time ATT/SE for both control groups, plus the Eq. 4.14 overalloverall_att_esagainstagg_ddd(type="eventstudy")). CSV fixtures are gitignored and regenerated on-the-fly frombenchmarks/R/benchmark_staggered_triplediff.R; the JSON golden is committed.
Requirements checklist:
[x] Panel data with (unit, time, enabling-group S, eligibility Q, outcome Y)
[x] Three comparison sub-groups per (g, g_c): (S=g, Q=0), (S=g_c, Q=1), (S=g_c, Q=0)
[x] Individual comparison cohorts, never pooled — combined via GMM weights
[x] Comparison groups satisfy g_c > max(t, base_period) + anticipation (notyettreated) or g_c = never-enabled only (nevertreated)
[x] Doubly robust: consistent if either propensity or outcome model correct (per component)
[x] GMM-optimal weighting via closed-form inverse-variance formula
[x] Event-study aggregation with cohort-share weights (via CS mixin)
[x] Pre-treatment event-study coefficients constructable
[x] Influence-function-based SEs
[x] Multiplier bootstrap for simultaneous confidence bands (via CS mixin)
[ ] Cluster-robust analytical SEs (accepted but not wired — deferred)
[x] Survey design support (pweight, strata/PSU/FPC, replicate weights)
[x] Validation against R
triplediffpackage: group-time ATT and SE match within 0.001% across 10 scenarios (3 seeds, 3 methods, both control group modes). Aggregation (event study, overall ATT) uses CS mixin cohort-size weights which differ from R’sagg_ddd()group-probability weights (within 25%); this is a documented weighting choice, not a specification violation.
TROP#
Primary source: Athey, S., Imbens, G.W., Qu, Z., & Viviano, D. (2025). Triply Robust Panel Estimators. arXiv:2508.21536.
Note (version pinning): the methodology promotion (METHODOLOGY_REVIEW.md #### TROP → Complete as of 2026-05-24) is anchored on arXiv:2508.21536v2; the current arXiv version is v3. The v3 PDF was consulted for the treatment-assignment-pattern sections as part of the non-absorbing support work (§2.1 general assignment / “units moving into and out of treatment”; §2.2 Eq. 2 masking; §6.1 Eq. 12 / Algorithm 2; Assumption 1(i); Theorem 5.1) and confirms the general-assignment scope used here. A full v2→v3 source delta-check across all promoted sections (Eqs. 2-3, Algorithms 1-3, Section 2.2, Section 5.2-5.3, Section 6.1-6.2, Theorem 5.1, Corollary 1, Appendix Theorem 8.1) is still deferred. See docs/methodology/papers/athey-2025-review.md “Version-pinning note” for the deferred action item.
Key implementation requirements:
Assumption checks / warnings:
Requires sufficient pre-treatment periods for factor estimation (at least 2)
Unit weights can become degenerate if λ_unit too large
Returns Q(λ) = ∞ if ANY LOOCV fit fails (Equation 5 compliance)
Treatment indicator (D matrix) semantics:
By default (non_absorbing=False) D must be an ABSORBING STATE indicator, not a
treatment timing indicator:
D[t, i] = 0 for all t < g_i (pre-treatment periods for unit i)
D[t, i] = 1 for all t >= g_i (during and after treatment for unit i)
where g_i is the treatment start time for unit i.
For staggered adoption (different units treated at different times, but still absorbing) the D matrix naturally handles this - distances use periods where BOTH units have D=0, matching the paper’s (1 - W_iu)(1 - W_ju) formula in Equation 3.
True non-absorbing assignment (treatment switches on and off) is a distinct case
from staggered adoption. The paper (§2.1: “units moving into and out of treatment”)
supports it via the same Eq. 12 / Algorithm 2 masking, and the library exposes it through
the opt-in TROP(non_absorbing=True) (method='local' only). See the requirements
checklist below and the **Note:** entries on the no-dynamic-effects requirement and the
block-only inference theory.
Wrong D specification: With the default non_absorbing=False, an event-style D (only
the first treatment period has D=1, then back to 0) is a non-monotonic indicator and is
rejected with a ValueError (see “D matrix validation” below). This guards against the
common mistake of encoding absorbing treatment as an event spike, which would silently bias
the ATT. A user with genuinely non-absorbing treatment passes non_absorbing=True.
ATT definition (Equation 1, Section 6.1):
τ̂ = (1 / Σ_i Σ_t W_{it}) Σ_{i=1}^N Σ_{t=1}^T W_{it} τ̂_{it}(λ̂)
ATT averages over all cells where D_it=1 (treatment indicator) that are estimable. On balanced / support-complete absorbing panels every treated cell is estimable, so this is all D=1 cells. A cell is non-estimable (NaN, excluded) when
alpha_i + beta_tis unidentified — its target unit and period are not in the same connected component of the observed-control graph; this is reachable undernon_absorbing=True(always-treated unit, fully-treated period, disconnected support) and on unbalanced absorbing panels (entirely-missing unit/period controls). See the non-estimable-cell**Note:**below — matching the library-wide non-estimable→NaN convention (cf. CallawaySantAnna group-time cells).No separate “post_periods” concept - D matrix is the sole input for treatment timing
Supports general assignment patterns including staggered adoption and (with
non_absorbing=True) on/off switching
Estimator equation (as implemented, Section 2.2):
Working model (separating unit/time FE from regularized factor component):
Y_it(0) = α_i + β_t + L_it + ε_it, E[ε_it | L] = 0
where α_i are unit fixed effects, β_t are time fixed effects, and L = UΣV’ is a low-rank factor structure. The FE are estimated separately from L because L is regularized but the fixed effects are not.
Optimization (Equation 2):
(α̂, β̂, L̂) = argmin_{α,β,L} Σ_j Σ_s θ_s^{i,t} ω_j^{i,t} (1-W_js)(Y_js - α_j - β_s - L_js)² + λ_nn ||L||_*
Solved via alternating minimization. For α, β: weighted least squares (closed form). The global solver adds an intercept μ and solves for (μ, α, β, L) on control data only, extracting τ_it post-hoc as residuals (see Global section below). For L: proximal gradient with step size η = 1/(2·max(W)):
Gradient step: G = L + (W/max(W)) ⊙ (R - L)
Proximal step: L = U × soft_threshold(Σ, η·λ_nn) × V' (SVD of G = UΣV')
where R is the residual after removing fixed effects. Both the local and global solvers use FISTA/Nesterov acceleration for the inner L update (O(1/k²) convergence rate, up to 20 inner iterations per outer alternating step).
Per-observation weights (Equation 3):
θ_s^{i,t}(λ) = exp(-λ_time × |t - s|)
ω_j^{i,t}(λ) = exp(-λ_unit × dist^unit_{-t}(j, i))
dist^unit_{-t}(j, i) = (Σ_u 1{u≠t}(1-W_iu)(1-W_ju)(Y_iu - Y_ju)² / Σ_u 1{u≠t}(1-W_iu)(1-W_ju))^{1/2}
Note: weights are per-(i,t) observation-specific. The distance formula excludes the target period t and uses only periods where both units are untreated (W=0).
Special cases (Section 2.2):
λ_nn=∞, ω_j=θ_s=1 (uniform weights) → recovers DID/TWFE
ω_j=θ_s=1, λ_nn<∞ → recovers Matrix Completion (Athey et al. 2021)
λ_nn=∞ with specific ω_j, θ_s → recovers SC/SDID
LOOCV tuning parameter selection (Equation 5, Footnote 2):
Q(λ) = Σ_{j,s: D_js=0} [τ̂_js^loocv(λ)]²
Score is SUM of squared pseudo-treatment effects on control observations
Two-stage procedure (per paper’s footnote 2):
Stage 1: Univariate grid searches with extreme fixed values
λ_time search: fix λ_unit=0, λ_nn=∞ (disabled)
λ_nn search: fix λ_time=0 (uniform time weights), λ_unit=0
λ_unit search: fix λ_nn=∞, λ_time=0
Stage 2: Cycling (coordinate descent) until convergence
“Disabled” parameter semantics (per paper Section 4.3, Table 5, Footnote 2):
λ_time=0: Uniform time weights (disabled), because exp(-0 × dist) = 1λ_unit=0: Uniform unit weights (disabled), because exp(-0 × dist) = 1λ_nn=∞: Factor model disabled (L=0), because infinite penalty; converted to1e10internallyNote:
λ_nn=0means NO regularization (full-rank L), which is the OPPOSITE of “disabled”Validation:
lambda_time_gridandlambda_unit_gridmust not contain inf. AValueErroris raised if they do, guiding users to use 0.0 for uniform weights per Eq. 3.
LOOCV failure handling (Equation 5 compliance):
If ANY LOOCV fit fails for a parameter combination, Q(λ) = ∞
A warning is emitted on the first failure with the observation (t, i) and λ values
Subsequent failures for the same λ are not individually warned (early return)
This ensures λ selection only considers fully estimable combinations
Standard errors:
Block bootstrap preserving panel structure (Algorithm 3)
Edge cases:
Rank selection: implicit via nuclear-norm soft-thresholding (paper Section 5.3 + Appendix);
TROPResults.effective_rankreports the diagnostic. No discreterank_selectionconstructor parameter (cv / ic / elbow) is exposed — earlier prose claiming “automatic via cross-validation, information criterion, or elbow” was an overclaim, corrected in the 2026-05-24 methodology-promotion PR. See the Requirements checklist Rank-selection bullet below.Zero singular values: handled by soft-thresholding
Extreme distances: weights regularized to prevent degeneracy
LOOCV fit failures: returns Q(λ) = ∞ on first failure (per Equation 5 requirement that Q sums over ALL control observations where D==0); if all parameter combinations fail, falls back to defaults (1.0, 1.0, 0.1)
λ_nn=∞ implementation: Only λ_nn uses infinity (converted to 1e10 for computation):
λ_nn=∞ → 1e10 (large penalty → L≈0, factor model disabled)
Conversion applied to grid values during LOOCV (including Rust backend)
Conversion applied to selected values for point estimation
Conversion applied to selected values for variance estimation (ensures SE matches ATT)
Results storage:
TROPResultsstores original λ_nn value (inf), while computations use 1e10. λ_time and λ_unit store their selected values directly (0.0 = uniform).
Empty control observations: If no valid control observations exist, returns Q(λ) = ∞ with warning. A score of 0.0 would incorrectly “win” over legitimate parameters.
Infinite LOOCV score handling: If best LOOCV score is infinite,
best_lambdais set to None, triggering defaults fallbackValidation: by default requires at least 2 periods before first treatment; with
non_absorbing=Truethis becomes “at least 2 periods contain untreated cells” (the leading all-control block is ill-defined when treatment toggles)D matrix validation (default
non_absorbing=False): Treatment indicator must be an absorbing state (monotonic non-decreasing per unit)Detection:
np.diff(D, axis=0) < 0for any column indicates violationHandling: Raises
ValueErrorwith list of violating unit IDs and remediation guidanceError message includes: “convert to absorbing state: D[t, i] = 1 for all t >= first treatment period” AND the opt-in pointer (“if treatment genuinely turns on and off, pass
non_absorbing=True”)Rationale: Event-style D (0→1→0) silently biases ATT when the user meant absorbing treatment; runtime validation prevents that misuse while the opt-in serves genuine on/off designs
non_absorbing=True: the monotonicity check is skipped entirely, so on/off (and event-style) D matrices are accepted. Identification falls back to untreated cells (the per-(i,t) estimator masks treated cells via (1-W) and fits the rest), so even a fully toggling panel with no never-treated unit is admitted; only “no D=0 cells at all” is rejected. See the requirements checklist + Notes for the no-dynamic-effects requirement and the block-only inference caveat.Unbalanced panels: Missing unit-period observations are allowed. Monotonicity validation (default mode) checks each unit’s observed D sequence for monotonicity, which correctly catches 1→0 violations that span missing period gaps (e.g., D[2]=1, missing [3,4], D[5]=0 is detected as a violation even though the gap hides the transition in adjacent-period checks).
n_post_periods metadata: Counts periods where D=1 is actually observed (at least one unit has D=1), not calendar periods from first treatment. In unbalanced panels where treated units are missing in some post-treatment periods, only periods with observed D=1 values are counted.
Wrong D specification: with the default
non_absorbing=False, an event-style D (only first treatment period) is rejected with aValueErrorcarrying both the convert-to-absorbing guidance and thenon_absorbing=Trueopt-in pointerBootstrap minimum:
n_bootstrapmust be >= 2 (enforced viaValueError). TROP uses bootstrap for all variance estimation — there is no analytical SE formula.Note: TROP bootstrap loops (
_bootstrap_variance,_bootstrap_rao_wu, and their global counterparts, including both Rust happy paths — local and global) emit a proportionalUserWarningviadiff_diff.bootstrap_utils.warn_bootstrap_failure_ratewhen the replicate failure rate exceeds 5%. The previous hard-coded< 10 successesthreshold let high-failure runs (e.g. 11 of 200) pass silently; this was classified as a silent failure under the Phase 2 audit (axis D — degenerate-replicate handling). The 5% threshold matches the existing SyntheticDiD bootstrap and placebo guards. When zero replicates succeed, SE is set toNaN(unchanged). The local Rust path previously also usedlen >= 10as a Python-fallback trigger; it now accepts any non-zero Rust result and emits the proportional warning instead of path-switching silently.LOOCV failure metadata: When LOOCV fits fail in the Rust backend, the first failed observation coordinates (t, i) are returned to Python for informative warning messages
Inference CI distribution: After
safe_inference()migration, CI uses t-distribution (df = max(1, n_treated_obs - 1)), consistent with p_value. Previously CI used normal-distribution while p_value used t-distribution (inconsistent). This is a minor behavioral change; CIs may be slightly wider for small n_treated_obs.Note: Both the
localalternating-minimization solver (_estimate_model) and theglobalalternating-minimization solver (_solve_global_with_lowrank, including its hard-coded inner FISTA loop of 20 iterations) emitUserWarningviadiff_diff.utils.warn_if_not_convergedwhen the outer loop exhaustsmax_iterwithout reachingtol. The global-method warning surfaces the inner-FISTA non-convergence count as diagnostic context. Silent return of the current iterate was classified as a silent failure under the Phase 2 audit and replaced with an explicit signal to match the convention used across other iterative solvers in the library.
Reference implementation(s):
Authors’ replication code (forthcoming)
Requirements checklist:
[x] Factor matrix estimated via soft-threshold SVD
[x] Unit weights:
exp(-λ_unit × distance)(unnormalized, matching Eq. 2)[x] LOOCV implemented for tuning parameter selection
[x] LOOCV uses SUM of squared errors per Equation 5
[x] Rank selection implicit via nuclear-norm soft-thresholding (paper Section 5.3 + Appendix);
TROPResults.effective_rankreports the diagnostic. No discreterank_selectionconstructor parameter is exposed — earlier mention of “cv / ic / elbow” methods in this checklist was an overclaim, corrected in the methodology-promotion PR. Locked bytests/test_methodology_trop.py::TestTROPDeviations::test_rank_selection_is_implicit_via_nuclear_norm.[x] Returns the fitted factor matrix and an effective-rank diagnostic (
TROPResults.factor_matrixandTROPResults.effective_rank). The library does NOT expose separate factor-loading / factor-score outputs — earlier prose claiming “factor loadings and scores” was an overclaim corrected in the 2026-05-24 methodology-promotion PR (TROP’s nuclear-norm soft-thresholded L is delivered as a single (n_periods × n_units) matrix, not decomposed into loading / score components on Results).[x] ATT averages over all estimable D==1 cells (staggered adoption by default; on/off switching with
non_absorbing=True). All D==1 cells are estimable on balanced / support-complete panels; cells whosealpha_i + beta_tis unidentified (target unit and period in different connected components of the observed-control graph) are NaN and excluded (see the non-estimable-cell**Note:**).[x] No post_periods parameter (D matrix determines treatment timing)
[x] D matrix semantics documented (absorbing state, not event indicator)
[x] Unbalanced panels supported — missing control / pre-treatment cells don’t trigger false absorbing-state violations. Locked by
tests/test_methodology_trop.py::TestTROPDeviations::test_unbalanced_panels_supported(10% random drops on control + pre-treatment subset). Three additional unbalanced-panel regressions live intests/test_trop.py::TestPR110FeedbackRound8(test_unbalanced_panel_d_matrix_validation,test_unbalanced_panel_real_violation_still_caught,test_unbalanced_panel_multiple_missing_periods). Absorbing-state monotonicity validation (which fires on unbalanced cases too) is covered bytests/test_trop.py::TestDMatrixValidation.[x] Per-observation treatment-effect estimation (Eq. 13 / Algorithm 2) —
treatment_effectsdict contains oneτ_hat_itentry per treated cell (finite for estimable cells; NaN for a missing outcome or, undernon_absorbing, a cell with no weighted control support — see the no-support**Note:**), and the aggregate ATT equals the unweighted mean of the finite per-cell effects (Eq. 1). The methodology test exercises block adoption with a constant treatment effect; absorbing-state staggered adoption and heterogeneous per-cell effects (paper Remark 6.1) are SUPPORTED by the code path (the implementation does not gate on cohort or effect-magnitude pattern), but are not directly verified in the methodology test surface for those specific patterns. Cross-coverage of the staggered-cohort fit path istests/test_methodology_trop.py::TestTROPAlgorithm1LOOCV::test_control_set_includes_pretreat_of_eventually_treated(two-cohort early-/late-treated panel under LOOCV-tunedλ_unit); absorbing-state structural validation istests/test_trop.py::TestDMatrixValidation.[x] Section 6.1 non-absorbing / on-off / switching assignment patterns are SUPPORTED via the opt-in
TROP(non_absorbing=True)(method='local'only) — matching the paper’s general-assignment scope (§2.1 “units moving into and out of treatment”; Eq. 12 / Algorithm 2 mask treated cells per (i,t) with no monotonicity requirement). The default (non_absorbing=False) still rejects non-monotonic D as a defensive guard (see the**Note:**entries below). Removing this opt-in restriction narrows a prior implementation over-restriction (the shipped estimator was stricter than the paper); it is not a new methodology deviation. Recovery on a no-dynamic-effects toggling DGP, the per-cell effect count, and the caveat warning are locked bytests/test_methodology_trop.py::TestTROPDeviations::test_non_absorbing_general_assignment_supported; the default-mode rejection contract byTestTROPDeviations::test_event_style_d_rejected_with_value_error; opt-in acceptance, the local-only guard, params round-trip, and Rust/Python parity bytests/test_trop.py::TestDMatrixValidation.[x] Special-case reductions (paper Section 2.2): DiD benchmark sanity check (NOT a direct algebraic-equivalence proof) — TROP with
λ_nn=∞+ uniform weights produces an ATT within 0.5 ofDifferenceInDifferencesfitted as a basic 2×2 design on a TWFE-clean multi-period panel. This is empirical numerical agreement on a friendly DGP. A direct Section 2.2 reduction lock (true 2-period block-assignment panel where basic DiD is the algebraic target, or a comparison againstTwoWayFixedEffectswith explicit unit FE) is deferred. Matrix Completion code path exercised — TROP with uniform weights + finiteλ_nnengages the nuclear-norm prox solver (effective_rank > 0) and beats the DiD-style baseline on a factor-confounded DGP; not an equivalence check against an independent MC reference. SC and SDID reductions are paper-claimed under “specific (omega, theta) weight choices” not provided in the paper text; cross-language anchor deferred until paper-author reference implementation clarifies the weight map. Seetests/test_methodology_trop.py::TestTROPSpecialCases.Note: The balancing representation / decomposition (paper Eq. 10, Section 5.2) is a paper-side identity. Direct numerical reconstruction of the four-term sum requires the internal
θ_s^{i,t}/ω_j^{i,t}weight vectors, which are not exposed on the public TROP API; numerical Eq. 10 verification is therefore out of scope. The testtests/test_methodology_trop.py::TestTROPNuclearNormProx::test_factor_matrix_consistent_with_treatment_effectsis a structural pointer only — it checksfactor_matrixshape + finiteness + thattreatment_effectsis populated with finite entries, but does NOT lock the magnitude ofL_hat. (The test DGP uses additive unit + time effects only; on a no-interactive-FE panel, the paper’s framework absorbs the additive surfaces intoα_i/β_t, so a near-zeroL_hatis methodologically correct. Aneffective_rank > 0assertion would lock a solver artifact, not the intended low-rank behavior.) This is NOT a full Eq. 10 lock. The Eq. 2 ingredients (soft-threshold SVD, plain prox-gradient monotonicity — NOT the shipped accelerated FISTA outer loop, which uses Nesterov momentum and does not guarantee per-step monotonicity, seeTestTROPNuclearNormProxclass docstring — weighted-prox) that the Eq. 10 derivation relies on are independently verified in the same class.Note (library-side choice): Weight normalization (Gap #5 in
docs/methodology/papers/athey-2025-review.md): paper Section 5 (p. 20) states weights sum to one (1ᵀω = 1ᵀθ = 1), but Eq. 3 (p. 7) writes unnormalized exponential weights. The paper-side ambiguity remains open; the library resolves it as a documented deviation — the shipped implementation matches Eq. 2 (unnormalized). Verified bytests/test_methodology_trop.py::TestTROPDeviations::test_unnormalized_weights_match_eq2. Will be revisited once paper-author reference implementation lands.Note (deferral): Equation 14 covariate extension (
Y_it = α_i + β_t + X_it·β_coef + R_itwith R low-rank, paper Section 6.2) is not implemented.TROP.fit()does not accept acovariateskeyword argument. The corresponding Theorem 8.1 covariate-triple-robustness result is correspondingly out of scope. The non-support is locked bytests/test_methodology_trop.py::TestTROPDeviations::test_covariates_not_supported, which usesinspect.signatureto guard against future**kwargssilently breaking the contract. Deferred until use cases motivate the X threading throughtrop_local.py/trop_global.py/ LOOCV / bootstrap.Note: Survey support: weights, strata, PSU, and FPC are all supported via Rao-Wu rescaled bootstrap with cross-classified pseudo-strata (Phase 6). Rust backend remains pweight-only; full-design surveys fall back to the Python bootstrap path. Survey weights enter ATT aggregation only — population-weighted average of per-observation treatment effects. Model fitting (kernel weights, LOOCV, nuclear norm regularization) stays unchanged. Rust and Python bootstrap paths both support survey-weighted ATT in each iteration.
Note (defensive default):
non_absorbingdefaults toFalse, retaining the absorbing-state monotonicity gate. This is an implementation choice, not a paper requirement: the gate’s primary value is catching the common mistake of encoding absorbing treatment as an event-style spike (a single D=1 period), which silently biases the ATT. Genuine on/off designs opt in withnon_absorbing=True. The default-mode rejection message carries both the convert-to-absorbing guidance and the opt-in pointer.Note (scope — local only):
non_absorbing=Trueis supported only formethod='local'. Theglobalmethod’s post-hoc weighting and bootstrap bake in a contiguous, simultaneous treated block (it already rejects staggered adoption), soTROP(method='global', non_absorbing=True)raises aValueError. The Rust local LOOCV/bootstrap paths are already mask-driven (D==0/D==1) and required no change; Rust/Python ATT parity on a non-absorbing panel is locked bytests/test_trop.py::TestDMatrixValidation::test_non_absorbing_rust_python_parity. For a fully toggling panel (no never-treated unit), the local Rust bootstrap is bypassed in favour of the Python loop (the Rust stratified resampler can return a degenerate ~0 SE on an empty control stratum).Note (inference caveat for non-absorbing): The paper’s point estimator (Eq. 12 / Algorithm 2) supports general assignment, but the formal triple-robustness guarantee (Theorem 5.1) is proven only under Assumption 1(i) block assignment
W_it = 1{i>N0}·1{t>T0}; the paper does not extend that guarantee to general/non-absorbing patterns (cf.docs/methodology/papers/athey-2025-review.md). The non-parametric bootstrap (Algorithm 3) is offered generally but “its validity requires a growing number of treated units.” Non-absorbing validity additionally relies on the paper’s no-spillover / no-dynamic-effects (no carryover) assumption (paper §2.1).TROP.fit()emits a one-timeUserWarningcarrying these caveats whenevernon_absorbing=True; the warning is locked bytests/test_methodology_trop.py::TestTROPDeviations::test_non_absorbing_general_assignment_supportedand its absence in default mode bytest_non_absorbing_no_caveat_in_default_mode.Note (non-absorbing non-estimable-cell trimming → estimable-cell ATT): The working model fits unregularized unit/time fixed effects
alpha_j/beta_son the weighted observed control cells, then setstau_it = Y_it - alpha_i - beta_t - L_it. A treated cell (i,t) is estimable only if the sumalpha_i + beta_tis identified by that two-way-FE fit. In a two-way FE model the effects are pinned only within each connected component of the bipartite graph whose nodes are units and periods and whose edges are the positively-weighted observed control cells (usable = (D==0) & ~missing & isfinite(Y) & ω>0); across components there is a free per-component offset. So estimability requires the target unit node and target period node to lie in the same connected component of that graph (predicatediff_diff.trop_local._treated_cell_is_estimable, a bipartite BFS run per treated cell with a cheap empty-row/empty-column fast-path). A marginal “the target unit has some usable control AND the target period has some usable control” test is necessary but not sufficient — e.g. usable cells at(unitA,t0)and(unitB,t1)with target(unitA,t1)pass it yet span two disconnected components, leavingalpha_A + beta_1unidentified. The connected-component check subsumes the simpler degeneracies: undernon_absorbing=True(1) an always-treated unit has an empty control column (isolated unit node) — true even withlambda_unit=0; and (2) a fully-treated period has an empty control row (isolated period node). In all these cases tau would silently leak the fixed effect. Non-estimable cells are materialized asNaNintreatment_effectsand excluded from the ATT, which is therefore the mean over estimable treated cells — NOT all D=1 cells. This matches the library-wide non-estimable→NaN convention (the per-named-cell analogue of CallawaySantAnna materializing non-estimable (g,t) as NaN); it is a defensive choice for a degeneracy the paper does not cover (the paper assumes enough overlap), not a deviation from Eq. 1 on the cells it covers. There is noλthat restores identification for these cells (the missing control row/column is structural), so the warning does not suggest one. The predicate is applied to every local fit (absorbing and non-absorbing) as a general correctness guard — it NaNs exactly the cells whose FE is genuinely unidentified. It is a no-op whenever every treated cell’s target unit and period have an OBSERVED control cell: always true on a balanced panel, and in absorbing mode also true on unbalanced panels (a never-treated unit is a control at every observed period and each treated unit’s pre-treatment controls are observed) — unless an unbalanced absorbing panel happens to leave a treated unit’s pre-period controls or a period’s controls entirely missing, in which case NaN-ing those cells is the correct fix to the identical latent FE leak (the prior behavior silently reported a contaminated tau). So estimable-cell trimming is the contract for all local TROP fits on unbalanced panels, not only non-absorbing ones. The point fit and the bootstrap refit apply the identical predicate; a draw with no estimable cell returns NaN and counts as a failed bootstrap replicate. Rust/bootstrap parity: the Rust per-cell bootstrap lacks the estimability guard, so whenever the point fit trims any cell (force_python=True, set fromn_no_support>0) — or undernon_absorbinggenerally — the bootstrap is routed to the guarded Python_fit_with_fixed_lambda, keeping the SE and the point ATT on the same estimable-cell set. (Rust remains the happy path for clean fits with no trimming.) LOOCV is support-agnostic by design: a degenerate pseudo-control cell yields a large raw-outcome pseudo-effect that inflatesQ(λ), so support-destroyingλ_unitvalues are naturally disfavored (a soft penalty) rather than hard-rejected — hard-rejecting (Q=∞) would over-restrict.TROP.fit()emits aUserWarningnaming the count of non-estimable cells. Locked bytests/test_methodology_trop.py::TestTROPDeviations:test_non_absorbing_always_treated_unit_not_raw_outcome(always-treated unit,lambda_unit>0andlambda_unit=0),test_non_absorbing_fully_treated_period_not_estimable(fully-treated period),test_non_absorbing_disconnected_support_not_estimable(disconnected bipartite control graph), andtest_unbalanced_absorbing_unidentified_unit_not_estimable(the guard +force_pythonbootstrap parity in default absorbing mode).
TROP Global Estimation Method#
Method: method="global" in TROP estimator
Approach: Computationally efficient adaptation using the (1-W) masking
principle from Eq. 2. Fits a single global model on control data, then
extracts treatment effects as post-hoc residuals. For the paper’s full
per-treated-cell estimator (Algorithm 2), use method='local'.
Objective function (Equation G1):
min_{μ, α, β, L} Σ_{i,t} (1-W_{it}) × δ_{it} × (Y_{it} - μ - α_i - β_t - L_{it})² + λ_nn×||L||_*
where:
(1-W_{it}) masks out treated observations — model is fit on control data only
δ_{it} = δ_time(t) × δ_unit(i) are observation weights (product of time and unit weights)
μ is the intercept
α_i are unit fixed effects
β_t are time fixed effects
L_{it} is the low-rank factor component
Post-hoc treatment effect extraction:
τ̂_{it} = Y_{it} - μ̂ - α̂_i - β̂_t - L̂_{it} for all (i,t) where W_{it} = 1
ATT = mean(τ̂_{it}) over all treated observations
Treatment effects are heterogeneous per-observation values. ATT is their mean.
Weight computation (differs from local):
Time weights: δ_time(t) = exp(-λ_time × |t - center|) where center = T - treated_periods/2
Unit weights: δ_unit(i) = exp(-λ_unit × RMSE(i, treated_avg)) where RMSE is computed over pre-treatment periods comparing to average treated trajectory
(1-W) masking applied after outer product: δ_{it} = 0 for all treated cells
Implementation approach (without CVXPY):
Without low-rank (λ_nn = ∞): Standard weighted least squares
Build design matrix with unit/time dummies (no treatment indicator)
Solve via np.linalg.lstsq for (μ, α, β) using (1-W)-masked weights
Both the Python fallback and the Rust acceleration path use SVD-based minimum-norm least squares with numpy-compatible rcond = eps × max(n, k), so they return the canonical minimum-norm solution on rank-deficient Y (e.g., two near-parallel control units)
With low-rank (finite λ_nn): Alternating minimization
Alternate between:
Fix L, solve weighted LS for (μ, α, β)
Fix (μ, α, β), proximal gradient for L:
Lipschitz constant of ∇f is L_f = 2·max(δ)
Step size η = 1/L_f = 1/(2·max(δ))
Proximal operator: soft_threshold(gradient_step, η·λ_nn)
Inner solver uses FISTA/Nesterov acceleration (O(1/k²))
Continue until max(|L_new - L_old|) < tol
Post-hoc: Extract τ̂_{it} = Y_{it} - μ̂ - α̂_i - β̂_t - L̂_{it} for treated cells
LOOCV parameter selection (unified with local, Equation 5): Following paper’s Equation 5 and footnote 2:
Q(λ) = Σ_{j,s: D_js=0} [τ̂_js^loocv(λ)]²
where τ̂_js^loocv is the pseudo-treatment effect at control observation (j,s) with that observation excluded from fitting.
For global method, LOOCV works as follows:
For each control observation (t, i):
Zero out weight δ_{ti} = 0 (exclude from weighted objective)
Fit global model on remaining data → obtain (μ̂, α̂, β̂, L̂)
Compute pseudo-treatment: τ̂_{ti} = Y_{ti} - μ̂ - α̂_i - β̂_t - L̂_{ti}
Score = Σ τ̂_{ti}² (sum of squared pseudo-treatment effects)
Select λ combination that minimizes Q(λ)
Rust acceleration: The LOOCV grid search is parallelized in Rust for 5-10x speedup.
loocv_grid_search_global()- Parallel LOOCV across all λ combinationsbootstrap_trop_variance_global()- Parallel bootstrap variance estimation
Key differences from local method:
Global weights (distance to treated block center) vs. per-observation weights
Single model fit per λ combination vs. N_treated fits
Treatment effects are post-hoc residuals from a single global model (global) vs. post-hoc residuals from per-observation models (local)
Both use (1-W) masking (control-only fitting)
Faster computation for large panels
Assumptions:
Simultaneous adoption (enforced): The global method requires all treated units to receive treatment at the same time. A
ValueErroris raised if staggered adoption is detected (units first treated at different periods). Treatment timing is inferred once and held constant for bootstrap variance estimation. For staggered adoption designs, usemethod="local".
Reference: Adapted from reference implementation. See also Athey et al. (2025).
Edge Cases (treated NaN outcomes):
Partial NaN: When some treated outcomes Y_{it} are NaN/missing:
_extract_posthoc_tau()(global) skips these cells; only finite τ̂ values are averagedLocal loop skips NaN outcomes entirely (no model fit, no tau appended)
n_treated_obsin results reflects valid (finite) count, not total D==1 countdf_trop = max(1, n_valid_treated - 1)uses valid countWarning issued when n_valid_treated < total treated count
All NaN: When all treated outcomes are NaN:
ATT = NaN, warning issued
n_treated_obs = 0
Bootstrap SE with <2 draws: Returns
se=NaN(not 0.0) when zero bootstrap iterations succeed.safe_inference()propagates NaN downstream.
Requirements checklist:
[x] Same LOOCV framework as local (Equation 5)
[x] Global weight computation using treated block center
[x] (1-W) masking for control-only fitting (per paper Eq. 2)
[x] Alternating minimization for nuclear norm penalty
[x] Returns ATT = mean of per-observation post-hoc τ̂_{it}
[x] Rust acceleration for LOOCV and bootstrap
HeterogeneousAdoptionDiD#
Implementation status (2026-04-18): Methodology plan approved; implementation queued across 7 phased PRs (Phase 1a kernels + local-linear + HC2/Bell-McCaffrey; Phase 1b MSE-optimal bandwidth; Phase 1c bias-corrected CI + nprobust parity; Phase 2 HeterogeneousAdoptionDiD class + multi-period event study; Phase 3 QUG/Stute/Yatchew-HR diagnostics; Phase 4 Pierce-Schott replication harness; Phase 5 docs + tutorial + practitioner_next_steps integration). Full plan at ~/.claude/plans/vectorized-beaming-feather.md; full paper review at docs/methodology/papers/dechaisemartin-2026-review.md. The requirements checklist at the end of this section tracks phase completion.
Primary source: de Chaisemartin, C., Ciccia, D., D’Haultfœuille, X., & Knau, F. (2026). Difference-in-Differences Estimators When No Unit Remains Untreated. arXiv:2405.04465v6.
Scope: Heterogeneous Adoption Design (HAD): a single-date, two-period DiD setting in which no unit is treated at period one and at period two all units receive strictly positive, heterogeneous treatment doses D_{g,2} >= 0. The estimator targets a Weighted Average Slope (WAS) when no genuinely untreated group exists. Extensions cover multiple periods without variation in treatment timing (Appendix B.2) and covariate-adjusted identification (Appendix B.1, future work).
Key implementation requirements:
Assumption checks / warnings:
Data must be panel (or repeated cross-section) with
D_{g,1} = 0for allg(nobody treated in period one).Treatment dose
D_{g,2} >= 0. For Design 1’ (the QUG case) the support infimumd̲ := inf Supp(D_{g,2})must equal 0; for Design 1 (no QUG)d̲ > 0and Assumption 5 or 6 must be invoked.Assumption 1 (i.i.d. sample):
(Y_{g,1}, Y_{g,2}, D_{g,1}, D_{g,2})_{g=1,...,G}i.i.d.Assumption 2 (parallel trends for the least-treated):
lim_{d ↓ d̲} E[ΔY(0) | D_2 ≤ d] = E[ΔY(0)]. Testable with pre-trends when a pre-treatment periodt=0exists. Reduces to standard parallel trends when treatment is binary.Assumption 3 (uniform continuity of
d → Y_2(d)at zero): excludes extensive-margin effects; holds ifd → Y_2(d)is Lipschitz. Not testable.Assumption 4 (regularity for nonparametric estimation): positive density at boundary (
lim_{d ↓ 0} f_{D_2}(d) > 0), twice-differentiablem(d) := E[ΔY | D_2 = d]near 0, continuousσ²(d) := V(ΔY | D_2 = d)withlim_{d ↓ 0} σ²(d) > 0, bounded kernel, bandwidthh_G → 0withG h_G → ∞.Assumption 5 (for Design 1 sign identification):
lim_{d ↓ d̲} E(TE_2 | D_2 ≤ d) / WAS < E(D_2) / d̲. Not testable via pre-trends. Sufficient version Equation 9:0 ≤ E(TE_2 | D_2 = d) / E(TE_2 | D_2 = d') < E(D_2) / d̲for all(d, d')inSupp(D_2)².Assumption 6 (for Design 1 WAS_{d̲} identification):
lim_{d ↓ d̲} E[Y_2(d̲) - Y_2(0) | D_2 ≤ d] = E[Y_2(d̲) - Y_2(0)]. Not testable.Warn (do NOT fit silently) when staggered treatment timing is detected: the paper’s Appendix B.2 excludes designs with variation in treatment timing and no untreated group (only the last treatment cohort’s effects are identified in a staggered setting).
Warn when Assumption 5/6 is invoked that these are not testable via pre-trends.
With Design 1 (no QUG) WAS is NOT point-identified under Assumptions 1-3 alone (Proposition 1); only sign identification (Theorem 2) or the alternative target WAS_{d̲} (Theorem 3) is available.
Target parameter - Weighted Average Slope (WAS, Equation 2):
WAS := E[(D_2 / E[D_2]) · TE_2]
= E[Y_2(D_2) - Y_2(0)] / E[D_2]
where TE_2 := (Y_2(D_2) - Y_2(0)) / D_2 is the per-unit slope relative to “no treatment”. Authors prefer WAS over the unweighted Average Slope AS := E[TE_2] because AS suffers a small-denominator problem near D_2 = 0 that prevents √G-rate estimation.
Alternative target (Design 1 under Assumption 6):
WAS_{d̲} := E[(D_2 - d̲) / E[D_2 - d̲] · TE_{2,d̲}]
where TE_{2,d̲} := (Y_2(D_2) - Y_2(d̲)) / (D_2 - d̲). Compares to a counterfactual where every unit gets the lowest dose, not zero; authors describe it as “less policy-relevant” than WAS.
Estimator equations:
Design 1’ identification (Theorem 1, Equation 3):
WAS = (E[ΔY] - lim_{d ↓ 0} E[ΔY | D_2 ≤ d]) / E[D_2]
Nonparametric local-linear estimator (Equation 7):
β̂_{h*_G}^{np} := ((1/G) Σ_{g=1}^G ΔY_g - μ̂_{h*_G}) / ((1/G) Σ_{g=1}^G D_{g,2})
where μ̂_h is the intercept from a local-linear regression of ΔY_g on D_{g,2} using weights k(D_{g,2}/h)/h. This estimates the conditional mean m(0) = lim_{d ↓ 0} E[ΔY | D_2 ≤ d].
Design 1 mass-point case (Section 3.2.4, discrete bunching at d̲):
target = (E[ΔY] - E[ΔY | D_2 = d̲]) / E[D_2 - d̲]
= (E[ΔY | D_2 > d̲] - E[ΔY | D_2 = d̲]) / (E[D_2 | D_2 > d̲] - E[D_2 | D_2 = d̲])
Compute via sample averages or a 2SLS of ΔY on D_2 with instrument 1{D_2 > d̲}. Convergence rate is √G.
Design 1 continuous-near-d̲ case: use the same kernel construction as Equation 7 with 0 replaced by d̲ and D_2 replaced by D_2 - d̲. d̲ is estimated by min_g D_{g,2}, which converges at rate G (asymptotically negligible versus the G^{2/5} nonparametric rate of β̂_{h*_G}^{np}).
Sign identification for Design 1 (Theorem 2, Equation 10):
WAS ≥ 0 ⟺ (E[ΔY] - lim_{d ↓ d̲} E[ΔY | D_2 ≤ d]) / E[D_2 - d̲] ≥ 0
WAS_{d̲} identification (Theorem 3, Equation 11):
WAS_{d̲} = (E[ΔY] - lim_{d ↓ d̲} E[ΔY | D_2 ≤ d]) / E[D_2 - d̲]
With covariates / conditional identification (Equation 19, Appendix B.1):
Assumption 9 (conditional parallel trends): almost surely, lim_{d ↓ 0} E[ΔY(0) | D_2 ≤ d, X] = E[ΔY(0) | X].
Theorem 6 (Design 1’ + Assumptions 3 and 9):
WAS = (E[ΔY] - E[ lim_{d ↓ 0} E[ΔY | D_2 ≤ d, X] ]) / E[D_2]
Implementing Equation 19 requires MULTIVARIATE nonparametric regression E[ΔY | D_2, X]; Calonico et al. (2018) covers only the univariate case, so the authors leave this extension to future work. The Phase-2 estimator will raise NotImplementedError when covariates= is passed, pointing to this section.
TWFE-with-covariates (Appendix B.1, Equations 20-21): under linearity Assumption 10 (E[ΔY(0) | D_2, X] = X' γ_0) and homogeneity E[TE_2 | D_2, X] = X' δ_0,
E[ΔY | D_2, X] = X' γ_0 + D_2 X' δ_0 (21)
so δ_0 is recovered by OLS of ΔY on X and D_2 * X; Average Slope is ((1/n) Σ X_i)' δ̂^X.
Standard errors (Section 3.1.3-3.1.4, 4):
Nonparametric estimator (Design 1’ and Design 1 continuous-near-
d̲): bias-corrected Calonico-Cattaneo-Farrell (2018, 2019) 95% CI (Equation 8):[ β̂_{ĥ*_G}^{np} + M̂_{ĥ*_G} / ((1/G) Σ D_{g,2}) ± q_{1-α/2} sqrt(V̂_{ĥ*_G} / (G ĥ*_G)) / ((1/G) Σ D_{g,2}) ]The procedure ports the Calonico et al.
nprobustmachinery in-house (Phase 1a/1b/1c of the implementation plan): estimate optimal bandwidthĥ*_G, computeμ̂_{ĥ*_G}, the first-order bias estimatorM̂_{ĥ*_G}, and the variance estimatorV̂_{ĥ*_G}.
Weighted extension (Phase 4.5 survey support): HeterogeneousAdoptionDiD.fit() accepts survey_design=SurveyDesign(...) (design-based inference) on the two continuous-dose paths (continuous_at_zero, continuous_near_d_lower). Weights thread through _nprobust_port.lprobust via pointwise multiplication into the kernel weights: W_combined = k((D − d̲)/h) · w_g. Design matrices, Q.q bias-correction matrix, and variance matrices inherit the combined weights automatically. The β-scale rescaling uses weighted population moments: β̂_weighted = (Σ w_g ΔY_g / Σ w_g − μ̂_weighted(h)) / (Σ w_g (D_g − d̲) / Σ w_g).
Under survey_design=SurveyDesign(weights, strata, psu, fpc), the variance composes via Binder (1983) Taylor-series linearization — the per-unit influence function of μ̂ is aggregated by PSU within strata with FPC correction, using the shared compute_survey_if_variance helper (diff_diff/survey.py:1802) consumed by dCDH / EfficientDiD / CallawaySantAnna. Survey design columns (strata / PSU / FPC) are required to be constant within unit (sampling-unit assignment convention); within-unit variance raises ValueError front-door per feedback_no_silent_failures.
Note (parity gap): no public weighted-CCF bias-corrected local-linear reference exists in any language.
nprobust::lprobusthas no weight argument; Calonico-Cattaneo-Farrell’s companionrdrobustis RD-shaped (not HAD-shaped);np::npreg’s local-linear algorithm does not reduce to a straightforward weighted-OLS at the intercept. The Phase 1catol=1e-12R bit-parity bar is therefore NOT reachable on the weighted bias-corrected CI. Methodology confidence under informative weights comes from the stack documented below.Note: Uniform-weights bit-parity —
lprobust(..., weights=np.ones(N))≡ unweighted atatol=1e-14, rtol=1e-14across the full output struct (tau_cl,tau_bc,se_cl,se_rb,V_Y_cl,V_Y_bc). Regression tests intests/test_nprobust_port.py::TestWeightedLprobustandtests/test_bias_corrected_lprobust.py::TestWeightedBiasCorrectedLocalLinear.Note: Cross-language weighted-OLS parity —
benchmarks/R/generate_np_npreg_weighted_golden.Rproduces a manually-implemented-R weighted-OLS reference against which Python recovers the intercept + slope atatol=1e-12on 4 DGPs (tests/test_np_npreg_weighted_parity.py). This is a regression lock on the kernel + weighted-OLS formula, not third-party validation of the CCF bias correction.Note: Monte Carlo oracle consistency —
tests/test_had_mc.pyvalidates that the weighted estimator recovers the oracle τ under informative sampling, with coverage near nominal and visible bias reduction vs unweighted. Slow-gated; 4 tests.Note: Auto-bandwidth selection (Phase 1b MSE-DPI via
lpbwselect_mse_dpi) remains UNWEIGHTED in this phase; users who want a weight-aware bandwidth should passh/bexplicitly. The auto path with uniform weights reduces to the existing unweighted bandwidth selector, so the uniform-weights bit-parity chain is preserved.Note: Replicate-weight SurveyDesigns (BRR / Fay / JK1 / JKn / SDR) on the HAD continuous path raise
NotImplementedErrorin this PR; Rao-Wu-style rescaled bootstrap is deferred to Phase 4.5 C (survey-under-pretests).Note:
HeterogeneousAdoptionDiD.fit()dispatch matrix after Phase 4.5 B + 4.5 C — survey-weighted inference (survey_design=) is supported on ALL design × aggregate combinations (continuous × {overall, event-study}, mass-point × {overall, event-study}). The HAD pretests (qug_test,stute_test,yatchew_hr_test, joint Stute variants,did_had_pretest_workflow) ship survey support in Phase 4.5 C (PR #370) and the Phase 4.5 C strata extension (this PR) —qug_testpermanently rejects (Phase 4.5 C0 deferral; see “QUG Null Test” §); the linearity family supports pweight + PSU + FPC + strata via PSU-level Mammen multipliers with within-stratum demean + Bessel rescale (Stute, see “Note (Stute stratified survey-bootstrap calibration)” below) + closed-form weighted variance components (Yatchew). Replicate-weight designs raiseNotImplementedError(parallel follow-up);lonely_psu='adjust'+ singleton-strata raisesNotImplementedErroron the Stute family (same pseudo-stratum centering gap as the HAD sup-t deviation, parallel follow-up). The canonical kwarg on all 8 HAD surfaces issurvey_design=(see “Note (HAD survey-design API consolidation)” below); the deprecatedsurvey=/weights=aliases were removed in 3.7.0 onHeterogeneousAdoptionDiD.fitand in 3.7.x on the 7 pretest helpers (passing them raisesTypeError).Note (HAD survey-design API consolidation): All 8 HAD surfaces —
HeterogeneousAdoptionDiD.fit,did_had_pretest_workflow,qug_test,stute_test,yatchew_hr_test,stute_joint_pretest,joint_pretrends_test,joint_homogeneity_test— accept the canonical kwargsurvey_design=(matchingContinuousDiD,EfficientDiD,ChaisemartinDHaultfoeuille). OnHeterogeneousAdoptionDiD.fit, the deprecatedsurvey=/weights=kwargs were removed in 3.7.0 —survey_design=is the sole weighting entry (the pweight/CCT-2014 dispatch branches are gone, sovariance_formulais now one ofNone/"survey_binder_tsl"/"survey_binder_tsl_2sls"), andcbandis now keyword-only. On the 7 pretest helpers (did_had_pretest_workflow,qug_test,stute_test,yatchew_hr_test,stute_joint_pretest,joint_pretrends_test,joint_homogeneity_test) the deprecatedsurvey=/weights=aliases were removed in 3.7.x — as onfit, passing either raisesTypeError, andsurvey_design=is the sole weighting entry (the 3-way alias mutex and its per-surface-group error messages are gone with the aliases). Internal back-end behavior for the survivingsurvey_design=/ unweighted paths is UNCHANGED (byte-identical output). Migration by surface group: data-in surfaces (workflow + joint data-in wrappers) takesurvey_design=SurveyDesign(weights='col_name', ...)— the former row-levelweights=array shortcut is gone, add the weights as a column instead (per-unit aggregation + mean-1 normalization make the two forms numerically identical); the three array-in linearity helpers (stute_test/yatchew_hr_test/stute_joint_pretest) takesurvey_design=make_pweight_design(arr)(for pweight-only) orsurvey_design=<pre-resolved ResolvedSurveyDesign>(for full PSU/strata/FPC). The 8th surface —qug_test— permanently rejectssurvey_design=(Phase 4.5 C0 deferral, see “QUG Null Test” §). Array-in helpers rejectsurvey_design=SurveyDesign(...)withTypeErrorsince they have nodatato resolve column names against. Themake_pweight_design(weights: np.ndarray) -> ResolvedSurveyDesignfactory is exported from thediff_difftop level (formerlysurvey._make_trivial_resolved, kept as a permanent private alias for back-compat);weightsmust be 1-D (scalar / 0-D / column-vector inputs raiseValueErrorat the front door).
Weighted 2SLS (Phase 4.5 B): _fit_mass_point_2sls(..., weights=, return_influence=) extends the Wald-IV / 2SLS sandwich with pweight semantics:
Weighted bread:
Z'WX = Z'·diag(w)·X(w¹, matchesestimatr::iv_robust(..., weights=)weighted-bread convention).HC1 pweight meat:
Ω_HC1 = (n/(n-k)) · Z'·diag(w² u²)·Z(w²squared, Wooldridge 2010 Eq. 12.37; matcheslinalg.py:1141pweight convention). Bit-exact withestimatr::iv_robust(..., weights=, se_type="HC1")atatol=1e-10.CR1 pweight-cluster meat: for each cluster c,
s_c = Z'_c·(w·u)_c;Ω_CR1 = (G/(G-1))·((n-1)/(n-k))·Σ_c s_c s_c'(w¹inside cluster score). Bit-exact withestimatr::iv_robust(..., weights=, clusters=, se_type="stata")atatol=1e-10.Classical: sandwich form
Ω_cl = σ²·Z'·diag(w²)·Zwithσ² = Σw²u²/(Σw-k). Deviates fromestimatrclassical (projection-form +n-kDOF) byO(1/n)at non-uniform weights; unweighted path is bit-exact by equivalence. Skipped in cross-language parity tests.Per-unit IF on β̂-scale (for Binder-TSL survey composition):
psi_g = [(Z'WX)^{-1} · z_g · w_g · u_g][1] · sqrt((n-1)/(n-k)). The scaling factor absorbs DOF / small-sample differences socompute_survey_if_variance(psi, trivial_resolved) ≈ V_HC1[1,1]atatol=1e-10(mirrors PR #359 convention; asserted byTestIFScaleInvariantand bit-exact against estimatr HC1 on 4 DGPs). Fixture:benchmarks/R/generate_estimatr_iv_robust_golden.R→benchmarks/data/estimatr_iv_robust_golden.json.Intercept SE (
return_intercept_se=True): the 2×2 sandwichValready carries the intercept varianceV[0,0]; the opt-in hook surfacessqrt(V[0,0])(default off — the production 3-tuple return is byte-unchanged, so no public-API change). HC1 and CR1 intercept SEs are bit-exact withestimatrse_interceptatatol=1e-10(tests/test_estimatr_iv_robust_parity.py); the classical intercept carries the sameO(1/n)projection/DOF deviation as the slope (bullet above) and is likewise excluded from the parity lock.
Event-study survey composition (Phase 4.5 B): The per-horizon loop in _fit_event_study threads weights_unit_full + resolved_survey_unit_full through to both _fit_continuous and _fit_mass_point_2sls (the latter with return_influence=True under weighted fits). The returned IF matrix Psi ∈ R^{G × H} has a shared construction contract across paths — each column on the β̂-scale, such that compute_survey_if_variance(Psi[:, e], resolved) ≈ V_β[e]. Per-horizon analytical variance uses Binder-TSL via compute_survey_if_variance (the survey_design= path). survey_metadata, variance_formula ("survey_binder_tsl" / "survey_binder_tsl_2sls"), and effective_dose_mean populate identically to the static path. Pre-PR numerical output is preserved bit-exactly on the unweighted path when cband=False (stability invariant; Phase 2b convention unchanged for unweighted fits).
Sup-t multiplier bootstrap (Phase 4.5 B): Simultaneous confidence band on the weighted/survey OR clustered event-study path via _sup_t_multiplier_bootstrap (the clustered branch is documented in “Note (HAD clustered event-study sup-t band)”):
Multiplier draws: reuse
diff_diff.bootstrap_utils.generate_survey_multiplier_weights_batch(survey_design= path: PSU-level draws with stratum centering, FPC scaling, lonely-PSU handling) orgenerate_bootstrap_weights_batch(clustered band: cluster-level Rademacher). Defaultn_bootstrap=999(CS parity);seedexposed onHeterogeneousAdoptionDiD.__init__for reproducibility.Perturbations:
delta = xi @ Psi— shape(B, H)matrix-matrix product, NO(1/n)prefactor (matchesstaggered_bootstrap.py:373idiom;Psiis already on the β̂-scale).t-statistics:
t[b, e] = delta[b, e] / se[e]wherese[e]is the per-horizon analytical Binder-TSL / HC1 SE from the loop above.Sup-t distribution:
sup_t[b] = max_e |t[b, e]|with finite-mask filtering of degenerate horizons.Critical value:
q = quantile(sup_t[finite], 1 - alpha). Simultaneous band:cband_low[e] = att[e] - q · se[e].
Reduction invariant: at H=1, the sup collapses to the marginal and q → Φ⁻¹(1 - alpha/2) ≈ 1.96 at alpha=0.05 up to MC noise. Locked by TestSupTReducesToNormalAtH1 (G=500, B=5000, seed=42, atol=0.15 on the quantile), its clustered variant test_clustered_sup_t_h1_reduces_to_normal (both the continuous scale-1.0 and mass-point √(G/(G-1)) scalars), and TestEventStudySurveyCband::test_trivial_survey_h1_sup_t_matches_analytical / test_stratified_h1_sup_t_matches_analytical for the trivial-survey and stratified cases respectively.
Scope: sup-t bootstrap runs when aggregate="event_study" AND cband=True (default) AND either (a) survey_design= is supplied (the survey band) OR (b) cluster= is supplied (the clustered band — see “Note (HAD clustered event-study sup-t band)”). An unweighted, unclustered event-study skips the bootstrap entirely — pre-Phase 4.5 B numerical output bit-exactly preserved. Setting cband=False disables the bootstrap on any path.
Deviation from shared survey-bootstrap contract:
_sup_t_multiplier_bootstrapraisesNotImplementedErroronSurveyDesign(lonely_psu="adjust")with singleton strata. The sharedgenerate_survey_multiplier_weights_batchhelper pools singleton PSUs into a pseudo-stratum with NONZERO multipliers, butcompute_survey_if_variancecenters singleton PSU scores at the GLOBAL mean of PSU scores (rather than the pseudo-stratum mean). Matching the two would require a pooled-singleton pseudo-stratum centering transform in the HAD sup-t path that has not been derived. The HAD-specific limitation is scoped to: weighted event-study +cband=True+lonely_psu="adjust"+ at least one singleton stratum. Practitioners can uselonely_psu="remove"or"certainty"(matches the analytical target bit-exactly on the HAD sup-t path), or passcband=Falseto skip the simultaneous band. All other survey-bootstrap consumers (CallawaySantAnna, dCDH, SDID) retain fulllonely_psu="adjust"support through the shared helper.Deviation: weighted mass-point
vcov_type="classical"on survey/sup-t paths:vcov_type="classical"raisesNotImplementedErrorwhenever the mass-point IF matrix is consumed downstream — specifically ondesign="mass_point"+survey_design=(static and event-study, regardless ofcband, since the Binder-TSL analytical SE consumes the HC1-scaled IF either way) — only whencluster=is NOT set (withcluster=the mass-point path computes the CR1 sandwich regardless ofvcov_type, so no classical-vs-HC1 mismatch exists and the classical-rejection is guarded bycluster_arg is None). The per-unit 2SLS IF returned by_fit_mass_point_2slsis scaled (sqrt((n-1)/(n-k))) to match V_HC1 viacompute_survey_if_variance; mixing it with a classical analytical SE would silently return a V_HC1-targeted variance under a classical label. A classical-aligned IF derivation is queued for a follow-up PR. The allowed weighted-mass-point combinations are:vcov_type="hc1"on everysurvey_design=path; and anycluster=composition (resolves to CR1).Deviation:
cluster=+survey_design=rejected (both designs): thesurvey_design=path composes Binder-TSL variance viacompute_survey_if_variance, which would silently overwrite the cluster-robust sandwich while result metadata still reportsvcov_type="cr1". Rejected up-front oncluster=+survey_design=for BOTH designs (continuous_*andmass_point), static and event-study — for weighted clustering route throughsurvey_design=SurveyDesign(weights='<weight_col>', psu='<cluster_col>')instead. All othercluster=compositions WORK end-to-end (pointwise CIs AND the simultaneous band): a bare unweightedcluster=withcbandeitherFalse(pointwise cluster-robust only) orTrue(adds the clustered sup-t band — see “Note (HAD clustered event-study sup-t band)” below).Note (HAD clustered event-study sup-t band): when a bare
cluster=is set,_fit_event_studyproduces cluster-robust per-horizon pointwise CIs AND a cluster-robust simultaneous band on BOTH designs (continuous CCT and mass-point 2SLS) on an unweighted fit (acluster=+survey_design=composition is rejected — see the deviation above; weighted clustering routes throughsurvey_design=SurveyDesign(weights=..., psu=...), which takes the survey branch, not this clustered branch). Pointwise:cluster_arrthreads intobias_corrected_local_linear(continuous, static-path parity) or_fit_mass_point_2sls(mass-point CR1). Band:_sup_t_multiplier_bootstraptakes a dedicated clustered branch — it aggregates the per-unit β̂-scale influence function to cluster level (s_c = Σ_{i∈c} ψ_i) and draws cluster-level iid Rademacher multipliers, so the perturbation variance is the RAW cluster sandwichΣ_c s_c²(no stratum-centering / FPC / Bessel — distinct from the survey branch). This matches each path’s analytical cluster-robust SE via a path scalar: 1.0 for continuous (thelprobust_vcecluster meat carries nog/(g-1)correction, soΣ_c s_c² == se_rb²exactly) and√(G/(G-1))for mass-point (the returned IF carries√((n-1)/(n-k))but not the CR1G/(G-1)factor;Gis the full-array cluster count, identical to the bootstrap branch’s ownn_clusters, so a wholly-zero-weight cluster contributess_c=0to both the analytical Ω and the bootstrap yet is counted inGby both). The variance-family reconciliation is validated bootstrap-free:sqrt(Σ_c (scale·s_c)²) == setoatol=1e-10on the real IF for both paths (TestEventStudyClusterBand::test_{continuous,masspoint}_if_reconciliation_deterministic), plus theH=1 → 1.96reduction on the clustered branch (TestSupTReducesToNormalAtH1::test_clustered_sup_t_h1_reduces_to_normal). Single cluster (G<2) → NaN band +RuntimeWarning(CR undefined).cband_method="cluster_multiplier_bootstrap"on this path. No R anchor (no reference package computes an HAD clustered sup-t band); the reconciliation identity is the validation.2SLS (Design 1 mass-point case): standard 2SLS inference (details not elaborated in the paper).
TWFE with small
G: HC2 standard errors with Bell-McCaffrey (2002) degrees-of-freedom correction, following Imbens and Kolesar (2016). Used in the Pierce and Schott (2016) application withG=103. Added library-wide todiff_diff/linalg.pyas a newvcov_typedispatch (Phase 1a), exposed onDifferenceInDifferencesandTwoWayFixedEffects.Bootstrap: wild bootstrap with Mammen (1993) two-point weights is used for the Stute test (see Diagnostics below), NOT for the main WAS estimator. Reuses the existing
diff_diff.bootstrap_utils.generate_bootstrap_weights(..., weight_type="mammen")helper.Clustering: no explicit clustering formulas in the paper’s core equations.
Convergence rates:
Design 1’ nonparametric estimator:
G^{2/5}(univariate nonparametric rate; Equations 5-6).Design 1 discrete-mass-point case:
√G(parametric rate).Estimate of
d̲viamin_g D_{g,2}: rateG(asymptotically negligible).
Asymptotic distributions (Equations 5-6):
Equation 5:
√(G h_G) (β̂_{h_G}^{np} - WAS - h_G² · C m''(0) / (2 E[D_2])) →^d N(0, σ²(0) ∫_0^∞ k*(u)² du / (E[D_2]² f_{D_2}(0)))Equation 6 (optimal rate,
G^{1/5} h_G → c > 0):G^{2/5} (β̂_{h_G}^{np} - WAS) →^d N(c² C m''(0) / (2 E[D_2]), σ²(0) ∫_0^∞ k*(u)² du / (c E[D_2]² f_{D_2}(0)))Kernel constants:
κ_k := ∫_0^∞ t^k k(t) dt,k*(t) := (κ_2 - κ_1 t) / (κ_0 κ_2 - κ_1²) · k(t),C := (κ_2² - κ_1 κ_3) / (κ_0 κ_2 - κ_1²).
Edge cases:
No genuinely untreated units, D_2 continuous with
d̲ = 0(Design 1’): useβ̂_{h*_G}^{np}(Equation 7) with bias-corrected CI (Equation 8).No untreated units,
d̲ > 0,D_2has mass point atd̲: use 2SLS ofΔYonD_2with instrument1{D_2 > d̲}, or equivalent sample-average formula. Identifies WAS_{d̲} under Assumption 6 (Theorem 3) or the sign of WAS under Assumption 5 (Theorem 2).No untreated units,
d̲ > 0,D_2continuous neard̲: replace 0 byd̲andD_2byD_2 - d̲in Equation 7; estimated̲bymin_g D_{g,2}.Genuinely untreated units present but a small share: Authors do NOT require untreated units to be dropped. In the Garrett et al. (2020) bonus-depreciation application with 12 untreated counties out of 2,954, they keep the untreated subsample. Simulations (DGP 2, DGP 3) suggest CIs retain close-to-nominal coverage even when
f_{D_2}(0) = 0.WAS is not point-identified without a QUG (Proposition 1, proof C.1): the proof explicitly constructs
tilde-Y_2(d) := Y_2(d) + (c / d̲) · E[D_2] · (d - d̲)for anyc ∈ R, compatible with the data under Assumptions 2 and 3 but withtilde-WAS = WAS + c. Practical consequence: do NOT report a point estimate of WAS under Design 1 without Assumption 5 or 6; fall back to Theorem 2 (sign) or Theorem 3 (WAS_{d̲}).Extensive-margin effects: ruled out by Assumption 3. If a jump
Y_2(0) ≠ Y_2(0+)is suspected, the target parameter and estimator are not appropriate.Partial identification of WAS_{d̲}: only identified up to a positive constant offset
≤ εby the bound in Equation 22 (Jensen inequality argument in Appendix C.3).Density at boundary: Assumption 4 requires
f_{D_2}(0) > 0. This is a non-trivial assumption since 0 is on the boundary ofSupp(D_2).Variation in treatment timing: Appendix B.2 - “in designs with variation in treatment timing, there must be an untreated group, at least till the period where the last cohort gets treated.” In Phase 2b (
aggregate="event_study") the implementation auto-filters to the last-treatment cohort plus never-treated units with aUserWarningwhenfirst_treat_colis supplied (see Phase 2b last-cohort filter note below); whenfirst_treat_colis omitted the estimator detects multiple first-positive-dose cohorts from the dose path and raises a front-doorValueErrordirecting users to passfirst_treat_color useChaisemartinDHaultfoeuille.Mechanical zero at reference period under linear trends (Footnote 13, main text p. 31): with industry/unit-specific linear trends, the pre-trends estimator is mechanically zero in the second-to-last pre-period (the slope anchor year). Practical consequence: that year is not an informative placebo check.
Algorithm (Design 1’ nonparametric - summarized from Section 3.1.3-3.1.4 and Equations 7-8):
Compute bandwidth
ĥ*_Gvia Calonico et al. (2018) plug-in MSE-optimal bandwidth selector on the local-linear regression ofΔY_gonD_{g,2}with kernel weightsk(D_{g,2}/h)/h.Fit the local-linear regression at bandwidth
ĥ*_G; read off the interceptμ̂_{ĥ*_G}.Compute
β̂_{ĥ*_G}^{np} = ((1/G) Σ ΔY_g - μ̂_{ĥ*_G}) / ((1/G) Σ D_{g,2})(Equation 7).Compute the first-order bias estimator
M̂_{ĥ*_G}and the variance estimatorV̂_{ĥ*_G}(Calonico et al. 2018, 2019).Form the bias-corrected 95% CI by Equation 8.
Algorithm variant - Design 1 mass-point 2SLS (Section 3.2.4):
Detect a mass point at
d̲: either user-suppliedd̲or detected automatically via thedesign="auto"rule (fraction of observations atmin_g D_{g,2}exceeds 2%).Either compute
(Ȳ_{D_2 > d̲} - Ȳ_{D_2 = d̲}) / (D̄_{D_2 > d̲} - D̄_{D_2 = d̲})(sample averages), or run 2SLS ofΔY_gonD_{g,2}with instrument1{D_{g,2} > d̲}.Report the estimate as WAS_{d̲} under Assumption 6 or as the sign-identifying quantity under Assumption 5.
Algorithm variant - QUG null test (Theorem 4, Section 3.3):
Tuning-parameter-free test of H_0: d̲ = 0 versus H_1: d̲ > 0. Shipped in diff_diff/had_pretests.py as qug_test().
Sort
D_{2,g}ascending to obtain order statisticsD_{2,(1)} ≤ D_{2,(2)} ≤ ... ≤ D_{2,(G)}.Compute test statistic
T := D_{2,(1)} / (D_{2,(2)} - D_{2,(1)}).Reject
H_0ifT > 1/α - 1.Theorem 4 establishes: asymptotic size
α; uniform consistency against fixed alternatives; local power at rateGon the classF^{d̲,d̄}_{m,K}of differentiable cdfs with positive density and Lipschitz derivative.Li et al. (2024, Theorem 2.4) implies the QUG test is asymptotically independent of the WAS / TWFE estimator, so conditional inference on WAS given non-rejection does not distort inference (asymptotically; the paper’s Footnote 8 notes the extension to triangular arrays is conjectured but not proven).
Note: Implementation is
O(G)vianp.partition; no sort required.Note (Phase 4.5 C0):
qug_test(..., survey_design=...)raisesNotImplementedErrorpermanently (Phase 4.5 C0 decision gate, 2026-04 – direct-helper gate is permanent; the removed 3.7.xsurvey=/weights=aliases now raiseTypeErrorat the signature). The Phase 4.5 C0 release also gateddid_had_pretest_workflow(..., survey=...)/weights=withNotImplementedError, but that workflow gate was temporary: Phase 4.5 C (PR #370, 2026-04) replaces it with functional dispatch that skips the QUG step withUserWarningand runs the linearity family with the survey-aware mechanism (see Note (Phase 4.5 C) below for the full algorithm). Direct callers ofqug_teststill get the permanent rejection. Three reasons QUG-under-survey is genuinely hard, not “we just haven’t done the lit review”:Extreme order statistics are not smooth functionals of the empirical CDF. Standard survey machinery (Binder-TSL linearization via
compute_survey_if_variance, Rao-Wu rescaled bootstrap viabootstrap_utils.generate_rao_wu_weights, Krieger-Pfeffermann (1997) EDF tests for complex surveys) all rely on Hadamard differentiability of the test statistic in the empirical CDF. The first two order statistics are NOT differentiable functionals — small perturbations to F near zero produce O(1) shifts inD_{(1)}. None of the standard survey-bootstrap or linearization tools give a calibrated test for QUG.The
Exp(1)/Exp(1)limit law assumes iid sampling with smooth density at zero. Under cluster sampling,D_{(1)}andD_{(2)}may both come from the same PSU, breaking the independence required for the Poisson-process limit of rescaled spacings near the boundary. Under stratification, the smallest dose may come from a small stratum that’s systematically over- or under-sampled, biasing the test.The literature on EVT under unequal-probability sampling is sparse. Quintos et al. (2001) and Beirlant et al. cover tail-INDEX estimation under unequal sample sizes. There is no off-the-shelf method for “test the support endpoint under complex sampling” in the standard survey-statistics toolkit. Adapting Hill / Pickands / DEdH estimators to the boundary problem would be novel research, not engineering. The de Chaisemartin et al. (2026) paper itself does not discuss survey extensions of QUG. The survey-compatible alternative for HAD pretesting is joint Stute (a CvM cusum of regression residuals) — a smooth functional of the empirical CDF for which Krieger-Pfeffermann (1997) + a survey-aware multiplier bootstrap give a calibrated test. Phase 4.5 C (PR #370) ships survey support for the linearity family — the PSU-level Mammen multiplier bootstrap for
stute_testand the joint variants (NOT Rao-Wu rescaling — multiplier bootstrap is a different mechanism), and closed-form weighted OLS + pweight-sandwich variance components foryatchew_hr_test. See the dedicated Note (Phase 4.5 C) below for the full algorithm. Research direction (out of scope for diff-diff): the bridge IS sketchable by combining (a) endpoint-estimation EVT under iid (Hall 1982, Aarssen-de Haan 1994, Hall-Wang 1999, Beirlant-de Wet-Goegebeur 2006); (b) survey-aware functional CLT for the empirical process (Boistard-Lopuhaä-Ruiz-Gazen 2017, Bertail-Chautru-Clémençon 2017); and (c) tail-empirical-process theory (Drees 2003) to define a “design-effective boundary intensity”λ_eff = Σ_h W_h · f_h(0+). Under a “no boundary clumping” assumption (P(D_{(1)}, D_{(2)}in same PSU| both ≤ δ) → 0), theExp(1)/Exp(1)limit law’s pivotality is preserved and only the calibration needs a survey-aware bootstrap (subsampling within strata per Politis-Romano-Wolf, or Bertail et al.’s design-aware bootstrap). This is publishable methodology research — one paper, ~6-12 months for a methods PhD student. If the bridge gets built and published externally, this gate can be revisited.
Note (Phase 4.5 C):
stute_test,yatchew_hr_test,stute_joint_pretest,joint_pretrends_test,joint_homogeneity_test, anddid_had_pretest_workflowacceptsurvey_design=(the sole weighting entry — the deprecatedsurvey=/weights=aliases were removed in 3.7.x; see “Note (HAD survey-design API consolidation)” below). On data-in surfaces (did_had_pretest_workflow,joint_pretrends_test,joint_homogeneity_test),survey_design=accepts aSurveyDesign(resolved againstdataat fit time). On array-in surfaces (stute_test,yatchew_hr_test,stute_joint_pretest),survey_design=accepts a pre-resolvedResolvedSurveyDesign; for the pweight-only convenience, construct viasurvey_design=make_pweight_design(arr)(make_pweight_designexported from thediff_difftop level). Mechanism varies by test:Stute family (
stute_test,stute_joint_pretest, joint wrappers) uses PSU-level Mammen multiplier bootstrap viabootstrap_utils.generate_survey_multiplier_weights_batch(the same kernel as PR #363’s HAD event-study sup-t bootstrap). Each replicate draws an(n_bootstrap, n_psu)Mammen multiplier matrix; multipliers broadcast to per-obs perturbationeta_obs[g] = eta_psu[psu(g)]. The bootstrap residual perturbation isdy_b = fitted + eps * eta_obs(paper Appendix D wild-bootstrap form — multipliers attach to UNWEIGHTED residuals; the weighting flows through the OLS refit + the weighted CvM, NOT through the perturbation step). Followed by weighted OLS refit (_fit_weighted_ols_intercept_slope) and weighted CvM recompute via_cvm_statistic_weighted. Joint Stute SHARES the multiplier matrix across horizons within each replicate, preserving both the vector-valued empirical-process unit-level dependence (Delgado 1993; Escanciano 2006) AND PSU clustering (Krieger-Pfeffermann 1997). PSU-shared multipliers are conservative under no-within-PSU outcome correlation (over-clustering gives conservative size in finite samples), asymptotically correct under the standard survey assumption that PSU is the ultimate sampling unit AND outcomes correlate within PSU. The pweight-only entry (survey_design=make_pweight_design(arr)) routes through a synthetic trivialResolvedSurveyDesign(constructed viamake_pweight_design, the public alias for the formerly privatesurvey._make_trivial_resolved) so the kernel is shared across both entry paths. NOT “Rao-Wu rescaled bootstrap” — different mechanism (the Rao-Wu kernel rescales per-unit weights via stratified PSU resampling, while this kernel applies multipliers without resampling).Yatchew (
yatchew_hr_test) uses closed-form weighted OLS + pweight-sandwich variance components (no bootstrap). All three components reduce bit-exactly to the unweighted formulas atw=ones(G)(locked atatol=1e-14inTestYatchewHRTestSurvey::test_weighted_reduces_to_unweighted_at_uniform_weights):sigma2_lin = sum(w * eps^2) / sum(w)(weighted OLS residual variance).sigma2_diff = sum(w_avg * (dy_g - dy_{g-1})^2) / (2 * sum(w))with arithmetic-mean pair weightsw_avg_g = (w_g + w_{g-1})/2. Divisor usessum(w)(=G atw=1), NOTsum(w_avg), to match the existing(1/(2G))unweighted formula inyatchew_hr_test.sigma4_W = sum(w_avg * eps_g^2 * eps_{g-1}^2) / sum(w_avg)reduces to(1/(G-1)) * sum(prod)atw=1.T_hr = sqrt(sum(w)) * (sigma2_lin - sigma2_diff) / sigma2_W(effective-sample-size convention; reduces tosqrt(G)atw=1). Strictly positive weights required (the adjacent-difference variance is undefined under contiguous-zero blocks). PSU clustering is NOT propagated through the variance-ratio statistic (would require a survey-aware variance-of-variance estimator, out of scope). Pair-weight convention follows Krieger-Pfeffermann (1997, §3) for design-consistent inference on smooth functionals.
Workflow (
did_had_pretest_workflow) undersurvey_design=: skips the QUG step with aUserWarning(per Phase 4.5 C0 deferral), setsqug=Noneon the report, and dispatches the linearity family with the survey-aware mechanism. Verdict carries a"linearity-conditional verdict; QUG-under-survey deferred per Phase 4.5 C0"suffix.all_passdrops the QUG-conclusiveness condition; the linearity-conditional rule splits by aggregate:aggregate="overall":Trueiff at least one ofstute/yatchewis conclusive AND no conclusive test rejects (paper Section 4 step-3 “Stute OR Yatchew” wording carries through).aggregate="event_study":Trueiffpretrends_jointis non-None and conclusive,homogeneity_jointis conclusive, AND neither rejects. Both joint variants must be conclusive on the event-study path (same step-2 + step-3 closure as the unweighted aggregate, just without the QUG step).
Replicate-weight survey designs (BRR/Fay/JK1/JKn/SDR) deferred to a parallel follow-up. Each helper raises
NotImplementedErroronsurvey.replicate_weights is not None(defense in depth: workflow + every direct-helper entry rejects, mirroring the reciprocal-guard discipline from PR #346). The per-replicate weight-ratio rescaling for the OLS-on-residuals refit step is not covered by the multiplier-bootstrap composition above.lonely_psu='adjust'with singleton strata is rejected withNotImplementedErroron the Stute family (mirrors HAD sup-t bootstrap athad.py:2081-2118). The bootstrap multiplier helper pools singleton strata into a pseudo-stratum with nonzero multipliers, but the analytical variance target requires a pseudo-stratum centering transform that has not been derived for the Stute CvM. Uselonely_psu='remove'(drops singleton contributions) or'certainty'(zero-variance singletons); both produce all-zero singleton multipliers that match a well-defined analytical target. Variance-unidentified designs (df_survey <= 0after the adjust+singleton case is handled) returnNaNwith aUserWarning(single-PSU unstratified or one-PSU-per-stratum under remove/certainty).Stratified designs (
SurveyDesign(strata=...)) are supported via the standard stratified clustered wild bootstrap correction on the PSU multipliers (Cameron-Gelbach-Miller 2008; Davidson-Flachaire 2008; Djogbenou-MacKinnon-Nielsen 2019; Kreiss-Lahiri 2012; Wu 1986; Liu 1988). See the dedicated “Note: Stute stratified survey-bootstrap calibration” below for the algorithm. Remaining deferrals:lonely_psu='adjust'+ singleton strata (same pseudo-stratum centering gap as the HAD sup-t deviation documented above; requires a separate analytical-target derivation) and replicate-weight designs (BRR/Fay/JK1/JKn/SDR; separate Rao-Wu/JKn bootstrap composition).
Note (Stute stratified survey-bootstrap calibration): The Stute survey-bootstrap is a wild residual bootstrap (Hlávka-Hušková 2020) with cluster-level multipliers (Cameron, Gelbach & Miller 2008). The per-replicate loop is
eta_obs = psu_mults[b, psu_col_idx]; dy_b = fitted + eps * eta_obs; refit weighted OLS; recompute weighted CvM(the per-replicate loops instute_testandstute_joint_pretest). Under stratified PSU sampling, the multiplierspsu_mults[b, :]exitgenerate_survey_multiplier_weights_batchas within-stratum-independent draws with the(1 - f_h)FPC factor already baked in (bootstrap_utils.py:579-651). To make the bootstrap CvM variance match the analytical Binder-TSL stratified targetV_S = sum_h (1 - f_h) * (n_h / (n_h - 1)) * sum_{j in h} (psi_hj - psi_h_bar)², two additional corrections are applied to the multipliers BEFORE the per-obs broadcast. Citations below are ingredients, not direct papers on this exact composition — the specific recipe (within-stratum demean + Bessel rescale on PSU multipliers applied before broadcast in a wild-residual refit-in-loop bootstrap for the Stute CvM functional) is a library synthesis; no single paper covers all of it.Within-stratum demean: for each stratum
h,psu_mults[b, cols_h] -= psu_mults[b, cols_h].mean(). The within-cluster mean-zero requirement is the canonical wild-bootstrap centering for heteroskedastic regression (Davidson & Flachaire 2008); applying it within each stratum (rather than across all clusters) under stratified PSU sampling is the library synthesis. The Kreiss-Lahiri (2012) block-bootstrap family supplies the methodological analogy for within-block centering under hierarchical sampling but does not cover stratified-survey designs directly.Bessel rescale: multiply by
sqrt(n_h / (n_h - 1)). Standard small-sample correction (Wu 1986; Liu 1988) — makes the bootstrap variance match the unbiased within-stratum variance estimator and bakes the remaining(n_h / (n_h - 1))factor ofV_S.
The combined correction is algebraically identical to the HAD sup-t event-study bootstrap’s stratum centering (
had.py:2188-2204), applied to PSU multipliers instead of the PSU-aggregated influence tensor. Both call sites consume the shared helperbootstrap_utils.apply_stratum_centering(psu_mults, resolved_survey, psu_ids)to lock the algebraic identity architecturally rather than relying on parallel code blocks staying in sync.The pipeline-position difference between Stute and HAD sup-t is forced by the bootstrap structure, NOT a difference in the correction algebra: HAD sup-t is a multiplier bootstrap on a precomputed PSU influence tensor (the sup-t statistic is a linear functional of that tensor —
perturbations = psu_weights @ Psi_psu_scaled), so the demean + Bessel rescale can be applied to the tensor after PSU aggregation. Stute is a wild residual bootstrap that refits OLS and recomputes the nonlinear CvM functional inside the per-draw loop, so there is no precomputed PSU influence tensor to scale; the correction has to be applied at the multiplier-generation step, before the per-obs broadcast.Consistency of the resulting bootstrap CvM distribution under stratified PSU sampling follows from Djogbenou, MacKinnon & Nielsen (2019) Theorem 2 (empirical-process consistency of the cluster wild bootstrap), with the Krieger-Pfeffermann (1997) survey-weighted multiplier-bootstrap extension routed through the multiplier draws rather than the influence tensor. For the multi-horizon joint Stute (
stute_joint_pretest), the samepsu_mults[b, :]row is shared across horizons within each replicate, preserving cross-horizon empirical-process dependence (Hlávka-Hušková 2020 §3 condition) and PSU clustering. The combined correction is the standard non-parametric requirement and does not depend on the CvM functional shape — it works for any nonlinear smooth-functional bootstrap consumer ofeta_obs = psu_mults[b, psu_col_idx].Non-strata calibration improvement. When
strata=None, the correction is applied uniformly with a single implicit stratum (n_h = n_psu): demean across all PSUs, multiply bysqrt(n_psu / (n_psu - 1)). This mirrors the HAD sup-t convention athad.py:2199-2204and brings Stute non-strata into line with the sibling event-study path. The pre-PR Phase 4.5 C non-strata path applied no centering or rescaling — multipliers were raw iid draws. The bootstrap CvM p-values on non-strata designs (pweight-only, PSU-only, FPC-only) shift by approximatelysqrt(n_psu / (n_psu - 1)) - 1relative to the pre-PR path (≈ 1.7% forn_psu = 60, decreasing to ≈ 0.5% forn_psu = 100). This is a calibration improvement, NOT a regression: the pre-PR path was under-corrected by exactly this factor. Two complementary regressions cover any revert of the helper or its wiring: (1) the helper bit-parity regression attests/test_bootstrap_utils.py::TestApplyStratumCentering::test_bit_parity_vs_pre_refactor_inline_block(locked atatol=1e-14) catches any change to the helper’s axis-0 algebra; (2) a wired-in regression attests/test_had_pretests.py::TestStuteStratifiedSurveyBootstrap::test_stute_call_sites_invoke_apply_stratum_centeringmonkey-patches the helper and asserts both Stute call sites (stute_testandstute_joint_pretest) invoke it withpsu_axis=1, which catches the disconnection case the helper bit-parity test does not. End-to-end Stute non-strata fit is exercised as a finite + range smoke (tests/test_had_pretests.py::TestStuteStratifiedSurveyBootstrap::test_calibration_shift_non_strata_end_to_end_smoke); a heavier worktree-based pre/post baseline comparison was considered and intentionally skipped as redundant with the helper-level bit-parity lock and the call-site wiring regression.Validated via: MC oracle consistency under a stratified null DGP (200 draws, 4 strata × 6 PSUs/stratum, weights+strata+PSU design — no FPC at the panel level; the helper’s FPC bake-in is covered separately by
tests/test_bootstrap_utils.py::TestApplyStratumCentering::test_fpc_baked_in_helper_is_fpc_agnostic); empirical Type I error at α=0.05 in[0.0, 0.10](3σ band, seed-set). MC power under a stratified known-alternative DGP (same shape, quadraticE[ΔY|D]); rejection rate > 0.50 at α=0.05.Known parity gap. No R reference implements stratified Stute under survey weights —
chaisemartin::did_haddoes not run pretests at all, andnprobusthas no weight argument. Methodology confidence comes from the algebraic-identity reduction to the existing HAD sup-t centering (locked atatol=1e-14by the shared-helper unit test + HAD sup-t bit-parity regression) + the MC oracle + power simulations above. Same parity-ceiling acknowledgment as Phase 4.5 A0 (no public weighted-CCF reference for the bias-corrected local-linear).Constant-within-unit invariant: the per-row
survey_design=SurveyDesign(weights='col', ...)inputs are aggregated to per-unit(G,)arrays via the existing HAD helpers_aggregate_unit_weights/_aggregate_unit_resolved_survey(had.py:1604, :1671); these enforce constant-within-unit invariant on weights and on every survey design column (strata, psu, fpc) and raise on violation. Direct callers passing already-resolvedResolvedSurveyDesign(or per-unitweightsarray) bypass this aggregation; the invariant is the caller’s responsibility on that path.Distributional parity, NOT bit-exact: at unit weights (
make_pweight_design(ones(G))) the survey path produces a different bootstrap p-value than the unweighted path because RNG consumption differs (batchedgenerate_survey_multiplier_weights_batchvs per-iteration_generate_mammen_weights). The two paths agree DISTRIBUTIONALLY at large B (|p_avg_diff| < 0.03over 100 reps atB=5000); they DO NOT agree numerically atatol=1e-10. The unweighted code path is preserved bit-exactly (stability invariant; the survey-awaresurvey_design=branch is a separateifarm).
Algorithm variant - TWFE linearity test via Stute (1997) Cramér-von Mises with wild bootstrap (Section 4.3, Appendix D):
Shipped in diff_diff/had_pretests.py as stute_test(). Tests whether E(ΔY | D_2) is linear, the testable implication of TWFE’s homogeneity assumption (Assumption 8) in HADs.
Fit linear regression of
ΔY_gon constant andD_{g,2}; collect residualsε̂_{lin,g}.Form cusum process
c_G(d) := G^{-1/2} Σ_{g=1}^G 1{D_{g,2} ≤ d} · ε̂_{lin,g}.Compute Cramér-von Mises statistic
S := (1/G) Σ_{g=1}^G c_G²(D_{g,2}). Equivalently, after sorting byD_{g,2}:S = Σ_{g=1}^G (g/G)² · ((1/g) Σ_{h=1}^g ε̂_{lin,(h)})².Wild bootstrap for p-value (Stute, Manteiga, Quindimil 1998; Algorithm in main text p. 25 and vectorized form in Appendix D):
Draw
(η_g)_{g=1,...,G}i.i.d. from the Mammen two-point distribution:η_g = (1+√5)/2with probability(√5-1)/(2√5), elseη_g = (1-√5)/2. Reusesdiff_diff.bootstrap_utils.generate_bootstrap_weights(..., "mammen").Set
ε̂*_{lin,g} := ε̂_{lin,g} · η_g.Compute
ΔY*_g = β̂_0 + D_{g,2} · β̂_{fe} + ε̂*_{lin,g}(paper writesΔD_ghere, which equalsD_{g,2}sinceD_{g,1} = 0; the two forms are equivalent in this design).Re-fit OLS on the bootstrap sample to get
ε̂*_{lin,g}, computeS*.Repeat B times; the p-value is the fraction of
S*exceedingS.
Properties (page 26): asymptotic size, consistency under any fixed alternative, non-trivial local power at rate
G^{-1/2}.Vectorized implementation (Appendix D): with
LaG × Glower-triangular matrix of ones,S = (1/G²) · 1ᵀ (L · E)^{∘2}. Bootstrap uses aG × Grealization matrixHof Mammen weights; memory-bounded atG ≈ 100,000.
Note: Default
n_bootstrap = 999is a diff-diff choice; the paper does not prescribe. A front-doorValueErroris raised forn_bootstrap < 99(below which the discretised bootstrap p-value grid1/(B+1)is too coarse to be meaningful).
Algorithm variant - Yatchew (1997) heteroskedasticity-robust specification test (Appendix E, Theorem 7):
Shipped in diff_diff/had_pretests.py as yatchew_hr_test(). Alternative to Stute when G is large or heteroskedasticity is suspected. Two nulls supported via the keyword-only null= argument: "linearity" (default; paper Theorem 7) and "mean_independence" (R-parity extension, see Note below).
Sort
(D_{g,2}, ΔY_g)byD_{g,2}.Compute difference-based variance estimator:
σ̂²_{diff} := (1/(2G)) Σ_{g=2}^G [(Y_{2,(g)} - Y_{1,(g)}) - (Y_{2,(g-1)} - Y_{1,(g-1)})]².Fit OLS; compute residual variance
σ̂²_{lin}. Undernull="linearity"(default): residuals fromdy ~ 1 + d(paper Theorem 7). Undernull="mean_independence": residuals from intercept-onlydy ~ 1, i.e.eps = dy - mean(dy)(R-parity extension).Heteroskedasticity-robust variance:
σ̂⁴_W := (1/(G-1)) Σ_{g=2}^G ε̂²_{lin,(g)} ε̂²_{lin,(g-1)}.Robust test statistic:
T_{hr} := √G · (σ̂²_{lin} - σ̂²_{diff}) / σ̂²_W. Undernull="linearity", reject linearity ifT_{hr} ≥ q_{1-α}(Equation 29 and downstream in Theorem 7). Undernull="mean_independence", the same statistic and critical value reject the mean-independence nullH_0: E[dY|D] = E[dY]— only the residual definition (and thereforeσ̂²_lin) differs between modes; theσ̂²_diff,σ̂⁴_W, and sort-by-dmachinery are shared.Theorem 7: under
H_0,lim E[φ_α] = α; under fixed alternative,lim E[φ_α] = 1; local power against alternatives at rateG^{-1/4}(slower than Stute’sG^{-1/2}rate, but scales toG ≥ 10⁵).Inference on
β̂_{fe}conditional on accepting the linearity test is asymptotically valid (Theorem 7, Point 1; citing de Chaisemartin and D’Haultfœuille 2024 arXiv:2407.03725).
Note (mean-independence null is the R-parity extension, not paper-derived): The paper (Appendix E, Theorem 7) defines
yatchew_hr_testonly under the linearity null. Thenull="mean_independence"mode mirrors RYatchewTest::yatchew_test(order=0)and is used by RDIDHAD::did_had(yatchew=TRUE)on placebo rows (“non-parametric pre-trends test” per the package README). Exposed for parity coverage of the placebo-Yatchew rows intests/test_did_had_parity.py::TestYatchewParity(PR #397). The default"linearity"mode is paper-derived; users invoking"mean_independence"are running an R-parity extension, not a paper-prescribed test.
Four-step pre-testing workflow (Section 4.2-4.3):
Shipped as did_had_pretest_workflow() in Phase 3 (two-period aggregate="overall") and extended in the Phase 3 follow-up with aggregate="event_study" dispatch that closes the step-2 pre-trends gap on multi-period panels. The paper’s decision rule for TWFE reliability in HADs:
Test the null of a QUG (
H_0: d̲ = 0) usingqug_test().Run a pre-trends test of Assumption 7 (requires at least one earlier pre-period).
Test that
E(ΔY | D_2)is linear (stute_testoryatchew_hr_test; or the joint Stute variants below in event-study dispatch).If NONE of the three is rejected,
β̂_{fe}from TWFE may be used to estimate the treatment effect.
Phase 3 delivery (aggregate="overall", two-period): did_had_pretest_workflow() runs steps 1 + 3 (QUG + Stute + Yatchew). Step 2 is NOT run on this path because a two-period panel has no pre-period placebo horizon to test against; the verdict explicitly flags the Assumption 7 gap via the “paper step 2 deferred” caveat.
Phase 3 follow-up delivery (aggregate="event_study", multi-period): did_had_pretest_workflow(..., aggregate="event_study") dispatches on a balanced ≥3-period panel. Runs QUG at F + joint pre-trends Stute across earlier pre-periods (step 2, mean-independence null) + joint homogeneity-linearity Stute across post-periods (step 3 joint extension, linearity null). Step 2 closure requires at least TWO pre-periods (the base pre-period plus one earlier placebo); on panels with only a single pre-period (the base F-1) the workflow emits pretrends_joint=None and the verdict flags the skip (“joint pre-trends skipped (no earlier pre-period)”). all_pass is False in this degenerate case. The verdict on the event-study path does NOT emit the “paper step 2 deferred” caveat when step 2 runs.
Algorithm variant - Joint Stute tests (Section 4.2-4.3 joint; Phase 3 follow-up 2026-04):
Shipped in diff_diff/had_pretests.py as stute_joint_pretest() (residuals-in core) plus two thin data-in wrappers joint_pretrends_test() (mean-independence null) and joint_homogeneity_test() (linearity null). Generalizes the single-horizon Stute CvM (above) to K horizons with joint inference.
Per-horizon statistic: for each horizon
k, computeS_kvia the tie-safe CvM on residualsε̂_{g,k}sorted by doseD_g.Joint aggregation:
S_joint = Σ_k S_k(sum-of-CvMs).Wild bootstrap with shared Mammen multiplier
η_gacross horizons per unit — preserves the vector-valued empirical process’s unit-level dependence (Delgado-Manteiga 2001; Hlávka-Hušková 2020 for related vector wild-bootstrap theory). Per-horizon OLS refit with shared design matrix precomputes(X'X)^{-1} X'once; the bootstrap loop cost per draw isO(G·p·K)for K horizons.Per-horizon exact-linear short-circuit: scale- and translation-invariant
Σ eps_k² / centered_TSS_k < _EXACT_LINEAR_RELATIVE_TOLtest applied per horizon. Joint short-circuit fires only when EVERY horizon is machine-exact linear; a single degenerate horizon does not collapse the test when others have nontrivial residuals.Two data-in wrappers:
joint_pretrends_test(pre_periods, base_period):null_form="mean_independence", design matrix[1]; residuals fromOLS(Y_t - Y_base ~ 1)per pre-period (paper Section 4.2 footnote 6 + Section 4.3 paragraph 1: “regress Y_1 − Y_0 on a constant [only], then apply CvM to residuals vs D_2”).joint_homogeneity_test(post_periods, base_period):null_form="linearity", design matrix[1, D]; residuals fromOLS(Y_t - Y_base ~ 1 + D)per post-period (paper Section 4.3 page 32 joint across post-periods, Pierce-Schott reports p=0.40).
Note: Sum-of-CvMs aggregation is a standard joint specification-test construction (Delgado 1993; Escanciano 2006); the paper does not prescribe an aggregation rule. Sum-of-CvMs balances power across diffuse vs concentrated alternatives and bootstraps cleanly with shared-η.
Note: Event-study dispatch adjudicates step 3 via joint Stute only; there is no joint Yatchew variant because the paper does not derive one. The overall two-period path still uses the Phase 3 “Stute OR Yatchew” adjudication. Users who need Yatchew-style adjacent-difference variance-ratio robustness under multi-period data can run
yatchew_hr_teston each (base, post) pair manually.Note (Phase 4 — Eq 17 / Eq 18 linear-trend detrending shipped):
trends_lin: bool = False(keyword-only) onHeterogeneousAdoptionDiD.fit(aggregate="event_study"),joint_pretrends_test, andjoint_homogeneity_test(PR #389, 2026-04). Mirrors RDIDHAD::did_had(..., trends_lin=TRUE)(Credible-Answers/did_had v2.0.0, SHAedc09197). Per-group linear-trend slope estimated asY[g, F-1] - Y[g, F-2]and applied as(t - base) × slopeadjustment to per-event-time outcome evolutions (HAD.fit) or toY[g, t] - Y[g, base]directly (joint pretests). The “consumed” placebo at our event-timee=-2is auto-dropped (R reduces max placebo lag by 1 with the same effect). Requires F ≥ 3 /base_period - 1in panel — front-doorValueErrorif not. Mutually exclusive with survey weighting (raisesNotImplementedErrorperfeedback_per_method_survey_element_contract; weighted slope estimator not derived from paper). Pierce-Schott published-number replication (paper p=0.51 / p=0.40 anchors) deferred indefinitely — primary analysis panel is LBD-restricted (Census FSRDC); the public-deposit proxy panel has filtering ambiguity that prevents exact published-number parity. Replaced by end-to-end R-package parity below, which is a strictly stronger correctness signal.Note (R-package end-to-end parity, PR #389): Validated against
DIDHADv2.0.0 (Credible-Answers/did_had, SHAedc09197) on thedesign="continuous_at_zero"(Design 1’) surface, on 3 paper-derived synthetic DGPs (Uniform, Beta(2,2), Beta(0.5,1)) × 5 method combinations (overall, event-study, placebo, yatchew, trends_lin). Generator:benchmarks/R/generate_did_had_golden.R; fixture:benchmarks/data/did_had_golden.json; test:tests/test_did_had_parity.py. Scope qualifier (PR #392 R8 P3): the harness explicitly forcesHeterogeneousAdoptionDiD(design="continuous_at_zero")because Rdid_hadalways evaluates the local-linear atd=0regardless of dose distribution. Our defaultdesign="auto"may legitimately resolve tocontinuous_near_d_lower(d_lower=d.min(), Design 1) ormass_point(Design 2) on dose distributions with boundary density bounded away from zero (e.g., Beta(2,2) at G=200), in which case the WAS estimand evaluates at a different point and diverges from R’sdid_hadnumerically. That divergence is methodologically defensible — our auto-detect uses more information when boundary mass is sparse — but is out of scope for this parity contract. Tolerances: point estimate / SE / CI bounds atatol=1e-8; closed-form Yatchew T-stat atatol=1e-10after a documented× G/(G-1)finite-sample convention shift. Two intentional convention deviations from R: (a) we report the bias-corrected point estimateatt = (mean(ΔY) - tau.bc) / mean(D)(modern CCF 2018 convention); R’sEstimatecolumn reports the conventional estimate(mean(ΔY) - tau.us) / mean(D)with the bias-corrected CI separately — ourattmatches R’s CI midpoint, ourse/conf_int_low/conf_int_highmatch R’sse/ci_lo/ci_hidirectly. (b) Ouryatchew_hr_testfollows paper Appendix E’s literal(1/G)and(1/(2G))variance-denominator convention; R’sYatchewTest::yatchew_testuses base-Rvar()’s(1/(N-1))sample-variance convention. Ratio is exactlyN/(N-1); both converge to the same asymptotic null distribution. Yatchew on placebos with R’s mean-independence null (order=0, fitsY ~ 1) is exposed viayatchew_hr_test(null="mean_independence")(added post-PR #392). The parity test routes effect rows throughnull="linearity"(Rorder=1) and placebo rows throughnull="mean_independence"(Rorder=0); both modes share the same(1/G)vs(1/(N-1))finite-sample convention shift and parity holds atatol=1e-10after the documented× G/(G-1)transform.Note: Horizon labels in
StuteJointResult.horizon_labelsarestr(t)verbatim and carry STRING IDENTITY ONLY — NOT a chronological ordering key. Callers who need chronological order must preserve the original period values alongside (e.g. from thepre_periods/post_periodsargument).Note: NaN propagation is explicit: when any horizon has NaN in residuals,
cvm_stat_joint=NaN,p_value=NaN,reject=False, ANDper_horizon_stats={label: np.nan for every horizon}(full dict preserved with NaN values — not empty, not partial).
Phase 3 follow-up delivery: stute_joint_pretest(), joint_pretrends_test(), joint_homogeneity_test(), StuteJointResult, and did_had_pretest_workflow(aggregate="event_study") shipped together in PR #353 (2026-04). The practitioner_next_steps() HAD handlers landed in Phase 5 wave 1 (PR #402); the T21 HAD pretest workflow tutorial landed in PR #409 (Phase 5 wave 2 first slice). The T22 survey-weighted HAD tutorial (docs/tutorials/22_had_survey_design.ipynb) shipped as the follow-up to PR #432 (2026-05).
Reference implementation(s):
R:
did_had(de Chaisemartin, Ciccia, D’Haultfœuille, Knau 2024a);stute_test(2024c);yatchew_test(Online Appendix, Table 3).Stata:
did_had(2024b);stute_test(2024d);yatchew_test. Alsotwowayfeweights(de Chaisemartin, D’Haultfœuille, Deeb 2019) for negative-weight diagnostics.Underlying bias-correction machinery: Calonico, Cattaneo, Farrell (2018, 2019)
nprobust; ported in-house for diff-diff (decision recorded in the plan).
Deviations and library extensions:
Notes #1-#2 lock implementation choices (paper-permitted choices the library codified); Notes #3-#4 document validation-harness work waived in this PR with documented rationale; #5 is a Library extension where the library departs from the paper’s prescription toward stricter safety.
Note: Equal-weighting on the continuous path. Paper does not prescribe a unit-weighting scheme on the continuous local-linear paths. Library uses per-unit equal weighting (
w_g = 1default, matchingdiff_diff/_nprobust_port.lprobust’s default), NOT dose-cell-size weights. Practical consequence: WAS is the population-mean slope from Eq. 3 —[E(ΔY) − lim_{d↓d̲} E(ΔY | D ≤ d)] / E(D)(computed asatt = (mean(ΔY) − τ_bc) / mean(D)), not a cell-size-weighted average; with cell-size weighting, units in less-densely-populated regions of the dose distribution would contribute disproportionately to the boundary slope. User-supplied weights (viasurvey_design=SurveyDesign(weights=...), pweight) override the equal-weight default and thread through asW_combined = k((D − d̲)/h) · w_g. Lock intests/test_methodology_had.py::TestHADDeviations::test_equal_weighting_is_per_row_not_per_dose_cell.Note: Sup-t bootstrap gating. Simultaneous-band sup-t multiplier bootstrap runs when
aggregate="event_study"ANDcband=True(default) AND eithersurvey_design=is supplied (survey band) ORcluster=(cluster-robust band — fires even on an unweighted fit, Phase 2b). The unweighted, unclustered event-study path bit-exactly preserves pre-Phase 4.5 B numerical output (stability invariant). Settingcband=Falsedisables the bootstrap on any path. See the algorithmic contract above at_sup_t_multiplier_bootstrap.Note: Pierce-Schott (2016) Figure 2 replication harness deferred. The paper’s empirical application self-acknowledges (Section 5.2; mirrored in
dechaisemartin-2026-review.md:321) that “NP estimators are too noisy to be informative” on the LBD-restricted PNTR panel. R parity atatol=1e-8on 3 DGPs × 5 method combos viatests/test_did_had_parity.py(bit-exact,rtol=0) is a stronger correctness anchor than reproducing pointwise CIs on LBD-restricted data. Scope caveat: R parity locks point estimate, SE, and CI bounds bit-exactly to R’s bounds — it does NOT independently verify the asymptotic-coverage properties of the bias-corrected CI in small samples. Paper Table 1 documents under-coverage at small G (89% at G=100 on DGP 1, 93% at G=500, 95% at G=2500); this is inherited from the CCF asymptotic theory itself, and Python is exact-parity with R at the limit-law machinery.Note: Table 1 coverage-rate reproduction deferred. Paper Section 3.1.5 reports 2,000-iter Monte Carlo coverage rates at
G ∈ {100, 500, 2500}on DGPs 1/2/3. The existingtests/test_did_had_parity.pyR parity atatol=1e-8on the same 3 DGPs reproduces the exact point estimate and SE algorithm to bit-exact tolerance; coverage-rate MC would re-verify the CCF asymptotic coverage already pinned by R parity (Python ≡ R ≡ paper) at the sample-mean level. Scope caveat (mirrors above): R parity does NOT re-prove asymptotic-coverage at small G; paper Table 1’s 89% / 93% / 95% under-coverage band is valid for both R and Python.Library extension: Staggered-timing fail-closed. Paper Appendix B.2 prescribes “Warn” when staggered treatment timing is detected; library raises
ValueErroratdiff_diff/had.py:1511when multiple first-treat cohorts are detected withoutfirst_treat_col. Library extension toward stricter safety:UserWarningwould let the silent-misuse bug class through (HAD’s Appendix B.2 only identifies the LAST cohort under staggered timing); fail-closed forces the user to either supplyfirst_treat_col(which activates auto-filter to last-cohort + never-treated per Appendix B.2) or redirect toChaisemartinDHaultfoeuille(did_multiplegt_dyn). Lock intests/test_methodology_had.py::TestHADDeviations.Note: Extensive-margin / positive-untreated-mass fit-time warning (library convention). The paper (de Chaisemartin et al. 2026, Section 2 / Assumption 3) defines HAD for the case where no genuine untreated group exists and recommends (Section 4 practitioner checklist) that a user with a positive mass of untreated units consider a standard DiD instead — but it prescribes only “warn” with NO numeric cutoff, and explicitly RETAINS small untreated shares (the Garrett et al. bonus-depreciation application keeps 12 untreated counties out of 2,954 ≈ 0.4%, with simulations showing close-to-nominal coverage even at
f_{D_2}(0) = 0). The library therefore emits aUserWarningatHeterogeneousAdoptionDiD.fit()time only when the fraction of units with EXACTLY-zero post-period dose is>= 0.10(_HAD_EXTENSIVE_MARGIN_ZERO_DOSE_FRACindiff_diff/had.py) — a 10% library-convention cutoff chosen to sit ~25× above the paper’s kept 0.4% example, so valid small-share fits are not nagged while a substantial untreated mass is flagged. Overall path only: the warning is emitted after theaggregate="event_study"dispatch returns, because the event-study path REQUIRES never-treated (zero-dose) units per Appendix B.2 (the last-cohort filter retains them), so an untreated mass is expected there, not a misuse signal. Surfaces the recommendation at fit time rather than only viaqug_test()’s zero-doseUserWarning(which fires only when the user runs the pretests). Lock intests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention.Note:
covariates=is reserved but NOT implemented.HeterogeneousAdoptionDiD.fit(covariates=...)raisesNotImplementedError— an explicit keyword-only param, so the message points to the deferred extension instead of letting an unknown kwarg surface as a bareTypeError. Covariate-adjusted HAD identification is the paper’s Appendix B.1 / Theorem 6 multivariate-covariate extension (a multivariate nonparametric regression of ΔY on (D, X) at the dose boundary), which is not derived in the library. Workaround: pre-residualize the outcome on the covariates before callingfit(), or omitcovariates=for the unconditional WAS estimand. Lock intests/test_methodology_had.py::TestHADDeviations::test_covariates_not_implemented_is_documented.
Requirements checklist (tracks implementation phase completion):
[x] Phase 1a: Epanechnikov / triangular / uniform kernels with closed-form
κ_kconstants (diff_diff/local_linear.py).[x] Phase 1a: Univariate local-linear regression at a boundary (
local_linear_fitindiff_diff/local_linear.py).[x] Phase 1a: HC2 + Bell-McCaffrey DOF correction in
diff_diff/linalg.pyviavcov_type="hc2_bm"enum (both one-way and CR2 cluster-robust with Imbens-Kolesar / Pustejovsky-Tipton Satterthwaite DOF). Weighted Bell-McCaffrey (hc2_bm + weights, both one-way and cluster) is now supported on the analytical linalg surface (compute_robust_vcov/solve_ols/LinearRegression) via the clubSandwich WLS-CR2 port. The port matches clubSandwich’spweight(sampling-weight) convention only —weight_type="aweight"andweight_type="fweight"raiseNotImplementedErroronhc2_bm + weights(separate methodology task; CR1 /vcov_type="hc1"continues to support all three weight types). Estimator-levelsurvey_design=paths continue to use the Taylor-series linearization (TSL) survey variance, which takes precedence over the analytical sandwich (no change in this PR). Known precision limit on high-leverage FE-dummy coefficients: the Satterthwaite DOF formula(tr P)² / sum(P²)is at the float64 noise floor for contrasts whose projection onto the design is essentially zero (typically FE-dummy coefficients where the dummy fires in a single cluster). In this regime, BLAS reduction-order differences between NumPy and R’s BLAS produce 15-30% DOF discrepancies despite vcov matching at machine precision._cr2_bm_dof_inner_weighteddetects this regime via two criteria applied union-wise — (a) batch-relative: per-contrast max|P| below1e-10 ×the largest contrast’s max|P| (catches noise-floor coefficients in a per-coefficient sweep where a non-noise reference is available); (b) absolute single-contrast safe: per-contrast max|P| below(EPS × n × k × max(bread_inv_scale, 1))²(catches single-contrast calls like MPD avg_att where no batch reference exists) — and returns NaN with aUserWarningrather than ship arbitrarily-different DOF on the user-facing surface. The coefficient SEs remain valid; only the DOF (and any t-test / CI that depends on it) is suppressed. The diff-diff implementation matches clubSandwich’s specific algebra (R source:CR-adjustments.R::CR2,clubSandwich.R::vcov_CR,coef_test.R::Satterthwaite_df), not a textbook Pustejovsky-Tipton (2018) §3.3 transform-once derivation. clubSandwich uses W (not √W) in the hat matrixH_gg = X_g M_U X_g' W_g, W² in the bias-correction termS_W = Σ_g X_g' W_g² X_g, and unweighted residuals in the score constructions_g = X_g' W_g A_g e_g. The Satterthwaite DOF uses the full H1/H2/H3 array construction (get_arrays.R::get_GH), not the simplified(tr B)² / tr(B²)form (which works for the unweighted case but diverges from clubSandwich by 0.5-30% on weighted designs — seefeedback_wls_cr2_clubsandwich_parity). Pinned at atol=1e-10 againstclubSandwich::vcovCR(..., type="CR2") + coef_test(test="Satterthwaite")$df_SattandWald_test(test="HTZ")$df_denomon six weighted scenarios inbenchmarks/data/clubsandwich_cr2_golden.json(vcov + non-noise-floor per-coefficient DOF + compound-contrast DOF; high-leverage FE-dummy coefficients are suppressed to NaN per the precision-limit note below) (tests/test_methodology_wls_cr2.py). Unweighted CR2-BM behavior is bit-equal to prior (regression-safe viaTestUnweightedRegressionStillBitEqual+TestDOFFormulaDualPathEquivalence). The one-way weighted HC2-BM path uses clubSandwich’s singleton-cluster CR2 reduction (each obs is its own cluster), routed via_compute_bm_dof_from_contrastswhenweights is not None. clubSandwich pin:≥ 0.7.0.Note (scope limitation on absorbed FE): HC2 and HC2 + Bell-McCaffrey on within-transformed designs still depend on the FULL FE hat matrix because FWL preserves coefficients and residuals but NOT the hat matrix:
h_ii = x_i' (X'X)^{-1} x_ion the reduced design is not the diagonal of the full FE projection, and CR2’s block adjustmentA_g = (I - H_gg)^{-1/2}likewise depends on the full cluster-block hat matrix. The status across the three estimators that previously rejected this combination:DifferenceInDifferences(absorb=..., vcov_type in {"hc2","hc2_bm"})— SUPPORTED (auto-route). When the user pairsabsorb=with HC2 / HC2-BM,DiD.fit()internally promotes the absorb columns tofixed_effects=so the existing full-dummy code path computes the algebraically correct vcov from the full FE projection. Verified at ~1e-10 vslm() + sandwich::vcovHC(type="HC2")andlm() + clubSandwich::vcovCR(cluster=..., type="CR2")(singleton-cluster CR2 trick for one-way HC2-BM Satterthwaite DOF; PT2018 §3.3 unweighted CR2 algebra). User-visible surface change: under the auto-route, the entireDiDResults(coefficients, vcov, residuals, fitted_values, r_squared) reflect the full-dummy fit rather than the within-transformed fit — the FE-dummy entries are included inresult.coefficients/result.vcov,r_squaredis computed on the un-demeaned outcome, andresiduals/fitted_valuesare on the original scale.result.attis unaffected (FWL-equivalent). HC1/CR1 paths onabsorb=are unchanged (no leverage term). Survey-design scope: whensurvey_design=is supplied, the existing survey variance path (Taylor-series linearization / replicate weights) takes precedence over the analytical HC2/HC2-BM sandwich; the auto-route only changes the FE handling (removing the prior reject) and does not redirect to the analytical small-sample sandwich on survey fits.TwoWayFixedEffects(vcov_type in {"hc2","hc2_bm"})— SUPPORTED (inline full-dummy build). TWFE has noabsorb=/fixed_effects=parameter (the unit + time FE are baked into the estimator’s identity), so the same parameter-swap auto-route used for DiD-absorb / MPD-absorb is not directly applicable. Instead,TwoWayFixedEffects.fit()bypasses the within-transform whenvcov_type in {"hc2","hc2_bm"}and builds the full-dummy design[intercept, treated×post, covariates, factor(unit), factor(time)]explicitly, then runs OLS through the standardsolve_olspath so the leverage correction and BM DOF compute on the full FE projection. Verified at atol=1e-10 vslm(y ~ treat_post + factor(unit) + factor(post)) + sandwich::vcovHC(type="HC2")for HC2 and vsclubSandwich::vcovCR(cluster=seq_len(n), type="CR2")for the singleton-cluster one-way HC2-BM Satterthwaite DOF; vsvcovCR(cluster=unit, type="CR2")for the auto-cluster CR2-BM path. Auto-cluster default (non-survey analytical path): TWFE’s unit auto-cluster is preserved onhc2_bm(routes to CR2-BM at unit) and onhc2 + wild_bootstrap(the bootstrap consumes the cluster structure for resampling regardless of the analytical sandwich choice); dropped on explicithc2 + analyticalto match the one-way contract (the linalg validator rejectshc2 + cluster_ids).hc2_bm + analyticalwith no explicit cluster yields the auto-cluster CR2-BM path. Survey-design exception: undersurvey_design=with no explicitcluster=, TWFE intentionally keeps the documented implicit-PSU path (the auto-cluster is NOT injected into the survey PSU structure); users who want unit-level PSU injection under a survey design must pass explicitcluster="unit"or setsurvey_design.psudirectly. User-visible surface change (matches the DiD-absorb / MPD-absorb disclosure above): under HC2 / HC2-BM,result.coefficients,result.vcov,result.residuals,result.fitted_values, andresult.r_squaredreflect the full-dummy fit rather than the within-transformed reduced fit (FE-dummy entries are included,r_squaredis computed on the un-demeaned outcome, residuals/fitted are on the original scale).result.att, its SE, and analytical inference are unchanged (FWL-equivalent). HC1 / CR1 / Conley / classical paths remain on the within-transform (no leverage term in those vcov families). Survey-design scope (mirrors the DiD-absorb auto-route contract above): whensurvey_design=is supplied, the existing survey variance path (Taylor-series linearization for analytical-weight designs, or replicate-weight variance for BRR/Fay/JK1/JKn/SDR) takes precedence over the analytical HC2/HC2-BM sandwich; the full-dummy build only changes the FE handling (removing the prior reject) and does not redirect to the analytical small-sample sandwich on survey fits. Replicate-weight survey designs are blocked at the estimator level:vcov_type in {"hc2","hc2_bm"}+ replicate weights raisesNotImplementedErrorbecause the replicate refit path re-demeans per replicate, which doesn’t compose with the full-dummy build (would require per-replicate full-dummy refit); workaround: usevcov_type="hc1"for replicate-weight CR1.hc2_bm + weights(Gates 4-5) is now lifted via the clubSandwich WLS-CR2 port; the survey-design rejection here is a separate estimator-level gate (analytical sandwich vs survey TSL precedence), independent of the linalg validator.MultiPeriodDiD(absorb=..., vcov_type in {"hc2","hc2_bm"})— SUPPORTED (auto-route). Same auto-route pattern asDifferenceInDifferences:MultiPeriodDiD.fit()internally promotes the absorb columns tofixed_effects=for HC2 / HC2-BM callers, so the existing full-dummy code path computes the algebraically correct vcov from the full FE projection on the event-study design (treated + period_X dummies + treated:period_X interactions + factor(unit)). Verified at ~1e-10 vslm() + sandwich::vcovHC(type="HC2")andlm() + clubSandwich::vcovCR(cluster=1:n, type="CR2")on a 5-cohort × 5-period event-study fixture; the parity target is a per-period interactiontreated:period_Xbecause MPD requires thetreatedcolumn to be a time-invariant ever-treated indicator, which lies in the span of the intercept and the post-auto-route unit FE dummies (underpd.get_dummies(..., drop_first=True)the dropped reference unit is implicit in the intercept, so the exact alias relation depends on the omitted FE category — it is NOT simply “the sum of treated-cohort unit dummies”).solve_olsdrops one column from the collinear set under R-style rank-deficiency handling; in the shipped parity fixture (4 ever-treated cohorts of 5 units + 1 never-treated cohort of 5 units) it drops a unit dummy from the never-treated cohort (unit_25) and thetreatedmain effect remains finite, but the specific column that gets NaN’d is pivot-order and dummy-coding dependent. Either way, the slope coefficients (treated:period_X) and the post-period-averageavg_attare identified and invariant to which column was dropped. SameMultiPeriodDiDResultssurface change as DiD:vcov,residuals,fitted_values,r_squared, andcoefficientsreflect the full-dummy fit, withperiod_effects[t].effect/.se/.p_value/.conf_intinvariant by FWL. HC1/CR1 paths onabsorb=are unchanged (no leverage term). Same survey-design scope as DiD: replicate-weight variance routes through the standardcompute_replicate_vcovpath on the fixed full-dummy design rather than the per-replicate refit branch (which targets the demeaning path); since the auto-routed design does not depend on replicate weights, no refit is needed. Redundant time-FE skip: when the routed (or directly-supplied)fixed_effectslist contains thetimecolumn, MPD silently skips emitting<time>_<X>dummies for that entry because the design already absorbs time via the non-reference period dummies. Without the skip, those blocks collide on dummy names andMultiPeriodDiDResults.coefficients(built as{name: coef for name, coef in zip(var_names, coefficients)}) would silently drop duplicates, breaking the coefficients-vs-vcov alignment that downstream consumers (HonestDiD sub-VCV extraction, BusinessReport, etc.) rely on. The skip applies to BOTH the newabsorb=auto-route AND the pre-existingfixed_effects=[<time_col>]invocation (pre-PR,fixed_effects=["unit", time]produced a dict withlen < vcov.shape[0]and NaN values overwriting the real event-study period coefficients).All three previously-rejecting absorbed-FE paths are now SUPPORTED. Weighted-CR2 variants (Gates 4-5:
vcov_type="hc2_bm" + weights; weighted one-way HC2-BM) are now LIFTED via the clubSandwich WLS-CR2 port documented above on the Phase 1a row. The lift applies to the analytical surface:compute_robust_vcov,solve_ols, andLinearRegressiondirect callers. The helper_compute_cr2_bm_contrast_dof(weights=...)is also helper-ready for the contrast-DOF case (e.g., MPDavg_att), but no public estimator API currently passes non-None weights to that helper —MultiPeriodDiD.fit()has no non-survey weighted entry point, andsurvey_design=paths route through TSL (which takes precedence over the analytical CR2 sandwich). Estimator-level weighted CR2-BM via the public estimator surface would require a separate non-survey weights entry point and is out of scope here.
[x] Phase 1a:
vcov_typeenum threaded throughDifferenceInDifferences(MultiPeriodDiD,TwoWayFixedEffectsinherit);robust=True<=>vcov_type="hc1",robust=False<=>vcov_type="classical". Conflict detection at__init__. Results summary prints the variance-family label.Note (
MultiPeriodDiD(cluster=..., vcov_type="hc2_bm")— SUPPORTED via cluster-aware contrast DOF): the scalar-coefficientDifferenceInDifferencespath uses_compute_cr2_bm’s per-coefficient Satterthwaite DOF directly for the single-ATT contrast, butMultiPeriodDiDalso reports a post-period-average ATT constructed as a contrast of the event-study coefficients (avg_att = (1/n_post) Σ_{t ≥ t_treat} β_t). Pre-PR the combination raisedNotImplementedErrorbecause the cluster-aware CR2 Bell-McCaffrey Satterthwaite DOF for an arbitrary linear combination was not implemented — only the per-coefficient case existed. The new_compute_cr2_bm_contrast_dofhelper indiff_diff/linalg.pygeneralizes the per-coefficient loop to arbitrary(k, m)contrast matrices using the identical Pustejovsky-Tipton 2018 Section 4 algebra (q = X bread_inv c,omega_g = A_g X_g bread_inv c,DOF = trace(B)² / trace(B²)), and_compute_cr2_bmis refactored to call it withcontrasts=eye(k)so the per-coefficient case is recovered at machine precision (atol=1e-10, see refactor regression intests/test_linalg_hc2_bm.py::TestCR2BMContrastDOF).MultiPeriodDiD.fit()extends its existing avg_att DOF block to branch on cluster presence: one-way_compute_bm_dof_from_contrastsforeffective_cluster_ids is None, cluster-aware_compute_cr2_bm_contrast_dofotherwise. R parity verified against clubSandwich’sWald_test(constraints=matrix(c, 1), test="HTZ")$df_denomat atol=1e-10 on thempd_clustered_avg_att_doffixture inbenchmarks/data/clubsandwich_cr2_golden.json(Wald_test’s HTZ on a 1-row constraint matrix yields the Satterthwaite t-test DOF). Cluster IDs are per-observation lengthnand are NOT subscripted by the rank-deficient column-drop mask_kept— the helper accepts the fulleffective_cluster_idsarray. Weighted CR2-BM (survey_design=paths) remains a separate gate.
[x] Phase 1a:
clubSandwich::vcovCR(..., type="CR2")parity harness committed: R script atbenchmarks/R/generate_clubsandwich_golden.Rplus the authoritative R-generated JSON atbenchmarks/data/clubsandwich_cr2_golden.json("source": "clubSandwich", withclubSandwich_version,R_version, andgenerated_atcaptured inmetafor forensic traceability). The parity test attests/test_linalg_hc2_bm.py::TestCR2BMCluster::test_cr2_parity_with_goldenruns at 1e-6 tolerance and passes at ≤ 7.1e-15 across all three datasets — Python’s_compute_cr2_bmmatches clubSandwich at machine precision.[x] Phase 1b: Calonico-Cattaneo-Farrell (2018) MSE-optimal bandwidth selector. In-house port of
nprobust::lpbwselect(bwselect="mse-dpi")(nprobust 0.5.0, SHA36e4e53) asdiff_diff.mse_optimal_bandwidthandBandwidthResult, backed by the privatediff_diff._nprobust_portmodule (kernel_W,lprobust_bw,lpbwselect_mse_dpi). Three-stage DPI with fourlprobust.bwcalls at ordersq+1,q+2,q,p. Python matches R to0.0000%relative error (i.e., bit-parity within float64 precision, ~8-13 digits agreement) on all five stage bandwidths (c_bw,bw_mp2,bw_mp3,b_mse,h_mse) across three deterministic DGPs (uniform, Beta(2,2), half-normal) viabenchmarks/R/generate_nprobust_golden.R→benchmarks/data/nprobust_mse_dpi_golden.json. Note:weights=is currently unsupported (raisesNotImplementedError); nprobust’slpbwselecthas no weight argument so there is no parity anchor. Weighted-data support deferred to Phase 2 (survey-design adaptation). Note (public API scope restriction): the exported wrappermse_optimal_bandwidthhard-codes the HAD Phase 1b configuration (p=1,deriv=0,interior=False,vce="nn",nnmatch=3). The underlying port supports a broader surface (hc0/hc1/hc2/hc3variance, interior evaluation, higherp), but those paths are not parity-tested againstnprobustand are deferred. Callers needing the broader surface should usediff_diff._nprobust_port.lpbwselect_mse_dpidirectly and accept that parity has not been verified on non-HAD configurations. Note (input contract): the wrapper enforces HAD’s support restrictionD_{g,2} >= 0(front-doorValueErroron negative doses and empty inputs).boundarymust equal0(Design 1’) orfloat(d.min())(Design 1 continuous-near-d_lower) within float tolerance; off-support values raiseValueError. Whenboundary ~ 0, the wrapper additionally requiresd.min() <= 0.05 * median(|d|)as a Design 1’ support plausibility heuristic, chosen to pass the paper’s thin-boundary-density DGPs (Beta(2,2), d.min/median ~ 3%) while rejecting substantially off-support samples (U(0.5, 1.0), d.min/median ~ 1.0). Detected mass-point designs (d.min() > 0with modal fraction atd.min() > 2%) raiseNotImplementedErrorpointing to the Phase 2 2SLS path per paper Section 3.2.4.[x] Phase 1c: First-order bias estimator
M̂_{ĥ*_G}and robust varianceV̂_{ĥ*_G}. Implemented via Calonico-Cattaneo-Titiunik (2014) bias-combined design matrixQ.qin the in-house portdiff_diff._nprobust_port.lprobust(single-eval-point path ofnprobust::lprobust, npfunctions.R:177-246).[x] Phase 1c: Bias-corrected CI (Equation 8) with
nprobustparity. Public wrapperdiff_diff.bias_corrected_local_linearreturnsBiasCorrectedFitwith μ̂-scale point estimate, robust SE, and bias-corrected 95% CI[tau.bc ± z_{1-α/2} * se.rb]. The β-scale rescaling from Equation 8,(1/G) Σ D_{g,2}, is applied by Phase 2’sHeterogeneousAdoptionDiD.fit(). Parity againstnprobust::lprobust(..., bwselect="mse-dpi")is asserted atatol=1e-12ontau_cl/tau_bc/se_cl/se_rb/ci_low/ci_highacross the three unclustered golden DGPs (DGP 1 and DGP 3 typically land closer to1e-13). The Python wrapper computes its ownz_{1-α/2}viascipy.stats.norm.ppfinsidesafe_inference(); R’sqnormvalue is stored in the golden JSON for audit, and the parity harness compares Python’s CI bounds to R’s pre-computed CI bounds so any residual drift is purely the floating-point arithmetic intau.bc ± z * se.rb, not a critical-value disagreement. The clustered DGP achieves bit-parity (atol=1e-14) when cluster IDs are in first-appearance order; otherwise BLAS reduction ordering can drift toatol=1e-10. Generator:benchmarks/R/generate_nprobust_lprobust_golden.R. Note: The wrapper matches nprobust’srho=1default (b = hin auto mode), so Phase 1b’s separately-computedb_mseis surfaced viabandwidth_diagnostics.b_msebut not applied. Note (public-API surface restriction): Phase 1c restricts the public wrapper’svceparameter to"nn"; hc0/hc1/hc2/hc3 raiseNotImplementedErrorand are queued for Phase 2+ pending dedicated R parity goldens. The port-leveldiff_diff._nprobust_port.lprobuststill accepts all five vce modes (matching R’snprobust::lprobustsignature) for callers who need the broader surface and accept that the hc-mode variance path — which reuses p-fit hat-matrix leverage for the q-fit residual in R (lprobust.R:229-241) — has not been separately parity-tested. Note (Phase 1c internal bug workaround): The clustered golden DGP 4 uses manualh=b=0.3to sidestep an nprobust-internal singleton-cluster shape bug inlprobust.vcefired by the mse-dpi pilot fits; the Python port has no equivalent bug.[x] Phase 2a:
HeterogeneousAdoptionDiDclass with separate code paths for Design 1’ (continuous_at_zero), Design 1 continuous-near-d̲(continuous_near_d_lower), and Design 1 mass-point. Continuous paths compose Phase 1c’sbias_corrected_local_linearand form the beta-scale WAS estimateβ̂ = (mean(ΔY) - τ̂_bc) / denwhereτ̂_bcis the bias-corrected local-linear estimate of the boundary limitlim_{d↓d̲} E[ΔY | D_2 ≤ d]andden = E[D_2]for Design 1’ (paper Theorem 1 / Equation 3 identification; Equation 7 sample estimator) orden = E[D_2 - d̲]for Design 1 (paper Theorem 3 / Equation 11,WAS_{d̲}under Assumption 6). Mass-point path uses a sample-average 2SLS estimator with instrument1{D_{g,2} > d̲}(paper Section 3.2.4).[x] Phase 2a:
design="auto"detection rule (min_g D_{g,2} < 0.01 · median_g D_{g,2}→ continuous_at_zero; modal-min fraction > 2% → mass_point; else continuous_near_lower). Implemented as strict first-match indiff_diff.had._detect_design; whend.min() == 0exactly, resolvescontinuous_at_zerounconditionally (modal-min check runs only whend.min() > 0). Edge case covered: 3% atD=0+ 97%Uniform(0.5, 1)resolves tocontinuous_at_zero, matching the paper-endorsed Design 1’ handling of small-share-of-treated samples.[x] Phase 2a: Panel validator (
diff_diff.had._validate_had_panel) verifiesD_{g,1} = 0for all units, rejects negative post-period doses (D_{g,2} < 0) front-door on the original (unshifted) scale, rejects>2time periods on theaggregate="overall"path (multi-period panels must useaggregate="event_study", Phase 2b), and rejects unbalanced panels and NaN in outcome/dose/unit columns. Both Design 1 paths (continuous_near_d_lowerandmass_point) additionally required_lower == float(d.min())within float tolerance; mismatched overrides raise with a pointer to the unsupported (LATE-like / off-support) estimand.[x] Phase 2a: NaN-propagation tests covering constant-y, degenerate-mass-point, and single-cluster-CR1 inputs. The guaranteed NaN coupling is on the DOWNSTREAM triple (
t_stat,p_value,conf_int) via thesafe_inference()gate, which returns NaN on all three wheneverseis non-finite, zero, or negative.attandsethemselves are raw estimator outputs: on constant-y / no-dose-variation / divide-by-zero the fit paths return(att=nan, se=nan)so all five fields move to NaN together; on the degenerate single-cluster CR1 configuration on the mass-point path,_fit_mass_point_2slsreturns(att=beta_hat, se=nan)-attis finite (Wald-IV is well defined) whileseis NaN, so the downstream triple is NaN whileattremains the raw 2SLS coefficient. Theassert_nan_inferencefixture intests/conftest.pychecks the downstream triple against this contract without requiringattto be NaN.Note (mass-point SE): Standard errors on the mass-point path use the structural-residual 2SLS sandwich
[Z'X]^{-1} · Ω · [Z'X]^{-T}withΩbuilt from the structural residualsu = ΔY - α̂ - β̂·D(not the reduced-form residuals from an OLS-on-indicator shortcut). Supported:classical,hc1, and CR1 (cluster-robust) whencluster=is supplied.hc2andhc2_bmraiseNotImplementedErrorpending a 2SLS-specific leverage derivation (the OLS leveragex_i' (X'X)^{-1} x_iis wrong for 2SLS; the correct finite-sample correction depends on(Z'X)^{-1}rather than(X'X)^{-1}) plus a dedicated R parity anchor. Queued for the follow-up PR.Note (continuous cluster-robust SE, Phase 2a): On the continuous designs (
continuous_at_zero/continuous_near_d_lower),cluster=threads the per-unit cluster IDs intobias_corrected_local_linear, whose CCT-2014 robust variance then becomes the cluster-robust nonparametric SE; the β̂-scale SE isse_robust / |den|. Because the β-scale rescale is a deterministic linear transform, the estimator-level clustered SE equals the directbias_corrected_local_linear(cluster=...).se_robust / |den|to machine precision — this is the in-library validation anchor, and the clustered CCT SE is itself golden-tested againstnprobuston DGP 4 (see the Phase 1c note above:atol=1e-14in first-appearance cluster order). Cluster IDs must be unit-constant (validated up front; a nonexistent column, NaN, or within-unit-varying cluster now raises rather than being silently ignored, mirroring the mass-point path). Cluster-robust inference is unidentified with fewer than two clusters in the active kernel window (eC = cluster[ind], the in-bandwidth subset the CCT variance is actually computed on — a stricter condition than the global cluster count, since clusters can be separated from the boundary by the bandwidth): the guard lives in_nprobust_port.lprobustand NaNsse_rb/se_cl, sobias_corrected_local_linear.se_robustis NaN and HAD’ssafe_inferenceNaNs the t-stat / p-value / CI while the point estimate stays finite — the same single-cluster NaN contract as the mass-point CR1 path (_fit_mass_point_2sls), applied at the variance-computation site so it also covers the directbias_corrected_local_linearAPI. A window with ≥2 distinct clusters is bit-identical (DGP-4 golden parity preserved). A barecluster=gives unweighted cluster-robust inference; thecluster=+survey_design=composition raisesNotImplementedError: the Binder (1983) TSL survey variance is composed from the per-unit influence function viacompute_survey_if_varianceand would silently override the cluster-robust SE — for weighted clustering route throughsurvey_design=SurveyDesign(weights='<weight_col>', psu='<cluster_col>')instead. Result metadata reportsvcov_type="cr1"+cluster_name=<col>withinference_method="analytical_nonparametric"(distinguishing the clustered CCT variance from the mass-point 2SLS CR1 sandwich). Estimator-level cluster threading also extends to the Phase 2b event-study path (cluster-robust per-horizon pointwise CIs on both designs plus a cluster-robust simultaneous sup-t band; the per-horizon variance-family reconciliation is documented in “Note (HAD clustered event-study sup-t band)” above) — the former “cluster ignored”UserWarningis removed.Note (Design 1 identification):
continuous_near_d_lowerandmass_pointfits emit aUserWarningsurfacing thatWAS_{d̲}identification requires Assumption 6 (or Assumption 5 for sign identification only) beyond parallel trends, and that neither is testable via pre-trends.continuous_at_zero(Design 1’, Assumption 3 only) does not emit this warning.Note (CI endpoints): Because the continuous-path
attis(mean(ΔY) - τ̂_bc) / den, the beta-scale CI endpoints reverse relative to the Phase 1c boundary-limit CI:CI_lower(β̂) = (mean(ΔY) - CI_upper(τ̂_bc)) / denandCI_upper(β̂) = (mean(ΔY) - CI_lower(τ̂_bc)) / den. TheHeterogeneousAdoptionDiD.fit()implementation computesatt ± z · sedirectly viasafe_inference, which handles the reversal naturally from the transformed point estimate.Note (Phase 2a/2b scope, superseded by Phase 4.5): Phase 2a ships the single-period
aggregate="overall"path; Phase 2b liftsaggregate="event_study"(Appendix B.2 multi-period extension) which returns aHeterogeneousAdoptionDiDEventStudyResultswith per-event-time WAS estimates and pointwise CIs. The original Phase 2a/2b release raisedNotImplementedErroron all weighting; Phase 4.5 (A/B/C0) lifted that with the per-design vcov contract documented above (see L2340-L2379, including the mass-pointvcov_type="classical"deviation andcband=Truesup-t restriction); thesurvey_design=path composes Binder (1983) TSL viacompute_survey_if_varianceon both continuous and mass-point IFs.Note (panel-only): The paper (Section 2) defines HAD on panel or repeated cross-section data, but both the overall and event-study paths ship a panel-only implementation:
HeterogeneousAdoptionDiD.fit()requires a balanced panel with a unit identifier so that unit-level first differencesΔY_{g,t} = Y_{g,t} - Y_{g,t_anchor}can be formed. Repeated-cross-section inputs (disjoint unit IDs between periods) are rejected by the balanced-panel validator. RCS support is queued for a follow-up PR (tracked inTODO.md); it will need a separate identification path based on pre/post cell means rather than unit-level differences.
[x] Phase 2b: Multi-period event-study extension (Appendix B.2).
aggregate="event_study"produces per-event-time WAS estimates using a uniformF-1baseline (ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}for every horizon), reusing the three Phase 2a design paths on per-horizon first differences. Pre-period placebos included fore <= -2(the anchore = -1is skipped sinceΔY = 0trivially). Post-period estimates fore >= 0. The joint Stute test (Equation 18) across pre-periods is a SEPARATE diagnostic deferred to a Phase 3 follow-up patch (Phase 3 ships the single-horizon Stute test; the joint stacked-residual variant is tracked inTODO.md).[x] Phase 2b: event-study
cluster=threading — cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous band via the clustered branch of_sup_t_multiplier_bootstrap(continuous scale 1.0; mass-point√(G/(G-1))). Closes the former “cluster ignored on the nonparametric path” deferral (TODO.md). See “Note (HAD clustered event-study sup-t band)”.cluster=+survey_design=remains rejected (for weighted clustering route throughsurvey_design=SurveyDesign(weights=..., psu=...)).Note (Phase 2b last-cohort filter): When
first_treat_colindicates more than one nonzero cohort, the panel is auto-filtered to the last-treatment cohort (F_last = max(cohorts)) plus never-treated units (first_treat = 0), with aUserWarningnaming kept/dropped unit counts and dropped cohort labels. Paper Appendix B.2 is explicit that HAD “may be used only for the LAST treatment cohort in a staggered design”; the auto-filter implements this prescription, retaining never-treated units per the paper’s “there must be an untreated group, at least till the period where the last cohort gets treated” requirement. Only earlier-cohort units (withfirst_treat > 0and< F_last) are dropped — never-treated units satisfy the dose invariant at every period (D = 0throughout) and preserve Design 1’ identifiability (boundary at0) when last-cohort doses are uniformly positive. Whenfirst_treat_colis omitted on a >2-period panel, the validator infers each unit’s first-positive-dose period from the dose path; if multiple distinct first-positive-dose cohorts are detected, the estimator raises a front-doorValueErrordirecting users to passfirst_treat_col(which activates the auto-filter) or useChaisemartinDHaultfoeuillefor full staggered support — there is no silent acceptance of staggered panels without cohort metadata. Common-adoption panels (single first-positive-dose cohort, or only never-treated + one cohort) pass through unchanged withFinferred from the dose invariant, and require dose contiguity (pre-periods < post-periods in natural ordering). Non-contiguous dose sequences (e.g., reverse treatment) raise with a pointer toChaisemartinDHaultfoeuille.Note (Phase 2b constant-dose requirement): The event-study aggregation uses
D_{g, F}(first-treatment-period dose) as the single regressor for every event-time horizon, per paper Appendix B.2’s “once treated, stay treated with the same dose” convention. The validator REJECTS panels where a unit has time-varying dose across post-treatment periods (D_{g, t} != D_{g, F}for anyt >= Fwithin-unit, beyond float tolerance) with a front-doorValueError, directing users with genuinely time-varying post-treatment doses toChaisemartinDHaultfoeuille(did_multiplegt_dyn). Silent acceptance would misattribute later-horizon treatment-effect heterogeneity to the period-F dose. A follow-up PR could implement a time-varying-dose estimator; tracked inTODO.md.Note (Phase 2b per-horizon SE): Each event-time horizon uses an INDEPENDENT sandwich computed on that horizon’s first differences: continuous paths use the CCT-2014 robust SE from Phase 1c divided by
|den|; mass-point path uses the structural-residual 2SLS sandwich from Phase 2a. This produces pointwise CIs per horizon, matching the paper’s Pierce-Schott application (Section 5.2, Figure 2: “nonparametric pointwise CIs”). Joint cross-horizon covariance (IF-based stacking or block bootstrap) is NOT computed — the paper does not derive it and all reported CIs are pointwise. Follow-up PRs may add joint covariance for cross-horizon hypothesis tests; current tracking inTODO.md.Note (Phase 2b baseline convention): All event-time horizons use a uniform
F-1anchor:ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}for everyt. This is consistent with the paper’s Garrett-et-al. application (Section 5.1: “outcomeY_{g,t} - Y_{g,2001}” whereF = 2002), simplifies event-time indexing (e = t - Fsoe = -1is the anchor, skipped), and keeps the implementation symmetric for pre- and post-period horizons. The paper review text’s asymmetric “Y_{g,t} - Y_{g,1}for pre” / “Y_{g,t} - Y_{g,F-1}for post” phrasing is covered by the uniform convention since both give the same placebo interpretation under parallel trends (the paper’s own applications use the uniform anchor).Note (Phase 2b result class):
aggregate="event_study"returns a newHeterogeneousAdoptionDiDEventStudyResultsdataclass (distinct from the single-periodHeterogeneousAdoptionDiDResults) with per-horizon arrays (event_times,att,se,t_stat,p_value,conf_int_low,conf_int_high,n_obs_per_horizon) and shared metadata.to_dataframe()returns a tidy per-horizon DataFrame;to_dict()returns a dict with list-of-per-horizon fields. The static return-type annotation onfit()isUnion[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults], matching the runtime polymorphism onaggregate; callers should narrow viaisinstance(or via the aggregate they passed) when reading aggregate-specific fields.Note (Phase 3 pretest workflow): Phase 3 ships the Section 4 pre-test diagnostics in a new module
diff_diff/had_pretests.py(separate from the 2,800-linehad.pyestimator module). The three tests —qug_test,stute_test,yatchew_hr_test— accept raw(d, dy)arrays and return their own result dataclasses (QUGTestResults,StuteTestResults,YatchewTestResults); the compositedid_had_pretest_workflowdispatches onaggregate("overall"requires a balanced two-period panel;"event_study"accepts a multi-period panel with >= 3 periods) and returnsHADPretestReportwith a priority-ordered verdict string. Thedata-only workflow signature (noresult=-consuming variant) avoids coupling pretests to theBiasCorrectedFitinternal state, which does not expose residuals. Below-minimum sample sizes emitUserWarningand return all-NaN inference (noValueErrorraise) for library consistency with thesafe_inferenceconvention; input-shape violations (non-1D, NaN-containing, mismatched length) still raise. No multiple-testing adjustment (Bonferroni/Holm) is applied — the paper does not prescribe one. Partial-workflow semantic (path-dependent):aggregate="overall"(default; two-period panel) runs paper steps 1 (QUG) + 3 (linearity via Stute + Yatchew-HR) ONLY; step 2 (Assumption 7 pre-trends test via Equation 18) is NOT covered on this path and a fail-to-reject verdict appends an explicit Assumption 7 gap flag ("QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)").aggregate="event_study"(multi-period panel, requires >=3 periods + earlier pre-period for joint pretrends) closes step 2 via joint Stute pre-trends (Equation 18) over pre-period horizons AND joint Stute homogeneity over post-period horizons; the verdict on a fail-to-reject across QUG + joint pretrends + joint homogeneity reads “TWFE admissible under Section 4 assumptions” without the Assumption 7 caveat. TheHADPretestReportdocstring and structural fields (pretrends_joint,homogeneity_joint) reflect the aggregate-dependent coverage.Note (Phase 3 Stute bootstrap): The Stute CvM statistic is implemented via the simplified-form
S = (1/G²) · Σ cumsum², which is algebraically equivalent to the paper’s statedΣ(g/G)² · ((1/g) Σ eps_{(h)})²form. Bootstrap follows the paper’s Appendix D Algorithm literally: each iteration refits OLS ondy_b = a_hat + b_hat · d + eps · eta(null DGP multiplied by Mammen weights). The Appendix-D vectorized matrix form (precomputingM = I - X(X'X)⁻¹X'once and applying it toeps · etain each iteration) is functionally identical and ~2× faster; it is deferred as a performance follow-up to keep the Phase 3 reviewer surface small. Bootstrap reproducibility usesnp.random.default_rng(seed)(library convention, matchingwild_bootstrap_se);seed=42in tests for bitwise stability.Note (Phase 3 Yatchew normalizer):
σ̂²_diffinyatchew_hr_testdivides by2G(paper-literal from Theorem 7), NOT by2(G-1). A unit test hand-computesσ̂²_diffatG=4on deterministic inputs and asserts the2G-normalizer form atatol=1e-12so any later regression to the (asymptotically equivalent but finite-sample different)2(G-1)form would fail the test.σ̂⁴_Wusesnp.mean(eps_{(g)}² · eps_{(g-1)}²)which divides byG-1vianp.meanlength, matching the paper’s Theorem 7 / Equation 29 normalization.Note (Phase 3 Yatchew tie policy):
yatchew_hr_testREJECTS duplicate dose values with aUserWarning+ all-NaN result at the front door. The difference-based variance estimatorσ̂²_diffand the heteroskedasticity-robust scaleσ̂⁴_Wboth use adjacent differences (of sorted dy and of adjacent squared residuals, respectively); under tied doses the within-tie row ordering is arbitrary (stable sort falls back to input order), so the statistic becomes non-methodological and order-dependent. Callers with tied doses (mass-point designs, discretised dose registers) should usestute_testinstead — its tie-safe Cramer-von Mises statistic collapses tie blocks to the post-tie cumulative sum and is provably order-invariant under within-tie permutations. A regression test onstute_testasserts bit-identicalcvm_statacross tied-dose permutations atatol=1e-14; a matching regression onyatchew_hr_testasserts the NaN+warning behavior on duplicated and constant doses. This tie policy is a Phase 3 diff-diff choice (the paper describes Yatchew only as “sort by D” and does not specify a tie rule); an alternative implementation could document a justified tie-resolution convention and back it with permutation tests, but at the cost of a methodology surface the paper does not cover. Workflow fallback:did_had_pretest_workflowfollows the paper’s “Stute OR Yatchew” step-3 wording — a conclusive Stute (which is tie-safe) is sufficient to adjudicate linearity even when Yatchew returns NaN on tied-dose panels. The composite verdict then reads “QUG and linearity diagnostics fail-to-reject (Yatchew NaN - skipped); …” rather than forcing the whole report to “inconclusive”, andall_passisTruewhen QUG + at least one conclusive linearity test fail-to-reject.Note (Phase 3 exact-linear short-circuit): Both
stute_testandyatchew_hr_testdetect numerically-exact linear fits (OLS residuals below machine precision relative to the signal) and short-circuit top=1.0, reject=Falsewithout running the bootstrap / computingT_hr. The detection comparessum(eps^2)against the CENTERED total sum of squaressum((dy - dybar)^2)(ratio<= 1e-24), which is equivalent to1 - R^2and is TRANSLATION-INVARIANT under additive shifts in dy. Comparing against uncenteredsum(dy^2)would NOT be translation-invariant: adding a large constant to dy inflatessum(dy^2)and can spuriously trip the short-circuit on genuinely noisy data. Regression tests pin (a) the exact-linear fail-to-reject behavior fordy = a + b*d, (b) translation-invariance underdy -> dy + 1e12(the short-circuit must not fire on noisy data regardless of the constant offset).
[x] Phase 3:
qug_test()(T = D_{2,(1)} / (D_{2,(2)} - D_{2,(1)}), rejection{T > 1/α - 1}). One-sided asymptotic p-value1 / (1 + T)under the Exp(1)/Exp(1) limit law (Theorem 4). Zero-dose observations filtered upfront (withUserWarning);D_{(1)} == D_{(2)}tie returns all-NaN inference (conservative). Closed-form tight parity tested atatol=1e-12.[x] Phase 3:
stute_test()Cramér-von Mises with Mammen wild bootstrap. StatisticS = (1/G^2) Σ (cumsum_g)^2(algebraically equivalent to paper’sΣ(g/G)^2 · ((1/g) Σ eps_{(h)})^2). Bootstrap follows paper Appendix D Algorithm literal (per-iteration OLS refit).n_bootstrap=999default,n_bootstrap >= 99validated.G < 10returns NaN;G > 100_000emits aUserWarningpointing toyatchew_hr_test. Appendix-D vectorized matrix form deferred as a performance follow-up (tracked inTODO.md).[x] Phase 3:
yatchew_hr_test()heteroskedasticity-robust specification test. Test statisticT_hr = sqrt(G) · (σ̂²_lin - σ̂²_diff) / σ̂²_Wfrom paper Equation 29. Normalizerσ̂²_diffdivides by2G(paper-literal Theorem 7), NOT2(G-1); hand-computed tight parity asserted atatol=1e-12. One-sided standard-normal critical value.G < 3returns NaN. Phase 3 shipped only the linearity null (paper Theorem 7); thenull="mean_independence"R-parity extension shipped post-PR #392 (see the algorithm-variant block above for the contract).[x] Phase 3:
did_had_pretest_workflow()composite helper.data-only entry point withaggregatedispatch:aggregate="overall"(default) requires a balanced two-period panel — multi-period panels are rejected at the front door by_validate_had_panelwith a pointer toaggregate="event_study"— and runs steps 1 (QUG) + 3 (Stute + Yatchew-HR) only;aggregate="event_study"takes a multi-period panel (>=3 periods) and additionally runs step 2 (joint Stute pre-trends over pre-period horizons) + joint Stute homogeneity over post-period horizons, populatingpretrends_joint/homogeneity_joint.seedforwards to all bootstrap-based tests (QUG and Yatchew are deterministic). ReturnsHADPretestReportwith priority-ordered verdict string. Onaggregate="overall"a fail-to-reject verdict explicitly flags the Assumption 7 gap rather than claiming unconditional TWFE safety:"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"; onaggregate="event_study"a fail-to-reject across all three covered diagnostics reads"TWFE admissible under Section 4 assumptions"without the Assumption 7 caveat. Verdict priority follows the paper’s one-way rule (TWFE admissible only if NO test rejects): conclusive rejections are the primary verdict and are NEVER hidden by inconclusive status — any unresolved-step note is appended via"; additional steps unresolved: ..."rather than replacing the rejection. The pure"inconclusive - QUG NaN"/"inconclusive - both Stute and Yatchew linearity tests NaN"forms only fire when NO conclusive test rejects AND a required step is unresolved. The partial-workflow fail-to-reject verdict may carry a"(Yatchew NaN - skipped)"(or Stute) suffix when one linearity test is NaN but the other is conclusive (step 3 resolved via the paper’s “Stute OR Yatchew” wording). Bundled rejection-reason strings name each failed assumption in the conclusive-rejection case.all_passisTrueiff QUG is conclusive AND at least one of Stute/Yatchew is conclusive AND no conclusive test rejects. Non-negative-dose contract: all three raw linearity helpers (qug_test,stute_test,yatchew_hr_test) raise a front-doorValueErroron anyd < 0, mirroring the_validate_had_panelguard (paper Section 2 HAD support restriction). On theaggregate="overall"path, the panel must already be exactly two periods (_validate_had_panelraises with a pointer toaggregate="event_study"otherwise); the first-difference helper computes(t_post, t_pre)per unit and feeds each raw helper directly. On theaggregate="event_study"path, joint Stute is dispatched across pre-period and post-period horizons directly (the joint Equation-18 form, no per-horizon pre-slicing).[x] Phase 4: Pierce-Schott (2016) replication harness reproduces Figure 2 values. Waived 2026-05-20: see Deviations block above; the paper itself self-acknowledges that NP estimators are too noisy to be informative on the LBD-restricted PNTR panel (Section 5.2), and R parity at
atol=1e-8viatests/test_did_had_parity.pyis a strictly stronger correctness anchor than Figure-2 reproduction on a proxy panel. Tracked as Low-priority follow-up inTODO.md.[x] Phase 4: Full DGP 1/2/3 coverage-rate reproduction from Table 1. Waived 2026-05-20: see Deviations block above; R parity at
atol=1e-8on the same 3 DGPs reproduces the exact point estimate and SE algorithm (Python ≡ R ≡ paper) at sample-mean level — stronger than coverage-rate MC, which re-verifies asymptotic-coverage already pinned by R parity. Tracked as Low-priority follow-up inTODO.md.[x] Phase 5 (wave 1, PR #402):
practitioner_next_steps()integration for HAD results -_handle_hadand_handle_had_event_studyroute both result classes through HAD-specific Baker et al. (2025) step guidance with bidirectional HAD ↔ ContinuousDiD Step-4 routing closure. The_check_nan_atthelper extends to ndarrayatt(HAD event-study) vianp.all(np.isnan(arr))semantics; scalar path bit-exact preserved. Thellms-full.txtHAD section’s documented constructor andfit()parameter lists are regression-locked againstinspect.signature(HeterogeneousAdoptionDiD.__init__)andHeterogeneousAdoptionDiD.fitfor parameter-name presence (parameter defaults and the non-return parameter type annotations remain unpinned by the currentinspect.signaturetest). Thefit()return annotation is widened toUnion[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]at the source-code level to match the runtime polymorphism, AND that union is now pinned at the test level bytests/test_had.py::TestFitReturnAnnotation::test_fit_return_annotation_is_union_of_result_classesviatyping.get_type_hintsso the contract cannot drift silently.[x] Phase 5 (wave 1, PR #402):
llms-full.txtHeterogeneousAdoptionDiD section + result-class blocks +## HAD Pretestsindex + Choosing-an-Estimator row landed; constructor / fit() parameter names are regression-locked againstinspect.signature(HeterogeneousAdoptionDiD.__init__)andHeterogeneousAdoptionDiD.fitfor parameter-name presence (parameter defaults and the non-return parameter type annotations remain unpinned; thefit()return-type union is locked BOTH at the source-code level AND at the test level byTestFitReturnAnnotation); result-class field tables enumerate every public dataclass field (regression-tested viadataclasses.fields());llms-practitioner.txtStep 4 decision tree distinguishes ContinuousDiD (per-dose ATT(d), needs never-treated) from HeterogeneousAdoptionDiD (WAS, universal-rollout-compatible).[x] Phase 5 (partial): README catalog one-liner, bundled
llms.txt## Estimatorsentry,docs/api/had.rst(autoclass for the three classes), anddocs/references.rstcitation landed in PR #372 docs refresh.[x] Phase 5 (wave 2 first slice, PR #409): T21 HAD pretest workflow tutorial (
docs/tutorials/21_had_pretest_workflow.ipynb) — composite pre-test walkthrough fordid_had_pretest_workflow. Uses aUniform[$0.01K, $50K]dose-distribution variant of T20’s brand-campaign panel (true support strictly positive but near-zero, chosen so QUG fails-to-rejectH0: d_lower = 0in finite sample). Walks throughaggregate="overall"(Steps 1 + 3 only, verdict explicitly flags Step 2 deferral) and upgrades toaggregate="event_study"(joint pre-trends Stute + joint homogeneity Stute close the gap). Side panel exercises bothyatchew_hr_testnull modes (linearityvsmean_independence). Companion drift-test filetests/test_t21_had_pretest_workflow_drift.py(17 tests pinning panel composition, both verdict pivots, structural anchors, deterministic stats, bootstrap p-value tolerance bands per backend, andHAD(design="auto")resolution tocontinuous_at_zeroon this panel).[x] Phase 5 (wave 2 second slice): T22 weighted/survey HAD tutorial (
docs/tutorials/22_had_survey_design.ipynb) - shipped as the follow-up to PR #432. End-to-end walkthrough ofHeterogeneousAdoptionDiD+did_had_pretest_workflowunderSurveyDesign(weights, strata, psu, fpc)on a BRFSS-shape state-rollout panel (5 strata x 6 PSUs/stratum x 2 states/PSU = 60 states; post-stratification raking weights with CV ~ 0.30; FPC = 30 PSUs/stratum). Companion drift-test filetests/test_t22_had_survey_design_drift.py(32 tests pinning panel composition, naive-vs-survey SE inflation direction, design auto-detection, event-study cband-vs-pointwise width ordering,_QUG_DEFERRED_SUFFIXsubstring onreport.verdictfor both overall and event-study paths, the distinctreport.summary()QUG-skip note on the event-study path, deterministic Yatchew sigma2_*, bootstrap p-value anchored windows of total width 0.30 (± 0.15 around seeded centers) perfeedback_strata_bootstrap_path_divergence, workflow-surface separation between overall and event-study paths, and the weighted point-estimation contract via the_fit_continuousalgebraic identity).[x] Documentation of non-testability of Assumptions 5 and 6. Closed 2026-05-20:
HeterogeneousAdoptionDiDclass docstring carries a “Non-testable assumptions (paper Section 3.1.2)” Notes block;qug_test/stute_test/yatchew_hr_test/did_had_pretest_workflowNotes sections carry “Scope (what this test does NOT cover)” clauses explicitly stating they verify ADJACENT identifying conditions (QUG: support-infimum nulld_lower = 0; Stute / Yatchew: Assumption 8 linearity;joint_pretrends_test: Assumption 7 mean-independence) and CANNOT test Assumptions 5 or 6. The composite workflow verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on the two-periodaggregate="overall"path. The Assumption 5/6 non-testability caveat is surfaced separately by (a)HAD.fit()’s fit-timeUserWarningindiff_diff/had.py(search for “—- Assumption 5/6 warning on Design 1 paths —-”) which fires whenever the resolved design is Design 1 family (continuous_near_d_lowerormass_point), and (b) T21 (HAD pretest workflow tutorial) tutorial prose.[x] Warnings for staggered treatment timing (redirect to
ChaisemartinDHaultfoeuille). Closed 2026-05-20: fail-closedValueErroratdiff_diff/had.py:1511(see Deviations § “Library extension: Staggered-timing fail-closed” for the rationale on raising vs warning).[x]
NotImplementedErrorphase pointer whencovariates=is passed (Theorem 6 future work). Closed 2026-06-01:HAD.fit()now takes an explicit keyword-onlycovariates=Noneparam and raisesNotImplementedError(with the Appendix B.1 / Theorem 6 multivariate-covariate-extension pointer + a pre-residualization workaround) when it is not None, replacing the prior bareTypeErrorfrom the absent kwarg. See the- **Note:**(”covariates=is reserved but NOT implemented”) above anddiff_diff/had.py::HeterogeneousAdoptionDiD.fit; locked bytests/test_methodology_had.py::TestHADDeviations::test_covariates_not_implemented_is_documented.
Diagnostics and Sensitivity#
PlaceboTests#
Primary source: Bertrand, M., Duflo, E., & Mullainathan, S. (2004). How Much Should We Trust Differences-in-Differences Estimates? The Quarterly Journal of Economics, 119(1), 249-275.. Paper review on file: docs/methodology/papers/bertrand-duflo-mullainathan-2004-review.md.
Module: diff_diff/diagnostics.py
Scope: A battery of placebo / randomization-inference diagnostics for the parallel-trends assumption, built on the base 2×2 DifferenceInDifferences. BDM (2004) introduce the placebo-law experiment — randomly assign a fake treatment date and/or fake treated group, estimate the DD, and check whether a (necessarily spurious) “effect” is significant more than ~5% of the time. These are diagnostics, not estimators, and are distinct from the per-estimator placebo/LOO surfaces documented elsewhere (SyntheticDiD donor leave-one-out per ADH 2015 §4, HAD pre-tests, DCDH placebo). Public API: run_placebo_test (dispatcher) → placebo_timing_test / placebo_group_test / permutation_test / leave_one_out_test, plus run_all_placebo_tests and PlaceboTestResults.
Key implementation requirements:
The four diagnostics:
placebo_timing_test(fake timing): restrict to pre-treatment periods, assign a fake post indicator (time >= fake_treatment_period), and run the DD on the real treated vs control units. A significant effect flags differential pre-trends. Requires ≥2 pre-periods; afake_treatment_periodinsidepost_periodsraisesValueError.placebo_group_test(fake group): designate control units as fake-treated. With the optionaltreatment=column, units that are ever real-treated are dropped first (placebo on never-treated units only, uncontaminated by the real effect); without it, the caller must pass control-only data. Degenerate designs (all fake-treated dropped, or no controls remain) raiseValueError; a fake-treated unit that is itself real-treated emits aUserWarning. Via therun_placebo_testdispatcher (which always has thetreatmentcolumn) thefake_grouppath filters ever-treated units by default.permutation_test(randomization inference): randomly reassign the treated-group label across units (BDM placebo-law over a fixed outcome set; the randomization-inference link is BDM fn 11, citing Rosenbaum 1996) and form the null distribution of the DD estimate.leave_one_out_test: drop each treated unit, refit, and report the per-drop ATTs (single-unit sensitivity).
Randomization-inference p-value (Phipson & Smyth 2010):
p = (1 + #{ |ATT*_b| >= |ATT_obs| }) / (B + 1)
where ATT*_b are the B valid permutation estimates and the +1 includes the observed statistic in both numerator and denominator (intrinsic floor 1/(B+1)). Assignments are drawn independently each iteration (Monte-Carlo sampling with replacement from the assignment space), so this is the Phipson & Smyth (2010) valid but slightly conservative RI p-value — not an exact finite-sample value. The term exact is reserved for the full enumeration of all C(N, n_treated) assignments (observed included), #{|ATT*| >= |ATT_obs|} / total, to which the sampled value converges as B → ∞ (this enumeration is the R-parity reference).
Standard errors / inference (stated honestly):
placebo_timing_test/placebo_group_testsurface the underlyingDifferenceInDifferencesestimator’s own jointly-computed inference (HC1 default viasafe_inference).permutation_testdoes not usesafe_inference: the p-value is the count-based RI value above, the confidence interval is the percentile interval of the null (permutation) distribution — not an effect CI, andt_stat = original_att / seis computed individually (se= std of the null distribution).placebo_effectreports the mean of the null distribution (≈0), withoriginal_effectholding the observed ATT.leave_one_out_testusessafe_inference(t-distribution,df = n_valid − 1), but itsseis the dispersion (sample std) of the leave-one-out ATTs — a sensitivity spread, not a design-based jackknife SE; the per-unitleave_one_out_effectsdict is the primary output andt_stat/p_value/CI are heuristic.
Edge cases:
Permutation NaN-decoupling (deliberate): the RI p-value is count-based and stays finite even when the permutation
seis degenerate (se == 0for identical permutations, orseNaN atn_valid == 1), in which caset_statis NaN. This intentionally departs from the bootstrap-NaN contract (non-finite SE → full NaN tuple), because the RI p-value does not depend onse(BDM fn 12: the placebo reference distribution is not standard normal, so the count-based RI p-value — not anse-based statistic — is the valid one). Note: intentional contract; seetests/test_methodology_placebo.py::TestPlaceboInferenceContracts.NaN inference for undefined statistics:
permutation_test: t_stat is NaN when the permutation SE is zero/degenerate (the p-value remains valid — see decoupling above).leave_one_out_test: t_stat, p_value, CI are NaN when the LOO SE is zero (all LOO effects identical) or< 2valid effects.Note: Defensive enhancement matching CallawaySantAnna NaN convention.
Fail-closed:
permutation_testandleave_one_out_testraiseRuntimeErrorif all refits fail;permutation_testemits aUserWarningwhen> 10%of permutations fail.
Reference implementation(s):
No single canonical R/Stata package implements the DiD placebo battery as one command (BDM’s own code is custom). R parity is anchored by exact enumeration in base R (
combn) of the full randomization distribution, with an optionalri2/coinconvention cross-check (guarded byrequireNamespace, not a committed dependency). Generator:benchmarks/R/generate_placebo_golden.R; golden:benchmarks/data/placebo_golden.json(+placebo_test_panel.csv). The deterministicleave_one_out_test/placebo_group_test/ observed ATT match R exactly (atol≈1e-10); the sampledpermutation_testmatches the exact value within Monte-Carlo tolerance.
Deviations:
Note (permutation inference is not
safe_inference): the permutation path uses the RI p-value + a null-distribution percentile interval (not an effect CI) and a null-meanplacebo_effect. This is the standard randomization-inference convention and deliberately differs from the project-widesafe_inferencet-based inference (which assumes a sampling-distribution SE the permutation test does not have).Note (leave-one-out spread):
leave_one_out_test’s reportedse/t_stat/p_value/CI summarize the dispersion of the per-drop ATTs (a robustness signal), not a design-based jackknife standard error; the per-unit effects are the primary output.Note (BDM scope): BDM (2004) is primarily about serial-correlation-robust standard errors (parametric AR, block bootstrap, cluster/arbitrary VCV, time aggregation). Those inference corrections are out of scope for this diagnostic surface — diff-diff’s cluster-robust SE and bootstrap paths cover them under the relevant estimators. This entry covers only the placebo-law / randomization-inference diagnostics that BDM motivate.
Requirements checklist:
[x] RI p-value
(1+count)/(B+1)(sampled) converges to the exact enumerationcount/total:tests/test_methodology_placebo.py::TestPlaceboRandomizationInference[x] R parity: exact enumeration + observed ATT at
atol=1e-12; deterministic LOO / fake-group atatol=1e-10; sampled permutation within MC tolerance:TestPlaceboParityR(skip-guarded)[x] Fake-timing detects differential pre-trends; null under parallel trends; pre-data only:
TestPlaceboFakeTiming[x] Fake-group never-treated
treatmentfilter + degenerateValueError+ misuseUserWarning; backward-compatible withouttreatment:TestPlaceboFakeGroup[x] Permutation NaN-decoupling + fail-closed
RuntimeError:TestPlaceboInferenceContracts[x] Functional coverage (dispatch routing, zero-SE /
<2-LOO NaN-inference):tests/test_diagnostics.py
BaconDecomposition#
Primary source: Goodman-Bacon, A. (2021). Difference-in-differences with variation in treatment timing. Journal of Econometrics, 225(2), 254-277.. Paper review on file: docs/methodology/papers/goodman-bacon-2021-review.md.
Scope: Decomposes the two-way fixed-effects DD (TWFEDD) estimator in Equation (2) when treatment timing varies across units. Theorem 1 expresses β̂^DD as a positively-weighted average of all possible 2x2 DD estimators in the data, with weights summing to 1. The decomposition is a diagnostic tool, not a treatment-effect estimator: it explains which comparisons drive the TWFEDD coefficient and why the estimator can fail to identify an interpretable causal parameter when treatment effects vary over time.
Key implementation requirements:
Assumption checks / warnings:
Requires variation in treatment timing (staggered adoption)
Always-treated units (
first_treat <= min(time), excluding the never-treated sentinels0andnp.inf; per paper footnote 11 with a library-convention extension on the first-period boundary case, see**Deviation (first-period boundary extension)**below) are automatically remapped to theU(untreated) bucket with aUserWarning; see the**Note (always-treated remap)**below for the full ordered-time / sentinel contractUnbalanced panels are accepted with a
UserWarning; the paper’s Appendix A proof assumes balanced panelsFalls back to timing-only comparisons when no never-treated units are present (no untreated group →
s_{kU}terms drop, weights rescale to sum to 1; VWCT and ΔATT can still bias the result — see paper Eqs. 14-15)
Estimator equation (Theorem 1, Equation 10a):
β̂^DD = Σ_{k ≠ U} s_{kU} · β̂_{kU}^{2x2}
+ Σ_{k ≠ U} Σ_{ℓ > k} [ s_{kℓ}^k · β̂_{kℓ}^{2x2,k} + s_{kℓ}^ℓ · β̂_{kℓ}^{2x2,ℓ} ]
The three 2x2 estimators (Eqs. 10b-d):
β̂_{kU}^{2x2} = (ȳ_k^POST(k) - ȳ_k^PRE(k)) - (ȳ_U^POST(k) - ȳ_U^PRE(k)) (Eq. 10b)
β̂_{kℓ}^{2x2,k} = (ȳ_k^MID(k,ℓ) - ȳ_k^PRE(k)) - (ȳ_ℓ^MID(k,ℓ) - ȳ_ℓ^PRE(k)) (Eq. 10c)
β̂_{kℓ}^{2x2,ℓ} = (ȳ_ℓ^POST(ℓ) - ȳ_ℓ^MID(k,ℓ)) - (ȳ_k^POST(ℓ) - ȳ_k^MID(k,ℓ)) (Eq. 10d)
Comparison-type labels in BaconDecompositionResults.comparisons:
"treated_vs_never"↔ Eq. 10b"earlier_vs_later"↔ Eq. 10c (k = early = treated; ℓ = late = control during MID)"later_vs_earlier"↔ Eq. 10d (ℓ = late = treated; k = early = already-treated control)
Weight construction (Eqs. 7-9 for variances, 10e-g for weights):
Fixed-effects-adjusted treatment-dummy variances:
V̂_{kU}^D = n_{kU}(1 - n_{kU}) · D̄_k(1 - D̄_k) (Eq. 7)
V̂_{kℓ}^{D,k} = n_{kℓ}(1 - n_{kℓ}) · (D̄_k - D̄_ℓ)/(1 - D̄_ℓ) · (1 - D̄_k)/(1 - D̄_ℓ) (Eq. 8)
V̂_{kℓ}^{D,ℓ} = n_{kℓ}(1 - n_{kℓ}) · D̄_ℓ/D̄_k · (D̄_k - D̄_ℓ)/D̄_k (Eq. 9)
Decomposition weights:
s_{kU} = ((n_k + n_U)^2 · V̂_{kU}^D) / V̂^D (Eq. 10e)
s_{kℓ}^k = ((n_k + n_ℓ)(1 - D̄_ℓ))^2 · V̂_{kℓ}^{D,k} / V̂^D (Eq. 10f)
s_{kℓ}^ℓ = ((n_k + n_ℓ) · D̄_k)^2 · V̂_{kℓ}^{D,ℓ} / V̂^D (Eq. 10g)
Where n_k is the sample share of timing group k, n_{kℓ} = n_k / (n_k + n_ℓ), and D̄_k = (T - k + 1)/T is the share of periods group k spends treated. Weights are strictly positive and sum to 1 (Theorem 1).
Standard errors:
The decomposition is a deterministic algebraic identity; standard errors are not the paper’s focus. The point estimates and weights are exact given the data, and on balanced panels
β̂^DDfrom the decomposition matches the OLS coefficient from TWFEDD to machine precision underweights="exact". The machine-precision claim does not extend to unbalanced panels (see edge cases below).Inference for the TWFEDD coefficient itself is typically cluster-robust at the unit level.
Edge cases:
Continuous treatment: not supported, requires binary
Single treatment time: K=1 with U is valid (only
treated_vs_neverterms); K=1 without U has no decomposition (zero variation inD).D̄_k → 0orD̄_k → 1: subsample treatment variance goes to zero, that timing group contributes zero weightAlways-treated units: see
**Note (always-treated remap)**below
Reference implementation(s):
R:
bacondecomp::bacon()(CRAN). Parity script atbenchmarks/R/generate_bacon_golden.R; goldens committed atbenchmarks/data/r_bacondecomp_golden.json(generated againstbacondecomp0.1.1 + R 4.5.2). Parity validated atatol=1e-6viatests/test_methodology_bacon.py::TestBaconParityR(4 tests: TWFE coefficient + weights-sum match across 3 fixtures; per-component estimate + weight parity locked on the 2 non-remap fixtures and on the 6 timing-vs-timing rows ofalways_treated_remapped; the U-bucket convention divergence onalways_treated_remappedis pinned by a dedicated fold-back test).Stata:
bacondecomp(SSC). Authors: Goodman-Bacon, Goldring, Nichols (2019).
Requirements checklist:
[x] Three comparison types: treated_vs_never, earlier_vs_later, later_vs_earlier
[x] Weights sum to 1 (machine precision under
weights="exact"on balanced panels; see PR-B audit)[x] TWFE coefficient = weighted sum of 2×2 estimates (machine precision under
weights="exact"on balanced panels)[x] Visualization shows weight vs. estimate by comparison type
[x] Always-treated remap to U per Goodman-Bacon (2021) footnote 11 (PR-B audit)
[x] Hand-calculable Theorem 1 verification:
tests/test_methodology_bacon.py::TestBaconHandCalculation(7 tests, atol=1e-10)[x] R
bacondecomp::bacon()parity at atol=1e-6 (3 fixtures; TWFE coefficient + weights-sum match across all 3; per-component parity locked on the 2 non-remap fixtures and on the 6 timing-vs-timing rows ofalways_treated_remapped; the U-bucket fold-back is asserted by a dedicatedtest_always_treated_remapped_fold_back_matches_r— see**Note (R parity convention divergence)**below)[x] Survey design support (Phase 3): weighted cell means, weighted within-transform, weighted group shares
Note (weight modes):
weights="exact"(default, paper-faithful Eqs. 7-9 + 10e-g) vsweights="approximate"(simplified variance, opt-in for speed-sensitive diagnostic loops). The PR-A paper review (#451) and PR-B audit established"exact"as the default to match Rbacondecomp::bacon()and the paper’s Theorem 1 contract; R parity is validated atatol=1e-6(see**Note (R parity convention divergence)**below for the one structural convention difference). Hand-calculation + TWFE-vs-weighted-sum identity hold atatol=1e-10. The approximate path is retained for backward compatibility; numerical output may differ from R.Note (always-treated remap): Units whose
first_treatis at or before the first observable period (first_treat <= min(time), excluding the never-treated sentinels0andnp.inf) are automatically remapped to theUbucket via an internal column (__bacon_first_treat_internal__) with aUserWarning— per paper footnote 11 (with a library boundary extension onfirst_treat == min(time); see**Deviation (first-period boundary extension)**below). Detection uses ordered-time logic on the time axis, so panels whosetimecolumn has negative or zero-crossing labels (e.g. event-timetime ∈ [-2,..,3]) are handled correctly: a cohort atfirst_treat=-1on such a panel is a valid timing group; a cohort atfirst_treat=-3is remapped to U. The user’s originalfirst_treatcolumn on the inputdataframe is preserved unchanged. The count of remapped units is surfaced viaBaconDecompositionResults.n_always_treated_remapped. Sentinel restriction:first_treat ∈ {0, np.inf}is reserved as the never-treated marker and is not configurable today; a real treatment cohort withfirst_treat == 0would be folded intoUand should be re-labeled to a non-sentinel value before fitting. The0reservation applies tofirst_treatonly, not totime.Note (Bacon survey diagnostic): Bacon decomposition with survey weights is diagnostic; exact-sum guarantee holds at machine precision under
weights="exact"on balanced panels.weights="exact"requires within-unit-constant survey columns (approximate path accepts time-varying weights).Note (R parity convention divergence on always-treated): R
bacondecomp::bacon()keepsfirst_treat=1(the always-treated cohort) as a separate timing cohort and emits an additional comparison typeLater vs Always Treated(cohort k vs the always-treated cell) alongside the standardTreated vs Untreatedrow. Python’s footnote-11 convention remaps these units to theUbucket and folds those R-side rows into a singletreated_vs_nevercell per treated cohort. The aggregate (TWFE coefficient + sum of weights) is invariant to this re-bucketing — Theorem 1’s identity holds identically because the U bucket’s total weight gets re-allocated across nested 2x2 cells but the total weight on{cohort_k vs U}is the same. The per-component breakdown, however, differs structurally between the two conventions. The R parity test (tests/test_methodology_bacon.py::TestBaconParityR::test_component_estimates_match_r) asserts per-component parity atatol=1e-6on the 2 fixtures without always-treated (uniform_3groups_with_never_treated,two_groups_no_never_treated) AND on the 6 timing-vs-timing rows ofalways_treated_remapped— the carve-out is narrowed to U-bucket rows only (R’sLater vs Always Treatedrows canonicalize totreated_vs_neverand are dropped alongside the matching Python rows). The R→Python U-bucket fold-back is pinned separately bytest_always_treated_remapped_fold_back_matches_r, which aggregates R’s splitLater vs Always Treated+Treated vs Untreatedrows per treated cohort and asserts the combined weight + weight-averaged estimate match Python’s singletreated_vs_nevercell atatol=1e-6. Aggregate parity (test_twfe_coef_matches_r,test_weights_sum_matches_r) is locked across all 3 fixtures.Deviation (first-period boundary extension on always-treated remap): Paper footnote 11 (Goodman-Bacon 2021) uses the strict inequality
t_i < 1(units treated before the first observable period) for the always-treated bucket. The library applies the inclusivefirst_treat <= min(time)rule, which additionally folds units treated at the first observable period (first_treat == min(time)) intoU. This is a library boundary convention, not a paper-faithful rule: such units have no untreated cell in the observed panel and so cannot contribute to any 2x2 DD as a treated cohort, so folding them into the U bucket mirrors the always-treated handling rather than dropping them silently. Rbacondecomp::bacon()does not apply this boundary fold-back — it keepsfirst_treat == min(time)cohorts in their own bucket and emitsLater vs Always Treatedcomparisons (see the Note (R parity convention divergence on always-treated) above for how the parity tests handle the resulting structural breakdown difference; aggregate Theorem 1 identity remains invariant). Whenmin(time)is strictly greater than 1 (no first-period-treated cohorts), the library rule reduces to the paper’s strict rule and the two conventions coincide.Deviation (unbalanced-panel library extension): Unbalanced panels are accepted with a
UserWarning(“Unbalanced panel detected. Bacon decomposition assumes balanced panels. Results may be inaccurate.”). Goodman-Bacon (2021) Appendix A’s proof assumes a balanced panel; under unbalance, the Theorem 1 identity holds only approximately. The decomposition still returns finite, well-defined outputs butweights="exact"does NOT achieve the machine-precision algebraic identity that the balanced-panel claims above describe.
HonestDiD#
Primary source: Rambachan, A., & Roth, J. (2023). A More Credible Approach to Parallel Trends. Review of Economic Studies, 90(5), 2555-2591.
Key implementation requirements:
Assumption checks / warnings:
Requires event-study estimates with pre-treatment coefficients
Warns if pre-treatment coefficients suggest parallel trends violation
M=0 for Delta^SD: enforces linear trend extrapolation (not exact parallel trends)
Restriction classes (Equations 8, Section 2.3):
Delta^SD(M) — Smoothness (second differences, all periods):
Δ^SD(M) = {δ : |(δ_{t+1} − δ_t) − (δ_t − δ_{t-1})| ≤ M, for all t}
with δ_0 = 0 at the pre-post boundary. M=0 enforces linear trends.
Delta^RM(M̄) — Relative magnitudes (post-treatment first differences):
Δ^RM(M̄) = {δ : |δ_{t+1} − δ_t| ≤ M̄ × max_{s<0} |δ_{s+1} − δ_s|, for all t ≥ 0}
Post-treatment consecutive first differences bounded by M̄ × max pre-treatment first difference. Union of polyhedra (one per max location).
Identified set (Equations 5-6):
θ^lb = l'β_post − max{l'δ_post : δ ∈ Δ, δ_pre = β_pre}
θ^ub = l'β_post − min{l'δ_post : δ ∈ Δ, δ_pre = β_pre}
CRITICAL: δ_pre = β_pre pins pre-treatment violations to observed coefficients. Solved via LP (scipy.optimize.linprog).
Inference (Sections 3.2, 4.1):
Delta^SD: Optimal FLCI — a nested convex program (inner: minimize worst-case bias at a fixed estimator SD
h; outer: minimize the half-length overh) that matches RHonestDiD::findOptimalFLCI’s optimal affine estimator, using the folded-normal quantile cv_α(bias/se) (Equation 18). See the “Δ^SD FLCI optimizer” note below for the algorithm + R parity. Whendf_surveyis present, uses folded non-central t (scipy.stats.nct) instead of folded normal;df_survey=0→ NaN inference. For M=0, uses_get_critical_value(alpha, df)(standard t/z).Delta^RM: Paper recommends ARP conditional/hybrid confidence sets (Equations 14-15, κ = α/10). Currently uses naive FLCI unconditionally (conservative — wider CIs, valid coverage). ARP infrastructure exists but is disabled.
Note (deviation from R): Delta^RM CIs use naive FLCI (
lb - z*se, ub + z*se) instead of the paper’s ARP hybrid. R’sHonestDiDpackage implements full ARP conditional/hybrid. Our naive FLCI is conservative (wider, valid coverage) but does not adapt to the length of the identified set. ARP implementation deferred (see TODO.md).Note:
method="combined"(Delta^SDRM) uses naive FLCI on the intersection of Delta^SD and Delta^RM bounds. The paper proves FLCI is NOT consistent for Delta^SDRM (Proposition 4.2). The paper recommends ARP hybrid for non-SD restriction classes. This is a known conservative approximation; a runtime UserWarning is emitted.
Standard errors:
Inherits Σ̂ from underlying event-study estimation
Sensitivity analysis reports identified set bounds and confidence sets
Edge cases:
M=0 for Δ^SD: linear extrapolation, point identification, FLCI near-optimal
M̄=0 for Δ^RM: post-treatment first differences = 0, point identification
Breakdown point: smallest M where CI includes zero
Negative M: not valid (constraints become infeasible)
Note: Δ^SD empty estimated identified set (the observed pre-trend’s curvature exceeds M, so the
δ_pre = β_pre-pinned identified-set LP is infeasible →lb/ub= NaN) does not suppress the FLCI. The optimal FLCI is an affine estimator whose worst-case bias is taken overδ ∈ Δ^SD(M)treating β as random, so it is well-defined given (Σ, M) regardless of whether the realizedβ_prelies in Δ; R’sHonestDiD::createSensitivityResultsreturns it in exactly this case._compute_smoothness_boundstherefore returns the finite FLCI withlb/ub= NaN (empty id-set), matching R (prior behavior NaN-propagated the whole result, silently yielding no inference on high-curvature pre-trends). The naive fallback FLCI (diagonal Σ only) still requires finite id-set bounds and returns NaN CI when infeasible. Guarded bytest_infeasible_smoothness_fit_returns_flci_with_empty_idset.Note (Δ^SD FLCI optimizer — R-faithful nested algorithm; B2b resolved): The optimal FLCI is a faithful port of R
HonestDiD::findOptimalFLCI’s nested convex program. For a fixed estimator standard deviationh, an inner problem minimizes the worst-case bias overΔ^SD(M)subject tosd(estimator) ≤ h— a smooth convex QCQP over the slope weightsw(variables[U; w],U ≥ |cumsum(w)|, one second-order-conevar(w) ≤ h², linear-trend neutralityΣw = Σ_s s·l_s), solved withscipy.optimizeSLSQP (no cvxpy). An outer 1-D search overh ∈ [hMin, h0](a warm-started grid zoom) minimizes the half-lengthcv_α(M·bias(h)/h)·h. The worst-case bias uses R’s exact closed formM·(constant + Σ_i |cumsum(w)_i|)(a direct port of R’s.createObjectiveObjectForBias), so the inner objective is linear in the abs-linearized weights;hMin/h0are the min-variance and min-bias SDs (R’s.findLowestH/.findHForMinimumBias). For nonnegative (averaging)l_vec— the standard case, including the default first-post-period basis vector and equal weights — this closed form equals the general LP oracle_compute_worst_case_bias(max |v’δ| over Δ^SD) to machine precision. For a signed / contrastl_vec(e.g.[1, -1]) R’s closed form is conservative relative to the exact LP-oracle bias (a shared R property, not a diff-diff bug); the FLCI still matches R (verified to ~1e-6 forl_vec=[1,-1],test_signed_contrast_lvec_matches_r), because we port R’s objective exactly. This matches R’s optimal FLCI center + half-length + optimalVec to ~1e-3 (median ~1e-5) across a stress grid (num_pre × num_post × AR(1) ρ × M);TestHonestFLCIParityR, goldenbenchmarks/data/honest_flci_golden.json. The prior flat Nelder-Mead optimizer drifted from R’s center by up to ~9% at intermediate M (widths matched, coverage unaffected); the nested structure removes that drift (SE-audit B2b). A derivative-based outer solve is not viable: the min-bias inner solutions are degenerate (mostcumsum(w)components sit at the abs-value kink), so the SD-constraint multiplier is not recoverable fromsign(Lw), and the width surface is near-flat at the optimum.Deviation from R: diff-diff uses an analytical folded-normal critical value (
_cv_alpha,scipy.stats) in place of R’s Monte-Carlo.qfoldednormal(10⁶ draws, seed 0) — strictly more accurate (noise-free). Against override-R (R with the MC quantile replaced by the analytical one, so both solve the same deterministic outer problem) the center matches to ~1e-3; against stock MC-R the center matches to ~1.4e-2, the residual being R’s own simulation noise on the near-flat width surface. The committed golden stores both tiers.Note: Phase 7d: survey variance support. When input results carry
survey_metadatawithdf_survey, Delta^SD smoothness uses folded non-central t critical values (scipy.stats.nct); Delta^RM and naive FLCI paths use_get_critical_value(alpha, df)(standard t-distribution).df_survey=0→ NaN inference. CallawaySantAnnaResults storesevent_study_vcov(full cross-event-time VCV from IF vectors), which HonestDiD uses instead of the diagonal fallback. For replicate-weight designs, the event-study VCV falls back to diagonal (multivariate replicate VCV deferred).Note (deviation from R): When HonestDiD receives bootstrap-fitted CallawaySantAnna results (
n_bootstrap > 0), the full event-study covariance is unavailable (cleared to prevent mixing analytical VCV with bootstrap SEs). HonestDiD falls back todiag(se^2)from the bootstrap SEs with a UserWarning. R’shonest_did.AGGTEobjcomputes a full covariance from the influence function matrix; implementing bootstrap event-study covariance is deferred. For full covariance structure in HonestDiD, use analytical SEs (n_bootstrap=0).Note (deviation from R): When CallawaySantAnna results are passed to HonestDiD,
base_period != "universal"emits a warning but does not error. R’shonest_did::honest_did.AGGTEobjrequires universal base period. Our implementation warns because the varying-base pre-treatment coefficients use consecutive comparisons (not a common reference), which changes the parallel-trends restriction interpretation.
Reference implementation(s):
R:
HonestDiDpackage (Rambachan & Roth’s official package)
Requirements checklist:
[x] Δ^SD constrains second differences with δ_0 = 0 boundary handling
[x] Δ^RM constrains first differences (not levels), union of polyhedra
[x] Identified set LP pins δ_pre = β_pre (Equations 5-6)
[x] Optimal FLCI for Δ^SD (convex optimization, folded normal quantile)
[x] ARP hybrid framework for Δ^RM (vertex enumeration, truncated normal)
[x] Sensitivity analysis over M/M̄ grid with breakdown value
[x] M parameter must be ≥ 0
[ ] ARP hybrid produces valid (non-degenerate) CIs for all test cases
PreTrendsPower#
Primary source: Roth, J. (2022). Pretest with Caution: Event-Study Estimates after Testing for Parallel Trends. American Economic Review: Insights, 4(3), 305-322.. Paper review on file: docs/methodology/papers/roth-2022-review.md.
Key implementation requirements:
Assumption checks / warnings:
Requires specification of variance-covariance matrix Σ_22 of pre-period coefficients
Pre-trend zero-anticipation: τ_pre = 0 (so β̂_pre estimates δ_pre directly) — same convention as Rambachan-Roth (2023) HonestDiD
Warns if pre-trends test has low power (uninformative) relative to typical effect sizes
Different violation types and pretest forms have different power properties
Estimator equation (primary form — NIS box probability; Roth 2022 Section II.A-B):
The paper-analyzed pretest is the no-individually-significant (NIS) test: reject parallel trends if any pre-period coefficient lies outside its own (1 - α) CI. The acceptance region is
B_NIS(Σ) = { b ∈ R^K : |b_t| ≤ z_{1-α/2} · σ_t, for all t ∈ pre-periods }
Under H1 with violation δ_pre = M · weights and β̂_pre ~ N(δ_pre, Σ_22), the rejection probability is computed via the centered change-of-variable Y = β̂_pre - δ_pre ~ N(0, Σ_22):
Power(δ_pre) = 1 - P( Y_t ∈ [-z·σ_t - δ_t, z·σ_t - δ_t] for all t )
= 1 - F_MVN(upper, lower; mean=0, cov=Σ_22)
where F_MVN is the multivariate normal CDF over the rectangular box. Computed via scipy.stats.multivariate_normal.cdf(upper, lower_limit=lower, mean=zeros, cov=Σ_22, allow_singular=True) (Genz method; supports K up to ~20). Falls back to MC simulation (N=20000 draws) when the analytical CDF returns NaN on degenerate Σ.
MDV: solve Power(γ · weights) = target_power for γ via doubling expansion + optimize.brentq bisection. Non-convergence cap at γ_high = 1000 returns np.inf.
Estimator equation (paper-supported alternative — Wald pretest form):
W = δ̂_pre' Σ_22^{-1} δ̂_pre ~ χ²(K)
Power(δ_pre) = 1 - F_ncχ²(c_α; K, λ), where λ = δ_pre' Σ_22^{-1} δ_pre
(noncentrality parameter)
The Wald acceptance region is a convex ellipsoid, so Propositions 1+3+4 of Roth (2022) all apply. Retained for backwards compatibility with the pre-PR-B shipped numerical output (Wald was the implicit default before PR-B 2026-05-17). Activated via pretest_form='wald'.
Violation types:
Linear:
δ_t = γ · t(Roth’s slope convention). Whenrelative_timesis threaded throughfit(), weights =|t|directly with no L2 normalization, so the reported MDV is in Roth’s γ units.Constant:
δ_t = c(level shift)Last period:
δ_{-1} = c, others zeroCustom: user-specified
violation_weightspatternNote (paper-supported alternative — Wald pretest form): the library retains the Wald noncentral-χ² form as
pretest_form='wald'. NIS is the paper’s primary analysis convention (used for all 12 surveyed papers’ empirical exercises in Section I), but the Wald form is also a paper-supported alternative: Roth’s Propositions 1, 3, and 4 apply to any (measurable) acceptance region for the conditional moments (Props 1+3) and to any convex acceptance region for the variance-reduction guarantee (Prop 4). The Wald ellipsoid is convex, so all four propositions apply. Wald is faster (no MVN CDF call) and matches the pre-PR-B shipped numerical baseline. Use Wald for backwards-compat / speed; use NIS for canonical paper alignment and Rpretrendsparity.Note (convention —
linearviolation pattern, γ-unit MDV):_get_violation_weights('linear')consumes actual pre-period relative-time labels threaded throughfit()(PR-B 2026-05-17 resolution of the PR-A linear-pattern deviation). Whenrelative_timesis provided (e.g.,[-3, -2, -1]for a regular grid or[-5, -3, -1]for an irregular grid), weights =|t|directly with NO L2 normalization, soδ_pre = M · |t|reflects Roth’sδ_t = γ · tconvention and the reported MDV equals γ. Callers that bypassfit()and supply onlyn_preretain the previous count-based, L2-normalized[n_pre-1, ..., 0]direction (preserves shipped Wald numerical baselines for unit tests). MPD period-label coverage: forMultiPeriodDiDResults, the relative-time derivation in_extract_pre_period_paramssupports numeric labels (int/float/np.int64) andpandas.Period/pandas.Timestamp/np.datetime64(via Period or Timedelta arithmetic with units of frequency / days respectively). For genuinely non-numeric or unordered labels (string period IDs, unranked categoricals), the helper emits an explicitUserWarningand falls back to the legacy count-based normalized direction — the reported MDV is then NOT in Roth’s γ units. Users on string period IDs who need γ-unit MDV should re-fit with numeric labels.
Standard errors:
Power calculations are exact (no sampling variability — power is computed against a hypothesized population trend, not estimated)
Uncertainty comes from the user-supplied Σ_22
Footnote 7 equivariance: the distribution of
β̂_postconditional onβ̂_prepassing the pretest is equivariant w.r.t.τ_post(Roth 2022 Section I.C); MDV/power do not depend on the value ofτ_post
Edge cases:
Perfect collinearity in pre-periods: test not well-defined;
multivariate_normal.cdf(allow_singular=True)may return NaN — MC simulation fallback kicks in.Single pre-period (K=1): NIS power reduces to a univariate normal-tail probability; closed-form match with Roth Section II.B Proposition 2 proof:
E[β̂_pre | β̂_pre ∈ B_NIS] - β_pre ∝ φ(-z - β_pre/σ) - φ(z - β_pre/σ).Very high power: MDV approaches zero.
Symmetric two-sided pretests under parallel trends:
β̂_postremains unbiased forτ_post(Roth Section II.B paragraph after Prop 1 —E[β̂_pre | β̂_pre ∈ B] = 0if B is symmetric andβ_pre = 0).Note (deviation from paper — diagonal pre-period VCV fallback, bootstrap-only after PR-B): Roth (2022)’s power and bias objects operate on the full pre-period covariance block Σ_22. After PR-B 2026-05-17, the shipped
compute_pretrends_poweradapter consumes full Σ_22 on the non-bootstrap paths for ALL three result types:MultiPeriodDiDResults: full pre-period sub-block fromresults.vcovwheninteraction_indicesis populated; diag fallback only wheninteraction_indicesis None.CallawaySantAnnaResults: fullevent_study_vcovsub-block on non-bootstrap fits (the matrix is persisted atstaggered_results.py:126-128). Bootstrap CS fits clearevent_study_vcovatstaggered.py:2032-2036to prevent mixing analytical VCV with bootstrap SEs, so they fall through todiag(ses^2).SunAbrahamResults: fullevent_study_vcovsub-block on non-bootstrap fits, constructed insun_abraham.pyviaW @ vcov_cohort @ W.Twhere W is the cohort-aggregation matrix (PR-B Step 3 SA extension). Bootstrap SA fits and replicate-weight survey fits clearevent_study_vcovfor the same reason as CS.
The diag-fallback path is therefore reserved for cases where the analytical VCV is genuinely unavailable (bootstrap fits, replicate-weight survey fits, MPD without
interaction_indices). In those cases dropping off-diagonals is documented as a non-paper approximation — not provably conservative, since the direction of the discrepancy with the full-Σ_22 calc depends on the sign and magnitude of the dropped correlations. Seedocs/methodology/papers/roth-2022-review.mdfor the full derivation.Backwards-compat addendum (
power_at()forviolation_type='custom'):PreTrendsPowerResultsnow persistsviolation_weightson fresh fits (PR-B Step 5), sopower_at(M)works for all four violation types including custom. Old serialized results from before PR-B’s field addition haveviolation_weights=None; for those legacy results,power_at(M)falls back to weight reconstruction fromviolation_type + n_pre_periods, but forviolation_type='custom'the custom weights cannot be reconstructed andpower_at(M)raisesNotImplementedErrorwith a “refit with current library version” message. Fresh fits do not hit this guard.
Reference implementation(s):
R:
pretrends(Roth’s official package). NIS-based (pretrends(),slope_for_power(),*_NIShelpers). R-parity goldens atbenchmarks/data/r_pretrends_golden.jsongenerated bybenchmarks/R/generate_pretrends_golden.Ragainst commit122731d082(package version 0.1.0; PR-C, 2026-05-19). Parity atatol=1e-4on both NIS power and γ_p MDV — R hardcodesthresholdTstat=1.96while Python usesscipy.stats.norm.ppf(0.975) = 1.959963984540054, and Rslope_for_powerusesuniroot(tol = .Machine$double.eps^0.25 ≈ 1.22e-4)versus Pythonbrentq(xtol=2e-12); the inverse-solver tolerance gap dominates the γ_p residual.R dependency:
tmvtnorm(Manjunath & Wilhelm 2012) — used by Rpretrendsfor truncated multivariate normal moments. The Python library usesscipy.stats.multivariate_normal.cdfdirectly for the box probability (does not require atmvtnormport).
Requirements checklist:
[x] NIS box probability implemented via scipy MVN CDF (PR-B)
[x] Wald form retained as paper-supported alternative under
pretest_form='wald'(PR-B)[x] Non-bootstrap CS/SA route through full
event_study_vcovsub-block (PR-B Step 3)[x] Linear-violation weights honor actual relative-time labels → γ-unit MDV (PR-B Step 4)
[x] Custom-violation weights persisted on
PreTrendsPowerResults;power_at(custom)works on fresh fits (PR-B Step 5)[x] Helper API (
compute_pretrends_power/compute_mdv) supportsviolation_weights+pretest_form(PR-B Step 6)[x] Methodology test file with paper-equation-numbered Verified Components walk-through (PR-B Step 7 —
tests/test_methodology_pretrends.py)[x] R
pretrendsparity at commit122731d082(PR-C, 2026-05-19; 4 fixtures × NIS power + γ_p MDV atatol=1e-4)[x] Power curve plotting over violation magnitudes (preserved from pre-PR-B)
[x] Integrates with HonestDiD for combined sensitivity analysis (preserved from pre-PR-B)
PowerAnalysis#
Primary source:
Bloom, H.S. (1995). Minimum Detectable Effects: A Simple Way to Report the Statistical Power of Experimental Designs. Evaluation Review, 19(5), 547-556. https://doi.org/10.1177/0193841X9501900504
Burlig, F., Preonas, L., & Woerman, M. (2020). Panel Data and Experimental Design. Journal of Development Economics, 144, 102458.
Key implementation requirements:
Assumption checks / warnings:
Requires the outcome SD
sigma(validated finite and ≥ 0); within-unit (serial) equicorrelation viarho(default 0).The analytical methods validate
n_pre ≥ 1,n_post ≥ 1,rho ∈ [-1/(T-1), 1), positive group counts (n_treated,n_control> 0), andtreat_frac ∈ (0, 1)— each raisesValueErroron violation. The analytical path does not emit low-power / insufficient-sample warnings (those live only on the simulation path).Survey / cluster design effects enter via the multiplicative
deffparameter (default 1.0;deff < 1emits aUserWarning); there is no separate cluster-randomization parameter set.
Estimator equation (as implemented):
Critical values use the normal (z) distribution (Bloom 1995), not t.
Minimum detectable effect (MDE) — Bloom (1995), p.549:
MDE = (z_{1-α/2} + z_{1-κ}) × SE(τ̂) [two-sided]
MDE = (z_{1-α} + z_{1-κ}) × SE(τ̂) [one-sided]
where κ is target power (typically 0.8) and z_q is the standard-normal quantile.
Deviation from R: the MDE multiplier uses the normal distribution following Bloom (1995), whereas Burlig et al. (2020) Eq. 1 and
pwr::pwr.t.testuse the t distribution. This is a deliberate large-sample approximation (exact as the number of units grows; mildly anti-conservative — a few percent — for very small unit counts). The parity reference for the analytical path is therefore normal-based (pwr::pwr.norm.test/ a hand-derivedqnormgolden), notpwr.t.test.
Standard error — DiD with m = n_pre pre-periods and r = n_post post-periods: the within-unit equicorrelated special case of Burlig, Preonas & Woerman (2020), Eq. 2 (ψ^B = ψ^A = ψ^X = ρσ²):
SE(τ̂) = σ × √( (1/n_T + 1/n_C) × (1/m + 1/r) × (1 - ρ) )
where ρ is the within-unit (serial) equicorrelation. Cross-period correlation
lowers the DiD variance (differencing cancels the shared within-unit
component), so the MDE decreases as ρ increases — the opposite of a Moulton
mean-inflation factor. Valid range: ρ ∈ [-1/(T-1), 1), T = m + r; n_pre ≥ 1 and
n_post ≥ 1 are required and validated before computation (for all designs,
not only T > 2).
The basic 2×2 design (n_pre = n_post = 1) is the m = r = 1 special case (Burlig footnote 11): SE = σ × √( 2 × (1/n_T + 1/n_C) × (1 - ρ) ), which reduces to the DiD analog of Bloom (1995) Eq. 1 — σ × √( 2 × (1/n_T + 1/n_C) ) — at ρ = 0.
Note: only the equicorrelated special case (a single ρ) is implemented; the fully general serial-correlation-robust form with independent ψ^B, ψ^A, ψ^X (Burlig Eq. 2) is not. The equicorrelated case has lineage Frison & Pocock (1992) / McKenzie (2012), which Burlig generalizes.
Note: the analytical path does not model Bloom’s covariate-adjustment factor (1 - R²); R² = 0 is assumed. Survey design effects enter separately via the multiplicative
deffparameter (Kish 1965).
Power function — exact two-tailed normal:
Power = 1 - Φ(z_{1-α/2} - |τ|/SE) + Φ(-z_{1-α/2} - |τ|/SE)
(the lower-tail term is negligible but retained for exactness).
Sample size for target power (treatment fraction f = n_T/n; allocation factor f(1-f), maximized at f = 1/2; M = z_{1-α/2 or 1-α} + z_{1-κ}):
n = 2 M² σ² (1 - ρ) / ( δ² f(1-f) ) [basic 2×2, m=r=1 case]
n = M² σ² (1/m + 1/r)(1 - ρ) / ( δ² f(1-f) ) [panel]
where δ is the target effect.
Standard errors:
Analytical formulas (no estimation uncertainty in power calculation)
Simulation-based power accounts for finite-sample and model-specific factors
Edge cases:
Very small effects: may require infeasibly large samples
Within-unit equicorrelation ρ: higher ρ lowers the DiD variance (and hence the MDE and required N) because the difference-in-differences cancels the shared within-unit component (Burlig Eq. 2 equicorrelated case) — the opposite of survey/cluster design effects (
deff), which inflate variance (see thedeffNote below)Unequal allocation: optimal is often 50-50 but depends on costs
Note:
data_generator_kwargskeys that overlap with registry-managed simulation inputs (treatment_effect,noise_sd,n_units,n_periods,treatment_fraction,treatment_period,n_pre,n_post) are rejected withValueErrorto prevent silent desync between the DGP and result metadata.n_preandn_postare derived fromtreatment_periodandn_periodsin factor-model DGPs (SyntheticDiD, TROP); the 3-way intersection check naturally scopes the rejection to those estimators only. Use the correspondingsimulate_power()parameters directly, or pass a customdata_generatorto override the DGP entirely.Note: For the cross-sectional
TripleDifferencepath (n_periods ≤ 2),simulate_sample_size()rejectsn_per_cellindata_generator_kwargsbecausen_per_cellis derived fromn_units(the search variable). A fixed override would freeze the effective sample size across bisection iterations, making the search degenerate. Usesimulate_power()with a fixedn_per_celloverride instead, or pass a customdata_generator. (On the panel path —n_periods > 2—n_per_cellis rejected upstream insimulate_powerbecause the panel DGP does not accept it; see the panel routing Note below.)Note: The simulation-based power registry (
simulate_power,simulate_mde,simulate_sample_size) uses a single-cohort staggered DGP by default. Estimators configured withcontrol_group="not_yet_treated",clean_control="strict", oranticipation>0will receive aUserWarningbecause the default DGP does not match their identification strategy. Users must supplydata_generator_kwargs(e.g.,cohort_periods=[2, 4],never_treated_frac=0.0) or a customdata_generatorto match the estimator design.Note: The
TripleDifferenceregistry adapter routes byn_periods. Forn_periods ≤ 2it usesgenerate_ddd_data, a fixed 2×2×2 factorial DGP (group × partition × time): then_periods,treatment_period, andtreatment_fractionparameters are ignored — it always simulates 2 periods with balanced groups.n_unitsis mapped ton_per_cell = max(2, n_units // 8)(effective total N =n_per_cell × 8), so non-multiples of 8 are rounded down and values below 16 are clamped to 16. AUserWarningis emitted when simulation inputs differ from the effective DDD design. When rounding occurs, all result objects (SimulationPowerResults,SimulationMDEResults,SimulationSampleSizeResults) seteffective_n_unitsto the actual sample size used; it isNonewhen no rounding occurred.simulate_sample_size()snaps bisection candidates to multiples of 8 so thatrequired_nis always a realizable DDD sample size. Passingn_per_cellindata_generator_kwargssuppresses the effective-N rounding warning but not warnings for ignored parameters (treatment_period,treatment_fraction).Note: For
n_periods > 2,TripleDifferencepower routes togenerate_ddd_panel_data(a balanced panel with time-invariantgroup/partitionand a derivedpostindicator), which honorsn_periodsandtreatment_period.n_unitsmaps directly to panel units (no// 8rounding;effective_n_unitsisNone), andsimulate_sample_size()uses a continuous (step-1) search.treatment_fractionis still ignored (the design is a balanced 2×2×2 withgroup_frac = partition_frac = 0.5); passgroup_frac/partition_fracviadata_generator_kwargsto vary the split, and aUserWarningfires iftreatment_fraction ≠ 0.5.n_per_cell(a cross-sectional-only key) raisesValueError. Clustering caveat: the panel DGP has within-unit serial correlation, so unclustered standard errors are anti-conservative and overstate power — construct the estimator asTripleDifference(cluster="unit")(Liang-Zeger CR1; the panel DGP’s unit column is named"unit"). AUserWarningis emitted when the estimator lackscluster="unit". The point estimate is invariant to clustering; the inference contract is not.Note: The analytical power methods (
PowerAnalysis.power/mde/sample_sizeand thecompute_power/compute_mde/compute_sample_sizeconvenience functions) accept adeffparameter (survey design effect, default 1.0). This inflates variance multiplicatively:Var(ATT) *= deff, and inflates required sample size:n_total *= deff. Thedeffparameter is not redundant withrho(intra-cluster correlation):rhomodels within-unit (serial) equicorrelation in panel data via the Burlig (2020) Eq. 2 equicorrelated factor(1/m + 1/r)(1 - rho), whiledeffmodels the survey design effect from stratified multi-stage sampling (clustering + unequal weighting). A survey panel study may need both. Valuesdeff > 0are accepted;deff < 1.0(net variance reduction, e.g., from stratification gain) emits a warning.Note:
simulate_power()catches a narrow set of exception types —ValueError,numpy.linalg.LinAlgError,KeyError,RuntimeError,ZeroDivisionError— raised inside the per-simulation fit and result-extraction block, increments a per-effect failure counter, and skips the replicate. Programming errors (TypeError,AttributeError,NameError,IndexError, etc.) are allowed to propagate so that bugs in the estimator or custom result extractor surface loudly instead of being absorbed as simulation failures. The primary-effect failure count is surfaced on the result object asSimulationPowerResults.n_simulation_failures; aUserWarningstill fires when the failure rate exceeds 10% for any effect size, and all-failed runs raiseRuntimeError. This replaces the prior bareexcept Exceptionthat swallowed root causes and kept the counter internal to the function (axis C — silent fallback — under the Phase 2 audit).Note:
SurveyPowerConfig._build_survey_design()no longer caches its return value inself._cached_survey_design. Reassigningconfig.survey_design(either replacing a user-suppliedSurveyDesignwith another, or toggling betweenNoneand a user-supplied design) after the first call used to silently return the stale cached design; the method now returns the liveself.survey_design(or the default construction whenNone) every call. Other config fields (n_strata,icc,weight_variation, etc.) never influenced the returned design, so the staleness surface was specificallysurvey_designreassignment. Construction is microseconds — the cache never earned its complexity. Axis-J finding #28 in the Phase 2 silent-failures audit.Note: The simulation-based power functions (
simulate_power/simulate_mde/simulate_sample_size) accept asurvey_configparameter (SurveyPowerConfigdataclass). When set, the simulation loop usesgenerate_survey_did_datainstead of the default registry DGP, and automatically injectsSurveyDesign(weights="weight", strata="stratum", psu="psu", fpc="fpc")into the estimator’sfit()call. Supported estimators: DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD, CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, EfficientDiD. Unsupported (raisesValueError): TROP, SyntheticDiD, TripleDifference (generate_survey_did_data produces staggered cohort data incompatible with factor-model/DDD DGPs).survey_configanddata_generatorare mutually exclusive.data_generator_kwargsmay not contain keys managed bySurveyPowerConfig(n_strata, psu_per_stratum, etc.) but may contain passthrough DGP params (unit_fe_sd, add_covariates, strata_sizes). Repeated cross-section survey power (panel=False) is only supported forCallawaySantAnna(panel=False)with a matchingdata_generator_kwargs={"panel": False}; both mismatch directions are rejected.estimator_kwargsmay not containsurvey_designwhensurvey_configis set (useSurveyPowerConfig(survey_design=...)instead). Estimator settings that require a multi-cohort DGP (control_group="not_yet_treated",control_group="last_cohort",clean_control="strict") are rejected because the survey DGP uses a single cohort; use the customdata_generatorpath for these configurations.simulate_sample_sizeraises the bisection floor ton_strata * psu_per_stratum * 2to ensure viable survey structure and rejectsstrata_sizesindata_generator_kwargs(it depends onn_unitswhich varies during bisection).
Reference implementation(s):
R:
pwr::pwr.norm.test(analytical path — normal-based, matching D1; notpwr.t.test, which is noncentral-t),DeclareDesign(simulation-based)Stata:
pcpanel(Burlig et al. 2020 panel variance),powercommand (cross-sectional)
Requirements checklist:
[x] MDE calculation given sample size and variance parameters
[x] Power calculation given effect size and sample size
[x] Sample size calculation given MDE and target power
[x] Simulation-based power for complex designs
[x] Cluster adjustment for clustered designs (within-unit
rho+ surveydeff)
Visualization#
Event Study Plotting (plot_event_study)#
Reference Period Normalization
Normalization only occurs when reference_period is explicitly specified by the user:
Explicit
reference_period=X: Normalizes effects (subtracts ref effect), sets ref SE to NaNPoint estimates:
effect_normalized = effect - effect_refReference period SE → NaN (it’s now a constraint, not an estimate)
Other periods’ SEs unchanged (uncertainty relative to the constraint)
CIs recomputed from normalized effects and original SEs
Auto-inferred reference (from CallawaySantAnna results): Hollow marker styling only, no normalization
Original effects are plotted unchanged
Reference period shown with hollow marker for visual indication
All periods retain their original SEs and error bars
This design prevents unintended normalization when the reference period isn’t a true
identifying constraint (e.g., CallawaySantAnna with base_period="varying" where different
cohorts use different comparison periods).
The explicit-only normalization follows the fixest (R) convention where the omitted/reference
category is an identifying constraint with no associated uncertainty. Auto-inferred references
follow the did (R) package convention which does not normalize and reports full inference.
Rationale: When normalizing to a reference period, we’re treating that period as an identifying constraint (effect ≡ 0 by definition). The variance of a constant is zero, but since it’s a constraint rather than an estimated quantity, we report NaN rather than 0. Auto-inferred references may not represent true identifying constraints, so normalization should be a deliberate user choice.
Edge Cases:
If
reference_periodnot in data: No normalization appliedIf reference effect is NaN: No normalization applied
Reference period CI becomes (NaN, NaN) after normalization (explicit only)
Reference period is plotted with hollow marker (both explicit and auto-inferred)
Reference period error bars: removed for explicit, retained for auto-inferred
Reference implementation(s):
R:
fixest::coefplot()with reference category shown at 0 with no CIR:
did::ggdid()does not normalize; shows full inference for all periods
Cross-Reference: Standard Errors Summary#
Estimator |
Default SE |
Alternatives |
|---|---|---|
DifferenceInDifferences |
HC1 robust |
Cluster-robust, wild bootstrap |
MultiPeriodDiD |
HC1 robust |
Cluster-robust (via |
TwoWayFixedEffects |
Cluster at unit |
Wild bootstrap |
CallawaySantAnna |
Analytical (influence fn) |
Multiplier bootstrap |
SunAbraham |
Cluster-robust + delta method |
Pairs bootstrap |
ImputationDiD |
Conservative clustered (Thm 3) |
Multiplier bootstrap (library extension; percentile CIs and empirical p-values, consistent with CS/SA) |
TwoStageDiD |
GMM sandwich (Newey & McFadden 1994) |
Multiplier bootstrap on GMM influence function |
SyntheticDiD |
Placebo variance (Alg 4) |
Unit-level pairs bootstrap (paper-faithful refit, Alg 2 step 2); jackknife (Alg 3) |
TripleDifference |
Influence function (all methods) |
SE = std(IF) / sqrt(n) |
StackedDiD |
Cluster-robust (unit) |
Cluster at unit × sub-experiment |
TROP |
Block bootstrap |
— |
BaconDecomposition |
N/A (exact decomposition) |
Individual 2×2 SEs |
HonestDiD |
Inherited from event study |
FLCI, C-LF |
PreTrendsPower |
Exact (analytical) |
- |
PowerAnalysis |
Exact (analytical) |
Simulation-based |
Cross-Reference: R Package Equivalents#
diff-diff Estimator |
R Package |
Function |
|---|---|---|
DifferenceInDifferences |
fixest |
|
MultiPeriodDiD |
fixest |
|
TwoWayFixedEffects |
fixest |
|
CallawaySantAnna |
did |
|
SunAbraham |
fixest |
|
ImputationDiD |
didimputation |
|
TwoStageDiD |
did2s |
|
ContinuousDiD |
contdid |
|
SyntheticDiD |
synthdid |
|
TripleDifference |
triplediff |
|
StackedDiD |
stacked-did-weights |
|
TROP |
- |
(forthcoming) |
BaconDecomposition |
bacondecomp |
|
HonestDiD |
HonestDiD |
|
PreTrendsPower |
pretrends |
|
PowerAnalysis |
pwr / DeclareDesign / pcpanel |
|
SpilloverDiD#
Primary source: Butts, K. (2023). Difference-in-Differences with Spatial Spillovers. arXiv:2105.03737v3 (originally posted 2021). https://arxiv.org/abs/2105.03737
Secondary sources:
Gardner, J. (2022). Two-stage differences in differences. arXiv:2207.05943. The stage-1+2 residualization pattern this estimator reuses.
Conley, T. G. (1999). GMM Estimation with Cross-Sectional Dependence. Journal of Econometrics 92(1), 1-45. The spatial-HAC variance estimator recommended by Butts page 13 for inference with cutoff =
d_bar.
Scope: Spillover-aware Difference-in-Differences. Augments two-stage Gardner DiD with ring-indicator covariates that identify the direct effect on treated units (tau_total) alongside per-ring spillover effects on near-control units (delta_j). Handles both panel non-staggered timing and Section 5 staggered timing in a single estimator; non-staggered is the special case where all treated units share an onset time.
Note: This estimator is a documented synthesis of ingredients — no single published software package implements the exact recipe. did2s (R/Stata, Butts & Gardner 2022) implements the Gardner two-stage residualization but does NOT support ring covariates. The Butts (2021/2023) paper proposes the ring estimator in Equations 5/6/8 (non-staggered) and Section 5 / Table 2 (staggered) but does not ship reference software. The diff-diff implementation combines: (a) Butts (2021) Section 5 / Table 2 identification, (b) Gardner (2022) two-stage residualize-then-fit, (c) Wave A’s Conley spatial-HAC vcov.
Note (rank-guarded Wave D bread): The Wave D variance bread
A_22 = (X_2'WX_2)^{-1}is inverted by the shared_rank_guarded_inv(diff_diff/linalg.py) on the already-column-dropped kept submatrix.np.linalg.solveraised only on an exactly-singular bread (prior fallback: denselstsq); a near-singular kept Gram would otherwise flow a garbage inverse (~1e13) into the SE. The rank-guard truncates redundant directions → finite SE on the identified subspace (NaN at rank 0), re-inflated to (k, k) with NaN at the dropped positions, and warns. See the CallawaySantAnna “rank-guarded IF standard errors” Note. Sibling of axis-A finding #17.
Identification spec (committed):
The stage-2 regressor for ring j is the time-varying form
(1 - D_it) * Ring_{it,j}
where D_it is the treatment indicator (1 if unit i is treated by time t) and Ring_{it,j} is the indicator that unit i’s nearest currently-treated unit (treated by time t) is in distance bin j. For non-staggered timing, Ring_{it,j} is unit-static post-treatment and zero pre-treatment (no unit treated yet); for staggered timing, Ring_{it,j} is unit-time-varying.
Note: Reading the literal unit-static (1 - D_it) * S_i from paper Equation 5 yields a rank-deficient design under TWFE: (1 - D_it) * S_i = S_i - D_it * S_i = S_i - D_it (since D_it = 1 ⇒ S_i = 1), and S_i is absorbed by the unit FE mu_i, leaving -D_it. The two regressors are perfectly anti-collinear after FE absorption. The paper’s identification argument (Proposition 2.3, Section 3.1 subsample language) only resolves when S_it = S_i * 1{t >= first_treat} is read as the time-varying form — which the paper defines on page 12 (the line directly above Equation 5) and which Section 5 Table 2 makes explicit (S^k_{it}, Ring^k_{it,j}). The implementation matches the paper’s intent once the S_it notation is interpreted as time-varying.
Note: Stage-1 fits unit + time FE on Butts’ STRICTER subsample Omega_0 = {D_it = 0 AND S_it = 0} (untreated AND unexposed) — the clean far-away control group. This differs from TwoStageDiD’s Omega_0 = {D_it = 0} (untreated; includes near-controls in post-treatment periods). The stricter Butts subsample prevents spillover-contaminated near-controls from biasing the time FE; near-controls post-treatment carry delta_j variation that the ring covariates pick up at stage 2.
Note (shared FE engine, 2026-07): The stage-1 solver _iterative_fe_subset is a thin Butts-subsample wrapper over the shared bincount Gauss-Seidel helper diff_diff.utils._iterative_fe_solve (the same engine ImputationDiD/TwoStageDiD route through) — the wrapper owns the SpilloverDiD front door (empty-Omega_0 and empty positive-weight-Omega_0 ValueError gates, subsample extraction) and delegates the iteration, the zero-weight/positive-weight NaN-FE convention, and the warn_if_not_converged non-convergence UserWarning (labelled “SpilloverDiD stage-1 FE (Butts Omega_0 subsample)”) to the shared engine. Per sweep the shared engine computes the identical group means and convergence metric as the historical local loop, so converged fits are bit-identical; max_iter is aligned from the historical local cap of 100 to the shared 10,000 convention (the R fixest/pyfixest budget already used by ImputationDiD/TwoStageDiD) — fits that previously exhausted 100 iterations and warned may now converge instead (strictly more accurate FE; tol=1e-10 unchanged).
Note (Omega_0 row-level identification — period strict, unit warn-and-drop, plus connectivity): Every period must have at least one Omega_0 row (else time FE is structurally unidentified for that period, and dropping it would lose all units’ cross-time identification) — hard ValueError. Units lacking Omega_0 rows (e.g. baseline-treated units with D_it = 1 at every observed t) are warned-and-dropped: their unit FE is NaN, residualization writes NaN on their rows, and the downstream finite-mask path at stage 2 excludes them from estimation. This mirrors TwoStageDiD’s always-treated unit handling (two_stage.py:294-336) and Gardner’s framework, which identifies effects from supported observations rather than requiring every unit estimable. Connectivity: the supported-units bipartite graph (supported units linked by shared Omega_0 periods) must form a single connected component. If the graph splits into K > 1 components, the iterative FE solver identifies (mu_i, lambda_t) only up to component-specific constants, and residualization combines mu_i from one component with lambda_t from another — silently corrupting y_tilde and downstream tau_total / delta_j. Balanced panel + per-unit/per-period Omega_0 coverage is NECESSARY but NOT SUFFICIENT; connectivity is the load-bearing identification condition. Under the current absorbing-treatment regime the disconnected case is plausibly unreachable in practice (we were unable to construct an example surviving the upstream validators), but _check_omega_0_connectivity is in place as defense-in-depth and future-proofs Wave B follow-ups (event-study, survey-design integration, reversible-treatment relaxation if ever added).
Note (Gardner identity, non-staggered): Under non-staggered timing, the two-stage Gardner residualize-then-fit with the Omega_0-restricted stage 1 is empirically bit-identical to the single-stage TWFE ring regression on the full sample using the time-varying form y_it ~ mu_i + lambda_t + tau * D_it + sum_j delta_j * (1 - D_it) * Ring_{it,j}. This is the non-staggered ring estimator from Butts Equations 4-6. The empirical equivalence is verified by a 20-seed deterministic regression test (TestSpilloverDiDNonStaggeredFEEquivalence) at atol=1e-10. The Omega_0 restriction is therefore innocent for the non-staggered point estimate — it only changes the variance composition (which is why the stage-1 GMM correction enters at stage 2 in the staggered case). Reported tau_total for non-staggered timing IS the Butts Eqs. 4-6 estimator.
Note: Treated units have d_it = 0 (their own location) and fall in Ring_1 geometrically, but the (1 - D_it) factor zeros their ring contribution. n_units_ever_in_ring counts include treated units in Ring_1 by construction. Under staggered timing this field counts each unit in EVERY ring it visits across periods (not a clean partition); under non-staggered timing it IS a partition.
Note (anticipation shift): The anticipation: int constructor kwarg (default 0) shifts BOTH the treatment and ring-membership clocks by -anticipation so the stage-1 Omega_0 subsample correctly excludes the anticipation pre-treatment periods (which would otherwise contaminate the FE estimation with anticipation effects). Concretely, the effective treatment indicator becomes D'_it = 1{t >= first_treat - anticipation} and ring membership uses the corresponding shifted “currently-treated” set when computing d_it for staggered timing. Stage 2 then estimates tau_total and delta_j net of the anticipation window. Mirrors TwoStageDiD’s anticipation parameter semantics — recommended use is a small integer (1-3 periods) when domain knowledge suggests treatment effects begin before formal onset (e.g., policy announcements). Setting anticipation > 0 AND providing a first_treat column where first_treat - anticipation < min(time) for any unit will mark that unit as treated from the panel’s first observation, with the same baseline-treated handling as if it were always-treated (warn-and-drop via the Omega_0 unit-level check above).
Note: No published R/Stata software exists for the two-period ring estimator (Butts Eqs. 5-6). Correctness anchored on (a) synthetic-DGP Monte Carlo identification tests on the non-staggered DGP (50-seed default + 200-seed @pytest.mark.slow variant — both recover tau_total AND delta_1 within 0.02 absolute tolerance on Butts-Assumption-satisfying DGPs) plus a staggered-DGP MC test (30-seed, no slow variant) that anchors both tau_total within 0.04 and delta_1 within 0.03 absolute tolerance (looser bounds because each staggered DGP is larger and noisier); per-event-time delta_jk recovery on staggered DGPs is shipped in Wave C (TestSpilloverDiDEventStudyIdentification); (b) reduce-to-TWFE limit (non-staggered, 20-seed deterministic bit-identity at atol=1e-10 via TestSpilloverDiDNonStaggeredFEEquivalence); (c) Wave A’s Conley sparse-vs-dense parity inherited via solve_ols. A reduce-to-TwoStageDiD limit was scoped during planning but not shipped in Wave B (the Omega_0 stricter subsample requires additional fixture work to align with TwoStageDiD’s {D_it = 0} subsample); queued as a follow-up alongside the Gardner GMM correction.
Event-study mode (Wave C, event_study=True)#
Replaces the aggregate spec with the per-event-time × ring decomposition from Butts Section 5 / Table 2. Direct effects tau_k and per-(ring, event-time) spillover effects delta_jk are emitted in att_dynamic and a MultiIndex spillover_effects. A TwoStageDiD-compatible event_study_effects: Dict[int, Dict] alias (mirroring two_stage.py:1355-1389 schema with conf_int = (low, high) tuple) is also emitted for plot_event_study consumption — _extract_plot_data prefers the new reference_period attribute over the legacy n_obs==0 heuristic. DiagnosticReport routing is now wired (see the DiagnosticReport applicability Note below).
Note (DiagnosticReport applicability):
SpilloverDiDResultsis registered inDiagnosticReport’s_APPLICABILITY/_PT_METHODtables with applicable checks {parallel_trends(methodevent_study, on the per-event-time DIRECT-effect dynamics — populated whenevent_study=True; Bonferroni fallback across pre-period coefs since there is noevent_study_vcov),design_effect(instance-gated onsurvey_metadata),heterogeneity(readsevent_study_effects)}. This mirrors theTwoStageDiDset minusbacon.baconis deliberately excluded: SpilloverDiD identifies the direct effect off FAR-AWAY control observations (d_it > d_bar, Assumption 5), not off the TWFE 2×2 comparisons a Goodman-Bacon decomposition enumerates; runningbacon_decomposeon the raw binary treatment would ignore the ring/distance structure and pool spillover-contaminated in-ring units into the control group — the exact SUTVA violation this estimator handles (same rationale that excludesbaconfor SyntheticControl / TROP / ContinuousDiD).pretrends_power/sensitivity/epv/estimator_nativeare not applicable (nocompute_pretrends_poweradapter, no HonestDiD adapter, noepv_diagnostics, not an SDiD/TROP/SCM native path).
Note (two-clock K_it): Butts Section 5 uses one symbol K_it, but operationally there are TWO event-time clocks. The direct-effect clock is K_direct_{it} = t - effective_first_treat(i) for ever-treated unit rows (NaN for never-treated). The spillover-exposure clock is K_spill_{it} = t - min{ effective_first_treat(j) : d(i, j) ≤ d_bar AND effective_first_treat(j) ≤ t } — time since i first became exposed to ANY treated neighbor (running min across activated cohorts). K_spill is structurally non-negative (pre-trigger rows are NaN and contribute to stage 1 only); spillover placebos at k < 0 are therefore NOT meaningful and rectangular emission of those cells uses NaN coef + n_obs = 0. The trigger cohort for unit i is the earliest activated cohort whose treated members fall within d_bar of i — NOT necessarily the geographically nearest cohort.
Note (endpoint binning, divergence from TwoStageDiD): horizon_max bins event-times outside [-H, +H] into endpoint pools (k ≤ -H aggregated into one pre-bin dummy; k ≥ +H into one post-bin dummy). No observations are dropped. This diverges intentionally from TwoStageDiD’s horizon_max semantic, which filters rows with |K_it| > H out of the stage-2 sample. SpilloverDiD’s bin-into-endpoint behavior honors the no-silent-data-drop policy; the divergence is documented on both estimators’ docstrings to prevent future unification. With horizon_max=None, the helper auto-detects the event-time bin set from the observed K values (no binning).
Note (reference period -1 - anticipation): The reference period (the event-time dummy dropped to anchor the level interpretation) is ref_period = -1 - anticipation. With anticipation=0, ref_period = -1 (standard event-study convention; coefficients are relative to one period before treatment). With anticipation > 0, the reference period shifts to -1 - anticipation so the “pre-treatment” anchor sits BEFORE the anticipation window. Mirrors TwoStageDiD’s convention at two_stage.py:486. The reference row appears in att_dynamic and event_study_effects with coef = 0.0, se = 0.0, n_obs = 0, conf_int = (0.0, 0.0) (TwoStageDiD parity, two_stage.py:1355-1362). When horizon_max is set and ref_period < -horizon_max (i.e., anticipation > horizon_max - 1), the fit raises ValueError — silently floor-shifting the reference to -horizon_max would change identification (rejected per feedback_no_silent_failures).
Note (post-finite_mask sample): att_dynamic["n_obs"], event_study_effects[k]["n_obs"], AND the scalar att share weights all reflect the POST-finite_mask stage-2 estimation sample — not the pre-mask design built by _build_event_study_design. On warn-and-drop fits (baseline-treated units without Omega_0 rows are excluded via finite_mask), counts and weights are recomputed from X_2_fit so the reported metadata matches the actual stage-2 sample that solve_ols sees.
Note (fail-closed scalar att): When event_study=True, if any post-treatment direct-effect coefficient is NaN (rank-deficient drop by solve_ols), the scalar att is set to NaN with an explicit warning rather than silently zeroing the dropped column’s contribution via np.nansum on a fixed weight vector. The library convention (feedback_no_silent_failures) is to surface unidentified aggregates as NaN; users should inspect att_dynamic for the per-event-time coefficients and re-aggregate manually with a documented reweighting rule if appropriate.
Note (scalar att aggregation): The top-level att field, when event_study=True, is a share-weighted average of post-treatment direct effects:
att = sum_{k >= 0} (share_k / sum_{k >= 0} share_k) * tau_k
The standard error comes from linear-combination inference on the post-treatment block of the stage-2 vcov: Var(att) = w' V_subset w where w is the share-weight row vector and V_subset is the sub-vcov of post-treatment tau_k rows. This properly accounts for cross-event-time covariance and does NOT require a separate fit. CallawaySantAnna’s aggregate_method="simple" and TwoStageDiD’s analogous aggregate-from-event-study path use the same share-weighting convention.
Note (Wave E.1 — survey-weighted share definition): when survey_design= is supplied, the per-horizon shares share_k are survey-weight totals rather than raw observation counts:
share_k = sum_{i: K_direct_i = k AND treated_i = 1} w_i
where w_i are the Hájek-normalized survey weights. Using raw n_treated_at_k shares on weighted WLS horizon coefficients would mix unweighted aggregation with weighted horizons and target the wrong estimand. The same survey-weight totals enter both att and Var(att) = w' V_subset w, so the lincom variance stays consistent with the point estimate. On the no-survey path, share_k = n_treated_at_k (sample-share rule) — backward-compatible with Wave C.
Note (rectangular schema): att_dynamic and the MultiIndex spillover_effects are emitted rectangularly across the full event-time × ring grid implied by horizon_max. Empty cells (no rows contribute to the corresponding stage-2 design column — pre-filtered to keep solve_ols rank warnings quiet) emit coef = NaN, se = NaN, n_obs = 0. This includes negative-k spillover cells (structurally empty: K_spill ≥ 0) and outer-ring cells when the panel has no units in that ring band. The (ring_label, ref_period) cells are also emitted in spillover_effects (one per ring) with coef = 0.0, se = 0.0, n_obs = 0 to mirror the direct-effect reference row anchor — without this, consumers iterating [-H, ..., +H] would hit a missing (ring, ref_period) slice. Downstream consumers can iterate the rectangular grid without KeyError on missing cells.
Reduce-to-aggregate equivalence: Under a constant-tau DGP with horizon_max=None, the sample-share-weighted scalar att reproduces Wave B’s aggregate tau_total (bit-identical at machine precision in the deterministic limit; small MC noise on realized panels). This is the canonical equivalence path. Note: horizon_max=0 is not supported under event_study=True (rejected at validation with a clear remediation message): the single bin k=0 leaves no event-time pair to anchor the reference period ref_period = -1 - anticipation against. Users wanting a single aggregate direct effect should use event_study=False (the Wave B static spec); event-study mode requires horizon_max>=1 or horizon_max=None.
Variance: Per-event-time SEs apply the Wave D Gardner GMM first-stage uncertainty correction (see “Variance (Wave D)” subsection below). Per-tau_k and per-delta_jk SEs are shifted upward by a few percent relative to Wave C uncorrected SEs.
Assumptions (Butts 2021):
Assumption 1 (Random Sampling) — i.i.d. panel.
Assumption 3 (Parallel Counterfactual Trends) — counterfactual trends in absence of all treatment AND zero exposure do not depend on own treatment status.
Assumption 5 (Spillovers Are Local): (i)
min_{j: D_j=1} d(i,j) > d_bar ⇒ h_i(D-vector) = 0(spillovers vanish pastd_bar); (ii) at least one treated unit AND one control unit exist withmin d > d_bar(clean far-away control group). The validator enforces (ii) via_validate_far_away_existswith a strict> d_barcheck; failure raisesValueErrorciting the assumption.Assumption 6 (Total Effect Parallel Trends) — counterfactual trends do not depend on
(D_i, S_i). Stronger than Assumption 3.Assumption 7 (Spillover Effect Parallel Trends) — counterfactual trends do not depend on
(D_i, S_i)forS_i ∈ {0, 1}. Required to identifygamma_0/delta_j.Assumption 8 (Parallel Counterfactual Trends, Staggered) — additive unit + time FE structure on untreated/unexposed potential outcomes. Stronger than Assumption 3.
Variance (Wave D — Gardner GMM first-stage correction across HC1 / Conley / cluster):
Stage-2 variance applies the Gardner (2022) GMM sandwich influence-function correction for stage-1 FE estimation uncertainty across all three vcov_type paths. The unified IF outer-product formula:
psi_i = gamma_hat' * X_{10,i} * eps_{10,i} - X_{2,i} * eps_{2,i} # shape (p_2,)
Psi = [psi_1; ...; psi_n] # (n, p_2)
gamma_hat = (X_10' X_10)^{-1} (X_1' X_2) # (p_1, p_2)
meat = Psi' @ K @ Psi # (p_2, p_2)
vcov = (X_2' X_2)^{-1} @ meat @ (X_2' X_2)^{-1}
where the kernel K is path-dependent:
HC1:
K = I_n→meat = Psi' Psiwithn / (n - p_2)finite-sample multiplier.Cluster CR1:
K_ij = 1{cluster_i = cluster_j}→ per-cluster sum + outer product, withG / (G-1) * (n-1) / (n - p_2)finite-sample multiplier.Conley:
K_ij = kernel(d_ij / cutoff) * 1{cluster_i = cluster_j}(cross-sectional) or panel-block decomposed (conley_time/conley_unit/conley_lag_cutoffset). No finite-sample multiplier — matchesconleyregconvention.
The correction applies unconditionally (no opt-out kwarg). Point estimates (tau_total, delta_j, event-study tau_k / delta_jk) are byte-identical to the pre-Wave-D path; SE values shift upward by 1-few percent.
Note (documented synthesis): no R / Stata software combines all three ingredients (Butts (2021) §3.1 IF construction for spillover-aware DiD + Gardner (2022) §4 two-stage GMM sandwich + Conley (1999) spatial kernel).
did2s(Gardner) implements GMM with HC1/cluster but no Conley.conleyreg/acregimplement Conley but no two-stage correction. Wave D is the documented synthesis.Note (no finite-sample multiplier on Conley path): preserves the
conleyreg/ Wave B convention. HC1 and cluster paths apply the standardn/(n-p)andG/(G-1) * (n-1)/(n-p)multipliers respectively.Note (Conley meat may be non-PSD): the radial 1-D Bartlett and uniform kernels are practitioner specializations of Conley 1999 and are not formally PSD-guaranteed; a
UserWarningfires when the smallest meat eigenvalue is < -1e-12. Applies on both standard-sandwich and GMM-corrected sandwich paths.
Implementation: new module-level helper _compute_gmm_corrected_meat at diff_diff/two_stage.py (NOT a modification of the existing _compute_gmm_variance method — TwoStageDiD’s path is unchanged); new helper _build_butts_fe_design_csr at diff_diff/spillover.py; _compute_conley_meat factored out of _compute_conley_vcov at diff_diff/conley.py so the same kernel-application code path handles both standard sandwich (X * residuals) and Wave D IF outer product (Psi).
Variance (Wave E.1 — survey-design integration via Binder TSL)#
survey_design= is now supported on vcov_type ∈ {"hc1"} and CR1 (cluster=<col>) paths. The vcov is the design-consistent sandwich V = A_22^{-1} @ meat @ A_22^{-1} where:
A_22 = X_2' W X_2(bread with weighted-OLS structure whensurvey_weightsprovided)meat = sum_h (1-f_h) * n_h/(n_h-1) * sum_j (S_psu_hj - S_psu_h_bar) (S_psu_hj - S_psu_h_bar)'S_psu[g] = sum_{i in PSU g} Psi[i]withPsi[i] = w_i * gamma_hat' * X_{10,i} * eps_{10,i} - w_i * X_{2,i} * eps_{2,i}
The _compute_stratified_meat_from_psu_scores helper at diff_diff/survey.py implements the Binder TSL formula and handles lonely_psu ∈ {"remove", "certainty", "adjust"} and FPC consistently across the package.
Degrees of freedom for the t-distribution lookup use ResolvedSurveyDesign.df_survey (the standard survey 4-way branch: PSU+strata → n_PSU - n_strata; PSU only → n_PSU - 1; strata only → n_obs - n_strata; neither → n_obs - 1). Threaded through all four safe_inference call sites: aggregate tau_total, per-ring delta_j, event-study per-event-time tau_k / delta_jk, and the scalar att lincom in event-study mode.
Note (documented synthesis): Wave E.1 composes Gerber (2026, arXiv:2605.04124) Proposition 1 — Binder Taylor Series Linearization for IF representations of smooth functionals; explicitly derived for TwoStageDiD in the paper’s Appendix — with the Wave D Gardner GMM first-stage uncertainty correction (Butts 2021 §3.1 + Gardner 2022 §4) applied to SpilloverDiD’s ring-indicator stage-2 design. The composition is mechanical: SpilloverDiD’s Wave D Psi is aggregated to PSU level and passed to the audited Binder TSL meat helper. Survey weights enter via Hájek normalization at the gamma_hat solve, eps construction, and bread inversion. No reference software combines all ingredients; Wave E.2 extends with the Conley × survey product-kernel composition (cross-sectional
conley_lag_cutoff = 0) — see “Variance (Wave E.2)” subsection below; Wave E.2 follow-up further extends with the within-PSU serial Bartlett HAC forconley_lag_cutoff > 0— see “Variance (Wave E.2 follow-up)” subsection below.Note (warn-and-use-PSU for cluster + survey): when both
cluster=<col>andsurvey_design.psuare supplied with different groupings, the cluster argument emits aUserWarningand is overridden by PSU (mirrorsTwoStageDiD._resolve_effective_cluster). PSU is the design-relevant cluster on survey panels;cluster=<col>on SpilloverDiD is more often a spatial / unit-level label, so the design constraint wins. When both knobs are supplied with the same groupings, no warning fires and PSU still takes precedence (the inference is unchanged either way).Note (
SurveyDesign.subpopulation()+ warn-and-drop full-design retention, Wave E.3): whensurvey_designis built viaSurveyDesign.subpopulation()(or otherwise carries zero-weight padding rows) AND those zero-weight rows lose stage-1 FE support (warn-and-drop unit path), Wave E.3 preserves the full-domain resolved survey design:n_psu,df_survey, and the Binder TSL centering reflect the full domain. Both drop mechanisms (subpopulation weight=0 and warn-and-drop) are treated as zero-score padding rows — the meat-helper boundary at_compute_gmm_corrected_meatbuilds Psi on the SURVEY-FINITE-MASK subset of fit-sample inputs (finite_mask & (survey_weights > 0), which EXCLUDES zero-weight subpop rows so the FE drop-first basis is invariant to which units the subpop mask removed) and zero-pads it to full panel length via a newscore_pad_mask=survey_finite_maskkwarg AFTER construction but BEFORE kernel dispatch, while the kernel-dispatch arrays (cluster_ids,conley_*,resolved_survey) are passed at full length. See “Variance (Wave E.3)” subsection below.Note (saturated
df_survey = 0NaN-fail): whenlonely_psu="remove"removes all strata (single PSU per stratum),_compute_stratified_meat_from_psu_scoresreturns(_, var_computed=False, legit_zero=0). SpilloverDiD’s Wave E.1 path returns NaN meat with aUserWarningmatching"df_survey"so callers can pin viapytest.warns(UserWarning, match="df_survey"). This is a departure from TwoStageDiD (two_stage.py:2003-2005) which currently NaN-fails SILENTLY; Wave E.1 surfaces the diagnostic perfeedback_no_silent_failures.Note (
weights is Nonebit-identical fallback): the_iterative_fe_subsetweighted-bincount path falls through to the existing unweightednp.bincountcode whenweights is None. The Wave B/C/D no-survey contract is unchanged; the bit-identity testtest_a_uniform_weight_degenerate_matches_wave_dis the load-bearing check.Note (replicate-weight follow-up): BRR / Fay / JK1 / JKn / SDR are rejected upfront (
NotImplementedErrorwith"follow-up"matcher). Per Gerber (2026) Appendix A, the per-replicate IF-rescaling shortcut does NOT apply to TwoStageDiD-class estimators becausegamma_hatis weight-sensitive; correct support requires per-replicate full re-fit of stage 1 and stage 2.
Implementation: _compute_gmm_corrected_meat extended with survey_weights + resolved_survey kwargs at diff_diff/two_stage.py:56; new module-level helper _compute_binder_tsl_meat at diff_diff/two_stage.py wraps _compute_stratified_meat_from_psu_scores with the Wave E.1 NaN-fail + warning. _iterative_fe_subset weighted path at diff_diff/spillover.py:1382 (in-place extension, bit-identical fallback). SpilloverDiDResults extended with survey_metadata, n_psu, n_strata fields at diff_diff/results.py. Tests: TestSpilloverDiDWaveE1SurveyDesignHc1 + TestSpilloverDiDWaveE1SurveyDesignEventStudy at tests/test_spillover.py.
Variance (Wave E.2 — Conley × survey via stratified-Conley sandwich on PSU totals)#
vcov_type="conley" + survey_design= is now supported via a per-stratum Conley sandwich applied to PSU-aggregated Wave D Gardner GMM influence functions. SHIPPED in Wave E.2.
Note (documented synthesis): Wave E.2 composes Conley (1999) spatial-HAC with Gerber (2026, arXiv:2605.04124) Proposition 1 Binder TSL (the Wave E.1 foundation) and the Wave D Gardner GMM first-stage uncertainty correction (Butts 2021 §3.1 + Gardner 2022 §4) applied to SpilloverDiD’s ring-indicator stage-2 design. The composition is panel-aware — it preserves the library’s existing
conley_lag_cutoff = 0semantic (“within-period spatial only — exclude cross-period pairs”) atdiff_diff.conley._compute_conley_meat. Per-PSU centroids are computed ascentroid_g = mean over i in PSU g of conley_coords[i](panel-constant — PSU is a sampling unit with fixed location). For each periodt, SpilloverDiD’s per-obs Wave D IFpsi_iis aggregated to per-period PSU totalsS_psu_t[g] = sum_{i in PSU g, time t} psi_i; the within-stratum sandwich isM_h_t = (1 - f_h) * n_h/(n_h-1) * sum_{j,k in PSUs_h} K(d(centroid_j, centroid_k) / cutoff) * (S_psu_t[j] - S_bar_h_t)(S_psu_t[k] - S_bar_h_t)', where K is the Bartlett kernel (SpilloverDiD currently exposes Bartlett only and hardcodes it at the fit-call site; the survey helper’skernelparameter can also take"uniform", but exposing that on the SpilloverDiD constructor is a separate follow-up) anddis haversine / euclidean / callable perConleyMetric. Cross-stratum kernel weights are exactly zero by sampling design (strata are independence partitions). Total meat issum_t sum_h M_h_t. Cross-period spatial pairs are excluded by construction — the per-period loop aggregates only within-period observations into eachS_psu_t, matching the Wave Dconley_lag_cutoff = 0block decomposition. No reference software combines all three ingredients (Conley spatial-HAC + Binder TSL + Gardner GMM correction) on a two-stage influence function.Reduction semantics (load-bearing for tests):
Per-period sum invariant: the orchestrator’s panel-aware meat equals
sum_tof per-period within-stratum stratified-Conley sandwiches on per-period PSU totals. Pinned attests/test_spillover.py::TestSpilloverDiDWaveE2ConleySurveyDesign::test_b_panel_aware_per_period_sum_invariant(pure unit test on the orchestrator + helper composition).Single stratum (H = 1, FPC = inf): reduces to
sum_tplain Conley sandwich on per-period PSU totals via_compute_conley_meat(S_psu_t_centered, centroids, ...). Note this is NOT plain Conley on time-collapsed PSU totals — the per-period loop preserves the library’slag_cutoff = 0semantic.All PSUs singleton in their stratum +
lonely_psu="remove":df_survey = 0and the stratified-Conley meat NaN-fails (matches Wave E.1 saturation behaviour, withUserWarningtemplate “Wave E.2 stratified-Conley sandwich: df_survey = 0…”).Cross-stratum kernel weight is exactly zero (sampling-design assumption — no kernel pair crosses a stratum boundary).
Note (singleton-stratum
lonely_psu="adjust"FPC skip parity): when a stratum hasn_h = 1andlonely_psu="adjust", the new_compute_stratified_conley_meat_from_psu_scoreshelper mirrors the Binder helper’scontinue-skip-FPC pattern exactly (the FPC scale(1 - f_h) * n_h / (n_h - 1)would divide by zero withn_h = 1). The degenerate one-PSU kernelK = [[K(0)]] = [[1.0]]reduces tocentered.T @ centered, matching Binder’s singleton-adjust contribution bit-identically.Cluster + Conley + survey routing:
cluster=<col> + survey_design.psu + vcov_type="conley"coercescluster=<col>to PSU per Wave E.1’s_resolve_effective_clusterwarn-and-use-PSU pattern. The dispatch wrapper_compute_stratified_conley_meatintentionally does NOT threadcluster_idsinto the inner Conley kernel call — after PSU aggregation every PSU is its own cluster, so a cluster product kernel1{cluster_j == cluster_k}would be zero for allj != kand the cross-PSU kernel weights would be silently dropped. The Wave E.2 architectural choice: PSU-aggregation handles within-PSU clustering exactly; cross-PSU spatial dependence enters via the kernel; cross-stratum independence is exact.Restrictions / out-of-scope (Wave E.2):
Replicate-weight variance (BRR / Fay / JK1 / JKn / SDR) raises
NotImplementedError(inherits Wave E.1 gate; per-replicate full refit is separate follow-up scope).LinearRegression-side
vcov_type="conley" + survey_design=gate inLinearRegression.fit()(Conley + survey rejection block) remains. Note (open methodological question): weighted spatial-HAC under probability sampling is an open methodological question; no canonical extension of Conley (1999) exists for the combination — separate roadmap, not Wave E.DiagnosticReport routing for
SpilloverDiDResults(vcov_type="conley", survey_design=)is queued for a follow-up Wave F PR. The base_APPLICABILITY/_PT_METHODregistration prerequisite has now landed (parallel_trends/design_effect/heterogeneity; perfeedback_audit_diagnostic_report_wiring_before_claim); the Conley + survey-design combination still needs its own downstream-consumability validation before being claimed.
Implementation: new _compute_stratified_conley_meat_from_psu_scores helper in diff_diff/survey.py (parallel to existing Binder helper; 3-tuple (meat, variance_computed, legitimate_zero_count) return contract; per-stratum loop replaces the inner centered.T @ centered with _compute_conley_meat(centered, coords_h, cutoff, metric, kernel) cross-sectional mode). New dispatch wrapper _compute_stratified_conley_meat in diff_diff/two_stage.py (parallel to _compute_binder_tsl_meat; per-obs Psi → PSU aggregation via np.add.at + PSU centroid derivation via vectorized np.add.at sums / np.bincount counts + dispatch to survey helper; intentionally no cluster_ids parameter). _compute_gmm_corrected_meat conley branch extended at diff_diff/two_stage.py with if resolved_survey is not None routing to the new wrapper; the resolved_survey is None branch is bit-identical to Wave D no-survey Conley. Saturation NaN-fail mirrors Wave E.1 (UserWarning template “Wave E.2 stratified-Conley sandwich: df_survey = 0…”). Wave E.1 stage-1 weighted FE solver, finite_mask survey-array subsetting, df_survey threading to safe_inference call sites, bread weighting, and SpilloverDiDResults survey metadata are all inherited UNCHANGED — Psi construction is bit-identical regardless of vcov_type. Tests: TestSpilloverDiDWaveE2ConleySurveyDesign + TestSpilloverDiDWaveE2ConleySurveyDesignEventStudy at tests/test_spillover.py.
Variance (Wave E.2 follow-up — conley_lag_cutoff > 0 panel-block composition via spatial + serial Bartlett HAC)#
vcov_type="conley" + conley_lag_cutoff > 0 + survey_design= is now supported via panel-block stratified-Conley sandwich. SHIPPED in Wave E.2 follow-up.
Note (documented synthesis): Wave E.2 follow-up composes Wave E.2’s panel-aware stratified-Conley spatial sandwich (Conley 1999 spatial-HAC × Binder/Gerber 2026 stratified TSL × Wave D Gardner GMM correction) with within-PSU serial Bartlett HAC over time (Newey-West 1987 form, kernel weights
1 - |t-s|/(L+1)for|t-s| ≤ L, t ≠ s). The composition ismeat = meat_spatial + meat_serialwith disjoint index sets, exactly matching the no-survey panel-block decomposition atdiff_diff.conley._compute_conley_meat(Conley 1999 + Newey-West 1987 separable form, NOT Driscoll-Kraay 2D-HAC). The serial term aggregates per-period PSU totalsS_psu_t[g] = sum_{i in PSU g, time t} psi_ialong the time axis: for each stratumh,meat_serial_h = FPC_h_panel * sum_{g in stratum h} sum_{|t-s| ≤ L, t ≠ s, both periods present for PSU g} K_serial(|t-s|/(L+1)) * S_centered_t[g] @ S_centered_s[g]'whereS_centered_t[g] = S_psu_t[g] - S_bar_h(g)_tis per-period within-stratum centered (Binder TSL form — matches the spatial helper’s centering exactly), and|t-s|is computed on panel-wide dense time codes (matchesconley.py:940documented R deviation that mirrors Rconleyreg). Serial Bartlett kernel is hardcoded regardless ofconley_kernel(mirrors conley.py:951-965; the user-selectedconley_kernelgoverns the spatial kernel only). Total meat issum_t sum_h M_h_t + sum_h meat_serial_h. No reference software combines panel-block Conley + Binder TSL + Gardner GMM correction on a two-stage influence function.FPC convention (panel-wide per-stratum) — standalone Newey-West composition on stratified clusters: the serial sum aggregates within-PSU temporal correlation across all observed periods — it is a PANEL-level construct, not a period-level construct. The cluster set for the panel-level sum is the panel-wide set of PSUs in stratum
h, so the FPC denominator usesn_h_panel = |unique PSUs in stratum h across the active sample|andN_h = fpc_per_psu[first PSU in h](panel-constant by sampling design); serial term in stratum h scales by(1 - n_h_panel/N_h) * n_h_panel/(n_h_panel - 1). The spatial term keeps its existing per-period FPC unchanged (the period-tspatial sum IS a within-period stratified-cluster sandwich at one time index). For balanced panels with PSU present in every period,n_h_panel = n_h_tfor alltso the two FPC denominators converge; the difference surfaces under unbalanced panels. Standalone citation chain (Binder 1983 for FPC factor form, Gerber 2026 Prop 1 for Binder TSL composition with two-stage IF, Newey-West 1987 for serial Bartlett kernel weights, Conley 1999 for spatial kernel and panel-block decomposition) — deliberately NOT by analogy to the Binder helper’s per-stratum first-occurrence FPC convention at_compute_binder_tsl_meat, which is a cross-sectional application of the same principle.Centering asymmetry vs no-survey reference: the no-survey panel-block path at
conley.py:949-965uses RAW scores for the serial term (no centering) because it assumesE[scores] = 0under correct specification — centering is a no-op under that assumption. The survey-weighted Binder TSL form estimates the within-stratum mean and centers explicitly (textbook stratified-cluster sandwich; the per-period stratum mean enters via Binder’s finite-population variance derivation). Using raw scores in the survey case would inflate variance by twice the squared per-period stratum mean and would NOT reduce to the cross-sectional Wave E.2 form atlag = 0. The centering applied to each leg (tands) of the cross-period pair matches the spatial helper’s per-period centering exactly. Hand-check atTestSpilloverDiDWaveE2FollowupConleySurveyLagCutoff::test_c0_serial_centering_hand_check_raw_vs_centeredpins this contract.Reduction semantics (load-bearing for tests):
conley_lag_cutoff = 0orNone: bit-identical to shipped Wave E.2 ATT and scalar SE (orchestrator skips the serial helper invocation; the spatial loop + saturation guard + new PSD/finite guard still run on the spatial-only meat).assert_array_equalregression pin at test_a covers ATT + scalar SE; test_a2 mock-spy independently pins that the serial helper is NOT invoked at lag=0.conley_time is NoneorT = 1: serial helper short-circuits to zero meat (no cross-period pairs possible) — the degenerate panel-block path, NOT a saturation diagnostic.Single stratum (
H = 1,FPC = inf): spatial reduces tosum_tConley sandwich on per-period within-stratum-CENTERED PSU totals (NOT raw — at H=1 the centering still subtracts the per-period mean over all G PSUs); serial reduces to Newey-West Bartlett HAC on per-period within-stratum-CENTERED PSU totals (NOT raw scores; the survey-weighted form retains Binder TSL centering even at H=1). Both reductions carry the panel-wideG/(G-1)survey factor in lieu of FPC.Bandwidth → 0,
L > 0: spatial reduces tosum_tper-period within-stratum HC sandwich on PSU totals; serial term unchanged (separable form).All PSUs singleton in stratum +
lonely_psu="remove":df_survey = 0and the meat NaN-fails (saturation warning template “Wave E.2 stratified-Conley sandwich” covers both cross-sectional and panel-block cases).Cross-stratum kernel weight is exactly zero on the serial term too (the within-PSU loop is necessarily within-stratum).
Singleton-stratum
lonely_psu="adjust"panel-wide mean asymmetry: for the serial helper,_global_psu_meanis the panel-wide mean of per-period PSU totals (averaged over all(g, t)withpresent[g, t]), NOT the per-period within-stratum mean used by the spatial helper. The scope difference reflects the serial term’s panel-level nature: a singleton stratum at the panel level has no within-stratum cross-PSU variation to demean against, so the only meaningful centering target is the panel-wide PSU mean. Thecontinue-skip-FPC pattern matches the spatial helper atsurvey.py:2007-2017exactly to avoid divide-by-zero onn_h_panel = 1.Restrictions (Wave E.2 follow-up):
Requires an effective PSU: either an explicit
survey_design.psuOR acluster=<col>argument that gets injected as the effective PSU per Wave E.1’s_inject_cluster_as_psurouting. No-effective-PSU survey designs (weights-only / strata-only WITHOUT a cluster fallback) raiseNotImplementedErroratSpilloverDiD.fitpost-resolution because under the pseudo-PSU = obs-index fallback each pseudo-PSU appears in exactly one period — the per-PSU serial cross-period loop would silently contribute zero. Routing the serial loop toconley_unitwould mix IF allocators with the spatial term’s pseudo-PSU aggregation (the user-confirmed methodology pins a single IF allocator); fail-closed perfeedback_no_silent_failuresuntil a no-effective-PSU derivation is queued (tracked in TODO.md).Replicate-weight variance (BRR / Fay / JK1 / JKn / SDR) raises
NotImplementedError(inherits Wave E.1 gate).LinearRegression-side
vcov_type="conley" + survey_design=gate inLinearRegression.fit()(Conley + survey rejection block) remains. Note (open methodological question): weighted spatial-HAC under probability sampling is an open methodological question; no canonical extension of Conley (1999) exists for the combination — separate roadmap, not Wave E.DiagnosticReport routing for
SpilloverDiDResults(vcov_type="conley", conley_lag_cutoff > 0, survey_design=)is queued for Wave F follow-up. Base_APPLICABILITY/_PT_METHODregistration has landed (perfeedback_audit_diagnostic_report_wiring_before_claim); the panel-block Conley + survey-design combination still needs its own downstream-consumability validation before being claimed.
Implementation: new sibling helper _compute_stratified_serial_bartlett_meat in diff_diff/two_stage.py (parallel to the Wave E.2 spatial orchestrator; ~200 LoC; three-mode singleton-stratum branching with FPC scaling inside the multi-PSU branch to avoid divide-by-zero; panel-wide dense time codes for the lag math). Orchestrator _compute_stratified_conley_meat signature extended with conley_lag_cutoff: Optional[int] = None; spatial loop unchanged; serial helper called after spatial loop when conley_lag_cutoff > 0; saturation NaN-fail accounting merges both terms’ (variance_computed, legitimate_zero) flags. Dispatch in _compute_gmm_corrected_meat conley branch threads conley_lag_cutoff through to the orchestrator. Spillover-side gate at spillover.py:2210 deleted (Wave E.2 era NotImplementedError for lag>0 + survey). Stage-1 weighted FE solver, finite_mask survey-array subsetting, df_survey threading, bread weighting, and SpilloverDiDResults survey metadata are all inherited UNCHANGED — Psi construction is bit-identical regardless of vcov_type or lag. Tests: TestSpilloverDiDWaveE2FollowupConleySurveyLagCutoff + TestSpilloverDiDWaveE2FollowupConleySurveyLagCutoffEventStudy at tests/test_spillover.py.
Note (shared helpers): the within-PSU serial Bartlett kernel matrix construction and the post-meat finite/PSD guard are shared with the no-survey reference path via _serial_bartlett_kernel_matrix and _validate_meat_psd in conley.py. The survey orchestrator’s per-PSU serial loop (_compute_stratified_serial_bartlett_meat) and the orchestrator’s combined-meat PSD guard call the same helpers as _compute_conley_meat, so survey and no-survey paths cannot drift on kernel weights or PSD threshold (< -1e-12).
Variance (Wave E.3 — SurveyDesign.subpopulation() / warn-and-drop full-design retention via zero-pad scores)#
SurveyDesign.subpopulation()-derived designs AND warn-and-drop fits now preserve the full-domain resolved survey design for inference bookkeeping. n_psu, n_strata, df_survey, and the Binder TSL per-stratum centering reflect the FULL domain rather than the post-finite_mask fit sample. SHIPPED in Wave E.3.
Note (documented synthesis — library-convention adoption, NOT new methodology): Wave E.3 adopts the canonical “zero-pad scores to full panel + retain full-design resolved survey” pattern from R’s
survey::svyrecvarapplied to asubset()design (Lumley 2010 §2.5 “Domains and subpopulations”). The same convention is already established indiff_diff/imputation.py:2175-2183(PreTrendsImputation lead regression — Omega_0 scores zero-padded back to full panel length so PSU/strata structure is maintained) anddiff_diff/prep.py:1401-1432(DCDH cell variance — IF zero-padded outside the cell, preserving full strata/PSU structure). Wave E.3 propagates the same convention to SpilloverDiD’s Wave E.1 Binder TSL × Wave D Gardner GMM × Wave E.2/follow-up stratified-Conley + serial Bartlett meat. The “documented synthesis” framing applies because no SpilloverDiD-specific derivation is required — Psi is constructed on the survey-finite-mask subset (finite_mask & (survey_weights > 0)) and then explicitly zero-padded back to full panel length at the meat-helper boundary via the newscore_pad_mask=survey_finite_maskkwarg, so excluded rows contribute exactly zero score by construction. The meat helpers’ per-stratum centering + FPC scaling produce the same numeric output as if those rows had been correctly zero-padded inside the helpers.Mechanical realization (one new
_compute_gmm_corrected_meatkwarg): the gamma_hat / Psi construction stays on the SURVEY-FINITE-MASK inputs (X_1_sparse_fit,X_10_sparse_fit,eps_10_fitbuilt onsurvey_finite_mask = finite_mask & survey_weights > 0;X_2_kept_gamma,eps_2_fit_gamma,survey_weights_fit_gammaprojected from the fit-sample frame down to survey_finite_mask) so the drop-first stage-1 FE column space is bit-identical to the pre-E.3 path — critical because_build_butts_fe_design_csrre-factorizes inputs viapd.factorizeand drops the first unit / time code; if a warn-dropped unit sorts first, the full-length build would produce a DIFFERENT column space (all-zeroX_10column for the dropped unit → rank-deficientX_10' W X_10→ lstsq fallback → differentgamma_hat). The full-domain zero-pad invariant is delivered by:score_pad_mask=survey_finite_mask: a new optional kwarg on_compute_gmm_corrected_meat. When provided, the helper zero-pads the survey-finite-maskPsi(built on rows where BOTHfinite_mask == TrueANDsurvey_weights > 0) to full panel length AFTER construction but BEFORE kernel dispatch viaPsi_padded = np.zeros((n_full, p_2)); Psi_padded[score_pad_mask] = Psi. Warn-dropped rows have positive survey weights but are physically removed from the fit sample (NaNy_tilde→finite_mask=False). Subpopulation rows have weight=0 via_subpop_weightand are explicitly excluded by the(survey_weights > 0)filter; the prior R6 fix added this filter because otherwise_build_butts_fe_design_csr’spd.factorizecompaction would include zero-weight rows in the FE drop-first basis ordering and silently shiftgamma_hat. Both drop mechanisms are zero-padded back into the meat boundary by the samescore_pad_maskstep. Either way, excluded rows contribute exactly zero score at the meat boundary.Kernel-dispatch arrays at FULL length:
cluster_ids_full,coord_array_full,np.asarray(time_vals),np.asarray(unit_vals),resolved_surveyare passed un-subsetted so the meat helpers (Binder TSL / stratified-Conley / serial Bartlett) see the full-domain PSU / strata / centroid / time geometry.Resolved survey design is NOT subsetted via
finite_mask— the prior Wave E.1replace(resolved_survey, weights=resolved_survey.weights[finite_mask], ...)block atspillover.py:2845-2896is removed.Conley validator length:
_validate_conley_kwargsattwo_stage.py:222-237readsn_for_conley = len(score_pad_mask)when the kwarg is set, so the Conley shape checks see the full-length geometry (matches the post-pad Psi length the kernel actually consumes).
gamma_hatsolve invariance: the gamma_hat solve attwo_stage.py:244-269operates on fit-sample inputs — bit-identical to the pre-E.3 path. Pre-E.3 baseline-parity test atTestSpilloverDiDWaveE3SubpopulationFullDesign::test_cpins this via fixed goldens.Bread sandwich invariance: the bread
A_22^{-1} = (X_2_kept' W X_2_kept)^{-1}atspillover.py:3187-3214still uses the fit-lengthX_2_keptbecause the OLS solve operates on the active sample. MathematicallyA_22_full = X_2_full' W_full X_2_fullequalsA_22_keptbecause the zero-weight rows contribute zero to the cross-product.Conley centroid scope: under Wave E.3 the survey path passes the FULL-LENGTH
coord_array_full(nofinite_maskorweight > 0filter) to_compute_stratified_conley_meat, which then derives panel-constant per-PSU centroids from all coord rows present in the full panel. Zero-weight subpopulation rows AND warn-and-dropped rows both pin their PSU’s geometric centroid — matches Rsvyrecvar(the PSU’s geometric footprint is fixed by sampling design, not by the analyst’s domain filter or by stage-1 FE identifiability). On the no-survey path the conley helper still receives the fit-lengthcoord_array_fit(preserves the pre-E.3 Wave E.2 R4 fix invariant for the no-survey case).Panel-block conley + subpopulation (Wave E.2 follow-up cross-reference): the panel-block
n_h_panelFPC convention (panel-wide unique PSU count per stratum, see Wave E.2 follow-up subsection above) is preserved under subpopulation — zero-weighted PSU-period cells still count towardn_h_panelbecause the design retains those PSUs at full length. End-to-end smoke attest_g1/test_g2(conley + lag>0 + subpopulation × explicit-PSU and cluster-injection branches) covers the path; a dedicatedn_h_panelhand-computation test on a fixture with mixed zero/positive-weight periods per PSU is deferred to the Wave E.3 parity follow-up.Reduction semantics:
When
finite_mask.all() == TrueAND all weights> 0(no zero-pad needed): bit-identical to shipped Wave E.2 / E.2-follow-up baseline.assert_array_equalregression pin attest_c.A2 invariant: warn-and-drop and subpopulation drops are treated identically (both apply the zero-pad mechanism). Test class explicitly covers both drop mechanisms in test (
a), (b), (i2). The “both mechanisms compose cleanly” case (subpop-excluded row that is ALSO warn-and-dropped) is locked at test (i2).Subpopulation parity vs upstream-subset:
df_surveymatches the full domain (n_psu_full - n_strata_full) regardless of how many rows the subpopulation mask excludes. SE may differ fromfit(data[mask], ..., survey_design=plain_design)by design (subpopulation retains zero-padded PSU geometry; subset drops PSUs entirely).
Restrictions / unchanged from Wave E.1/E.2/follow-up:
Replicate-weight variance (BRR / Fay / JK1 / JKn / SDR) raises
NotImplementedError(Wave E.1 gate atspillover.py:2400inherited; per-replicate refit + subpopulation is a separate follow-up).TwoStageDiD’s analogous always-treated design-subset pattern was shipped in a follow-up PR (TwoStageDiD Wave E.3 parity) — see “Note (documented synthesis — Wave E.3 parity, full-domain survey design under always-treated drop)” in the TwoStageDiD section above. The two estimators now share the full-design retention contract; the trigger differs (TwoStageDiD: always-treated unit detection; SpilloverDiD:
finite_maskwarn-and-drop / subpopulation zero-weight rows) but the invariant is identical (zero-pad scores at the meat-helper boundary; retain full-domainresolved_survey.psu / strata / fpc).df_for_inferenceatspillover.py:3229-3234readsresolved_survey_fit.df_surveywhich is the full-domain value under Wave E.3 (sinceresolved_survey_fit = resolved_survey). All foursafe_inferencecall sites (aggregatetau_total, per-ringdelta_j, event-study per-event-timetau_k/delta_jk, scalarattlincom) inherit the full-domain df.
Implementation: spillover.py:2845-2896 design-subset block deleted; survey_weights_fit = survey_weights[finite_mask] retained for the stage-2 OLS solve (which still operates on the fit sample); cluster_ids_full[finite_mask] subset dropped on the survey path (cluster_ids_fit = cluster_ids_full under survey). The _compute_gmm_corrected_meat call at spillover.py:3163 is rewired to receive SURVEY-FINITE-MASK gamma_hat-construction inputs (X_1_sparse_fit, X_10_sparse_fit, eps_10_fit built on survey_finite_mask; X_2_kept_gamma, eps_2_fit_gamma, survey_weights_fit_gamma projected from the fit-sample frame down to survey_finite_mask) plus FULL-LENGTH kernel-dispatch arrays (cluster_ids_for_meat, conley_coords_for_meat, conley_time_for_meat, conley_unit_for_meat, resolved_survey_fit) plus the new score_pad_mask=survey_finite_mask kwarg; no-survey path passes score_pad_mask=None and uses fit-length variables throughout (bit-identical to pre-E.3). _compute_gmm_corrected_meat at two_stage.py adds one new optional kwarg score_pad_mask: Optional[np.ndarray] = None and one post-Psi-construction zero-pad block; the _validate_conley_kwargs call uses n_for_conley = len(score_pad_mask) when the kwarg is set. Within-unit-constancy validator at spillover.py:2913 updated to operate on full-length unit array (matches full-length cluster array under Wave E.3). Second compute_survey_metadata recompute at spillover.py:2954-2959 now uses full-length raw_w (no [finite_mask] subset). Tests: TestSpilloverDiDWaveE3SubpopulationFullDesign + TestSpilloverDiDWaveE3SubpopulationFullDesignEventStudy at tests/test_spillover.py.
Edge cases (from paper Section 3.2 / Discussion):
# |
Edge case |
Handling |
|---|---|---|
1 |
No nearby control units (Assumption 5(ii) fails) |
Hard error via |
2 |
|
User responsibility; sensitivity analysis across |
3 |
|
Same as #2; bias-variance trade-off (paper page 13-14) |
4 |
Single-ring |
Use multiple rings (Equation 6 multi-ring spec); supported by passing more breakpoints |
5 |
Spillovers extend past largest ring |
User inspects outermost |
6 |
Additive spillovers in count of nearby treated |
|
7 |
Staggered + negative Goodman-Bacon weights |
Two-stage Gardner methodology avoids this (paper page 22) |
Restrictions / deferred features:
event_study=TrueSHIPPED in Wave C — see Event-study mode subsection above. Emitsatt_dynamic, MultiIndexspillover_effects, and a TwoStageDiD-compatibleevent_study_effectsdict alias.survey_design=forvcov_type ∈ {"hc1"}(pluscluster=<col>for CR1) SHIPPED in Wave E.1 — see “Variance (Wave E.1)” subsection below. Threads Hájek-normalized survey weights through stage-1 FE estimation, gamma_hat solve, eps construction, and bread inversion; aggregates the Wave D Psi to PSU totals and routes through the audited_compute_stratified_meat_from_psu_scoresBinder TSL meat helper.vcov_type="conley"combined withsurvey_design=SHIPPED in Wave E.2 for cross-sectional Conley (conley_lag_cutoff = 0) — see “Variance (Wave E.2)” subsection below (stratified-Conley sandwich on PSU totals). Wave E.2 follow-up adds the panel-block composition (conley_lag_cutoff > 0) via spatial + serial Bartlett HAC — see “Variance (Wave E.2 follow-up)” subsection below.SurveyDesign.subpopulation()and warn-and-drop full-design retention via zero-pad scores SHIPPED in Wave E.3 — see “Variance (Wave E.3)” subsection below (matches Rsurvey::svyrecvar(subset())+ in-library precedent atimputation.py:2175-2183andprep.py:1401-1432). Replicate-weight variance (BRR / Fay / JK1 / JKn / SDR) raisesNotImplementedError— Gerber (2026) Appendix A notes the IF-reweighting shortcut does NOT apply to TwoStageDiD-class estimators becausegamma_hatis weight-sensitive; correct support requires per-replicate full re-fit and is queued as a follow-up.covariates=raisesNotImplementedError— Gardner-style stage-1 residualization not yet wired through; planned follow-up.ring_method="count"not exposed — only the nearest-treated-ring specification.vcov_type∈ {"hc2","hc2_bm","classical"} raisesNotImplementedError—hc2/hc2_bmbecause current stage-2 inference uses generic residual df rather than per-coefficient Bell-McCaffrey / CR2 DOF;classicalbecause the Wave D Gardner GMM first-stage correction has not been derived for the classical homoskedastic variance (different meat structuresigma_hat^2 * (X_10' X_10)vs the Wave D IF outer productPsi' Psi). Use"hc1"or"conley", or pair withcluster=for CR1 — all three apply the Wave D GMM correction.rings[0]must equal 0 — the partition must cover treated locations (d_it = 0belongs to Ring 1). Rings starting at a nonzero inner edge would leave units in0 <= d_it < rings[0]as exposed-but-unmodeled, silently biasing the estimator. Validator rejects such inputs.Balanced panel required (Wave B MVP) — every unit must observe every period. An unbalanced (unit, time) Ω₀ bipartite graph can produce disconnected FE components and unidentified stage-1 residuals on treated rows. Exact graph-connectivity-based identification (which would relax this to a strictly weaker condition) is queued as a follow-up extension. Validator rejects unbalanced inputs.
One row per
(unit, time)cell required — duplicate cells silently re-weight stage-1 FE estimation AND stage-2 OLS. Validator rejects duplicate cells.Data-driven
d_barselection (Butts 2021b / Butts 2023 JUE Insight) not exposed.Gardner GMM first-stage correction at stage 2 SHIPPED in Wave D — see “Variance (Wave D)” subsection above. Applies unconditionally across HC1 / Conley / cluster.
Implementation: diff_diff/spillover.py. Public class SpilloverDiD; result class SpilloverDiDResults(DiDResults) at diff_diff/results.py. Tests at tests/test_spillover.py; DGP factories tests/_dgp_utils.py::generate_butts_nonstaggered_dgp / generate_butts_staggered_dgp (satisfy Butts Assumptions 1/3/5/7 by construction).
ConleySpatialHAC#
Primary source: Conley, T. G. (1999). GMM Estimation with Cross-Sectional Dependence. Journal of Econometrics 92(1), 1-45. DOI: 10.1016/S0304-4076(98)00084-0
Secondary sources:
Andrews, D. W. K. (1991). Heteroskedasticity and autocorrelation consistent covariance matrix estimation. Econometrica 59(3), 817-858.
Düsterhöft, C. (2021). conleyreg: Estimations using Conley Standard Errors. CRAN R package, https://github.com/cdueben/conleyreg. Our parity benchmark target.
Colella, F., Lalive, R., Sakalli, S. O., & Thoenig, M. (2019). Inference with Arbitrary Clustering. IZA DP No. 12584. Stata
acregreference implementation; cited as the parallel canonical implementation in the Stata ecosystem (not parity-tested here).
Scope: Spatial heteroskedasticity-and-autocorrelation-consistent standard errors for OLS when residuals are spatially (and optionally temporally) correlated. Extends White (1980) HC0 by allowing pairwise correlation that decays with geographic distance, plus a within-unit Newey-West-style Bartlett temporal HAC on panel data.
Note (rank-guarded design bread): The spatial-HAC sandwich bread
(X'WX)^{-1}(_compute_conley_vcov,conley.py) is inverted by the shared_rank_guarded_inv(diff_diff/linalg.py). A near-singular design Gram previously returned a garbage inverse (~1e13) and an exactly singular one raisedValueError; the bread now rank-reduces to a finite SE on the identified subspace (NaN only at rank 0) and warns, matching the other structural bread guards. A dropped (unidentified) regression coefficient is reported with NaN SE (its row/col in the returned vcov), not the zero-filled0. Behavior change: a rank-deficient design (collinear regressors) no longer raises — it rank-reduces with a warning. The well-conditioned path is unchanged (np.linalg.solve(A, I))._rank_guarded_invis imported lazily inside the function becauselinalgimportsconley(one-way), so a top-level import would be circular. See the CallawaySantAnna “rank-guarded IF standard errors” Note.
Two operating modes:
Cross-sectional (Phase 1): Pass
vcov_type="conley"plusconley_coords(n × 2 array) andconley_cutoff_kmon directcompute_robust_vcov/LinearRegression.Panel block-decomposed (Phase 2): Additionally pass the three co-required kwargs
conley_time(n-length array),conley_unit(n-length array), andconley_lag_cutoff=<int>(non-negative).MultiPeriodDiDandTwoWayFixedEffectsauto-deriveconley_timeandconley_unitfrom the estimator’stime/unitcolumn-name arguments; onlyconley_lag_cutoffis set on the constructor.
Panel API restrictions (Phase 2 + Wave A):
DifferenceInDifferences(vcov_type="conley")is supported (Wave A #118): passunit=<col>as a fit-time argument tofit(...)(NOT on__init__; unused unless Conley is set). DiD inherits the same panel block-decomposed sandwich as MPD/TWFE on the two-period design.SyntheticDiD(vcov_type="conley")raisesTypeError. SyntheticDiD uses bootstrap/jackknife/placebo variance, not the analytical sandwich.TWFE’s default auto-cluster on the Conley path is silently dropped (no combined kernel from auto-cluster). Explicit
cluster=<col>+ Conley enables the combined spatial + cluster product kernel (Wave A #119; see “Combined spatial + cluster product kernel” subsection below). On the panel path the validator enforces that cluster membership is constant within each unit across periods.inference="wild_bootstrap"+ Conley raises (wild bootstrap is a separate inference path that does not consume the analytical sandwich).
Note (DiD vs TWFE cluster asymmetry on the Conley path): TWFE auto-clusters
at the unit level by default, so combining with Conley silently drops the
auto-cluster (otherwise every between-unit pair would be zeroed out, defeating
the spatial pooling). To opt into the combined kernel, the user must pass an
explicit cluster=<col> that is constant within each unit (typically an
above-unit grouping like region). DiD has no auto-cluster — combining with
Conley is fully opt-in: absent cluster=, pure Conley spatial HAC applies;
with cluster=, the combined kernel applies. This asymmetry preserves the
existing TWFE auto-cluster contract while making the cluster intent explicit
on the Conley path.
Variance estimator — cross-sectional (Phase 1, Conley 1999 Eq 4.2 in pairwise-distance form, OLS specialization):
Var̂(β) = (X'X)^{-1} · ( Σ_{i,j} K(d_ij / h) · X_i ε_i ε_j X_j' ) · (X'X)^{-1}
where d_ij is the geographic distance, h is the user-supplied bandwidth
(conley_cutoff_km), and K(·) is the kernel. The i = j diagonal contributes
the standard White HC0 term X_i ε_i² X_i'.
Variance estimator — panel block-decomposed (Phase 2, R conleyreg form
with lag_cutoff > 0):
XeeX_spatial = Σ_t Σ_{i,j∈units} K_space(d_ij/h) · X_{i,t} ε_{i,t} ε_{j,t} X_{j,t}'
XeeX_serial = Σ_u Σ_{|t-s|≤L,t≠s} (1 - |t-s|/(L+1)) · X_{u,t} ε_{u,t} ε_{u,s} X_{u,s}'
Var̂(β) = (X'X)^{-1} · ( XeeX_spatial + XeeX_serial ) · (X'X)^{-1}
The spatial part sums within each time period only (cross-time spatial
pairs are NOT paired). The serial part sums within each unit only with
lag = 0 (same-time) excluded to avoid double-counting the diagonal already
in the spatial component. The temporal kernel is hardcoded Bartlett-style
regardless of conley_kernel (matches conleyreg::time_dist.cpp). This is
NOT a multiplicative product kernel — verified against R conleyreg at
~1e-14 on the panel parity fixtures.
Note (deviation from R-symmetric API): R conleyreg’s kernel argument
controls ONLY the spatial component; the temporal kernel is unconditionally
the Bartlett form (1 - |lag|/(L+1)) (visible in conleyreg::time_dist.cpp
where the formula is written explicitly with no bartlett flag passed
through). diff-diff matches this asymmetry exactly for R parity. Independent
temporal kernel choice would be a follow-up API extension if user demand
emerges.
Note (deviation from R conleyreg literal: time-label normalization):
R conleyreg uses raw time values directly in the lag computation
(time_dist.cpp’s t_diff = abs(times - times[i])). On non-dense time
encodings (e.g., time = 202012, 202101 for monthly panels), the raw
difference is 89, so a lag_cutoff=1 request silently drops valid lag-1
serial pairs in R. diff-diff normalizes time to dense panel-period codes
0..T-1 via np.unique(return_inverse=True) before the lag computation,
so conley_lag_cutoff always counts panel periods regardless of label
encoding (int year, YYYYMM, datetime64, pd.Period, strings). On dense
integer labels (the parity-test convention), the two paths produce
bit-identical results. For non-dense encodings, diff-diff is the more
robust default; pass time as a dense integer index for bit-exact R parity.
Kernel functions:
conley_kernel="bartlett"(default):K(u) = max(0, 1 - |u|)evaluated on the pairwise distanced_ij/h. The radial 1-D form on pairwise distance, matching Rconleyreg, Stataacreg(Colella et al. 2019), and Hsiang (2010).conley_kernel="uniform":K(u) = 1{|u| ≤ 1}. Conley 1999 page 11; spectral window negative in regions (footnote 11).
Note (deviation / source specialization): Conley 1999’s explicitly PSD-guaranteed Bartlett formula (Eq 3.14, page 12) is the 2-D separable product window K(j, k) = (1 - |j|/L_M)(1 - |k|/L_N) indexed on a lattice. The 1-D radial form on pairwise distance that diff-diff implements (matching R conleyreg) is a practitioner specialization that is not explicitly written in the paper and is therefore not formally PSD-guaranteed. We apply the same indefiniteness check to both kernels: a UserWarning is emitted if any meat eigenvalue is materially negative (< -1e-12).
Note (shared helpers): the within-unit serial Bartlett kernel matrix construction and the finite/PSD guard are shared with the SpilloverDiD Wave E.2 follow-up survey path via _serial_bartlett_kernel_matrix and _validate_meat_psd in conley.py. The no-survey reference path (_compute_conley_meat) and the survey panel-block path (_compute_stratified_serial_bartlett_meat’s caller in two_stage.py) exercise the same kernel weights and PSD threshold (< -1e-12); see the parallel note in the SpilloverDiD Wave E.2 follow-up section below.
Distance metrics:
conley_metric="haversine"(default): great-circle in km using Earth’s mean radius (6371.01 km, matching Rconleyreg). Validateslat ∈ [-90, 90],lon ∈ [-180, 180].conley_metric="euclidean": Euclidean from projected coords. Skips lat/lon range checks (user owns the projection’s units).conley_metric=callable(coords1, coords2) -> n×n array: custom distance for non-geographic networks.
Note: No default bandwidth. Conley 1999 does not propose a plug-in selector;
the empirical example (Section 5) uses a sensitivity grid. Implementation
requires conley_cutoff_km to be supplied; None raises ValueError (per the
project’s no-silent-failures rule). Practitioners should rerun on a coarse cutoff
grid (e.g., 50, 100, 200, 500 km) and report the SE range, mirroring Conley’s
Section 5 robustness check.
Note (FWL composability under TWFE): Conley’s meat depends only on
scores X_i·ε_i, which FWL preserves under within-transformation. The
block-decomposed sandwich applied to FE-residualized scores produces the
same meat as the full-dummy-expansion design (unlike vcov_type="hc2" /
vcov_type="hc2_bm", whose leverage corrections depend on the full hat
matrix). TwoWayFixedEffects(vcov_type="conley", conley_lag_cutoff=...)
threads the within-transformed scores plus the original time / unit
vectors into the same helper that LinearRegression uses.
Note (R conleyreg parity): diff-diff’s Conley implementation matches R
conleyreg (Düsterhöft 2021, CRAN v0.1.9) to ≤ 1e-6 on six benchmark
fixtures (benchmarks/data/r_conleyreg_conley_golden.json): three
cross-sectional (Phase 1) plus three panel fixtures with lag_cutoff > 0
(Phase 2). Earth radius constant is 6371.01 km (mean radius), matching
conleyreg::haversine_dist. Regeneration:
cd benchmarks/R && Rscript generate_conley_golden.R.
Combined spatial + cluster product kernel (Wave A #119)#
When cluster_ids is supplied alongside vcov_type="conley", the meat
applies the combined product kernel:
K_total[i, j] = K_space(d_ij/h) · 1{cluster_i = cluster_j}
On the panel block-decomposed path the cluster indicator multiplies BOTH the within-period spatial sandwich AND the within-unit serial sandwich:
XeeX_spatial = Σ_t Σ_{i,j∈units} K_space(d_ij/h) · 1{c_{i,t}=c_{j,t}} · X_{i,t} ε_{i,t} ε_{j,t} X_{j,t}'
XeeX_serial = Σ_u Σ_{|t-s|≤L, t≠s} (1 - |t-s|/(L+1)) · 1{c_{u,t}=c_{u,s}} · X_{u,t} ε_{u,t} ε_{u,s} X_{u,s}'
Cluster-time-invariance contract: on the panel block-decomposed path
the validator REQUIRES that cluster_ids be constant within each unit
across periods. The within-unit serial sandwich’s cluster mask is then
trivially all-ones, and the math simplifies to the bare serial Bartlett
HAC weighted by the spatial mask only. If a unit’s cluster changes
across periods (e.g. a unit migrating between regions), the within-unit
mask would zero out adjacent-time pairs that should contribute,
producing a methodologically-muddled meat — the validator raises
ValueError naming the violating unit(s). The cross-sectional path
has no time dimension, so no invariance constraint applies.
Note: R conleyreg does not support a combined spatial + cluster
product kernel; this is a diff-diff convention validated by two limit
fixtures rather than R parity:
All-unique-clusters reduction: when every observation is in its own cluster, the cluster mask is the identity, and the meat reduces to the diagonal HC0 contribution
Σ_i X_i ε_i² X_i'.Huge-cutoff reduction: when
conley_cutoff_kmis large enough thatK_space = 1on every pair, the meat reduces to the pure within-cluster sumΣ_g X_g' ε_g ε_g' X_g(CR1 without the Liang-Zeger small-sample correction). This exact reduction holds only forconley_kernel="uniform"(K_uniform(u) = 1for|u| ≤ 1). The Bartlett kernel givesK_bartlett(u) = 1 - |u|, which is strictly less than 1 for0 < |u| ≤ 1, so the huge-cutoff limit under Bartlett is asymptotic (K → 1ascutoff → ∞only at finite off-diagonal distances), not exact at any finite cutoff. The fixture anchor uses uniform for an exact identity check.
The combined-kernel meat is well-defined either way; the two fixture limits anchor the math, and the panel time-invariance contract guarantees the serial component is unaffected by the cluster choice.
Performance / scale (Wave A #120)#
A sparse k-d-tree fast path auto-activates for the spatial Bartlett meat
when n > _CONLEY_SPARSE_N_THRESHOLD (default 5,000) AND conley_metric
is "haversine" or "euclidean" (NOT a callable) AND conley_kernel
is "bartlett". The dense O(n²) distance matrix is replaced with a CSR
sparse kernel matrix built from scipy.spatial.cKDTree.query_ball_tree
neighbor queries.
Why bartlett-only: Bartlett at u = 1.0 returns exactly 0.0, so
pairs at exactly the cutoff distance contribute zero to the meat — the
sparse path can safely drop them. Uniform at u = 1.0 returns 1.0,
which would require a closed-interval query semantic that the haversine
chord-projection roundoff cannot reliably preserve. The auto-toggle
falls back to the dense path for uniform regardless of n. Callable
metrics also fall back (kd-tree needs a vectorizable Minkowski distance).
For haversine, the kd-tree operates on a 3-D unit-sphere projection
(x = cos(lat)cos(lon), y = cos(lat)sin(lon), z = sin(lat)) with the
chord radius matching the arc-length cutoff; the exact great-circle
distance is recomputed only for in-range neighbors before the kernel
evaluation. The numerical tolerance vs the dense path is typically
~1e-12 in absolute terms; R parity at atol=1e-6 is preserved on the
existing fixtures under the auto-toggle.
The _CONLEY_DENSE_OOM_WARN_N = 20_000 constant remains as a separate
warning threshold for the dense fallback (callable metrics, uniform
kernel) where O(n²) memory is at material risk. The two thresholds are
independent — sparse auto-toggle at 5,000 is a compute optimization;
dense OOM warning at 20,000 is a memory caution.
Density gate (_CONLEY_SPARSE_DENSITY_THRESHOLD = 0.3): the sparse
path’s CSR storage carries ~12 bytes per non-zero (data + indices +
indptr) vs 8 bytes per cell for dense float64. The memory crossover
is at ~67% density, but at high density the CSR overhead loses its
advantage well before that. The sparse helper measures actual neighbor
density via cKDTree.count_neighbors (shares tree traversal with
query_ball_tree, no extra allocation) and falls back to the dense
path with a UserWarning when neighbor density exceeds 30%. This
prevents the “sparse” path from silently using MORE memory than dense
when cutoffs are large relative to the data span (e.g. cutoffs above
half-Earth circumference on a global panel, or unit-scale cutoffs on
a clustered dataset). Users see one line explaining the fallback so
they can either reduce conley_cutoff_km or accept the dense path.
Callable conley_metric validation (Wave A #123)#
When conley_metric is a user-supplied callable, the result is
validated at the boundary via _validate_callable_metric_result:
Result casts to a float64 array (raises
ValueErrorif not).Shape is exactly
(n, n)(raises if mismatched).All entries are finite (NaN/inf raises).
All entries are non-negative (negative distances raise).
Symmetric to within
atol=1e-10(asymmetric matrix raises).Zero diagonal:
|d(i, i)| ≤ 1e-10for alli(nonzero diagonal raises).
Each failure produces a ValueError naming the violated invariant.
Sub-tolerance asymmetry (eps-level roundoff) is accepted. The zero-
diagonal invariant is load-bearing for the Conley sandwich: the
i = j term contributes K(d_ii / h) · X_i ε_i² X_i', which must
reduce to the HC0 diagonal X_i ε_i² X_i' (i.e., K(0) = 1). A
callable with positive self-distance would attenuate the HC0 term
by K(d_ii / h) < 1 and silently misstate Conley SEs. Built-in
metrics ("haversine", "euclidean") satisfy this by construction.
Edge cases / restrictions:
DifferenceInDifferences(vcov_type="conley")is supported (Wave A #118): passunit=<col>tofit(...)(NOT on__init__; unused unless Conley is set; not part ofget_params()/set_params()).MultiPeriodDiD/TwoWayFixedEffects/DifferenceInDifferences+ vcov_type="conley"withoutconley_lag_cutoff→ValueError.MultiPeriodDiD/DifferenceInDifferences(vcov_type="conley")withoutunit=at fit-time →ValueError.TwoWayFixedEffects(vcov_type="conley", cluster=<col>)is supported (Wave A #119): combined spatial + cluster product kernel applies. The cluster must be time-invariant within each unit on the panel path (validator-enforced). TWFE’s default auto-cluster is silently dropped on the Conley path; explicit cluster is required to opt in.DifferenceInDifferences(vcov_type="conley", cluster=<col>): combined kernel applies; same time-invariance contract on the panel path. DiD has no auto-cluster, so the cluster choice is fully explicit.DifferenceInDifferences/MultiPeriodDiD/TwoWayFixedEffects(vcov_type="conley", inference="wild_bootstrap")→NotImplementedError. (MPD’s pre-Conley analytical-fallbackUserWarningis suppressed whenvcov_type="conley"so the user gets one consistent error message.)DifferenceInDifferences/MultiPeriodDiD/TwoWayFixedEffects(vcov_type="conley")+survey_design=→NotImplementedErrorat the estimator level. Note (open methodological question): weighted spatial-HAC under probability sampling is an open methodological question; no canonical extension of Conley (1999) exists for the combination.SyntheticDiD(vcov_type="conley")→TypeError(SyntheticDiD uses bootstrap/jackknife/placebo variance, not the analytical sandwich; tracked in TODO.md).Generic
LinearRegression(vcov_type="conley", survey_design=...)→NotImplementedError. GenericLinearRegression / compute_robust_vcovConley rejectsweights=for anyweight_type(pweight/aweight/fweight) →NotImplementedError(weighted Conley is not implemented on the generic linalg surface).compute_robust_vcovdoes not acceptsurvey_design=; the survey-design surface isLinearRegressiononly. Note (open methodological question): thepweight/survey_designsubset additionally reflects an open methodological question — no canonical extension of Conley (1999) exists for weighted spatial-HAC under probability sampling. (Estimator-specific shipped surfaces — SpilloverDiD via Wave E.1/E.2/E.3 and TwoStageDiD via Wave E.3 parity — are explicitly excepted; see “Note (deferral status, 2026-05-26)” below.)Panel path: partial
conley_time/conley_unit/conley_lag_cutoff(not all three set) →ValueErrorat validator.Panel path with
cluster_idsthat vary across periods within a unit →ValueError(time-invariance contract).n > 20_000withconley_kernel='uniform'or callable metric: emitsUserWarningabout O(n²) distance-matrix memory (sparse fast path doesn’t apply; consider switching to bartlett or projecting to euclidean for performance).conley_cutoff_km ≤ 0,nan, orinf: rejected withValueError. The HC0 reduction at h→0 is documented but not the sanctioned path; users should passvcov_type="hc1".Identical coordinates (
d_ij = 0fori ≠ j):K(0) = 1, contributing the full HC0 weight per Conley 1999 page 19. Documented behavior; no warning.Callable
conley_metricreturning a non-(n,n)/NaN/inf/negative/asymmetric/non-zero-diagonal matrix raisesValueErrornaming the violated invariant. The zero-diagonal contract (|d(i, i)| ≤ 1e-10) is load-bearing for the Conley sandwich’s HC0 reductionK(0) = 1; see “Callable conley_metric validation” subsection above.
Note (deferral status, 2026-05-26): Conley + survey_design / weights surface boundary —
Shipped (survey): SpilloverDiD + Conley + survey via Wave E.1/E.2/E.3 (PR #468 / #474 / #482, stratified-Conley sandwich on PSU totals with within-PSU serial Bartlett HAC for
lag_cutoff > 0); TwoStageDiD + Conley + survey via Wave E.3 parity (PR #485 /fdf2cebc).Shipped (non-survey OLS-sandwich):
DifferenceInDifferences/MultiPeriodDiD/TwoWayFixedEffects(Wave A #118-#123), plusSunAbrahamandWooldridgeDiD-OLS — conley threaded through the within-transform design viasolve_ols/conley.py(cross-sectional + panel block-decomposed; no survey/weights).StackedDiDconley is deferred for a methodology reason (unit replication across sub-experiments breaks the spatial-distance interpretation; noconleyreganchor — see the StackedDiD variance-families row), NOT plumbing.Deferred (generic linalg surface — any weight_type): DiD / MPD / TWFE / LinearRegression generic path + Conley +
survey_design=;LinearRegression/compute_robust_vcovConley +weights=(rejected forpweight,aweight, ANDfweight; weighted Conley is not implemented on the generic linalg surface).Open methodological question (subset): the
pweight/survey_designportion of the deferral additionally has no canonical methodological extension — weighted spatial-HAC under probability sampling lacks a canonical extension of Conley (1999).
Reference implementations:
R:
conleyreg::conleyreg(...)(Düsterhöft 2021, CRAN v0.1.9) — parity benchmark for diff-diffStata:
acreg dep indeps, latitude(lat) longitude(lon) spatial dist(km) bartlett(Colella et al. 2019) — academic spec source for the cluster-flexible variance, not parity-tested hereMATLAB:
ols_spatial_HAC.m(Hsiang 2010)
Survey Data Support#
Survey-weighted estimation allows correct population-level inference from data collected via complex survey designs (multi-stage sampling, stratification, unequal selection probabilities).
Weighted Estimation#
Reference: Lumley (2004) “Analysis of Complex Survey Samples”, Journal of Statistical Software 9(8). Solon, Haider, & Wooldridge (2015) “What Are We Weighting For?” Journal of Human Resources 50(2).
WLS formula:
beta_WLS = (X'WX)^{-1} X'WywhereW = diag(w_i)Implementation: Equivalent transformation via
sqrt(w)scaling, then standard OLS. Residuals back-transformed to original scale.Weight types: pweight (inverse selection probability), fweight (frequency/expansion), aweight (inverse variance/precision)
Note: Weight normalization uses
sum(w) = nconvention (DRDID/Stata), not raw weights (Rsurvey). Coefficients are identical; SEs differ by constant factor.
Taylor Series Linearization (TSL) Variance#
Reference: Binder (1983) “On the Variances of Asymptotically Normal Estimators from Complex Surveys”, International Statistical Review 51(3) (paper review on file:
docs/methodology/papers/binder-1983-review.md). Lumley (2004).Formula:
V_TSL = (X'WX)^{-1} [sum_h V_h] (X'WX)^{-1}with stratified PSU-level scoresRelationship to sandwich estimator: TSL is a generalization of the Huber-White sandwich estimator that accounts for stratification and finite population correction
Deviation from R: R
surveydefaultslonely_psuto “fail”; we default to “remove” with warning, matching common applied practiceEdge case: Singleton strata (one PSU per stratum) — handled via
lonely_psuparameter (“remove”, “certainty”, or “adjust”)Note: For unstratified designs with a single PSU, all
lonely_psumodes produce NaN variance. The “adjust” mode cannot center against a global mean when there is only one stratum (the single PSU is the entire sample).Note: Weights-only designs (no explicit PSU or strata) use implicit per-observation PSUs for the TSL meat computation, consistent with the stratified-no-PSU path. The adjustment factor is
n/(n-1)(not HC1’sn/(n-k)).Note: TSL now precondition-checks
X'WXvianp.linalg.condbefore solving the sandwich. If the condition number exceeds1/sqrt(eps)(≈ 6.7e7) aUserWarningfires stating that the bread is ill-conditioned and variance estimates may be numerically unstable. Previously a near- singularX'WXcould silently produce unstable SEs. Axis-A finding #19 in the Phase 2 silent-failures audit.
Weight Type Effects on Inference#
Note: aweights use unweighted meat in the sandwich estimator (no
winu^2term). This matches Stata convention. Rationale: aweights model known heteroskedasticity; after WLS transformation, errors are approximately homoskedastic.Note: fweights affect degrees of freedom (
df = sum(w) - k, notn - k). This matches Stata convention for frequency-expanded data.Note: pweight HC1 meat uses score outer products (Σ s_i s_i’ where s_i = w_i x_i u_i), giving w² in the meat. fweight HC1 meat uses X’diag(w u²)X (one power of w), matching frequency-expanded HC1.
Note: fweights must be non-negative integers; fractional values are rejected by
_validate_weights(). All-zero vectors rejected at solver level. This matches Stata’s convention.
Absorbed Fixed Effects with Survey Weights#
Note: When
absorbis used in DiD/MultiPeriodDiD, all regressors (treatment, time, interactions, covariates) are within-transformed alongside the outcome per the FWL theorem. Regressors collinear with the absorbed FE (e.g., treatment after absorbing unit FE) are dropped via rank-deficiency handling. Multiple absorbed variables (weighted or unweighted) are within-transformed via the method of alternating projections (diff_diff.utils.demean_by_groups): each variable is demeaned by each FE dimension in turn until the iterate converges. This is the exact (weighted) FWL residualization onto the combined column space of all absorbed dummies for both balanced and unbalanced panels, matching Rfixest/reghdfe/lfe. A single sequential sweep (the prior behavior) is only the one-iteration approximation and is exact only when the FE subspaces are orthogonal (balanced fully-crossed panels); on unbalanced panels it was wrong. A single absorbed variable converges in one pass (delegates todemean_by_group).Note: The shared within-transformation path (
diff_diff.utils.demean_by_groups, also reached via the two-waywithin_transform) emits aUserWarningper call when any transformed variable exits the alternating-projection loop without reachingtolwithinmax_iter. This now covers the unweighted path as well (previously the unweighted two-way transform used a closed-form additive demean and could not warn). Defaults:max_iter=10_000in bothdemean_by_groupsandwithin_transform(raised from 100 in v3.6.x to match the compiled-library convention - Rfixestfixef.iterandpyfixestfixef_maxiterboth default to 10,000; correlated FE incidence such as contiguous unit lifetimes in order-level data genuinely requires hundreds of iterations, measured ~250-280 on thetail_stressbenchmark scenario where the old cap of 100 warned and returned slightly-off residuals).tol=1e-8viawithin_transform(TwoWayFixedEffects, SunAbraham, BaconDecomposition, WooldridgeDiD) andtol=1e-10for the DiD/MultiPeriodDiDabsorb=path. Balanced panels converge in ~2 iterations. Worst-case trade-off, accepted deliberately: an input that cannot converge at all now burns the full 10,000 iterations before warning (correctness-over-latency). Silent return of the current iterate was classified as a silent failure under the Phase 2 audit and replaced with this explicit signal.Note: The MAP inner loop factorizes each absorbed dimension once (
pd.factorize) and forms group means vianp.bincountaccumulation. Plain summation is not Kahan-compensated the way pandasgroupby().mean()is, so demeaned values agree with the pre-v3.6.x pandas implementation to ~1e-10 order (drift compounds across MAP iterations), not bit-for-bit; estimator estimates are validated unchanged at the FE-absorption benchmark identity gate (benchmarks/speed_review/bench_fe_absorption.py --check-estimates).Note: When the optional Rust backend is available, the MAP sweeps run in the compiled
demean_mapkernel (rayon-parallel across the demeaned variables). The kernel mirrors the canonical numpy engine (diff_diff.utils._demean_map_numpy) exactly: same per-variable independent convergence loops, same dimension sweep order, the same row-order scatter-add accumulation asnp.bincount, division by the per-group sums, the same zero-total-weight inert-row guard, and the samemax|x - x_old| < tolstopping rule with NaN-poisoning semantics. Per the python-canonical policy, numpy is the reference implementation; equivalence tests assert iteration-count equality plusassert_allcloseat atol=1e-12 (never a bit-identity claim).DIFF_DIFF_BACKEND=pythondisables the kernel; any kernel-side validation error falls back to the numpy engine. The dispatch can partition variables into balanced column blocks (DIFF_DIFF_DEMEAN_CHUNK_COLS, internal opt-in knob, OFF by default) to bound the kernel’s transient memory; per-column results and iteration counts are unchanged by construction (each column’s MAP loop is fully independent - no cross-column arithmetic).Edge case: NaN in an absorbed group column raises a
ValueErrornaming the column.pd.factorizecodes NaN keys as -1, which would otherwise silently index the last group’s mean; the prior pandas behavior was itself silently bad (unweighted: NaN-poisoned the affected rows; weighted: passed those rows through un-demeaned into the regression), so the explicit error replaces two distinct silent failure modes.Edge case (FE-spanned regressors, v3.6.x): a regressor lying exactly in the span of the absorbed/within-transformed FE dummies (e.g. the treated-group indicator after absorbing the unit dimension, a period dummy after absorbing time, or a unit-constant covariate) demeans to numerical junk (relative norm ~1e-13), NOT exact zero. Such a column previously reached the solver, where column equilibration re-inflated it to unit norm, it passed the rank check as linearly independent, and its arbitrary direction perturbed the identified coefficients at the ~1e-5 level (tolerance- and implementation-dependent).
diff_diff.utils.snap_absorbed_regressorsnow zeroes spanned regressors at every demeaning consumer (DiD/MultiPeriodDiDabsorb=, TwoWayFixedEffects, SunAbraham, BaconDecomposition, WooldridgeDiD) — including the replicate-refit closures (silently, matching theirrank_deficient_action="silent"solves) — so the rank-deficiency machinery drops them deterministically (coefficient NaN). Detection is two-stage, because the MAP stopping rule bounds the last iteration step, not the distance to the limit, and a spanned column in a slow-convergence regime (unbalanced, correlated FE incidence — e.g.x = a_unit + b_timeon a contiguous-lifetimes panel) can stop with a structured truncation residual far above any fixed norm threshold (measured 1.9e-10 at tol=1e-10 and 2e-8 at tol=1e-8; left unsnapped it shifted ATT by ~3e-3 and reported a ~1e14-scale garbage coefficient): (1) relative demeaned norm <= 1e-10 snaps immediately; (2) candidates in (1e-10, 1e-3] get an exact span-membership confirmation via sparse LSMR on the (weighted) FE incidence and snap iff the true projection residual is <= 1e-10 relative — genuinely identified low-within-variation regressors are left untouched (their LSMR residual equals their real within-variation). Norms aresqrt(w)-weighted under WLS so zero-weight domain rows (left inert by the weighted demean) cannot mask spanning on the positive-weight sample. A cause-specificUserWarningnaming the spanned regressors is emitted underrank_deficient_action="warn";"silent"and"error"defer to the rank machinery per that parameter’s existing contract. Identified coefficients are consequently stable in the demeaning tolerance (verified to ~1e-14 across tol=1e-8..1e-12, previously ~1e-5 swings).
Survey Degrees of Freedom#
Reference: Korn & Graubard (1990) “Simultaneous Testing of Regression Coefficients with Complex Survey Data: Use of Bonferroni t Statistics”, The American Statistician 44(4), 270-276.
Formula:
df = n_PSU - n_strata(replacesn - kfor t-distribution inference)Deviation from R: Some software uses Satterthwaite-type df approximation; we use the simpler and more common
n_PSU - n_strataconvention.Note: When no explicit PSU is specified (weights-only or stratified-no-PSU designs), each observation is treated as its own PSU for df purposes. Survey df becomes
n_obs - n_strata(orn_obs - 1when unstratified).Note: When survey_design specifies weights only (no PSU) and cluster= is specified, cluster IDs are injected as effective PSUs for Taylor Series Linearization variance estimation, matching the R
surveypackage convention that clusters are the primary sampling units.
Survey Aggregation (aggregate_survey)#
Aggregation of individual-level survey microdata to geographic-period cells with design-based precision estimates, for use as a pre-processing step before panel DiD estimation on repeated cross-section survey data.
Reference: Lumley (2004) “Analysis of Complex Survey Samples”, Journal of Statistical Software 9(8), Section 3.4 (domain estimation).
Cell mean: Design-weighted mean
ȳ_g = Σ w_i y_i / Σ w_ifor each cell g defined by grouping columns (e.g., state × year).Cell variance: Each cell is treated as a subpopulation/domain of the full survey design (consistent with
SurveyDesign.subpopulation()and the Subpopulation Analysis section below). The influence functionψ_i = w_i (y_i - ȳ_g) / Σ w_jis zero-padded outside the cell, preserving full strata/PSU structure for variance estimation viacompute_survey_if_variance()(TSL) orcompute_replicate_if_variance()(replicate designs).Second-stage weights (
second_stage_weightsparameter):"pweight"(default): Population weight = mean of per-cellΣ w_iwithin each geographic unit (firstbycolumn), constant across periods. Proportional to the Horvitz-Thompson estimated population count, averaged over periods to satisfy the unit-constant survey column contract required by panel estimators. Compatible with all survey-capable estimators including pweight-only estimators (CallawaySantAnna, ImputationDiD, TwoStageDiD, StackedDiD, etc.)."aweight": Precision weight =1 / V(ȳ_g)(inverse variance). Produces efficiency-weighted estimates via WLS. Compatible only with estimators that accept aweight (DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD, SunAbraham, ContinuousDiD, EfficientDiD).Reference: Solon, Haider & Wooldridge (2015) “What Are We Weighting For?”, Journal of Human Resources 50(2), 301-316. Population weights estimate the population parameter; precision weights are efficient under correct variance specification. Both are valid with heteroskedasticity-robust standard errors.
Reference: Donald & Lang (2007) “Inference with Difference-in-Differences and Other Panel Data”, Review of Economics and Statistics 89(2), 221-233.
Note: The pweight default matches the R
didpackage convention whereweightsnameaccepts sampling/population weights, not inverse-variance weights.
Note: SRS fallback when design-based variance is unidentifiable (e.g., all strata contribute zero variance) or when the cell has fewer than
min_nvalid observations. Formula:V_SRS = Σ w_i(y_i - ȳ)² / (Σ w_j)² × n/(n-1). Cells using SRS fallback are flagged viasrs_fallbackcolumn.Edge case: Zero-variance cells (all observations identical) set precision to NaN. Under aweight mode this maps to weight 0.0; under pweight mode the cell retains its positive population weight.
Survey-Aware Bootstrap (Phase 6)#
Two strategies for bootstrap variance under complex survey designs:
Multiplier Bootstrap at PSU Level (CallawaySantAnna, ImputationDiD, TwoStageDiD, ContinuousDiD, EfficientDiD):
Reference: Standard Taylor linearization bootstrap (Shao 2003, “Impact of the Bootstrap on Sample Surveys”, Statistical Science 18(2))
Formula: Generate multiplier weights independently within strata at the PSU level. Scale by
sqrt(1 - f_h)for FPC. Perturbation:ATT_boot[b] = ATT + w_b^T @ psi_psuwherepsi_psuare PSU-aggregated IF sums.Note: When no strata/PSU/FPC, degenerates to standard unit-level multiplier bootstrap.
Rao-Wu Rescaled Bootstrap (SunAbraham, TROP):
Reference: Rao & Wu (1988) “Resampling Inference with Complex Survey Data”, JASA 83(401); Rao, Wu & Yue (1992) “Some Recent Work on Resampling Methods for Complex Surveys”, Survey Methodology 18(2), Section 3.
Formula: Within each stratum h with n_h PSUs, draw
m_hPSUs with replacement. Without FPC:m_h = n_h - 1. With FPC:m_h = max(1, round((1 - f_h) * (n_h - 1))). Rescaled weight:w*_i = w_i * (n_h / m_h) * r_hiwherer_hi= count of PSU i drawn.Note: FPC enters through the resample size
m_h, not as a post-hoc scaling factor. Whenf_h >= 1(census stratum), observations keep original weights (zero variance).Note: SyntheticDiD joins this list via a hybrid pairs-bootstrap + Rao-Wu rescaling (PR #352). Unlike SunAbraham / TROP, which use standalone Rao-Wu (resample PSUs within strata and rescale weights), SDID first performs unit-level pairs-bootstrap (
boot_idx = rng.choice(n_total)) and then slices Rao-Wu rescaled weights over the resampled units, passing them into a weighted Frank-Wolfe re-estimation of ω̂ and λ̂ per draw. The full objective and argmin-set caveat live in §SyntheticDiD “Note (survey + bootstrap composition)”; the previous fixed-ω-and-rescaled-weights path was removed in PR #351 and replaced with this weighted-FW derivation.Note: Bootstrap paths support all three
lonely_psumodes:"remove","certainty", and"adjust". For"adjust", singleton PSUs from different strata are pooled into a combined pseudo-stratum and weights are generated for the pooled group. This is the bootstrap analogue of the TSL “adjust” behavior (centering around the global mean). Applies to both multiplier bootstrap (CallawaySantAnna, ImputationDiD, TwoStageDiD, ContinuousDiD, EfficientDiD) and Rao-Wu bootstrap (SunAbraham, TROP). FPC scaling is skipped for pooled singletons (conservative). When only one singleton stratum exists total, pooling is not possible — the singleton contributes zero bootstrap variance (same asremove), with aUserWarningemitted. This is a library-specific documented fallback (R’s analyticaladjustuses grand-mean centering, but the bootstrap analogue for a single singleton is not defined in the literature). Reference: Rust & Rao (1996).Deviation from R: For the no-FPC case (
m_h = n_h - 1), this matches Rsurvey::as.svrepdesign(type="subbootstrap"). The FPC-adjusted resample sizem_h = round((1-f_h)*(n_h-1))follows Rao, Wu & Yue (1992) Section 3.
CallawaySantAnna Design-Based Aggregated SEs:
Formula:
V_design = sum_h (1-f_h) * (n_h/(n_h-1)) * sum_j (psi_hj - psi_h_bar)^2wherepsi_hj = sum_{i in PSU j} psi_iandpsi_iis the combined IF (standard + WIF).Note: Per-(g,t) cell SEs use the simpler IF-based formula
sqrt(sum(psi^2))which already incorporates survey weights. Only aggregated SEs (overall, event study, group) use the full design-based variance.
TROP Cross-Classified Strata:
Note (deviation from R): When survey strata and treatment groups both exist, TROP creates pseudo-strata as
(survey_stratum x treatment_group)for Rao-Wu resampling. This preserves both survey variance structure and treatment ratio. Survey df computed from pseudo-strata structure.Note: When
survey_design.stratais None but PSU/FPC trigger full-design bootstrap, TROP uses treatment group (treated vs control) as pseudo-strata for Rao-Wu resampling to preserve treatment ratio. FPC is applied within these pseudo-strata. This matches TROP’s existing treatment-stratified resampling pattern.Note (deviation from block bootstrap): In Rao-Wu survey bootstrap, per-observation treatment effects tau_{it} are deterministic given (Y, D, lambda) because survey weights do not enter the kernel-weighted matrix completion. The Rao-Wu path therefore precomputes tau values once and only varies the ATT aggregation weights across draws. This is mathematically equivalent to refitting per draw and avoids redundant computation.
Replicate Weight Variance (Phase 6)#
Alternative to TSL: re-run WLS for each replicate weight column and compute variance from the distribution of replicate estimates.
Reference: Wolter (2007) “Introduction to Variance Estimation”, 2nd ed. Rao & Wu (1988).
Supported methods: BRR, Fay’s BRR, JK1, JKn, SDR
Formulas:
BRR:
V = (1/R) * sum_r (theta_r - theta)^2SDR:
V = (4/R) * sum_r (theta_r - theta)^2(Fay & Train 1995)Fay:
V = 1/(R*(1-rho)^2) * sum_r (theta_r - theta)^2JK1:
V = (R-1)/R * sum_r (theta_r - theta)^2JKn:
V = sum_h ((n_h-1)/n_h) * sum_{r in h} (theta_r - theta)^2
Note: SDR (Successive Difference Replication) uses variance factor 4/R, following Fay & Train (1995). Used by ACS PUMS (80 replicate columns). Treated identically to BRR for scaling purposes — no fay_rho, no replicate_strata, custom scale/rscales ignored.
Note (vcov_type has no effect under replicate variance, 2026-07): with
uses_replicate_variance, the analytical sandwich is replaced wholesale by the replicate-refit variance — the per-replicate refits return point estimates only, which are identical across vcov families (FWL: full-dummy and within-transformed fits give the same coefficients). An explicit non-hc1analyticalvcov_type(hc2,hc2_bm,classical) onDifferenceInDifferences/MultiPeriodDiD/TwoWayFixedEffectstherefore emits aUserWarningand the (discarded) base fit remaps tohc1— avoiding wasted CR2-BM work, one-way-only validator rejections, and the TWFE full-dummy auto-route (which does not compose with per-replicate re-demeaning). Explicithc1stays silent (it is the remap target).conleyis excluded from the remap: it carries its own survey-design support contract (TSL stratified-Conley sandwich; dedicated per-design validators), which keeps firing unchanged. This replaces two prior inconsistent behaviors:TwoWayFixedEffects(vcov_type="hc2"/"hc2_bm")raisedNotImplementedError, whileDifferenceInDifferencessilently ignored the kwarg — a per-replicate full-dummy HC2 implementation was considered and rejected as a costly no-op (it cannot change the replicate variance).IF-based replicate variance: For influence-function estimators (CS aggregation, ContinuousDiD, EfficientDiD, TripleDifference), replicate contrasts are formed via weight-ratio rescaling:
theta_r = sum((w_r/w_full) * psi)whencombined_weights=True,theta_r = sum(w_r * psi)whencombined_weights=False.Survey df: QR-rank of the analysis-weight matrix minus 1, matching R’s
survey::degf()which usesqr(..., tol=1e-5)$rank. Forcombined_weights=True(default), analysis weights are the raw replicate columns. Forcombined_weights=False, analysis weights arereplicate_weights * full_sample_weights. ReturnsNone(undefined) when rank <= 1, yielding NaN inference. Replacesn_PSU - n_strata.Mutual exclusion: Replicate weights cannot be combined with strata/psu/fpc (the replicates encode design structure implicitly)
Design parameters (matching R
svrepdesign()):combined_weights(default True): replicate columns include full-sample weight. If False, replicate columns are perturbation factors multiplied by full-sample weight before WLS.replicate_scale: overall variance multiplier, applied multiplicatively withreplicate_rscaleswhen both are provided (scale * rscales)replicate_rscales: per-replicate scaling factors (vector of length R). BRR and Fay ignore customreplicate_scale/replicate_rscaleswith a warning (fixed scaling by design); JK1/JKn allow overrides.mse(default False, matching R’ssurvey::svrepdesign()): if True, center variance on full-sample estimate; if False, center on mean of replicate estimates. Whenreplicate_rscalescontains zero entries andmse=False, centering excludes zero-scaled replicates, matching R’ssurvey::svrVar()convention.
Note: Replicate columns are NOT normalized — raw values are preserved to maintain correct weight ratios in the IF path.
Note: JKn requires explicit
replicate_strata(per-replicate stratum assignment). Auto-derivation from weight patterns is not supported.Note: Invalid replicate solves (singular/degenerate) are dropped with a warning. Variance is computed from valid replicates only. Fewer than 2 valid replicates returns NaN variance. The variance scaling factor (e.g.,
1/Rfor BRR,(R-1)/Rfor JK1) uses the original design’sR, not the valid count — matching R’ssurveypackage convention where the design structure is fixed and dropped replicates contribute zero to the sum without changing the scale. Survey df usesn_valid - 1for t-based inference.Note: Replicate-weight support matrix (13 of 20 public estimators):
Supported: CallawaySantAnna (reg/ipw/dr with or without covariates, no bootstrap; IF-based replicate variance is covariate-agnostic), ContinuousDiD (no bootstrap), EfficientDiD (no bootstrap), TripleDifference (all methods), StaggeredTripleDifference (IF-based), DifferenceInDifferences (no-absorb via LinearRegression dispatch, absorb via estimator-level refit), MultiPeriodDiD (no-absorb via
compute_replicate_vcov, absorb via estimator-level refit), TwoWayFixedEffects (estimator-level refit with within-transformation), SunAbraham (estimator-level refit, replacesvcov_cohort), StackedDiD (estimator-level refit with Q-weight composition), ImputationDiD (two-stage refit), TwoStageDiD (two-stage refit), ChaisemartinDHaultfoeuille (closed-form cell-collapse replicate ATT, multi-horizon and placebo paths; replicate +n_bootstrap > 0rejected — see the ChaisemartinDHaultfoeuille Notes for the allocator contract)Rejected with NotImplementedError: SyntheticDiD, TROP (bootstrap-based variance), WooldridgeDiD, LPDiD, SpilloverDiD, HeterogeneousAdoptionDiD (TSL-only survey paths; replicate designs rejected at
fit()), SyntheticControl (rejectssurvey_designentirely)BaconDecomposition is diagnostic-only — outside the 20-estimator count — and likewise rejects replicate designs
Estimators with replicate support reject replicate + bootstrap (replicate weights provide analytical variance)
Note: When invalid replicates are dropped in
compute_replicate_vcov(OLS path),n_validis returned and used fordf_survey = n_valid - 1inLinearRegression.fit(). For IF-based replicate paths, replicates essentially never fail (weighted sums cannot be singular), son_validequalsRin practice and df propagation is not needed.
DEFF Diagnostics (Phase 6)#
Per-coefficient design effect comparing survey variance to SRS variance.
Reference: Kish (1965) “Survey Sampling”, Wiley. Chapter 8.
Formula:
DEFF_k = Var_survey(beta_k) / Var_SRS(beta_k)where SRS baseline uses HC1 sandwich ignoring design structureEffective n:
n_eff_k = n / DEFF_kDisplay: Existing weight-based DEFF labeled “Kish DEFF (weights)”; per-coefficient DEFF available via
compute_deff_diagnostics()orLinearRegression.compute_deff()post-fitNote: Opt-in computation — not run automatically. Users call standalone function or post-fit method when diagnostics are needed.
Subpopulation Analysis (Phase 6)#
Domain estimation preserving full design structure.
Reference: Lumley (2004) Section 3.4. Stata
svy: subpop.Method:
SurveyDesign.subpopulation(data, mask)zeros out weights for excluded observations while retaining strata/PSU layout for correct variance estimationNote: Unlike naive subsetting, subpopulation analysis preserves design information (PSU structure, strata counts) that would be lost by dropping observations. This is the methodologically correct approach for domain estimation under complex survey designs.
Note: Weight validation relaxed from “strictly positive” to “non-negative” to support zero-weight observations. Negative weights still rejected. All-zero weight vectors rejected at solver level.
Note: Survey design df (
n_PSU - n_strata) uses the full design structure (including zero-weight rows), ensuring variance estimation accounts for all strata and PSUs. The generic HC1/classical inference paths use positive-weight count for df adjustments, ensuring zero-weight padding is inference-invariant outside the survey vcov path. DEFF effective-n also uses positive-weight count.Note: The TSL meat itself follows the same full-design convention:
_compute_stratified_psu_meat’s per-stratum finite-sample correction(1 - f_h)·n_{PSU,h}/(n_{PSU,h}-1)and PSU-mean centering count zero-weight PSUs (a genuine-subpopulation PSU with all members outside the domain contributes a zero PSU-score0that is centered to-\bar{z}_hand still incrementsn_{PSU,h}). This is the Lumley (2004 §3.4) / Rsurvey::svyrecvar(subset())domain estimator — so the survey SE is deliberately not invariant to genuine-subpopulation zeroing: it differs from the SE of a naive physical subset, which is the whole point of preserving the design (subpopulation()is correct because it does not equal the naive subset). Zero-weight rows that reuse an existing PSU label are inert (their weighted score is0, so the PSU-score sum is unchanged), so padding that preserves PSU membership is bit-invariant; only adding new synthetic all-zero PSUs would shift the SE, and no estimator path does so (domain padding goes through the zero-padded full-design cell variance inprep.py, which retains the real PSU layout). A former TODO proposed counting only positive-weight PSUs to force SE invariance; that was waived (TODO § “Won’t-fix / waived”) because it would break this documented R parity.Deviation from R:
subpopulation()preserves all strata in df computation even when a stratum has no positive-weight observations, while R’ssubset()drops empty strata fromsurvey::degf(). For example, subsetting a 3-stratum design to one stratum gives df=n-3 in diff-diff vs df=n-1 in R. Both ATT and SE match; only df (and therefore t-based CI width) differs. The diff-diff approach is conservative (more strata → lower df → wider CI) and preserves the full design structure per Lumley (2004) Section 3.4.Note: For replicate-weight designs,
subpopulation()zeros out both full-sample and replicate weight columns for excluded observations, preserving all replicate metadata.Note: Estimator-level replicate refits (TWFE, SunAbraham, DiD/MultiPeriodDiD with
absorb) drop zero-weight observations before weighted demeaning to prevent division-by-zero in within-transformation group means. This matches R’ssurvey::withReplicates()convention where zero-weight units are excluded from per-replicate estimation. Replicates that fail despite this (e.g., rank-deficient after unit deletion) are counted as invalid and excluded from variance computation.Note: Defensive enhancement: ContinuousDiD and TripleDifference validate the positive-weight effective sample size before WLS cell fits. After
subpopulation()zeroes weights, raw row counts may exceed the regression rank requirement while the weighted effective sample does not. Underidentified cells are skipped (ContinuousDiD) or fall back to weighted means (TripleDifference).
Practitioner Guide#
The 8-step workflow in diff_diff/guides/llms-practitioner.txt is adapted from Baker et al. (2025)
“Difference-in-Differences Designs: A Practitioner’s Guide” (arXiv:2503.13323), not a
1:1 mapping of the paper’s forward-engineering framework.
Note: The diff-diff canonical numbering is: 1-Define, 2-Assumptions, 3-Test PT, 4-Choose estimator, 5-Estimate, 6-Sensitivity, 7-Heterogeneity, 8-Robustness. Paper’s numbering: 1-Define, 2-Assumptions, 3-Estimation method, 4-Uncertainty, 5-Estimate, 6-Sensitivity, 7-Heterogeneity, 8-Keep learning.
Note: Parallel trends testing is a separate Step 3 (paper embeds it in Step 2), to ensure AI agents execute it as a distinct action.
Note: Sources of uncertainty (paper’s Step 4) is folded into Step 5 (Estimate) with an explicit cluster-count check directive (>= 50 clusters for asymptotic SEs, otherwise wild bootstrap). The 50-cluster threshold is a diff-diff convention.
Note: Step 8 is “Robustness & Reporting” (compare estimators, report with/without covariates). Paper’s Step 8 is “Keep learning.” The mandatory with/without covariate comparison is a diff-diff convention.
Survey DGP (generate_survey_did_data)#
Note: The
iccparameter calibratespsu_re_sdusing the full variance decompositionVar(Y) = sigma²_psu * (1 + psu_period_factor²) + sigma²_unit + sigma²_noise + sigma²_cov. Whenadd_covariates=True, covariate variancesigma²_cov = beta1² * Var(x1) + beta2² * Var(x2)is included, where(beta1, beta2)defaults to(0.5, 0.3)but is configurable viacovariate_effects.Note: When
informative_sampling=Trueandadd_covariates=True, covariate contributions are included in the Y(0) ranking used for weight assignment. Covariates are pre-drawn before the ranking step (panel: once before the loop; cross-section: each period) and reused in the outcome generation.Note: When
conditional_pt != 0, the DGP creates X-dependent time trends that violate unconditional parallel trends while preserving conditional PT. Two mechanisms activate: (1) treated units’ x1 is drawn from N(1, 1) instead of N(0, 1), creating differential covariate distributions; (2) the outcome includesconditional_pt * x1_i * (t / n_periods)for all units. Because E[x1 | treated] != E[x1 | control], the average time trend differs by group (unconditional PT fails). Conditional on x1, trends are identical (conditional PT holds). DR/IPW estimators with x1 as covariate recover the true ATT. Requires at least one ever-treated and one never-treated unit (rejected otherwise because the x1 mean shift only differentiates ever-treated from never-treated units).Note: When
conditional_pt != 0is combined withicc, the ICC calibration is approximate. The x1 mean shift creates a mixture distribution with marginal Var(x1) = 1 + p_treated * (1 - p_treated) > 1, slightly inflating non-PSU variance and causing realized ICC to undershoot the target.
Reporting#
BusinessReport and DiagnosticReport are the practitioner-ready output layer. Their methodology (phrasing rules, pre-trends verdict thresholds, power-aware phrasing, unit-translation policy, schema stability, no-traffic-light-gates decision, estimator-native diagnostic routing) is recorded in a dedicated file to keep this registry estimator-focused:
See
REPORTING.md.
Version History#
v1.3 (2026-03-26): Added Replicate Weight Variance, DEFF Diagnostics, and Subpopulation Analysis sections (Phase 6 completion)
v1.2 (2026-03-24): Added Survey-Aware Bootstrap section (Phase 6)
v1.1 (2026-03-20): Added Survey Data Support section
v1.0 (2025-01-19): Initial registry with 12 estimators