"Correlation told me everything I wanted to hear. Causation told me everything I needed to know. I still preferred correlation. It was nicer."
A Causal Graph With Too Many Confounders
Every scientific experiment is a causal question in disguise: "If I change X, what happens to Y?" Answering this from observational data alone is perilous because lurking confounders (variables that influence both treatment and outcome, creating spurious associations) can make harmless interventions appear dangerous and dangerous interventions appear harmless. Judea Pearl's do-calculus provides a complete mathematical framework for determining when and how causal effects can be identified from a combination of observational data and qualitative causal assumptions encoded in a directed acyclic graph (DAG). This section develops the framework from first principles, gives you the three rules, and shows you how to implement causal identification in Python. The full machinery returns in Chapter 31: Causal Discovery and Causal Inference, where we learn the graph itself from data.
1. The Ladder of Causation
A hospital notices that patients who receive a new drug recover faster, so it recommends the drug to everyone. Mortality rises instead of falling. Wealthier patients had been choosing the drug and recovering for reasons unrelated to it. Why did a reasonable inference from real data produce the opposite of the truth? No amount of additional data can bridge the gap between observing an association and intervening on a cause. Pearl's "ladder of causation" organizes this distinction into three levels, each strictly more powerful than the one below:
- Association (seeing): \(P(Y \mid X)\). "Patients who take the drug have lower mortality." This is what observational data gives us. No causal claim is warranted.
- Intervention (doing): \(P(Y \mid do(X))\). "If we administer the drug, mortality will decrease." This is what a randomized controlled trial answers. It requires either experimental data or the do-calculus.
- Counterfactual (imagining): \(P(Y_x \mid X = x', Y = y')\). "Would this specific patient have survived if we had given the drug, given that we did not and the patient died?" This is the most powerful level, requiring the full structural causal model.
The critical insight: you cannot climb the ladder using data alone. No amount of observational data (level 1) can answer an interventional question (level 2) without additional causal assumptions. This is not a limitation of sample size or statistical power; it is a mathematical impossibility. The do-calculus provides the minimum set of assumptions (encoded as a causal DAG) needed to bridge the gap. In short: Data alone tells you what happened; only causal assumptions can tell you what would happen if you intervened.
Mental Model
Think of the three rungs as three ways of reasoning about an ice cream shop on a hot day. Association is reading the sales log: "On days when we sold a lot of ice cream, the local pool was also crowded." You see the correlation but cannot tell whether ice cream drives pool attendance. Intervention is running an experiment: you shut down the shop for a week and observe that pool attendance stays the same, proving ice cream sales do not cause pool visits (the hidden cause is temperature). Counterfactual is the most specific: "Given that last Tuesday was 35 degrees and we sold 400 cones, would we have sold only 100 if the temperature had been 18 degrees?" Each rung demands strictly more knowledge: the sales log alone (no matter how large) cannot tell you what closing the shop would do, and the closure experiment cannot tell you what would have happened on a specific past day under different weather. The ladder is not about better data; it is about a qualitatively different kind of question, each requiring its own kind of causal assumption.
2. Causal DAGs and the Do-Operator
In 2020, a widely cited observational study reported that hydroxychloroquine appeared to reduce COVID-19 mortality, a finding that evaporated once randomized trials controlled for the confounders the original analysis had ignored. The tool that explains why the two analyses diverged, and that predicts such divergences before lives are at stake, is the causal DAG paired with the do-operator.
A causal DAG (directed acyclic graph) represents the qualitative causal structure of a system. Each node is a variable; each directed edge \(X \rightarrow Y\) means "\(X\) is a direct cause of \(Y\)." The graph encodes two kinds of information: what causes what, and (equally important) what does not cause what (missing edges).
The do-operator, written $do(X = x)$, represents an intervention that sets variable \(X\) to value \(x\) regardless of its natural causes. Graphically, $do(X = x)$ means deleting all incoming edges to \(X\) (severing it from its parents) and fixing \(X = x\). The resulting mutilated graph captures the post-intervention causal structure. Figure 4.3.1 illustrates do-operator graph surgery and the mutilated graph.
Why the Do-Operator Matters
The do-operator bridges observational statistics and experimental science. It translates "what would happen if we force \(X\) to take value \(x\)?" into a precise mathematical object, \(P(Y \mid do(X = x))\), comparable with the purely observational \(P(Y \mid X = x)\). These two quantities can diverge wildly whenever confounders exist. Confusing them is the root cause of most spurious causal claims from observational data. The mechanism is graph surgery: deleting every incoming edge to \(X\) simulates an idealized randomized experiment. \(X\) is assigned externally, breaking all associations with its natural causes. The do-operator (and the do-calculus that manipulates it) applies when causal conclusions must come from non-experimental data. When a true randomized experiment is feasible and ethical, it provides \(P(Y \mid do(X))\) directly without requiring DAG assumptions.
Consider the classic confounding example. Let \(Z\) be socioeconomic status, \(X\) be whether a patient receives a drug, and \(Y\) be recovery. Figure 4.3 shows the original DAG alongside the mutilated graph produced by \(do(X)\):
$$ Z \rightarrow X, \quad Z \rightarrow Y, \quad X \rightarrow Y $$In this graph, \(Z\) confounds the relationship between \(X\) and \(Y\). Wealthier patients (\(Z\) high) both receive the drug more often (\(Z \rightarrow X\)) and recover more often for other reasons (\(Z \rightarrow Y\)). The observational quantity \(P(Y \mid X)\) mixes the drug's true effect with the confounding effect of wealth. The interventional quantity \(P(Y \mid do(X))\) isolates the drug's true effect by severing the \(Z \rightarrow X\) edge.
import networkx as nx
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def build_causal_dag(edges: list[tuple[str, str]]) -> nx.DiGraph:
"""Build a causal DAG from a list of directed edges."""
G = nx.DiGraph()
G.add_edges_from(edges)
assert nx.is_directed_acyclic_graph(G), "Graph has cycles!"
return G
def mutilate(G: nx.DiGraph, intervention_node: str) -> nx.DiGraph:
"""Apply do-operator: remove all incoming edges to the intervention node.
This produces the 'mutilated graph' G_X that represents
the causal structure after the intervention do(X=x).
"""
G_mutilated = G.copy()
parents = list(G_mutilated.predecessors(intervention_node))
for parent in parents:
G_mutilated.remove_edge(parent, intervention_node)
return G_mutilated
# Confounding example: Z -> X, Z -> Y, X -> Y
dag = build_causal_dag([("Z", "X"), ("Z", "Y"), ("X", "Y")])
print("Original DAG edges:", list(dag.edges()))
# Output: [('Z', 'X'), ('Z', 'Y'), ('X', 'Y')]
# Apply do(X): sever Z -> X
dag_do_x = mutilate(dag, "X")
print("After do(X) edges:", list(dag_do_x.edges()))
# Output: [('Z', 'Y'), ('X', 'Y')]
# In the mutilated graph, Z no longer confounds X -> Y
# because Z has no path to X
3. The Backdoor Criterion
Graph mutilation tells us what the post-intervention world looks like, but we still need a practical test for when the interventional distribution can be computed from observational data alone; the backdoor criterion provides exactly that test.
The backdoor criterion (Pearl, 1993) provides a sufficient condition for identifying causal effects from observational data. A set of variables \(\mathbf{Z}\) satisfies the backdoor criterion relative to \((X, Y)\) if:
- No node in \(\mathbf{Z}\) is a descendant of \(X\)
- \(\mathbf{Z}\) blocks every path between \(X\) and \(Y\) that contains an arrow into \(X\) (a "backdoor path," where information flows from \(Y\) to \(X\) through a common cause rather than along the causal direction)
When a valid backdoor set \(\mathbf{Z}\) exists, the causal effect is identifiable via the adjustment formula:
$$ P(Y \mid do(X = x)) = \sum_{\mathbf{z}} P(Y \mid X = x, \mathbf{Z} = \mathbf{z}) \cdot P(\mathbf{Z} = \mathbf{z}) $$This is the mathematical justification for "controlling for confounders" in statistical analysis. The formula says: to compute the effect of intervening on \(X\), condition on \(X\) and the confounders \(\mathbf{Z}\), then average over the natural distribution of \(\mathbf{Z}\).
Common Misconception
"Controlling for more variables is always safer." Many practitioners assume that adding extra covariates to a regression can only reduce bias, so including every available variable is the conservative choice. This is wrong: conditioning on a collider (a common effect of two variables) or on a mediator (a variable on the causal path from treatment to outcome) can open new confounding paths or block the very effect you are trying to measure, increasing bias rather than reducing it. The backdoor criterion exists precisely to distinguish helpful adjustments from harmful ones; always consult the causal DAG before deciding which variables to include.
def find_backdoor_paths(
G: nx.DiGraph,
treatment: str,
outcome: str
) -> list[list[str]]:
"""Find all backdoor paths from treatment to outcome.
A backdoor path starts with an arrow INTO treatment
(i.e., goes through a parent of treatment).
"""
backdoor_paths = []
# Convert to undirected for path finding, but track direction
G_undirected = G.to_undirected()
for path in nx.all_simple_paths(G_undirected, treatment, outcome):
if len(path) < 2:
continue
# A backdoor path has an arrow INTO treatment at the first step
# i.e., path[1] -> treatment exists in original DAG
if G.has_edge(path[1], treatment):
backdoor_paths.append(path)
return backdoor_paths
def satisfies_backdoor(
G: nx.DiGraph,
treatment: str,
outcome: str,
adjustment_set: set[str]
) -> bool:
"""Check if adjustment_set satisfies the backdoor criterion.
Conditions:
1. No node in adjustment_set is a descendant of treatment
2. adjustment_set blocks all backdoor paths
"""
# Condition 1: no descendants of treatment
descendants = nx.descendants(G, treatment)
if adjustment_set & descendants:
return False
# Condition 2: blocks all backdoor paths
backdoor_paths = find_backdoor_paths(G, treatment, outcome)
for path in backdoor_paths:
# A path is blocked if any node in it (except endpoints)
# is in the adjustment set
interior = set(path[1:-1])
if not (interior & adjustment_set):
return False # This path is not blocked
return True
# Extended example: Drug trial with confounder and mediator
# Z (socioeconomic) -> X (drug), Z -> Y (recovery)
# X -> M (biomarker) -> Y
dag = build_causal_dag([
("Z", "X"), ("Z", "Y"), ("X", "M"), ("M", "Y")
])
# Test various adjustment sets
for adj_set in [{"Z"}, {"M"}, {"Z", "M"}, set()]:
valid = satisfies_backdoor(dag, "X", "Y", adj_set)
print(f" Adjust for {adj_set or '{}'}: {'VALID' if valid else 'INVALID'}")
# Output:
# Adjust for {'Z'}: VALID
# Adjust for {'M'}: INVALID (M is a descendant of X)
# Adjust for {'Z', 'M'}: INVALID (M is a descendant of X)
# Adjust for {}: INVALID (backdoor Z->X not blocked)
Conditioning on a descendant of the treatment variable (a mediator or a collider descendant) can introduce bias rather than remove it. This is one of the most common errors in applied science: researchers control for every available variable, including post-treatment mediators, and obtain biased estimates. The backdoor criterion provides a principled guard against this error. In discovery AI systems, the causal DAG serves as a constraint that prevents the system from "controlling for" variables it should leave alone. This lesson is developed fully in Chapter 31.
4. The Frontdoor Criterion
What if no valid backdoor adjustment set exists? The frontdoor criterion handles a specific but important case: when the treatment affects the outcome only through a mediator \(M\), and the confounder \(U\) does not directly affect \(M\). The strategy is to decompose the overall causal effect into two sub-effects (treatment on mediator, mediator on outcome), each of which can be identified individually, and then compose them.
$$ U \rightarrow X, \quad U \rightarrow Y, \quad X \rightarrow M \rightarrow Y $$Here \(U\) is unobserved (no data available), so we cannot adjust for it. Yet the frontdoor criterion lets us identify the causal effect of \(X\) on \(Y\) through \(M\):
$$ P(Y \mid do(X = x)) = \sum_m P(M = m \mid X = x) \sum_{x'} P(Y \mid X = x', M = m) P(X = x') $$The intuition: first identify how \(X\) affects \(M\) (unconfounded because \(U\) does not affect \(M\) directly), then identify how \(M\) affects \(Y\) (using \(X\) as a backdoor adjustment for \(M \rightarrow Y\)), and compose the two effects.
Pearl's motivating example for the frontdoor criterion: does smoking (\(X\)) cause cancer (\(Y\))? An unobserved genotype (\(U\)) might predispose people to both smoke and develop cancer. Direct backdoor adjustment is impossible because \(U\) is unobserved. However, if smoking causes cancer only through tar deposits (\(M\)), and the genotype does not directly affect tar deposits (tar comes from smoke, not genes), then the frontdoor criterion applies. We can identify the causal effect of smoking on cancer by (1) measuring how smoking affects tar and (2) measuring how tar affects cancer while adjusting for smoking. This is a powerful template for discovery AI: when confounders are unobservable, look for mediating mechanisms that provide an alternative identification path.
5. The Three Rules of Do-Calculus
The backdoor and frontdoor criteria are special cases of Pearl's do-calculus, a complete system for causal identification: if a causal effect can be identified from a DAG, these three rules (plus standard probability) suffice to derive the formula. In particular, both the backdoor adjustment formula (Section 3) and the frontdoor formula (Section 4) can be derived by applying these rules in sequence to their respective graphs. (Three rules. That is the entire calculus. Every identifiable causal effect in every possible graph yields to some sequence of just these three transformations.)
Let \(G\) be a causal DAG, \(X\), \(Y\), \(Z\), \(W\) be disjoint sets of variables, and let \(G_{\overline{X}}\) denote the graph with incoming edges to \(X\) removed, \(G_{\underline{X}}\) denote the graph with outgoing edges from \(X\) removed.
Rule 1 (Insertion/deletion of observations):
$$ P(Y \mid do(X), Z, W) = P(Y \mid do(X), W) \quad \text{if } (Y \perp\!\!\!\perp Z \mid X, W)_{G_{\overline{X}}} $$We can add or remove an observation \(Z\) if \(Y\) and \(Z\) are d-separated (conditionally independent given the graph structure; informally, every path between them is "blocked" by either a non-collider in the conditioning set or a collider outside it) in the manipulated graph.
Rule 2 (Action/observation exchange):
$$ P(Y \mid do(X), do(Z), W) = P(Y \mid do(X), Z, W) \quad \text{if } (Y \perp\!\!\!\perp Z \mid X, W)_{G_{\overline{X}\underline{Z}}} $$We can replace \(do(Z)\) with observation \(Z\) (or vice versa) if \(Y\) and \(Z\) are d-separated in the graph where incoming edges to \(X\) and outgoing edges from \(Z\) are removed.
Rule 3 (Insertion/deletion of actions):
$$ P(Y \mid do(X), do(Z), W) = P(Y \mid do(X), W) \quad \text{if } (Y \perp\!\!\!\perp Z \mid X, W)_{G_{\overline{X}\overline{Z(S)}}} $$where \(Z(S)\) is the set of nodes in \(Z\) that are not ancestors of any node in \(W\) in \(G_{\overline{X}}\). We can remove the action \(do(Z)\) entirely if \(Y\) and \(Z\) are d-separated in the appropriately manipulated graph.
Checkpoint
So far: the three rules of do-calculus each permit a specific rewriting of expressions involving \(do(\cdot)\), and each rule's applicability is decided by a d-separation test on a modified version of the DAG (edges removed according to the rule). Applying these rules in sequence can convert an interventional query \(P(Y \mid do(X))\) into a purely observational expression, which is what "identification" means.
def d_separated(
G: nx.DiGraph,
X: set[str],
Y: set[str],
Z: set[str]
) -> bool:
"""Test d-separation in a DAG: X _||_ Y | Z.
Uses the Bayes-Ball algorithm (Shachter, 1998), a message-passing
procedure that traces which nodes are reachable from X given the
conditioning set Z, respecting the direction of edges and collider
blocking rules.
Returns True if X and Y are d-separated given Z.
"""
# Build ancestor set of Z for collider unblocking
z_ancestors = set()
for z in Z:
z_ancestors |= nx.ancestors(G, z)
z_ancestors |= Z
# BFS from X nodes, tracking direction of traversal
# States: (node, direction) where direction is "up" or "down"
visited = set()
queue = []
for x in X:
queue.append((x, "up"))
queue.append((x, "down"))
reachable = set()
while queue:
node, direction = queue.pop(0)
if (node, direction) in visited:
continue
visited.add((node, direction))
if node in Y:
reachable.add(node)
# If arriving from a child (going "up")
if direction == "up" and node not in Z:
# Continue to parents (up)
for parent in G.predecessors(node):
queue.append((parent, "up"))
# Continue to children (down)
for child in G.successors(node):
queue.append((child, "down"))
# If arriving from a parent (going "down")
elif direction == "down":
# If not in Z, pass through to children
if node not in Z:
for child in G.successors(node):
queue.append((child, "down"))
# If in Z or ancestor of Z, collider is unblocked: go up
if node in z_ancestors:
for parent in G.predecessors(node):
queue.append((parent, "up"))
return len(reachable) == 0
def apply_do_calculus_rule2(
G: nx.DiGraph,
Y: str,
X: str,
Z: str,
W: set[str]
) -> bool:
"""Check if Rule 2 applies: can we replace do(Z) with observation Z?
Requires: Y _||_ Z | (X, W) in G with incoming edges to X removed
and outgoing edges from Z removed.
"""
# Build G_{overline{X}, underline{Z}}
G_modified = G.copy()
# Remove incoming edges to X (overline)
for parent in list(G_modified.predecessors(X)):
G_modified.remove_edge(parent, X)
# Remove outgoing edges from Z (underline)
for child in list(G_modified.successors(Z)):
G_modified.remove_edge(Z, child)
# Test d-separation
conditioning = {X} | W
return d_separated(G_modified, {Z}, {Y}, conditioning)
# Test on the frontdoor graph: U -> X, U -> Y, X -> M -> Y
frontdoor_dag = build_causal_dag([
("U", "X"), ("U", "Y"), ("X", "M"), ("M", "Y")
])
# Can we replace do(X) with observation X when estimating
# the effect on M? (Step 1 of frontdoor)
can_exchange = apply_do_calculus_rule2(
frontdoor_dag, Y="M", X="X", Z="X", W=set()
)
print(f"Rule 2 applicable (M, do(X) -> obs X): {can_exchange}")
# Output: True (because U does not affect M except through X)
The completeness of do-calculus was proven by Huang and Valtorta (2006) and independently by Shpitser and Pearl (2006). This means that if you cannot derive an identifying formula using the three rules, then the causal effect is genuinely non-identifiable from your DAG and data. No amount of cleverness or additional statistics will help. You need either a better DAG (more assumptions) or experimental data (an actual intervention). This is one of the cleaner examples of a negative result in formal science: the calculus tells you not just what you can know, but exactly what you cannot.
6. Counterfactual Reasoning
The do-calculus settles what we can learn about populations under interventions, but science often demands a more personal question: what would have happened to this particular patient, experiment, or observation under different circumstances?
The highest rung of Pearl's ladder is counterfactual reasoning: "What would have happened if things had been different?" Counterfactuals require a full structural causal model (SCM), not just a DAG. An SCM specifies the functional form of each variable:
$$ X_i = f_i(\text{pa}(X_i), U_i) $$where \(\text{pa}(X_i)\) are the parents of \(X_i\) in the DAG and \(U_i\) is an exogenous noise variable (a factor determined entirely outside the model, representing unmodeled influences on \(X_i\)). Given an SCM and observed evidence, counterfactual reasoning proceeds in three steps:
- Abduction: Use the observed evidence to infer the values of exogenous variables \(U_i\)
- Action: Modify the SCM according to the hypothetical intervention
- Prediction: Compute the outcome in the modified model with the inferred \(U_i\) values
import numpy as np
class StructuralCausalModel:
"""A simple linear structural causal model for counterfactual reasoning."""
def __init__(self):
self.equations = {} # node -> (coefficients_dict, noise_std)
self.noise_values = {} # Realized noise (set during abduction)
def add_equation(
self,
variable: str,
parents: dict[str, float],
noise_std: float = 1.0
):
"""Add structural equation: variable = sum(coeff * parent) + noise."""
self.equations[variable] = (parents, noise_std)
def sample(self, n: int = 1, seed: int = 42) -> dict[str, np.ndarray]:
"""Generate observational samples from the SCM."""
rng = np.random.default_rng(seed)
data = {}
for var in self._topological_order():
parents, noise_std = self.equations[var]
value = rng.normal(0, noise_std, size=n)
for parent, coeff in parents.items():
value = value + coeff * data[parent]
data[var] = value
return data
def counterfactual(
self,
observed: dict[str, float],
intervention: dict[str, float],
query: str
) -> float:
"""Compute a counterfactual: P(query | observed, do(intervention)).
Step 1 (Abduction): Infer noise from observations
Step 2 (Action): Apply intervention
Step 3 (Prediction): Compute query under modified model
"""
# Step 1: Abduction - infer noise values from observations
noise = {}
for var in self._topological_order():
parents, _ = self.equations[var]
if var in observed:
# noise = observed - sum(coeff * parent_value)
parent_contribution = sum(
coeff * observed.get(p, 0.0) for p, coeff in parents.items()
)
noise[var] = observed[var] - parent_contribution
else:
noise[var] = 0.0 # Use expected noise for unobserved
# Step 2 & 3: Action + Prediction with inferred noise
values = {}
for var in self._topological_order():
if var in intervention:
values[var] = intervention[var] # Intervened: fixed value
else:
parents, _ = self.equations[var]
value = noise[var]
for parent, coeff in parents.items():
value += coeff * values[parent]
values[var] = value
return values[query]
def _topological_order(self) -> list[str]:
"""Return variables in topological order."""
G = nx.DiGraph()
for var, (parents, _) in self.equations.items():
for parent in parents:
G.add_edge(parent, var)
if not parents:
G.add_node(var)
return list(nx.topological_sort(G))
# Example: Drug dosage -> Biomarker -> Recovery
# With confounder Age affecting both Dosage and Recovery
scm = StructuralCausalModel()
scm.add_equation("Age", {}, noise_std=10.0) # Exogenous
scm.add_equation("Dosage", {"Age": -0.1}, noise_std=2.0) # Older -> less drug
scm.add_equation("Biomarker", {"Dosage": 0.5}, noise_std=1.0)
scm.add_equation("Recovery", {"Biomarker": 0.8, "Age": -0.05}, noise_std=1.0)
# Observed patient: Age=60, Dosage=2, Biomarker=1.5, Recovery=0.7
observed = {"Age": 60.0, "Dosage": 2.0, "Biomarker": 1.5, "Recovery": 0.7}
# Counterfactual: what if we had given Dosage=10?
cf_recovery = scm.counterfactual(
observed=observed,
intervention={"Dosage": 10.0},
query="Recovery"
)
print(f"Observed recovery: {observed['Recovery']:.2f}")
print(f"Counterfactual recovery: {cf_recovery:.2f}")
print(f"Estimated treatment effect: {cf_recovery - observed['Recovery']:.2f}")
# Output:
# Observed recovery: 0.70
# Counterfactual recovery: 3.90
# Estimated treatment effect: 3.20
Counterfactual reasoning in AI systems is advancing rapidly on multiple fronts. Causal representation learning (Scholkopf et al., 2021) aims to learn structural equations and causal variables directly from high-dimensional data rather than requiring hand-specified DAGs. Large language model (LLM) based causal reasoning (Kiciman et al., 2023) explores whether LLMs can serve as approximate causal reasoners, with early results showing strength in commonsense causality but weakness in formal counterfactual consistency. Most recently, CausalBench (Melnychuk et al., 2025) introduced a large-scale benchmark for evaluating causal reasoning in foundation models across interventional prediction, counterfactual estimation, and graph discovery tasks, revealing that even state-of-the-art models fail systematically on problems requiring multi-step application of do-calculus rules. This benchmark provides the first standardized way to measure whether an AI system can perform the kind of formal causal identification taught in this section, and early results suggest that augmenting LLMs with explicit graphical reasoning modules (rather than relying on in-context causal knowledge alone) is necessary for reliable performance. Both threads converge in Chapter 31, where we build systems that discover causal structure from data.
7. Causal Identification in Practice
For discovery AI practitioners, the operational question is: "Given my causal DAG and available data, can I identify the causal effect I care about, and if so, what formula should I use?" The following algorithm automates this process for simple cases:
from dataclasses import dataclass
from enum import Enum, auto
class IdentificationResult(Enum):
BACKDOOR = auto()
FRONTDOOR = auto()
NOT_IDENTIFIED = auto()
@dataclass
class CausalQuery:
treatment: str
outcome: str
dag: nx.DiGraph
observed: set[str] # Variables we can measure
def identify_effect(query: CausalQuery) -> tuple[IdentificationResult, str]:
"""Attempt to identify a causal effect using standard criteria.
Tries backdoor first (simpler), then frontdoor.
Returns the identification method and adjustment formula.
"""
G = query.dag
X, Y = query.treatment, query.outcome
# Try backdoor: find a valid adjustment set among observed variables
candidates = query.observed - {X, Y}
descendants_X = nx.descendants(G, X)
# Try subsets of candidates (smallest first for parsimony)
from itertools import combinations
for size in range(len(candidates) + 1):
for subset in combinations(candidates, size):
adj_set = set(subset)
if adj_set & descendants_X:
continue # Violates condition 1
if satisfies_backdoor(G, X, Y, adj_set):
formula = (
f"P({Y}|do({X})) = "
f"Sum_({','.join(adj_set) or 'none'}) "
f"P({Y}|{X},{','.join(adj_set)}) * "
f"P({','.join(adj_set)})"
)
return IdentificationResult.BACKDOOR, formula
# Try frontdoor: find mediator M where X -> M -> Y
for M in query.observed - {X, Y}:
if G.has_edge(X, M) and nx.has_path(G, M, Y):
# Check: no unblocked backdoor path from X to M
if satisfies_backdoor(G, X, M, set()):
formula = (
f"P({Y}|do({X})) = "
f"Sum_{M} P({M}|{X}) * "
f"Sum_{X}' P({Y}|{X}',{M}) * P({X}')"
)
return IdentificationResult.FRONTDOOR, formula
return IdentificationResult.NOT_IDENTIFIED, "Effect not identifiable"
# Test 1: Simple confounding (backdoor works)
simple_dag = build_causal_dag([("Z", "X"), ("Z", "Y"), ("X", "Y")])
q1 = CausalQuery("X", "Y", simple_dag, {"X", "Y", "Z"})
result, formula = identify_effect(q1)
print(f"Simple confounding: {result.name}")
print(f" Formula: {formula}")
# Test 2: Unobserved confounding with mediator (frontdoor works)
frontdoor_dag = build_causal_dag([
("U", "X"), ("U", "Y"), ("X", "M"), ("M", "Y")
])
q2 = CausalQuery("X", "Y", frontdoor_dag, {"X", "Y", "M"}) # U unobserved
result2, formula2 = identify_effect(q2)
print(f"\nUnobserved confounding: {result2.name}")
print(f" Formula: {formula2}")
# Output:
# Simple confounding: BACKDOOR
# Formula: P(Y|do(X)) = Sum_(Z) P(Y|X,Z) * P(Z)
# Unobserved confounding: FRONTDOOR
# Formula: P(Y|do(X)) = Sum_M P(M|X) * Sum_X' P(Y|X',M) * P(X')
The from-scratch implementations above teach the concepts, but production causal inference uses mature libraries. DoWhy (now maintained by the PyWhy community; originally developed at Microsoft) provides a four-step API (model, identify, estimate, refute) that automates identification using the do-calculus and supports multiple estimation methods (matching, IPW, instrumental variables). CausalFusion implements the complete do-calculus with a web interface. pgmpy provides Bayesian network operations including d-separation and causal inference. These tools reduce the identification code from ~100 lines to ~10, handle edge cases (cycles, selection bias, transportability), and include refutation tests to check the robustness of estimates.
The do-calculus separates two concerns that are often conflated: the identification question ("can I compute the causal effect from this DAG and data?") and the estimation question ("what statistical method gives me the best estimate?"). Identification is a graph-theoretic problem with a definite answer (yes or no); estimation is a statistical problem with tradeoffs (bias, variance, efficiency). Discovery AI systems should address identification first. If the effect is not identifiable, no statistical method will save you. If it is identifiable, the formula tells you exactly what to estimate, and you can then choose the best estimator for your data size and structure.
Try It: Estimate a Causal Effect with the Backdoor Adjustment
Build a complete causal inference pipeline in Python using only NumPy, NetworkX, and basic statistics:
- Simulate confounded data. Create a dataset of 5,000 samples from the SCM: \(Z \sim \mathcal{N}(0,1)\), \(X = 0.6Z + \epsilon_X\), \(Y = 0.4X + 0.8Z + \epsilon_Y\) (all noise terms standard normal). The true causal effect of \(X\) on \(Y\) is 0.4, but a naive regression of \(Y\) on \(X\) alone will overestimate it because \(Z\) confounds the relationship.
- Compute the naive (biased) estimate. Regress \(Y\) on \(X\) without adjusting for \(Z\) using
numpy.linalg.lstsq. Record the coefficient; it should be noticeably larger than 0.4. - Build the causal DAG with NetworkX (
edges = [("Z","X"), ("Z","Y"), ("X","Y")]) and verify programmatically (using thesatisfies_backdoorfunction from this section) that \(\{Z\}\) is a valid adjustment set. - Compute the backdoor-adjusted estimate. Regress \(Y\) on both \(X\) and \(Z\). The coefficient on \(X\) should now be close to 0.4, recovering the true causal effect.
- Stress-test with a collider trap. Add a collider \(C = X + Y + \epsilon_C\) to the data. Regress \(Y\) on \(X\) and \(C\) (without \(Z\)). Observe that conditioning on the collider produces a worse estimate than the naive regression, illustrating why the backdoor criterion forbids adjusting for descendants of the treatment.
Exercise 4.3.1
Consider the DAG: \(A \rightarrow B\), \(A \rightarrow C\), \(B \rightarrow D\), \(C \rightarrow D\), \(B \rightarrow E\), \(D \rightarrow E\). You want to estimate the causal effect of \(B\) on \(E\). Which of the following adjustment sets satisfy the backdoor criterion: (a) \(\{A\}\), (b) \(\{C\}\), (c) \(\{D\}\), (d) \(\{A, C\}\)? For each invalid set, explain which condition of the backdoor criterion it violates.
Hint
First list all backdoor paths from \(B\) to \(E\) (paths with an arrow into \(B\)). Then check each candidate set against both conditions: (1) no node in the set is a descendant of \(B\), and (2) every backdoor path is blocked. Remember that \(D\) is a descendant of \(B\), which immediately disqualifies any set containing it.
Step-Through: Backdoor Adjustment with Concrete Numbers
Trace through the backdoor adjustment formula on a tiny discrete example. Suppose \(Z \in \{0, 1\}\), \(X \in \{0, 1\}\), \(Y \in \{0, 1\}\), with the DAG \(Z \rightarrow X\), \(Z \rightarrow Y\), \(X \rightarrow Y\). The data gives us:
\(P(Z=0) = 0.5\), \(P(Z=1) = 0.5\)
\(P(Y=1 \mid X=1, Z=0) = 0.3\), \(P(Y=1 \mid X=1, Z=1) = 0.9\)
\(P(Y=1 \mid X=0, Z=0) = 0.1\), \(P(Y=1 \mid X=0, Z=1) = 0.7\)
\(P(X=1 \mid Z=0) = 0.2\), \(P(X=1 \mid Z=1) = 0.8\)
Step 1: Compute \(P(Y=1 \mid do(X=1))\) using the adjustment formula:
\(= P(Y=1 \mid X=1, Z=0) \cdot P(Z=0) + P(Y=1 \mid X=1, Z=1) \cdot P(Z=1)\)
\(= 0.3 \times 0.5 + 0.9 \times 0.5 = 0.15 + 0.45 = 0.60\)
Step 2: Compute \(P(Y=1 \mid do(X=0))\) similarly:
\(= 0.1 \times 0.5 + 0.7 \times 0.5 = 0.05 + 0.35 = 0.40\)
Step 3: The causal effect is \(0.60 - 0.40 = 0.20\).
Contrast with naive conditioning: \(P(Y=1 \mid X=1)\) pools over \(Z\) weighted by \(P(Z \mid X=1)\), not \(P(Z)\). Since \(Z=1\) makes \(X=1\) more likely, the naive estimate over-weights the \(Z=1\) stratum: \(P(Y=1 \mid X=1) = 0.3 \times \frac{0.1}{0.5} + 0.9 \times \frac{0.4}{0.5} = 0.06 + 0.72 = 0.78\), which drastically overstates the drug's effect.
Real-World Application: Online Advertising at Google
Google's causal inference team has published work on measuring the causal effect of ad exposure on user purchases (Brodersen et al., 2015), a problem where naive click-through metrics are typically confounded by user intent (users who search for a product are more likely to both see the ad and buy the product regardless). Their CausalImpact framework builds causal models that separate organic purchase intent from ad-driven conversions, enabling adjustment formulas to recover incremental revenue attributable to advertising spend.
Lab: Causal Effect Identification with DoWhy
Goal: Use the DoWhy library to identify, estimate, and refute a causal effect on synthetic data, comparing the result against your known ground truth.
Tools needed: Python 3.9+, pip install dowhy networkx matplotlib (about 15 minutes total).
Setup: Generate 10,000 samples from a linear SCM with two confounders (\(Z_1\), \(Z_2\)), a treatment \(X\), and outcome \(Y\), where the true causal effect of \(X\) on \(Y\) is exactly 2.0. Encode the DAG in DoWhy's graph format (Graph Modelling Language (GML) string).
What to vary: (1) Try omitting one confounder from the DAG and observe how DoWhy's identification step changes. (2) Add a collider \(C = X + Y + \text{noise}\) to the DAG as a parent of \(Y\) and observe how the estimate degrades. (3) Switch estimation methods (linear regression, propensity score stratification, inverse probability weighting) and compare precision.
What to observe: Does DoWhy correctly refuse to identify the effect when the DAG is misspecified? How do the refutation tests (random common cause, placebo treatment, data subset) flag the collider-biased estimate? Record the estimated effect and 95% confidence interval for each configuration.
Exercises
- Conceptual: Draw a causal DAG for the following scenario: "Education level affects income, which affects health. Education also directly affects health through health literacy. Genetic factors affect both education level and health." Identify all backdoor paths from education to health, and find the minimal adjustment set.
- Coding: Extend the
StructuralCausalModelclass to support non-linear equations (e.g., \(Y = \beta_1 X + \beta_2 X^2 + U\)). Demonstrate a case where the linear and non-linear SCMs give qualitatively different counterfactual answers for the same observed data. - Analysis: A pharmaceutical company observes that patients who take their drug and exercise regularly have better outcomes than patients who take the drug alone. They claim the drug works synergistically with exercise. Draw two causal DAGs that are both consistent with this observation but imply different interventional conclusions. Explain how a randomized trial could distinguish them.
What's Next
With the reasoning toolkit complete (deduction, induction, abduction, analogy, and causal inference), Section 4.4: Building a Reasoning Pipeline assembles these components into a working system. The pipeline takes observed data, generates abductive explanations using an LLM, scores them by plausibility and testability using causal and deductive checks, and outputs ranked hypotheses ready for experimental design in Chapter 46.
Bibliography
Foundational Papers
The definitive reference for causal inference. Introduces the do-operator, do-calculus, backdoor/frontdoor criteria, counterfactuals, and structural causal models. Chapters 3-4 cover the material in this section.
A concise survey of the graphical approach to causal inference, more accessible than the full Causality book. Covers the ladder of causation, do-calculus, and identification criteria.
Proved the completeness of the do-calculus: if a causal effect cannot be identified by the three rules, it is genuinely non-identifiable. A landmark result in causal inference theory.
Books
A popular-science introduction to causal inference. Covers the same concepts as Causality but with historical narrative and minimal math. Excellent for building intuition before diving into the formalism.
A complementary perspective on causal inference from the potential-outcomes (Rubin) tradition. Bridges the graphical and potential-outcomes frameworks with practical epidemiological examples.
Tools & Libraries
Production-grade causal inference library with four-step API: model (DAG), identify (do-calculus), estimate (statistical), refute (sensitivity analysis). The recommended tool for applied causal inference in Python. As of 2024, DoWhy is maintained under the PyWhy organization and integrates with the broader PyWhy ecosystem, including EconML for heterogeneous treatment effects and the CausalLearn library for causal discovery.
Bayesian network library with d-separation, causal inference, and structure learning. Used for the graph-theoretic operations throughout this section.
General-purpose graph library used for DAG construction, topological sorting, ancestor/descendant computation, and path finding in our causal implementations.
Surveys
Benchmarks LLM performance on causal tasks including pairwise causal discovery, counterfactual reasoning, and effect estimation. Shows strengths in common-sense causality and weaknesses in formal counterfactual consistency.
Proposes learning causal variables and their relationships from raw data, bridging representation learning (Chapter 26) with causal inference. A foundational vision for the next generation of causal AI.