Triple Difference (DDD)#

Triple Difference estimator for designs where treatment requires two criteria.

This module implements the methodology from Ortiz-Villavicencio & Sant’Anna (2025), which correctly handles covariate adjustment in DDD designs. Unlike naive implementations that difference two DiDs, this approach provides valid estimates when identification requires conditioning on covariates.

When to use DDD instead of DiD:

DDD allows for violations of parallel trends that are:

  • Group-specific (e.g., economic shocks affecting treatment states)

  • Partition-specific (e.g., trends affecting women everywhere)

As long as these biases are additive, DDD differences them out. The key assumption is that the differential trend between eligible and ineligible units would be the same across groups.

Reference: Ortiz-Villavicencio, M., & Sant’Anna, P. H. C. (2025). Better Understanding Triple Differences Estimators. Working Paper. arXiv:2505.09942

TripleDifference#

Main estimator class for Triple Difference designs.

Since 3.9 this class serves BOTH triple-difference designs (ledger row M-013): the 2x2x2 design, and the staggered-adoption design that StaggeredTripleDifference used to own. first_treat= selects the staggered engine; mixing the two designs’ parameters raises rather than guessing. The estimation cores are unchanged - both surfaces share one engine, so the staggered numbers are identical to the deprecated class’s.

from diff_diff import TripleDifference, generate_ddd_data, generate_staggered_ddd_data

# 2x2x2 design (unchanged) - columns: outcome, group, partition, time
df = generate_ddd_data(n_per_cell=100, treatment_effect=2.0, seed=42)
ddd = TripleDifference(estimation_method="dr")
res = ddd.fit(df, outcome="outcome", group="group", partition="partition", post="time")

# staggered adoption - the staggered params are keyword-only
sdf = generate_staggered_ddd_data(n_units=120, n_periods=8, seed=42)
sddd = TripleDifference(estimation_method="dr", control_group="not_yet_treated")
res = sddd.fit(
    sdf,
    outcome="outcome",
    partition="eligibility",
    unit="unit",
    time="period",
    first_treat="first_treat",
    aggregate="event_study",
)

2x2x2 mode returns TripleDifferenceResults; staggered mode returns StaggeredTripleDiffResults (the containers unify at 4.0, row M-014). cluster= gives Liang-Zeger CR1 in 2x2x2 mode and raises in staggered mode, where unit-level clustering is available through n_bootstrap > 0.

class diff_diff.TripleDifference[source]

Bases: _StaggeredTripleDiffEngineMixin, CallawaySantAnnaBootstrapMixin, CallawaySantAnnaAggregationMixin, BaseEstimator

Triple Difference (DDD) estimator.

Estimates the Average Treatment effect on the Treated (ATT) when treatment requires satisfying two criteria: belonging to a treated group AND being in an eligible partition of the population. The DDD design was popularized by Gruber (1994) [2].

This implementation follows Ortiz-Villavicencio & Sant’Anna (2025) [1], which shows that naive DDD implementations (difference of two DiDs, three-way fixed effects) are invalid when covariates are needed for identification.

