DID is the workhorse of policy evaluation in economics and social sciences.
import pandas as pd
import numpy as np
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
import os
# MANDATORY: Validate panel structure
required = {"outcome", "treat", "post", "unit_id", "time", "cluster_id"}
assert required.issubset(df.columns), f"Missing columns: {required - set(df.columns)}"
# Verify treatment is binary
assert df["treat"].isin([0, 1]).all(), "Treatment must be binary (0/1)"
# Check panel balance
panel_check = df.groupby("unit_id")["time"].nunique()
is_balanced = (panel_check == panel_check.iloc[0]).all()
print(f"Panel structure: {'Balanced' if is_balanced else 'Unbalanced'}")
# MANDATORY: Test parallel trends visually
pre_data = df[df["post"] == 0].copy()
trends = pre_data.groupby(["time", "treat"])["outcome"].mean().unstack()
fig, axes = plt.subplots(1, 2, figsize=(15, 6))
# Pre-trends plot
axes[0].plot(trends.index, trends[0], marker='o', label='Control', linewidth=2)
axes[0].plot(trends.index, trends[1], marker='s', label='Treatment', linewidth=2)
axes[0].set_xlabel("Time")
axes[0].set_ylabel("Outcome (mean)")
axes[0].set_title("Pre-Treatment Trends (Parallel Trends Check)")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# MANDATORY: Statistical test for parallel trends
if len(pre_data["time"].unique()) >= 3:
# Test for differential trends in pre-period
pre_data["time_trend"] = pre_data.groupby("unit_id")["time"].transform(lambda x: x - x.min())
pre_data["treat_trend"] = pre_data["treat"] * pre_data["time_trend"]
trend_test = smf.ols(
"outcome ~ treat + time_trend + treat_trend + C(unit_id) + C(time)",
data=pre_data
).fit(cov_type="cluster", cov_kwds={"groups": pre_data["cluster_id"]})
trend_coef = trend_test.params.get("treat_trend", 0)
trend_pval = trend_test.pvalues.get("treat_trend", 1)
print("\n" + "=" * 60)
print("PARALLEL TRENDS TEST")
print("=" * 60)
print(f"Differential trend coefficient: {trend_coef:.4f}")
print(f"P-value: {trend_pval:.4f}")
if trend_pval < 0.05:
print("WARNING: Parallel trends assumption may be violated!")
else:
print("SUCCESS: No significant differential pre-trends detected")
# MANDATORY: Main DID estimation with cluster-robust SE
df_did = df.copy()
df_did["did"] = df_did["treat"] * df_did["post"]
# Two-way fixed effects specification
did_model = smf.ols(
"outcome ~ did + C(unit_id) + C(time)",
data=df_did
).fit(
cov_type="cluster",
cov_kwds={"groups": df_did["cluster_id"]}
)
# Extract key results
did_coef = did_model.params["did"]
did_se = did_model.bse["did"]
did_pval = did_model.pvalues["did"]
ci_lower = did_coef - 1.96 * did_se
ci_upper = did_coef + 1.96 * did_se
print("\n" + "=" * 60)
print("DIFFERENCE-IN-DIFFERENCES RESULTS")
print("=" * 60)
print(f"Treatment Effect: {did_coef:.4f}")
print(f"Clustered SE: {did_se:.4f}")
print(f"95% CI: [{ci_lower:.4f}, {ci_upper:.4f}]")
print(f"P-value: {did_pval:.4f}")
print(f"\nClustering: {df_did['cluster_id'].nunique()} clusters")
print(f"N observations: {len(df_did):,}")
# MANDATORY: Event study for dynamics and further pre-trends check
# Create relative time variable if not exists
if "rel_time" not in df.columns:
treatment_time = df[df["post"] == 1].groupby("unit_id")["time"].min()
df["treatment_time"] = df["unit_id"].map(treatment_time)
df["rel_time"] = df["time"] - df["treatment_time"]
df.loc[df["treat"] == 0, "rel_time"] = -999 # Never treated
# Event study with reasonable window
event_window = range(-4, 5) # 4 pre, 4 post periods
df_event = df[(df["rel_time"].isin(event_window)) | (df["rel_time"] == -999)].copy()
# Create event time dummies (omit -1 as reference)
for k in event_window:
if k == -1:
continue
df_event[f"event_{k}"] = ((df_event["rel_time"] == k) & (df_event["treat"] == 1)).astype(int)
# Build formula
event_vars = [f"event_{k}" for k in event_window if k != -1]
event_formula = "outcome ~ " + " + ".join(event_vars) + " + C(unit_id) + C(time)"
event_model = smf.ols(event_formula, data=df_event).fit(
cov_type="cluster",
cov_kwds={"groups": df_event["cluster_id"]}
)
# Plot event study
periods = [k for k in event_window if k != -1]
coefs = [event_model.params.get(f"event_{k}", 0) for k in periods]
ses = [event_model.bse.get(f"event_{k}", 0) for k in periods]
axes[1].axhline(0, color='gray', linestyle='--', alpha=0.5)
axes[1].axvline(-0.5, color='red', linestyle='--', alpha=0.5, label='Treatment')
axes[1].errorbar(periods, coefs, yerr=[1.96*s for s in ses],
marker='o', capsize=5, linewidth=2)
axes[1].set_xlabel("Relative Time to Treatment")
axes[1].set_ylabel("Effect Estimate")
axes[1].set_title("Event Study (Pre-trends & Dynamics)")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# MANDATORY: Save diagnostic plots
plt.tight_layout()
os.makedirs("./images", exist_ok=True)
plt.savefig("./images/did_diagnostics.png", dpi=144, bbox_inches="tight")
print(f"\nSUCCESS: Diagnostic plots saved to ./images/did_diagnostics.png")
# Get figure for inline display
fig = plt.gcf()
return did_model, event_model, fig,
</code_template>
</implementation_pattern>
<examples>
<example context="clean_2x2" difficulty="basic">
<description>Classic 2×2 DID with one treatment group and two periods</description>
<code>
```python
@app.cell
def did_2x2_simple(df):
# Implementing the canonical 2×2 DID where we have one treatment group,
# one control group, and two time periods. This is the simplest and most
# transparent DID design (Card & Krueger, 1994).
import statsmodels.formula.api as smf
import pandas as pd
# Verify 2×2 structure
assert df["treat"].nunique() == 2, "Need exactly 2 groups"
assert df["time"].nunique() == 2, "Need exactly 2 time periods"
assert df["post"].nunique() == 2, "Need pre and post indicators"
# Calculate means for 2×2 table
means = df.groupby(["treat", "post"])["outcome"].mean().unstack()
print("2×2 DID Table")
print("=" * 50)
print(means)
# Manual DID calculation for transparency
diff_treat = means.loc[1, 1] - means.loc[1, 0] # Treatment group change
diff_control = means.loc[0, 1] - means.loc[0, 0] # Control group change
did_manual = diff_treat - diff_control
print(f"\nManual DID Calculation:")
print(f" Treatment group change: {diff_treat:.4f}")
print(f" Control group change: {diff_control:.4f}")
print(f" DID estimate: {did_manual:.4f}")
# Regression approach (should match manual calculation)
df["did"] = df["treat"] * df["post"]
model = smf.ols("outcome ~ treat + post + did", data=df).fit(
cov_type="cluster",
cov_kwds={"groups": df["cluster_id"]}
)
print(f"\nRegression DID estimate: {model.params['did']:.4f}")
print(f"Clustered SE: {model.bse['did']:.4f}")
print(f"P-value: {model.pvalues['did']:.4f}")
# Interpretation
baseline_mean = means.loc[1, 0] # Pre-treatment mean for treated group
pct_effect = (model.params['did'] / baseline_mean) * 100
print(f"\nEffect size: {pct_effect:.1f}% relative to baseline")
return model,
import pandas as pd
import numpy as np
import statsmodels.formula.api as smf
# Check for staggered adoption
treatment_times = df[df["treat"] == 1].groupby("unit_id")["time"].min()
n_cohorts = treatment_times.nunique()
print("Staggered Adoption Structure")
print("=" * 50)
print(f"Number of treatment cohorts: {n_cohorts}")
print("\nTreatment timing distribution:")
print(treatment_times.value_counts().sort_index())
if n_cohorts > 1:
print("\nWARNING: Staggered adoption detected!")
print("Standard TWFE may be biased. Showing both approaches:")
# 1. Standard TWFE (potentially biased)
print("\n1. STANDARD TWFE APPROACH")
print("-" * 40)
df["treated_post"] = (df["treat"] == 1) & (df["post"] == 1)
twfe = smf.ols(
"outcome ~ treated_post + C(unit_id) + C(time)",
data=df
).fit(cov_type="cluster", cov_kwds={"groups": df["cluster_id"]})
print(f"TWFE estimate: {twfe.params['treated_post[T.True]']:.4f}")
print(f"SE: {twfe.bse['treated_post[T.True]']:.4f}")
# 2. Cohort-specific effects (diagnostic)
print("\n2. COHORT-SPECIFIC EFFECTS")
print("-" * 40)
cohort_effects = {}
for cohort_time in treatment_times.unique():
cohort_units = treatment_times[treatment_times == cohort_time].index
# Get cohort data plus never-treated controls
cohort_data = df[
(df["unit_id"].isin(cohort_units)) |
(df["treat"] == 0)
].copy()
cohort_data["post_cohort"] = cohort_data["time"] >= cohort_time
cohort_data["did_cohort"] = (
cohort_data["unit_id"].isin(cohort_units) &
cohort_data["post_cohort"]
)
cohort_model = smf.ols(
"outcome ~ did_cohort + C(unit_id) + C(time)",
data=cohort_data
).fit(cov_type="cluster", cov_kwds={"groups": cohort_data["cluster_id"]})
effect = cohort_model.params.get("did_cohort[T.True]", np.nan)
cohort_effects[cohort_time] = effect
print(f"Cohort {cohort_time}: {effect:.4f}")
# Check for heterogeneity
effects_array = np.array(list(cohort_effects.values()))
heterogeneity = effects_array.std()
print(f"\nHeterogeneity check:")
print(f" SD of cohort effects: {heterogeneity:.4f}")
if heterogeneity > abs(effects_array.mean()) * 0.5:
print(" WARNING: Substantial heterogeneity detected!")
print(" Consider using robust DID methods (CS, SA, or Sun-Abraham)")
# 3. Simple aggregation (equally weighted)
print("\n3. SIMPLE AVERAGE OF COHORT EFFECTS")
print("-" * 40)
simple_avg = effects_array.mean()
print(f"Average effect: {simple_avg:.4f}")
print(f"Difference from TWFE: {simple_avg - twfe.params['treated_post[T.True]']:.4f}")
return twfe, cohort_effects,
</code>
<lesson>
With staggered adoption:
1. TWFE can be biased due to "bad comparisons"
2. Check cohort-specific effects for heterogeneity
3. Consider modern robust estimators (Callaway-Sant'Anna, Sun-Abraham)
4. Report both standard and robust estimates for transparency
</lesson>
</example>
<example context="failed_pretrends" difficulty="advanced">
<description>Handling DID when parallel trends fails</description>
<code>
```python
@app.cell
def did_with_failed_pretrends(df):
# When parallel trends fails, standard DID is biased. We demonstrate the
# failure, then show alternative approaches: controlling for differential
# trends, matching before DID, or synthetic control methods.
import pandas as pd
import numpy as np
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
import os
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import NearestNeighbors
# First, demonstrate the pre-trends failure
pre_data = df[df["post"] == 0].copy()
pre_data["time_numeric"] = pre_data.groupby("unit_id")["time"].transform(
lambda x: x - x.min()
)
# Test for differential trends
pre_data["treat_x_time"] = pre_data["treat"] * pre_data["time_numeric"]
trend_test = smf.ols(
"outcome ~ treat + time_numeric + treat_x_time + C(unit_id)",
data=pre_data
).fit(cov_type="cluster", cov_kwds={"groups": pre_data["cluster_id"]})
diff_trend = trend_test.params["treat_x_time"]
p_value = trend_test.pvalues["treat_x_time"]
print("PRE-TRENDS DIAGNOSTIC")
print("=" * 60)
print(f"Differential trend: {diff_trend:.4f} (p={p_value:.4f})")
if p_value < 0.05:
print("WARNING: PARALLEL TRENDS VIOLATED - Standard DID is biased!")
print("\nImplementing alternative approaches:")
# Approach 1: Control for differential trends
print("\n1. DID WITH DIFFERENTIAL TRENDS")
print("-" * 40)
df_trends = df.copy()
df_trends["time_numeric"] = df_trends.groupby("unit_id")["time"].transform(
lambda x: x - x.min()
)
df_trends["treat_x_time"] = df_trends["treat"] * df_trends["time_numeric"]
df_trends["did"] = df_trends["treat"] * df_trends["post"]
trends_model = smf.ols(
"outcome ~ did + treat_x_time + C(unit_id) + C(time)",
data=df_trends
).fit(cov_type="cluster", cov_kwds={"groups": df_trends["cluster_id"]})
print(f"Effect (controlling for trends): {trends_model.params['did']:.4f}")
print(f"SE: {trends_model.bse['did']:.4f}")
print("Note: Assumes linear differential trends continue post-treatment")
# Approach 2: Propensity score matching + DID
print("\n2. MATCHED DID")
print("-" * 40)
# Match on pre-treatment characteristics
pre_chars = pre_data.groupby("unit_id").agg({
"outcome": ["mean", "std", lambda x: x.iloc[-1] - x.iloc[0]], # level, volatility, trend
"treat": "first"
}).reset_index()
pre_chars.columns = ["unit_id", "pre_mean", "pre_std", "pre_trend", "treat"]
# Estimate propensity scores
X = pre_chars[["pre_mean", "pre_std", "pre_trend"]].values
y = pre_chars["treat"].values
ps_model = LogisticRegression(random_state=42)
ps_model.fit(X, y)
pre_chars["pscore"] = ps_model.predict_proba(X)[:, 1]
# Match treated to control units
treated_units = pre_chars[pre_chars["treat"] == 1]
control_units = pre_chars[pre_chars["treat"] == 0]
# 1:1 nearest neighbor matching on propensity score
nn = NearestNeighbors(n_neighbors=1)
nn.fit(control_units[["pscore"]].values)
distances, indices = nn.kneighbors(treated_units[["pscore"]].values)
matched_controls = control_units.iloc[indices.flatten()]["unit_id"].values
treated_ids = treated_units["unit_id"].values
# Create matched sample
matched_df = df[
df["unit_id"].isin(treated_ids) |
df["unit_id"].isin(matched_controls)
].copy()
print(f"Matched sample: {len(matched_df['unit_id'].unique())} units")
print(f" ({len(treated_ids)} treated, {len(matched_controls)} control)")
# Run DID on matched sample
matched_df["did"] = matched_df["treat"] * matched_df["post"]
matched_model = smf.ols(
"outcome ~ did + C(unit_id) + C(time)",
data=matched_df
).fit(cov_type="cluster", cov_kwds={"groups": matched_df["cluster_id"]})
print(f"Effect (matched DID): {matched_model.params['did']:.4f}")
print(f"SE: {matched_model.bse['did']:.4f}")
# Approach 3: Recommend alternatives
print("\n3. RECOMMENDED ALTERNATIVES")
print("-" * 40)
print("Consider these methods when parallel trends fails:")
print("• Synthetic Control Method (if few treated units)")
print("• Changes-in-Changes (Athey & Imbens, 2006)")
print("• Interactive Fixed Effects (Bai, 2009)")
print("• Instrumental Variables (if available)")
# Visualize the pre-trends failure and solutions
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# Original pre-trends (failing)
pre_means = pre_data.groupby(["time", "treat"])["outcome"].mean().unstack()
axes[0].plot(pre_means.index, pre_means[0], 'o-', label='Control')
axes[0].plot(pre_means.index, pre_means[1], 's-', label='Treatment')
axes[0].set_title("Original: Parallel Trends FAILED")
axes[0].set_xlabel("Time")
axes[0].set_ylabel("Outcome")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# After matching
matched_pre = matched_df[matched_df["post"] == 0]
matched_means = matched_pre.groupby(["time", "treat"])["outcome"].mean().unstack()
axes[1].plot(matched_means.index, matched_means[0], 'o-', label='Matched Control')
axes[1].plot(matched_means.index, matched_means[1], 's-', label='Treatment')
axes[1].set_title("After Matching: Improved Balance")
axes[1].set_xlabel("Time")
axes[1].set_ylabel("Outcome")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# Comparison of estimates
methods = ["Standard\nDID", "Trend-\nAdjusted", "Matched\nDID"]
estimates = [
df[df["treat"] == 1]["outcome"].mean() - df[df["treat"] == 0]["outcome"].mean(),
trends_model.params["did"],
matched_model.params["did"]
]
axes[2].bar(methods, estimates)
axes[2].set_title("Comparison of Estimates")
axes[2].set_ylabel("Effect Estimate")
axes[2].axhline(0, color='gray', linestyle='--', alpha=0.5)
axes[2].grid(True, alpha=0.3, axis='y')
plt.tight_layout()
os.makedirs("./images", exist_ok=True)
plt.savefig("./images/did_pretrends_failure.png", dpi=144, bbox_inches="tight")
print("\nSUCCESS: Diagnostic plots saved to ./images/did_pretrends_failure.png")
# Get figure for inline display
fig = plt.gcf()
return trends_model, matched_model, fig,
else:
print("SUCCESS: Parallel trends holds - proceed with standard DID")
return None, None, None,
1---2name: difference-in-differences3description: Causal inference with treatment/control groups over time. Use when user mentions: treatment effects, policy evaluation, pre/post comparison, parallel trends, DID, DiD, difference in differences, natural experiment, quasi-experimental.4---56<skill_content>78<overview>9Difference-in-Differences (DID) is a causal inference method that estimates treatment effects by comparing changes over time between treated and control groups. It leverages both cross-sectional and temporal variation to identify causal effects when randomization is infeasible.1011DID is the workhorse of policy evaluation in economics and social sciences.12</overview>1314<mandatory_requirements>1516<requirement priority="critical">17 <name>Parallel Trends Testing</name>18 <description>MUST test the parallel trends assumption using pre-treatment data</description>19 <rationale>Parallel trends is THE identifying assumption. Without it, DID estimates are biased. Violations have invalidated numerous published studies (Roth, 2022)</rationale>20 <consequence>Biased treatment effect estimates that could be entirely spurious</consequence>21</requirement>2223<requirement priority="critical">24 <name>Cluster-Robust Standard Errors</name>25 <description>MUST cluster standard errors at the treatment level</description>26 <rationale>Serial correlation within units severely underestimates standard errors. Bertrand, Duflo & Mullainathan (2004) show 45% false positive rate with unclustered SE</rationale>27 <consequence>Type I error rates can exceed 45% instead of 5%</consequence>28</requirement>2930<requirement priority="critical">31 <name>Event Study Specification</name>32 <description>MUST run event study with leads and lags when multiple pre-periods exist</description>33 <rationale>Event studies reveal dynamics, test pre-trends, and detect anticipation effects simultaneously (Sun & Abraham, 2021)</rationale>34 <consequence>Missing pre-trends violations, anticipation effects, or dynamic treatment effects</consequence>35</requirement>3637<requirement priority="high">38 <name>Clear Treatment Timing</name>39 <description>Document exactly when treatment starts and who is treated</description>40 <rationale>Ambiguous treatment timing or definition makes results unreplicable and potentially wrong</rationale>41 <consequence>Wrong attribution of effects to treatment</consequence>42</requirement>4344</mandatory_requirements>4546<assumptions>4748<assumption name="Parallel Trends">49 <description>Treatment and control groups would follow parallel trajectories absent treatment</description>50 <how_to_check>Visual inspection of pre-trends, event study coefficients on leads, formal pre-trends test</how_to_check>51 <if_violated>Consider synthetic control, changes-in-changes, or interactive fixed effects</if_violated>52</assumption>5354<assumption name="No Anticipation">55 <description>Units don't change behavior before treatment actually occurs</description>56 <how_to_check>Check event study leads immediately before treatment</how_to_check>57 <if_violated>Redefine treatment timing to when announcement occurred</if_violated>58</assumption>5960<assumption name="SUTVA">61 <description>No spillovers between treated and control units</description>62 <how_to_check>Test for spatial correlation, check economic linkages</how_to_check>63 <if_violated>Exclude contaminated controls or use spatial methods</if_violated>64</assumption>6566<assumption name="Common Support">67 <description>Treatment and control groups are comparable</description>68 <how_to_check>Compare covariate distributions and pre-treatment outcomes</how_to_check>69 <if_violated>Consider matching or weighting before DID</if_violated>70</assumption>7172</assumptions>7374<thinking_process>75When implementing DID:761. Verify panel structure and treatment timing772. Check pre-treatment balance between groups783. Test parallel trends assumption (CRITICAL)794. Implement main DID specification805. Run event study for dynamics816. Conduct robustness checks827. Interpret magnitude and significance carefully83</thinking_process>8485<implementation_pattern>8687<code_template>88```python89@app.cell90def did_with_diagnostics(df):91 # Implementing DID with all required diagnostics to ensure valid causal92 # inference. Following Angrist & Pischke (2009) and recent best practices93 # from Roth et al. (2023) on credible DID designs.9495 import pandas as pd96 import numpy as np97 import statsmodels.formula.api as smf98 import matplotlib.pyplot as plt99 import os100101 # MANDATORY: Validate panel structure102 required = {"outcome", "treat", "post", "unit_id", "time", "cluster_id"}103 assert required.issubset(df.columns), f"Missing columns: {required - set(df.columns)}"104105 # Verify treatment is binary106 assert df["treat"].isin([0, 1]).all(), "Treatment must be binary (0/1)"107108 # Check panel balance109 panel_check = df.groupby("unit_id")["time"].nunique()110 is_balanced = (panel_check == panel_check.iloc[0]).all()111 print(f"Panel structure: {'Balanced' if is_balanced else 'Unbalanced'}")112113 # MANDATORY: Test parallel trends visually114 pre_data = df[df["post"] == 0].copy()115 trends = pre_data.groupby(["time", "treat"])["outcome"].mean().unstack()116117 fig, axes = plt.subplots(1, 2, figsize=(15, 6))118119 # Pre-trends plot120 axes[0].plot(trends.index, trends[0], marker='o', label='Control', linewidth=2)121 axes[0].plot(trends.index, trends[1], marker='s', label='Treatment', linewidth=2)122 axes[0].set_xlabel("Time")123 axes[0].set_ylabel("Outcome (mean)")124 axes[0].set_title("Pre-Treatment Trends (Parallel Trends Check)")125 axes[0].legend()126 axes[0].grid(True, alpha=0.3)127128 # MANDATORY: Statistical test for parallel trends129 if len(pre_data["time"].unique()) >= 3:130 # Test for differential trends in pre-period131 pre_data["time_trend"] = pre_data.groupby("unit_id")["time"].transform(lambda x: x - x.min())132 pre_data["treat_trend"] = pre_data["treat"] * pre_data["time_trend"]133134 trend_test = smf.ols(135 "outcome ~ treat + time_trend + treat_trend + C(unit_id) + C(time)",136 data=pre_data137 ).fit(cov_type="cluster", cov_kwds={"groups": pre_data["cluster_id"]})138139 trend_coef = trend_test.params.get("treat_trend", 0)140 trend_pval = trend_test.pvalues.get("treat_trend", 1)141142 print("\n" + "=" * 60)143 print("PARALLEL TRENDS TEST")144 print("=" * 60)145 print(f"Differential trend coefficient: {trend_coef:.4f}")146 print(f"P-value: {trend_pval:.4f}")147 if trend_pval < 0.05:148 print("WARNING: Parallel trends assumption may be violated!")149 else:150 print("SUCCESS: No significant differential pre-trends detected")151152 # MANDATORY: Main DID estimation with cluster-robust SE153 df_did = df.copy()154 df_did["did"] = df_did["treat"] * df_did["post"]155156 # Two-way fixed effects specification157 did_model = smf.ols(158 "outcome ~ did + C(unit_id) + C(time)",159 data=df_did160 ).fit(161 cov_type="cluster",162 cov_kwds={"groups": df_did["cluster_id"]}163 )164165 # Extract key results166 did_coef = did_model.params["did"]167 did_se = did_model.bse["did"]168 did_pval = did_model.pvalues["did"]169 ci_lower = did_coef - 1.96 * did_se170 ci_upper = did_coef + 1.96 * did_se171172 print("\n" + "=" * 60)173 print("DIFFERENCE-IN-DIFFERENCES RESULTS")174 print("=" * 60)175 print(f"Treatment Effect: {did_coef:.4f}")176 print(f"Clustered SE: {did_se:.4f}")177 print(f"95% CI: [{ci_lower:.4f}, {ci_upper:.4f}]")178 print(f"P-value: {did_pval:.4f}")179 print(f"\nClustering: {df_did['cluster_id'].nunique()} clusters")180 print(f"N observations: {len(df_did):,}")181182 # MANDATORY: Event study for dynamics and further pre-trends check183 # Create relative time variable if not exists184 if "rel_time" not in df.columns:185 treatment_time = df[df["post"] == 1].groupby("unit_id")["time"].min()186 df["treatment_time"] = df["unit_id"].map(treatment_time)187 df["rel_time"] = df["time"] - df["treatment_time"]188 df.loc[df["treat"] == 0, "rel_time"] = -999 # Never treated189190 # Event study with reasonable window191 event_window = range(-4, 5) # 4 pre, 4 post periods192 df_event = df[(df["rel_time"].isin(event_window)) | (df["rel_time"] == -999)].copy()193194 # Create event time dummies (omit -1 as reference)195 for k in event_window:196 if k == -1:197 continue198 df_event[f"event_{k}"] = ((df_event["rel_time"] == k) & (df_event["treat"] == 1)).astype(int)199200 # Build formula201 event_vars = [f"event_{k}" for k in event_window if k != -1]202 event_formula = "outcome ~ " + " + ".join(event_vars) + " + C(unit_id) + C(time)"203204 event_model = smf.ols(event_formula, data=df_event).fit(205 cov_type="cluster",206 cov_kwds={"groups": df_event["cluster_id"]}207 )208209 # Plot event study210 periods = [k for k in event_window if k != -1]211 coefs = [event_model.params.get(f"event_{k}", 0) for k in periods]212 ses = [event_model.bse.get(f"event_{k}", 0) for k in periods]213214 axes[1].axhline(0, color='gray', linestyle='--', alpha=0.5)215 axes[1].axvline(-0.5, color='red', linestyle='--', alpha=0.5, label='Treatment')216 axes[1].errorbar(periods, coefs, yerr=[1.96*s for s in ses],217 marker='o', capsize=5, linewidth=2)218 axes[1].set_xlabel("Relative Time to Treatment")219 axes[1].set_ylabel("Effect Estimate")220 axes[1].set_title("Event Study (Pre-trends & Dynamics)")221 axes[1].legend()222 axes[1].grid(True, alpha=0.3)223224 # MANDATORY: Save diagnostic plots225 plt.tight_layout()226 os.makedirs("./images", exist_ok=True)227 plt.savefig("./images/did_diagnostics.png", dpi=144, bbox_inches="tight")228229 print(f"\nSUCCESS: Diagnostic plots saved to ./images/did_diagnostics.png")230231 # Get figure for inline display232 fig = plt.gcf()233 return did_model, event_model, fig,234```235</code_template>236237</implementation_pattern>238239<examples>240241<example context="clean_2x2" difficulty="basic">242<description>Classic 2×2 DID with one treatment group and two periods</description>243<code>244```python245@app.cell246def did_2x2_simple(df):247 # Implementing the canonical 2×2 DID where we have one treatment group,248 # one control group, and two time periods. This is the simplest and most249 # transparent DID design (Card & Krueger, 1994).250251 import statsmodels.formula.api as smf252 import pandas as pd253254 # Verify 2×2 structure255 assert df["treat"].nunique() == 2, "Need exactly 2 groups"256 assert df["time"].nunique() == 2, "Need exactly 2 time periods"257 assert df["post"].nunique() == 2, "Need pre and post indicators"258259 # Calculate means for 2×2 table260 means = df.groupby(["treat", "post"])["outcome"].mean().unstack()261262 print("2×2 DID Table")263 print("=" * 50)264 print(means)265266 # Manual DID calculation for transparency267 diff_treat = means.loc[1, 1] - means.loc[1, 0] # Treatment group change268 diff_control = means.loc[0, 1] - means.loc[0, 0] # Control group change269 did_manual = diff_treat - diff_control270271 print(f"\nManual DID Calculation:")272 print(f" Treatment group change: {diff_treat:.4f}")273 print(f" Control group change: {diff_control:.4f}")274 print(f" DID estimate: {did_manual:.4f}")275276 # Regression approach (should match manual calculation)277 df["did"] = df["treat"] * df["post"]278 model = smf.ols("outcome ~ treat + post + did", data=df).fit(279 cov_type="cluster",280 cov_kwds={"groups": df["cluster_id"]}281 )282283 print(f"\nRegression DID estimate: {model.params['did']:.4f}")284 print(f"Clustered SE: {model.bse['did']:.4f}")285 print(f"P-value: {model.pvalues['did']:.4f}")286287 # Interpretation288 baseline_mean = means.loc[1, 0] # Pre-treatment mean for treated group289 pct_effect = (model.params['did'] / baseline_mean) * 100290291 print(f"\nEffect size: {pct_effect:.1f}% relative to baseline")292293 return model,294```295</code>296<output_interpretation>297In 2×2 DID:298- The coefficient represents the average treatment effect on the treated (ATT)299- Compare manual and regression estimates to verify implementation300- Report both absolute and relative (%) effects for context301</output_interpretation>302</example>303304<example context="staggered_adoption" difficulty="intermediate">305<description>Staggered treatment adoption with heterogeneous effects</description>306<code>307```python308@app.cell309def did_staggered_robust(df):310 # Standard TWFE DID is biased with staggered adoption and heterogeneous311 # effects (Goodman-Bacon, 2021). We implement both standard TWFE and312 # robust alternatives (Callaway & Sant'Anna, 2021).313314 import pandas as pd315 import numpy as np316 import statsmodels.formula.api as smf317318 # Check for staggered adoption319 treatment_times = df[df["treat"] == 1].groupby("unit_id")["time"].min()320 n_cohorts = treatment_times.nunique()321322 print("Staggered Adoption Structure")323 print("=" * 50)324 print(f"Number of treatment cohorts: {n_cohorts}")325 print("\nTreatment timing distribution:")326 print(treatment_times.value_counts().sort_index())327328 if n_cohorts > 1:329 print("\nWARNING: Staggered adoption detected!")330 print("Standard TWFE may be biased. Showing both approaches:")331332 # 1. Standard TWFE (potentially biased)333 print("\n1. STANDARD TWFE APPROACH")334 print("-" * 40)335336 df["treated_post"] = (df["treat"] == 1) & (df["post"] == 1)337 twfe = smf.ols(338 "outcome ~ treated_post + C(unit_id) + C(time)",339 data=df340 ).fit(cov_type="cluster", cov_kwds={"groups": df["cluster_id"]})341342 print(f"TWFE estimate: {twfe.params['treated_post[T.True]']:.4f}")343 print(f"SE: {twfe.bse['treated_post[T.True]']:.4f}")344345 # 2. Cohort-specific effects (diagnostic)346 print("\n2. COHORT-SPECIFIC EFFECTS")347 print("-" * 40)348349 cohort_effects = {}350 for cohort_time in treatment_times.unique():351 cohort_units = treatment_times[treatment_times == cohort_time].index352353 # Get cohort data plus never-treated controls354 cohort_data = df[355 (df["unit_id"].isin(cohort_units)) |356 (df["treat"] == 0)357 ].copy()358359 cohort_data["post_cohort"] = cohort_data["time"] >= cohort_time360 cohort_data["did_cohort"] = (361 cohort_data["unit_id"].isin(cohort_units) &362 cohort_data["post_cohort"]363 )364365 cohort_model = smf.ols(366 "outcome ~ did_cohort + C(unit_id) + C(time)",367 data=cohort_data368 ).fit(cov_type="cluster", cov_kwds={"groups": cohort_data["cluster_id"]})369370 effect = cohort_model.params.get("did_cohort[T.True]", np.nan)371 cohort_effects[cohort_time] = effect372 print(f"Cohort {cohort_time}: {effect:.4f}")373374 # Check for heterogeneity375 effects_array = np.array(list(cohort_effects.values()))376 heterogeneity = effects_array.std()377378 print(f"\nHeterogeneity check:")379 print(f" SD of cohort effects: {heterogeneity:.4f}")380 if heterogeneity > abs(effects_array.mean()) * 0.5:381 print(" WARNING: Substantial heterogeneity detected!")382 print(" Consider using robust DID methods (CS, SA, or Sun-Abraham)")383384 # 3. Simple aggregation (equally weighted)385 print("\n3. SIMPLE AVERAGE OF COHORT EFFECTS")386 print("-" * 40)387 simple_avg = effects_array.mean()388 print(f"Average effect: {simple_avg:.4f}")389 print(f"Difference from TWFE: {simple_avg - twfe.params['treated_post[T.True]']:.4f}")390391 return twfe, cohort_effects,392```393</code>394<lesson>395With staggered adoption:3961. TWFE can be biased due to "bad comparisons"3972. Check cohort-specific effects for heterogeneity3983. Consider modern robust estimators (Callaway-Sant'Anna, Sun-Abraham)3994. Report both standard and robust estimates for transparency400</lesson>401</example>402403<example context="failed_pretrends" difficulty="advanced">404<description>Handling DID when parallel trends fails</description>405<code>406```python407@app.cell408def did_with_failed_pretrends(df):409 # When parallel trends fails, standard DID is biased. We demonstrate the410 # failure, then show alternative approaches: controlling for differential411 # trends, matching before DID, or synthetic control methods.412413 import pandas as pd414 import numpy as np415 import statsmodels.formula.api as smf416 import matplotlib.pyplot as plt417 import os418 from sklearn.linear_model import LogisticRegression419 from sklearn.neighbors import NearestNeighbors420421 # First, demonstrate the pre-trends failure422 pre_data = df[df["post"] == 0].copy()423 pre_data["time_numeric"] = pre_data.groupby("unit_id")["time"].transform(424 lambda x: x - x.min()425 )426427 # Test for differential trends428 pre_data["treat_x_time"] = pre_data["treat"] * pre_data["time_numeric"]429 trend_test = smf.ols(430 "outcome ~ treat + time_numeric + treat_x_time + C(unit_id)",431 data=pre_data432 ).fit(cov_type="cluster", cov_kwds={"groups": pre_data["cluster_id"]})433434 diff_trend = trend_test.params["treat_x_time"]435 p_value = trend_test.pvalues["treat_x_time"]436437 print("PRE-TRENDS DIAGNOSTIC")438 print("=" * 60)439 print(f"Differential trend: {diff_trend:.4f} (p={p_value:.4f})")440441 if p_value < 0.05:442 print("WARNING: PARALLEL TRENDS VIOLATED - Standard DID is biased!")443 print("\nImplementing alternative approaches:")444445 # Approach 1: Control for differential trends446 print("\n1. DID WITH DIFFERENTIAL TRENDS")447 print("-" * 40)448449 df_trends = df.copy()450 df_trends["time_numeric"] = df_trends.groupby("unit_id")["time"].transform(451 lambda x: x - x.min()452 )453 df_trends["treat_x_time"] = df_trends["treat"] * df_trends["time_numeric"]454 df_trends["did"] = df_trends["treat"] * df_trends["post"]455456 trends_model = smf.ols(457 "outcome ~ did + treat_x_time + C(unit_id) + C(time)",458 data=df_trends459 ).fit(cov_type="cluster", cov_kwds={"groups": df_trends["cluster_id"]})460461 print(f"Effect (controlling for trends): {trends_model.params['did']:.4f}")462 print(f"SE: {trends_model.bse['did']:.4f}")463 print("Note: Assumes linear differential trends continue post-treatment")464465 # Approach 2: Propensity score matching + DID466 print("\n2. MATCHED DID")467 print("-" * 40)468469 # Match on pre-treatment characteristics470 pre_chars = pre_data.groupby("unit_id").agg({471 "outcome": ["mean", "std", lambda x: x.iloc[-1] - x.iloc[0]], # level, volatility, trend472 "treat": "first"473 }).reset_index()474475 pre_chars.columns = ["unit_id", "pre_mean", "pre_std", "pre_trend", "treat"]476477 # Estimate propensity scores478 X = pre_chars[["pre_mean", "pre_std", "pre_trend"]].values479 y = pre_chars["treat"].values480481 ps_model = LogisticRegression(random_state=42)482 ps_model.fit(X, y)483 pre_chars["pscore"] = ps_model.predict_proba(X)[:, 1]484485 # Match treated to control units486 treated_units = pre_chars[pre_chars["treat"] == 1]487 control_units = pre_chars[pre_chars["treat"] == 0]488489 # 1:1 nearest neighbor matching on propensity score490 nn = NearestNeighbors(n_neighbors=1)491 nn.fit(control_units[["pscore"]].values)492493 distances, indices = nn.kneighbors(treated_units[["pscore"]].values)494495 matched_controls = control_units.iloc[indices.flatten()]["unit_id"].values496 treated_ids = treated_units["unit_id"].values497498 # Create matched sample499 matched_df = df[500 df["unit_id"].isin(treated_ids) |501 df["unit_id"].isin(matched_controls)502 ].copy()503504 print(f"Matched sample: {len(matched_df['unit_id'].unique())} units")505 print(f" ({len(treated_ids)} treated, {len(matched_controls)} control)")506507 # Run DID on matched sample508 matched_df["did"] = matched_df["treat"] * matched_df["post"]509 matched_model = smf.ols(510 "outcome ~ did + C(unit_id) + C(time)",511 data=matched_df512 ).fit(cov_type="cluster", cov_kwds={"groups": matched_df["cluster_id"]})513514 print(f"Effect (matched DID): {matched_model.params['did']:.4f}")515 print(f"SE: {matched_model.bse['did']:.4f}")516517 # Approach 3: Recommend alternatives518 print("\n3. RECOMMENDED ALTERNATIVES")519 print("-" * 40)520 print("Consider these methods when parallel trends fails:")521 print("• Synthetic Control Method (if few treated units)")522 print("• Changes-in-Changes (Athey & Imbens, 2006)")523 print("• Interactive Fixed Effects (Bai, 2009)")524 print("• Instrumental Variables (if available)")525526 # Visualize the pre-trends failure and solutions527 fig, axes = plt.subplots(1, 3, figsize=(18, 6))528529 # Original pre-trends (failing)530 pre_means = pre_data.groupby(["time", "treat"])["outcome"].mean().unstack()531 axes[0].plot(pre_means.index, pre_means[0], 'o-', label='Control')532 axes[0].plot(pre_means.index, pre_means[1], 's-', label='Treatment')533 axes[0].set_title("Original: Parallel Trends FAILED")534 axes[0].set_xlabel("Time")535 axes[0].set_ylabel("Outcome")536 axes[0].legend()537 axes[0].grid(True, alpha=0.3)538539 # After matching540 matched_pre = matched_df[matched_df["post"] == 0]541 matched_means = matched_pre.groupby(["time", "treat"])["outcome"].mean().unstack()542 axes[1].plot(matched_means.index, matched_means[0], 'o-', label='Matched Control')543 axes[1].plot(matched_means.index, matched_means[1], 's-', label='Treatment')544 axes[1].set_title("After Matching: Improved Balance")545 axes[1].set_xlabel("Time")546 axes[1].set_ylabel("Outcome")547 axes[1].legend()548 axes[1].grid(True, alpha=0.3)549550 # Comparison of estimates551 methods = ["Standard\nDID", "Trend-\nAdjusted", "Matched\nDID"]552 estimates = [553 df[df["treat"] == 1]["outcome"].mean() - df[df["treat"] == 0]["outcome"].mean(),554 trends_model.params["did"],555 matched_model.params["did"]556 ]557 axes[2].bar(methods, estimates)558 axes[2].set_title("Comparison of Estimates")559 axes[2].set_ylabel("Effect Estimate")560 axes[2].axhline(0, color='gray', linestyle='--', alpha=0.5)561 axes[2].grid(True, alpha=0.3, axis='y')562563 plt.tight_layout()564 os.makedirs("./images", exist_ok=True)565 plt.savefig("./images/did_pretrends_failure.png", dpi=144, bbox_inches="tight")566567 print("\nSUCCESS: Diagnostic plots saved to ./images/did_pretrends_failure.png")568569 # Get figure for inline display570 fig = plt.gcf()571 return trends_model, matched_model, fig,572573 else:574 print("SUCCESS: Parallel trends holds - proceed with standard DID")575 return None, None, None,576```577</code>578<best_practice>579When parallel trends fails:5801. NEVER ignore it - standard DID will be biased5812. Try multiple approaches and check sensitivity5823. Be transparent about the failure in reporting5834. Consider that treatment might truly be endogenous5845. Sometimes the honest answer is "we cannot identify the causal effect"585</best_practice>586</example>587588</examples>589590<common_mistakes>591592<mistake severity="critical">593 <what>Not clustering standard errors</what>594 <consequence>Type I error rates can be 45% instead of 5% (Bertrand et al., 2004)</consequence>595 <prevention>ALWAYS cluster at treatment assignment level (usually unit level)</prevention>596</mistake>597598<mistake severity="critical">599 <what>Ignoring pre-trends test</what>600 <consequence>Entire estimate may be spurious if trends differ</consequence>601 <prevention>Always test and visualize pre-trends, report even if fails</prevention>602</mistake>603604<mistake severity="high">605 <what>Using TWFE with heterogeneous effects and staggered adoption</what>606 <consequence>Estimate is weighted average including "bad comparisons" (negative weights)</consequence>607 <prevention>Use Callaway-Sant'Anna, Sun-Abraham, or other robust estimators</prevention>608</mistake>609610<mistake severity="high">611 <what>Including bad controls (post-treatment variables)</what>612 <consequence>Bias from controlling for mechanisms</consequence>613 <prevention>Only control for pre-treatment characteristics</prevention>614</mistake>615616<mistake severity="medium">617 <what>Not checking for anticipation effects</what>618 <consequence>Underestimate true effect if behavior changes before official treatment</consequence>619 <prevention>Check event study leads just before treatment</prevention>620</mistake>621622</common_mistakes>623624<interpretation_guide>625626<interpreting_results>627- DID coefficient = Average Treatment Effect on the Treated (ATT)628- Units are same as outcome variable (be specific!)629- Relative effect = (coefficient / pre-treatment mean) × 100%630- Check both statistical and economic significance631</interpreting_results>632633<red_flags>634- Pre-trend coefficients significantly different from zero635- Event study shows effects before treatment (anticipation)636- Very different estimates with different specifications637- Implausibly large effects (>50% changes are rare)638</red_flags>639640<next_steps>641- All checks pass → Report main estimate with confidence642- Pre-trends fail → Try alternative methods or acknowledge limitation643- Heterogeneous effects → Report by subgroup644- Dynamic effects → Focus on event study rather than single coefficient645</next_steps>646647</interpretation_guide>648649<references>650<paper>Angrist, J.D. & Pischke, J.S. (2009). "Mostly Harmless Econometrics." Princeton University Press. Canonical DID exposition.</paper>651<paper>Bertrand, M., Duflo, E. & Mullainathan, S. (2004). "How Much Should We Trust DID Estimates?" QJE. Serial correlation and clustering.</paper>652<paper>Roth, J. et al. (2023). "What's Trending in DID?" AER Insights. Modern best practices and pre-testing.</paper>653<paper>Goodman-Bacon, A. (2021). "DID with Variation in Treatment Timing." Journal of Econometrics. TWFE bias.</paper>654<paper>Callaway, B. & Sant'Anna, P.H.C. (2021). "DID with Multiple Time Periods." Journal of Econometrics. Robust estimation.</paper>655<paper>Sun, L. & Abraham, S. (2021). "Estimating Dynamic Treatment Effects." Journal of Econometrics. Event study bias correction.</paper>656</references>657658</skill_content>