Draw your assumptions before drawing your conclusions 🔀

Causal thinking with DAGs –plus an interactive gallery of DiD, RDD, IV, matching, fixed effects and synthetic control

statistics
causality
DAG
ggdag
simulation
natural experiments
Correlation is not causation –but why, exactly, and what would be? Learn the three atoms of causal structure (fork, chain, collider), simulate each one to see when adjusting helps and when it manufactures bias, then step into an interactive gallery of six research designs –difference-in-differences, regression discontinuity, instrumental variables, matching, fixed effects and synthetic control– where a slider breaks each identifying assumption and you watch the estimate fall apart in real time.
Author

Nelson Amaya

Published

July 4, 2026

Modified

July 18, 2026

“No causes in; no causes out.”
–Nancy Cartwright

Level: Advanced  ·  Time: ~90 min  ·  Prerequisites: All models are wrong  ·  Tools: ggdag, dplyr, interactive OJS explainers (no installation needed)

You will learn to

  • Draw a causal diagram (DAG) and identify its three basic atoms: fork, chain, collider.
  • Simulate a confounder and see adjusting for it fix a biased estimate.
  • Simulate a collider and see adjusting for it create bias that wasn’t there.
  • Recognise post-treatment bias: adjusting for something measured after the treatment.
  • Explore six natural-experiment research designs (DiD, RDD, IV, matching, fixed effects, synthetic control) and see each one’s identifying assumption break in an interactive gallery.

PART I: The question regression can’t answer alone

The previous session ended on a warning: a regression coefficient describes an association. But the questions worth money and lives are about intervention: what happens to sales if we cut the price? To health if we take the drug? Association answers “what do I see?”; causation answers “what if I act?” –and no formula converts one into the other1.

What does convert one into the other is an ingredient statistics cannot supply: your assumptions about how the world works. This session’s title is Miguel Hernán’s motto2, and it is the whole method in one sentence. The modern discipline is to draw those assumptions as a graph –a DAG (directed acyclic graph): variables as nodes, arrows meaning “causes”, no cycles allowed. Once drawn, the graph tells you –mechanically– which variables you must adjust for and, just as important, which ones you must leave alone.

Every causal structure, however monstrous, is built from three atoms. Let’s draw them with ggdag and then –this track’s signature move– simulate each one to see what it does to a regression.

Show the code
library(tidyverse)
library(ggdag)

fork     <- ggdag::dagify(x ~ z, y ~ z, coords = ggdag::time_ordered_coords())
chain    <- ggdag::dagify(m ~ x, y ~ m, coords = ggdag::time_ordered_coords())
collider <- ggdag::dagify(c ~ x, c ~ y, coords = ggdag::time_ordered_coords())

list(Fork = fork, Chain = chain, Collider = collider) |>
  purrr::imap(\(dag, name) {
    ggdag::ggdag(dag, node_size = 14, text_size = 4) +
      ggdag::theme_dag() +
      labs(title = name)
    }) |>
  patchwork::wrap_plots(nrow = 1)
1
patchwork glues the three plots side by side. Fork: z causes both x and y. Chain: x causes y through m. Collider: x and y both cause c –the arrows collide.

PART II: The fork –confounding, the classic villain

Ice cream sales correlate with drowning deaths. The fork explains it: summer (z) causes both. In a fork, x and y correlate without any arrow between them –and the fix is to adjust for the confounder. Here is the atom wearing its story:

Now watch it in twelve lines:

Show the code
set.seed(44)

fork_world <- tibble(
  summer    = rbinom(2000, 1, 0.5),
  ice_cream = 10 + 5 * summer + rnorm(2000),
  drownings =  2 + 3 * summer + rnorm(2000)
  )

lm(drownings ~ ice_cream, data = fork_world) |>
  broom::tidy() |> dplyr::filter(term == "ice_cream")

lm(drownings ~ ice_cream + summer, data = fork_world) |>
  broom::tidy() |> dplyr::filter(term == "ice_cream")
