Introduction

Ice-cream sales and drownings rise and fall together. Nobody believes ice cream drowns people: hot weather drives both. That is the whole problem of causal inference in one sentence — data shows you associations, but decisions need effects. If I give this patient the drug, change this price, ship this feature, what will happen?

Potential outcomes

The modern answer starts with a deceptively simple idea, due to Jerzy Neyman (1923, for agricultural experiments) and generalized by Donald Rubin (1974) to observational studies. For every unit ii (a patient, a field, a user) imagine two outcomes:

  • Yi(1)Y_i(1) — what would happen if ii received the treatment,
  • Yi(0)Y_i(0) — what would happen if ii did not.

The individual effect is Yi(1)−Yi(0)Y_i(1)-Y_i(0), and the average treatment effect is τ=E[Y(1)−Y(0)]\tau = \mathbb{E}[Y(1)-Y(0)]. The catch, which Paul Holland (1986) named the fundamental problem of causal inference: for each unit you observe only one of the two. The other is a counterfactual — forever missing data.

Why randomization works

Ronald Fisher made randomization the core of experimental design at the Rothamsted agricultural station (The Design of Experiments, 1935). Flip a coin to decide who gets the treatment and XX becomes independent of (Y(1),Y(0))(Y(1), Y(0)). Then the treated group is a fair sample of what everyone would do under treatment, and

E[Y∣X=1]−E[Y∣X=0]=E[Y(1)]−E[Y(0)]=τ.\mathbb{E}[Y\mid X=1]-\mathbb{E}[Y\mid X=0]=\mathbb{E}[Y(1)]-\mathbb{E}[Y(0)]=\tau .

Without the coin, people choose (or are chosen for) treatment for reasons that also affect the outcome: sicker patients get the aggressive therapy, richer users buy the premium plan. Those reasons are confounders, and they poison the simple comparison.

Seeing versus doing

Judea Pearl gave the second great language: structural causal models. Each variable is a function of its direct causes plus independent noise, and the "who causes whom" skeleton is a directed acyclic graph (DAG). In this language an intervention is surgery on the model: do(X=x)do(X=x) deletes the equation for XX (and every arrow into XX) and sets it to xx. Hence the central distinction:

  • P(y∣x)P(y\mid x) — among the units where we saw X=xX=x, how often is Y=yY=y?
  • P(y∣do(x))P(y\mid do(x)) — if we forced X=xX=x on everyone, how often would Y=yY=y?

They coincide in a randomized trial and can differ wildly in observational data. Pearl summarized his program in Causality (2000) and received the 2011 ACM Turing Award for "a calculus for probabilistic and causal reasoning". In The Book of Why (2018, with Dana Mackenzie) he arranged the questions on a ladder of causation: (1) association — seeing, P(y∣x)P(y\mid x); (2) intervention — doing, P(y∣do(x))P(y\mid do(x)); (3) counterfactuals — imagining, "would this patient have recovered had she not been treated?". Each rung needs strictly more causal knowledge than the one below.

Causal graphs are close relatives of Bayesian networks: same DAGs, same factorizations, but the arrows now promise what happens when you intervene, not just how probabilities factor.

Try It: Seeing vs Doing

The simulator below is a real structural causal model with binary variables: a confounder ZZ raises both the chance of treatment XX and the chance of a good outcome YY, and XX has a true effect on YY that you set. It draws 5000 units, each with its own random noise, and computes three numbers:

  • Naive: P(Y=1∣X=1)−P(Y=1∣X=0)P(Y{=}1\mid X{=}1)-P(Y{=}1\mid X{=}0) — what a dashboard would report.
  • Back-door: ∑z[P(Y=1∣X=1,z)−P(Y=1∣X=0,z)] P(z)\sum_z \big[P(Y{=}1\mid X{=}1,z)-P(Y{=}1\mid X{=}0,z)\big]\,P(z) — compare within each level of ZZ, then average.
  • do(X): re-run every unit twice, with XX forced to 1 and to 0 and the same noise. Only a simulator can do this — it sees both potential outcomes.
