ergodicDocs
Products: switch from ergodic to another product
Get started

GuidesTest a graph against data

Test a graph against data

Check whether a causal graph you already have, written by hand or returned by discovery, is compatible with a table of observations. You'll test a true graph, a graph with a missing edge, and a graph that differs from the truth but implies the same independences.

You'll need

  • ergodic, installed with pip install "ergodic".
  • A candidate graph whose observed nodes are columns of your table.
  • A table whose rows are independent observations.

Run the program#

A missing edge is a claim. If the graph has no edge between ads and sales, it says some set of other variables makes them independent. test_graph lists one such claim for every pair without an edge, tests each on the data, and combines the p-values into one. The data here come from a known graph, so you can see what a pass and a failure look like.

test_a_graph.pyDownload
import numpy as np

from ergodic import dag, tabular, test_graph

# 1. Simulate 1,000 weeks from a known linear graph.
rng = np.random.default_rng(0)
n = 1000
season = rng.normal(size=n)
ads = 0.8 * season + rng.normal(size=n)
visits = 0.9 * ads + 0.3 * season + rng.normal(size=n)
price = rng.normal(size=n)
sales = 0.7 * visits - 0.6 * price + rng.normal(size=n)
data = tabular(
    {"season": season, "ads": ads, "visits": visits, "price": price, "sales": sales}
)
truth = dag(
    [
        "season -> ads",
        "season -> visits",
        "ads -> visits",
        "visits -> sales",
        "price -> sales",
    ]
)

# 2. Test the true graph: one claim per pair without an edge.
result = test_graph(truth, data)
print(result)
print(result.to_frame()[["x", "y", "given", "p_value", "p_holm"]].round(3))

# 3. Drop an edge the data need.
missing = test_graph(truth.remove_edge("season", "visits"), data)
print(missing)
worst = missing.implications[0]
print(f"{worst.x} _||_ {worst.y} | {', '.join(worst.given)}: p_holm = {worst.p_holm:.2g}")

# 4. Reverse season -> ads: a different graph with the same independences.
twin = truth.remove_edge("season", "ads").add_edge("ads -> season").as_kind("dag")
print(test_graph(twin, data))
Recorded output
GraphTest(dag, linear test, k=5, C=4.589, p=0.9169, not rejected at alpha=0.05, 0 claim(s) rejected after Holm)
        x       y                  given  p_value  p_holm
0  season   sales          price, visits    0.273     1.0
1   price     ads                 season    0.655     1.0
2   price  visits            ads, season    0.769     1.0
3   price  season                           0.811     1.0
4     ads   sales  price, season, visits    0.905     1.0
GraphTest(dag, linear test, k=6, C=42.518, p=2.724e-05, rejected at alpha=0.05, 1 claim(s) rejected after Holm)
season _||_ visits | ads: p_holm = 3.5e-08
GraphTest(dag, linear test, k=5, C=4.840, p=0.9016, not rejected at alpha=0.05, 0 claim(s) rejected after Holm)

What the p-value means#

The p-value answers one question: if every independence the graph implies held, how surprising would data like these be? A small value is evidence against the graph. The claims with small Holm-adjusted p-values say which missing edges the data reject.

A pass doesn't confirm the graph

A large p-value means the data don't contradict the graph's claims. It doesn't mean the graph is right:

  • Markov-equivalent graphs imply the same independences, so they all get the same verdict. Edge directions inside an equivalence class are never tested.
  • Only missing edges are tested. An edge the graph has but the world lacks costs nothing, so a complete graph passes any data.
  • With few rows every test has little power, and a wrong graph can pass.
  • The independence test brings its own assumptions. The partial-correlation test sees only linear dependence.

Decide how you'll read the result before you run the test. The combined p-value is the decision: rejected is True when it is at most alpha, and that is the test whose error rate is alpha. The Holm-adjusted p-values then locate the claims that fail. Don't reject the graph when either one rejects. Taking the union spends more than alpha: on simulated linear graphs it rejected true graphs about 8% of the time at alpha = 0.05.