1
A coin flip: is it summer?
2
Ice cream depends on summer –note it does not depend on drownings.
3
Drownings depend on summer –and not on ice cream. We built this world; we know the true effect of ice cream on drowning is exactly zero.
4
The naive regression finds a strong, “significant” effect. It is pure confounding.
5
Adjust for the fork and the coefficient collapses to ~0 –the truth we wired in. Adjustment worked because the graph said it would.
# A tibble: 1 × 5
  term      estimate std.error statistic p.value
  <chr>        <dbl>     <dbl>     <dbl>   <dbl>
1 ice_cream    0.517   0.00947      54.6       0
# A tibble: 1 × 5
  term      estimate std.error statistic p.value
  <chr>        <dbl>     <dbl>     <dbl>   <dbl>
1 ice_cream  0.00289    0.0223     0.130   0.897

This is the trap behind most “X linked to Y” headlines: wine drinkers live longer (income is the fork), private schools outperform (parental resources), coffee “causes” whatever it causes this week. The knee-jerk fix –“control for everything!”– seems to follow. It doesn’t. Meet the atom that punishes it.

PART III: The collider –where adjusting creates bias

In a collider, x and y are truly independent, but both cause c. Leave c alone and all is well. Adjust for it –or select your sample on it– and you manufacture a correlation out of nothing.

The classic example: are good-looking actors less talented? Suppose looks and talent are utterly unrelated, but either gets you into Hollywood:

Show the code
set.seed(44)

hollywood <- tibble(
  looks  = rnorm(5000),
  talent = rnorm(5000),
  famous = (looks + talent + rnorm(5000, sd = 0.5)) > 1.5
  )

lm(talent ~ looks, data = hollywood) |>
  broom::tidy() |> dplyr::filter(term == "looks")

lm(talent ~ looks, data = dplyr::filter(hollywood, famous)) |>
  broom::tidy() |> dplyr::filter(term == "looks")
1
Independent by construction: the correlation between looks and talent is zero in this world.
2
Fame is the collider: you get in on looks, talent, or luck.
3
In the full population: no relationship, correctly.
4
Among the famous only: a strong negative effect appears from thin air. Among people who cleared the bar, being gorgeous means you needed less talent to get in –selection did the distorting, no villain required.
# A tibble: 1 × 5
  term  estimate std.error statistic p.value
  <chr>    <dbl>     <dbl>     <dbl>   <dbl>
1 looks  -0.0162    0.0141     -1.15   0.251
# A tibble: 1 × 5
  term  estimate std.error statistic  p.value
  <chr>    <dbl>     <dbl>     <dbl>    <dbl>
1 looks   -0.592    0.0303     -19.6 3.14e-69
Show the code
hollywood |>
  ggplot(aes(looks, talent, color = famous)) +
  geom_point(alpha = 0.3, size = 1) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1) +
  scale_color_manual(values = c("grey70", "#F75431")) +
  labs(
    title = "Collider bias, a.k.a. Berkson's paradox",
    subtitle = "No relationship in the population (grey). A strong negative one among the selected (orange).",
    color = "Famous"
    ) +
  theme_minimal()

This is not a curiosity –it is everywhere your data was filtered before you got it: hospital patients (admission is a collider), survey respondents (responding is), hired employees, published papers, surviving companies. If your dataset exists because its rows cleared a bar, Berkson is already in it.

NoteYou’ve seen these before, animated

The interactive explainers in session 4 of the workshop let you drag the strength of a collider and an omitted variable and watch the bias move. This session is the theory those toys were built on. The chain (x → m → y), the third atom, gets an exercise below: adjusting for a mediator blocks the very effect you’re trying to measure –a third distinct way “controlling for more” backfires.

The moral of the two experiments, and arguably of the whole causal revolution: whether to adjust for a variable is not a statistical question. The same + z in your formula is the cure in a fork and the poison in a collider –and only the DAG, i.e. your drawn assumptions, can tell you which world you’re in3.

PART IV: Earning the arrow

