Part I: Foundations of Discovery AI
Chapter 4: Reasoning For Discovery

4.3 Causal Reasoning and the Do-Calculus

"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
The Big Picture

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:

  1. 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.
  2. 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.
  3. 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

Pearl's three rungs of causation as three ways of reasoning about an ice cream shop on a hot day

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.

Do-operator graph surgery and the mutilated graph
Figure 4.3.1: The do-operator as graph surgery. Left: the original causal DAG where the confounder Z creates a backdoor path (red) that biases the observational association P(Y|X). Right: the mutilated graph after applying do(X), which severs all incoming edges to X (dashed line with scissors), isolating the true causal effect P(Y|do(X)).

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 $$
Original DAG Z X Y confounds causal + confounded Mutilated DAG: do(X) Z X Y severed causal effect isolated X set externally (red node)
Figure 4.3: Graph surgery for the do-operator. Left: the original DAG where socioeconomic status Z confounds the treatment-outcome relationship (Z causes both X and Y). Right: the mutilated graph after applying do(X), which severs the Z-to-X edge (dashed line), isolating the direct causal effect of X on Y. The red node indicates the externally set (intervened) variable.

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
Listing 4.9: Building a causal DAG and applying the do-operator via graph mutilation. Removing incoming edges to the intervention node severs confounding paths, isolating the direct causal effect of X on Y.

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:

  1. No node in \(\mathbf{Z}\) is a descendant of \(X\)
  2. \(\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)
Listing 4.10: Backdoor criterion verification with confounder and mediator variables. The algorithm checks whether a proposed adjustment set blocks all confounding paths without conditioning on descendants of the treatment. Only adjusting for Z (socioeconomic status) is valid; adjusting for M (biomarker, a mediator) introduces post-treatment bias.
Key Insight

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.

Practical Example: Smoking, Tar, and Cancer

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)
Listing 4.11: D-separation testing via the Bayes-Ball algorithm and do-calculus Rule 2 verification on the frontdoor graph. The d-separation predicate is the core building block for all three do-calculus rules; here it confirms that replacing do(X) with observed X is valid when estimating the effect on the mediator M.
Fun Note

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:

Real-World Application: Online Advertising at Google
Real-World Application: Online Advertising at Google
  1. Abduction: Use the observed evidence to infer the values of exogenous variables \(U_i\)
  2. Action: Modify the SCM according to the hypothetical intervention
  3. 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
Listing 4.12: Counterfactual reasoning with a linear structural causal model. The three-step abduction-action-prediction procedure computes what a specific patient's recovery would have been under a higher dosage, preserving all individual-specific noise factors inferred from the observations.
Research Frontier

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')
Listing 4.13: Automated causal effect identification using backdoor and frontdoor criteria. The algorithm searches observed-variable subsets (smallest first for parsimony) for a valid backdoor set, then falls back to the frontdoor criterion. For production use, the DoWhy and CausalFusion libraries automate this with the full do-calculus.
Right Tool: Causal Inference Libraries

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.

Key Insight

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:

  1. 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.
  2. 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.
  3. Build the causal DAG with NetworkX (edges = [("Z","X"), ("Z","Y"), ("X","Y")]) and verify programmatically (using the satisfies_backdoor function from this section) that \(\{Z\}\) is a valid adjustment set.
  4. 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.
  5. 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

  1. 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.
  2. Coding: Extend the StructuralCausalModel class 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.
  3. 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

Pearl, J. (2009). Causality: Models, Reasoning, and Inference. 2nd ed.

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.

Pearl, J. (2009). Causal inference in statistics: An overview. Statistics Surveys 3.

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.

Shpitser, I. & Pearl, J. (2006). Identification of joint interventional distributions in recursive semi-Markovian causal models.

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

Pearl, J. & Mackenzie, D. (2018). The Book of Why. Basic 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.

Hernan, M.A. & Robins, J.M. (2020). Causal Inference: What If. CRC Press.

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

DoWhy: A Python Library for Causal Inference (PyWhy / originally Microsoft).

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.

pgmpy: Probabilistic Graphical Models in Python.

Bayesian network library with d-separation, causal inference, and structure learning. Used for the graph-theoretic operations throughout this section.

NetworkX: Network Analysis in Python.

General-purpose graph library used for DAG construction, topological sorting, ancestor/descendant computation, and path finding in our causal implementations.

Surveys

Kiciman, E. et al. (2023). Causal Reasoning and Large Language Models.

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.

Scholkopf, B. et al. (2021). Toward Causal Representation Learning.

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.