Continuous Difference-in-Differences#
Continuous DiD estimator for dose-response curves with continuous treatment intensity.
This module implements the methodology from Callaway, Goodman-Bacon & Sant’Anna (2024), “Difference-in-Differences with a Continuous Treatment” (NBER WP 32117), which:
Estimates dose-response curves: ATT(d) and ACRT(d) as functions of dose
Computes summary parameters: Overall ATT (binarized) and ACRT aggregated across doses
Uses B-spline smoothing: Flexible nonparametric estimation of dose-response functions
Supports multiplier bootstrap: Valid inference with proper standard errors and CIs
Note
Identification assumptions. The dose-response curves ATT(d) and ACRT(d),
as well as ATTglob and ACRTglob, require the Strong Parallel
Trends (SPT) assumption — that there is no selection into dose groups based on
treatment effects. Under the weaker standard Parallel Trends (PT) assumption,
only the binarized ATTloc (overall_att) is identified; it equals
ATTglob only when SPT holds. See Callaway, Goodman-Bacon & Sant’Anna
(2024), Assumptions 1–2.
When to use Continuous DiD:
Treatment varies in intensity or dose across units (not just binary on/off)
You want to estimate how effects change with treatment dose
Staggered adoption with heterogeneous dose levels
You need the full dose-response curve, not just a single average effect
Data requirements:
An untreated group (D = 0) must be present in the data
A balanced panel is required (all units observed in all time periods)
Dose must be time-invariant — each unit’s dose is fixed across periods
Reference: Callaway, B., Goodman-Bacon, A., & Sant’Anna, P. H. C. (2024). Difference-in-Differences with a Continuous Treatment. NBER Working Paper 32117. https://www.nber.org/papers/w32117
ContinuousDiD#
Main estimator class for Continuous Difference-in-Differences.
- class diff_diff.ContinuousDiD[source]
Bases:
_ContinuousDiDAggregationMixin,BaseEstimatorContinuous Difference-in-Differences estimator.
Implements the methodology from Callaway, Goodman-Bacon & Sant’Anna (2024) for estimating dose-response curves when treatment has a continuous intensity.
- Parameters:
degree (int, default=3) – B-spline degree (3 = cubic).
num_knots (int, default=0) – Number of interior knots for the B-spline basis.
dvals (array-like, optional) – Custom dose evaluation grid. If None, uses quantile-based default.
control_group (str, default="never_treated") –
"never_treated","not_yet_treated", or"lowest_dose"."lowest_dose"implements Remark 3.1 (CGBS 2024) for settings with no never-treated / zero-dose units (P(D=0) = 0): the lowest-dose groupd_Lbecomes the comparison and the estimand isATT(d) − ATT(d_L). Requires a genuine lowest-dose group (>= 2units atd_L, i.e.P(D=d_L) > 0) and no never-treated units present. Single-cohort only (multi-cohort andcovariates=raiseNotImplementedError).anticipation (int, default=0) – Number of periods of treatment anticipation. Must be a non-negative integer;
boolis rejected.base_period (str, default="varying") –
"varying"or"universal".alpha (float, default=0.05) – Significance level for confidence intervals.
n_bootstrap (int, default=0) – Number of multiplier bootstrap iterations. 0 for analytical SEs only.
bootstrap_weights (str, default="rademacher") – Bootstrap weight type:
"rademacher","mammen", or"webb".seed (int, optional) – Random seed for reproducibility.
rank_deficient_action (str, default="warn") – Action for rank-deficient B-spline OLS:
"warn","error", or"silent".covariates (list of str, optional) – DEPRECATED constructor home (row M-084; warns with
FutureWarning, removed in 4.0) - passcovariates=tofit()instead (the sklearn hyperparameter/data split). Column names of covariates for conditional parallel trends (E[ΔY(0) | D=d, X] = E[ΔY(0) | D=0, X]). WhenNone(default) the estimator uses unconditional parallel trends. Covariates enter through a covariate-adjusted per-cell control counterfactual (seeestimation_method). Covariates are read from the base period of each(g, t)comparison. Not currently composable withsurvey_design=(raisesNotImplementedError).estimation_method (str, default="dr") – Covariate-adjustment method (only used when
covariatesis set):"reg"(outcome regression) or"dr"(doubly-robust, the default)."ipw"is not supported on the dose / event-study aggregation — pure IPW’s covariate adjustment is a single scalar level shift, so it cannot adjust the dose-response shape (ACRT(d) would be identical to the unconditional fit); it raisesNotImplementedError.reganddrshare the dose-response shape and ACRT(d);drdiffers only in theoverall_att/ ATT(d) level and in its doubly-robust standard errors.pscore_trim (float, default=0.01) – Propensity-score trimming bound for the
drpath (scores clipped to[pscore_trim, 1 - pscore_trim]). Must be in(0, 0.5)and large enough that1 - pscore_trim < 1in float64 (a sub-ulp trim would disable the upper clip).epv_threshold (float, default=10.0) – Events-per-variable threshold for the
drpropensity logit diagnostics.pscore_fallback (str, default="error") – Action when
drpropensity estimation raises (the logit IRLS fails with aLinAlgError/ValueError, e.g. perfect separation or rank deficiency):"error"(re-raise — the default, fail-closed so a dr fit never silently degrades to a non-DR estimate) or"unconditional"(fall back to an unconditional propensity with a warning; the affected cells are then reg-like — use only when you knowingly accept that). Note: low events-per-variable emits a diagnostic warning but does not itself trigger the fallback.treatment_type (str, default="continuous") – Dose-response model:
"continuous"(B-spline sieve, the default) or"discrete"(saturated per-dose-level regression, CGBS 2024 Eq. 4.1). On the discrete path each distinct dose level gets its own effect coefficient —ATT(d_j) = mean_{D=d_j}(ΔY) − control(a per-level 2×2 DiD) — andACRT(d_j)is the paper’s backward finite difference on the grid{0, d_1, ..., d_J}(ACRT(d_1) = ATT(d_1)/d_1, so a binary doseD in {0, 1}givesACRT = ATT). It composes withcovariatesandsurvey_designand reduces to the per-level 2×2 DiD standard error. Multi-cohort fits must share the same dose support across cohorts (elseNotImplementedError); an off-supportdvalsvalue raisesValueError.
Examples
>>> from diff_diff import ContinuousDiD, generate_continuous_did_data >>> data = generate_continuous_did_data(n_units=200, seed=42) >>> est = ContinuousDiD(n_bootstrap=199, seed=42) >>> results = est.fit(data, outcome="outcome", unit="unit", ... time="period", first_treat="first_treat", ... dose="dose") >>> results.overall_att >>> results.aggregate("dose")
Methods
fit(data, outcome, unit, time, first_treat, dose)Fit the continuous DiD estimator.
get_params([deep])Get estimator parameters (sklearn-compatible).
set_params(**params)Set estimator parameters (sklearn-compatible, transactional).
- __init__(degree=3, num_knots=0, dvals=None, control_group='never_treated', anticipation=0, base_period='varying', alpha=0.05, n_bootstrap=0, bootstrap_weights='rademacher', seed=None, rank_deficient_action='warn', covariates=None, estimation_method='dr', pscore_trim=0.01, epv_threshold=10.0, pscore_fallback='error', treatment_type='continuous')[source]
- Parameters:
degree (int)
num_knots (int)
dvals (ndarray | None)
control_group (str)
anticipation (int)
base_period (str)
alpha (float)
n_bootstrap (int)
bootstrap_weights (str)
seed (int | None)
rank_deficient_action (str)
estimation_method (str)
pscore_trim (float)
epv_threshold (float)
pscore_fallback (str)
treatment_type (str)
- alpha: float
- fit(data, outcome, unit, time, first_treat, dose, aggregate=<not supplied>, survey_design=None, covariates=None)[source]
Fit the continuous DiD estimator.
- Parameters:
data (pd.DataFrame) – Panel data.
outcome (str) – Outcome column name.
unit (str) – Unit identifier column.
time (str) – Time period column.
first_treat (str) – First treatment period column (0 or inf for never-treated).
dose (str) – Continuous dose column.
aggregate (str, optional) – DEPRECATED (row M-025, removed in 4.0) - aggregate as a post-fit step instead:
results.aggregate('event_study')for the binarized event study (underscored - the"eventstudy"spelling dies with this parameter), orresults.aggregate('dose')/results.aggregate('simple')views. The dose-response curves and overall ATT/ACRT are always computed byfit(), soaggregate="dose"was already a no-op. Supplying ANY value (includingNone) warnsFutureWarning; supplied values still run the legacy routing unchanged until 4.0.survey_design (SurveyDesign, optional) – Survey design specification for design-based inference. Supports weighted estimation and Taylor series linearization variance with strata, PSU, and FPC.
covariates (list of str, optional) – Covariate column names for the conditional-parallel-trends estimand (the canonical fit-level home - row M-084). The deprecated constructor
covariates=still routes and warns; supplying both raisesValueError.
- Return type:
- get_params(deep=True)
Get estimator parameters (sklearn-compatible).
- Parameters:
deep (bool, default True) – Accepted for sklearn compatibility (
sklearn.base.clonecallsget_params(deep=False)) and ignored: no diff-diff estimator nests another estimator.- Returns:
Estimator parameters suitable for passing to
__init__. Keys follow the__init__signature order. Where a class stores a parameter’s raw value under a different attribute (_PARAM_ATTR_ALIASES), the raw value is returned so thattype(est)(**est.get_params())reconstructs the same configuration.- Return type:
Dict[str, Any]
- set_params(**params)
Set estimator parameters (sklearn-compatible, transactional).
Validates the merged configuration by constructing a throwaway instance of
type(self)- so a rejected call raises the same error__init__would and leaves this estimator unchanged - then adopts the probe’s parameter attributes and the class’s declared derived-config attributes. Fitted state is never touched.- Parameters:
**params (Any) – Estimator parameters. Unknown names raise
ValueErrorbefore any validation or mutation.self (TSelf)
**params
- Return type:
self
ContinuousDiDResults#
Results container for Continuous DiD estimation.
- class diff_diff.continuous_did_results.ContinuousDiDResults[source]
Bases:
BaseResults,AggregationMixinResults from Continuous Difference-in-Differences estimation.
Implements Callaway, Goodman-Bacon & Sant’Anna (2024).
- dose_response_att
ATT(d) dose-response curve.
- Type:
- dose_response_acrt
ACRT(d) dose-response curve.
- Type:
- overall_att
Binarized overall ATT (ATT^{loc} under PT, equals ATT^{glob} under SPT).
- Type:
- overall_acrt
Plug-in overall ACRT^{glob}.
- Type:
- group_time_effects
Per (g,t) cell results.
- Type:
- base_period
Base period strategy (
"varying"or"universal").- Type:
- anticipation
Number of anticipation periods.
- Type:
- n_bootstrap
Number of bootstrap iterations used.
- Type:
- bootstrap_weights
Bootstrap weight type (
"rademacher","mammen", or"webb").- Type:
- seed
Random seed used for bootstrap.
- Type:
int or None
- rank_deficient_action
How rank deficiency is handled (
"warn","error","silent").- Type:
- event_study_df
Scalar survey df governing the event-study rows’ t-inference.
Noneon non-survey fits, on bootstrapped fits, when no fit-time event-study surface was built, and for the replicate-undefined0sentinel.- Type:
float or None
Methods
summary([alpha])Generate formatted summary.
print_summary([alpha])Print summary to stdout.
to_dataframe([level])Convert results to DataFrame.
aggregate(type[, weights, balance_e])Re-aggregate this fit without refitting.
- dose_response_att: DoseResponseCurve
- dose_response_acrt: DoseResponseCurve
- overall_att: float
- overall_att_se: float
- overall_att_t_stat: float
- overall_att_p_value: float
- overall_acrt: float
- overall_acrt_se: float
- overall_acrt_t_stat: float
- overall_acrt_p_value: float
- dose_grid: ndarray
- n_obs: int
- n_treated_units: int
- n_control_units: int
- alpha: float = 0.05
- control_group: str = 'never_treated'
- degree: int = 3
- num_knots: int = 0
- base_period: str = 'varying'
- anticipation: int = 0
- n_bootstrap: int = 0
- bootstrap_weights: str = 'rademacher'
- rank_deficient_action: str = 'warn'
- estimation_method: str = 'dr'
- pscore_trim: float = 0.01
- epv_threshold: float = 10.0
- pscore_fallback: str = 'error'
- treatment_type: str = 'continuous'
- property att: float
- property se: float
- property p_value: float
- property t_stat: float
- property overall_se: float
- property overall_p_value: float
- property overall_t_stat: float
- property coef_var: float
SE / abs(overall ATT). NaN when ATT is 0 or SE non-finite.
- Type:
Coefficient of variation
- summary(alpha=None)[source]
Generate formatted summary.
- print_summary(alpha=None)[source]
Print summary to stdout.
- Parameters:
alpha (float | None)
- Return type:
None
- to_dict()[source]
Convert headline results to a dictionary.
- Returns:
Canonical ATT inference row, the ACRT companion estimand, and scalar metadata. Detailed dose-response / event-study tables are available via
to_dataframe(level=...).- Return type:
Dict[str, Any]
- to_dataframe(level='dose_response')[source]
Convert results to DataFrame.
- property is_significant: bool
Check if overall ATT is significant.
- property significance_stars: str
Significance stars for overall ATT.
- __init__(dose_response_att, dose_response_acrt, overall_att, overall_att_se, overall_att_t_stat, overall_att_p_value, overall_att_conf_int, overall_acrt, overall_acrt_se, overall_acrt_t_stat, overall_acrt_p_value, overall_acrt_conf_int, group_time_effects, dose_grid, groups, time_periods, n_obs, n_treated_units, n_control_units, alpha=0.05, control_group='never_treated', degree=3, num_knots=0, base_period='varying', anticipation=0, n_bootstrap=0, bootstrap_weights='rademacher', seed=None, rank_deficient_action='warn', covariates=None, estimation_method='dr', pscore_trim=0.01, epv_threshold=10.0, pscore_fallback='error', treatment_type='continuous', reference_dose=None, event_study_effects=None, survey_metadata=None, _aggregation_kit=None, event_study_df=None)
- Parameters:
dose_response_att (DoseResponseCurve)
dose_response_acrt (DoseResponseCurve)
overall_att (float)
overall_att_se (float)
overall_att_t_stat (float)
overall_att_p_value (float)
overall_acrt (float)
overall_acrt_se (float)
overall_acrt_t_stat (float)
overall_acrt_p_value (float)
dose_grid (ndarray)
n_obs (int)
n_treated_units (int)
n_control_units (int)
alpha (float)
control_group (str)
degree (int)
num_knots (int)
base_period (str)
anticipation (int)
n_bootstrap (int)
bootstrap_weights (str)
seed (int | None)
rank_deficient_action (str)
estimation_method (str)
pscore_trim (float)
epv_threshold (float)
pscore_fallback (str)
treatment_type (str)
reference_dose (float | None)
survey_metadata (Any | None)
_aggregation_kit (Any | None)
event_study_df (float | None)
- Return type:
None
DoseResponseCurve#
Dose-response curve container for ATT(d) or ACRT(d).
- class diff_diff.continuous_did_results.DoseResponseCurve[source]
Bases:
objectDose-response curve from continuous DiD estimation.
- dose_grid
Evaluation points, shape
(n_grid,).- Type:
np.ndarray
- effects
ATT(d) or ACRT(d) values, shape
(n_grid,).- Type:
np.ndarray
- se
Standard errors, shape
(n_grid,).- Type:
np.ndarray
- conf_int_lower
Lower CI bounds, shape
(n_grid,).- Type:
np.ndarray
- conf_int_upper
Upper CI bounds, shape
(n_grid,).- Type:
np.ndarray
- target
"att"or"acrt".- Type:
Methods
to_dataframe()Convert to DataFrame with dose, effect, se, CI, t_stat, p_value.
- dose_grid: ndarray
- effects: ndarray
- se: ndarray
- conf_int_lower: ndarray
- conf_int_upper: ndarray
- target: str
- n_bootstrap: int = 0
- to_dataframe()[source]
Convert to DataFrame with dose, effect, se, CI, t_stat, p_value.
- Return type:
- __init__(dose_grid, effects, se, conf_int_lower, conf_int_upper, target, p_value=None, n_bootstrap=0, df_survey=None)
Example Usage#
Basic usage:
from diff_diff import ContinuousDiD, generate_continuous_did_data
data = generate_continuous_did_data(n_units=200, seed=42)
est = ContinuousDiD(n_bootstrap=199, seed=42)
results = est.fit(data, outcome='outcome', unit='unit',
time='period', first_treat='first_treat',
dose='dose')
results.print_summary()
Accessing dose-response curves (always computed by fit()):
# ATT(d) dose-response curve as DataFrame
att_df = results.dose_response_att.to_dataframe()
print(att_df[['dose', 'effect', 'se', 'p_value']])
# ACRT(d) dose-response curve
acrt_df = results.dose_response_acrt.to_dataframe()
# Overall summary parameters
print(f"Overall ATT: {results.overall_att:.3f} (SE: {results.overall_att_se:.3f})")
print(f"Overall ACRT: {results.overall_acrt:.3f} (SE: {results.overall_acrt_se:.3f})")
# Or as post-fit aggregation tables (views - work on any fit):
dose_table = results.aggregate('dose') # ATT(d) + ACRT(d) rows
overall = results.aggregate('simple') # att + acrt overall rows
Event study aggregation (post-fit, row M-025):
# Dynamic effects (binarized ATT by relative period). The event-study
# recompute needs an ANALYTICAL fit - on a bootstrapped fit
# aggregate('event_study') raises; the deprecated fit-time
# aggregate='eventstudy' remains available for that case until 4.0.
est_es = ContinuousDiD(seed=42)
results_es = est_es.fit(data, outcome='outcome', unit='unit',
time='period', first_treat='first_treat',
dose='dose')
es = results_es.aggregate('event_study')
es_df = es.to_dataframe()
Comparison with CallawaySantAnna#
Feature |
ContinuousDiD |
CallawaySantAnna |
|---|---|---|
Treatment type |
Continuous dose / intensity |
Binary (treated / not treated) |
Target parameter |
ATTloc (PT); ATT(d), ACRT(d), ATTglob, ACRTglob (SPT) |
ATT(g,t), aggregated ATT |
Smoothing |
B-spline basis for dose-response |
None (nonparametric group-time) |
Dose-response curve |
Yes (full curve with CIs) |
No |
Bootstrap |
Multiplier bootstrap (optional) |
Multiplier bootstrap (optional) |
Control group |
never_treated / not_yet_treated / lowest_dose |
never_treated / not_yet_treated |