# install.packages(c("tidyverse", "dagitty", "ggdag"))
library(tidyverse)
library(dagitty)
library(ggdag)
A DAG (directed acyclic graph) is a picture of our causal assumptions. Each arrow says “this variable directly influences that one.”
To check that the DAG does what we claim, we do this each time:
rnorm(), and its mean depends on the variables pointing
into it through coefficients we choose (the “true parameters”).Because we picked the true coefficients, we know the right answer, so we can see exactly when a model is biased.
set.seed(2026)
n <- 2000 # number of simulated observations
Z causes both X and Y. X does not cause Y, so the true effect of X on Y is 0.
fork_dag <- dagify(
X ~ Z,
Y ~ Z,
coords = list(x = c(X = 0, Z = 1, Y = 2),
y = c(X = 0, Z = 1, Y = 0))
)
ggdag(fork_dag, text_size = 5) + theme_dag()
Simulate (true parameters: Z -> X is 1.5, Z -> Y is 2, X -> Y is 0):
fork_data <- tibble(
Z = rnorm(n, mean = 0, sd = 1),
X = rnorm(n, mean = 1.5 * Z, sd = 1),
Y = rnorm(n, mean = 2 * Z, sd = 1)
)
# Not conditioning on Z: a spurious association appears
fork_no_z <- lm(Y ~ X, data = fork_data)
# Conditioning on Z: the association should vanish
fork_with_z <- lm(Y ~ X + Z, data = fork_data)
coef(summary(fork_no_z))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## 0.89335482 0.01967857
coef(summary(fork_with_z))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## -0.04744988 0.02277931
Without Z, X looks like it affects Y (the estimate is far from 0), even though the true effect is 0. With Z in the model, the estimate is about 0.
ggplot(fork_data, aes(x = X, y = Y)) +
geom_point(alpha = 0.3) +
geom_smooth(method = "lm", se = FALSE) +
labs(title = "Fork: X and Y are associated (through Z)")
D-separation check. dagitty can tell us this without any simulation:
dseparated(fork_dag, "X", "Y") # FALSE: path is open
## [1] FALSE
dseparated(fork_dag, "X", "Y", "Z") # TRUE: conditioning on Z closes it
## [1] TRUE
impliedConditionalIndependencies(fork_dag) # X _||_ Y | Z
## X _||_ Y | Z
ggdag_dseparated(fork_dag, from = "X", to = "Y", controlling_for = "Z", text_size = 5) +
theme_dag()
X causes Z, and Z causes Y. Z carries the effect of X to Y.
pipe_dag <- dagify(
Z ~ X,
Y ~ Z,
coords = list(x = c(X = 0, Z = 1, Y = 2),
y = c(X = 0, Z = 0, Y = 0))
)
ggdag(pipe_dag, text_size = 5) + theme_dag()
Simulate (X -> Z is 1.5, Z -> Y is 2, so the total effect of X on Y is 1.5 x 2 = 3):
pipe_data <- tibble(
X = rnorm(n, mean = 0, sd = 1),
Z = rnorm(n, mean = 1.5 * X, sd = 1),
Y = rnorm(n, mean = 2 * Z, sd = 1)
)
# Not conditioning on Z: we recover the TOTAL effect (about 3)
pipe_no_z <- lm(Y ~ X, data = pipe_data)
# Conditioning on Z: we block the path, effect goes to about 0
pipe_with_z <- lm(Y ~ X + Z, data = pipe_data)
coef(summary(pipe_no_z))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## 3.07211226 0.04952265
coef(summary(pipe_with_z))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## 0.06037674 0.04093205
Conditioning on a mediator removes the association, but this is not always what we want. If we care about the total effect of X on Y, adding Z is a mistake (“overcontrol bias” or “post-treatment bias”).
dseparated(pipe_dag, "X", "Y")
## [1] FALSE
dseparated(pipe_dag, "X", "Y", "Z")
## [1] TRUE
Both X and Y cause Z. X and Y are independent of each other. Here the path is closed by default, and conditioning on Z opens it.
collider_dag <- dagify(
Z ~ X + Y,
coords = list(x = c(X = 0, Z = 1, Y = 2),
y = c(X = 1, Z = 0, Y = 1))
)
ggdag(collider_dag, text_size = 5) + theme_dag()
Simulate (X and Y are unrelated; Z = 1.5 X + 2 Y + noise):
collider_data <- tibble(
X = rnorm(n, mean = 0, sd = 1),
Y = rnorm(n, mean = 0, sd = 1),
Z = rnorm(n, mean = 1.5 * X + 2 * Y, sd = 1)
)
# Not conditioning on Z: no association (correct!)
collider_no_z <- lm(Y ~ X, data = collider_data)
# Conditioning on Z: a FAKE association appears (collider bias)
collider_with_z <- lm(Y ~ X + Z, data = collider_data)
coef(summary(collider_no_z))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## 0.009100393 0.022700026
coef(summary(collider_with_z))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## -0.58903175 0.01197979
The estimate with Z in the model is negative even though the true effect is 0. A common intuition: if Z is high and X is low, Y must be high to make up for it.
# Visualize: slice Z into groups. Within each group, X and Y become negatively related.
collider_data %>%
mutate(Z_group = cut(Z, breaks = quantile(Z, c(0, 1/3, 2/3, 1)),
labels = c("Low Z", "Mid Z", "High Z"),
include.lowest = TRUE)) %>%
ggplot(aes(x = X, y = Y, color = Z_group)) +
geom_point(alpha = 0.3) +
geom_smooth(method = "lm", se = FALSE) +
labs(title = "Collider: conditioning on Z creates an X-Y association")
dseparated(collider_dag, "X", "Y") # TRUE: closed
## [1] TRUE
dseparated(collider_dag, "X", "Y", "Z") # FALSE: conditioning opened it
## [1] FALSE
If D is caused by the collider Z, then conditioning on D partly conditions on Z, and opens the path too.
descendant_dag <- dagify(
Z ~ X + Y,
D ~ Z,
coords = list(x = c(X = 0, Z = 1, Y = 2, D = 1),
y = c(X = 1, Z = 0, Y = 1, D = -1))
)
ggdag(descendant_dag, text_size = 5) + theme_dag()
descendant_data <- tibble(
X = rnorm(n, mean = 0, sd = 1),
Y = rnorm(n, mean = 0, sd = 1),
Z = rnorm(n, mean = 1.5 * X + 2 * Y, sd = 1),
D = rnorm(n, mean = 1 * Z, sd = 1)
)
descendant_no_d <- lm(Y ~ X, data = descendant_data)
descendant_with_d <- lm(Y ~ X + D, data = descendant_data)
coef(summary(descendant_no_d))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## 0.01928239 0.02241204
coef(summary(descendant_with_d))["X", c("Estimate", "Std. Error")]
## Estimate Std. Error
## -0.48247287 0.01533924
dseparated(descendant_dag, "X", "Y")
## [1] TRUE
dseparated(descendant_dag, "X", "Y", "D")
## [1] FALSE
Now let’s use a DAG with a true effect of X on Y and a confounder Z.
bias_dag <- dagify(
X ~ Z,
Y ~ X + Z,
exposure = "X",
outcome = "Y",
coords = list(x = c(X = 0, Z = 1, Y = 2),
y = c(X = 0, Z = 1, Y = 0))
)
ggdag(bias_dag, text_size = 5) + theme_dag()
dagitty can tell us what to adjust for to estimate the effect of X on Y:
adjustmentSets(bias_dag) # { Z }
## { Z }
ggdag_adjustment_set(bias_dag, text_size = 4) + theme_dag()
bias_data <- tibble(
Z = rnorm(n, mean = 0, sd = 1),
X = rnorm(n, mean = 0.8 * Z, sd = 1),
Y = rnorm(n, mean = 0.5 * X + 1.2 * Z, sd = 1)
)
bias_naive <- lm(Y ~ X, data = bias_data) # biased: confounded by Z
bias_adjusted <- lm(Y ~ X + Z, data = bias_data) # adjusted: should be about 0.5
results <- tibble(
model = c("Naive: Y ~ X", "Adjusted: Y ~ X + Z", "Truth"),
estimate = c(coef(bias_naive)["X"], coef(bias_adjusted)["X"], 0.5)
)
results
ggplot(results, aes(x = model, y = estimate, fill = model)) +
geom_col(show.legend = FALSE) +
geom_hline(yintercept = 0.5, linetype = "dashed") +
labs(title = "Confounding bias: effect of X on Y",
x = NULL, y = "Estimated effect of X")
The naive model overstates the effect of X, because part of the X-Y association is just Z driving both. Conditioning on Z fixes it.
| Structure | DAG | X and Y associated (unconditioned)? | Condition on Z | What to do |
|---|---|---|---|---|
| Fork | X <- Z -> Y | Yes (spurious) | Removes association | Condition on Z (confounder) |
| Pipe | X -> Z -> Y | Yes (causal, via Z) | Removes association | Don’t condition if you want the total effect |
| Collider | X -> Z <- Y | No | Creates an association | Don’t condition on Z |
| Descendant | collider with D below Z | No | Creates a (weaker) association | Don’t condition on D |
Rule of thumb: whether to add a variable to a model depends on where it sits in the DAG, not on whether it improves fit or is “significant.”