Parameters:
  • estimation_method (str, default="dr") –

    Estimation method to use:

    • ”dr”: Doubly robust (recommended). Consistent if either the outcome model or propensity score model is correctly specified.

    • ”reg”: Regression adjustment (outcome regression).

    • ”ipw”: Inverse probability weighting.

  • robust (bool, optional) – DEPRECATED (row M-046; warns with FutureWarning, removed in 4.0 - use vcov_type=). Influence-function SEs are inherently robust to heteroskedasticity, so the flag never had an effect here; it was retained only for API compatibility.

  • cluster (str, optional) – Column name for cluster-robust standard errors. When provided, SEs are computed using the Liang-Zeger cluster-robust variance estimator on the influence function.

  • vcov_type (str, default="hc1") – Variance estimator. Permanently narrow to {"hc1"} per the IF-based variance decomposition: TripleDifference uses an efficient influence function and has no single design matrix on which the analytical-sandwich families (classical, hc2, hc2_bm) could compute hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF. conley is deferred. With hc1, default SE is std(IF)/sqrt(n); with hc1 + cluster=<col>, Liang-Zeger CR1 on the combined IF.

  • alpha (float, default=0.05) – Significance level for confidence intervals.

  • pscore_trim (float, default=0.01) – Trimming threshold for propensity scores. Scores below this value or above (1 - pscore_trim) are clipped to avoid extreme weights. Must be in (0, 0.5) and large enough that 1 - pscore_trim < 1 in float64 (a sub-ulp trim would disable the upper clip).

  • rank_deficient_action (str, default="warn") –

    Action when design matrix is rank-deficient (linearly dependent columns):

    • ”warn”: Issue warning and drop linearly dependent columns (default)

    • ”error”: Raise ValueError

    • ”silent”: Drop columns silently without warning

  • epv_threshold (float, default=10) – Events Per Variable threshold for propensity score logit. When the ratio of minority-class observations to predictor variables (excluding intercept) falls below this value, a warning is emitted (or ValueError raised if rank_deficient_action="error"). Based on Peduzzi et al. (1996). Only applies to IPW and DR estimation methods.

  • pscore_fallback (str, default="error") –

    Action when propensity score estimation fails:

    • ”error”: Raise the exception (default)

    • ”unconditional”: Fall back to unconditional propensity with a warning. For IPW, drops all covariates. For DR, the propensity model becomes unconditional but outcome regression still uses covariates.

    When rank_deficient_action="error", errors are always re-raised regardless of this setting.

  • control_group (str, default="not_yet_treated") – Comparison cohort for staggered mode: "not_yet_treated" (units whose enabling period is still in the future) or "never_treated" (the never-enabled cohort only). The compact R spellings "notyettreated"/"nevertreated" are accepted only by the deprecated StaggeredTripleDifference and die with it at 4.0.

  • anticipation (int, default=0) – Number of periods before the enabling period in which units may already respond. Must be a non-negative integer; bool is rejected. Shifts each cohort’s base period earlier and excludes cohorts entering treatment within the window from the comparison group.

  • base_period (str, default="varying") – "varying" uses consecutive comparisons - each pre-treatment period t is compared to t - 1, while post-treatment periods use the fixed cohort reference g - 1 - anticipation. "universal" uses g - 1 - anticipation for every period, pre and post alike. The two therefore differ only on the PRE-treatment effects, which is what makes "varying" the pre-trend-readable default.

  • n_bootstrap (int, default=0) – Multiplier-bootstrap replications for staggered mode. 0 means the analytical influence-function SEs (the library-wide 0 = off convention, row M-081). Clustered inference in staggered mode is only available through this path - cluster= is rejected there.

  • bootstrap_weights (str, default="rademacher") – Multiplier distribution: "rademacher", "mammen" or "webb".

  • seed (int, optional) – Seed for the multiplier bootstrap.

  • cband (bool, default=True) – Whether to compute simultaneous (sup-t) confidence bands alongside the pointwise ones.

results_

Estimation results after calling fit(). The 2x2x2 mode returns TripleDifferenceResults; the staggered mode returns StaggeredTripleDiffResults (the two containers unify at 4.0, row M-014).

Type:

TripleDifferenceResults or StaggeredTripleDiffResults

is_fitted_

Whether the model has been fitted.

Type:

bool

Examples

Basic usage with a DataFrame:

>>> import pandas as pd
>>> from diff_diff import TripleDifference
>>>
>>> # Data where treatment affects women (partition=1) in states
>>> # that enacted a policy (group=1)
>>> data = pd.DataFrame({
...     'outcome': [...],
...     'group': [1, 1, 0, 0, ...],      # 1=policy state, 0=control state
...     'partition': [1, 0, 1, 0, ...],  # 1=women, 0=men
...     'post': [0, 0, 1, 1, ...],       # 1=post-treatment period
... })
>>>
>>> # Fit using doubly robust estimation
>>> ddd = TripleDifference(estimation_method="dr")
>>> results = ddd.fit(
...     data,
...     outcome='outcome',
...     group='group',
...     partition='partition',
...     post='post'
... )
>>> print(results.att)  # ATT estimate

With covariates (properly handled unlike naive DDD):

>>> results = ddd.fit(
...     data,
...     outcome='outcome',
...     group='group',
...     partition='partition',
...     post='post',
...     covariates=['age', 'income']
... )

Notes

The DDD estimator is appropriate when:

  1. Treatment affects only units satisfying BOTH criteria: - Belonging to a treated group (G=1), e.g., states with a policy - Being in an eligible partition (P=1), e.g., women, low-income

  2. The DDD parallel trends assumption holds: the differential trend between eligible and ineligible partitions would have been the same across treated and control groups, absent treatment.

This is weaker than requiring separate parallel trends for two DiDs, as biases can cancel out in the differencing.

Which parameters belong to which mode. control_group, anticipation, base_period, n_bootstrap, bootstrap_weights, seed and cband apply to the STAGGERED mode only (fit(..., first_treat=...)).