Fisher's C assumes the claims' tests are independent of each other. Shipley (2000) argues that for the DAG basis set they are when the model holds, and with large-sample tests such as partial correlation and G squared that holds approximately. The partial-correlation test is the only one whose size is calibrated. On simulated six-variable linear-Gaussian graphs, the registered run of 200 worlds per sample size rejected the true graph at alpha = 0.05 in 9.5% of worlds with 200 rows, outside its band of 2% to 8%, and 4.5% with 1,000 rows. A pre-registered replication on 2,000 fresh worlds per sample size gave 4.65% and 4.85%, inside its band of 3.5% to 6.5%:

Rows Registered run (200 worlds) Replication (2,000 worlds, 95% interval)
200 9.5% 4.65% (3.77% to 5.67%)
1,000 4.5% 4.85% (3.95% to 5.88%)

Against the true graph minus one edge of weight at least 0.5, power was close to 1.

The basis set is also not every independence the graph implies. Together the claims imply the rest when the distribution has the composition property, as Gaussian distributions do. With discrete data a pass says less.

Read the result#

test_graph returns a GraphTest:

Field What it holds
statistic, dof Fisher's C and its degrees of freedom, twice the number of claims.
p_value The combined p-value; 1.0 when there is nothing to test.
rejected Whether p_value <= alpha.
k, n_rejected How many claims were tested, and how many Holm rejects.
test The independence test that ran, such as "linear".
kind How the graph was read: dag, cpdag, admg, mag or pag.
n Rows used, after dropping incomplete ones.
implications The claims, smallest p-value first. Ties, such as p-values too small to represent that print as 0, go to the larger statistic.
untestable Pairs without an edge that no observed set separates.
vacuous Claims whose test had zero degrees of freedom, such as one with a single-level column. They carry no information and are left out of k.
notes Caveats about this run, such as a small sample or a test that isn't calibrated.

Each claim is an ImpliedIndependence with x, y, given, statistic, p_value, p_holm, rejected, dof and rule. x comes before y in a topological order of the graph; for a CPDAG or PAG, of the member graph the claims were read in. to_frame() lays them out as a table, and to_dict() gives the whole result as plain data.

Rows with a missing value in the graph's columns are dropped, and a note counts them. An infinite value raises an error instead: a test would read it as no evidence at all.

Which claims each graph makes#

Graph The claim for a pair without an edge
DAG Independent given the parents of both.
CPDAG The same, read in one member DAG. Every member implies it.
ADMG, or a DAG with latent= or selection= nodes Independent given the parents of both when that set separates them in the graph. Otherwise a minimal set drawn from the observed ancestors of the pair and of the selection nodes.
MAG The same, with undirected edges followed back as well.
PAG The same, read in one member MAG. Every member implies it.

With selection= nodes, every claim is read given the selection nodes too, because the data were sampled given them. Every conditioning set is checked against the graph's own separation rule before any data are touched. With a hidden common cause or selection, some pairs without an edge still can't be separated by any set of observed variables. Those pairs carry no claim and appear in untestable. Fisher's C is an approximation for these graphs, because the claims' tests needn't be independent.

Choose the test#

test=None picks the test from the columns of the graph's observed variables, as discover does: partial correlation when all are continuous, G squared when all are discrete, and the degenerate-Gaussian test when they mix. Pass a name or an unbound test to choose another; Choose a method and test describes each one. A test already bound to data is refused, so the rows come from one place.

Only the partial-correlation test, the default for continuous data, is calibrated. With any other test the result carries a note saying so, and two are known to reject true graphs too often:

What if my table has more columns than the graph?

The extra columns are ignored, and a note lists them. The graph's claims are about its own variables, and they hold in the margin over those variables.

Next steps#

Documentation

Search ergodic 0.3.0

Search ergodic by task, function or error.