Metrics
Functions for comparing two causal graphs: edge-based distances, separation-based distances that compare the implied conditional independence structure, and adjustment-based distances that compares implied causal effect estimands.
When comparing several causal-discovery algorithms against each other using a known ground truth, first convert the true graph to the equivalence class the algorithms actually target (dag_to_cpdag for CPDAG output, mag_to_pag for PAG output). If a guess is an MPDAG built from background knowledge, apply the same apply_background_knowledge to the converted true CPDAG.
This applies to the edge-based and adjustment-based distances below; the separation-based distances are unaffected, since a graph and its equivalence-class imply the same separations.
Edge-based
CausalStructures.hd — Function
hd(cg1::CausalGraph, cg2::CausalGraph; normalized::Bool = false) -> RealSkeleton Hamming distance between cg1 and cg2. Counts the number of node pairs on which the two graphs disagree about whether an edge is present, ignoring edge type and orientation entirely. cg1 and cg2 must have the same node set; they may be of different graph classes.
cg1::CausalGraph: the first graph.cg2::CausalGraph: the second graph.
normalized::Bool = false: divide by the number of node pairs.
The number of node pairs on which the two skeletons disagree, or, if normalized = true, that count divided by n * (n - 1) / 2 (a value in [0, 1], 0.0 when cg1/cg2 have fewer than two nodes).
CausalStructures.shd — Function
shd(cg1::CausalGraph, cg2::CausalGraph; normalized::Bool = false) -> RealStructural Hamming Distance (SHD) (Tsamardinos et al., 2006) between cg1 and cg2. Counts the node pairs on which the two graphs disagree, weighting skeleton and orientation mismatches equally. cg1 and cg2 must have the same node set; they may be of different graph classes.
The original paper only defined SHD for PDAGs, but we do the natural extension of the metric to any pair of causal graphs.
cg1::CausalGraph: the first graph.cg2::CausalGraph: the second graph.
normalized::Bool = false: divide by the number of node pairs.
The number of node pairs on which the two graphs disagree, or, if normalized = true, that count divided by n * (n - 1) / 2 (a value in [0, 1], 0.0 when cg1/cg2 have fewer than two nodes).
Separation-based
CausalStructures.separation_distance — Function
separation_distance(cg1::DAG, cg2::DAG; strategy::Symbol = :parents,
mb_enhanced::Bool = false, symmetric::Bool = false,
normalized::Bool = false) -> Real
separation_distance(cg1::AbstractAG, cg2::AbstractAG; mb_enhanced::Bool = false,
symmetric::Bool = false, normalized::Bool = false) -> Real
separation_distance(cg1::AbstractPDAG, cg2::AbstractPDAG; mb_enhanced::Bool = false,
symmetric::Bool = false, normalized::Bool = false) -> Real
separation_distance(cg1::PAG, cg2::PAG; mb_enhanced::Bool = false,
symmetric::Bool = false, normalized::Bool = false) -> RealSeparation distance (SD) from cg1 to cg2 (Wahl and Runge, 03–05 May 2025). For every pair of nodes non-adjacent in cg2, picks a separator in cg2 (per strategy, where applicable) and checks whether it still separates the pair in cg1, returning the number of pairs where it does not.
Not symmetric in general (separators are read off cg2, then verified in cg1); pass symmetric = true for the mean of both directions (zero iff cg1 and cg2 are Markov equivalent, for mb_enhanced = false).
The separator is class-specific:
- For a
DAG,strategyis:parents(default,parents(cg2, x) ∪ parents(cg2, y)),:ancestors(ancestors(cg2, x) ∪ ancestors(cg2, y)), or:zl(minimal_separator). - For an
AbstractAG(AG/MAG) orPAG, ZL-separation is the only strategy proven valid, so there is nostrategykeyword there; it also works onDAGs, just usually more expensive than:parents/:ancestors. - For an
AbstractPDAG(PDAG/CPDAG/MPDAG), p-parent separation is used:ppa(cg2, x) ∪ ppa(cg2, y), whereppa(v)is the union ofv's parents across every DAG consistent withcg2's edges (viapossible_parent_sets). Unlike plainparents, this is invariant under Markov equivalence, matching what anAbstractPDAGfixes.
mb_enhanced = true uses x's markov_blanket as the separator for every y not already in it (falling back to the base strategy otherwise); often cheaper on sparse graphs.
cg1::CausalGraph: the graph the separator is verified in.cg2::CausalGraph: the graph the separator is read off, of the same class ascg1.
strategy::Symbol = :parents: the separator strategy forDAGs, one of:parents,:ancestors, or:zl; not available for other graph classes.mb_enhanced::Bool = false: usex's Markov blanket as the separator where possible.symmetric::Bool = false: average the distance over both directions.normalized::Bool = false: divide by the number of node pairs.
The number of pairs non-adjacent in cg2 whose cg2-separator fails to separate them in cg1, or, if normalized = true, that count divided by n * (n - 1) / 2 (a value in [0, 1]), or the mean of both directions if symmetric = true.
julia> separation_distance(DAG("A --> B --> C"), DAG("A --> B --> C"))
0
julia> separation_distance(DAG("A --> C <-- B"), DAG("A --> B --> C"))
1
julia> separation_distance(DAG("A --> C <-- B"), DAG("A --> B --> C"); symmetric = true)
1.0
julia> cg1 = DAG("A --> B --> C, A --> C, C --> D"); cg2 = DAG("A --> B --> C --> D");
julia> separation_distance(cg1, cg2)
1
julia> separation_distance(cg1, cg2; strategy = :zl)
2
julia> separation_distance(cg1, cg2; mb_enhanced = true)
2
julia> separation_distance(cg1, cg2; normalized = true)
0.16666666666666666
julia> separation_distance(MAG("A <-> B, C --> B --> D"), MAG("A <-> B, C --> B --> D"))
0
julia> cpdag1 = dag_to_cpdag(DAG("A --> B --> C, A --> C"));
julia> cpdag2 = dag_to_cpdag(DAG("A --> B --> C"));
julia> separation_distance(cpdag1, cpdag2)
1
julia> pag1 = mag_to_pag(MAG("A --> X --> M --> Y, A --> Y"));
julia> pag2 = mag_to_pag(MAG("A --> X --> M --> Y"));
julia> separation_distance(pag1, pag2)
2CausalStructures.sc_metric — Function
sc_metric(cg1::CausalGraph, cg2::CausalGraph; max_order::Union{Nothing,Int} = nothing) -> Float64The s/c-metric (Wahl and Runge, 03–05 May 2025): the (normalized) fraction of separation/connection statements (X, Y, S) on which cg1 and cg2 disagree, graded by the order (size) of S. For each order k = 0, ..., K, averages disagreement over every ordered pair of distinct nodes (X, Y) and every k-subset S of the remaining nodes; the result is the mean over k of those per-order averages.
cg1 and cg2 must have the same node set, and must use the same separation notion: both from {DAG, AbstractPDAG} (d-separation) or both from {ADMG, AbstractAG, PAG} (m-separation). See markov_metric/ faithfulness_metric for one-sided variants against a fixed ground-truth graph.
By default (max_order = nothing), computes the full s/c-metric (K = n - 2), which is a proper metric (sc_metric(cg1, cg2) == 0 iff cg1 and cg2 are Markov equivalent) but exponential in the number of nodes. Pass max_order to bound the conditioning-set size, e.g. max_order = 0 for pairwise marginal (in)dependence alone.
cg1::CausalGraph: the first graph.cg2::CausalGraph: the second graph.
max_order::Union{Nothing,Int} = nothing: the maximum conditioning-set size to consider;nothingfor the fulln - 2.
The mean, over orders k = 0, ..., K, of the fraction of order-k separation/connection statements on which cg1 and cg2 disagree.
julia> g = DAG("A --> B --> C --> D");
julia> sc_metric(g, g)
0.0
julia> cg1 = DAG("A --> B --> C --> D"); cg2 = DAG("A --> B <-- C --> D");
julia> sc_metric(cg1, cg2)
0.24999999999999997
julia> sc_metric(cg1, cg2; max_order = 0)
0.3333333333333333
julia> sc_metric(cg1, cg2; max_order = 1)
0.29166666666666663CausalStructures.markov_metric — Function
markov_metric(cg_truth::CausalGraph, cg_test::CausalGraph;
max_order::Union{Nothing,Int} = nothing) -> Float64The Markov metric (c-metric) of cg_test relative to cg_truth (Wahl and Runge, 03–05 May 2025). The proportion of order-graded connection statements (X, Y, S) that are connected in cg_truth but separated in cg_test, the false-negative rate for connections, i.e. how far cg_test is from satisfying the causal Markov condition relative to cg_truth.
Unlike sc_metric, which compares graphs symmetrically, this treats cg_truth as a fixed reference (e.g. ground truth in a simulation). Same node-set/separation-notion requirements and max_order handling as sc_metric.
0.0 means cg_test is at least as connected as cg_truth for every statement considered (in particular whenever the graphs are Markov equivalent). An order k with no connection statements in cg_truth contributes 0.0 rather than being skipped, so every one of the K + 1 orders counts toward the mean. See faithfulness_metric for the complementary false-positive rate.
cg_truth::CausalGraph: the ground-truth graph.cg_test::CausalGraph: the graph being evaluated.
max_order::Union{Nothing,Int} = nothing: the maximum conditioning-set size to consider;nothingfor the fulln - 2.
The mean, over orders k = 0, ..., K, of the false-negative rate for connection statements of cg_test relative to cg_truth.
CausalStructures.faithfulness_metric — Function
faithfulness_metric(cg_truth::CausalGraph, cg_test::CausalGraph;
max_order::Union{Nothing,Int} = nothing) -> Float64The faithfulness metric (s-metric) of cg_test relative to cg_truth (Wahl and Runge, 03–05 May 2025). The proportion of order-graded separation statements (X, Y, S) that are separated in cg_truth but connected in cg_test, the false-positive rate for separations, i.e. how many spurious connections cg_test introduces relative to cg_truth.
Unlike sc_metric, which compares graphs symmetrically, this treats cg_truth as a fixed reference (e.g. ground truth in a simulation). Same node-set/separation-notion requirements and max_order handling as sc_metric.
0.0 means cg_test is at least as separated as cg_truth for every statement considered (in particular whenever the graphs are Markov equivalent). An order k with no separation statements in cg_truth contributes 0.0 rather than being skipped, so every one of the K + 1 orders counts toward the mean. See markov_metric for the complementary false-negative rate.
cg_truth::CausalGraph: the ground-truth graph.cg_test::CausalGraph: the graph being evaluated.
max_order::Union{Nothing,Int} = nothing: the maximum conditioning-set size to consider;nothingfor the fulln - 2.
The mean, over orders k = 0, ..., K, of the false-positive rate for separation statements of cg_test relative to cg_truth.
Adjustment-based
CausalStructures.aid — Function
aid(cg_true::Union{DAG,AbstractPDAG}, cg_guess::Union{DAG,AbstractPDAG};
type::Symbol = :oset, normalized::Bool = false) -> RealAdjustment Identification Distance (AID) (Henckel et al., 2024) from cg_guess to cg_true. For every ordered pair of distinct nodes (T, Y), an adjustment-based identification strategy applied to cg_guess is checked against cg_true and counted as a mistake if it is wrong there. cg_true and cg_guess must have the same node set; they may be of different graph classes (DAG or any AbstractPDAG).
type selects the identification strategy:
:parent(Parent-AID): proposesparents(cg_guess, T), coinciding with the structural intervention distance (SID) forDAGs.:ancestor(Ancestor-AID): proposes the ancestors ofT, which makes the distance zero whenevercg_guessrespects the causal order ofcg_true.:oset(Oset-AID, default): proposes the statistically optimal adjustment set (adjustment_setwithtype = :optimal).
Outside the pairs its adjustment set applies to (Y not a possible descendant of T in cg_guess, or, for :parent, Y itself a parent of T), the strategy instead claims a zero effect, correct iff Y is not a possible descendant of T in cg_true. For an AbstractPDAG guess where (cg_guess, T, Y) is not amenable, the strategy claims the effect is not identifiable, correct iff (cg_true, T, Y) is not amenable either.
cg_true::Union{DAG,AbstractPDAG}: the ground-truth graph.cg_guess::Union{DAG,AbstractPDAG}: the guessed graph.
type::Symbol = :oset: the identification strategy, one of:parent,:ancestor, or:oset.normalized::Bool = false: divide by the number of ordered pairs.
The number of ordered pairs (T, Y) on which cg_guess's identification claim is wrong, or, if normalized = true, that count divided by n * (n - 1) (a value in [0, 1], 0.0 when cg_true/cg_guess have fewer than two nodes).
julia> aid(DAG("A --> B --> C"), DAG("A --> B --> C"))
0
julia> aid(DAG("A --> B --> C"), DAG("A --> B <-- C"))
4
julia> aid(DAG("A --> B --> C"), DAG("A --> B <-- C"); normalized = true)
0.6666666666666666
julia> aid(DAG("A --> B --> C, A --> C"), DAG("A --> B --> C"); type = :parent)
1
julia> aid(DAG("A --> B --> C, A --> C"), DAG("A --> B --> C"); type = :ancestor)
0