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.

Comparing causal discovery algorithms

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) -> Real

Skeleton 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.

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).

julia> hd(DAG("A --> B --> C"), DAG("A --> B <-- C"))
0

julia> hd(DAG("A --> B, C"), DAG("A --> B --> C"))
1

julia> hd(DAG("A --> B"), ADMG("A <-> B"))
0

julia> hd(DAG("A --> B, C"), DAG("A --> B --> C"); normalized = true)
0.3333333333333333
source
CausalStructures.shd — Function
shd(cg1::CausalGraph, cg2::CausalGraph; normalized::Bool = false) -> Real

Structural 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.

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).

julia> shd(DAG("A --> B --> C"), DAG("A --> B <-- C"))
1

julia> shd(DAG("A --> B"), DAG("B --> A"))
1

julia> shd(DAG("A --> B"), ADMG("A <-> B"))
1

julia> shd(DAG("A --> B --> C"), DAG("A --> B --> C"))
0
source

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) -> Real

Separation 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, strategy is :parents (default, parents(cg2, x) ∪ parents(cg2, y)), :ancestors (ancestors(cg2, x) ∪ ancestors(cg2, y)), or :zl (minimal_separator).
  • For an AbstractAG (AG/MAG) or PAG, ZL-separation is the only strategy proven valid, so there is no strategy keyword there; it also works on DAGs, just usually more expensive than :parents/:ancestors.
  • For an AbstractPDAG (PDAG/CPDAG/MPDAG), p-parent separation is used: ppa(cg2, x) ∪ ppa(cg2, y), where ppa(v) is the union of v's parents across every DAG consistent with cg2's edges (via possible_parent_sets). Unlike plain parents, this is invariant under Markov equivalence, matching what an AbstractPDAG fixes.

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 as cg1.

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)
2
source
CausalStructures.sc_metric — Function
sc_metric(cg1::CausalGraph, cg2::CausalGraph; max_order::Union{Nothing,Int} = nothing) -> Float64

The 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.

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.29166666666666663
source
CausalStructures.markov_metric — Function
markov_metric(cg_truth::CausalGraph, cg_test::CausalGraph;
              max_order::Union{Nothing,Int} = nothing) -> Float64

The 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.

The mean, over orders k = 0, ..., K, of the false-negative rate for connection statements of cg_test relative to cg_truth.

julia> g = DAG("A --> B --> C --> D");

julia> markov_metric(g, g)
0.0

julia> cg_truth = DAG("A --> B --> C --> D"); cg_test = DAG("A --> B <-- C --> D");

julia> markov_metric(cg_truth, cg_test)
0.15277777777777776

julia> markov_metric(cg_truth, cg_test; max_order = 0)
0.3333333333333333
source
CausalStructures.faithfulness_metric — Function
faithfulness_metric(cg_truth::CausalGraph, cg_test::CausalGraph;
                    max_order::Union{Nothing,Int} = nothing) -> Float64

The 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.

The mean, over orders k = 0, ..., K, of the false-positive rate for separation statements of cg_test relative to cg_truth.

julia> g = DAG("A --> B --> C --> D");

julia> faithfulness_metric(g, g)
0.0

julia> cg_truth = DAG("A --> B --> C --> D"); cg_test = DAG("A --> B <-- C --> D");

julia> faithfulness_metric(cg_truth, cg_test)
0.27777777777777773

julia> faithfulness_metric(cg_truth, cg_test; max_order = 0)
0.0
source

Adjustment-based

CausalStructures.aid — Function
aid(cg_true::Union{DAG,AbstractPDAG}, cg_guess::Union{DAG,AbstractPDAG};
    type::Symbol = :oset, normalized::Bool = false) -> Real

Adjustment 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): proposes parents(cg_guess, T), coinciding with the structural intervention distance (SID) for DAGs.
  • :ancestor (Ancestor-AID): proposes the ancestors of T, which makes the distance zero whenever cg_guess respects the causal order of cg_true.
  • :oset (Oset-AID, default): proposes the statistically optimal adjustment set (adjustment_set with type = :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.

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
source