Examples

Constraint-based discovery (PC / FCI / GFCI / CD-NOD)#

Open in Colab Download this notebook

The constraint-based methods use conditional-independence tests instead of a score. pc returns a CPDAG; fci returns a PAG (partial ancestral graph) that stays sound under latent confounding. gfci builds a PAG from a GES graph, and cdnod learns from data pooled from several domains.

Start#

Choose a CPU runtime and run this cell first. In Colab it installs andrey-core==0.9.0 from PyPI; local kernels use the installed environment.

import os
import sys

IN_COLAB = "google.colab" in sys.modules

if IN_COLAB:
    os.environ["ANDREY_DEVICE"] = "cpu"
    !pip install --quiet "andrey-core[viz]==0.9.0" pandas
import numpy as np
import pandas as pd

import andrey

The same linear-Gaussian chain x0 -> x1 -> x2. Each example here lists its columns in an order drawn from the seed, so their order says nothing about the causal order; shuffle=False keeps the order the variables are written in.

CHAIN = [("x0", "x1"), ("x1", "x2")]  # the true edges, cause first


def sample_chain(seed: int, n: int = 1500, shuffle: bool = True) -> pd.DataFrame:
    """Draw n rows of the chain, its columns in an order drawn from the seed."""
    rng = np.random.default_rng(seed)
    x0 = rng.standard_normal(n)
    x1 = 0.8 * x0 + rng.standard_normal(n)
    x2 = 0.8 * x1 + rng.standard_normal(n)
    frame = pd.DataFrame({"x0": x0, "x1": x1, "x2": x2})
    return frame[rng.permutation(frame.columns)] if shuffle else frame


X = sample_chain(seed=2)
print("columns:", list(X.columns))
columns: ['x2', 'x1', 'x0']

PC with a Fisher-Z independence test at alpha = 0.05.

pc_out = andrey.pc(X, alpha=0.05)
print("PC  ->", pc_out.structure.kind, pc_out.structure.oriented_edges())
PC  -> cpdag [('x2', 'x1', 'undirected'), ('x1', 'x0', 'undirected')]

FCI returns a PAG, whose endpoint marks encode possible latent confounding.

fci_out = andrey.fci(X, alpha=0.05)
print("FCI ->", fci_out.structure.kind)
FCI -> pag
import andrey.viz

andrey.viz.draw(pc_out.structure)
x2x1x0

GFCI#

GFCI starts from the GES graph, removes the edges that independence tests reject, and orients the rest with FCI’s rules, so it also returns a PAG. Here c and d share a hidden cause, and a and b each cause one of them; the PAG marks c <-> d.

HIDDEN = [("h", "c"), ("h", "d"), ("a", "c"), ("b", "d")]  # h has no column


def sample_hidden(seed: int, n: int = 2000, shuffle: bool = True) -> pd.DataFrame:
    """Draw n rows of a, b, c, d, whose hidden common cause h is left out."""
    rng = np.random.default_rng(seed)
    hidden = rng.standard_normal(n)
    a = rng.standard_normal(n)
    b = rng.standard_normal(n)
    c = 0.8 * a + 0.8 * hidden + rng.standard_normal(n)
    d = 0.8 * b + 0.8 * hidden + rng.standard_normal(n)
    frame = pd.DataFrame({"a": a, "b": b, "c": c, "d": d})
    return frame[rng.permutation(frame.columns)] if shuffle else frame


gfci_out = andrey.gfci(sample_hidden(seed=3))
print(gfci_out.structure.oriented_edges())
andrey.viz.draw(gfci_out.structure)
[('d', 'c', 'bidirected'), ('b', 'd', 'partially_directed'), ('a', 'c', 'partially_directed')]
dacb

CD-NOD#

CD-NOD takes rows pooled from several domains and a domain index, one value per row. Here the mean of x moves between two domains. PC leaves the chain x - y - z undirected; the index orients it.

DOMAINS = [("x", "y"), ("y", "z")]  # the true edges, cause first


def sample_domains(
    seed: int, n: int = 1000, shuffle: bool = True
) -> tuple[pd.DataFrame, np.ndarray]:
    """Draw n rows from two domains, and the domain of each row; the mean of x moves with it."""
    rng = np.random.default_rng(seed)
    domain = np.repeat([0.0, 1.0], n // 2)  # the first n / 2 rows come from domain 0
    x = rng.standard_normal(n) + 1.5 * domain
    y = 0.8 * x + rng.standard_normal(n)
    z = 0.8 * y + rng.standard_normal(n)
    frame = pd.DataFrame({"x": x, "y": y, "z": z})
    return (frame[rng.permutation(frame.columns)] if shuffle else frame), domain


cdnod_out = andrey.cdnod(*sample_domains(seed=4))
print(cdnod_out.structure.oriented_edges())
andrey.viz.draw(cdnod_out.structure)
[('x', 'y', 'directed'), ('y', 'z', 'directed')]
yxz