Interactive notebook

This tutorial is a Jupyter notebook. You can view it on GitHub or download it to run locally.

Triple Difference (DDD) Estimation#

This tutorial covers the Triple Difference (DDD) estimator, which extends standard Difference-in-Differences to settings where treatment requires satisfying two criteria.

When to Use Triple Difference#

Triple Difference is appropriate when:

  1. Treatment requires two criteria: Units must satisfy BOTH conditions to be treated:

    • Belonging to a treated group (e.g., states that enacted a policy)

    • Being in an eligible partition (e.g., women, low-income individuals)

  2. You want to relax parallel trends: DDD allows for group-specific AND partition-specific violations of parallel trends, as long as the differential trend is parallel.

Classic Example: Maternity Benefits#

Gruber (1994) studied state mandates requiring employers to provide maternity benefits:

  • Group: States that enacted mandates vs. states that didn’t

  • Partition: Women of childbearing age vs. other workers

  • Outcome: Wages

Only women in mandate states were “treated” - the policy affected their labor costs.

[ ]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from diff_diff import TripleDifference

Generating Synthetic DDD Data#

Let’s create synthetic data that mimics a DDD setting. We’ll simulate a policy that:

  • Was enacted in some states (group=1) but not others (group=0)

  • Affects only eligible individuals (partition=1) but not others (partition=0)

  • Has a true treatment effect of 2.0

[ ]:
# Generate DDD data using the library function
from diff_diff import generate_ddd_data

# Generate synthetic DDD data that mimics a policy setting:
# - Enacted in some states (group=1) but not others (group=0)
# - Affects only eligible individuals (partition=1) but not others (partition=0)
# - Has a true treatment effect of 2.0
data = generate_ddd_data(
    n_per_cell=200,
    treatment_effect=2.0,
    group_effect=5.0,      # Main effect of being in treated group
    partition_effect=3.0,  # Main effect of being in eligible partition
    time_effect=2.0,       # Main effect of post-treatment period
    noise_sd=3.0,
    add_covariates=True,   # Include age and education covariates
    seed=42
)

print(f"Dataset shape: {data.shape}")
print(f"\nSample composition:")
print(data.groupby(['group', 'partition', 'time']).size().unstack(fill_value=0))

Basic DDD Estimation#

Let’s estimate the treatment effect using the TripleDifference class:

[ ]:
# Create and fit the DDD estimator
ddd = TripleDifference(estimation_method='dr')  # doubly robust (recommended)

results = ddd.fit(
    data,
    outcome='outcome',
    group='group',
    partition='partition',
    post='time'
)

# Print results
results.print_summary()

Understanding the DDD Estimand#

The Triple Difference can be written as:

DDD = [Y(G=1,P=1,T=1) - Y(G=1,P=1,T=0)]   # Change for treated, eligible
    - [Y(G=1,P=0,T=1) - Y(G=1,P=0,T=0)]   # Change for treated, ineligible
    - [Y(G=0,P=1,T=1) - Y(G=0,P=1,T=0)]   # Change for control, eligible
    + [Y(G=0,P=0,T=1) - Y(G=0,P=0,T=0)]   # Change for control, ineligible

Let’s verify this manually:

[ ]:
# Compute cell means
cell_means = data.groupby(['group', 'partition', 'time'])['outcome'].mean().unstack()
print("Cell Means:")
print(cell_means)
print()

# Manual DDD calculation
y_111 = data[(data['group']==1) & (data['partition']==1) & (data['time']==1)]['outcome'].mean()
y_110 = data[(data['group']==1) & (data['partition']==1) & (data['time']==0)]['outcome'].mean()
y_101 = data[(data['group']==1) & (data['partition']==0) & (data['time']==1)]['outcome'].mean()
y_100 = data[(data['group']==1) & (data['partition']==0) & (data['time']==0)]['outcome'].mean()
y_011 = data[(data['group']==0) & (data['partition']==1) & (data['time']==1)]['outcome'].mean()
y_010 = data[(data['group']==0) & (data['partition']==1) & (data['time']==0)]['outcome'].mean()
y_001 = data[(data['group']==0) & (data['partition']==0) & (data['time']==1)]['outcome'].mean()
y_000 = data[(data['group']==0) & (data['partition']==0) & (data['time']==0)]['outcome'].mean()

manual_ddd = (y_111 - y_110) - (y_101 - y_100) - (y_011 - y_010) + (y_001 - y_000)
print(f"Manual DDD calculation: {manual_ddd:.4f}")
print(f"Estimator DDD result:   {results.att:.4f}")

Estimation Methods#

The TripleDifference class supports three estimation methods:

  1. Regression Adjustment (``reg``): Uses outcome regression with full interactions

  2. Inverse Probability Weighting (``ipw``): Uses propensity scores to reweight observations

  3. Doubly Robust (``dr``): Combines both methods for robustness

The doubly robust estimator is recommended as it’s consistent if either the outcome model or the propensity score model is correctly specified.

[ ]:
# Compare estimation methods
methods = ['reg', 'ipw', 'dr']
results_comparison = {}

for method in methods:
    est = TripleDifference(estimation_method=method)
    res = est.fit(
        data,
        outcome='outcome',
        group='group',
        partition='partition',
        post='time'
    )
    results_comparison[method] = res
    print(f"{method.upper():4s}: ATT = {res.att:7.4f} (SE = {res.se:.4f}, p = {res.p_value:.4f})")

Adding Covariates#

A key insight from Ortiz-Villavicencio & Sant’Anna (2025) is that naive DDD implementations are invalid when covariates are needed for identification. The TripleDifference class properly incorporates covariates:

