Markov check: do the data contradict a graph?#
A graph implies conditional independences: each variable is independent of every earlier variable
in a causal order that is not its parent, given its parents. andrey.markov_check tests each one
with a conditional-independence test and asks whether the p-values look uniform, as they do when
every independence holds. A small uniformity p-value rejects the graph. Here the check runs on data
simulated on the ALARM network, whose true graph we know, to show what it catches, what it misses,
and how it can choose among graphs.
Start#
Choose a CPU runtime and run this cell first. In Colab it installs andrey-core==0.13.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.13.0" pandas
import pandas as pd
import andrey
import andrey.viz
from andrey import GraphStructure
from andrey.data import load_dataset
from andrey.metrics import shd, to_cpdag
ALARM is a published network of 37 variables and 46 arcs. load_dataset simulates rows on it from
a linear-Gaussian model. A graph fitted to some rows can pass a check on those rows because the fit
made its independences true there, so the check needs rows the graph was not learned from. We learn
on the first 1,000 rows and check on the other 1,000.
alarm = load_dataset("alarm", n=2000, seed=1)
data, truth = alarm.data, alarm.graph
learn, held = data.iloc[:1000], data.iloc[1000:]
true_cpdag = to_cpdag(truth)
print(data.shape[1], "variables,", len(truth.oriented_edges()), "true edges")
37 variables, 46 true edges
Check a graph#
The true graph is the control: its independences hold, so the check should not reject it. The result prints the verdict, the number of independences tested, and the uniformity statistic.
check = andrey.markov_check(truth, held)
print(check)
Not rejected: the p-values of the 620 independences the graph implies are consistent with uniform (Anderson-Darling p = 0.656 > 0.05); 5.6% have p < 0.05, about 5% expected. A pass does not show the edges are right: a graph with extra edges also passes.
620 tests | Anderson-Darling A^2 = 0.592 | each test on 500 rows | indep_test fisherz
A damaged graph is rejected#
Remove the edge CO -> BP from the true graph. The graph now claims that BP is independent of
the causes of CO, such as LVFAILURE, given TPR, which the data contradict. table() lists
every tested independence with its p-value, so the smallest ones show where the graph is wrong:
def with_pair(graph, source, target, edge):
"""Return a copy of the DAG with `source -> target` added, or removed when not `edge`."""
marks = graph.to_numpy().copy()
i, j = graph.labels.index(source), graph.labels.index(target)
marks[i, j], marks[j, i] = (1, 2) if edge else (0, 0) # tail at source, arrowhead at target
return GraphStructure.from_numpy(marks, kind="dag", labels=graph.labels)
damaged = with_pair(truth, "CO", "BP", edge=False)
check = andrey.markov_check(damaged, held)
print(check)
pd.DataFrame(check.table()).nsmallest(3, "p_value")
Rejected: the p-values of the 621 independences the graph implies are not uniform (Anderson-Darling p = 9.66e-07 <= 0.05); 8.5% have p < 0.05, about 5% expected.
621 tests | Anderson-Darling A^2 = 48.4 | each test on 500 rows | indep_test fisherz
| x | y | given | p_value | |
|---|---|---|---|---|
| 185 | BP | INSUFFANESTH | [TPR] | 0.0 |
| 188 | BP | LVFAILURE | [TPR] | 0.0 |
| 197 | BP | STROKEVOLUME | [TPR] | 0.0 |
What a pass does not show#
The check rejects a missing edge only when the data can see the dependence. PULMEMBOLUS -> PAP
is weak enough here that a graph without it still passes. A graph with an extra edge passes too,
because an extra edge removes an independence from the graph’s claims and leaves the rest true.
So a pass says the data do not contradict the graph, not that its edges are right:
variants = {
"without PULMEMBOLUS -> PAP": with_pair(truth, "PULMEMBOLUS", "PAP", edge=False),
"plus HYPOVOLEMIA -> CVP": with_pair(truth, "HYPOVOLEMIA", "CVP", edge=True),
}
rows = []
for name, graph in variants.items():
check = andrey.markov_check(graph, held)
rows.append(
{
"graph": name,
"edges": len(graph.oriented_edges()),
"tests": check.n_tests,
"uniformity p": round(check.uniformity_p_value, 3),
"verdict": "not rejected" if check.passed else "rejected",
}
)
pd.DataFrame(rows)
| graph | edges | tests | uniformity p | verdict | |
|---|---|---|---|---|---|
| 0 | without PULMEMBOLUS -> PAP | 45 | 621 | 0.216 | not rejected |
| 1 | plus HYPOVOLEMIA -> CVP | 47 | 619 | 0.903 | not rejected |
Choosing a graph: the sparsest one that passes#
When the truth is unknown, Ramsey, Andrews, and Spirtes (2024) use the check to choose among the
graphs a method returns over a range of one setting: drop the graphs the data reject and keep the
sparsest of the rest, the simplest explanation the data allow. GES’s penalty_discount multiplies
the BIC penalty, and a larger value gives fewer edges. Fit GES on the learn rows over five values
and check each fit on the held rows:
rows, fits = [], {}
for penalty in (0.5, 1, 2, 4, 8):
fit = andrey.ges(learn, penalty_discount=penalty)
check = andrey.markov_check(fit, held)
fits[penalty] = fit
rows.append(
{
"penalty_discount": penalty,
"edges": len(fit.structure.oriented_edges()),
"errors": shd(fit.structure, true_cpdag),
"uniformity p": round(check.uniformity_p_value, 3),
"verdict": "not rejected" if check.passed else "rejected",
}
)
sweep = pd.DataFrame(rows)
sweep
| penalty_discount | edges | errors | uniformity p | verdict | |
|---|---|---|---|---|---|
| 0 | 0.5 | 59 | 18 | 0.267 | not rejected |
| 1 | 1.0 | 52 | 9 | 0.675 | not rejected |
| 2 | 2.0 | 51 | 8 | 0.149 | not rejected |
| 3 | 4.0 | 50 | 15 | 0.247 | not rejected |
| 4 | 8.0 | 50 | 15 | 0.247 | not rejected |
The data reject none of the five graphs, so the rule picks the sparsest, 50 edges at
penalty_discount 4 (8 returns the same graph). It is not the most accurate: its 15 errors against
the true CPDAG are more than the 8 of the 51-edge graph at 2 and the 9 of the 52-edge graph at 1.
The check rules out graphs the data contradict, and among the rest fewer edges is a preference,
not a measure of accuracy.
passing = sweep[sweep.verdict == "not rejected"]
sparsest = passing.loc[passing.edges.idxmin(), "penalty_discount"]
print("sparsest graph that passes: penalty_discount", sparsest)
andrey.viz.draw(fits[sparsest].structure)
sparsest graph that passes: penalty_discount 4.0
PC: no graph passes#
The same sweep over PC’s alpha, the level of its independence tests. PC returns a CPDAG, which
the check reads through a DAG of its class; a graph with no consistent DAG cannot be checked, and
markov_check raises ValueError:
rows = []
for alpha in (0.001, 0.01, 0.05, 0.1, 0.3):
fit = andrey.pc(learn, alpha=alpha)
row = {
"alpha": alpha,
"edges": len(fit.structure.oriented_edges()),
"errors": shd(fit.structure, true_cpdag),
}
try:
check = andrey.markov_check(fit, held)
row["uniformity p"] = round(check.uniformity_p_value, 3)
row["verdict"] = "not rejected" if check.passed else "rejected"
except ValueError:
row["uniformity p"], row["verdict"] = None, "no DAG to check"
rows.append(row)
pd.DataFrame(rows)
| alpha | edges | errors | uniformity p | verdict | |
|---|---|---|---|---|---|
| 0 | 0.001 | 35 | 25 | 0.0 | rejected |
| 1 | 0.010 | 36 | 24 | 0.0 | rejected |
| 2 | 0.050 | 42 | 24 | 0.0 | rejected |
| 3 | 0.100 | 43 | 25 | 0.0 | rejected |
| 4 | 0.300 | 52 | 31 | NaN | no DAG to check |
At every alpha the data reject the graph PC returns, or the graph has no DAG to check, so there
is nothing to choose from. This is a finding about PC on these data and this sample size: PC’s
graphs differ from the true CPDAG by many more errors than GES’s, and the check agrees that they do
not fit the data. It does not say which edges to change; the smallest p-values in table() show
where to look. andrey markov-check runs the same check from the shell.