Duration DiD (Deaner & Ku)#
Causal duration analysis with difference-in-differences for a binary
absorbing outcome — a spell that ends (job found, subscription cancelled,
patient discharged) and stays ended — in a two-group, common-timing design.
Standard DiD on the cumulative event indicator imposes a constant gap in
event probabilities, which mechanically forces the survivors’ hazards to
diverge (Deaner & Ku 2026, Appendix A.1). DurationDiD instead restricts
the two groups’ untreated hazards:
method="cd"(common dynamics): a constant additive hazard gapc.method="ph"(proportional hazards): a constant hazard ratioc.
The coefficient is fitted from the pre-treatment cumulative hazards (default:
equal weights over every eligible pre-treatment date after the baseline;
pre_periods= / pre_period_weights= select a window), the treated
group’s counterfactual survival is imputed from the control group’s cumulative
hazard and the treated baseline (Theorem 1), and the absorption ATT
E[Y_t - Y_t(0) | treated] is reported at every post-treatment date
(positive = more cumulative exit than the counterfactual). The headline
att is the uniform average over the post-treatment dates.
Inference is the paper’s whole-individual pooled bootstrap (Appendix B,
Algorithm 1): each draw resamples complete histories, recomputes everything,
and the reported pointwise intervals and simultaneous (max-|t|) band are
centered absolute-deviation bands. The Algorithm 2 fixed-anchor
pre-treatment specification test is reported separately in
results.pretest. Every inference family is either fully available or
fully withheld with a named inference_status; failed draws are never
retried or silently dropped.
Note
Requirements: exactly one row per (individual, date) on a common, equally
spaced real numeric time grid; a fixed real numeric or boolean 0/1 group
indicator; a real numeric or boolean 0/1 absorbing
outcome (baseline absorption is allowed and those individuals stay in the
estimand). last_pre_period (the last untreated date) is required and
never inferred. Dates are matched by exact numeric identity. Integer
labels retain their identity across the supported signed/unsigned dtype
ranges (including nullable integer columns without missing values).
Spacings and offsets are subtracted before conversion to float64 elapsed
durations; the spacing check uses relative tolerance 1e-8 and zero absolute
tolerance, and elapsed durations must remain finite and strictly increasing.
Floating labels must be finite and exactly representable as float64
(including float32 and exactly representable longdouble values). Object,
string, boolean and complex time columns are rejected. Inference uses
float64 elapsed arithmetic and cannot reconstruct precision already lost
when callers created floating labels. Date selectors reject booleans,
strings, complex and nonfinite values; fitting weights must be finite,
real and nonnegative.
Covariates, staggered adoption, censoring, survey weights
and cluster dependence are not supported in this version; the CD model
can extrapolate an invalid counterfactual curve (survival above one or a
decreasing cumulative hazard), which is reported through
curve_status / period_status with the post-period inference
withheld. The warning names the remedy that applies: a violation at a
fitted pre-treatment date cannot be repaired by a shorter horizon (change
method or the fitting window); a violation at a later post date admits
an explicit refit on the dates before the first invalid post date; a
violation at the first post date admits no valid shorter horizon. See
docs/methodology/REGISTRY.md for the full contract.
When to use DurationDiD:
A binary, absorbing outcome observed over time for a treated and a control group, with a common intervention date
You want the effect on the probability of having exited by each post-treatment date, identified through hazards rather than outcome levels
Individuals are independent draws (the bootstrap resamples individuals)
Reference: Deaner, B., & Ku, H. (2026). Causal Duration Analysis with Diff-in-Diff. arXiv:2405.05220v2. https://arxiv.org/abs/2405.05220v2
DurationDiD#
Main estimator class.
- class diff_diff.DurationDiD[source]
Bases:
BaseEstimatorDuration difference-in-differences (Deaner & Ku 2026) for absorbing outcomes.
Two-group, common-timing design:
treatmentis a FIXED 0/1 group indicator,outcomeis a binary absorbing spell-ended indicator on a balanced, equally spaced numeric time grid, andlast_pre_periodis the last untreated date. The counterfactual treated survival is imputed from the control group under a restriction on the untreated hazards:method="cd"(common dynamics): the untreated hazards differ by a constant additive gapc(Equation 2.3); fitted as the weighted mean of the pre-treatment average-hazard gaps (Equations 3.2-3.4).method="ph"(proportional hazards): the untreated hazards are proportional, ratioc(Equation 2.4); fitted as the weighted mean of the pre-treatment cumulative-increment ratios (Theorem 1).
The reported effect at each post-treatment date is the absorption ATT
E[Y_it - Y_it(0) | treated](positive = more cumulative exit); the headlineattis its uniform average over the post-treatment dates. Inference is the Appendix B whole-individual pooled bootstrap with centered absolute-deviation pointwise and simultaneous (max-|t|) bands, plus the Algorithm 2 fixed-anchor pre-treatment specification test.- Parameters:
method ({"cd", "ph"}, default="cd") – Untreated-hazard restriction.
n_bootstrap (int, default=1000) – Whole-individual bootstrap draws.
0returns point estimates with NaN inference; otherwise at least2.alpha (float, default=0.05) – Significance level for every band and the pretest.
seed (int, optional) – Seed for
numpy.random.default_rng.
Notes
Identification requires binary absorbing outcomes, a fixed population, no anticipation before the common intervention, unaffected controls, and the chosen hazard restriction on the UNTREATED hazards (not on outcome levels). Bootstrap validity additionally assumes independence across individuals with arbitrary serial dependence within each history. Covariates, staggered adoption, censoring, survey weights and cluster dependence are not supported in this version. Fitting dates default to every eligible pre-treatment date after the baseline with equal weights;
fit(pre_periods=..., pre_period_weights=...)selects a subset and nonnegative weights (seefit()).Methods
fit(data, outcome, unit, time, treatment, *, ...)Fit the estimator on a balanced long individual panel.
get_params([deep])Get estimator parameters (sklearn-compatible).
set_params(**params)Set estimator parameters (sklearn-compatible, transactional).
- __init__(method='cd', n_bootstrap=1000, alpha=0.05, seed=None)[source]
- fit(data, outcome, unit, time, treatment, *, last_pre_period, pre_periods=None, pre_period_weights=None)[source]
Fit the estimator on a balanced long individual panel.
- Parameters:
data (pd.DataFrame) – Long panel with exactly one row per (individual, date).
outcome (str) – Real numeric or boolean absorbing spell-ended indicator (0/1; once 1, always 1 within an individual). Baseline absorption is allowed.
unit (str) – Individual identifier column.
time (str) – Real numeric calendar or elapsed-duration column; every individual must be observed at the same equally spaced dates (relative tolerance 1e-8, zero absolute tolerance). Signed/unsigned integer labels, including nullable integer dtypes without missing values, retain exact identity across their dtype’s range. Differences are computed before conversion to float64 elapsed durations, which must remain finite and strictly increasing. Floating labels must be finite and exactly representable as float64; this includes float32 and exactly representable longdouble values. Object, string, boolean and complex time columns are not supported. Precision already lost in caller-created floats cannot be recovered.
treatment (str) – Fixed real numeric or boolean 0/1 group indicator (constant within individual).
last_pre_period (value of
time) – The last untreated date (tstar); the intervention occurs strictly afterwards. Matched by exact numeric identity, never inferred from the data. Boolean, string, complex and nonfinite date selectors are rejected.pre_periods (list of
timevalues, optional) – Pre-treatment dates used to fit the hazard relationship (strictly after the baseline, at or beforelast_pre_period). Default: every eligible pre-treatment date after the baseline. Dates use the same exact numeric matching aslast_pre_period.pre_period_weights (array-like, optional) – Finite real nonnegative weights aligned with
pre_periods(normalized to sum to one; a zero weight drops that date). Requirespre_periods. Default: equal weights over the eligible set.
- 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
DurationDiDResults#
Results container: headline inference, per-date effects with pointwise and simultaneous bands, survival curves, fitted coefficient, bootstrap diagnostics, and the pretest.
- class diff_diff.duration_did_results.DurationDiDResults[source]
Bases:
BaseResults,AggregationMixinResults of
DurationDiD.The headline
attis the uniform average over the post-treatment dates of the absorption ATTE[Y_t - Y_t(0) | treated](positive = more cumulative absorption/exit than the counterfactual). Per-date effects and both band families live in the*_by_period/cband_*fields. Inference is whole-individual pooled bootstrap only; every family is either available (inference_status == "ok") or fully withheld.- att, se, t_stat, p_value, conf_int
Headline inference (centered-bootstrap SD, p-value and symmetric interval).
p_value <= alphais exactly the band rule.
- method
"cd"or"ph".- Type:
- n_units
Individuals (the bootstrap resampling unit).
- Type:
- n_obs
Panel rows used (
n_units * n_periods).- Type:
- n_treated, n_control
Individuals per group.
- Type:
- periods
The common time grid (all dates, sorted).
- Type:
np.ndarray
- last_pre_period
The last untreated date (
tstar).- Type:
Any
- post_periods
Dates strictly after
last_pre_period(the declared post family).- Type:
np.ndarray
- pre_periods, pre_period_weights
The realized fitting dates and their normalized weights.
- Type:
np.ndarray
- excluded_pre_periods
Candidate fitting dates that were excluded, with the reason.
- Type:
- coefficient
Fitted CD gap
c(per unit of time) or PH ratioc.- Type:
- survival_treated, survival_control, counterfactual_survival
Group survival curves and the imputed treated counterfactual over the whole grid (pre-dates are the fitted values); NaN where no counterfactual exists or the value is non-finite.
- Type:
np.ndarray
- att_by_period, se_by_period, t_stat_by_period, p_value_by_period
Per post-date effect and pointwise inference.
- conf_int_by_period
(P, 2)pointwise intervals.- Type:
np.ndarray
- pointwise_crit_values, cband_lower, cband_upper, cband_crit_value
Pointwise critical values and the simultaneous band.
- joint_p_value
Simultaneous p-value for the null that every post-date effect is zero.
- Type:
- vcov
(P, P)bootstrap covariance of the post-date effects;Nonewhenever the post family is unavailable.- Type:
np.ndarray or None
- curve_status, period_status
Validity of the imputed counterfactual curve at every date / every post-date (
"ok","control_survival_zero","counterfactual_nonfinite","counterfactual_survival_above_one","counterfactual_nonmonotone").
- inference_status
"ok","disabled","unavailable_invalid_periods","unavailable_failed_draws"or"unavailable_zero_se".- Type:
- bootstrap_effects
(n_bootstrap, P)raw post-effect draws (NaN rows for failed draws). Statistics of the completed draws are diagnostics, never inference.- Type:
np.ndarray or None
- bootstrap_failure_reasons
{"post": {reason: count}, "pretest": {reason: count}}.- Type:
- n_draws_invalid_counterfactual
Complete post-family draws whose imputed counterfactual curve left the domain (survival above one or a decreasing cumulative hazard). Such draws are well-defined statistics and are NOT failures, but a large share signals weak support for the CD extrapolation; reported in
summary()as a count and a share of the complete draws.- Type:
- pretest
The Algorithm 2 specification test (always populated).
Methods
summary([alpha])Formatted summary.
to_dict()JSON-serializable dictionary (arrays as lists, scalars native).
to_dataframe([level])Tabular view.
aggregate(type[, weights, balance_e])Re-aggregate this fit without refitting.
- att: float
- se: float
- t_stat: float
- p_value: float
- method: str
- alpha: float
- n_bootstrap: int
- n_bootstrap_valid: int
- n_bootstrap_valid_pretest: int
- n_units: int
- n_obs: int
- n_treated: int
- n_control: int
- n_periods: int
- periods: ndarray
- last_pre_period: Any
- post_periods: ndarray
- pre_periods: ndarray
- pre_period_weights: ndarray
- coefficient: float
- ph_ratio_boundary: bool
- n_treated_survivors_at_last_pre: int
- n_control_survivors_at_horizon: int
- survival_treated: ndarray
- survival_control: ndarray
- counterfactual_survival: ndarray
- att_by_period: ndarray
- se_by_period: ndarray
- t_stat_by_period: ndarray
- p_value_by_period: ndarray
- conf_int_by_period: ndarray
- pointwise_crit_values: ndarray
- cband_lower: ndarray
- cband_upper: ndarray
- cband_crit_value: float
- joint_p_value: float
- inference_status: str
- n_draws_invalid_counterfactual: int
- pretest: DurationDiDPretestResults
- property is_significant: bool
the headline centered-bootstrap interval excludes zero.
Under the inverse-empirical-CDF quantile this is exactly
p_value <= alpha;Falsewhen inference is unavailable.- Type:
Band rule
- property significance_stars: str
Significance stars for the headline p-value (”” when NaN).
- to_dict()[source]
JSON-serializable dictionary (arrays as lists, scalars native).
- to_dataframe(level='periods')[source]
Tabular view.
- Parameters:
level ({"periods", "att"}) –
"periods": one row per post-treatment date with the effect, pointwise inference, simultaneous band and validity status."att": the single headline row.- Return type:
- summary(alpha=None)[source]
Formatted summary.
alphamust equal the fit alpha.
- print_summary(alpha=None)[source]
Print
summary().- Parameters:
alpha (float | None)
- Return type:
None
- __init__(att, se, t_stat, p_value, conf_int, method, alpha, n_bootstrap, n_bootstrap_valid, n_bootstrap_valid_pretest, seed, n_units, n_obs, n_treated, n_control, n_periods, periods, last_pre_period, post_periods, pre_periods, pre_period_weights, excluded_pre_periods, coefficient, ph_ratio_boundary, n_treated_survivors_at_last_pre, n_control_survivors_at_horizon, survival_treated, survival_control, counterfactual_survival, att_by_period, se_by_period, t_stat_by_period, p_value_by_period, conf_int_by_period, pointwise_crit_values, cband_lower, cband_upper, cband_crit_value, joint_p_value, vcov, curve_status, period_status, inference_status, bootstrap_effects, bootstrap_failure_reasons, n_draws_invalid_counterfactual, pretest)
- Parameters:
att (float)
se (float)
t_stat (float)
p_value (float)
method (str)
alpha (float)
n_bootstrap (int)
n_bootstrap_valid (int)
n_bootstrap_valid_pretest (int)
seed (int | None)
n_units (int)
n_obs (int)
n_treated (int)
n_control (int)
n_periods (int)
periods (ndarray)
last_pre_period (Any)
post_periods (ndarray)
pre_periods (ndarray)
pre_period_weights (ndarray)
coefficient (float)
ph_ratio_boundary (bool)
n_treated_survivors_at_last_pre (int)
n_control_survivors_at_horizon (int)
survival_treated (ndarray)
survival_control (ndarray)
counterfactual_survival (ndarray)
att_by_period (ndarray)
se_by_period (ndarray)
t_stat_by_period (ndarray)
p_value_by_period (ndarray)
conf_int_by_period (ndarray)
pointwise_crit_values (ndarray)
cband_lower (ndarray)
cband_upper (ndarray)
cband_crit_value (float)
joint_p_value (float)
vcov (ndarray | None)
inference_status (str)
bootstrap_effects (ndarray | None)
n_draws_invalid_counterfactual (int)
pretest (DurationDiDPretestResults)
- Return type:
None
DurationDiDPretestResults#
The Algorithm 2 fixed-anchor pre-treatment specification test.
- class diff_diff.duration_did_results.DurationDiDPretestResults[source]
Bases:
DiagnosticAppendix B Algorithm 2 pre-treatment specification test.
For every interior pre-treatment date
t(strictly between the baseline andanchor_period, the last pre-treatment date) the contrast compares the hazard relationship attwith its value at the fixed anchor: the average-hazard GAP undermethod="cd"and the cumulative-increment RATIO undermethod="ph". The whole-individual bootstrap gives one SD per contrast, a simultaneous critical value from the maximum absolute centered pivot, symmetric bandscontrast +/- crit_value * seand themax-|t|statistic. The test rejects when any band excludes zero, which is exactlyp_value <= alphaunder the inverse-empirical-CDF quantile.- method
"cd"or"ph".- Type:
- periods
The tested interior pre-treatment dates (
J).- Type:
np.ndarray
- anchor_period
The last pre-treatment date (the fixed anchor).
- Type:
Any
- contrast
Point contrasts, one per tested date (NaN when unavailable).
- Type:
np.ndarray
- se
Bootstrap SDs of the contrasts (NaN when unavailable).
- Type:
np.ndarray
- band_lower, band_upper
Simultaneous bands at level
1 - alpha.- Type:
np.ndarray
- crit_value
Simultaneous critical value (NaN when unavailable).
- Type:
- statistic
max |contrast / se|over the tested dates.- Type:
- p_value
Empirical tail fraction of the bootstrap maxima at or above
statistic(NaN when unavailable).- Type:
- reject
Truewhen any band excludes zero;Nonewhen unavailable.- Type:
bool or None
- alpha
Significance level.
- Type:
- n_bootstrap, n_bootstrap_valid
Requested draws and complete draws for this family.
- Type:
- status
"ok","disabled"(n_bootstrap=0),"unavailable_insufficient_pre_periods"(no interior pre-date),"unavailable_ph_support"(a zero control increment at a tested date or the anchor under PH),"unavailable_nonfinite_moments","unavailable_failed_draws", or"unavailable_zero_se".- Type:
Methods
summary()Human-readable summary.
to_dataframe()One row per tested pre-treatment date.
to_dict()JSON-serializable dictionary.
- method: str
- periods: ndarray
- anchor_period: Any
- contrast: ndarray
- se: ndarray
- band_lower: ndarray
- band_upper: ndarray
- crit_value: float
- statistic: float
- p_value: float
- alpha: float
- n_bootstrap: int
- n_bootstrap_valid: int
- status: str
- __init__(method, periods, anchor_period, contrast, se, band_lower, band_upper, crit_value, statistic, p_value, reject, alpha, n_bootstrap, n_bootstrap_valid, status)
Example Usage#
Every block below builds its own absorbing panel from population survival curves (one uniform draw per individual), so it runs on its own.
Basic CD fit with bootstrap inference:
import numpy as np
import pandas as pd
from diff_diff import DurationDiD
rng = np.random.default_rng(0)
n, T = 500, 8
control = 0.8 * np.exp(-np.cumsum(np.r_[0.0, 0.15 + 0.02 * np.arange(2, T + 1)]))
treated = 0.6 * np.exp(-np.cumsum(np.r_[0.0, 0.20 + 0.02 * np.arange(2, T + 1)
+ 0.3 * (np.arange(2, T + 1) > 4)]))
group = np.repeat([1, 0], n)
curves = np.where(group[:, None] == 1, treated[None, :], control[None, :])
exited = (rng.uniform(size=2 * n)[:, None] > curves).astype(int)
data = pd.DataFrame({
"unit": np.repeat(np.arange(2 * n), T),
"time": np.tile(np.arange(1, T + 1), 2 * n),
"treated": np.repeat(group, T),
"exited": exited.ravel(),
})
results = DurationDiD(method="cd", n_bootstrap=200, seed=42).fit(
data, outcome="exited", unit="unit", time="time", treatment="treated",
last_pre_period=4,
)
print(results.summary())
print(results.to_dataframe()) # per-date ATT, CI, simultaneous band
print(results.pretest.summary()) # Algorithm 2 specification test
Proportional hazards with a fitting window on the last two pre-treatment dates and explicit weights:
import numpy as np
import pandas as pd
from diff_diff import DurationDiD
rng = np.random.default_rng(1)
n, T = 400, 8
control = 0.8 * np.exp(-np.cumsum(np.r_[0.0, 0.15 + 0.02 * np.arange(2, T + 1)]))
treated = 0.6 * np.exp(-np.cumsum(np.r_[0.0, 1.5 * (0.15 + 0.02 * np.arange(2, T + 1))
+ 0.3 * (np.arange(2, T + 1) > 4)]))
group = np.repeat([1, 0], n)
curves = np.where(group[:, None] == 1, treated[None, :], control[None, :])
data = pd.DataFrame({
"unit": np.repeat(np.arange(2 * n), T),
"time": np.tile(np.arange(1, T + 1), 2 * n),
"treated": np.repeat(group, T),
"exited": (rng.uniform(size=2 * n)[:, None] > curves).astype(int).ravel(),
})
results = DurationDiD(method="ph", n_bootstrap=100, seed=7).fit(
data, outcome="exited", unit="unit", time="time", treatment="treated",
last_pre_period=4, pre_periods=[3, 4], pre_period_weights=[1, 3],
)
print(results.coefficient) # fitted hazard ratio
print(results.pre_periods, results.pre_period_weights)
es = results.aggregate("event_study") # unified event-study container
print(es.to_dataframe())