So how do you ever get to say “causes”? By design, in descending order of purity:

  • Randomization. Flip the coin yourself. Random assignment cuts every incoming arrow to the treatment –no forks left, nothing to adjust. It’s the reason A/B tests and clinical trials are the gold standard, and why sample() is secretly the most powerful causal tool in R.
  • Natural experiments and panel designs. When you can’t randomize, hunt for situations where the world almost did: policies applied to some regions and not others (difference-in-differences), arbitrary thresholds like exam cutoffs (regression discontinuity), lottery-like exposures (instrumental variables), the same units observed over and over (fixed effects), a lone treated unit rebuilt from a weighted blend of untreated ones (synthetic control), and honest adjustment when treatment goes to observably different units (matching). Each is a chapter of Causal Inference: The Mixtape and The Effect –both free, both excellent, both R-based. And every one of them gets its own interactive machine in Part V, right below.
  • Adjustment with a defended DAG. The weakest but most common: observational data plus a graph you are willing to draw in public. The graph doesn’t make it true –it makes it criticizable, which is what science runs on.

Randomization deserves its own picture, because it is the only design that removes arrows instead of arguing about them –the coin flip owns the treatment, so nothing else can:

How to ruin a perfect experiment: post-treatment bias

Randomization guards the front door –but there is a back door you can open yourself, after the coin flip did its job. A post-treatment variable is anything measured after treatment that treatment itself can affect. Adjust for one, and you can bias a flawless RCT4.

Say a job-training program is randomly assigned and raises earnings by exactly 2 (thousand, per year –we wire it in). Trainees can also earn a certificate, which depends on the training and on unobserved motivation. A well-meaning analyst reasons: “let’s compare people with the same qualifications” and controls for the certificate. Look at the graph before the code –the certificate is our collider atom, grown downstream of the treatment:

Show the code
set.seed(44)

rct <- tibble(
  training    = rbinom(5000, 1, 0.5),
  motivation  = rnorm(5000),
  certificate = as.integer(0.8 * training + motivation
                           + rnorm(5000, sd = 0.5) > 0.5),
  earnings    = 30 + 2 * training + 3 * motivation + rnorm(5000, sd = 2)
  )

lm(earnings ~ training, data = rct) |>
  broom::tidy() |> dplyr::filter(term == "training")

lm(earnings ~ training + certificate, data = rct) |>
  broom::tidy() |> dplyr::filter(term == "training")
1
A genuine experiment: the coin flip owns the treatment.
2
Motivation is unobserved –the analyst never sees this column.
3
The post-treatment variable: getting certified takes training or motivation. D → C ← U –the collider atom, wearing a lanyard.
4
Earnings: exactly +2 for training, +3 per unit of motivation. The certificate itself pays nothing.
5
The clean comparison: randomization works, and the estimate lands on the truth, ~2.
6
“Controlling for qualifications” cuts the estimate to a third of the truth –in a randomized experiment, with an honest analyst, using the most routine line of R imaginable.
# A tibble: 1 × 5
  term     estimate std.error statistic  p.value
  <chr>       <dbl>     <dbl>     <dbl>    <dbl>
1 training     1.99     0.103      19.2 1.75e-79
# A tibble: 1 × 5
  term     estimate std.error statistic  p.value
  <chr>       <dbl>     <dbl>     <dbl>    <dbl>
1 training    0.698    0.0876      7.96 2.05e-15

Where did the effect go? Conditioning on the certificate opened the collider: among certificate-holders, the untrained must be unusually motivated (they got certified without the program’s help). Check:

Show the code
rct |>
  dplyr::filter(certificate == 1) |>
  dplyr::summarise(mean_motivation = mean(motivation), .by = training)
1
Among the certified, the control group is the more motivated one –so “comparing like with like” actually compares trained-and-ordinary against untrained-and-driven, and hands part of the training effect to motivation. The same logic biases the certificate-free stratum in the same direction.
# A tibble: 2 × 2
  training mean_motivation
     <int>           <dbl>
1        1           0.583
2        0           0.993