<!-- Causal-inference lab: a 4-variable structural causal model, 5000 simulated units, three estimators of the effect of X on Y. -->
<p class="hint">Z is a common cause of the treatment X and the outcome Y. Move the sliders, then compare the naive difference P(Y|X=1) − P(Y|X=0), the back-door adjusted estimate and the true effect obtained by intervening, do(X).</p>
<svg id="dag" class="dag" viewBox="0 0 320 186" role="img" aria-label="Causal graph: Z causes X and Y, X causes Y; optional collider W caused by X and Y.">
  <defs>
    <marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerUnits="userSpaceOnUse" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
      <path class="ahead" d="M0,0 L10,5 L0,10 z"></path>
    </marker>
  </defs>
  <g id="edges"></g>
  <g id="nodes"></g>
</svg>
<div class="presets">
  <button type="button" class="ghost preset" data-p="clean">No confounding</button>
  <button type="button" class="ghost preset" data-p="conf">Confounded</button>
  <button type="button" class="ghost preset" data-p="simpson">Simpson reversal</button>
  <button type="button" class="ghost preset" data-p="berkson">Collider bias</button>
</div>
<div class="sliders">
  <label><span>Confounding Z → X</span><input id="sConf" type="range" min="0" max="1" step="0.05" value="0.6"><b id="vConf"></b></label>
  <label><span>Effect Z → Y</span><input id="sZY" type="range" min="0" max="0.5" step="0.05" value="0.4"><b id="vZY"></b></label>
  <label><span>True effect X → Y</span><input id="sXY" type="range" min="-0.25" max="0.25" step="0.05" value="0.1"><b id="vXY"></b></label>
  <label class="chk"><input id="cColl" type="checkbox"> <span>Only look at units with W = 1 (condition on the collider)</span></label>
</div>
<div class="est" id="est"></div>
<div class="legend">Bars: estimated change in P(Y = 1) when X goes from 0 to 1. Dashed line = the true causal effect you set. Naive = difference of observed rates; Back-door = Σz [P(Y|X=1,z) − P(Y|X=0,z)] P(z); do(X) = force X to 1 and to 0 for every unit and compare.</div>
<div class="tblwrap"><table id="tbl"></table></div>
<div class="status" id="status"></div>
<div class="small" id="info"></div>
<div class="btns">
  <button id="btnResample" type="button">New sample</button>
  <button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* Light theme colors; one color per estimator. */
