Causal Identification

See the Causal Identification guide for worked examples.

Adjustment

CausalStructures.adjustment_set — Function
adjustment_set(cg::ADMG, x, y) -> Union{Nothing,Vector{Symbol}}

Return a valid inclusion-minimal adjustment set for the causal effect of x on y in cg, or nothing if none exists.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Unlike the DAG/AbstractPDAG methods of adjustment_set, this method takes no type keyword.

Examples

julia> admg = ADMG("L --> X --> Y, L --> Y");

julia> adjustment_set(admg, :X, :Y)
1-element Vector{Symbol}:
 :L

julia> admg2 = ADMG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> sort(adjustment_set(admg2, [:X1, :X2], :Y))
2-element Vector{Symbol}:
 :L1
 :L2

julia> admg3 = ADMG(directed(:X, :Y), bidirected(:X, :Y));  # direct edge plus latent confounder

julia> adjustment_set(admg3, :X, :Y) === nothing
true

References

source
adjustment_set(cg::AbstractAG, x, y) -> Union{Nothing,Vector{Symbol}}

Return a inclusion-minimal valid adjustment set for the causal effect of x on y in cg, or nothing if none exists.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Unlike the DAG/AbstractPDAG methods of adjustment_set, this method takes no type keyword.

Examples

julia> mag = MAG("A <-> X, A --> M --> Y, X --> Y");

julia> adjustment_set(mag, :X, :Y)
1-element Vector{Symbol}:
 :A

julia> mag2 = MAG("A <-> X1, B <-> X2, A --> M1 --> Y, B --> M2 --> Y, X1 --> Y, X2 --> Y");

julia> sort(adjustment_set(mag2, [:X1, :X2], :Y))
2-element Vector{Symbol}:
 :A
 :B

julia> mag3 = MAG(directed(:X, :Y));  # invisible edge: no witness rules out a latent confounder

julia> adjustment_set(mag3, :X, :Y) === nothing
true

References

source
adjustment_set(cg::PAG, x, y) -> Union{Nothing,Vector{Symbol}}

Return a inclusion-minimalvalid adjustment set for the causal effect of x on y in cg, or nothing if none exists.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Unlike the DAG/AbstractPDAG methods of adjustment_set, this method takes no type keyword.

Examples

julia> mag = MAG("B --> X, A <-> X, A --> Y, X --> Y");

julia> pag = mag_to_pag(mag);

julia> adjustment_set(pag, :X, :Y)
1-element Vector{Symbol}:
 :A

