diff_diff.ChaisemartinDHaultfoeuille#
- class diff_diff.ChaisemartinDHaultfoeuille[source]#
Bases:
ChaisemartinDHaultfoeuilleBootstrapMixinde Chaisemartin-D’Haultfoeuille (dCDH) estimator.
The only modern DiD estimator in the library that handles reversible (non-absorbing) treatments - treatment may switch on AND off over time. Computes the contemporaneous-switch DiD
DID_Mfrom the AER 2020 paper (equivalentlyDID_1at horizonl = 1of the dynamic companion paper, NBER WP 29873) plus the full multi-horizon event studyDID_lforl = 1..L_maxvia theL_maxparameter onfit().Supported:
Headline
DID_Mplus multi-horizonDID_levent studyJoiners-only
DID_+and leavers-onlyDID_-decompositionsSingle-lag placebo
DID_M^pland dynamic placebosDID^{pl}_l(computed automatically by default; gate viaplacebo=False)Analytical SE via the cohort-recentered plug-in formula from Web Appendix Section 3.7.3; multiplier bootstrap clustered at the group level by default via
n_bootstrap; undersurvey_designwith strictly-coarser PSUs the bootstrap automatically upgrades to PSU-level Hall-Mammen wild clustering (see REGISTRY.mdChaisemartinDHaultfoeuilleNote on survey + bootstrap)Normalized estimator
DID^n_l, cost-benefit aggregatedelta, and sup-t simultaneous confidence bandsResidualization-style covariate adjustment (
DID^X) viacontrols=, group-specific linear trends (DID^{fd}) viatrends_linear=True, state-set-specific trends viatrends_nonparam=, heterogeneity testing, non-binary treatment, HonestDiD sensitivity integration on placebos viahonest_did=TruePer-path event-study disaggregation via
by_path=k(top-k most common observed treatment paths within the window[F_g-1, F_g-1+L_max]; requiresdrop_larger_lower=False; supports binary or integer-coded discrete treatment) or viapaths_of_interest=[(...), ...]for an explicit user-specified path subset (Python-only API; mutex withby_path=k)Survey support via
survey_design=: pweight with strata/PSU/FPC via Taylor Series Linearization (analytical) or replicate-weight variance (BRR/Fay/JK1/JKn/SDR)TWFE decomposition diagnostic from Theorem 1 of AER 2020
Only
aggregateonfit()still raisesNotImplementedError.- Parameters:
alpha (float, default=0.05) – Significance level for confidence intervals.
cluster (str, optional, default=None) –
Must be
None(the default). User-specified clustering via this kwarg is not supported — passing any non-Nonevalue raisesNotImplementedErrorat construction time (and the same gate fires fromset_params). The effective clustering depends on how you callfit():Default (no survey_design): clustered at the group level via the cohort-recentered influence-function plug-in (analytical SEs) and the multiplier bootstrap.
Under ``survey_design`` with auto-inject or explicit ``psu=group``: PSU coincides with the group and the group-level and PSU-level paths are bit-identical.
Under ``survey_design`` with strictly-coarser PSUs: the multiplier bootstrap automatically upgrades to PSU-level Hall-Mammen wild clustering.
So dCDH does NOT always cluster at the group level — see REGISTRY.md
ChaisemartinDHaultfoeuilleNotes on cluster contract and survey + bootstrap for the full matrix. Custom user-specified clustering at a coarser or finer level than the group is a planned extension.n_bootstrap (int, default=0) – Number of multiplier-bootstrap iterations.
0(default) uses only the analytical SE. Set to999or higher for stable bootstrap inference.bootstrap_weights (str, default="rademacher") – Type of multiplier-bootstrap weights:
"rademacher","mammen", or"webb". Ignored unlessn_bootstrap > 0.seed (int, optional) – Random seed for the multiplier bootstrap.
placebo (bool, default=True) – If
True(default), automatically compute the single-lag placeboDID_M^pl(AER 2020 placebo specification) on the same data. Set toFalseto skip the placebo computation for speed; the results object will still exposeplacebo_*fields, but with NaN values andplacebo_available=False.twfe_diagnostic (bool, default=True) – If
True(default), compute the TWFE decomposition diagnostic from Theorem 1 of AER 2020: per-(g, t)weights, fraction of treated cells with negative weights, andsigma_fe(the smallest cell-effect standard deviation that could flip the sign of the plain TWFE coefficient). The diagnostic answers “what would the plain TWFE estimator say on the data you passed in?”, so it runs on the FULL pre-filter cell sample (the same input as the standalonetwowayfeweights()function), NOT on the post-filter estimation sample used byDID_M. When the ragged-panel filter ordrop_larger_lowerdrops groups, the fittedresults.twfe_*values describe a LARGER sample (pre-filter) thanresults.overall_attand aUserWarningis emitted to make the divergence explicit. See REGISTRY.mdChaisemartinDHaultfoeuilleNote (TWFE diagnostic sample contract)for the full rationale.drop_larger_lower (bool, default=True) – If
True(default, matches RDIDmultiplegtDYN), drops groups whose treatment switches more than once (multi-switch groups) before estimation. This is required for the analytical variance formula to be consistent with the AER 2020 Theorem 3 point estimate — both formulas operate on the same post-drop dataset. Setting toFalseis supported for diagnostic comparison but produces an inconsistent estimator-variance pairing for multi-switch groups; a warning is emitted.by_path (int, optional, default=None) –
If set to a positive integer
k, disaggregate the per-horizon event study by the observed treatment trajectory in the window[F_g - 1, F_g, ..., F_g - 1 + L_max], reporting ATT + SE + inference for thekmost common observed paths (ties broken lexicographically on the path tuple). Ifkexceeds the number of observed paths, all paths are returned and aUserWarningis emitted.None(the default) disables the disaggregation.Requires
drop_larger_lower=False(multi-switch groups are the object of interest) andL_max >= 1(the path window depends onL_max). Compatible with non-binary integer-coded treatment (D in Z); path tuples become integer-state tuples like(0, 2, 2, 2). D values must be integer-valued (D == round(D)); aValueErroris raised at fit-time on continuous D. Compatible withsurvey_designfor analytical Binder TSL SE and replicate-weight bootstrap; per-path SE routes through the cell-period allocator, with non-path switcher-side contributions skipped (control contributions remain unchanged, matching the joiners/leavers IF convention).n_bootstrap > 0(multiplier bootstrap) undersurvey_designis not yet supported and raisesNotImplementedError. Top-k path ranking undersurvey_designremains group-cardinality-based (unweighted), not population-weight-based — survey weights do not affect which paths are selected as “top-k”.Compatible with
heterogeneity="<col>"— per-path heterogeneity coefficient is computed by re-running the Lemma 7 regression on each path-restricted switcher subsample. Cohort dummies absorb baseline (no R-divergence warning needed). Surfaces onresults.path_heterogeneity_effectskeyed{path: {l: {beta, se, t_stat, p_value, conf_int, n_obs}}}and onto_dataframe(level="by_path")viahet_*columns. Mirrors Rdid_multiplegt_dyn(..., by_path, predict_het)per-by_level. Composes withsurvey_design(analytical Binder TSL + replicate-weight) via the existing cell-period IF allocator path. Incompatible withdesign2andhonest_did(each combination raisesNotImplementedErrorin the current release).Mutually exclusive with
paths_of_interest— useby_path=kfor top-k automatic ranking by frequency, orpaths_of_interest=[(...), ...]for an explicit user- specified path list. Setting both raisesValueError.Compatible with
controls(DID^X residualization) – the per-baseline OLS residualization runs once on first-differencedYBEFORE path enumeration, so per-path point estimates, bootstrap SE, per-path placebos, and per-path sup-t bands all consume the residualizedY_matautomatically (Frisch- Waugh-Lovell). Per-period effects remain unadjusted, consistent with the existingcontrols+ per-period DID contract.Deviation from R on multi-baseline switcher panels: R
did_multiplegt_dyn(..., by_path, controls)re-runs the per-baseline residualization on each path’s restricted subsample (path’s switchers + same-baseline not-yet-treated controls), so its residualization coefficients vary per path when switchers have different baseline values. Our global- residualization architecture coincides with R on single- baseline panels (every switcher shares the sameD_{g,1}) and per-path point estimates match exactly on the one- observation-per-(g, t)regime; on multi-observation-per- cell panels the existing DID^X cell-weighting deviation from R applies (seedocs/methodology/REGISTRY.md“Note (Phase 3 DID^X covariate adjustment)”; independent of the by_path lift). On multi-baseline switcher panels, point estimates can diverge — aUserWarningis emitted at fit-time when this configuration is detected. SE inherits the cross-path cohort- sharing deviation from R documented forpath_effects.Compatible with
trends_linear(DID^{fd} group-specific linear trends) – first-differencing replacesYwithZ = Y_t - Y_{t-1}once globally before path enumeration, so per-path raw second-differences DID^{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'}are surfaced on the newresults.path_cumulated_event_study[path][l]field (mirroring the globallinear_trends_effectscumulation; inner dict keyed by horizon directly, no"horizons"wrapper). SE on the cumulated layer is the conservative upper bound (sum of per-horizon component SEs, NaN-consistent), matching the globallinear_trends_effectsSE convention. Path enumeration runs on the post-first-differencedN_mat_fd: switchers withF_g==2fail the window-eligibility check and are dropped from path enumeration entirely, so a path whose switchers all haveF_g < 3is silently absent frompath_effects(the existing globalF_g < 3warning still fires). Per-path R parity matches Rdid_multiplegt_dyn(..., by_path, trends_lin)on per-path cumulated point estimates under single-baseline panels with sufficient pre-window depth (F_g >= 4for every selected- path switcher). R re-runs the per-path full pipeline on each path’s restricted subsample; same multi-baseline divergence pattern ascontrols(aUserWarningfires when switcher baselines take multiple values). F_g=3 boundary-case divergence: F_g=3 switchers have only 1 valid pre-window Z value after first-differencing and thetime==1filter, which causes Python’s global-then-disaggregate architecture to diverge from R’s per-path full-pipeline call (30%+ on point estimates observed empirically). A separateUserWarningfires at fit-time when the panel includes any F_g=3 switchers and by_path + trends_linear is set, so practitioners hitting this boundary regime see the divergence flag explicitly. Placebo under trends_linear returns RAW per-horizon values, not cumulated – there is no per-path placebo cumulation surface (verified empirically against R via the existingjoiners_only_trends_linparity scenario).Compatible with
trends_nonparam(state-set trends) – the set membership column is validated and stored once globally (time-invariance, NaN rejection, partition coarseness checks unchanged); per-path analytical SE, bootstrap SE, per-path placebos, and per-path sup-t bands all inherit the set-restricted control pool automatically through theset_idsparameter threaded through the per-path IF helpers. Per-path R parity matches Rdid_multiplegt_dyn(..., by_path, trends_nonparam)on per-path point estimates under single-baseline panels.Compatible with
n_bootstrap > 0– the top-k paths are enumerated once on the observed data (paths held fixed across bootstrap draws, matching Rdid_multiplegt_dyn(..., by_path, bootstrap=B)) and bootstrap SE / percentile CI / percentile p-value are written topath_effects[path]["horizons"][l]in place of the analytical fields. See REGISTRY.md for the full bootstrap contract.Compatible with
placebo=True– when both are active, per-path backward-horizon placebosDID^{pl}_{path, l}forl = 1..L_maxare surfaced onresults.path_placebo_event_study[path][-l](negative-int keys mirroringplacebo_event_study). The same per-path SE convention is applied backward (joiners/leavers IF precedent; cohort-recentered plug-in with path-specific divisor); the cross-path cohort-sharing deviation from R is inherited from the analytical event-study path.With
n_bootstrap > 0, per-path joint sup-t simultaneous confidence bands are also computed across horizons1..L_maxwithin each path. A path-specific critical valuec_p(constructed from a fresh shared-weights multiplier- bootstrap draw per path) is surfaced at top level asresults.path_sup_t_bands[path] = {"crit_value", "alpha", "n_bootstrap", "method", "n_valid_horizons"}, applied per-horizon ascband_conf_intonpath_effects[path]["horizons"][l], and rendered ascband_lower/cband_uppercolumns onresults.to_dataframe(level="by_path")(mirroring the OVERALLlevel="event_study"schema). Bands cover joint inference WITHIN a single path across horizons; they do NOT provide simultaneous coverage across paths. Python-only library extension; Rdid_multiplegt_dynprovides no joint bands at any surface. See REGISTRY.mdNote (Phase 3 by_path per-path joint sup-t bands).SE convention: per-path IF parallels the joiners / leavers construction — the switcher-side contribution is zeroed for groups not in the selected path, and the cohort structure and control pool are unchanged. Plug-in SE uses the path-specific divisor
N_l_path(count of path switchers eligible at horizonl), matching howjoiners_se/leavers_seuse their respective counts as divisors. See REGISTRY.mdChaisemartinDHaultfoeuilleNoteonby_pathfor the full contract.Results are exposed on
results.path_effectsas a dict keyed by the path tuple, with nested"horizons"dicts per horizonl. Also available viaresults.to_dataframe(level="by_path").paths_of_interest (list of tuple of int, optional, default=None) –
Explicit user-specified treatment paths to disaggregate by, as an alternative to
by_path=k’s top-k automatic ranking. Each path tuple must have lengthL_max + 1and represents the treatment trajectory in the window[F_g - 1, F_g, ..., F_g - 1 + L_max], e.g.[(0, 1, 1, 1), (0, 1, 0, 0)]for two paths underL_max=3. Mutually exclusive withby_path; setting both raisesValueError.Validation:
Each path element must be an
int(boolandnp.bool_rejected;np.integeraccepted and canonicalized to Pythonint).All paths must have the same length (uniformity validated at
__init__; length match againstL_max + 1validated at fit-time).Empty list raises
ValueError.Duplicate paths are deduplicated with a
UserWarning.A path with zero observed groups in the panel emits a
UserWarningand is omitted frompath_effects.
Compatible with non-binary integer treatment (paths can contain integer states like
(0, 2, 2)).Compatible with all downstream surfaces inherited by
by_path: bootstrap, per-path placebos, per-path joint sup-t bands,controls,trends_linear,trends_nonparam,survey_design(analytical Binder TSL + replicate-weight; multiplier bootstrap under survey remains gated, same asby_path=k), andheterogeneity(per-path heterogeneity coefficient surfaces onresults.path_heterogeneity_effects). Mechanical extension to path enumeration; no methodology change.Order semantics: paths appear in
results.path_effectsin the user-specified order, modulo deduplication and unobserved-path filtering.Python-only API extension; no R equivalent. R’s
did_multiplegt_dyn(..., by_path=k)only accepts a positive int (top-k) or-1(all paths); there is no list-based path selection in R.Results expose the same surfaces as
by_path:results.path_effects(dict keyed by path tuple),results.path_placebo_event_study,results.path_sup_t_bands,results.path_cumulated_event_study(undertrends_linear), and thelevel="by_path"DataFrame.rank_deficient_action (str, default="warn") – Action when the TWFE decomposition diagnostic OLS encounters a rank-deficient design matrix:
"warn","error", or"silent". Only used whentwfe_diagnostic=True.
- results_#
Estimation results after calling
fit().
Notes
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” in
REGISTRY.md.Examples
Basic single-switch panel:
>>> from diff_diff import ChaisemartinDHaultfoeuille >>> from diff_diff.prep_dgp import generate_reversible_did_data >>> data = generate_reversible_did_data(n_groups=80, n_periods=6, seed=42) >>> est = ChaisemartinDHaultfoeuille() >>> results = est.fit( ... data, outcome="outcome", group="group", ... time="period", treatment="treatment", ... ) >>> abs(results.overall_att - 2.0) < 1.0 # close to the true effect True
Methods
__init__([alpha, cluster, n_bootstrap, ...])fit(data, outcome, group, time, treatment[, ...])Fit the dCDH estimator on individual-level panel data.
get_params()Return all
__init__parameters as a dictionary.set_params(**params)Set estimator parameters (sklearn-compatible).
Attributes
n_bootstrapbootstrap_weightsalphaseed- __init__(alpha=0.05, cluster=None, n_bootstrap=0, bootstrap_weights='rademacher', seed=None, placebo=True, twfe_diagnostic=True, drop_larger_lower=True, by_path=None, paths_of_interest=None, rank_deficient_action='warn')[source]#
- classmethod __new__(*args, **kwargs)#