Operations
Skeleton & subgraphs
CausalStructures.skeleton — Function
skeleton(cg::Union{DAG,AbstractPDAG}) -> UGReturn the skeleton of cg: the undirected graph obtained by replacing every directed or partially-directed edge with an undirected edge.
Examples
julia> dag = DAG("A --> B --> C");
julia> skeleton(dag)
UG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --- B, B --- CCausalStructures.subgraph — Function
subgraph(cg::CausalGraph, nodes) -> CausalGraphReturn the subgraph of cg induced by nodes: restricted to the given node set, keeping only edges whose both endpoints are in nodes.
nodes may be a single Symbol or an AbstractVector{Symbol}.
The return type matches cg for most classes, but two classes are downgraded, because the induced subgraph need not satisfy the stronger class invariant:
CPDAGsubgraphs are returned asMPDAG: removing a node can orphan a directed edge that was only strongly protected by that node, but the result is still Meek-closed and therefore a valid MPDAG.PAGsubgraphs are returned asUNKNOWN: the invariant marks of a Markov equivalence class are not preserved by node restriction, so the result need not be a valid PAG.
Examples
julia> dag = DAG("A --> B --> C, A --> C");
julia> sg = subgraph(dag, [:A, :B])
DAG with 2 nodes and 1 edge:
nodes: A, B
edges:
A --> BCausalStructures.moralize — Function
moralize(cg::Union{DAG,AbstractPDAG}) -> UGReturn the moral graph of cg. Connects every pair of directed parents that share a common child (adding a "marriage" edge), then replaces every edge with an undirected edge.
For AbstractPDAG, only directed parents participate in marriage edges; undirected neighbors are included in the skeleton but do not form a clique.
Examples
julia> dag = DAG("A --> C <-- B");
julia> moralize(dag) # A and B are now married (share child C)
UG with 3 nodes and 3 edges:
nodes: A, B, C
edges:
A --- C, B --- C, A --- B
julia> pdag = PDAG("A --> C <-- B, D --- C");
julia> moralize(pdag) # A married to B (co-directed-parents of C); D not married
UG with 4 nodes and 4 edges:
nodes: A, B, C, D
edges:
A --- C, B --- C, C --- D, A --- BPDAG orientation
CausalStructures.dag_from_pdag — Function
dag_from_pdag(cg::AbstractPDAG) -> DAGExtend cg to a consistent DAG by orienting all undirected edges, using the Dor-Tarsi algorithm.
The algorithm repeatedly finds a potential-sink node x (no directed children, whose undirected neighbors form a clique, and whose undirected neighbors are each adjacent to every existing parent of x), orients all undirected edges toward x, and removes it. Raises an error if no valid DAG extension exists. The last condition on x's existing parents ensures the extension has exactly the same v-structures as cg.
Examples
julia> pdag = PDAG("A --- B --- C");
julia> dag = dag_from_pdag(pdag)
DAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --> B, B --> CReferences
CausalStructures.dag_to_cpdag — Function
dag_to_cpdag(cg::DAG) -> CPDAGReturn the CPDAG representing the Markov equivalence class (MEC) of cg.
The algorithm detects v-structures (unshielded colliders) to determine compelled edge orientations, builds an initial PDAG from the skeleton, then applies meek_closure to propagate all implied orientations.
Examples
julia> dag = DAG("A --> B");
julia> dag_to_cpdag(dag)
CPDAG with 2 nodes and 1 edge:
nodes: A, B
edges:
A --- Bjulia> dag = DAG("C --> X, A --> X + Y, Y --> Z");
julia> dag_to_cpdag(dag)
CPDAG with 5 nodes and 4 edges:
nodes: A, C, X, Y, Z
edges:
A --- Y, C --> X, A --> X, Y --- ZCausalStructures.dag_to_mpdag — Function
dag_to_mpdag(cg::DAG, bk = BackgroundKnowledge()) -> MPDAGReturn the MPDAG representing the DAGs that are Markov equivalent to cg and consistent with the background knowledge bk. This is the CPDAG of cg with the bk orientations applied and closed under Meek's rules R1-R4.
bk may be a BackgroundKnowledge or a string accepted by its constructor. Raises an error if cg itself violates bk; this guarantees the restricted equivalence class is non-empty, since it contains cg.
Examples
julia> dag = DAG("A --> B --> C");
julia> dag_to_cpdag(dag)
CPDAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --- B, B --- C
julia> dag_to_mpdag(dag, "A --> B")
MPDAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --> B, B --> CReferences
CausalStructures.apply_background_knowledge — Function
apply_background_knowledge(cg::AbstractPDAG, bk) -> MPDAGOrient the edges of cg according to the background knowledge bk, then close under Meek's rules R1-R4 (see meek_closure), returning the MPDAG that represents the DAGs consistent with both cg and bk.
bk may be a BackgroundKnowledge or a string accepted by its constructor.
Per constraint, against the current state of the edge in cg:
| Constraint | A --- B | A --> B | B --> A | not adjacent |
|---|---|---|---|---|
required A --> B | orient A --> B | no-op | error | error |
forbidden A --> B | orient B --> A | error | no-op | no-op |
Errors are raised because background knowledge cannot add or remove adjacencies, only orient existing edges. The orientations are applied one at a time, each followed by Meek's rules; an error is also raised if a later one contradicts an orientation this implies, i.e. if bk is inconsistent with cg (e.g. A --> B, C --> B on A --- B --- C).
Examples
julia> cpdag = CPDAG("A --- B --- C");
julia> apply_background_knowledge(cpdag, "C --> B")
MPDAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
B --> A, C --> B
julia> apply_background_knowledge(cpdag, "C !--> B")
MPDAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --- B, B --> CReferences
CausalStructures.meek_closure — Function
meek_closure(cg::AbstractPDAG; check_cycles::Bool = true, r4::Bool = true) -> MPDAGApply Meek's orientation rules (R1-R4) to cg until no further orientations are implied, returning the resulting MPDAG.
The four rules are:
- R1:
a --> b --- c,anot adjacent toc–> orientb --> c - R2:
a --- b, directed patha --> w --> bexists –> orienta --> b - R3:
a --- b, two parentsc, dofbwithcnot adjacent tod, anda --- c,a --- d–> orienta --> b - R4:
a --- b,a --- c,cnot adjacent tob,aadjacent tod, and a directed pathc --> d --> bexists –> orienta --> b
Keyword arguments
check_cycles: whentrue(default), every candidate orientation is verified not to create a directed cycle before being applied. This check is redundant ifcgis a genuine Meek pattern – every directed edge already justified by an unshielded collider – since Meek's Theorem 3 then guarantees no cycle can arise. Potentially unsafe downstream, since it always returns an MPDAG without verifying acyclicity.r4: whether to apply R4 (defaulttrue). R4 is only needed whencgcarries directed edges beyond what its own v-structures imply (background knowledge).
Examples
julia> pdag = PDAG("A --> B --- C");
julia> result = meek_closure(pdag)
MPDAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --> B, B --> CReferences
Conditioning & marginalization
CausalStructures.latent_project — Function
latent_project(cg::DAG, latents) -> ADMGProject out latent (unobserved) variables from cg to produce an ADMG over the observed variables only.
latents may be a single Symbol or an AbstractVector{Symbol}.
Each latent node v is eliminated by node substitution: directed edges p --> c are added for every parent p and child c of v, and bidirected edges s <-> c are added for every sibling s (bidirected neighbor) and child c of v. All pairs of children of v also become bidirected-connected.
Examples
julia> dag = DAG("U --> X + Y, X --> Y");
julia> latent_project(dag, :U)
ADMG with 2 nodes and 2 edges:
nodes: X, Y
edges:
X --> Y, X <-> YCausalStructures.exogenize — Function
exogenize(cg::DAG, nodes) -> DAGReturn a copy of cg with each node in nodes made exogenous: all incoming edges to that node are removed, and its parents are connected directly to its children to preserve reachability.
nodes may be a single Symbol or an AbstractVector{Symbol}.
Examples
julia> dag = DAG("A --> B --> C");
julia> dag2 = exogenize(dag, :B)
DAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --> C, B --> CCausalStructures.normalize_latent_structure — Function
normalize_latent_structure(cg::DAG, latents) -> DAGNormalize the latent structure of cg while preserving the induced marginal model over the observed variables. Applies the following steps (Evans (2016), Lemmas 1-3):
- Exogenize all latent nodes (remove their incoming edges, rerouting through their parents).
- Remove latent nodes with at most one active child (they induce no confounding).
- Remove latent nodes whose child set is a strict subset of another latent node's child set.
latents may be a single Symbol or an AbstractVector{Symbol}.
Examples
julia> dag = DAG("A --> U --> X + Y");
julia> result = normalize_latent_structure(dag, :U)
DAG with 4 nodes and 4 edges:
nodes: A, U, X, Y
edges:
A --> X, A --> Y, U --> X, U --> YReferences
CausalStructures.condition_marginalize — Function
condition_marginalize(cg::Union{DAG,ADMG,AbstractAG};
given = Symbol[], index = Symbol[]) -> AGReturn the AG over the remaining nodes after conditioning on given and marginalizing out index, following Definition 4.2.1 of Richardson and Spirtes (2002).
given and index may each be a single Symbol or an AbstractVector{Symbol}.
Two remaining nodes are adjacent if and only if they cannot be m-separated by any subset of the other remaining nodes given given. The edge type is determined by the anterior relations: a --> b if a is anterior to b but not vice versa; a <-> b if neither is anterior to the other; a --- b if each is anterior to the other.
At least one of given or index must be non-empty, and they must be disjoint.
Examples
julia> dag = DAG("U --> X + Y");
julia> condition_marginalize(dag; index = :U)
AG with 2 nodes and 1 edge:
nodes: X, Y
edges:
X <-> Yjulia> admg = ADMG("U --> X + Y, X <-> Z, Y --> Z");
julia> condition_marginalize(admg; index = :U)
AG with 3 nodes and 3 edges:
nodes: X, Y, Z
edges:
X <-> Y, X <-> Z, Y --> Zjulia> mag = MAG("A <-> X, X --> C, Y --> C, A <-> Y");
julia> condition_marginalize(mag; index = :A)
AG with 3 nodes and 2 edges:
nodes: C, X, Y
edges:
X --> C, Y --> C
julia> condition_marginalize(mag; given = :A)
AG with 3 nodes and 3 edges:
nodes: C, X, Y
edges:
X --> C, Y --> C, X <-> YReferences
Enumeration
CausalStructures.enumerate_dags — Function
enumerate_dags(cg::AbstractPDAG) -> Vector{DAG}Enumerate every DAG in the Markov equivalence class (MEC) of cg.
Uses Chickering's (2002) recursive listing algorithm: applies Meek closure first to normalize cg, then branches on each undirected edge in lexicographic order, rejecting orientations that would introduce new v-structures or directed cycles, and propagating forced orientations via Meek's rules at each step.
Can call count_dags for sizing the problem before calling this function, as the number of DAGs in a MEC can be very large.
Examples
julia> pdag = PDAG("A --- B --- C");
julia> dags = enumerate_dags(pdag)
3-element Vector{DAG}:
DAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --> B, B --> C
DAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
B --> A, B --> C
DAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
B --> A, C --> BReferences
CausalStructures.count_dags — Function
count_dags(cg::AbstractPDAG) -> IntCount the number of DAGs in the Markov equivalence class (MEC) of cg, without materializing them. Useful for sizing the problem before calling enumerate_dags.
Unlike calling length(enumerate_dags(cg)), this shares the same Chickering recursion but skips building a DAG (edge list, backend, validation) at every leaf of the search, only incrementing a counter instead.
Examples
julia> pdag = PDAG("A --- B --- C");
julia> count_dags(pdag)
3CausalStructures.enumerate_mags — Function
enumerate_mags(cg::PAG; selection_bias::Bool = true) -> Vector{MAG}Enumerate every MAG in the Markov equivalence class represented by the PAG cg (as produced by mag_to_pag).
By default the result includes MAGs with selection bias, i.e. with undirected (---) edges. Pass selection_bias = false to keep only the MAGs without undirected edges, as assumed by e.g. pagcauses and backdoor_set on a PAG; this also prunes the search. If cg itself has an undirected edge, no such MAG exists and the result is empty.
Every member of the class shares cg's invariant (non-circle) endpoint marks, so each circle endpoint is independently resolved to a tail or an arrowhead. For a single representative instead of the whole class, use mag_from_pag.
Algorithm
This is a brute-force search. With k circle endpoints in the PAG, it iterates all 2^k tail/arrow assignments; for each it builds the candidate graph, validates it as a MAG (which itself runs an m-separation search for maximality), and keeps it only when mag_to_pag maps it back to cg. The cost is therefore $O(2^k)$ candidates times the per-candidate MAG validation, so it is exponential in the number of circle endpoints. The number of MAGs in a class can likewise be very large. Parallelizes over Threads.nthreads() once there are enough of them to be worth splitting across tasks.
Examples
julia> pag = PAG("A o-o B o-o C");
julia> length(enumerate_mags(pag))
8
julia> length(enumerate_mags(pag; selection_bias = false))
5References
MAG / PAG equivalence
CausalStructures.ag_to_mag — Function
ag_to_mag(cg::AG) -> MAGConvert an AG to a Markov equivalent MAG by adding edges between every pair of non-adjacent nodes that cannot be m-separated by any subset of the remaining nodes.
For each non-separable pair (u, v), the edge type is determined by the ancestor relationship in the current graph:
- u ancestor of v –> add u –> v
- v ancestor of u –> add v –> u
- Neither –> add u <-> v (bidirected)
Edges are added one at a time and the graph is re-evaluated after each addition, since each new edge can change m-separation and ancestor relationships.
Examples
julia> ag = AG("A --> B --> C");
julia> ag_to_mag(ag)
MAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --> B, B --> CReferences
CausalStructures.mag_to_pag — Function
mag_to_pag(cg::MAG) -> PAGReturn the Partial Ancestral Graph (PAG) representing the Markov equivalence class of the MAG cg.
The PAG has the same skeleton (adjacencies) as cg. Each endpoint carries an invariant mark: an arrowhead (>) or tail (-) when that mark is shared by every MAG in the equivalence class, and a circle (o) otherwise. Circle marks are represented with the partial (o-o), partially_directed (o->), and partially_undirected (o--) edge kinds.
The algorithm starts from the skeleton with all marks set to circles, orients unshielded colliders as they appear in cg, and then applies Zhang's complete orientation rules R1-R10 until no further mark is implied.
Examples
julia> mag = MAG("A --> B <-- C");
julia> mag_to_pag(mag)
PAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A o-> B, C o-> BReferences
CausalStructures.mag_from_pag — Function
mag_from_pag(cg::PAG) -> MAGReturn one MAG belonging to the Markov equivalence class represented by the PAG cg. This is a left inverse of mag_to_pag: mag_to_pag(mag_from_pag(pag)) recovers pag.
A PAG leaves some endpoints as circle marks (o) that are not invariant across the class. This resolves every circle into a tail or arrowhead, following Zhang's construction (2008, Theorem 2):
- A circle opposite a fixed mark always makes the edge directed. An
o->edge becomes-->(the circle turns into a tail), and ano--edge becomes a directed edge pointing out of the tail end (the circle turns into an arrowhead). - An undirected (
---) edge carries selection bias and is kept unchanged. - The remaining
o-oedges form a chordal component, oriented into a DAG with no new unshielded colliders by Dor-Tarsi simplicial elimination.
Examples
julia> pag = PAG("A o-> B <-o C");
julia> mag_from_pag(pag)
MAG with 3 nodes and 2 edges:
nodes: A, B, C
edges:
A --> B, C --> Bjulia> mag = MAG("A --- B --- C --- D --- A");
julia> mag_from_pag(mag_to_pag(mag))
MAG with 4 nodes and 4 edges:
nodes: A, B, C, D
edges:
A --- B, A --- D, B --- C, C --- DReferences
CausalStructures.maximal_local_mag — Function
maximal_local_mag(cg::PAG, x::Symbol, c) -> UNKNOWNReturn the maximal local MAG for the local structure c at x in cg.
c may be a single Symbol or an AbstractVector{Symbol}.
This is cg with x <-> v for v in c and x --> v for x's other circle-marked neighbors (Algorithm 1 of Wang et al. (2023)), treated as local background knowledge about x and closed under the corresponding rule set.
c should be a valid local structure, i.e. an entry of possible_local_structures(cg, x); passing an invalid one gives an unsound result. The returned graph may still contain circles (unlike a MAG, which is why it is returned as UNKNOWN rather than validated as a MAG or PAG): only what the local background knowledge about x implies is resolved, not necessarily everything.
Throws ArgumentError if cg has selection bias (undirected edges), since both source papers assume none throughout.
Examples
julia> mag = MAG("A <-> X, B --> X, A <-> B, X --> Y");
julia> pag = mag_to_pag(mag);
julia> maximal_local_mag(pag, :X, :A)
UNKNOWN with 4 nodes and 4 edges:
nodes: A, B, X, Y
edges:
A --> B, A o-> X, X --> B, X --> YReferences