Working with cbcd graphs

The cbcd graph types — cbcd.DAG, cbcd.CPDAG, cbcd.PAG, cbcd.MAG — satisfy the structural bnmetrics.GraphLike Protocol without modification, so every bnmetrics metric and visualiser accepts them directly. No conversion is required: cbcd.CPDAG already exposes n_vars, endpoints, and var_names.

A worked PC → bnmetrics round trip

The standard pattern is to run a constraint-based search in cbcd, then score the recovered graph against a reference using bnmetrics. The reference graph below is itself the output of pc() under a perfect d-separation oracle, which by construction returns the true CPDAG of the generating DAG:

from dagsampler import CausalDataGenerator
from cbcd import pc
import bnmetrics

cfg = {
    "simulation_params": {
        "n_samples": 3000, "seed_structure": 1, "seed_data": 2,
        "binary_proportion": 0.0,
    },
    "graph_params": {
        "type": "custom",
        "nodes": ["A", "B", "C", "D"],
        "edges": [["A", "B"], ["A", "C"], ["B", "D"], ["C", "D"]],
    },
}
gen = CausalDataGenerator(cfg)
result = gen.simulate()

true_cpdag = pc(result["data"], ci_test=gen.as_ci_oracle(), alpha=0.05)
recovered  = pc(result["data"], ci_test="fisherz",         alpha=0.05)

bnmetrics.shd(true_cpdag, recovered)        # 2
bnmetrics.f1(true_cpdag, recovered)         # 0.5
bnmetrics.plot_side_by_side(true_cpdag, recovered,
                      name1="true", name2="recovered",
                      direction="LR", save="comparison.svg")

Both true_cpdag and recovered are cbcd.CPDAG instances; bnmetrics does not import cbcd. The interop is mediated entirely by the GraphLike Protocol, which cbcd.CPDAG satisfies because it exposes the three required attributes:

true_cpdag.n_vars        # int
true_cpdag.endpoints     # np.ndarray[int8], shape (n_vars, n_vars)
true_cpdag.var_names     # tuple[str, ...] | None
isinstance(true_cpdag, bnmetrics.GraphLike)   # True

isinstance works because GraphLike is a @runtime_checkable Protocol; conformance is structural (duck-typed) rather than nominal, so no inheritance from a shared base class is required.