You have seen both halves of this before: the certificate is partly a mediator (blocking a path the effect travels through) and partly a collider with the unseen (opening a path that was shut). Either half alone is enough; real post-treatment variables are usually both. And it is everywhere: the gender wage gap “controlling for occupation” (occupation is post-treatment to discrimination), drug trials that condition on a side effect or analyse “only those who complied”, school-effect studies that control for end-of-year test scores. The defense is a question so simple it fits in a code review: when was this variable measured? If the answer is “after treatment”, it does not go on the right-hand side –randomization bought you a clean estimate of the total effect, and adjusting downstream sells it back.

What you may never do is run lm(y ~ x + everything) and read causality off the stars. Now you know the three reasons why –the fork you must adjust for, the collider you must not, and the post-treatment trap that smuggles a collider into your own experiment– and you can simulate all of them from scratch.

Key takeaways

  • Every causal structure is built from three atoms: fork (confounder), chain (mediator), collider.
  • Adjusting for a confounder removes bias; adjusting for a collider creates it – the same action, opposite effect, depending on the atom.
  • Post-treatment variables (measured after the treatment) are dangerous to adjust for, whichever atom they turn out to be.
  • Every natural-experiment design (DiD, RDD, IV, matching, fixed effects, synthetic control) rests on one identifying assumption – know what would have to be true for the estimate to be believed.
Back to top

Footnotes

  1. This is Judea Pearl’s “ladder of causation”, from The Book of Why –the best non-technical entry to this whole subject.↩︎

  2. It is the subtitle of Hernán’s free HarvardX course Causal Diagrams: Draw Your Assumptions Before Your Conclusions, and the working philosophy of the book he co-wrote with James Robins, Causal Inference: What If –free, rigorous, and the standard reference once you outgrow this session.↩︎

  3. ggdag::ggdag_adjustment_set(dag, exposure = "x", outcome = "y") automates the deduction: give it your graph and it returns the set(s) of variables to adjust for. The graph is your responsibility; the graph-reading is mechanical.↩︎

  4. The definitive paper carries the moral in its title: Montgomery, Nyhan & Torres (2018), “How Conditioning on Posttreatment Variables Can Ruin Your Experiment and What to Do About It”. Their audit found the practice in roughly half of the experimental papers in top political-science journals.↩︎

  5. This gallery is a love letter to two masters of the genre: Nick Huntington-Klein’s animated causal graphs, which showed how each design moves, and Kristoffer Magnusson’s interactive visualizations, which set the bar for how one should look. The interactives are written in Observable JS inside this very .qmd –the same {ojs} engine as the toggles in session 4 of the workshop, no server required.↩︎

  6. The visual is a direct homage to Nick Huntington-Klein’s matching animation; the theory is the Mixtape’s matching and subclassification chapter. In real work, don’t hand-roll nearest neighbors like this machine does –use MatchIt, which also handles propensity scores, calipers and diagnostics.↩︎

  7. The Mixtape’s panel data chapter covers the theory; in R the modern tool is fixestfeols(y ~ x | unit) and you’re done. The demeaning toggle below is Nick Huntington-Klein’s fixed-effects animation rebuilt as a switch.↩︎

  8. Abadie, Diamond & Hainmueller’s California study is the canonical application; the Mixtape’s synthetic control chapter walks it end to end. In R: tidysynth (tidy interface) or the original Synth. The machine below solves for the weights live –projected gradient descent on the simplex, right in your browser– every time you move a slider.↩︎

Citation

BibTeX citation:
@online{amaya2026,
  author = {Amaya, Nelson},
  title = {Draw Your Assumptions Before Drawing Your Conclusions 🔀},
  date = {2026-07-04},
  url = {https://r4dev.netlify.app/sessions_thinking/04-causal/04-causal},
  langid = {en}
}
For attribution, please cite this work as:
Amaya, Nelson. 2026. “Draw Your Assumptions Before Drawing Your Conclusions 🔀.” July 4. https://r4dev.netlify.app/sessions_thinking/04-causal/04-causal.