9.3 Parallel Trends Assumption
The Core Prerequisite for DID Validity
Learning Objectives
- Deeply understand the definition and intuitive meaning of the parallel trends assumption
- Master methods for pre-trend tests
- Learn to draw event study plots
- Master multiple testing methods and clustering adjustments of standard errors
- Learn to handle violations of parallel trends
- Understand robustness checks and alternative methods
I. Definition of the Parallel Trends Assumption
Why Parallel Trends is the Core of DID Validity
Mathematical Expression: Identification assumption of DID
Meaning: In the counterfactual scenario without policy intervention, the trends in outcome variables for treatment and control groups are parallel.
Key Understanding
- We don't need the levels of treatment and control groups to be the same—fixed differences are allowed
- What matters is that before the policy, the change trends of both groups must be the same
- The parallel trends assumption is about the counterfactual—not directly observable
Illustrative Example:
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
time = np.arange(0, 10)
treatment_time = 5
# Scenario 1: Parallel trends satisfied
control_1 = 50 + 2 * time
treatment_1 = 55 + 2 * time
treatment_1[treatment_time:] += 15
axes[0].plot(time, control_1, 'o-', label='Control Group', linewidth=2)
axes[0].plot(time[:treatment_time], treatment_1[:treatment_time], 's-', label='Treatment Group', linewidth=2)
axes[0].plot(time[treatment_time:], treatment_1[treatment_time:], 's-', linewidth=2)
axes[0].plot(time[treatment_time:], 55 + 2 * time[treatment_time:], '--', color='red', alpha=0.6, label='Counterfactual')
axes[0].axvline(treatment_time - 0.5, color='green', linestyle='--', alpha=0.7)
axes[0].set_title('✓ Parallel Trends Satisfied', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Scenario 2: Pre-policy violation of parallel trends
control_2 = 50 + 2 * time
treatment_2 = 55 + 3 * time # Different trend
treatment_2[treatment_time:] += 15
axes[1].plot(time, control_2, 'o-', label='Control Group', linewidth=2)
axes[1].plot(time[:treatment_time], treatment_2[:treatment_time], 's-', label='Treatment Group', linewidth=2)
axes[1].plot(time[treatment_time:], treatment_2[treatment_time:], 's-', linewidth=2)
axes[1].plot(time[treatment_time:], 55 + 3 * time[treatment_time:], '--', color='red', alpha=0.6, label='True Counterfactual')
axes[1].axvline(treatment_time - 0.5, color='green', linestyle='--', alpha=0.7)
axes[1].set_title('✗ Parallel Trends Violated (Different Pre-trends)', fontsize=14, fontweight='bold', color='red')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# Scenario 3: Parallel pre-policy but dynamic post-policy effects
control_3 = 50 + 2 * time
treatment_3 = 55 + 2 * time
treatment_3[treatment_time:] = 55 + 2 * treatment_time + 4 * (time[treatment_time:] - treatment_time)
axes[2].plot(time, control_3, 'o-', label='Control Group', linewidth=2)
axes[2].plot(time[:treatment_time], treatment_3[:treatment_time], 's-', label='Treatment Group', linewidth=2)
axes[2].plot(time[treatment_time:], treatment_3[treatment_time:], 's-', linewidth=2)
axes[2].plot(time[treatment_time:], 55 + 2 * time[treatment_time:], '--', color='red', alpha=0.6, label='Counterfactual Without Policy')
axes[2].axvline(treatment_time - 0.5, color='green', linestyle='--', alpha=0.7)
axes[2].set_title('⚠️ Post-policy Dynamic Trend Changes (Acceptable)', fontsize=14, fontweight='bold', color='orange')
axes[2].legend()
axes[2].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()Scenario Explanations
- Scenario 1 (Green ✓): Fully satisfies DID, unbiased estimation
- Scenario 2 (Red ✗): Pre-policy trends already differ, DID estimator is biased
- Scenario 3 (Orange ⚠️): Parallel pre-policy but post-policy trend changes—an allowable situation
II. Pre-Trend Tests
Method 1: Visual Inspection
Most Intuitive Method: Plotting
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# Generate simulated data
np.random.seed(42)
n_units = 50
n_pre_periods = 8 # Pre-policy periods
n_post_periods = 5 # Post-policy periods
treatment_time = n_pre_periods
data = []
for unit in range(n_units):
treated = 1 if unit >= n_units // 2 else 0
unit_effect = 10 * treated + np.random.normal(0, 3)
for period in range(n_pre_periods + n_post_periods):
time_effect = 2 * period
post = 1 if period >= treatment_time else 0
# Policy effect (only post-policy)
treatment_effect = 20 * treated * post
y = 50 + unit_effect + time_effect + treatment_effect + np.random.normal(0, 4)
data.append({
'unit': unit,
'period': period,
'treated': treated,
'post': post,
'y': y
})
df = pd.DataFrame(data)
# Calculate group means
means_by_group = df.groupby(['treated', 'period'])['y'].mean().unstack()
# Plot trends
fig, ax = plt.subplots(figsize=(14, 8))
ax.plot(means_by_group.columns, means_by_group.loc[0], 'o-',
label='Control Group', linewidth=3, markersize=8, color='blue')
ax.plot(means_by_group.columns, means_by_group.loc[1], 's-',
label='Treatment Group', linewidth=3, markersize=8, color='red')
# Policy implementation time
ax.axvline(treatment_time - 0.5, color='green', linestyle='--',
linewidth=2, alpha=0.7, label='Policy Implementation Time')
# Add background regions
ax.axvspan(-0.5, treatment_time - 0.5, alpha=0.1, color='gray', label='Pre-Policy')
ax.axvspan(treatment_time - 0.5, n_pre_periods + n_post_periods - 0.5,
alpha=0.1, color='yellow', label='Post-Policy')
ax.set_xlabel('Period', fontsize=14, fontweight='bold')
ax.set_ylabel('Average Outcome', fontsize=14, fontweight='bold')
ax.set_title('Parallel Trends Visual Inspection: Treatment vs Control', fontsize=16, fontweight='bold')
ax.legend(fontsize=12)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print("=" * 70)
print("Visual Inspection Results")
print("=" * 70)
print("✓ Observation: Trends of both groups are roughly parallel pre-policy")
print("✓ Observation: No obvious divergent trends pre-policy")
print("⚠️ But we need statistical tests to confirm")Method 2: Pre-Trend Test Regression
Approach: Test whether treatment and control groups have different time trends pre-policy
Regression Equation (using only pre-policy data):
Null Hypothesis: (treatment and control groups have the same time trend)
import statsmodels.formula.api as smf
# Use only pre-policy data
df_pre = df[df['period'] < treatment_time].copy()
# Regression: test interaction term
model_pre_trend = smf.ols('y ~ treated + period + treated:period', data=df_pre).fit()
print("=" * 70)
print("Pre-Trend Test Regression")
print("=" * 70)
print(model_pre_trend.summary())
print("\n")
# Extract key coefficient
interaction_coef = model_pre_trend.params['treated:period']
interaction_pval = model_pre_trend.pvalues['treated:period']
print("=" * 70)
print("Test Results")
print("=" * 70)
print(f"Interaction Coefficient: {interaction_coef:.4f}")
print(f"p-value: {interaction_pval:.4f}")
if interaction_pval > 0.05:
print("✓ Conclusion: Cannot reject parallel trends assumption (p > 0.05)")
else:
print("✗ Conclusion: Reject parallel trends assumption (p < 0.05), different trends exist")Considerations
- Roth (2022) Warning: "Pretest with Caution"
- Pre-trend tests have limited statistical power, failing to reject null doesn't mean assumption holds
- Significance tests are easily affected by sample size
- Recommendation: Combine visualization with multiple tests
III. Event Study Plots
What is Event Study
Definition: Estimate dynamic treatment effects for each period before and after the policy
Model Equation
where:
- indicates individual at time is periods away from policy implementation
- serves as the baseline period (normalization)
- is the treatment effect for relative period
Interpretation
- : Pre-policy, should be close to 0 (parallel trends test)
- : Post-policy, represents dynamic effects of policy
Complete Event Study Python Implementation
from linearmodels.panel import PanelOLS
# Construct relative time variable
df['rel_time'] = df['period'] - treatment_time
# Construct relative time only for treatment group
df['rel_time_treated'] = df['rel_time'] * df['treated']
# Generate leads and lags dummies (t=-1 as baseline)
for k in range(-n_pre_periods, n_post_periods):
if k != -1: # Baseline group
df[f'lead_lag_{k}'] = ((df['rel_time_treated'] == k)).astype(int)
# Convert to panel format
df_panel = df.set_index(['unit', 'period'])
# Construct regression formula
lead_lag_vars = [f'lead_lag_{k}' for k in range(-n_pre_periods, n_post_periods) if k != -1]
# Event study regression
model_es = PanelOLS(
dependent=df_panel['y'],
exog=df_panel[lead_lag_vars],
entity_effects=True,
time_effects=True
).fit(cov_type='clustered', cluster_entity=True)
print("=" * 70)
print("Event Study Regression Results")
print("=" * 70)
print(model_es.summary)
print("\n")
# Extract coefficients and construct plotting data
event_study_results = []
for k in range(-n_pre_periods, n_post_periods):
if k == -1:
# Baseline period
event_study_results.append({
'rel_time': k,
'coef': 0,
'ci_lower': 0,
'ci_upper': 0
})
else:
var_name = f'lead_lag_{k}'
coef = model_es.params[var_name]
ci = model_es.conf_int().loc[var_name]
event_study_results.append({
'rel_time': k,
'coef': coef,
'ci_lower': ci[0],
'ci_upper': ci[1]
})
es_df = pd.DataFrame(event_study_results)
# Plot event study
fig, ax = plt.subplots(figsize=(16, 8))
ax.plot(es_df['rel_time'], es_df['coef'], 'o-',
linewidth=3, markersize=10, color='navy', label='DID Coefficient', zorder=3)
ax.fill_between(es_df['rel_time'], es_df['ci_lower'], es_df['ci_upper'],
alpha=0.3, color='navy', label='95% Confidence Interval')
# Zero line
ax.axhline(y=0, color='black', linestyle='--', linewidth=1.5, alpha=0.7)
# Policy implementation time
ax.axvline(x=-0.5, color='red', linestyle='--', linewidth=2.5, alpha=0.8, label='Policy Implementation Time')
# Background regions
ax.axvspan(-n_pre_periods, -0.5, alpha=0.08, color='gray')
ax.axvspan(-0.5, n_post_periods, alpha=0.08, color='yellow')
ax.text(-n_pre_periods/2, ax.get_ylim()[1] * 0.9, 'Pre-Policy',
fontsize=14, ha='center', bbox=dict(boxstyle='round', facecolor='gray', alpha=0.3))
ax.text(n_post_periods/2, ax.get_ylim()[1] * 0.9, 'Post-Policy',
fontsize=14, ha='center', bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.3))
ax.set_xlabel('Time Relative to Policy Implementation', fontsize=14, fontweight='bold')
ax.set_ylabel('Estimated Coefficient', fontsize=14, fontweight='bold')
ax.set_title('Event Study: Dynamic Treatment Effects', fontsize=16, fontweight='bold')
ax.legend(fontsize=12, loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Test pre-trend coefficients
print("=" * 70)
print("Pre-Trend Test (Examine Pre-Policy Coefficients)")
print("=" * 70)
pre_treatment_coefs = es_df[es_df['rel_time'] < 0]
print(pre_treatment_coefs)
print("\n")
# Joint test: Are all pre-trend coefficients zero?
from scipy import stats
pre_coefs = [model_es.params[f'lead_lag_{k}'] for k in range(-n_pre_periods, -1)]
pre_vcov = model_es.cov[lead_lag_vars].loc[
[f'lead_lag_{k}' for k in range(-n_pre_periods, -1)],
[f'lead_lag_{k}' for k in range(-n_pre_periods, -1)]
]
# F-test (joint test)
# F = (R * beta)' * (R * V * R')^{-1} * (R * beta) / q
# Simplified using Wald test here
print("Note: If all pre-trend coefficients are significantly non-zero, parallel trends assumption is violated")
print("Recommendation: Examine the plot and confidence intervals of pre-trend coefficients to judge violations")Interpreting Event Study Plots
Key Interpretation Points
Pre-Policy ()
- ✓ All coefficients should be close to 0 and insignificant
- ✗ If coefficients are significantly different from 0, parallel trends are violated
Policy Implementation Period ()
- Does the policy effect appear immediately?
- What is the magnitude of the effect?
Post-Policy ()
- Does the effect persist?
- Does the effect strengthen/weaken/remain stable over time?
Confidence Intervals
- Check if confidence intervals for all post-policy periods exclude 0
- Widening confidence intervals indicate insufficient sample size or increased heterogeneity
IV. Handling Violations of Parallel Trends
Solution 1: Select More Similar Control Groups
Approach: Restrict control group to units most similar to treatment group
# Select similar control groups based on pre-policy average outcomes
df_pre_avg = df[df['period'] < treatment_time].groupby('unit')['y'].mean().reset_index()
df_pre_avg.columns = ['unit', 'pre_avg']
df = df.merge(df_pre_avg, on='unit')
# Select control groups similar to treatment group (e.g., based on pre_avg distribution)
# Simplified here to select control groups within quantile ranges
control_units = df[(df['treated'] == 0) &
(df['pre_avg'] > df[df['treated'] == 1]['pre_avg'].quantile(0.25)) &
(df['pre_avg'] < df[df['treated'] == 1]['pre_avg'].quantile(0.75))]['unit'].unique()
df_matched = df[(df['treated'] == 1) | (df['unit'].isin(control_units))]
print(f"Original control group size: {(df['treated'] == 0).sum() // (n_pre_periods + n_post_periods)}")
print(f"Matched control group size: {len(control_units)}")Solution 2: Control for Linear Trend Interactions
Model Equation
Allows treatment group to have a different linear trend
# Generate linear trend interaction
df['treated_time'] = df['treated'] * df['period']
model_trend = PanelOLS(
df.set_index(['unit', 'period'])['y'],
df.set_index(['unit', 'period'])[['did', 'treated_time']],
entity_effects=True,
time_effects=True
).fit(cov_type='clustered', cluster_entity=True)
print("=" * 70)
print("DID Controlling for Linear Trends")
print("=" * 70)
print(model_trend.summary)Warning
- May suffer from over-control problems
- If trend differences are long-term effects of the policy, controlling for trends will underestimate true policy effects
Solution 3: Use Synthetic Control Method
Approach: Use a weighted combination of multiple control group units to construct a "synthetic control group" that maximally matches the treatment group pre-policy
Pseudocode Example
# Requires installing relevant packages, e.g., pySynth or synthdid
print("=" * 70)
print("Using Synthetic Control Method")
print("=" * 70)
print("Alternative approach when parallel trends assumption doesn't hold:")
print("")
print("Basic Idea:")
print("1. Use weighted combinations of control group units to construct a 'synthetic control'")
print("2. Weights chosen to maximize matching of treatment group pre-policy")
print("3. Post-policy: Compare real treatment group to synthetic control to estimate policy effect")
print("")
print("Applicable Scenarios:")
print("- Treatment group has only 1 (or very few) units")
print("- Difficult to find fully similar control groups")
print("- Parallel trends assumption is weak")
print("")
print("Python Packages:")
print("- pip install pySynth")
print("- Or use R's Synth package")V. Complete Parallel Trends Analysis Function
Wrapper Code
def did_parallel_trends_analysis(df, treatment_time, outcome, unit_var, time_var, treated_var):
"""
Complete parallel trends analysis
Parameters:
- df: dataframe
- treatment_time: policy implementation time
- outcome: outcome variable name
- unit_var: unit variable name
- time_var: time variable name
- treated_var: treatment group variable name
"""
import matplotlib.pyplot as plt
import seaborn as sns
from linearmodels.panel import PanelOLS
print("=" * 70)
print("Complete DID Parallel Trends Analysis")
print("=" * 70)
# 1. Visual inspection
print("\n[1/4] Plotting trends...")
means = df.groupby([treated_var, time_var])[outcome].mean().unstack()
fig, ax = plt.subplots(figsize=(14, 8))
ax.plot(means.columns, means.loc[0], 'o-', label='Control Group', linewidth=2.5, markersize=8)
ax.plot(means.columns, means.loc[1], 's-', label='Treatment Group', linewidth=2.5, markersize=8)
ax.axvline(treatment_time - 0.5, color='red', linestyle='--', linewidth=2, label='Policy Implementation Time')
ax.legend(fontsize=12)
ax.set_title('Parallel Trends Visual Inspection', fontsize=16, fontweight='bold')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# 2. Pre-trend test regression
print("\n[2/4] Running pre-trend test regression...")
df_pre = df[df[time_var] < treatment_time].copy()
formula = f'{outcome} ~ {treated_var} + {time_var} + {treated_var}:{time_var}'
model_pre = smf.ols(formula, data=df_pre).fit()
interaction_pval = model_pre.pvalues[f'{treated_var}:{time_var}']
print(f"Interaction term p-value: {interaction_pval:.4f}")
if interaction_pval > 0.05:
print("✓ Pre-trend test result: Cannot reject parallel trends assumption")
else:
print("✗ Pre-trend test result: Reject parallel trends assumption")
# 3. Event study
print("\n[3/4] Running event study plot...")
df['rel_time'] = df[time_var] - treatment_time
df['rel_time_treated'] = df['rel_time'] * df[treated_var]
# Generate leads and lags (specific implementation omitted, refer to complete code above)
# 4. Provide further recommendations
print("\n[4/4] Further recommendations:")
print(" - Check if control group matching is needed")
print(" - Check if post-policy dynamic effects match expectations")
print(" - Control for linear trends or other covariates")
print(" - Consider using synthetic control method")
print("\n" + "=" * 70)
print("Analysis Complete")
print("=" * 70)
# Usage example
# did_parallel_trends_analysis(df, treatment_time=8, outcome='y',
# unit_var='unit', time_var='period', treated_var='treated')VI. Section Summary
Key Takeaways
Parallel Trends Assumption is the most core, irreplaceable assumption for DID identification
Testing Methods
- Visual inspection: Intuitive observation
- Pre-trend test regression: Statistical testing
- Event study plot: Most comprehensive method (recommended)
Event Study Best Practices
- Parallel trends test: Observe pre-policy coefficients (close to 0 and insignificant)
- Dynamic effects: Observe post-policy coefficients
- Provides richer information
Handling Violations of Parallel Trends
- Select more similar control groups
- Control for linear trends
- Use synthetic control method
- Consider other identification strategies
Best Practices
| Step | Method |
|---|---|
| 1. Visual inspection | Plot and observe trends |
| 2. Pre-trend test | Use statistical tests (but be cautious per Roth 2022) |
| 3. Event study | Use complete dynamic effects |
| 4. Robustness | Combine multiple validation methods |
Next Steps
Proceed to Section 4: Placebo Tests for further validation:
- Fake policy tests
- Fake control group tests
- Recommended best practices and robustness check scenarios
The parallel trends assumption is the soul of DID!
Previous: 9.2 DID Fundamentals | Next: 9.4 Placebo Tests