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,BaseEstimatorTriple 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 - usevcov_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.conleyis deferred. Withhc1, default SE isstd(IF)/sqrt(n); withhc1+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 that1 - pscore_trim < 1in 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
ValueErrorraised ifrank_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 deprecatedStaggeredTripleDifferenceand 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;
boolis 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 periodtis compared tot - 1, while post-treatment periods use the fixed cohort referenceg - 1 - anticipation."universal"usesg - 1 - anticipationfor 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.
0means the analytical influence-function SEs (the library-wide0 = offconvention, 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 returnsStaggeredTripleDiffResults(the two containers unify at 4.0, row M-014).
- is_fitted_
Whether the model has been fitted.
- Type:
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:
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
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,seedandcbandapply to the STAGGERED mode only (fit(..., first_treat=...)).The first four raise a
ValueErrorif 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,seedandcband- are ACCEPTED and inert in 2x2x2 mode. They act only throughn_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 tofit()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
- 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
- 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, whosetriplediff::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 withFutureWarning. 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.infmarks 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 returnsStaggeredTripleDiffResults. The two containers unify at 4.0 (row M-014).- Return type:
- 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.
- 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.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
TripleDifferenceResults#
Results container for Triple Difference estimation.
- class diff_diff.TripleDifferenceResults[source]
Bases:
BaseResultsResults 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:
- se
Standard error of the ATT estimate.
- Type:
- t_stat
T-statistic for the ATT estimate.
- Type:
- p_value
P-value for the null hypothesis that ATT = 0.
- Type:
- n_obs
Total number of observations used in estimation.
- Type:
- n_treated_eligible
Number of observations in treated group and eligible partition.
- Type:
- n_treated_ineligible
Number of observations in treated group and ineligible partition.
- Type:
- n_control_eligible
Number of observations in control group and eligible partition.
- Type:
- n_control_ineligible
Number of observations in control group and ineligible partition.
- Type:
- estimation_method
Estimation method used: “dr” (doubly robust), “reg” (regression adjustment), or “ipw” (inverse probability weighting).
- Type:
- alpha
Significance level used for confidence intervals.
- Type:
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
- 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
- inference_method: str = 'analytical'
- vcov_type: str = 'hc1'
- epv_threshold: float = 10
- pscore_fallback: str = 'error'
- 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
alpharaises ValueError rather than silently recomputing or relabeling. Re-fit at the desired alpha instead.- Returns:
Formatted summary table.
- Return type:
- 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:
att (float)
se (float)
t_stat (float)
p_value (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)
r_squared (float | None)
covariate_balance (DataFrame | None)
inference_method (str)
vcov_type (str)
cluster_name (str | None)
n_bootstrap (int | None)
n_clusters (int | None)
survey_metadata (Any | None)
epv_threshold (float)
pscore_fallback (str)
- 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 - usevcov_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_bmare rejected at__init__andconleyis deferred. See theTripleDifferenceclass 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:
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 |
|---|---|---|
|
Doubly robust |
Recommended. Consistent if either outcome or propensity model is correct |
|
Regression adjustment |
Simple outcome regression with full interactions |
|
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',
)