5Structural Causal Models as Executable Mechanisms
Status: Draft
v0.6
5.1 Introduction
This chapter develops Structural Causal Models (SCMs) as executable mechanisms: a graph for structure, assignments for how each variable is generated, and exogenous variables for inputs whose generating causes lie outside the model (Pearl 2009; Peters et al. 2017). It builds on the graph patterns of Graph Theory and Causal Patterns. In these pages the working type is CausalDynamics.jl’s GraphSCM, with simulate_scm, do_intervention / apply_intervention, and compute_counterfactual for shared-\(U\) unit-level alternatives.
NotePackages implement the examples
This book’s framing is general: structure, dynamics, and observation for complex dynamical systems. Executable examples use a Julia stack with four clear owners:
DAGMakie.jl — DAG figures from the same graph objects
The book is not package documentation; manuals and changelogs live in the repositories. Published executable claims prefer released versions or a publicly pinned commit tested in the book environment; local tips and /dev/ pages alone are not reproducibility references.
Code keeps Pearl / targeted-learning names for identification and estimation (Intervention, do_surgery, backdoor_adjustment_set, run_lmtp_grid, run_mediation, …); process vocabulary (prehension, concrescence, occasion, enduring) stays in prose and in the Glossary. How declared time (support, value representation, referent, graph kind) maps to package fields is the subject of Chapter 9 and 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.
\(U\): Exogenous (usually unobserved) variables whose generating causes are outside the model
\(F\): Structural assignments (functions) encoding the causal mechanisms
\(P(U)\): Distribution over exogenous variables
The graph \(G\) encodes which directed dependencies exist; the assignments \(F\) encode how parents and exogenous variables generate each endogenous variable. The notation does not imply that every exogenous input is independent, memoryless, or mere measurement error.
5.3 Structural Assignments
Each endogenous variable \(X_i\) is assigned via a structural equation:
\[
X_i \coloneqq f_i(\mathrm{Pa}(X_i), U_i)
\]
where \(\mathrm{Pa}(X_i)\) are the parents of \(X_i\) in \(G\). The parent set specifies which variables enter the mechanism; \(f_i\) specifies the mapping.
5.4 Structural Equations as Mechanisms
Each structural equation encodes a causal mechanism from:
Parents \(\mathrm{Pa}(X_i)\) (direct causes under the assumed graph)
Exogenous variable \(U_i\) (an input whose generating causes are outside the model; it may represent stochasticity or a latent input)
5.4.1 Defining a graph and mechanisms
The toy DAG \(X \rightarrow Y \leftarrow Z\) has structural equations. In general,
\[
\begin{aligned}
X &\coloneqq U_X, \\
Z &\coloneqq U_Z, \\
Y &\coloneqq \beta_{XY}\, X + \beta_{ZY}\, Z + U_Y,
\end{aligned}
\]
and a numeric specialisation used below is \(\beta_{XY}=2\), \(\beta_{ZY}=3\).
# Find project root and include ensure_packages.jlproject_root =let current =pwd()while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml")) parent =dirname(current) parent == current &&break current = parentend currentendinclude(joinpath(project_root, "scripts", "ensure_packages.jl"))@auto_using DAGMakie CairoMakie CausalDynamics Graphs# Nodes: 1 = X, 2 = Y, 3 = Zg_fork =SimpleDiGraph(3)add_edge!(g_fork, 1, 2) # X → Yadd_edge!(g_fork, 3, 2) # Z → Yfork_labels = ["X", "Y", "Z"]equations_fork =Dict{Int, Function}(1=> (u) -> u, # X := U_X3=> (u) -> u, # Z := U_Z2=> (x, z, u) ->2x +3z + u, # Y := 2X + 3Z + U_Y (parents sorted))scm_fork =GraphSCM(g_fork, equations_fork, Set{Int}())U_fork =Dict(1=>1.0, 2=>0.1, 3=>2.0)vals_fork =simulate_scm(scm_fork, U_fork)println("Factual values: X=$(vals_fork[1]), Y=$(vals_fork[2]), Z=$(vals_fork[3])")
Factual values: X=1.0, Y=8.1, Z=2.0
Toy SCM \(X \rightarrow Y \leftarrow Z\). Nodes \(X\) and \(Z\) have empty parent sets in \(G\); \(Y\) is determined by both parents and \(U_Y\).
GraphSCM stores a DiGraph, a Dict{Int, Function} of assignments (parent values in index order, then that node’s exogenous draw), and an optional set of exogenous node indices. simulate_scm(scm, U) evaluates in topological order for one fixed realisation of \(U\).
5.5 Modularity Principle
Under intervention \(do(X_i = x)\), the assignment for \(X_i\) is replaced by a constant:
\[
X_i \coloneqq x \quad \text{(instead of } f_i(\mathrm{Pa}(X_i), U_i)\text{)}
\]
All other mechanisms remain unchanged. That modularity of causal mechanisms is the local-change semantics of \(do(\cdot)\)(Pearl 2009): the intervened node no longer depends on its natural parents, but descendants still depend on its fixed value.
5.5.1 Implementation: modularity under intervention
NoteFrom CausalDynamics.jl
The modularity principle above is not only prose: apply_interventionis that local change. Incoming edges to the intervened node are removed, and its structural equation becomes a constant assignment. Other equations are left untouched.
# packages/CausalDynamics.jl/src/scm/interventions.jl (excerpt)functionapply_intervention(scm::GraphSCM, intervention::DoIntervention) var = intervention.variable val = intervention.value node = var isa Int ? var :throw(ArgumentError("Symbol-based variable resolution not yet supported for GraphSCM. Use integer node indices.", ))# Mutilated graph: remove all incoming edges to the intervened node new_graph =copy(scm.graph)for parent incollect(Graphs.inneighbors(new_graph, node)) Graphs.rem_edge!(new_graph, parent, node)end# Replace only this node's equation with a constant new_equations =copy(scm.equations) new_equations[node] = (args...) -> valreturnGraphSCM(new_graph, new_equations, scm.exogenous)end
The book’s examples call this API; the excerpt is the mechanism itself. Full reference: CausalDynamics docs.
On the fork, \(do(X = 5)\) removes \(X\)’s natural assignment (and any incoming edges) while \(Y\)’s equation still uses the fixed \(X\) and \(Z\):
scm_do =apply_intervention(scm_fork, do_intervention(1, 5.0))vals_do =simulate_scm(scm_do, U_fork)println("Under do(X = 5): X=$(vals_do[1]), Y=$(vals_do[2]), Z=$(vals_do[3])")println("Parents of X after surgery: ", collect(inneighbors(scm_do.graph, 1)))println("Y still has parents X and Z: ", has_edge(scm_do.graph, 1, 2) &&has_edge(scm_do.graph, 3, 2))
Under do(X = 5): X=5.0, Y=16.1, Z=2.0
Parents of X after surgery: Int64[]
Y still has parents X and Z: true
5.6 Shared-\(U\) counterfactuals
Interventional queries ask what happens in a population under \(do(\cdot)\), typically averaging over \(U\). Unit-level counterfactuals use an explicitly declared cross-world coupling, commonly holding the same \(U\) fixed while changing the intervention (Pearl’s abduction–action–prediction). The process vocabulary gives this comparison an interpretive gloss, but does not alter its SCM semantics; see the glossary. CausalDynamics exposes that convention as compute_counterfactual.
The chain \(Z \rightarrow X \rightarrow Y\) makes the arithmetic transparent:
g_chain =DiGraph(3)add_edge!(g_chain, 1, 2) # Z → Xadd_edge!(g_chain, 2, 3) # X → Yequations_chain =Dict{Int, Function}(1=> (u) -> u,2=> (z, u) -> z + u,3=> (x, u) ->2x + u,)scm_chain =GraphSCM(g_chain, equations_chain, Set{Int}())U_chain =Dict(1=>1.0, 2=>0.5, 3=>-0.3)result =compute_counterfactual(scm_chain, do_intervention(2, 10.0), U_chain)println("Factual Y = ", result.factual[3])println("Counterfactual Y under do(X = 10) = ", result.counterfactual[3])println("Same U_Y in both worlds: exogenous draw for node 3 held at ", U_chain[3])
Factual Y = 2.7
Counterfactual Y under do(X = 10) = 19.7
Same U_Y in both worlds: exogenous draw for node 3 held at -0.3
Factual \(Y = 2(1.0 + 0.5) + (-0.3) = 2.7\); under \(do(X = 10)\) with the same \(U\), \(Y = 2\cdot 10 + (-0.3) = 19.7\). Chapter 7 develops the twin-network reading of this construction.
Chain \(Z \rightarrow X \rightarrow Y\) used for the shared-\(U\) counterfactual. Intervention \(do(X = 10)\) mutilates incoming edges to \(X\) while \(Y\) remains a child of \(X\).
5.7 Linear SCMs and matrix representations
When structural equations are linear, they can be written in matrix form, connecting the edge set of Chapter 3 to executable assignments. For a static linear SEM (Peters et al. 2017)
\[
\mathbf{X} = B\mathbf{X} + \mathbf{U},
\]
with \(B_{ij}\) the structural weight on \(X_j \rightarrow X_i\). Under a topological order \(B\) is strictly triangular; its support matches the edge set of \(G\). Time-indexed linear dynamics (Part II) use related sparse transition maps. Matrix analysis and sparse factorisation are the usual algebraic companions (Strang 2016; Horn and Johnson 2013; Golub and Van Loan 2013; Davis 2006).
Sparse dependencies.Chapter 2 counted how few of the \(n(n-1)\) possible directed dyads a scientific DAG retains; Chapter 3 closed on Chapter 1’s good-control triangle as path-labelled edges. Missing edges imply structural zeros in \(B\); present edges mark the only non-zeros that need storing, estimating, or simulating.
5.7.1 Algebraic path labels, then numbers
The confounding DAG below is isomorphic to Chapter 1’s \(C \rightarrow A \rightarrow Y\), \(C \rightarrow Y\) (and to Chapter 3’s closing figure), written here as \(X_1 \rightarrow X_2 \rightarrow X_3\), \(X_1 \rightarrow X_3\) so that GraphSCM can simulate a numeric instance: first structural path coefficients, then filled weights.
coefficient_matrix_grid! (generic function with 1 method)
General linear confounding / mediation triangle: structural path coefficients on the DAG, symbolic \(B\), and assignments. \(B_{ij}\neq 0\) means \(X_j\rightarrow X_i\)(Peters et al. 2017).
Numeric instance \(\beta_{21}=0.5\), \(\beta_{31}=0.3\), \(\beta_{32}=0.7\), executed by GraphSCM / simulate_scm for \(U=(1,0,0)\).
Where the sign of a linear term is part of the declared mechanism, the same matrix can also distinguish an inhibitory local relation in the diagram. This is a statement about the displayed assignment, not about a total causal effect: other directed paths may reinforce or offset it. In Figure 5.1, the negative \(B_{32}\) term means that greater immune response reduces parasite burden, conditional on the other parents in the linear assignment.
Figure 5.1: Declared signed linear mechanisms: nutrient availability increases immune response and parasite burden, while immune response has a negative local coefficient on parasite burden. The -| terminal marks that negative structural term; it does not identify a total effect.
Graphs.jl stores the binary edge indicator as \(A_{ij}=1\) for \(i\rightarrow j\); the SEM matrix stores \(B_{ji}\neq 0\) for that edge, so unit-weight support is \(B = A^\top\). Chapter 2’s combinatorial sparsity becomes a concrete footprint once \(n\) is large.
Figure 5.2: Index convention: Graphs.jl adjacency \(A\) (\(A_{ij}=1\) means \(i\rightarrow j\)) versus unit-weight SEM support \(B=A^\top\) (\(B_{ij}=1\) means \(j\rightarrow i\)). Weighted \(B\) above fills this support with coefficients.
rng =StableRNG(3)g_large =SimpleDiGraph(100)whilene(g_large) <200 s =rand(rng, 1:100) d =rand(rng, 1:100)if s != dadd_edge!(g_large, s, d)endendprintln("Confounding triangle: nnz(B) = ", nnz(sparse(B_lin)), " of ", length(B_lin))println("100-node draw with ", ne(g_large), " edges")println(" dense Float64 footprint ≈ ", 100*100*sizeof(Float64), " bytes")println(" sparse nnz = ", nnz(sparse(adjacency_matrix(g_large))))
5.7.2 Executable SCMs at scale: GraphPPL and latent interfaces
For high-dimensional observations (e.g. MIRS spectra Y), the SCM is still defined over structural nodes, but the inference layer should not duplicate thousands of wavenumbers inside a PPL. One workable pattern (AgeSCM; Chapters 28b and 30):
Identification / graph: DAG and adjustment sets (CausalDynamics helpers such as prepare_for_rxinfer, prepare_for_tmle).
Encoder: e.g. Flux ẑ = g(Y; nuisances residualised).
Probabilistic programme: GraphPPL / RxInfer @model on ẑ and the outcome of interest, not on raw Y (live wiring in Chapter 30; poultry ATE in Chapter 5).
Interventions remain structural; the encoder is a learned observation mechanism whose outputs feed the PPL.
5.8 Stratum context
An intervention is a local change to a data-generating process while other mechanisms remain defined. Identification of what can be learned from data is the subject of Chapter 5; time-indexed CDMs extend the same modular idea across occasions in Part II and Chapter 28.
5.9 Summary
An SCM pairs a DAG with structural assignments and exogenous variables. GraphSCM makes that tuple executable: simulate_scm settles one unit for fixed \(U\), apply_intervention implements modularity by mutilating parents of the intervened node, and compute_counterfactual compares factual and alternative outcomes under shared \(U\). Linear maps \(\mathbf{X}=B\mathbf{X}+\mathbf{U}\) carry Chapter 3’s edge set into coefficients and simulation (with \(A\) versus \(B^\top\) as the index convention); at high dimension, keep the PPL on a low-dimensional structural interface rather than raw high-dimensional measurements.
Peters et al. (2017): Elements of Causal Inference — SCM framework
Chernozhukov et al. (2024): Applied Causal Inference Powered by ML and AI, Chapters 6–7 (linear and nonlinear SEMs with DAGs; intervention and counterfactual graphs)
Chernozhukov, Victor, Christian Hansen, Nathan Kallus, Martin Spindler, and Vasilis Syrgkanis. 2024. Applied Causal Inference Powered by ML and AI. https://doi.org/10.48550/arXiv.2403.02467.