# Find project root and include ensure_packages.jl
project_root = let
current = pwd()
while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
parent = dirname(current)
parent == current && break
current = parent
end
current
end
include(joinpath(dirname(Base.active_project()), "scripts", "book_bootstrap.jl"))
@auto_using DAGMakie CairoMakie Graphs
# Pattern 1: Chain A → B → C
g_chain = SimpleDiGraph(3)
add_edge!(g_chain, 1, 2) # A → B
add_edge!(g_chain, 2, 3) # B → C
# Pattern 2: Fork A ← B → C
g_fork = SimpleDiGraph(3)
add_edge!(g_fork, 2, 1) # B → A
add_edge!(g_fork, 2, 3) # B → C
# Pattern 3: Collider A → B ← C
g_collider = SimpleDiGraph(3)
add_edge!(g_collider, 1, 2) # A → B
add_edge!(g_collider, 3, 2) # C → B
# Visualise all three patterns
let
# Taller figure so DataAspect panels have room for node markers on flat chains
fig = Figure(size = (1200, 480))
ax1 = Axis(fig[1, 1], title = "Chain")
ax2 = Axis(fig[1, 2], title = "Fork")
ax3 = Axis(fig[1, 3], title = "Collider")
dag_kw = (
layout_mode = :acyclic,
node_color = RGBf(0.30, 0.55, 0.70),
node_size = 34,
edge_width = 2.0,
arrow_size = 14,
nlabels_fontsize = 16,
nlabels_color = :white,
nlabels_align = (:center, :center),
nlabels_distance = 0,
auto_align_labels = false,
padding = 0.35,
)
# Chain: A → B → C
dagplot!(ax1, g_chain; dag_kw..., nlabels = ["A", "B", "C"])
# Fork: A ← B → C
dagplot!(ax2, g_fork; dag_kw..., nlabels = ["A", "B", "C"])
# Collider: A → B ← C
dagplot!(ax3, g_collider; dag_kw..., nlabels = ["A", "B", "C"])
fig # Only this gets displayed
end5 Graph Theory and Causal Patterns
v0.4
5.1 Introduction
This chapter establishes graph theory as the foundation of the Structural layer: how we represent causal structure as a directed graph. We build from the dyad (Chapter 2) to complex graph structures, showing how much causal reasoning can be reduced to properties of paths and conditioning sets.
This book’s framing is general: structure, dynamics, and observation for complex dynamical systems. Executable examples use a Julia stack, CausalDynamics.jl (identify), CausalTargeted.jl (estimate LMTP / mediation), DAGMakie.jl (display). The book is not package documentation; manuals and changelogs live in the repositories.
Code keeps standard Pearl / targeted-learning names (Intervention, do_surgery, backdoor_adjustment_set, run_lmtp_grid, …). Prose may use a sparse process gloss where it helps: prehension (taking account of), shared \(U\) as creative advance (Introduction), L3 alternative concrescences, and Chapter 9’s organism / environment / Markov blanket / society vocabulary for CDM scope (\(\mu\), \(\eta\), \(s\), \(a\), \(G\), \(F\)). Prefer ordinary words (observation, intervention, time step) elsewhere. Mapping: Concept Reference, Tables 4 and 8.
Package source as notation. Where the implementation is the explanation (graph surgery, path blocking, shared-\(U\) counterfactuals), chapters may show a short, syntax-highlighted excerpt from the owned packages in a “From …jl” callout, alongside executed API chunks. Those excerpts are curated under snippets/package-source/; they are not a substitute for the Documenter manuals.
Key principle: All complex structures are combinations of dyads. The graph structure \(G = (V, E)\) is composed entirely of dyads (two nodes connected by one directed edge). Complex causal structures emerge from how dyads connect and overlap.
5.2 Graph Theory and Directed Dependencies
5.2.1 Building from Dyads
As established in The Primary Unit: The Dyad, a dyad consists of two nodes connected by a single directed edge: \(X \rightarrow Y\). This is the minimal unit of directed dependence in a causal graph.
Key principles:
- One dyad = one directed dependence
- The dyad encodes a local mechanism, typically written as \(Y \coloneqq f(X, U)\)
- Complex structures are combinations of dyads
- Graph structure = collection of dyads (two nodes, one edge each)
5.2.2 Graphs as Structural Structure
In a structural causal model, we have \(\mathcal{M} = (G, U, F, P(U))\) where \(G\) is a directed acyclic graph (DAG) representing causal structure (Pearl 2009; Spirtes et al. 2000). Formally, a graph is defined as \(G = (V, E)\) where:
- \(V\) is the set of vertices (nodes / variables)
- \(E\) is the set of edges (directed pairs) encoding direct dependencies assumed by the model
Each edge in \(E\) represents a dyad. The graph structure emerges from how these dyads connect.
5.2.3 Why Directed Acyclic Graphs?
DAGs are natural in many causal problems because:
- Direction: causal influence is directional. An edge \(A \rightarrow B\) means \(A\) is a parent of \(B\).
- Acyclicity: acyclicity rules out directed feedback within the structural graph (useful for many identification results).
- Parent sets: the parent set \(\text{Pa}(X_i)\) lists the direct inputs to the mechanism for \(X_i\).
5.2.4 The Three Fundamental Causal Patterns
The three fundamental patterns in causal graphs are all combinations of two dyads:
5.2.4.1 Chain: Two Sequential Dyads
Pattern: \(A \rightarrow B \rightarrow C\) (two sequential dyads)
- Dyad 1: \(A \rightarrow B\)
- Dyad 2: \(B \rightarrow C\)
Meaning: A mediating chain. This creates a directed path of influence.
d-separation: \(A\) and \(C\) are dependent marginally, but independent conditional on \(B\) (the mediator blocks the path).
5.2.5 Implementation: Visualising the Three Fundamental Patterns
We can visualise and verify the three fundamental patterns using Graphs.jl and GraphMakie:
5.2.5.1 Fork: Two Dyads from Common Source
Pattern: \(A \leftarrow B \rightarrow C\) (two dyads from common source)
- Dyad 1: \(B \rightarrow A\)
- Dyad 2: \(B \rightarrow C\)
Meaning: Common cause.
d-separation: \(A\) and \(C\) are dependent marginally (they share a common cause \(B\)), but independent conditional on \(B\) (conditioning on the common cause blocks the path).
5.2.5.2 Collider: Two Dyads Converging
Pattern: \(A \rightarrow B \leftarrow C\) (two dyads converging)
- Dyad 1: \(A \rightarrow B\)
- Dyad 2: \(C \rightarrow B\)
Meaning: Convergence (a collider).
d-separation: \(A\) and \(C\) are independent marginally (no direct connection), but dependent conditional on \(B\) (conditioning on the collider opens the path, the “explaining away” pattern).
5.2.5.3 M-bias
When each parent of a collider is tied to an unobserved common cause of an exposure or an outcome, the structure is often called M-bias (Pearl 2009). The five-node graph below has latents \(U_1\) and \(U_2\) and collider \(M\):
\[ X \leftarrow U_1 \rightarrow M \leftarrow U_2 \rightarrow Y. \]
\(X\) and \(Y\) are independent unconditionally. Conditioning on \(M\) opens the path through the collider and induces a spurious association. The practical warning follows at once: do not adjust for a collider (or its descendant) when estimating the effect of \(X\) on \(Y\).
dagplot_m_bias).5.2.6 d-Separation: Connecting Graph Structure to Conditional Independence
d-separation is a graph-theoretic criterion that connects graph structure to conditional independence (Pearl 2009). Two nodes \(X\) and \(Y\) are d-separated by a set \(Z\) if all paths between \(X\) and \(Y\) are blocked by \(Z\).
Path blocking rules (based on the three patterns):
- Chain: \(A \rightarrow B \rightarrow C\): Path is blocked if \(B \in Z\)
- Fork: \(A \leftarrow B \rightarrow C\): Path is blocked if \(B \in Z\)
- Collider: \(A \rightarrow B \leftarrow C\): Path is blocked if \(B \notin Z\) and no descendant of \(B\) is in \(Z\)
Why d-separation matters: If \(X\) and \(Y\) are d-separated by \(Z\) in the graph, then \(X ⫫ Y \mid Z\) in any probability distribution compatible with the graph structure.
5.2.7 Implementation: d-Separation Examples
We can verify d-separation properties in code:
# Find project root and include ensure_packages.jl
project_root = let
current = pwd()
while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
parent = dirname(current)
parent == current && break
current = parent
end
current
end
include(joinpath(project_root, "scripts", "ensure_packages.jl"))
@auto_using CausalDynamics Graphs
# Example 1: Chain A → B → C
# A and C are d-separated by B (mediator blocks the path)
g_chain = SimpleDiGraph(3)
add_edge!(g_chain, 1, 2) # A → B
add_edge!(g_chain, 2, 3) # B → C
println("Chain pattern: A → B → C")
println("A ⫫ C | B: ", CausalDynamics.d_separated(g_chain, 1, 3, [2])) # true: blocked by B
println("A ⫫ C: ", CausalDynamics.d_separated(g_chain, 1, 3, [])) # false: path exists when not conditioning
# Example 2: Fork A ← B → C
# A and C are d-separated by B (common cause blocks the path)
g_fork = SimpleDiGraph(3)
add_edge!(g_fork, 2, 1) # B → A
add_edge!(g_fork, 2, 3) # B → C
println("\nFork pattern: A ← B → C")
println("A ⫫ C | B: ", CausalDynamics.d_separated(g_fork, 1, 3, [2])) # true: blocked by B
println("A ⫫ C: ", CausalDynamics.d_separated(g_fork, 1, 3, [])) # false: path exists when not conditioning
# Example 3: Collider A → B ← C
# A and C are independent marginally, but dependent when conditioning on B
g_collider = SimpleDiGraph(3)
add_edge!(g_collider, 1, 2) # A → B
add_edge!(g_collider, 3, 2) # C → B
println("\nCollider pattern: A → B ← C")
println("A ⫫ C: ", CausalDynamics.d_separated(g_collider, 1, 3, [])) # true: no path (collider blocks)
println("A ⫫ C | B: ", CausalDynamics.d_separated(g_collider, 1, 3, [2])) # false: conditioning on collider opens path
# Example 4: More complex graph
# Z → X → Y ← W, with Z → W
# Check d-separation of X and Y given different sets
g_complex = SimpleDiGraph(4)
add_edge!(g_complex, 1, 2) # Z → X
add_edge!(g_complex, 2, 3) # X → Y
add_edge!(g_complex, 4, 3) # W → Y
add_edge!(g_complex, 1, 4) # Z → W
println("\nComplex graph: Z → X → Y ← W, Z → W")
println("X ⫫ Y: ", CausalDynamics.d_separated(g_complex, 2, 3, [])) # false: direct path X → Y
println("X ⫫ Y | W: ", CausalDynamics.d_separated(g_complex, 2, 3, [4])) # false: still direct path
println("Z ⫫ Y | X: ", CausalDynamics.d_separated(g_complex, 1, 3, [2])) # true: X blocks path Z → X → YChain pattern: A → B → C
A ⫫ C | B: true
A ⫫ C: false
Fork pattern: A ← B → C
A ⫫ C | B: true
A ⫫ C: false
Collider pattern: A → B ← C
A ⫫ C: true
A ⫫ C | B: false
Complex graph: Z → X → Y ← W, Z → W
X ⫫ Y: false
X ⫫ Y | W: false
Z ⫫ Y | X: false
5.2.8 Sparse Matrices: The Computational Bridge
Graph theory, linear algebra, and structural equations meet in a sparse matrix. For a static linear SCM the usual compact form is (Peters et al. 2017)
\[ \mathbf{X} = B\mathbf{X} + \mathbf{U}, \]
or equivalently \(\mathbf{X} = (I - B)^{-1}\mathbf{U}\) when \(I-B\) is invertible. The convention used here (and in much of the linear-SEM literature) is
\[ B_{ij} = \text{coefficient of $X_j$ in the equation for $X_i$} \quad\Longleftrightarrow\quad \text{edge } X_j \rightarrow X_i. \]
So the support of \(B\) (where \(B_{ij}\neq 0\)) is exactly the edge set of \(G\), written as a matrix. Most entries are zero: that is sparsity as modelling assumption and as computational fact. Time-indexed linear dynamics use the same idea with a transition map \(F\) in \(\mathbf{X}_{t+1} = F\mathbf{X}_t + \cdots\) (Chapter 4).
For the linear-algebra side of this bridge, the usual reference ladder is (Strang 2016) for foundations, (Horn and Johnson 2013) for matrix analysis proper, (Golub and Van Loan 2013) for numerical matrix computations, and (Davis 2006) for sparse direct methods (the algorithmic home of compressed adjacency and factorisation).
A standard pedagogical triple is therefore:
DAG (which edges) \(\;\longleftrightarrow\;\) matrix \(B\) (which structural parameters) \(\;\longleftrightarrow\;\) structural equations (the mechanisms).
Start with the general confounding DAG \(Z\rightarrow X\rightarrow Y\), \(Z\rightarrow Y\), labelled by structural path coefficients \(\beta_{ZX}\), \(\beta_{XY}\), and \(\beta_{ZY}\) (Wright’s notation; the mediated path is the product \(\beta_{ZX}\beta_{XY}\)). The numeric example below is the same graph with concrete values filled in (Peters et al. 2017).
project_root = let
current = pwd()
while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
parent = dirname(current)
parent == current && break
current = parent
end
current
end
include(joinpath(project_root, "scripts", "ensure_packages.jl"))
@auto_using DAGMakie CairoMakie Graphs SparseArrays StableRNGs
# Nodes: 1=Z, 2=X, 3=Y — Z → X → Y, Z → Y
g_conf = SimpleDiGraph(3)
add_edge!(g_conf, 1, 2)
add_edge!(g_conf, 2, 3)
add_edge!(g_conf, 1, 3)
labels_conf = ["Z", "X", "Y"]
# Graphs.edges order: 1→2, 1→3, 2→3
elabels_alg = structural_edge_labels(
g_conf,
["\\beta_{ZX}", "\\beta_{ZY}", "\\beta_{XY}"];
latex = true,
)
# Display matrix: B[i,j] on parent j → child i (zeros as "0")
B_alg_display = [
"0" "0" "0";
"\\beta_{ZX}" "0" "0";
"\\beta_{ZY}" "\\beta_{XY}" "0";
]
"""Draw an unshaded grid of matrix entries (numeric or TeX strings).
Non-zero / non-\"0\" entries are bold. Axis defaults match the SEM convention
B[i,j] = weight on parent j → child i.
"""
function coefficient_matrix_grid!(
ax, B, tick_labels;
digits = 1,
fontsize = 18,
xlabel = "parent j",
ylabel = "child i",
)
n = size(B, 1)
for i in 1:n, j in 1:n
poly!(ax, Rect(j - 0.5, i - 0.5, 1, 1);
color = :white, strokecolor = :black, strokewidth = 1.25)
entry = B[i, j]
if entry isa Real
text_str = string(round(entry; digits = digits))
is_zero = iszero(entry)
label = text_str
else
text_str = String(entry)
is_zero = text_str == "0" || text_str == "0.0"
label = occursin(r"[\\^_{}]", text_str) ? Makie.latexstring(text_str) : text_str
end
text!(ax, j, i;
text = label,
align = (:center, :center),
color = :black,
fontsize = fontsize,
font = is_zero ? :regular : :bold,
)
end
ax.xticks = (1:n, tick_labels)
ax.yticks = (1:n, tick_labels)
ax.xlabel = xlabel
ax.ylabel = ylabel
ax.yreversed = true
ax.aspect = DataAspect()
xlims!(ax, 0.5, n + 0.5)
ylims!(ax, 0.5, n + 0.5)
return ax
endMain.Notebook.coefficient_matrix_grid!
A numeric specialisation of the same graph replaces each \(\beta\) by a concrete weight.
# B[i,j] = coeff of X_j in equation for X_i (edge j → i)
B_conf = [
0.0 0.0 0.0; # Z := U_Z
0.8 0.0 0.0; # X := 0.8 Z + U_X
0.5 1.2 0.0; # Y := 0.5 Z + 1.2 X + U_Y
]3×3 Matrix{Float64}:
0.0 0.0 0.0
0.8 0.0 0.0
0.5 1.2 0.0
The three fundamental patterns from earlier in the chapter have the same bridge: each missing edge is a structural zero in \(B\).
# Chain / fork / collider on labels A,B,C (nodes 1,2,3)
g_ch = SimpleDiGraph(3); add_edge!(g_ch, 1, 2); add_edge!(g_ch, 2, 3)
B_ch = [0.0 0.0 0.0; 0.7 0.0 0.0; 0.0 0.9 0.0] # A→B→C
g_fk = SimpleDiGraph(3); add_edge!(g_fk, 2, 1); add_edge!(g_fk, 2, 3)
B_fk = [0.0 0.6 0.0; 0.0 0.0 0.0; 0.0 0.6 0.0] # A←B→C
g_co = SimpleDiGraph(3); add_edge!(g_co, 1, 2); add_edge!(g_co, 3, 2)
B_co = [0.0 0.0 0.0; 0.5 0.0 0.8; 0.0 0.0 0.0] # A→B←C
pattern_pack = (
("Chain A→B→C", g_ch, B_ch, ["A", "B", "C"]),
("Fork A←B→C", g_fk, B_fk, ["A", "B", "C"]),
("Collider A→B←C", g_co, B_co, ["A", "B", "C"]),
)(("Chain A→B→C", SimpleDiGraph{Int64}(2, [[2], [3], Int64[]], [Int64[], [1], [2]]), [0.0 0.0 0.0; 0.7 0.0 0.0; 0.0 0.9 0.0], ["A", "B", "C"]), ("Fork A←B→C", SimpleDiGraph{Int64}(2, [Int64[], [1, 3], Int64[]], [[2], Int64[], [2]]), [0.0 0.6 0.0; 0.0 0.0 0.0; 0.0 0.6 0.0], ["A", "B", "C"]), ("Collider A→B←C", SimpleDiGraph{Int64}(2, [[2], Int64[], [2]], [Int64[], [1, 3], Int64[]]), [0.0 0.0 0.0; 0.5 0.0 0.8; 0.0 0.0 0.0], ["A", "B", "C"]))
The same confounding graph makes the index convention concrete: Graphs.jl stores edges as \(A_{ij}=1\) for \(i\rightarrow j\), while the SEM matrix stores \(B_{ji}\neq 0\) for that edge. Visually, the support of \(B\) is the transpose of \(A\) (when edge weights are set to one).
A_conf = Float64.(Matrix(adjacency_matrix(g_conf)))
# Unit-weight SEM matrix: B_unit[i,j] = 1 iff edge j → i
B_unit = Matrix(transpose(A_conf))3×3 Matrix{Float64}:
0.0 0.0 0.0
1.0 0.0 0.0
1.0 1.0 0.0
5.2.9 Implementation: adjacency sparsity at scale
Graphs.adjacency_matrix uses the opposite index convention for the binary edge indicator (\(A_{ij}=1\) means edge \(i\rightarrow j\)). The support still matches \(B^\top\) for a linear SEM written as above. Sparse storage matters once \(n\) is large and \(|E|\ll n^2\).
project_root = let
current = pwd()
while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
parent = dirname(current)
parent == current && break
current = parent
end
current
end
include(joinpath(project_root, "scripts", "ensure_packages.jl"))
@auto_using Graphs SparseArrays StableRNGs
# Same confounding topology as the figure (nodes Z,X,Y)
g = SimpleDiGraph(3)
add_edge!(g, 1, 2)
add_edge!(g, 2, 3)
add_edge!(g, 1, 3)
A = adjacency_matrix(g)
println("Graphs.jl adjacency A (A[i,j]=1 ⇒ i→j):")
println(Matrix(A))
println("SEM coefficient support is A': non-zeros mark parents in each equation.")
println("nnz(A) = ", nnz(sparse(A)), " of ", length(A))
rng = StableRNG(3)
g_large = SimpleDiGraph(100)
while ne(g_large) < 200
s = rand(rng, 1:100)
d = rand(rng, 1:100)
if s != d
add_edge!(g_large, s, d)
end
end
A_large = adjacency_matrix(g_large)
println("\n100-node draw with ", ne(g_large), " edges:")
println(" dense Float64 footprint (approx): ", 100 * 100 * sizeof(Float64), " bytes")
println(" sparse nnz: ", nnz(sparse(A_large)))Graphs.jl adjacency A (A[i,j]=1 ⇒ i→j):
[0 1 1; 0 0 1; 0 0 0]
SEM coefficient support is A': non-zeros mark parents in each equation.
nnz(A) = 3 of 9
100-node draw with 200 edges:
dense Float64 footprint (approx): 80000 bytes
sparse nnz: 200
5.2.10 Markov Boundary: The Minimal Sufficient Set
The Markov boundary of a node \(X\) is the minimal set of nodes that makes \(X\) conditionally independent of all other nodes (Pearl 2009). It consists of:
- Parents of \(X\): Direct causes
- Children of \(X\): Direct effects
- Other parents of children: Spouses (other direct causes of \(X\)’s children)
The Markov boundary provides a principled criterion for determining the minimal set of variables needed for causal reasoning about \(X\).
5.2.11 Implementation: Computing Markov Boundary
We can compute the Markov boundary using graph structure. The Markov boundary of a node consists of its parents, children, and other parents of children (spouses):
# Find project root and include ensure_packages.jl
project_root = let
current = pwd()
while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
parent = dirname(current)
parent == current && break
current = parent
end
current
end
include(joinpath(project_root, "scripts", "ensure_packages.jl"))
@auto_using DAGMakie CairoMakie Graphs
# Example graph: Z → X → Y ← W, with Z → W
# For node X, Markov boundary should include:
# - Parents: Z
# - Children: Y
# - Spouses: W (other parent of Y)
g = SimpleDiGraph(4)
add_edge!(g, 1, 2) # Z → X
add_edge!(g, 2, 3) # X → Y
add_edge!(g, 4, 3) # W → Y
add_edge!(g, 1, 4) # Z → W
function markov_boundary(g::AbstractGraph, node::Integer)
"""
Compute the Markov boundary of a node in a directed graph.
The Markov boundary consists of:
- Parents: nodes with edges into the node
- Children: nodes with edges from the node
- Spouses: other parents of the node's children
"""
# Parents: nodes with edges into node
parents = Vector{Int}()
for src in 1:nv(g)
if has_edge(g, src, node)
push!(parents, src)
end
end
# Children: nodes with edges from node
children = Vector{Int}()
for dst in 1:nv(g)
if has_edge(g, node, dst)
push!(children, dst)
end
end
# Spouses: other parents of children
spouses = Set{Int}()
for child in children
for parent in 1:nv(g)
if has_edge(g, parent, child) && parent != node
push!(spouses, parent)
end
end
end
return (parents=parents, children=children, spouses=collect(spouses),
boundary=sort(unique([parents; children; collect(spouses)])))
end
# Compute Markov boundary for X (node 2)
mb = markov_boundary(g, 2)
println("Markov boundary for X (node 2):")
println(" Parents: ", mb.parents) # [1] = Z
println(" Children: ", mb.children) # [3] = Y
println(" Spouses: ", mb.spouses) # [4] = W
println(" Full boundary: ", mb.boundary) # [1, 3, 4] = Z, Y, W
# Visualise graph with Markov boundary highlighted
let
node_colors = [
RGBf(0.85, 0.65, 0.05), # Z (parent)
RGBf(0.20, 0.55, 0.45), # X (Markov boundary focus)
RGBf(0.30, 0.55, 0.70),
RGBf(0.30, 0.55, 0.70),
]
fig, ax, p = dagplot(g;
figure_size = (800, 600),
layout_mode = :acyclic,
node_color = node_colors,
nlabels = ["Z", "X", "Y", "W"],
node_size = 40,
edge_width = 2.0,
arrow_size = 15,
nlabels_fontsize = 16,
nlabels_color = :white,
nlabels_align = (:center, :center),
nlabels_distance = 0,
auto_align_labels = false,
padding = 0.40,
)
fig # Only this gets displayed
endMarkov boundary for X (node 2):
Parents: [1]
Children: [3]
Spouses: [4]
Full boundary: [1, 3, 4]
5.3 Stratum context
This chapter addresses Seeing in the Structural layer: what can we infer about causal structure from conditional independences and graph properties? Graph theory provides the mathematical foundation for representing and reasoning about structure. Many concepts here are abstract and not tied to a particular time index:
- Graph structure: assumed causal structure
- d-separation: implied conditional independences
- Sparse matrices: computational structure induced by sparse dependencies
- Markov boundary: minimal sufficient sets for prediction/adjustment
All complex structures are built from combinations of dyads, and graph theory provides the language for understanding these structures.
5.4 Summary
Graph theory supplies the language for Seeing in the Structural layer: dyads compose into chains, forks, and colliders; d-separation links those patterns to conditional independence; M-bias shows how conditioning on a collider can invent dependence; sparse coefficient matrices \(B\) carry the same edge set into linear algebra; and the Markov boundary names a minimal sufficient set for local reasoning about a node.
5.5 Further Reading
- Pearl (2009): Causality: Graph theory and d-separation
- Spirtes et al. (2000): Causation, Prediction, and Search: Graph-theoretic causal discovery
- Chernozhukov et al. (2024): Applied Causal Inference Powered by ML and AI, Chapters 6–7 and 11 (SEMs to DAGs, d-separation and backdoor, good and bad controls)
- Strang (2016): Introduction to Linear Algebra: Foundations for reading graphs as matrices (course site)
- Horn and Johnson (2013): Matrix Analysis: The standard analytic reference for eigenvalues, norms, and canonical forms (DOI)
- Golub and Van Loan (2013): Matrix Computations: The numerical linear-algebra bible (DOI)
- Davis (2006): Direct Methods for Sparse Linear Systems: Sparse storage, ordering, and factorisation (DOI)
- Structural Causal Models as Executable Mechanisms: How graphs are encoded in SCMs