The first four raise a ValueError if given a non-default value and then used to fit the 2x2x2 design, rather than being silently ignored: that engine has one pre/post contrast, so there is no comparison-cohort choice, no anticipation window, no base-period rule and no multiplier bootstrap.

The other three - bootstrap_weights, seed and cband - are ACCEPTED and inert in 2x2x2 mode. They act only through n_bootstrap > 0, which that mode already rejects, so they are unreachable by construction rather than silently ignored. The power helpers apply the same boundary, so an estimator that is legal to fit() is legal to simulate.

References

Methods

fit(data, outcome[, group, partition, post, ...])

Fit the Triple Difference model, in either of its two designs.

get_params([deep])

Get estimator parameters (sklearn-compatible).

set_params(**params)

Set estimator parameters (sklearn-compatible, transactional).

__init__(estimation_method='dr', robust=None, cluster=None, vcov_type='hc1', alpha=0.05, pscore_trim=0.01, rank_deficient_action='warn', epv_threshold=10, pscore_fallback='error', control_group='not_yet_treated', anticipation=0, base_period='varying', n_bootstrap=0, bootstrap_weights='rademacher', seed=None, cband=True)[source]
Parameters:
  • estimation_method (str)

  • robust (bool | None)

  • cluster (str | None)

  • vcov_type (str)

  • alpha (float)

  • pscore_trim (float)

  • rank_deficient_action (str)

  • epv_threshold (float)

  • pscore_fallback (str)

  • control_group (str)

  • anticipation (int)

  • base_period (str)

  • n_bootstrap (int)

  • bootstrap_weights (str)

  • seed (int | None)

  • cband (bool)

estimation_method: str
cluster: str | None
alpha: float
pscore_trim: float
rank_deficient_action: str
epv_threshold: float
pscore_fallback: str
control_group: str
anticipation: int
base_period: str
n_bootstrap: int
bootstrap_weights: str
seed: int | None
cband: bool
is_fitted_: bool
results_: TripleDifferenceResults | StaggeredTripleDiffResults | None
fit(data, outcome, group=<not supplied>, partition=<not supplied>, post=<not supplied>, covariates=None, survey_design=None, time=<not supplied>, *, unit=<not supplied>, first_treat=<not supplied>, aggregate=None, balance_e=None)[source]

Fit the Triple Difference model, in either of its two designs.

The 2x2x2 design takes (group, partition, post); the staggered design takes (unit, time, first_treat, partition). first_treat= selects the staggered engine - mixing the two parameter sets is an error, never a guess. This mirrors the reference implementation, whose triplediff::ddd() serves both designs from one signature.

The staggered-only parameters are keyword-only, so a staggered call written positionally cannot silently bind to the 2x2x2 slots.

Parameters:
  • data (pd.DataFrame) – DataFrame containing all variables.

  • outcome (str) – Name of the outcome variable column.

  • group (str) – 2x2x2 mode. Name of the group indicator column (0/1). 1 = treated group (e.g., states that enacted policy). 0 = control group.

  • partition (str) – BOTH modes. Name of the partition/eligibility indicator column (0/1). 1 = eligible partition (e.g., women, targeted demographic); 0 = ineligible partition. In staggered mode it must be time-invariant within units.

  • post (str) – Name of the post-period indicator column (0/1). 1 = post-treatment period. 0 = pre-treatment period.

  • covariates (list of str, optional) – List of covariate column names to adjust for. These are properly incorporated using the selected estimation method (unlike naive DDD implementations).

  • survey_design (SurveyDesign, optional) – Survey design specification for complex survey data. When provided, uses survey weights for estimation and Taylor Series Linearization (TSL) for variance estimation. Supported with all estimation methods (“reg”, “ipw”, “dr”).

  • time (str) – In STAGGERED mode, the calendar period column (keyword). In 2x2x2 mode it is the deprecated alias for post (row M-031) and warns with FutureWarning. From 4.0 it means the calendar column only (row M-085).

  • unit (str, keyword-only) – Staggered mode. Unit identifier column.

  • first_treat (str, keyword-only) – Staggered mode, and the MODE TRIGGER. Column giving each unit’s enabling period; 0 or np.inf marks never-enabled units.

  • aggregate (str, optional, keyword-only) – Staggered mode. "event_study", "group", "simple" or "all".

  • balance_e (int, optional, keyword-only) – Staggered mode. Event time to balance the event study on.

Returns:

2x2x2 mode returns TripleDifferenceResults; staggered mode returns StaggeredTripleDiffResults. The two containers unify at 4.0 (row M-014).