julia> mag2 = MAG(
           "B1 --> X1, A1 <-> X1, A1 --> Y, X1 --> Y,
           B2 --> X2, A2 <-> X2, A2 --> Y, X2 --> Y");

julia> pag2 = mag_to_pag(mag2);

julia> sort(adjustment_set(pag2, [:X1, :X2], :Y))
2-element Vector{Symbol}:
 :A1
 :A2

julia> pag3 = mag_to_pag(MAG(directed(:A, :X), directed(:X, :Y), directed(:A, :Y)));

julia> adjustment_set(pag3, :X, :Y) === nothing  # not adjustment-amenable
true

References

source
adjustment_set(cg::DAG, x, y; type::Symbol = :optimal) -> Union{Nothing,Vector{Symbol}}

Compute an adjustment set for the causal effect of x on y in cg, or nothing if no valid adjustment set exists.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Three types are supported:

  • :parents: $\bigcup \mathrm{Pa}(x) \setminus \{x, y\}$.
  • :backdoor: Pearl backdoor formula.
  • :optimal: O-set $\mathrm{Pa}(\mathrm{cn}(x,y)) \setminus \mathrm{Forb}(x,y)$, where $\mathrm{cn}(x,y)$ is the set of nodes other than x on proper causal paths from x to y (paths that meet x only at their first node) and $\mathrm{Forb}(x,y) = \mathrm{De}(\mathrm{cn}(x,y)) \cup x$.

The O-set (Henckel et al., 2022) is defined when every node in y is a descendant of x. It is then a valid adjustment set whenever any valid adjustment set exists, and it is asymptotically optimal among them. If some node in y is not a descendant of x (so x has no causal effect on it), :optimal emits a warning: its result is then not guaranteed to be optimal, and it may return nothing even though a valid adjustment set exists (e.g. x = :X, y = :Y in A --> X, A --> Y); use :backdoor or all_adjustment_sets instead.

The type keyword is specific to the DAG/AbstractPDAG methods; the ADMG/AbstractAG/PAG methods of adjustment_set take no type keyword and always return a fixed inclusion-minimal valid adjustment set.

Examples

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

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

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

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

julia> dag2 = DAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> sort(adjustment_set(dag2, [:X1, :X2], :Y; type = :optimal))
2-element Vector{Symbol}:
 :L1
 :L2

References

source
adjustment_set(cg::AbstractPDAG, x, y; type::Symbol = :optimal)
    -> Union{Nothing,Vector{Symbol}}

Compute an adjustment set for the causal effect of x on y in cg, or nothing if no valid adjustment set exists.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Two types are supported:

  • :parents: directed parents of x.
  • :optimal: O-set $\mathrm{Pa}(\mathrm{Cn}(x,y)) \setminus \mathrm{Forb}(x,y)$, where $\mathrm{Cn}(x,y)$ is the set of nodes on proper possibly directed paths from x to y and $\mathrm{Forb}(x,y)$ is the forbidden set (see is_valid_adjustment).

The O-set (Henckel et al., 2022) is defined when every node in y lies on a proper possibly causal path from x. If additionally the effect is amenable (see is_valid_adjustment), it is a valid adjustment set whenever any valid adjustment set exists, and it is asymptotically optimal among them in every DAG that cg represents. If some node in y lies on no such path (so x has no causal effect on it in any represented DAG), :optimal emits a warning: its result is then not guaranteed to be optimal, and it may return nothing even though a valid adjustment set exists; use all_adjustment_sets instead.

The type keyword is specific to the DAG/AbstractPDAG methods; the ADMG/AbstractAG/PAG methods of adjustment_set take no type keyword and always return a fixed inclusion-minimal valid adjustment set.

Examples

julia> pdag = PDAG("A --> X --> Y, A --> Y");

julia> adjustment_set(pdag, :X, :Y)
1-element Vector{Symbol}:
 :A

julia> is_valid_adjustment(pdag, :X, :Y, :A)
true

julia> pdag2 = PDAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> sort(adjustment_set(pdag2, [:X1, :X2], :Y))
2-element Vector{Symbol}:
 :L1
 :L2

References

source
CausalStructures.is_valid_adjustment — Function
is_valid_adjustment(cg::DAG, x, y, z = Symbol[]) -> Bool

Return true if z is a valid adjustment set for estimating the total causal effect of x on y in cg using the Generalized Adjustment Criterion (GAC).

x and y may each be a single Symbol or an AbstractVector{Symbol}.

For a DAG this reduces to the adjustment criterion of Shpitser (2012), which is sound and complete for adjustment – unlike is_valid_backdoor (Pearl's backdoor criterion, sound but not complete), z may include descendants of x as long as they are not on a causal path from x to y, so this criterion accepts some valid sets the backdoor criterion rejects.

Examples

julia> dag = DAG("A --> X --> Y, A --> Y");

julia> is_valid_adjustment(dag, :X, :Y)
false

julia> is_valid_adjustment(dag, :X, :Y, :A)
true

julia> dag2 = DAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> is_valid_adjustment(dag2, [:X1, :X2], :Y, [:L1, :L2])
true

References

source
is_valid_adjustment(cg::Union{ADMG,AbstractAG}, x, y, z = Symbol[]) -> Bool

Return true if z is a valid adjustment set for estimating the total causal effect of x on y in cg using the Generalized Adjustment Criterion (GAC).

x, y, and z may each be a single Symbol or an AbstractVector{Symbol}.

A set z is valid if it contains no forbidden node (no node in De(cn(x,y) \ {y}) ∪ {x}, where cn(x,y) are the proper causal nodes from x to y) and x and y are m-separated by z in the proper backdoor graph of cg.

Examples

julia> admg = ADMG("L --> X --> Y, L --> Y");

julia> is_valid_adjustment(admg, :X, :Y)       # empty Z does not block L --> Y
false

julia> is_valid_adjustment(admg, :X, :Y, :L) # conditioning on L blocks the backdoor path
true
julia> mag = MAG("A <-> X, A --> M --> Y, X --> Y");

julia> is_valid_adjustment(mag, :X, :Y)
false

julia> is_valid_adjustment(mag, :X, :Y, :A)
true
julia> admg2 = ADMG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> is_valid_adjustment(admg2, [:X1, :X2], :Y, [:L1, :L2])
true

References

source
is_valid_adjustment(cg::PAG, x, y, z = Symbol[]) -> Bool

Return true if z is a valid adjustment set for estimating the total causal effect of x on y in cg using the Generalized Adjustment Criterion (GAC).

x, y, and z may each be a single Symbol or an AbstractVector{Symbol}.

Circle marks collapse to tails for reachability purposes, and edges out of x are only removed from the proper backdoor graph if they are visible: there must be a witness node with an arrowhead into x (or reaching x via a collider path through parents of the edge's target) that is not adjacent to that target, ruling out a latent confounder riding along the edge (Zhang, 2006). If cg is not adjustment-amenable relative to (x, y) – some proper possibly-directed path from x to y starts with an invisible edge – no set satisfies the criterion, including the empty set.

Examples

julia> mag = MAG("B --> X, A <-> X, A --> Y, X --> Y");

julia> pag = mag_to_pag(mag);

julia> is_valid_adjustment(pag, :X, :Y)
false

julia> is_valid_adjustment(pag, :X, :Y, :A)
true

julia> mag2 = MAG(
           "B1 --> X1, A1 <-> X1, A1 --> Y, X1 --> Y,
           B2 --> X2, A2 <-> X2, A2 --> Y, X2 --> Y");

julia> pag2 = mag_to_pag(mag2);

julia> is_valid_adjustment(pag2, [:X1, :X2], :Y, [:A1, :A2])
true

References

source
is_valid_adjustment(cg::AbstractPDAG, x, y, z = Symbol[]) -> Bool

Return true if z is a valid adjustment set for estimating the total causal effect of x on y in cg using the Generalized Adjustment Criterion (GAC).

x, y, and z may each be a single Symbol or an AbstractVector{Symbol}.

The effect must be amenable (every proper possibly causal path from x to y starts with a directed edge), z must avoid the forbidden set (possible descendants of nodes on proper possibly causal paths), and z must block every proper non-causal path of definite status. A z overlapping y is never valid.

A PDAG is first closed under Meek's rules (see meek_closure), since the criterion is stated for MPDAGs.

Examples

julia> pdag = PDAG("A --> X --> Y, A --> Y");

julia> is_valid_adjustment(pdag, :X, :Y)
false

julia> is_valid_adjustment(pdag, :X, :Y, :A)
true

julia> pdag2 = PDAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> is_valid_adjustment(pdag2, [:X1, :X2], :Y, [:L1, :L2])
true

References

source
CausalStructures.all_adjustment_sets — Function
all_adjustment_sets(cg::DAG, x, y;
                    minimal::Bool = true, max_size::Int = 3)
    -> Vector{Vector{Symbol}}

Return all valid adjustment sets for the total causal effect of x on y in cg, up to size max_size.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Sets are validated using is_valid_adjustment. When minimal = true (default), only inclusion-minimal sets are returned.

Examples

julia> dag = DAG("A --> X, B --> X, X --> Y, A --> Y");

julia> all_adjustment_sets(dag, :X, :Y)
1-element Vector{Vector{Symbol}}:
 [:A]

julia> all_adjustment_sets(dag, :X, :Y; minimal=false)
2-element Vector{Vector{Symbol}}:
 [:A]
 [:A, :B]

julia> dag2 = DAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> all_adjustment_sets(dag2, [:X1, :X2], :Y)
1-element Vector{Vector{Symbol}}:
 [:L1, :L2]

References

source
all_adjustment_sets(cg::Union{ADMG,AbstractAG,AbstractPDAG}, x, y;
                    minimal::Bool = true, max_size::Int = 3)
    -> Vector{Vector{Symbol}}

Return all valid adjustment sets for the total causal effect of x on y in cg, up to size max_size.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Bruteforces over subsets of the allowed universe of nodes (nodes that are not forbidden and not y), checking each for validity using is_valid_adjustment. When minimal = true (default), only inclusion-minimal sets are returned.

Examples

julia> admg = ADMG("L --> X --> Y, L --> Y");

julia> all_adjustment_sets(admg, :X, :Y)
1-element Vector{Vector{Symbol}}:
 [:L]
julia> mag = MAG("A <-> X, A --> M --> Y, X --> Y");

julia> all_adjustment_sets(mag, :X, :Y)
2-element Vector{Vector{Symbol}}:
 [:A]
 [:M]
julia> mpdag = MPDAG("A --> X, B --> X, X --> Y, A --> Y, B --- K, K --> Y");

julia> all_adjustment_sets(mpdag, :X, :Y)
2-element Vector{Vector{Symbol}}:
 [:A, :B]
 [:A, :K]

julia> all_adjustment_sets(mpdag, :X, :Y, minimal = false)
3-element Vector{Vector{Symbol}}:
 [:A, :B]
 [:A, :K]
 [:A, :B, :K]
julia> admg2 = ADMG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> all_adjustment_sets(admg2, [:X1, :X2], :Y)
1-element Vector{Vector{Symbol}}:
 [:L1, :L2]
source
all_adjustment_sets(cg::PAG, x, y;
                    minimal::Bool = true, max_size::Int = 3)
    -> Vector{Vector{Symbol}}

Return all valid adjustment sets for the total causal effect of x on y in cg, up to size max_size.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Sets are validated using is_valid_adjustment. When minimal = true (default), only inclusion-minimal sets are returned.

Examples

julia> mag = MAG("B --> X, A <-> X, A --> Y, X --> Y");

julia> pag = mag_to_pag(mag);

julia> all_adjustment_sets(pag, :X, :Y)
1-element Vector{Vector{Symbol}}:
 [:A]

julia> mag2 = MAG(
           "B1 --> X1, A1 <-> X1, A1 --> Y, X1 --> Y,
           B2 --> X2, A2 <-> X2, A2 --> Y, X2 --> Y");

julia> pag2 = mag_to_pag(mag2);

julia> all_adjustment_sets(pag2, [:X1, :X2], :Y)
1-element Vector{Vector{Symbol}}:
 [:A1, :A2]

References

source
all_adjustment_sets(cg::AbstractPDAG, x, y;
                    minimal::Bool = true, max_size::Int = 3)
    -> Vector{Vector{Symbol}}

Return all valid adjustment sets for the total causal effect of x on y in cg, up to size max_size.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Sets are validated using is_valid_adjustment. When minimal = true (default), only inclusion-minimal sets are returned.

Examples

julia> mpdag = MPDAG("A --> X, B --> X, X --> Y, A --> Y, B --- K, K --> Y");

julia> all_adjustment_sets(mpdag, :X, :Y)
2-element Vector{Vector{Symbol}}:
 [:A, :B]
 [:A, :K]

julia> all_adjustment_sets(mpdag, :X, :Y, minimal = false)
3-element Vector{Vector{Symbol}}:
 [:A, :B]
 [:A, :K]
 [:A, :B, :K]

julia> pdag2 = PDAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> all_adjustment_sets(pdag2, [:X1, :X2], :Y)
1-element Vector{Vector{Symbol}}:
 [:L1, :L2]

References

source
CausalStructures.possible_optimal_adjustment_sets — Function
possible_optimal_adjustment_sets(cg::AbstractPDAG, x::Symbol, y::Symbol) ->
    Vector{Union{Vector{Symbol},Nothing}}

Return the optimal adjustment set (Henckel, Perkovic & Maathuis 2022) for each locally valid orientation of x's undirected neighbors in cg. The graph part of optimal-adjustment IDA (Witte, Henckel, Maathuis & Didelez 2020).

For each subset S of x's undirected neighbors that possible_parent_sets would accept (no new v-structure with collider x), this directs S --> x and x's remaining undirected neighbors away from x, closes the result under Meek's rules (apply_background_knowledge), and returns adjustment_set(mpdag, x, y; type = :optimal) for the resulting MPDAG. Entries are in the same order (one per locally valid orientation) as possible_parent_sets(cg, x), so pairing the two element-wise recovers, for each orientation, both its parent set and its optimal adjustment set.

An entry is nothing when y is among the resulting parents of x for that orientation, so no adjustment set applies, or when y is not a possible descendant of x in the resulting MPDAG. In the latter case the O-set is undefined and adjustment_set warns, but a valid adjustment set may still exist.

Examples

julia> cpdag = CPDAG("X1 --- X2 + X3 + X4, X3 + X4 --> Y");

julia> possible_parent_sets(cpdag, :X1)
4-element Vector{Vector{Symbol}}:
 []
 [:X2]
 [:X3]
 [:X4]

julia> possible_optimal_adjustment_sets(cpdag, :X1, :Y)
4-element Vector{Union{Nothing, Vector{Symbol}}}:
 Symbol[]
 Symbol[]
 [:X3]
 [:X4]

References

source
CausalStructures.pagcauses — Function
pagcauses(cg::PAG, x::Symbol, y::Symbol) -> Vector{Vector{Symbol}}

Return adjustment sets for the effect of x on y that together give every possible causal effect identifiable by adjustment in some DAG consistent with cg (Wang et al. 2025, Algorithm 1, "PAGcauses"; Theorem 4).

Returns Vector{Symbol}[] if x is not a possible ancestor of y. If the effect is directly identifiable in cg, returns the corresponding backdoor_set.

Throws ArgumentError if cg contains selection bias (undirected edges), which is not covered by the algorithm.

Algorithm

Rather than enumerating the $O(3^{(d^2-d)/2})$ MAGs consistent with a PAG and checking D-SEP in each, the algorithm performs a graphical check for each of the $O(2^d)$ candidate sets. For each possible local structure at x (possible_local_structures), it constructs the corresponding maximal local MAG (maximal_local_mag) and searches for adjustment sets satisfying Theorem 2, pruned by DD-SEP (Definition 7). The overall complexity is $O(5^d d^6)$ (Sec. 3.4).

Parallelizes over Threads.nthreads() when there are enough candidates.

Examples

julia> pag = PAG("X o-> Y, X o-> C, X o-> A, Y o-o C, C o-o A, B o-> C, B o-> Y, B o-> A");

julia> sort(sort.(pagcauses(pag, :X, :Y)))
5-element Vector{Vector{Symbol}}:
 []
 [:A, :B]
 [:A, :B, :C]
 [:B, :C]
 [:C]

References

source

Backdoor

CausalStructures.is_valid_backdoor — Function
is_valid_backdoor(cg::Union{DAG,ADMG}, x, y, z = Symbol[]) -> Bool

Return true if z satisfies the backdoor criterion for the causal effect of x on y in cg.

x, y, and z may each be a single Symbol or an AbstractVector{Symbol}.

z is a valid backdoor set if (1) no node in z is a descendant of x, and (2) z blocks every backdoor path from x to y.

  • DAG: equivalently, every parent of x is d-separated from y given z ∪ x.
  • ADMG: equivalently, z m-separates x from y in the graph obtained by removing every directed edge out of x.

Examples

julia> dag = DAG("A --> X --> Y, A --> Y");

julia> is_valid_backdoor(dag, :X, :Y)       # empty Z leaves the backdoor path A --> Y open
false

julia> is_valid_backdoor(dag, :X, :Y, :A) # conditioning on A blocks the backdoor path
true

julia> admg = ADMG("A --> X --> Y, A <-> Y");

julia> is_valid_backdoor(admg, :X, :Y)       # A <-> Y is an unobserved confounder of the path
false

julia> is_valid_backdoor(admg, :X, :Y, :A) # conditioning on A still blocks it
true

julia> dag2 = DAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> is_valid_backdoor(dag2, [:X1, :X2], :Y)              # both confounding paths are open
false

julia> is_valid_backdoor(dag2, [:X1, :X2], :Y, [:L1, :L2])  # conditioning on both blocks them
true

References

source
CausalStructures.all_backdoor_sets — Function
all_backdoor_sets(cg::Union{DAG,ADMG}, x, y;
                  minimal::Bool = true, max_size::Int = 3)
    -> Vector{Vector{Symbol}}

Return all sets satisfying the backdoor criterion for the causal effect of x on y in cg, up to size max_size.

x and y may each be a single Symbol or an AbstractVector{Symbol}.

Bruteforces over subsets of the allowed universe of nodes (nodes that are not descendants of x and not y), checking each for validity using is_valid_backdoor. When minimal = true (default), only inclusion-minimal sets are returned. For ADMG, the universe is drawn from the graph's observed nodes; latent confounders are already summarized by bidirected edges and are never candidates.

Examples

julia> dag = DAG("A --> X --> Y, A --> Y");

julia> all_backdoor_sets(dag, :X, :Y)
1-element Vector{Vector{Symbol}}:
 [:A]

julia> admg = ADMG("A --> X --> Y, A <-> Y");

julia> all_backdoor_sets(admg, :X, :Y)
1-element Vector{Vector{Symbol}}:
 [:A]

julia> dag2 = DAG("L1 --> X1, L1 --> Y, L2 --> X2, L2 --> Y, X1 --> Y, X2 --> Y");

julia> all_backdoor_sets(dag2, [:X1, :X2], :Y)
1-element Vector{Vector{Symbol}}:
 [:L1, :L2]
source
CausalStructures.backdoor_set — Function
backdoor_set(cg::DAG, x::Symbol, y::Symbol) -> Union{Vector{Symbol},Nothing}

Return a generalized back-door set relative to (x, y) and cg using the Generalized Backdoor Criterion (GBC; Maathuis and Colombo (2015), Corollary 4.1), or nothing if none exists.

For a DAG this reduces to Pearl's original result (Pearl (2009)): a generalized back-door set exists if and only if y is not a parent of x, and when it exists, parents(cg, x) is such a set (not necessarily minimal).

Examples

julia> dag = DAG("A --> X --> Y, A --> Y");

julia> backdoor_set(dag, :X, :Y)
1-element Vector{Symbol}:
 :A

julia> backdoor_set(dag, :Y, :A) === nothing  # A is a parent of Y
true

References

source
backdoor_set(cg::ADMG, x::Symbol, y::Symbol) -> Union{Vector{Symbol},Nothing}

Return a generalized back-door set relative to (x, y) and cg, or nothing if none exists.

Examples

julia> admg = ADMG("A --> X --> Y, A --> Y");

julia> backdoor_set(admg, :X, :Y)
1-element Vector{Symbol}:
 :A

julia> admg2 = ADMG("A --> X --> Y, A <-> Y");  # latent confounder A also causes Y

julia> backdoor_set(admg2, :X, :Y)
1-element Vector{Symbol}:
 :A

julia> admg3 = ADMG(directed(:X, :Y), bidirected(:X, :Y));  # direct edge plus latent confounder

julia> backdoor_set(admg3, :X, :Y) === nothing  # Y stays adjacent to X in M_X via X <-> Y
true

References

source
backdoor_set(cg::CPDAG, x::Symbol, y::Symbol) -> Union{Vector{Symbol},Nothing}

Return a generalized back-door set relative to (x, y) and cg using the Generalized Backdoor Criterion (GBC; Maathuis and Colombo (2015), Corollary 4.2), or nothing if none exists.

Let C_X be cg with every directed edge out of x removed. A generalized back-door set exists if and only if y is not a parent of x and y is not a possible descendant of x in C_X; when it exists, parents(cg, x) is such a set (not necessarily minimal).

Deliberately not defined for MPDAG or plain PDAG: Corollary 4.2 relies on a CPDAG-specific fact (every back-door path into x passes through a compelled parent) that fails once background knowledge introduces a partially directed cycle.

Examples

julia> cpdag = CPDAG("A --> X <-- C, X --> Y");  # A --> X <-- C protects both edges into X

julia> sort(backdoor_set(cpdag, :X, :Y))
2-element Vector{Symbol}:
 :A
 :C

julia> cpdag2 = CPDAG("X --- Y");

julia> backdoor_set(cpdag2, :X, :Y) === nothing  # Y is a possible descendant of X in C_X
true

References

source
backdoor_set(cg::MAG, x::Symbol, y::Symbol) -> Union{Vector{Symbol},Nothing}

Return a generalized back-door set relative to (x, y) and cg using the Generalized Backdoor Criterion (GBC; Maathuis and Colombo (2015), Corollary 4.3), or nothing if none exists.

Let M_X be cg with every visible directed edge out of x removed (Definition 4.2). A generalized back-door set exists if and only if y is not adjacent to x in M_X and D-SEP(x, y, M_X) does not intersect the descendants of x in cg; when it exists, D-SEP(x, y, M_X) is such a set (not necessarily minimal).

Examples

julia> mag = MAG("A <-> X, A --> M --> Y, X --> Y");

julia> backdoor_set(mag, :X, :Y)
1-element Vector{Symbol}:
 :A

julia> mag2 = MAG("X --> Y");

julia> backdoor_set(mag2, :X, :Y) === nothing  # X --> Y is invisible here, so Y ∈ adj(X, M_X)
true

References

source
backdoor_set(cg::PAG, x::Symbol, y::Symbol) -> Union{Vector{Symbol},Nothing}

Return a generalized back-door set relative to (x, y) and cg using the Generalized Backdoor Criterion (GBC; Maathuis and Colombo (2015), Theorem 4.1), or nothing if none exists.

Let R be any MAG in the Markov equivalence class of cg with the same number of edges into x as cg (Definition 4.2; such an R always exists, Lemma 7.6), and let R_X be R with every directed edge out of x that is visible in cg removed. A generalized back-door set exists if and only if y is not adjacent to x in R_X and D-SEP(x, y, R_X) does not intersect the possible descendants of x in cg; when it exists, D-SEP(x, y, R_X) is such a set (not necessarily minimal; Theorem 4.1 shows the result does not depend on which admissible R is used).

Examples

julia> mag = MAG("B --> X, A <-> X, A --> Y, X --> Y");

julia> pag = mag_to_pag(mag);  # A o-> X, A --> Y, B o-> X, X --> Y

julia> sort(backdoor_set(pag, :X, :Y))
2-element Vector{Symbol}:
 :A
 :B

References

source

Frontdoor

CausalStructures.frontdoor_set — Function
frontdoor_set(cg::Union{DAG,ADMG}, x, y; include=[], restrict=nothing)
    -> Vector{Symbol} or nothing

Return a front-door adjustment set Z with include ⊆ Z ⊆ restrict satisfying all three front-door conditions relative to (x, y) in cg, or nothing if no such set exists.

x, y, include, and restrict may each be a single Symbol or an AbstractVector{Symbol}.

  • include: nodes forced into the set.
  • restrict: candidate pool from which the set is drawn. Defaults to all nodes in cg except x and y.

Implements Algorithm 1 of (Jeong et al., 2022):

  • Step 1 (GETCAND2NDFDC): drop candidates that have a backdoor path from X.
  • Step 2 (GETCAND3RDFDC): drop candidates for which condition 3 cannot be met.
  • Step 3: check that the remaining set blocks all directed paths X –> Y.

The returned set is the full R'' from Steps 1-2 (not necessarily minimal). For ADMG, backdoor and condition-3 checks are done via m-separation, with bidirected edges treated as latent-confounder edges (a spouse contributes an arrowhead into a node just like a directed parent does).

Examples

julia> dag = DAG("U --> X + Y, X --> Z --> Y");

julia> frontdoor_set(dag, :X, :Y; restrict = :Z)
1-element Vector{Symbol}:
 :Z

Jeong et al. (2022) Fig. 1b:

julia> dag = DAG("U1 --> X + Y, U2 --> X + D, X --> A, A --> B + C + D, B + C + D --> Y");

julia> frontdoor_set(dag, :X, :Y; restrict = [:A, :B, :C, :D])
3-element Vector{Symbol}:
 :A
 :B
 :C

julia> frontdoor_set(dag, :X, :Y; include = :C, restrict = [:A, :C])
2-element Vector{Symbol}:
 :A
 :C

julia> frontdoor_set(dag, :X, :Y; include = :D, restrict = [:A, :B, :C, :D]) === nothing
true

Fig. 1b latent-projected to an ADMG: U1 -> X <-> Y, U2 -> X <-> D:

julia> admg = ADMG("X <-> Y, X <-> D, X --> A, A --> B + C + D, B + C + D --> Y");

julia> frontdoor_set(admg, :X, :Y; restrict = [:A, :B, :C, :D])
3-element Vector{Symbol}:
 :A
 :B
 :C
julia> dag2 = DAG("U1 --> X1 + Y1, U2 --> X2 + Y2, X1 --> M, X2 --> M, M --> Y1 + Y2");

julia> frontdoor_set(dag2, [:X1, :X2], [:Y1, :Y2]; restrict = :M)
1-element Vector{Symbol}:
 :M

References

source
CausalStructures.is_valid_frontdoor — Function
is_valid_frontdoor(cg::DAG, x, y, z = Symbol[]) -> Bool

Return true if z satisfies the front-door criterion for the causal effect of x on y in cg.

x, y, and z may each be a single Symbol or an AbstractVector{Symbol}.

z is a valid front-door set if:

  1. z intercepts all directed paths from x to y.
  2. There are no unblocked backdoor paths from x to any node in z (given the empty set).
  3. For every node zi in z, all backdoor paths from zi to y are blocked by x together with the remaining nodes in z (i.e., conditioned on x ∪ (z \ {zi})).

When these conditions hold, the causal effect is identified by the front-door formula, even in the presence of unmeasured confounders between x and y.

Examples

julia> dag = DAG("U --> X --> M --> Y, U --> Y");

julia> is_valid_frontdoor(dag, :X, :Y, :M)  # M mediates X -> Y and satisfies all conditions
true

julia> is_valid_frontdoor(dag, :X, :Y)         # empty Z leaves directed path X -> M -> Y open
false

julia> is_valid_frontdoor(dag, :X, :Y, :U)   # U does not intercept X -> M -> Y
false

julia> dag2 = DAG("U1 --> X1 + Y1, U2 --> X2 + Y2, X1 --> M, X2 --> M, M --> Y1 + Y2");

julia> is_valid_frontdoor(dag2, [:X1, :X2], [:Y1, :Y2], :M)  # M mediates every X --> Y path
true

References

source
CausalStructures.all_frontdoor_sets — Function
all_frontdoor_sets(cg::Union{DAG,ADMG}, x, y; include=[], restrict=nothing)
    -> Vector{Vector{Symbol}}

Return all front-door adjustment sets Z with include ⊆ Z ⊆ restrict relative to (x, y) in cg.

x, y, include, and restrict may each be a single Symbol or an AbstractVector{Symbol}.

  • include: nodes forced into every returned set.
  • restrict: candidate pool from which sets are drawn. Defaults to all nodes in cg except x and y.

Implements Algorithm 2 (LISTFDSETS) of (Jeong et al., 2022). The algorithm has polynomial-delay guarantees: it outputs the first result in polynomial time and takes polynomial time between consecutive results. For ADMG, see the note on m-separation in frontdoor_set.

Examples

julia> dag = DAG("U --> X + Y, X --> Z --> Y");

julia> all_frontdoor_sets(dag, :X, :Y; restrict = :Z)
1-element Vector{Vector{Symbol}}:
 [:Z]

Jeong et al. (2022) Fig. 1b:

julia> dag = DAG("U1 --> X + Y, U2 --> X + D, X --> A, A --> B + C + D, B + C + D --> Y");

julia> sort(all_frontdoor_sets(dag, :X, :Y; restrict = [:A, :B, :C, :D]))
4-element Vector{Vector{Symbol}}:
 [:A]
 [:A, :B]
 [:A, :B, :C]
 [:A, :C]

Fig. 1b latent-projected to an ADMG: U1 -> X <-> Y, U2 -> X <-> D:

julia> admg = ADMG("X <-> Y, X <-> D, X --> A, A --> B + C + D, B + C + D --> Y");

julia> sort(all_frontdoor_sets(admg, :X, :Y; restrict = [:A, :B, :C, :D]))
4-element Vector{Vector{Symbol}}:
 [:A]
 [:A, :B]
 [:A, :B, :C]
 [:A, :C]
julia> dag2 = DAG("U1 --> X1 + Y1, U2 --> X2 + Y2, X1 --> M, X2 --> M, M --> Y1 + Y2");

julia> all_frontdoor_sets(dag2, [:X1, :X2], [:Y1, :Y2]; restrict = :M)
1-element Vector{Vector{Symbol}}:
 [:M]

References

source

Instrumental variables

CausalStructures.is_valid_iv — Function
is_valid_iv(cg::Union{DAG,ADMG}, x::Symbol, y, z) -> Bool

Return true if z is a valid instrumental set for the causal effect of x on y in cg.

y and z may each be a single Symbol or an AbstractVector{Symbol}. x must be a single Symbol, since the instrumental-set criterion is defined for one structural coefficient x -> y.

z is a valid instrumental set if:

  1. Every zi ∈ z is d-/m-separated from y in the graph obtained from G by deleting the first edge x --> c of every causal path from x to y. This is the exclusion restriction: z can only affect y through x.
  2. At least one zi ∈ z is d-/m-connected to x in G. This is the relevance condition: z must be associated with the treatment.

When the only causal path is the edge x --> y, this is Definition 3.1 of van der Zander et al. (2015); deleting the first edge of every causal path extends it to the total effect.

Examples

Classic IV graph: Z --> X --> Y with hidden confounder U --> X, U --> Y:

julia> dag = DAG("Z --> X --> Y, U --> X + Y");

julia> is_valid_iv(dag, :X, :Y, :Z)  # Z is a valid instrument
true

julia> is_valid_iv(dag, :X, :Y, :U)  # U confounds X and Y; fails exclusion restriction
false

ADMG: X <-> Y encodes the hidden confounder directly:

julia> admg = ADMG("X <-> Y, Z --> X --> Y");

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

Z instruments X, which affects two outcomes Y1 and Y2:

julia> dag2 = DAG("Z --> X, X --> Y1 + Y2, U --> X + Y1 + Y2");

julia> is_valid_iv(dag2, :X, [:Y1, :Y2], :Z)
true

References

source
CausalStructures.all_iv_sets — Function
all_iv_sets(cg::Union{DAG,ADMG}, x::Symbol, y;
            minimal::Bool = true, max_size::Int = 3)
    -> Vector{Vector{Symbol}}

Return all valid instrumental sets for the causal effect of x on y in cg, up to size max_size.

y may be a single Symbol or an AbstractVector{Symbol}; x must be a single Symbol (see is_valid_iv).

Bruteforces over subsets of the allowed universe of nodes (nodes that are not x or y), checking each for validity using is_valid_iv. When minimal = true (default), only inclusion-minimal sets are returned.

Examples

julia> dag = DAG("Z1 --> X, Z2 --> X, X --> Y, U --> X + Y");

julia> all_iv_sets(dag, :X, :Y)
2-element Vector{Vector{Symbol}}:
 [:Z1]
 [:Z2]

julia> dag2 = DAG("Z1 --> X, Z2 --> X, X --> Y1 + Y2, U --> X + Y1 + Y2");

julia> all_iv_sets(dag2, :X, [:Y1, :Y2])
2-element Vector{Vector{Symbol}}:
 [:Z1]
 [:Z2]

References

source

Identification

CausalStructures.id — Function
id(cg::Union{DAG,ADMG}, x, y) -> Union{Estimand,Nothing}

Return the interventional distribution P(y | do(x)) as an Estimand, or nothing if the effect is not identifiable.

This is the ID algorithm of Shpitser and Pearl (2008).

x and y may each be a Symbol or a vector of them, and must be disjoint.

Bidirected edges represent latent confounders. To identify an effect in a DAG with unobserved variables, project them out first with latent_project.

Extra free variables

The returned formula may mention variables outside x and y. Line 3 of the algorithm enlarges the intervention set by a set W whenever intervening on W provably changes nothing, and the resulting expression is then a function of those values too.

Examples

Back-door adjustment is recovered as the g-formula:

julia> dag = DAG("Z --> X + Y, X --> Y");

julia> id(dag, :X, :Y)
Σ_{Z} P(Y | X, Z) P(Z)

The front-door graph, where X and Y are confounded but the effect is still identifiable through the mediator M:

julia> admg = ADMG("X --> M, M --> Y, X <-> Y");

julia> id(admg, :X, :Y)
Σ_{M} P(M | X) (Σ_{X'} P(X') P(Y | M, X'))

The inner sum ranges over a value of X distinct from the one intervened on, so it is printed under a primed name; summation indices are always renamed away from the variables of the query.

The bow arc, the canonical unidentifiable effect:

julia> bow = ADMG("X --> Y, X <-> Y");

julia> id(bow, :X, :Y) === nothing
true

References

source
CausalStructures.idc — Function
idc(cg::Union{DAG,ADMG}, x, y; given) -> Union{Estimand,Nothing}

Return the conditional interventional distribution P(y | do(x), given) as an Estimand, or nothing if it is not identifiable.

This is the IDC algorithm of Shpitser and Pearl (2008). Each variable in given that satisfies rule 2 of do-calculus is moved from the conditioning set into the intervention set; whatever remains is handled by id and normalized:

\[P(y | do(x), z) = ID(y ∪ z, x) / Σ_y ID(y ∪ z, x)\]

x, y, and given must be pairwise disjoint. With an empty given this reduces to id.

Examples

julia> dag = DAG("Z --> X + Y, X --> Y");

julia> idc(dag, :X, :Y; given = :Z)
P(Y | X, Z)

Conditioning on the mediator of the front-door graph leaves an effect that no longer depends on the intervened value at all:

julia> admg = ADMG("X --> M, M --> Y, X <-> Y");

julia> idc(admg, :X, :Y; given = :M)
Σ_{X'} P(X') P(Y | M, X')

References

source
CausalStructures.idp — Function
idp(cg::PAG, x, y) -> Union{Estimand,Nothing}

Return the interventional distribution P(y | do(x)) as an Estimand, or nothing if the effect is not identifiable from the PAG cg.

This is the IDP algorithm of Jaber et al. (2022), complete for identifying marginal effects from a partial ancestral graph (a Markov equivalence class of causal diagrams), generalizing id from a single ADMG to the equivalence-class setting.

x and y may each be a Symbol or a vector of them, and must be disjoint.

Examples

Effect identifiable through a witnessed backdoor:

julia> mag = MAG("B --> X, A <-> X, A --> Y, X --> Y");

julia> pag = mag_to_pag(mag);

julia> idp(pag, :X, :Y) === nothing
false

Effect not identifiable, since the PAG has no orientable structure at all (every conditional independence in the underlying equivalence class is consistent with several different causal directions):

julia> pag = mag_to_pag(MAG("Z --> X, Z --> Y, X --> Y"));

julia> idp(pag, :X, :Y) === nothing
true

References

source
CausalStructures.cidp — Function
cidp(cg::PAG, x, y; given) -> Union{Estimand,Nothing}

Return the conditional interventional distribution P(y | do(x), given) as an Estimand, or nothing if it is not identifiable from the PAG cg.

This is the CIDP algorithm of Jaber et al. (2022), complete for identifying conditional effects from a partial ancestral graph, generalizing idc from a single ADMG to the equivalence-class setting.

x, y, and given must be pairwise disjoint. With an empty given this reduces to idp.

Examples

Effect identifiable through a witnessed backdoor, conditioning on the confounder:

julia> mag = MAG("B --> X, A <-> X, A --> Y, X --> Y");

julia> pag = mag_to_pag(mag);

julia> cidp(pag, :X, :Y; given = :A) === nothing
false

Effect not identifiable, for the same reason as in idp's example:

julia> pag = mag_to_pag(MAG("Z --> X, Z --> Y, X --> Y"));

julia> cidp(pag, :X, :Y; given = :Z) === nothing
true

References

source

Estimands

CausalStructures.Estimand — Type
Estimand

Abstract supertype for symbolic causal estimands: formulas expressed purely in terms of the observational distribution.

The concrete subtypes are Prob, Marginal, Product, and Quotient. Build them with the smart constructors prob, marginal, product, and quotient.

Output formats

It can be rendered as text, rendered as LaTeX, or taken apart field by field:

julia> e = marginal([:Z], product([prob(:Y; given = [:X, :Z]), prob(:Z)]));

julia> string(e)
"Σ_{Z} P(Y | X, Z) P(Z)"

julia> e.index
1-element Vector{Symbol}:
 :Z

julia> e.term.terms[1].given
2-element Vector{Symbol}:
 :X
 :Z

The LaTeX form comes from the MIME"text/latex" method, so repr(MIME("text/latex"), e) gives $\sum_{Z} P(Y \mid X, Z) P(Z)$, used for frontend documentation.

source
CausalStructures.Prob — Type
Prob(vars, given)

A probability term P(vars | given), the leaf of an Estimand tree.

Both variable lists are stored sorted, so that terms differing only in the order the variables were listed compare equal. Use prob to construct.

source
CausalStructures.prob — Function
prob(vars; given = Symbol[]) -> Estimand

Build the probability term P(vars | given).

vars may be a single Symbol or a vector of them. Both lists are sorted and de-duplicated, and any variable appearing in given is dropped from vars (since P(X, Y | X) = P(Y | X)). A term left with an empty head is the constant 1.

Examples

julia> prob([:Y, :W]; given = [:X])
P(W, Y | X)
julia> prob(:Y)
P(Y)
source
CausalStructures.marginal — Function
marginal(index, term) -> Estimand

Sum term over the variables in index.

An empty index returns term unchanged, and nested sums are flattened into a single one. Summing a probability term over part of its own head marginalizes it directly: Σ_a P(a, b | c) becomes P(b | c).

Sums over a product are narrowed as far as they go: factors that do not mention the summation index are constants of the sum and are pulled out in front of it, and a factor whose whole head is summed over, and whose head no other factor under the sum mentions, sums to 1 and drops out. Under a ratio, the part of the index the denominator does not mention is summed in the numerator alone.

Examples

Here W is in the conditioning set rather than the head, so the sum stands:

julia> marginal([:W], prob(:Y; given = [:W, :X]))
Σ_{W} P(Y | W, X)

Summing over a variable in the head marginalizes it away instead:

julia> marginal([:W], prob([:W, :Y]; given = [:X]))
P(Y | X)
julia> marginal(Symbol[], prob(:Y))
P(Y)

P(Z | X) does not mention W, so it leaves the sum, and what remains sums to one:

julia> marginal([:W], product([prob(:Z; given = [:X]), prob(:W; given = [:X, :Z])]))
P(Z | X)
source
CausalStructures.product — Function
product(terms) -> Estimand

Multiply terms together.

Nested products are flattened and factors equal to 1 are dropped. A single-factor product collapses to that factor, and an empty product is 1. The order of the remaining factors is preserved.

Examples

julia> product([prob(:W; given = [:X]), prob(:Y; given = [:W, :X])])
P(W | X) P(Y | W, X)
julia> product([prob(:Y)])
P(Y)
source
CausalStructures.quotient — Function
quotient(num, den) -> Estimand

Form the ratio num / den.

A denominator of 1 returns num unchanged. When the denominator is itself a / b and the numerator is exactly a, the ratio collapses to b; this is what most often turns _reduce_bucket's divisions back into something readable. Otherwise, a factor appearing on both sides cancels, and a numerator factor and a denominator factor related by the chain rule P(a, b | c) = P(a | b, c) P(b | c) divide out: P(a, b | c) / P(b | c) becomes P(a | b, c), and P(a, b | c) / P(a | b, c) becomes P(b | c). That is what turns the ratios of marginals produced by the ID recursion back into ordinary conditionals.

Cancellation assumes the cancelled factor is non-zero, which is the positivity assumption the identification results are stated under anyway.

Examples

julia> quotient(prob([:Y, :Z]; given = [:X]), prob(:Z; given = [:X]))
P(Y | X, Z)
julia> quotient(prob([:W, :Y, :Z]), prob(:W; given = [:Y, :Z]))
P(Y, Z)
julia> a = quotient(prob([:Y, :Z]), prob(:X));

julia> b = marginal(:W, quotient(prob([:W, :Y, :Z]), prob(:W; given = [:X])));

julia> quotient(a, quotient(a, b))
Σ_{W} (P(W, Y, Z) / P(W | X))

The ratio is kept when it is not a conditional, here because the two terms condition on different things:

julia> quotient(prob([:Y, :Z]; given = [:X]), prob(:Z))
P(Y, Z | X) / P(Z)
source