Causal Identification

This guide expands on the Getting Started tutorial with more advanced identification techniques.

using CausalStructures
using CairoMakie
using Sugiyama

Adjustment strategies

The adjustment_set function supports three different strategies for finding adjustment sets. Let's build a DAG with multiple confounders and mediators – Figure 6.5 of Peters et al. (2017):

dag = DAG(
    "C --> X, A --> X + K, X --> F + D, K --> Y, D --> Y + G, Y --> H")
plot(dag)

Parents strategy: Adjusts for all parents of X. Simple but often includes unnecessary variables:

adjustment_set(dag, :X, :Y; type = :parents)
2-element Vector{Symbol}:
 :A
 :C

Backdoor strategy: Applies Pearl's backdoor criterion to find valid adjustment sets that block all confounding paths:

adjustment_set(dag, :X, :Y; type = :backdoor)
1-element Vector{Symbol}:
 :A

Optimal strategy (default): Computes the O-set, which minimizes the asymptotic variance of the causal effect estimator:

adjustment_set(dag, :X, :Y; type = :optimal)
1-element Vector{Symbol}:
 :K

Minimal separator

Sometimes we need the smallest set of variables that d-separates two nodes. The minimal_separator function finds one:

minimal_separator(dag, :X, :Y)
2-element Vector{Symbol}:
 :A
 :D

The restrict keyword limits the search to a specific pool of variables.

minimal_separator(dag, :X, :Y; restrict = [:D, :K])
2-element Vector{Symbol}:
 :D
 :K

The include keyword forces certain variables to always be in the result:

minimal_separator(dag, :X, :Y; include = :K)
2-element Vector{Symbol}:
 :D
 :K

Frontdoor adjustment

When confounders are unobserved, the backdoor criterion doesn't apply. Consider a graph where an unobserved confounder U affects both the treatment X and outcome Y:

dag2 = DAG("U --> X + Y, X --> M --> Y")
plot(dag2)

The mediator M is a descendant of X, so adjusting for it violates the backdoor criterion:

is_valid_backdoor(dag2, :X, :Y, :M)
false

However, M intercepts every causal path from X to Y and is itself unconfounded; this is exactly the frontdoor criterion:

is_valid_frontdoor(dag2, :X, :Y, :M)
true
frontdoor_set(dag2, :X, :Y)
1-element Vector{Symbol}:
 :M

Instrumental variables

When hidden confounders block backdoor adjustment and no suitable mediator exists for the frontdoor criterion, an instrumental variable (IV) can still identify the causal effect. An instrument Z must satisfy two conditions: it must be associated with the treatment X (relevance), and it can only affect the outcome Y through X (exclusion restriction).

Consider a graph where U is an unobserved confounder and Z is an available instrument, but there is no mediator on the path from X to Y:

dag3 = DAG("U --> X + Y, Z --> X --> Y")
plot(dag3)

Without a mediator, the frontdoor criterion cannot apply here:

is_valid_frontdoor(dag3, :X, :Y, :Z)
false

However, Z is a valid instrument since it is d-connected to X and d-separated from Y given X in the interventional graph $G_{\overline{X}}$ (the graph with all incoming edges to X removed):

is_valid_iv(dag3, :X, :Y, :Z)
true

ADMG adjustment

In an ADMG (Acyclic Directed Mixed Graph), bidirected edges <-> represent hidden common causes directly, without explicitly keeping the unobserved nodes in the graph. An ADMG arises naturally by projecting unobserved variables out of a DAG via latent_project. Let's project U out of dag3:

admg = latent_project(dag3, :U)
ADMG with 3 nodes and 3 edges:
  nodes: X, Y, Z
  edges:
    X --> Y, X <-> Y, Z --> X

Removing U introduces X <-> Y, which captures its confounding effect. The functions discussed above also work on ADMGs, e.g.:

is_valid_iv(admg, :X, :Y, :Z)
true

General identification

The methods discussed above are each sufficient, not necessary. CausalStructures also implements the ID algorithm of Shpitser and Pearl (2008) and returns the estimand as an Estimand.

On a graph with an observed confounder, it recovers the familiar g-formula:

dag4 = DAG("Z --> X + Y, X --> Y")
id(dag4, :X, :Y)

\[\sum_{Z} P(Y \mid X, Z) P(Z)\]

Projecting U out of the front-door graph gives an ADMG where no adjustment set exists, yet the effect is identified through the mediator:

admg2 = latent_project(dag2, :U)
id(admg2, :X, :Y)

\[\sum_{M} P(M \mid X) \left(\sum_{X'} P(X') P(Y \mid M, X')\right)\]

which is the standard front-door adjustment formula. The primed X' is a summation index distinct from the intervened value of X, following the usual convention.

In the following graph the causal effect cannot be identified:

admg
ADMG with 3 nodes and 3 edges:
  nodes: X, Y, Z
  edges:
    X --> Y, X <-> Y, Z --> X
id(admg, :X, :Y) === nothing
true

idc extends id to conditional effects $P(y \mid do(x), z)$:

idc(admg2, :X, :Y; given = :M)

\[\sum_{X'} P(X') P(Y \mid M, X')\]

Working with the result

The result is an immutable tree of Prob, Marginal, Product, and Quotient nodes, so it can be inspected or transformed programmatically:

e = id(dag4, :X, :Y)
(typeof(e), e.index, typeof.(e.term.terms))
(Marginal, [:Z], DataType[Prob, Prob])