Return type:

TripleDifferenceResults or StaggeredTripleDiffResults

Raises:
  • ValueError – If required columns are missing, data validation fails, or the two designs’ parameters are mixed.

  • NotImplementedError – If survey_design is used with wild_bootstrap inference.

summary()[source]

Get summary of estimation results.

Returns:

Formatted summary.

Return type:

str

print_summary()[source]

Print summary to stdout.

Return type:

None

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

TripleDifferenceResults#

Results container for Triple Difference estimation.

class diff_diff.TripleDifferenceResults[source]

Bases: BaseResults

Results from Triple Difference (DDD) estimation.

Provides access to the estimated average treatment effect on the treated (ATT), standard errors, confidence intervals, and diagnostic information.

att

Average Treatment effect on the Treated (ATT). This is the effect on units in the treated group (G=1) and eligible partition (P=1) after treatment (T=1).

Type:

float

se

Standard error of the ATT estimate.

Type:

float

t_stat

T-statistic for the ATT estimate.

Type:

float

p_value

P-value for the null hypothesis that ATT = 0.

Type:

float

conf_int

Confidence interval for the ATT.

Type:

tuple[float, float]

n_obs

Total number of observations used in estimation.

Type:

int

n_treated_eligible

Number of observations in treated group and eligible partition.

Type:

int

n_treated_ineligible

Number of observations in treated group and ineligible partition.

Type:

int

n_control_eligible

Number of observations in control group and eligible partition.

Type:

int

n_control_ineligible

Number of observations in control group and ineligible partition.

Type:

int

estimation_method

Estimation method used: “dr” (doubly robust), “reg” (regression adjustment), or “ipw” (inverse probability weighting).

Type:

str

alpha

Significance level used for confidence intervals.

Type:

float

Methods

summary([alpha])

Generate a formatted summary of the estimation results.

print_summary([alpha])

Print the summary to stdout.

to_dict()

Convert results to a dictionary.

to_dataframe()

Convert results to a pandas DataFrame.

att: float
se: float
t_stat: float
p_value: float
conf_int: Tuple[float, float]
n_obs: int
n_treated_eligible: int
n_treated_ineligible: int
n_control_eligible: int
n_control_ineligible: int
estimation_method: str
alpha: float = 0.05
group_means: Dict[str, float] | None = None
pscore_stats: Dict[str, float] | None = None
r_squared: float | None = None
covariate_balance: DataFrame | None = None
inference_method: str = 'analytical'
vcov_type: str = 'hc1'
cluster_name: str | None = None
n_bootstrap: int | None = None
n_clusters: int | None = None
survey_metadata: Any | None = None
epv_diagnostics: Dict[int, Dict[str, Any]] | None = None
epv_threshold: float = 10
pscore_fallback: str = 'error'
__repr__()[source]

Concise string representation.

Return type:

str

summary(alpha=None)[source]

Generate a formatted summary of the estimation results.

Parameters:

alpha (float, optional) – Accepted for signature uniformity. The stored intervals were computed at fit time; a value different from the stored alpha raises ValueError rather than silently recomputing or relabeling. Re-fit at the desired alpha instead.

Returns:

Formatted summary table.

Return type:

str

print_summary(alpha=None)[source]

Print the summary to stdout.

Parameters:

alpha (float | None)

Return type:

None

to_dict()[source]

Convert results to a dictionary.

Returns:

Dictionary containing all estimation results.

Return type:

Dict[str, Any]

to_dataframe()[source]

Convert results to a pandas DataFrame.

Returns:

DataFrame with estimation results.

Return type:

pd.DataFrame

property is_significant: bool

Check if the ATT is statistically significant at the alpha level.

property significance_stars: str

Return significance stars based on p-value.

epv_summary(show_all=False)[source]

Return per-subgroup EPV diagnostics as a DataFrame.

Parameters:

show_all (bool, default False) – If False, only show subgroups with low EPV. If True, show all.

Returns:

Columns: subgroup, epv, n_events, n_params, is_low.

Return type:

pd.DataFrame

__init__(att, se, t_stat, p_value, conf_int, n_obs, n_treated_eligible, n_treated_ineligible, n_control_eligible, n_control_ineligible, estimation_method, alpha=0.05, group_means=None, pscore_stats=None, r_squared=None, covariate_balance=None, inference_method='analytical', vcov_type='hc1', cluster_name=None, n_bootstrap=None, n_clusters=None, survey_metadata=None, epv_diagnostics=None, epv_threshold=10, pscore_fallback='error')
Parameters:
Return type:

None

