Causal Inference
Overview
Causal inference determines cause-and-effect relationships and estimates treatment effects, going beyond correlation to understand what causes what.
When to Use
- Evaluating the impact of policy interventions or business decisions
- Estimating treatment effects when randomized experiments aren't feasible
- Controlling for confounding variables in observational data
- Determining if a marketing campaign or product change caused an outcome
- Analyzing heterogeneous treatment effects across different user segments
- Making causal claims from non-experimental data using propensity scores or instrumental variables
Key Concepts
- Treatment: Intervention or exposure
- Outcome: Result or consequence
- Confounding: Variables affecting both treatment and outcome
- Causal Graph: Visual representation of relationships
- Treatment Effect: Impact of intervention
- Selection Bias: Non-random treatment assignment
Causal Methods
- Randomized Controlled Trials (RCT): Gold standard
- Propensity Score Matching: Balance treatment/control
- Difference-in-Differences: Before/after comparison
- Instrumental Variables: Handle endogeneity
- Causal Forests: Heterogeneous treatment effects
Implementation with Python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.preprocessing import StandardScaler
from scipy import stats
# Generate observational data with confounding
np.random.seed(42)
n = 1000
# Confounder: Age (affects both treatment and outcome)
age = np.random.uniform(25, 75, n)
# Treatment: Training program (more likely for younger people)
treatment_prob = 0.3 + 0.3 * (75 - age) / 50 # Inverse relationship with age
treatment = (np.random.uniform(0, 1, n) < treatment_prob).astype(int)
# Outcome: Salary (affected by both treatment and age)
# True causal effect of treatment: +$5000
salary = 40000 + 500 * age + 5000 * treatment + np.random.normal(0, 10000, n)
df = pd.DataFrame({
'age': age,
'treatment': treatment,
'salary': salary,
})
print("Observational Data Summary:")
print(df.describe())
print(f"\nTreatment Rate: {df['treatment'].mean():.1%}")
print(f"Average Salary (Control): ${df[df['treatment']==0]['salary'].mean():.0f}")
print(f"Average Salary (Treatment): ${df[df['treatment']==1]['salary'].mean():.0f}")
# 1. Naive Comparison (BIASED - ignores confounding)
naive_effect = df[df['treatment']==1]['salary'].mean() - df[df['treatment']==0]['salary'].mean()
print(f"\n1. Naive Comparison: ${naive_effect:.0f} (BIASED)")
# 2. Regression Adjustment (Covariate Adjustment)
X = df[['treatment', 'age']]
y = df['salary']
model = LinearRegression()
model.fit(X, y)
regression_effect = model.coef_[0]
print(f"\n2. Regression Adjustment: ${regression_effect:.0f}")
# 3. Propensity Score Matching
# Estimate probability of treatment given covariates
ps_model = LogisticRegression()
ps_model.fit(df[['age']], df['treatment'])
df['propensity_score'] = ps_model.predict_proba(df[['age']])[:, 1]
print(f"\n3. Propensity Score Matching:")
print(f"PS range: [{df['propensity_score'].min():.3f}, {df['propensity_score'].max():.3f}]")
# Matching: find control for each treated unit
matched_pairs = []
treated_units = df[df['treatment'] == 1].index
for treated_idx in treated_units:
treated_ps = df.loc[treated_idx, 'propensity_score']
treated_age = df.loc[treated_idx, 'age']
# Find closest control unit
control_units = df[(df['treatment'] == 0) &
(df['propensity_score'] >= treated_ps - 0.1) &
(df['propensity_score'] <= treated_ps + 0.1)].index
if len(control_units) > 0:
closest_control = min(control_units,
key=lambda x: abs(df.loc[x, 'propensity_score'] - treated_ps))
matched_pairs.append({
'treated_idx': treated_idx,
'control_idx': closest_control,
'treated_salary': df.loc[treated_idx, 'salary'],
'control_salary': df.loc[closest_control, 'salary'],
})
matched_df = pd.DataFrame(matched_pairs)
psm_effect = (matched_df['treated_salary'] - matched_df['control_salary']).mean()
print(f"PSM Effect: ${psm_effect:.0f}")
print(f"Matched pairs: {len(matched_df)}")
# 4. Stratification by Propensity Score
df['ps_stratum'] = pd.qcut(df['propensity_score'], q=5, labels=False, duplicates='drop')
stratified_effects = []
for stratum in df['ps_stratum'].unique():
stratum_data = df[df['ps_stratum'] == stratum]
if (stratum_data['treatment'] == 0).sum() > 0 and (stratum_data['treatment'] == 1).sum() > 0:
treated_mean = stratum_data[stratum_data['treatment'] == 1]['salary'].mean()
control_mean = stratum_data[stratum_data['treatment'] == 0]['salary'].mean()
effect = treated_mean - control_mean
stratified_effects.append(effect)
stratified_effect = np.mean(stratified_effects)
print(f"\n4. Stratification by PS: ${stratified_effect:.0f}")
# 5. Visualization
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Treatment distribution by age
ax = axes[0, 0]
treated = df[df['treatment'] == 1]
control = df[df['treatment'] == 0]
ax.hist(control['age'], bins=20, alpha=0.6, label='Control', color='blue')
ax.hist(treated['age'], bins=20, alpha=0.6, label='Treated', color='red')
ax.set_xlabel('Age')
ax.set_ylabel('Frequency')
ax.set_title('Age Distribution by Treatment')
ax.legend()
ax.grid(True, alpha=0.3, axis='y')
# Salary vs Age (colored by treatment)
ax = axes[0, 1]
ax.scatter(control['age'], control['salary'], alpha=0.5, label='Control', s=30)
ax.scatter(treated['age'], treated['salary'], alpha=0.5, label='Treated', s=30, color='red')
ax.set_xlabel('Age')
ax.set_ylabel('Salary')
ax.set_title('Salary vs Age by Treatment')
ax.legend()
ax.grid(True, alpha=0.3)
# Propensity Score Distribution
ax = axes[1, 0]
ax.hist(df[df['treatment'] == 0]['propensity_score'], bins=20, alpha=0.6, label='Control', color='blue')
ax.hist(df[df['treatment'] == 1]['propensity_score'], bins=20, alpha=0.6, label='Treated', color='red')
ax.set_xlabel('Propensity Score')
ax.set_ylabel('Frequency')
ax.set_title('Propensity Score Distribution')
ax.legend()
ax.grid(True, alpha=0.3, axis='y')
# Treatment Effect Comparison
ax = axes[1, 1]
methods = ['Naive', 'Regression', 'PSM', 'Stratified']
effects = [naive_effect, regression_effect, psm_effect, stratified_effect]
true_effect = 5000
ax.bar(methods, effects, color=['red', 'orange', 'yellow', 'lightgreen'], alpha=0.7, edgecolor='black')
ax.axhline(y=true_effect, color='green', linestyle='--', linewidth=2, label=f'True Effect (${true_effect:.0f})')
ax.set_ylabel('Treatment Effect ($)')
ax.set_title('Treatment Effect Estimates by Method')
ax.legend()
ax.grid(True, alpha=0.3, axis='y')
for i, effect in enumerate(effects):
ax.text(i, effect + 200, f'${effect:.0f}', ha='center', va='bottom')
plt.tight_layout()
plt.show()
# 6. Doubly Robust Estimation
from sklearn.ensemble import RandomForestRegressor
# Propensity score model
ps_model_dr = LogisticRegression().fit(df[['age']], df['treatment'])
ps_scores = ps_model_dr.predict_proba(df[['age']])[:, 1]
# Outcome model
outcome_model = RandomForestRegressor(n_estimators=50, random_state=42)
outcome_model.fit(df[['treatment', 'age']], df['salary'])
# Doubly robust estimator
treated_mask = df['treatment'] == 1
control_mask = df['treatment'] == 0
# Adjust for propensity score
treated_adjusted = (treated_mask.astype(int) * df['salary']) / (ps_scores + 0.01)
control_adjusted = (control_mask.astype(int) * df['salary']) / (1 - ps_scores + 0.01)
# Outcome predictions
pred_treated = outcome_model.predict(df[['treatment', 'age']].replace({'treatment': 0, 1: 1}))
pred_control = outcome_model.predict(df[['treatment', 'age']].replace({'treatment': 1, 0: 0}))
dr_effect = treated_adjusted.sum() / treated_mask.sum() - control_adjusted.sum() / control_mask.sum()
print(f"\n6. Doubly Robust Estimation: ${dr_effect:.0f}")
# 7. Heterogeneous Treatment Effects
print(f"\n7. Heterogeneous Treatment Effects (by Age Quartile):")
for age_q in pd.qcut(df['age'], q=4, duplicates='drop').unique():
mask = (df['age'] >= age_q.left) & (df['age'] < age_q.right)
stratum_data = df[mask]
if (stratum_data['treatment'] == 0).sum() > 0 and (stratum_data['treatment'] == 1).sum() > 0:
treated_mean = stratum_data[stratum_data['treatment'] == 1]['salary'].mean()
control_mean = stratum_data[stratum_data['treatment'] == 0]['salary'].mean()
effect = treated_mean - control_mean
print(f" Age {age_q.left:.0f}-{age_q.right:.0f}: ${effect:.0f}")
# 8. Sensitivity Analysis
print(f"\n8. Sensitivity Analysis (Hidden Confounder Impact):")
# Vary hidden confounder correlation with outcome
for hidden_effect in [1000, 2000, 5000, 10000]:
adjusted_effect = regression_effect - hidden_effect * 0.1
print(f" If hidden confounder worth ${hidden_effect}: Effect = ${adjusted_effect:.0f}")
# 9. Summary Table
print(f"\n" + "="*60)
print("CAUSAL INFERENCE SUMMARY")
print("="*60)
print(f"True Treatment Effect: ${true_effect:,.0f}")
print(f"\nEstimates:")
print(f" Naive (BIASED): ${naive_effect:,.0f}")
print(f" Regression Adjustment: ${regression_effect:,.0f}")
print(f" Propensity Score Matching: ${psm_effect:,.0f}")
print(f" Stratification: ${stratified_effect:,.0f}")
print(f" Doubly Robust: ${dr_effect:,.0f}")
print("="*60)
# 10. Causal Graph (Text representation)
print(f"\n10. Causal Graph (DAG):")
print(f"""
Age → Treatment ← (Selection Bias)
↓ ↓
└─→ Salary
Interpretation:
- Age is a confounder
- Treatment causally affects Salary
- Age directly affects Salary
- Age affects probability of Treatment
""")
Causal Assumptions
- Unconfoundedness: No unmeasured confounders
- Overlap: Common support on propensity scores
- SUTVA: No interference between units
- Consistency: Single version of treatment
Treatment Effect Types
- ATE: Average Treatment Effect (overall)
- ATT: Average Treatment on Treated
- CATE: Conditional Average Treatment Effect
- HTE: Heterogeneous Treatment Effects
Method Strengths
- RCT: Gold standard, controls all confounders
- Matching: Balances groups, preserves overlap
- Regression: Adjusts for covariates
- Instrumental Variables: Handles endogeneity
- Causal Forests: Learns heterogeneous effects
Deliverables
- Causal graph visualization
- Treatment effect estimates
- Sensitivity analysis
- Heterogeneous treatment effects
- Covariate balance assessment
- Propensity score diagnostics
- Final causal inference report
1---2name: causal-inference3description: Determine cause-and-effect relationships using propensity scoring, instrumental variables, and causal graphs for policy evaluation and treatment effects4---5
6# Causal Inference
7
8## Overview
9
10Causal inference determines cause-and-effect relationships and estimates treatment effects, going beyond correlation to understand what causes what.
11
12## When to Use
13
14- Evaluating the impact of policy interventions or business decisions
15- Estimating treatment effects when randomized experiments aren't feasible
16- Controlling for confounding variables in observational data
17- Determining if a marketing campaign or product change caused an outcome
18- Analyzing heterogeneous treatment effects across different user segments
19- Making causal claims from non-experimental data using propensity scores or instrumental variables
20
21## Key Concepts
22
23- **Treatment**: Intervention or exposure
24- **Outcome**: Result or consequence
25- **Confounding**: Variables affecting both treatment and outcome
26- **Causal Graph**: Visual representation of relationships
27- **Treatment Effect**: Impact of intervention
28- **Selection Bias**: Non-random treatment assignment
29
30## Causal Methods
31
32- **Randomized Controlled Trials (RCT)**: Gold standard
33- **Propensity Score Matching**: Balance treatment/control
34- **Difference-in-Differences**: Before/after comparison
35- **Instrumental Variables**: Handle endogeneity
36- **Causal Forests**: Heterogeneous treatment effects
37
38## Implementation with Python
39
40```python
41import pandas as pd
42import numpy as np
43import matplotlib.pyplot as plt
44import seaborn as sns
45from sklearn.linear_model import LinearRegression, LogisticRegression
46from sklearn.preprocessing import StandardScaler
47from scipy import stats
48
49# Generate observational data with confounding
50np.random.seed(42)
51
52n = 1000
53
54# Confounder: Age (affects both treatment and outcome)
55age = np.random.uniform(25, 75, n)
56
57# Treatment: Training program (more likely for younger people)
58treatment_prob = 0.3 + 0.3 * (75 - age) / 50 # Inverse relationship with age
59treatment = (np.random.uniform(0, 1, n) < treatment_prob).astype(int)
60
61# Outcome: Salary (affected by both treatment and age)
62# True causal effect of treatment: +$5000
63salary = 40000 + 500 * age + 5000 * treatment + np.random.normal(0, 10000, n)
64
65df = pd.DataFrame({
66 'age': age,
67 'treatment': treatment,
68 'salary': salary,
69})
70
71print("Observational Data Summary:")
72print(df.describe())
73print(f"\nTreatment Rate: {df['treatment'].mean():.1%}")
74print(f"Average Salary (Control): ${df[df['treatment']==0]['salary'].mean():.0f}")
75print(f"Average Salary (Treatment): ${df[df['treatment']==1]['salary'].mean():.0f}")
76
77# 1. Naive Comparison (BIASED - ignores confounding)
78naive_effect = df[df['treatment']==1]['salary'].mean() - df[df['treatment']==0]['salary'].mean()
79print(f"\n1. Naive Comparison: ${naive_effect:.0f} (BIASED)")
80
81# 2. Regression Adjustment (Covariate Adjustment)
82X = df[['treatment', 'age']]
83y = df['salary']
84model = LinearRegression()
85model.fit(X, y)
86regression_effect = model.coef_[0]
87
88print(f"\n2. Regression Adjustment: ${regression_effect:.0f}")
89
90# 3. Propensity Score Matching
91# Estimate probability of treatment given covariates
92ps_model = LogisticRegression()
93ps_model.fit(df[['age']], df['treatment'])
94df['propensity_score'] = ps_model.predict_proba(df[['age']])[:, 1]
95
96print(f"\n3. Propensity Score Matching:")
97print(f"PS range: [{df['propensity_score'].min():.3f}, {df['propensity_score'].max():.3f}]")
98
99# Matching: find control for each treated unit
100matched_pairs = []
101treated_units = df[df['treatment'] == 1].index
102for treated_idx in treated_units:
103 treated_ps = df.loc[treated_idx, 'propensity_score']
104 treated_age = df.loc[treated_idx, 'age']
105
106 # Find closest control unit
107 control_units = df[(df['treatment'] == 0) &
108 (df['propensity_score'] >= treated_ps - 0.1) &
109 (df['propensity_score'] <= treated_ps + 0.1)].index
110
111 if len(control_units) > 0:
112 closest_control = min(control_units,
113 key=lambda x: abs(df.loc[x, 'propensity_score'] - treated_ps))
114 matched_pairs.append({
115 'treated_idx': treated_idx,
116 'control_idx': closest_control,
117 'treated_salary': df.loc[treated_idx, 'salary'],
118 'control_salary': df.loc[closest_control, 'salary'],
119 })
120
121matched_df = pd.DataFrame(matched_pairs)
122psm_effect = (matched_df['treated_salary'] - matched_df['control_salary']).mean()
123print(f"PSM Effect: ${psm_effect:.0f}")
124print(f"Matched pairs: {len(matched_df)}")
125
126# 4. Stratification by Propensity Score
127df['ps_stratum'] = pd.qcut(df['propensity_score'], q=5, labels=False, duplicates='drop')
128
129stratified_effects = []
130for stratum in df['ps_stratum'].unique():
131 stratum_data = df[df['ps_stratum'] == stratum]
132 if (stratum_data['treatment'] == 0).sum() > 0 and (stratum_data['treatment'] == 1).sum() > 0:
133 treated_mean = stratum_data[stratum_data['treatment'] == 1]['salary'].mean()
134 control_mean = stratum_data[stratum_data['treatment'] == 0]['salary'].mean()
135 effect = treated_mean - control_mean
136 stratified_effects.append(effect)
137
138stratified_effect = np.mean(stratified_effects)
139print(f"\n4. Stratification by PS: ${stratified_effect:.0f}")
140
141# 5. Visualization
142fig, axes = plt.subplots(2, 2, figsize=(14, 10))
143
144# Treatment distribution by age
145ax = axes[0, 0]
146treated = df[df['treatment'] == 1]
147control = df[df['treatment'] == 0]
148ax.hist(control['age'], bins=20, alpha=0.6, label='Control', color='blue')
149ax.hist(treated['age'], bins=20, alpha=0.6, label='Treated', color='red')
150ax.set_xlabel('Age')
151ax.set_ylabel('Frequency')
152ax.set_title('Age Distribution by Treatment')
153ax.legend()
154ax.grid(True, alpha=0.3, axis='y')
155
156# Salary vs Age (colored by treatment)
157ax = axes[0, 1]
158ax.scatter(control['age'], control['salary'], alpha=0.5, label='Control', s=30)
159ax.scatter(treated['age'], treated['salary'], alpha=0.5, label='Treated', s=30, color='red')
160ax.set_xlabel('Age')
161ax.set_ylabel('Salary')
162ax.set_title('Salary vs Age by Treatment')
163ax.legend()
164ax.grid(True, alpha=0.3)
165
166# Propensity Score Distribution
167ax = axes[1, 0]
168ax.hist(df[df['treatment'] == 0]['propensity_score'], bins=20, alpha=0.6, label='Control', color='blue')
169ax.hist(df[df['treatment'] == 1]['propensity_score'], bins=20, alpha=0.6, label='Treated', color='red')
170ax.set_xlabel('Propensity Score')
171ax.set_ylabel('Frequency')
172ax.set_title('Propensity Score Distribution')
173ax.legend()
174ax.grid(True, alpha=0.3, axis='y')
175
176# Treatment Effect Comparison
177ax = axes[1, 1]
178methods = ['Naive', 'Regression', 'PSM', 'Stratified']
179effects = [naive_effect, regression_effect, psm_effect, stratified_effect]
180true_effect = 5000
181
182ax.bar(methods, effects, color=['red', 'orange', 'yellow', 'lightgreen'], alpha=0.7, edgecolor='black')
183ax.axhline(y=true_effect, color='green', linestyle='--', linewidth=2, label=f'True Effect (${true_effect:.0f})')
184ax.set_ylabel('Treatment Effect ($)')
185ax.set_title('Treatment Effect Estimates by Method')
186ax.legend()
187ax.grid(True, alpha=0.3, axis='y')
188
189for i, effect in enumerate(effects):
190 ax.text(i, effect + 200, f'${effect:.0f}', ha='center', va='bottom')
191
192plt.tight_layout()
193plt.show()
194
195# 6. Doubly Robust Estimation
196from sklearn.ensemble import RandomForestRegressor
197
198# Propensity score model
199ps_model_dr = LogisticRegression().fit(df[['age']], df['treatment'])
200ps_scores = ps_model_dr.predict_proba(df[['age']])[:, 1]
201
202# Outcome model
203outcome_model = RandomForestRegressor(n_estimators=50, random_state=42)
204outcome_model.fit(df[['treatment', 'age']], df['salary'])
205
206# Doubly robust estimator
207treated_mask = df['treatment'] == 1
208control_mask = df['treatment'] == 0
209
210# Adjust for propensity score
211treated_adjusted = (treated_mask.astype(int) * df['salary']) / (ps_scores + 0.01)
212control_adjusted = (control_mask.astype(int) * df['salary']) / (1 - ps_scores + 0.01)
213
214# Outcome predictions
215pred_treated = outcome_model.predict(df[['treatment', 'age']].replace({'treatment': 0, 1: 1}))
216pred_control = outcome_model.predict(df[['treatment', 'age']].replace({'treatment': 1, 0: 0}))
217
218dr_effect = treated_adjusted.sum() / treated_mask.sum() - control_adjusted.sum() / control_mask.sum()
219print(f"\n6. Doubly Robust Estimation: ${dr_effect:.0f}")
220
221# 7. Heterogeneous Treatment Effects
222print(f"\n7. Heterogeneous Treatment Effects (by Age Quartile):")
223
224for age_q in pd.qcut(df['age'], q=4, duplicates='drop').unique():
225 mask = (df['age'] >= age_q.left) & (df['age'] < age_q.right)
226 stratum_data = df[mask]
227
228 if (stratum_data['treatment'] == 0).sum() > 0 and (stratum_data['treatment'] == 1).sum() > 0:
229 treated_mean = stratum_data[stratum_data['treatment'] == 1]['salary'].mean()
230 control_mean = stratum_data[stratum_data['treatment'] == 0]['salary'].mean()
231 effect = treated_mean - control_mean
232
233 print(f" Age {age_q.left:.0f}-{age_q.right:.0f}: ${effect:.0f}")
234
235# 8. Sensitivity Analysis
236print(f"\n8. Sensitivity Analysis (Hidden Confounder Impact):")
237
238# Vary hidden confounder correlation with outcome
239for hidden_effect in [1000, 2000, 5000, 10000]:
240 adjusted_effect = regression_effect - hidden_effect * 0.1
241 print(f" If hidden confounder worth ${hidden_effect}: Effect = ${adjusted_effect:.0f}")
242
243# 9. Summary Table
244print(f"\n" + "="*60)
245print("CAUSAL INFERENCE SUMMARY")
246print("="*60)
247print(f"True Treatment Effect: ${true_effect:,.0f}")
248print(f"\nEstimates:")
249print(f" Naive (BIASED): ${naive_effect:,.0f}")
250print(f" Regression Adjustment: ${regression_effect:,.0f}")
251print(f" Propensity Score Matching: ${psm_effect:,.0f}")
252print(f" Stratification: ${stratified_effect:,.0f}")
253print(f" Doubly Robust: ${dr_effect:,.0f}")
254print("="*60)
255
256# 10. Causal Graph (Text representation)
257print(f"\n10. Causal Graph (DAG):")
258print(f"""
259Age → Treatment ← (Selection Bias)
260 ↓ ↓
261 └─→ Salary
262
263Interpretation:
264- Age is a confounder
265- Treatment causally affects Salary
266- Age directly affects Salary
267- Age affects probability of Treatment
268""")
269```
270
271## Causal Assumptions
272
273- **Unconfoundedness**: No unmeasured confounders
274- **Overlap**: Common support on propensity scores
275- **SUTVA**: No interference between units
276- **Consistency**: Single version of treatment
277
278## Treatment Effect Types
279
280- **ATE**: Average Treatment Effect (overall)
281- **ATT**: Average Treatment on Treated
282- **CATE**: Conditional Average Treatment Effect
283- **HTE**: Heterogeneous Treatment Effects
284
285## Method Strengths
286
287- **RCT**: Gold standard, controls all confounders
288- **Matching**: Balances groups, preserves overlap
289- **Regression**: Adjusts for covariates
290- **Instrumental Variables**: Handles endogeneity
291- **Causal Forests**: Learns heterogeneous effects
292
293## Deliverables
294
295- Causal graph visualization
296- Treatment effect estimates
297- Sensitivity analysis
298- Heterogeneous treatment effects
299- Covariate balance assessment
300- Propensity score diagnostics
301- Final causal inference report