Statistics tells us: Variables are associated
Causal inference asks: Does changing one variable cause changes in another?
Moving from “X is correlated with Y” to “X causes Y”
DAG: Directed Acyclic Graph
A visual tool for representing our assumptions about the data generating mechanism
This DAG says: X has a causal effect on Y
Example: Ice cream sales and drowning deaths are correlated
Does ice cream cause drowning?
No! Temperature causes both:
DAGs help us think clearly about what causes what
For each person \(i\), there are two potential outcomes:
Individual treatment effect: \[\tau_i = Y_i(1) - Y_i(0)\]
We can only observe ONE potential outcome for each person!
If person \(i\) gets exposed, we see \(Y_i(1)\) but not \(Y_i(0)\)
If person \(i\) doesn’t get exposed, we see \(Y_i(0)\) but not \(Y_i(1)\)
The missing outcome is called the counterfactual
Since we can’t measure individual effects, we focus on averages:
\[\text{ATE} = E[Y(1) - Y(0)] = E[Y(1)] - E[Y(0)]\]
In words: The average difference in outcomes if everyone was exposed vs. if no one was exposed
Tempting approach: Compare exposed vs. unexposed groups \[E[Y|X=1] - E[Y|X=0]\]
Problem: This is often NOT the same as the ATE because exposed and unexposed groups might differ in other ways
Imagine a drug study where:
# Naive comparison
naive_estimate <- mean(data$outcome[data$treatment == 1]) -
mean(data$outcome[data$treatment == 0])
naive_estimate[1] 5.212386
The naive estimate is wrong! It’s confounded by age
A confounder is a variable that:
Confounders create spurious associations
The correlation between X and Y is not purely causal, some of it is due to C
Solution: We need to “control for” or “adjust for” the confounder
Real problems often have multiple confounders
We need to adjust for all of them to get the causal effect
A collider is a variable that is caused by two other variables
Key point: Arrows point into the collider
Do NOT control for colliders!
Controlling for a collider creates a spurious association between its causes
This is called collider bias or selection bias
Among people who were hired (conditioning on the collider):
This is collider bias in action!
Rule of thumb:
A path is any route from X to Y following the arrows (in any direction)
Path 1: X → Y (direct causal path)
Path 2: X ← C → Y (path through confounder)
Open path: Association can flow through it
Closed path: Association is blocked
A path is closed if:
A backdoor path is a path from X to Y that starts with an arrow into X
Goal of causal inference: Isolate the causal effect of X on Y
Problem: Open backdoor paths allow non-causal association to flow
Solution: Close all backdoor paths by conditioning on the right variables
An adjustment set is a set of variables that, if we condition on them (control for them), will:
In other words: Variables that give us the causal effect when we include them in a regression
For this simple DAG, we need to adjust for C
library(dagitty)
# Simple confounder DAG
simple_dag <- dagify(
X ~ C,
Y ~ C + X,
exposure = "X",
outcome = "Y"
)
# Find adjustment sets
adjustmentSets(simple_dag, exposure = "X", outcome = "Y"){ C }
Result: We need to adjust for C
A set of variables satisfies the backdoor criterion if:
Practical meaning: These are valid adjustment sets
Don’t control for mediators! They’re part of the causal effect we want to measure
# More complex DAG
complex_dag <- dagify(
X ~ C1 + C2,
Y ~ C1 + C2 + C3 + X,
C3 ~ C2,
exposure = "X",
outcome = "Y"
)
adjustmentSets(complex_dag, exposure = "X", outcome = "Y", type = "all"){ C1, C2 }
{ C1, C2, C3 }
Both work! Usually we prefer smaller sets (fewer variables to measure)
{ C1, C2 }
Minimal set: The smallest set that closes all backdoor paths
Once we identify an adjustment set Z, we can estimate the ATE:
\[E[Y(1) - Y(0)] =\\ E_Z[E[Y|X=1, Z] - E[Y|X=0, Z]]\]
Goal: Show that adjusting for confounders gives us the ATE
\[E[Y(1) - Y(0)] = E_Z[E[Y|X=1, Z] - E[Y|X=0, Z]]\]
In words: The average treatment effect equals the average difference in outcomes when we condition on the confounders
1. Conditional Exchangeability (No unmeasured confounding)
\[Y(1), Y(0) \perp\!\!\!\perp X \mid Z\]
Given Z, exposure assignment is “as if random”
2. Positivity
\[0 < P(X=1|Z) < 1 \text{ for all } Z\]
Everyone has some chance of being exposed and unexposed
3. Consistency
\[Y = Y(1) \cdot X + Y(0) \cdot (1-X)\]
The outcome we observe equals the potential outcome under the exposure we received
Start with the definition of ATE:
\[E[Y(1) - Y(0)] = E[Y(1)] - E[Y(0)]\]
Focus on \(E[Y(1)]\) first (same logic applies to \(E[Y(0)]\))
Use the law of iterated expectations:
\[E[Y(1)] = E_Z[E[Y(1)|Z]]\]
Now use conditional exchangeability:
\[E[Y(1)|Z] = E[Y(1)|X=1, Z]\]
Why? Because \(Y(1) \perp\!\!\!\perp X \mid Z\)
Given Z, the potential outcome \(Y(1)\) is independent of whether you actually were exposed
Intuition: Among people with the same Z, those who were exposed are no different (in terms of potential outcomes) from those who weren’t
Use consistency:
\[E[Y(1)|X=1, Z] = E[Y|X=1, Z]\]
Why? For people who actually were exposed (\(X=1\)), their observed outcome \(Y\) equals their potential outcome under exposure \(Y(1)\)
This is the crucial step: We’ve gone from unobservable potential outcomes to observable actual outcomes!
Combining Steps 2-4:
\[E[Y(1)] = E_Z[E[Y(1)|Z]]\] \[= E_Z[E[Y(1)|X=1, Z]]\] \[= E_Z[E[Y|X=1, Z]]\]
By the same logic:
\[E[Y(0)] = E_Z[E[Y|X=0, Z]]\]
Therefore:
\[E[Y(1) - Y(0)] = E[Y(1)] - E[Y(0)]\] \[= E_Z[E[Y|X=1, Z]] - E_Z[E[Y|X=0, Z]]\] \[= E_Z[E[Y|X=1, Z] - E[Y|X=0, Z]]\]
How do we actually compute \(E[Y|X, Z]\)?
If Z is continuous or multidimensional, we can’t just stratify and take group means
Parametric modeling: Assume \(E[Y|X, Z] = \beta_0 + \beta_1 X + \boldsymbol\beta^T \mathbf{Z}\) - Fit with linear regression - Use model to predict under \(X=1\) and \(X=0\)
Matching: Pair treated and control units with similar Z values
Weighting: Reweight the sample to balance Z across treatment groups
If we assume a linear model: \[Y = \beta_0 + \beta_1 X + \boldsymbol\beta^T \mathbf{Z} + \varepsilon\]
Under (strong!) conditions: \[\hat{\beta}_1 = \text{Average Treatment Effect}\]
For \(\hat{\beta}_1\) to equal the ATE, we need:
Wrong! This is biased because C affects both X and Y
Correct! This recovers the true ATE of 2 (plus sampling error)
Remember the Frisch-Waugh-Lovell theorem?
When we regress Y ~ X + C, the coefficient on X is:
The relationship between the part of Y that C can’t explain and the part of X that C can’t explain
This is exactly what we want! We’ve removed C’s confounding influence
G-computation is a method to estimate causal effects by:
Recall the adjustment formula: \[E[Y(1)] = E_Z[E[Y|X=1, Z]]\]
G-computation procedure:
For simple linear models with no interactions: The regression coefficient equals the ATE
G-computation becomes useful when:
# Step 3: Predict outcomes under both scenarios
Y1_pred <- predict(outcome_model, newdata = data_treated)
Y0_pred <- predict(outcome_model, newdata = data_control)
# Step 4: Average predictions
E_Y1 <- mean(Y1_pred)
E_Y0 <- mean(Y0_pred)
# Step 5: Compute ATE
ATE_gcomp <- E_Y1 - E_Y0
ATE_gcomp[1] 2.025261
Same as the regression coefficient! (for linear models with no interactions)
Now the treatment effect is not constant:
(Intercept) X C X:C
-0.09771085 2.14169352 3.04054859 1.44840785
Question: What’s the average treatment effect?
Problem: The coefficient on X (2.14) is the effect when C = 0, not the average!
# Create counterfactual datasets
data_all_int <- data.frame(X = X, C = C, Y = Y_interact)
data_treated_int <- data_all_int
data_treated_int$X <- 1
data_control_int <- data_all_int
data_control_int$X <- 0
# Predict and average
Y1_pred_int <- predict(interact_model, newdata = data_treated_int)
Y0_pred_int <- predict(interact_model, newdata = data_control_int)
ATE_gcomp_int <- mean(Y1_pred_int) - mean(Y0_pred_int)
ATE_gcomp_int[1] 3.57323
G-computation gives us the average effect across the distribution of C!
The average treatment effect is: \(\text{ATE} = E[2 + 1.5 \times C]\)
Since \(E[C] \approx 1\), the ATE \(\approx 3.5\)
G-computation automatically:
Regression coefficient: Only gives effect at C = 0
G-computation averages the blue line using the distribution on the right
For any outcome model:
Step 1: Draw the DAG
Step 2: Identify adjustment set
Step 3: Collect data
Step 4: Fit the model
Step 5: Estimate the ATE
1. No unmeasured confounding
2. Positivity
3. Correct model specification
❌ Controlling for colliders
Creates bias where there was none!
❌ Not controlling for confounders
Leads to biased estimates
❌ Controlling for mediators
Blocks the causal effect you want to measure
❌ Interpreting regression coefficients as causal without thinking about the DAG
Correlation ≠ causation!
✓ Start with the DAG
Think causally before looking at data
✓ Use domain knowledge
Statistics can’t tell you what causes what
✓ Be transparent about assumptions
No unmeasured confounding is a strong assumption
✓ Consider sensitivity analyses
What if there’s an unmeasured confounder?
What if we missed a confounder?
Our estimate will be biased if U affects both X and Y
What we estimate when U is unmeasured:
\[\hat{\beta}_X = \beta_X + \beta_U \times \delta_{UX}\]
set.seed(1)
n <- 1000
# Unmeasured confounder: genetic fitness
genetic_fitness <- rnorm(n)
# Measured confounder: age
age <- rnorm(n, mean = 50, sd = 10)
# Exercise depends on both age and genetics
exercise_hours <- 5 + (-0.08 * age) + 0.6 * genetic_fitness + rnorm(n, sd = 1)
exercise_hours <- pmax(exercise_hours, 0) # Can't be negative
# Heart health depends on exercise AND genetics
# True effect of exercise = -2 (negative = better)
heart_disease_risk <- 60 + (-2 * exercise_hours) +
0.3 * age + (-3 * genetic_fitness) + rnorm(n, sd = 3)exercise_hours
-4.503693
Biased toward zero (or even positive): People with good genetics exercise more AND have lower risk
# Control for age only
partial_control <- lm(heart_disease_risk ~ exercise_hours + age)
coef(partial_control)["exercise_hours"]exercise_hours
-3.518357
Still biased! We adjusted for age, but genetic fitness remains unmeasured
# Control for both (hypothetical - we can't measure genetics)
full_control <- lm(heart_disease_risk ~ exercise_hours + age + genetic_fitness)
coef(full_control)["exercise_hours"]exercise_hours
-2.008628
Unbiased: Recovers the true effect of -2 (plus sampling error)
# Our estimate from partial control
beta_x_biased <- coef(partial_control)["exercise_hours"]
# Regress outcome on unmeasured U (controlling for measured)
beta_u <- coef(lm(heart_disease_risk ~ genetic_fitness + age))["genetic_fitness"]
# Regress treatment on unmeasured U (controlling for measured)
delta_ux <- coef(lm(exercise_hours ~ genetic_fitness + age))["genetic_fitness"]
# Predicted bias
predicted_bias <- beta_u * delta_ux
predicted_biasgenetic_fitness
-1.884238
# Actual bias
true_effect <- -2
actual_bias <- beta_x_biased - true_effect
data.frame(
actual_bias = actual_bias,
predicted_bias = predicted_bias
) actual_bias predicted_bias
exercise_hours -1.518357 -1.884238
They match! The OVB formula works