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 gap c.

  • method="ph" (proportional hazards): a constant hazard ratio c.

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: BaseEstimator

Duration difference-in-differences (Deaner & Ku 2026) for absorbing outcomes.

Two-group, common-timing design: treatment is a FIXED 0/1 group indicator, outcome is a binary absorbing spell-ended indicator on a balanced, equally spaced numeric time grid, and last_pre_period is 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 gap c (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, ratio c (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 headline att is 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. 0 returns point estimates with NaN inference; otherwise at least 2.

  • 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 (see fit()).

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]
Parameters:
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 time values, optional) – Pre-treatment dates used to fit the hazard relationship (strictly after the baseline, at or before last_pre_period). Default: every eligible pre-treatment date after the baseline. Dates use the same exact numeric matching as last_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). Requires pre_periods. Default: equal weights over the eligible set.

Return type:

DurationDiDResults

get_params(deep=True)

Get estimator parameters (sklearn-compatible).

Parameters:

deep (bool, default True) – Accepted for sklearn compatibility (sklearn.base.clone calls get_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 that type(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 ValueError before 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, AggregationMixin

Results of DurationDiD.

The headline att is the uniform average over the post-treatment dates of the absorption ATT E[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 <= alpha is exactly the band rule.

method

"cd" or "ph".

Type:

str

n_units

Individuals (the bootstrap resampling unit).

Type:

int

n_obs

Panel rows used (n_units * n_periods).

Type:

int

n_treated, n_control

Individuals per group.

Type:

int

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:

dict

coefficient

Fitted CD gap c (per unit of time) or PH ratio c.

Type:

float

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:

float

vcov

(P, P) bootstrap covariance of the post-date effects; None whenever 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").

Type:

list of str

inference_status

"ok", "disabled", "unavailable_invalid_periods", "unavailable_failed_draws" or "unavailable_zero_se".

Type:

str

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:

dict

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:

int

pretest

The Algorithm 2 specification test (always populated).

Type:

DurationDiDPretestResults

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
conf_int: Tuple[float, 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
excluded_pre_periods: Dict[Any, str]
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
curve_status: List[str]
period_status: List[str]
inference_status: str
bootstrap_effects: ndarray | None
bootstrap_failure_reasons: Dict[str, Dict[str, int]]
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; False when 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).

Return type:

Dict[str, Any]

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:

DataFrame

summary(alpha=None)[source]

Formatted summary. alpha must equal the fit alpha.

Parameters:

alpha (float | None)

Return type:

str

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:
Return type:

None

DurationDiDPretestResults#

The Algorithm 2 fixed-anchor pre-treatment specification test.

class diff_diff.duration_did_results.DurationDiDPretestResults[source]

Bases: Diagnostic

Appendix B Algorithm 2 pre-treatment specification test.

For every interior pre-treatment date t (strictly between the baseline and anchor_period, the last pre-treatment date) the contrast compares the hazard relationship at t with its value at the fixed anchor: the average-hazard GAP under method="cd" and the cumulative-increment RATIO under method="ph". The whole-individual bootstrap gives one SD per contrast, a simultaneous critical value from the maximum absolute centered pivot, symmetric bands contrast +/- crit_value * se and the max-|t| statistic. The test rejects when any band excludes zero, which is exactly p_value <= alpha under the inverse-empirical-CDF quantile.

method

"cd" or "ph".

Type:

str

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:

float

statistic

max |contrast / se| over the tested dates.

Type:

float

p_value

Empirical tail fraction of the bootstrap maxima at or above statistic (NaN when unavailable).

Type:

float

reject

True when any band excludes zero; None when unavailable.

Type:

bool or None

alpha

Significance level.

Type:

float

n_bootstrap, n_bootstrap_valid

Requested draws and complete draws for this family.

Type:

int

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:

str

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
reject: bool | None
alpha: float
n_bootstrap: int
n_bootstrap_valid: int
status: str
to_dataframe()[source]

One row per tested pre-treatment date.

Return type:

DataFrame

to_dict()[source]

JSON-serializable dictionary.

Return type:

Dict[str, Any]

summary()[source]

Human-readable summary.

Return type:

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)
Parameters:
Return type:

None

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())