Convenience Function#

diff_diff.triple_difference(data, outcome, group, partition, time, covariates=None, estimation_method='dr', robust=None, cluster=None, vcov_type='hc1', alpha=0.05, rank_deficient_action='warn', epv_threshold=10, pscore_fallback='error', survey_design=None)[source]#

Estimate Triple Difference (DDD) treatment effect.

Deprecated since version 3.9: triple_difference() is deprecated and will be removed in 4.0 (row M-075). Construct the estimator instead: TripleDifference(...).fit(data, ...).

Convenience function that creates a TripleDifference estimator and fits it to the data in one step.

Parameters:
  • data (pd.DataFrame) – DataFrame containing all variables.

  • outcome (str) – Name of the outcome variable column.

  • group (str) – Name of the group indicator column (0/1). 1 = treated group (e.g., states that enacted policy).

  • partition (str) – Name of the partition/eligibility indicator column (0/1). 1 = eligible partition (e.g., women, targeted demographic).

  • time (str) – Name of the time period indicator column (0/1). 1 = post-treatment period.

  • covariates (list of str, optional) – List of covariate column names to adjust for.

  • estimation_method (str, default="dr") – Estimation method: “dr” (doubly robust), “reg” (regression), or “ipw” (inverse probability weighting).

  • robust (bool, optional) – DEPRECATED (row M-046; warns with FutureWarning, removed in 4.0 - use vcov_type=). Influence-function SEs are inherently robust to heteroskedasticity, so the flag never had an effect here; it was retained only for API compatibility.

  • cluster (str, optional) – Column name for cluster-robust standard errors.

  • vcov_type (str, default="hc1") – Variance estimator. Permanently narrow to {"hc1"} per the IF-based variance decomposition; classical/hc2/hc2_bm are rejected at __init__ and conley is deferred. See the TripleDifference class docstring for the structural taxonomy.

  • alpha (float, default=0.05) – Significance level for confidence intervals.

  • rank_deficient_action (str, default="warn") – Action when design matrix is rank-deficient: - “warn”: Issue warning and drop linearly dependent columns (default) - “error”: Raise ValueError - “silent”: Drop columns silently without warning

  • epv_threshold (float, default=10) – Events Per Variable threshold for propensity score logit.

  • pscore_fallback (str, default="error") – Action when propensity score estimation fails: - “error”: Raise (default) - “unconditional”: Fall back to unconditional propensity

  • survey_design (SurveyDesign | None)

Returns:

Object containing estimation results.

Return type:

TripleDifferenceResults

Examples

>>> from diff_diff import triple_difference
>>> results = triple_difference(
...     data,
...     outcome='earnings',
...     group='policy_state',
...     partition='female',
...     time='post_policy',
...     covariates=['age', 'education']
... )
>>> print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})")

Estimation Methods#

The estimator supports three estimation methods:

Method

Description

When to use

"dr"

Doubly robust

Recommended. Consistent if either outcome or propensity model is correct

"reg"

Regression adjustment

Simple outcome regression with full interactions

"ipw"

Inverse probability weighting

When propensity score model is well-specified

Example Usage#

Basic usage:

from diff_diff import TripleDifference, generate_ddd_data

# Synthetic 2x2x2 DDD sample (repeated cross-section - each row is its own
# unit): group (0/1), partition (0/1), time (0=pre/1=post)
data = generate_ddd_data(n_per_cell=100, treatment_effect=2.0, seed=42)

ddd = TripleDifference(estimation_method='dr')
results = ddd.fit(
    data,
    outcome='outcome',
    group='group',          # 1=state enacted policy, 0=control state
    partition='partition',  # 1=women (affected by policy), 0=men
    post='time'             # 1=post-policy, 0=pre-policy
)
results.print_summary()

With covariates:

from diff_diff import TripleDifference, generate_ddd_data

data = generate_ddd_data(
    n_per_cell=100, treatment_effect=2.0,
    add_covariates=True, seed=42,
)

ddd = TripleDifference(estimation_method='dr')
results = ddd.fit(
    data,
    outcome='outcome',
    group='group',
    partition='partition',
    post='time',
    covariates=['age', 'education']
)

Quick one-call estimation (the triple_difference() wrapper is deprecated since 3.9 and removed in 4.0):

from diff_diff import TripleDifference, generate_ddd_data

data = generate_ddd_data(n_per_cell=100, treatment_effect=2.0, seed=42)

results = TripleDifference(estimation_method='dr').fit(
    data,
    outcome='outcome',
    group='group',
    partition='partition',
    post='time',
)