:root { --fg: #222; --muted: #555; --bg: #fff; --card: #f3f6f9; --line: #cdd9e3;
        --accent: #1d3557; --naive: #e63946; --adj: #3a6ea5; --do: #2a9d8f; --chip: #e8eef3; }
/* Dark theme overrides. */
@media (prefers-color-scheme: dark) {
  :root { --fg: #e6e9ee; --muted: #a8b3c0; --bg: #14181d; --card: #1e252d; --line: #34404c;
          --accent: #8fb3e0; --naive: #ff6b76; --adj: #7fb0ec; --do: #4fd1c0; --chip: #26313c; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; }
.hint { font-size: .86rem; color: var(--muted); margin: 0 0 .5rem; line-height: 1.45; }
.dag { width: 100%; max-width: 420px; display: block; margin: 0 auto .3rem; }
.edge { stroke: var(--fg); fill: none; opacity: .85; }
.edge.neg { stroke: var(--naive); }
.edge.off { opacity: .18; stroke-dasharray: 4 4; }
.ahead { fill: var(--fg); }
.elabel { font: 600 10px ui-monospace, monospace; fill: var(--muted); }
.node circle, .node rect { fill: var(--card); stroke: var(--accent); stroke-width: 2; }
.node.off circle { opacity: .3; }
.node .letter { font: 700 15px system-ui, sans-serif; fill: var(--fg); text-anchor: middle; dominant-baseline: central; }
.node .cap { font: 10px system-ui, sans-serif; fill: var(--muted); text-anchor: middle; }
.presets, .btns { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0; }
.sliders { display: grid; gap: .35rem; margin: .5rem 0; }
.sliders label { display: grid; grid-template-columns: minmax(0, 1fr) 7.5rem 3.2rem; align-items: center; gap: .4rem; font-size: .82rem; }
.sliders label.chk { grid-template-columns: auto 1fr; }
.sliders input[type=range] { width: 100%; }
.sliders b { font: 600 12px ui-monospace, monospace; text-align: right; }
.est { display: grid; gap: .3rem; margin: .5rem 0 .2rem; }
.row { display: grid; grid-template-columns: 5.6rem 1fr 3.6rem; align-items: center; gap: .4rem; font-size: .8rem; }
.row b { font: 600 12px ui-monospace, monospace; text-align: right; }
.track { position: relative; height: 16px; background: var(--card); border: 1px solid var(--line); border-radius: 5px; }
.track .zero { position: absolute; top: -2px; bottom: -2px; left: 50%; width: 1px; background: var(--muted); }
.track .truth { position: absolute; top: -4px; bottom: -4px; width: 0; border-left: 2px dashed var(--accent); }
.track .fill { position: absolute; top: 2px; bottom: 2px; border-radius: 3px; transition: all .2s; }
.fill.naive { background: var(--naive); }
.fill.adj { background: var(--adj); }
.fill.do { background: var(--do); }
.legend { font-size: .74rem; color: var(--muted); line-height: 1.4; }
.tblwrap { overflow-x: auto; margin-top: .5rem; }
table { border-collapse: collapse; font: 12px ui-monospace, monospace; width: 100%; min-width: 300px; }
th, td { border: 1px solid var(--line); padding: .25rem .35rem; text-align: right; }
th:first-child, td:first-child { text-align: left; }
th { background: var(--chip); font-weight: 600; }
tr.pooled td { font-weight: 700; }
td.pos { color: var(--do); }
td.neg { color: var(--naive); }
.status { font-size: .9rem; font-weight: 600; margin: .5rem 0 .2rem; min-height: 1.3em; line-height: 1.4; }
.status.ok { color: var(--do); }
.status.bad { color: var(--naive); }
.small { font-size: .76rem; color: var(--muted); line-height: 1.45; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .75rem; border: 1px solid var(--accent);
         background: var(--accent); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const N = 5000;
const BASE = 0.25, PZ = 0.5, RANGE = 0.6;
let seed = 7;

// Seeded pseudo-random generator (mulberry32) so every sample is reproducible.
function rng(s) {
  let a = s >>> 0;
  return function () {
    a = (a + 0x6D2B79F5) >>> 0; let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Structural equations: P(X=1|Z), P(Y=1|X,Z) (linear, so the true effect equals bx exactly) and P(W=1|X,Y).
const pX = (z, p) => 0.5 + (z ? 1 : -1) * 0.45 * p.c;
const pY = (x, z, p) => BASE + p.bx * x + p.bz * z;
const pW = (x, y) => 0.1 + 0.4 * x + 0.4 * y;

// Each unit gets four exogenous uniform noises, one per variable.
function makeUnits(s, n) {
  const r = rng(s), units = [];
  for (let i = 0; i < n; i++) units.push([r(), r(), r(), r()]);
  return units;
}

// Evaluate the structural equations for one unit; passing forceX performs the intervention do(X = forceX).
function realize(u, p, forceX) {
  const z = u[0] < PZ ? 1 : 0;
  const x = forceX === undefined ? (u[1] < pX(z, p) ? 1 : 0) : forceX;
  const y = u[2] < pY(x, z, p) ? 1 : 0;
  const w = u[3] < pW(x, y) ? 1 : 0;
  return { z, x, y, w };
}

// Count Y by stratum of Z and value of X, optionally keeping only W = 1.
function estimate(units, p, selectW) {
  const cell = [[{ n: 0, y: 0 }, { n: 0, y: 0 }], [{ n: 0, y: 0 }, { n: 0, y: 0 }]];
  let ate = 0, nObs = 0;
  for (const u of units) {
    // Both potential outcomes Y(1) and Y(0) of the same unit: only a simulator can see both.
    ate += realize(u, p, 1).y - realize(u, p, 0).y;
    const o = realize(u, p);
    if (selectW && o.w !== 1) continue;
    nObs++; cell[o.z][o.x].n++; cell[o.z][o.x].y += o.y;
  }
  ate /= units.length;
  const m = (c) => (c.n ? c.y / c.n : NaN);
  const pool = (x) => ({ n: cell[0][x].n + cell[1][x].n, y: cell[0][x].y + cell[1][x].y });
  const naive = m(pool(1)) - m(pool(0));
  // Back-door adjustment: average the within-stratum differences weighted by P(z).
  let adj = 0;
  const strata = [0, 1].map((z) => {
    const nz = cell[z][0].n + cell[z][1].n;
    const d = m(cell[z][1]) - m(cell[z][0]);
    adj += d * (nObs ? nz / nObs : NaN);
    return { z, p1: m(cell[z][1]), p0: m(cell[z][0]), n1: cell[z][1].n, n0: cell[z][0].n, d };
  });
  return { naive, adj, ate, truth: p.bx, strata, pooled: { p1: m(pool(1)), p0: m(pool(0)), n1: pool(1).n, n0: pool(0).n, d: naive }, nObs };
}

let units = makeUnits(seed, N);
const $ = (id) => document.getElementById(id);
const f3 = (v) => (Number.isFinite(v) ? (v >= 0 ? '+' : '') + v.toFixed(3) : "n/a");
const p3 = (v) => (Number.isFinite(v) ? v.toFixed(3) : "n/a");
const params = () => ({ c: +$('sConf').value, bz: +$('sZY').value, bx: +$('sXY').value });

// Draw the DAG; arrow width shows the strength of each mechanism.
const POS = { Z: [160, 30], X: [60, 100], Y: [260, 100], W: [160, 142] };
const CAP = { Z: "confounder", X: "treatment", Y: "outcome", W: "collider (selection)" };
const NS = 'http://www.w3.org/2000/svg';
function el(tag, attrs, text) {
  const e = document.createElementNS(NS, tag);
  for (const k in attrs) e.setAttribute(k, attrs[k]);
  if (text !== undefined) e.textContent = text;
  return e;
}
function drawDag(p, coll) {
  const E = $('edges'), V = $('nodes');
  E.innerHTML = ''; V.innerHTML = '';
  const edges = [['Z', 'X', p.c, p.c.toFixed(2)], ['Z', 'Y', p.bz * 2, p.bz.toFixed(2)], ['X', 'Y', Math.abs(p.bx) * 4, f3(p.bx)],
                 ['X', 'W', coll ? 0.8 : 0, ''], ['Y', 'W', coll ? 0.8 : 0, '']];
  for (const [a, b, s, lab] of edges) {
    const [x1, y1] = POS[a], [x2, y2] = POS[b];
    const dx = x2 - x1, dy = y2 - y1, L = Math.hypot(dx, dy), r = 19;
    const off = s < 0.02;
    let cls = 'edge' + (off ? ' off' : '') + (a === 'X' && b === 'Y' && p.bx < 0 ? ' neg' : '');
    E.appendChild(el('line', { x1: x1 + dx / L * r, y1: y1 + dy / L * r, x2: x2 - dx / L * (r + 2), y2: y2 - dy / L * (r + 2),
      class: cls, 'stroke-width': (1.2 + 4 * Math.min(1, s)).toFixed(2), 'marker-end': 'url(#ah)' }));
    if (lab) E.appendChild(el('text', { x: (x1 + x2) / 2 + (a === 'X' && b === 'Y' ? 0 : (a === 'Z' && b === 'X' ? -26 : 26)),
      y: (y1 + y2) / 2 + (a === 'X' && b === 'Y' ? -6 : -4), class: 'elabel', 'text-anchor': 'middle' }, lab));
  }
  for (const k of ['Z', 'X', 'Y', 'W']) {
    const [x, y] = POS[k];
    const g = el('g', { class: 'node' + (k === 'W' && !coll ? ' off' : '') });
    // A conditioned node is drawn as a box.
    if (k === 'W' && coll) g.appendChild(el('rect', { x: x - 17, y: y - 17, width: 34, height: 34, rx: 4 }));
    else g.appendChild(el('circle', { cx: x, cy: y, r: 17 }));
    g.appendChild(el('text', { x, y, class: 'letter' }, k));
    g.appendChild(el('text', { x, y: k === 'Z' ? y - 21 : y + 30, class: 'cap' }, CAP[k]));
    V.appendChild(g);
  }
}

function bar(cls, label, v, truth) {
  const pct = (t) => 50 + 50 * Math.max(-1, Math.min(1, t / RANGE));
  const a = Number.isFinite(v) ? pct(v) : 50;
  const left = Math.min(a, 50), width = Math.abs(a - 50);
  return `<div class="row"><span>${label}</span><div class="track"><div class="zero"></div>` +
    `<div class="fill ${cls}" style="left:${left}%;width:${width}%"></div>` +
    `<div class="truth" style="left:${pct(truth)}%"></div></div><b>${f3(v)}</b></div>`;
}

function render() {
  const p = params(), coll = $('cColl').checked;
  $('vConf').textContent = p.c.toFixed(2); $('vZY').textContent = p.bz.toFixed(2); $('vXY').textContent = f3(p.bx);
  drawDag(p, coll);
  const r = estimate(units, p, coll);
  $('est').innerHTML = bar('naive', "Naive", r.naive, r.truth) + bar('adj', "Back-door", r.adj, r.truth) + bar('do', "do(X)", r.ate, r.truth);
  const td = (v) => `<td class="${v > 0.0005 ? 'pos' : v < -0.0005 ? 'neg' : ''}">${f3(v)}</td>`;
  const rowH = (name, s, cls) => `<tr class="${cls}"><td>${name}</td><td>${p3(s.p1)}</td><td>${p3(s.p0)}</td>${td(s.d)}<td>${s.n1}/${s.n0}</td></tr>`;
  $('tbl').innerHTML = `<tr><th>Group</th><th>P(Y|X=1)</th><th>P(Y|X=0)</th><th>Δ</th><th>n (X=1/X=0)</th></tr>` +
    rowH('Z = 0', r.strata[0], '') + rowH('Z = 1', r.strata[1], '') + rowH("All (pooled)", r.pooled, 'pooled');
  const st = $('status');
  const ds = r.strata.map((s) => s.d);
  const simpson = !coll && ds.every((d) => Math.sign(d) === Math.sign(ds[0]) && Math.abs(d) > 0.02) &&
    Math.sign(r.naive) !== Math.sign(ds[0]) && Math.abs(r.naive) > 0.02;
  if (!Number.isFinite(r.adj)) { st.textContent = "Some stratum has no treated or no untreated units: the back-door formula is undefined (positivity fails)."; st.className = 'status bad'; }
  else if (coll) { st.textContent = i18nFmt("Selection on the collider W: naive %naive%, adjusted %adj%, but the true effect is %ate%. Filtering on a common effect manufactures a correlation.", { naive: f3(r.naive), adj: f3(r.adj), ate: f3(r.ate) }); st.className = 'status bad'; }
  else if (simpson) { st.textContent = i18nFmt("Simpson reversal: the effect is %s0% when Z = 0 and %s1% when Z = 1, yet pooled it is %pooled%. The confounder flips the sign.", { s0: f3(ds[0]), s1: f3(ds[1]), pooled: f3(r.naive) }); st.className = 'status bad'; }
  else if (Math.abs(r.naive - r.ate) > 0.03) { st.textContent = i18nFmt("Confounding: the naive estimate is off by %bias%. Adjusting for Z gives %adj%, close to the truth.", { bias: f3(r.naive - r.ate), adj: f3(r.adj) }); st.className = 'status bad'; }
  else { st.textContent = i18nFmt("No confounding: naive, adjusted and interventional estimates agree (≈ %ate%), just like in a randomized trial.", { ate: f3(r.ate) }); st.className = 'status ok'; }
  $('info').textContent = i18nFmt("%n% simulated units (seed %seed%) · observed sample used by the estimators: %m% · do(X) re-runs every unit with X overridden and the same noise.", { n: N, m: r.nObs, seed });
}

// Presets for the classic situations.
const PRESETS = {
  clean: { c: 0, bz: 0.3, bx: 0.1, coll: false },
  conf: { c: 0.6, bz: 0.4, bx: 0.1, coll: false },
  simpson: { c: 0.9, bz: 0.5, bx: -0.1, coll: false },
  berkson: { c: 0, bz: 0, bx: 0, coll: true }
};
function applyPreset(k) {
  const q = PRESETS[k];
  $('sConf').value = q.c; $('sZY').value = q.bz; $('sXY').value = q.bx; $('cColl').checked = q.coll;
  render();
}
document.querySelectorAll('.preset').forEach((b) => b.addEventListener('click', () => applyPreset(b.dataset.p)));
['sConf', 'sZY', 'sXY', 'cColl'].forEach((id) => $(id).addEventListener('input', render));
$('cColl').addEventListener('change', render);
$('btnResample').addEventListener('click', () => { seed++; units = makeUnits(seed, N); render(); });
$('btnReset').addEventListener('click', () => { seed = 7; units = makeUnits(seed, N); applyPreset('conf'); });
render();
Result
Console

Things to try:

  1. No confounding: all three bars land on the dashed line. This is what randomization buys you.
  2. Confounded: the naive bar overshoots; the back-door bar snaps back to the truth.
  3. Simpson reversal: set the true effect to be negative. Within Z=0Z=0 and within Z=1Z=1 the table shows harm, yet the pooled row shows benefit — the classic Simpson's paradox.
  4. Collider bias: set every effect to zero and keep only units with W=1W=1, where WW is caused by both XX and YY. A strong negative correlation appears out of nothing, and adjusting for ZZ cannot remove it.

The Real Complexity

Causal inference has two separate difficulties: identification (could infinite data answer the question at all?) and estimation (how well can finite data answer it?).

Identification with a graph

The back-door criterion. A set ZZ is admissible for the effect of XX on YY if (i) no node of ZZ is a descendant of XX and (ii) ZZ blocks every path from XX to YY that starts with an arrow into XX. Then

P(y∣do(x))=∑zP(y∣x,z) P(z).P(y\mid do(x))=\sum_z P(y\mid x,z)\,P(z).

Note the weights: P(z)P(z), not P(z∣x)P(z\mid x). Using P(z∣x)P(z\mid x) would give back the naive P(y∣x)P(y\mid x) — the whole bias lives in that difference.

The front-door criterion (Pearl, 1995) handles a confounder you cannot measure, provided a mediator MM carries all of the effect (smoking → tar → cancer, with a hidden genotype affecting smoking and cancer):

P(y∣do(x))=∑mP(m∣x)∑x′P(y∣m,x′) P(x′).P(y\mid do(x))=\sum_m P(m\mid x)\sum_{x'} P(y\mid m,x')\,P(x').

d-separation (Pearl, 1988, with Verma and Geiger) is the graphical rule behind both: a path is blocked by a set SS if it contains a chain A→B→CA\to B\to C or a fork A←B→CA\leftarrow B\to C with B∈SB\in S, or a collider A→B←CA\to B\leftarrow C where neither BB nor any descendant of BB is in SS. Colliders are the trap: conditioning on a common effect opens a path. That is Berkson's paradox (1946): among hospital patients, two independent diseases look negatively correlated because either one gets you admitted. Checking d-separation is a reachability search, linear in the size of the graph (O(∣V∣+∣E∣)O(|V|+|E|), e.g. Shachter's Bayes-Ball, 1998).

The do-calculus. Pearl's three rules (1995) rewrite expressions with do(⋅)do(\cdot) using d-separation in mutilated graphs. In 2006 two groups independently proved them complete — Shpitser & Pearl, and Huang & Valtorta: if the rules cannot eliminate every dodo, the effect is not identifiable from observational data in that graph, full stop. Shpitser and Pearl's ID algorithm decides this in polynomial time and returns the formula when it exists. The simplest non-identifiable case is the "bow": X→YX\to Y plus a hidden common cause of both. No amount of observational data separates effect from confounding there.

A back-door estimator for discrete data is a few lines:

function backdoor(data):           # rows (z, x, y), z discrete
    tau = 0
    for each value z:
        S = rows with Z = z
        if S has no X=1 or no X=0: return undefined   # positivity fails
        tau += (mean(y | S, x=1) - mean(y | S, x=0)) * |S| / |data|
    return tau                     # O(n) time, O(#strata) space

Estimation

  • Positivity. Every stratum needs both treated and untreated units: 0<P(X=1∣z)<10<P(X=1\mid z)<1. With kk binary confounders there are 2k2^{k} strata, so plain stratification collapses quickly — the curse of dimensionality.
  • Propensity scores (Rosenbaum & Rubin, 1983) compress ZZ into one number e(z)=P(X=1∣z)e(z)=P(X=1\mid z), typically fitted with logistic regression. Inverse probability weighting then estimates τ\tau as 1n∑i[XiYie(Zi)−(1−Xi)Yi1−e(Zi)]\frac1n\sum_i\Big[\frac{X_iY_i}{e(Z_i)}-\frac{(1-X_i)Y_i}{1-e(Z_i)}\Big], whose variance explodes when e(z)e(z) approaches 0 or 1.
  • Instrumental variables. If an instrument II shifts XX but reaches YY only through XX (an idea going back to Philip Wright, 1928), the linear-model effect is the Wald ratio β=Cov⁡(Y,I)/Cov⁡(X,I)\beta=\operatorname{Cov}(Y,I)/\operatorname{Cov}(X,I), even with unmeasured confounding. With heterogeneous effects it estimates a local average effect for the units the instrument moves (Imbens & Angrist, 1994).

Learning the graph itself

Everything above assumed the DAG is known. Learning it is far harder: there are 25 DAGs on 3 labelled nodes, 543 on 4 and 29,281 on 5 (the count grows super-exponentially), and finding the best-scoring Bayesian network structure is NP-hard (Chickering, 1996). The PC algorithm (Spirtes & Glymour, 1991) uses conditional-independence tests and runs in polynomial time when node degree is bounded — but from observational data alone it can only recover the graph up to its Markov equivalence class: X→YX\to Y and Y→XY\to X predict exactly the same correlations.

Where It Matters

  • Medicine. The randomized controlled trial is Fisher's coin flip scaled up to regulatory science. When trials are impossible or unethical, epidemiologists use DAGs to choose adjustment sets — and to avoid adjusting for colliders and mediators.
  • Economics and natural experiments. The 2021 Nobel Memorial Prize in Economic Sciences went half to David Card for empirical contributions to labour economics and half to Joshua Angrist and Guido Imbens for methodological contributions to the analysis of causal relationships. Card and Krueger (1994) compared fast-food jobs in New Jersey and Pennsylvania around a minimum-wage rise; Angrist (1990) used the Vietnam draft lottery as an instrument for military service. History ran the coin flip for them.
  • A/B tests. Every online experiment is an RCT; multi-armed bandits are the adaptive version that trades some causal precision for less regret while the experiment runs.
  • Uplift modeling. Marketing and medicine both want the conditional effect τ(x)=E[Y(1)−Y(0)∣features=x]\tau(x)=\mathbb{E}[Y(1)-Y(0)\mid \text{features}=x]: whom does the coupon actually change, rather than who would have bought anyway?
  • Shortcut learning in ML. A classifier trained on correlations happily uses any confounded cue. Geirhos et al. (2020) catalogued such "shortcuts"; Zech et al. (2018) found pneumonia models on chest X-rays partly recognizing which hospital an image came from. Causal thinking asks which features would survive an intervention on the environment.
  • Fairness. Whether a decision treats a group unfairly is often a counterfactual question (counterfactual fairness, Kusner et al., 2017) — see fair machine learning trade-offs.
  • Updating beliefs. Conditioning with Bayes' theorem is rung one of the ladder; causal models tell you when that conditioning answers a question about action.

Conclusion

"Correlation is not causation" is where most courses stop. Causal inference is what comes after: potential outcomes say precisely what we want to know, randomization says how to get it cheaply, and causal graphs say when observational data can stand in for an experiment — through back-door or front-door adjustment — and, thanks to the completeness of the do-calculus, exactly when it cannot.

The price is honesty about assumptions. As Nancy Cartwright put it, no causes in, no causes out: every formula above leans on a graph someone had to defend. The simulator shows both sides of that bargain — the right adjustment recovers the truth from biased data, and the wrong one (a collider) manufactures an effect from nothing.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/causal-inference/Content licensed under CC BY-NC 4.0.