The simulator below is a real structural causal model with binary variables: a confounder Z raises both the chance of treatment X and the chance of a good outcome Y, and X has a true effect on Y that you set. It draws 5000 units, each with its own random noise, and computes three numbers:
<!-- 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();
<!-- 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();
Comments
Loading comments...