[ ]:
# Estimate with covariates
ddd_with_cov = TripleDifference(estimation_method='dr')

results_cov = ddd_with_cov.fit(
    data,
    outcome='outcome',
    group='group',
    partition='partition',
    post='time',
    covariates=['age', 'education']
)

results_cov.print_summary()

Quick One-Call Estimation#

Constructor and fit() chain into a single expression (the old triple_difference() convenience wrapper is deprecated since 3.9 and removed in 4.0):

[ ]:
# One-liner estimation via the class API
quick_results = TripleDifference(estimation_method='dr').fit(
    data,
    outcome='outcome',
    group='group',
    partition='partition',
    post='time',
    covariates=['age', 'education'],
)

print(f"ATT: {quick_results.att:.4f} (95% CI: [{quick_results.conf_int[0]:.4f}, {quick_results.conf_int[1]:.4f}])")

Visualizing Cell Means#

It’s often helpful to visualize the data structure to understand the DDD:

[ ]:
# Plot cell means over time
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

cell_means = data.groupby(['group', 'partition', 'time'])['outcome'].mean().reset_index()

# Plot by group
for g, group_name in [(0, 'Control States'), (1, 'Treated States')]:
    ax = axes[g]
    for p, (style, label) in enumerate([('--', 'Ineligible'), ('-', 'Eligible')]):
        subset = cell_means[(cell_means['group']==g) & (cell_means['partition']==p)]
        ax.plot(subset['time'], subset['outcome'], style, marker='o',
                linewidth=2, markersize=8, label=label)

    ax.set_xlabel('Time Period (0=Pre, 1=Post)', fontsize=12)
    ax.set_ylabel('Mean Outcome', fontsize=12)
    ax.set_title(group_name, fontsize=14)
    ax.set_xticks([0, 1])
    ax.set_xticklabels(['Pre', 'Post'])
    ax.legend()
    ax.grid(True, alpha=0.3)

plt.suptitle('DDD Structure: Comparing Trends Across Groups and Partitions', fontsize=14, y=1.02)
plt.tight_layout()
plt.show()

Accessing Results#

The TripleDifferenceResults object provides easy access to all estimation details:

[ ]:
# Access individual results
print(f"ATT estimate: {results.att:.4f}")
print(f"Standard error: {results.se:.4f}")
print(f"t-statistic: {results.t_stat:.4f}")
print(f"p-value: {results.p_value:.4f}")
print(f"95% CI: ({results.conf_int[0]:.4f}, {results.conf_int[1]:.4f})")
print(f"\nStatistically significant at 5% level: {results.is_significant}")
print(f"Significance stars: {results.significance_stars}")
[ ]:
# Convert to DataFrame for further analysis
results_df = results.to_dataframe()
print(results_df.T)
[ ]:
# View cell means
print("Cell Means from Estimation:")
for cell, mean in results.group_means.items():
    print(f"  {cell}: {mean:.4f}")

Staggered adoption#

Since 3.9 the same class also serves the staggered-adoption DDD design, where groups become treatment-enabled in different periods, while each unit’s partition (eligibility) status stays time-invariant - the staggering is in the enabling cohort, not in the third dimension. Supply first_treat= (each unit’s enabling period, with 0 or np.inf for never-enabled units; negative values are rejected) and the estimator switches engines: instead of one ATT it returns group-time ATT(g,t) effects, combined across comparison cohorts by optimal GMM weighting (Ortiz-Villavicencio & Sant’Anna 2025, Eqs. 4.11-4.12).

The staggered parameters are keyword-only, and mixing the two designs’ parameters raises rather than guessing which one you meant.

This replaces StaggeredTripleDifference, which is deprecated in 3.9 and removed in 4.0. It runs the same engine, so the numbers are unchanged; on the merged surface the third dimension is named partition= (not eligibility=) and control_group takes "not_yet_treated" / "never_treated".

[ ]:
from diff_diff.prep import generate_staggered_ddd_data

panel = generate_staggered_ddd_data(
    n_units=120, n_periods=6, cohort_periods=[3, 4], seed=42
)
print(panel.head())

staggered = TripleDifference(
    estimation_method="dr", control_group="not_yet_treated"
)
staggered_results = staggered.fit(
    panel,
    outcome="outcome",
    partition="eligibility",
    unit="unit",
    time="period",
    first_treat="first_treat",
    aggregate="event_study",
)
print(staggered_results.summary())
[ ]:
# Group-time effects, and the event-study aggregation
es = staggered_results.to_dataframe(level="event_study")
print(es)

# The 2x2x2 parameters are rejected in staggered mode, not silently ignored
try:
    staggered.fit(
        panel,
        outcome="outcome",
        partition="eligibility",
        unit="unit",
        time="period",
        first_treat="first_treat",
        post="period",
    )
except ValueError as exc:
    print(f"\nValueError: {exc}")

Summary#

Key takeaways:

  1. Use DDD when treatment requires two criteria (group membership AND partition eligibility)

  2. DDD relaxes parallel trends by allowing group-specific and partition-specific violations

  3. Use doubly robust estimation (estimation_method='dr') for robustness to model misspecification

  4. Properly handle covariates - the TripleDifference class correctly incorporates them, unlike naive implementations

References#

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

  • Gruber, J. (1994). The incidence of mandated maternity benefits. American Economic Review, 84(3), 622-641.

  • Olden, A., & Møen, J. (2022). The triple difference estimator. The Econometrics Journal, 25(3), 531-553.