causalscale / tests /test_protocol.py
sgao-academics's picture
Release 4.0.0: repository is the replication package (protocol, records, audit, conventions, fixed toolkit)
b890fab verified
Raw History Blame Contribute Delete
6.07 kB
# -*- coding: utf-8 -*-
"""Tests for the benchmark protocol.
python -m pytest tests/ -q
python tests/test_protocol.py # plain-python fallback
These cover the properties a reader is entitled to assume without re-running the
solvers: the generators are actually acyclic, the metric is orientation-sensitive
in the way the paper says, and the records on disk agree with the protocol that is
supposed to have produced them.
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
from experiments import protocol as P # noqa: E402
def test_er_dag_is_acyclic():
"""S[i, j] != 0 means i -> j with i < j, so the lower triangle is empty.
That is the convention the whole protocol rests on: parents come first, and
sample_sem reads column j's parents off S[:, j].
"""
for d in (30, 100, 150):
S = P.make_er_dag(d, 0)
assert np.allclose(np.tril(S, k=-1), 0), \
"lower triangle must be zero: an edge i->j requires i < j"
assert (np.abs(S) > 0).sum() > 0, "a graph with no edges would be degenerate"
assert S.shape == (d, d)
def test_er_dag_density_matches_the_declared_parameter():
"""`p = 2/(d-1)` over d(d-1)/2 positions gives an expected d edges.
Equivalently a total (in + out) degree of 2, which is what the paper's
"expected degree 2" means. Averaged over seeds so that the test measures the
parameter rather than one draw; the spread for d = 400 over 5 seeds is a few
percent, so a +-20% band is generous without being vacuous.
"""
d = 400
seeds = 5
mean_edges = sum(int((np.abs(P.make_er_dag(d, s)) > 0).sum())
for s in range(seeds)) / seeds
assert 0.8 * d < mean_edges < 1.2 * d, (
f"expected ~{d} edges (p = 2/(d-1)), got {mean_edges:.0f}")
def test_ba_dag_is_acyclic_and_heavy_tailed():
"""The scale-free skeleton must remain acyclic and must actually have hubs."""
d = 200
S = P.make_ba_dag(d, 0)
indeg = (np.abs(S) > 0).sum(0)
assert indeg.max() >= 2, "every arriving node receives at least one parent"
outdeg = (np.abs(S) > 0).sum(1)
assert outdeg.max() > 3 * max(np.median(outdeg), 1), \
"hub structure missing -- this would not be a scale-free skeleton"
assert (np.abs(S) > 0).sum() > d, "a scale-free graph cannot be this sparse"
def test_dag_is_a_dag_by_permutation():
"""A DAG exists iff some permutation of the rows makes it triangular.
Checked constructively: keep removing sinks. If a cycle existed, a step
would find no sink.
"""
S = P.make_ba_dag(80, 3)
A = (np.abs(S) > 0).astype(int)
# topological order recoverable by repeated sink removal
remaining = set(range(A.shape[0]))
removed = 0
while remaining:
sinks = [j for j in remaining if A[list(remaining), j].sum() == 0]
assert sinks, "cycle detected: no sink in the remaining graph"
for j in sinks:
remaining.discard(j)
removed += 1
assert removed == A.shape[0]
def test_score_is_orientation_sensitive():
S = P.make_er_dag(50, 1)
exact = P.score(S, S)
assert exact["f1"] > 0.999, "the truth must score ~1 against itself"
transposed = P.score(S.T, S)
assert transposed["f1"] < exact["f1"], \
"transposing must not score as well -- the metric scores direction"
def test_score_masks_the_diagonal():
d = 20
S = P.make_er_dag(d, 2)
W = S + np.eye(d) * 5.0 # self-loops only
m = P.score(W, S)
assert m["edges"] == (np.abs(S) > 0.3).sum(), \
"self-loops must not be counted as edges"
def test_sample_sem_shape_and_standardisation():
S = P.make_er_dag(40, 4)
X = P.sample_sem(S, 200, 4)
assert X.shape == (200, 40)
assert np.allclose(X.mean(0), 0, atol=1e-6)
assert np.allclose(X.std(0), 1, atol=1e-6)
def test_records_agree_with_the_protocol():
"""Every released record row must correspond to a cell a suite declares.
This is what stops a stale or hand-edited record from silently becoming a
table: if a row's (dimension, seed, method) triple is not produced by any
suite in run_sweep.py, it is not part of the protocol.
"""
rec_dir = os.path.join(ROOT, "experiments", "records")
if not os.path.isdir(rec_dir):
return
from experiments.run_sweep import SUITES
for name, (fname, gen) in SUITES.items():
path = os.path.join(rec_dir, fname)
if not os.path.exists(path):
continue
declared = {(c["d"], c["seed"], c["method"]) for c in gen()}
for line in open(path, encoding="utf-8"):
line = line.strip()
if not line:
continue
row = json.loads(line)
# a suite that varies only the schedule does not name a method; it
# is NOTEARS, and the default records that.
triple = (row.get("d"), row.get("seed"),
row.get("method", "nt_official"))
assert triple in declared, (
f"{name}: row {triple} is not produced by suite '{name}' "
f"-- the records and the declared protocol have diverged")
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
failed = 0
for fn in fns:
try:
fn()
print(f"PASS {fn.__name__}")
except AssertionError as e:
failed += 1
print(f"FAIL {fn.__name__}: {e}")
except Exception as e: # noqa: BLE001
failed += 1
print(f"ERROR {fn.__name__}: {type(e).__name__}: {e}")
print(f"\n{len(fns) - failed}/{len(fns)} tests passed")
return failed
if __name__ == "__main__":
sys.exit(1 if _run_all() else 0)