b3EvCd8sYE / code /repro /generate.py
DineshAI's picture
Publish the round-3 verification: all six claims with raw data, checkers, controls and a visibility matrix
59e1897 verified
Raw
History Blame Contribute Delete
7.44 kB
"""Synthetic model generation, following Appendix D.1 of arXiv:2606.00278.
Two procedures are specified in the paper and both are implemented verbatim:
:func:`sample_linear_model` (used for Figures 2, 3, 8, 9) and
:func:`sample_graphical_model` (used for Figure 5).
"""
from __future__ import annotations
import numpy as np
from .graphical import (
StatementGraph,
has_confounding_path,
transitive_closure,
)
from .linear import A_from_gamma
__all__ = [
"sample_linear_model",
"perturb_statements",
"sample_graphical_model",
"inject_graph_errors",
"marginal_admg",
]
# --------------------------------------------------------------------------
# D.1, linear procedure (steps 1-6)
# --------------------------------------------------------------------------
def sample_linear_model(n: int, m: int, p: float, rng: np.random.Generator):
"""Sample a ground-truth linear Gaussian SEM and its true statements.
Returns ``(Sigma_obs, A_true, Gamma_obs)`` where ``Sigma_obs`` is the
correlation matrix of the ``n`` observed variables, ``Gamma_obs`` the
marginalised causal coefficient matrix and ``A_true = (I - Gamma_obs)^{-1}``
the matrix of correct bivariate causal statements.
Steps, quoting Appendix D.1:
1. draw causal coefficients for ``n + m`` variables from a standard normal
and error variances from an exponential distribution with variance 1;
2. zero each coefficient independently with probability ``1 - p``;
3. compute the covariance matrix assuming independent errors;
4. rescale error variances and coefficients so every variable has unit
variance;
5. marginalise to ``n`` uniformly chosen observed variables (Lemma 2.1);
6. read off ``A = (I - Gamma)^{-1}``.
"""
N = n + m
Gamma = np.tril(rng.normal(size=(N, N)), -1)
Gamma *= (rng.random((N, N)) < p)
Gamma = np.tril(Gamma, -1)
# exponential with variance 1 => scale 1 (mean 1, variance 1)
err_var = rng.exponential(scale=1.0, size=N)
A_full = A_from_gamma(Gamma)
Sigma = A_full @ np.diag(err_var) @ A_full.T
# Step 4: rescale so that every variable has unit variance. Writing
# S = diag(sd), the standardised model has Gamma' = S^{-1} Gamma S and
# error variances err_var / sd^2.
sd = np.sqrt(np.diag(Sigma))
Gamma = Gamma * np.outer(1.0 / sd, sd)
Sigma = Sigma / np.outer(sd, sd)
# Step 5: marginalise to n observed variables.
hidden = rng.choice(N, size=m, replace=False)
obs = np.array(sorted(set(range(N)) - set(hidden.tolist())))
Z = np.array(sorted(hidden.tolist()))
if m > 0:
G_YY = Gamma[np.ix_(obs, obs)]
G_YZ = Gamma[np.ix_(obs, Z)]
G_ZZ = Gamma[np.ix_(Z, Z)]
G_ZY = Gamma[np.ix_(Z, obs)]
Gamma_obs = G_YY + G_YZ @ np.linalg.solve(np.eye(m) - G_ZZ, G_ZY)
else:
Gamma_obs = Gamma.copy()
Gamma_obs = np.tril(Gamma_obs, -1)
Sigma_obs = Sigma[np.ix_(obs, obs)]
A_true = A_from_gamma(Gamma_obs)
return Sigma_obs, A_true, Gamma_obs
def perturb_statements(A_true: np.ndarray, sigma: float,
rng: np.random.Generator) -> np.ndarray:
"""D.1 step 7: add centred Gaussian noise of variance ``sigma`` to each
bivariate statement. ``sigma`` is a *variance*, as written in the paper, so
the added noise has standard deviation ``sqrt(sigma)``."""
n = A_true.shape[0]
A = A_true.copy()
noise = rng.normal(scale=np.sqrt(sigma), size=(n, n))
idx = np.tril_indices(n, -1)
A[idx] = A_true[idx] + noise[idx]
np.fill_diagonal(A, 1.0)
return np.tril(A)
# --------------------------------------------------------------------------
# D.1, graphical procedure
# --------------------------------------------------------------------------
def marginal_admg(D: np.ndarray, Bd: np.ndarray, keep: np.ndarray) -> StatementGraph:
"""Definition 3.2: marginalise a mixed graph onto the vertices ``keep``."""
N = D.shape[0]
full = StatementGraph(N, D, Bd)
hidden = sorted(set(range(N)) - set(keep.tolist()))
k = len(keep)
# 1. directed edge v -> w if a directed path exists with all intermediate
# vertices hidden.
reach = D.copy()
for h in hidden: # allow routing only through hidden vertices
reach = reach | (np.outer(reach[:, h], reach[h, :]) & ~np.eye(N, dtype=bool))
Dm = reach[np.ix_(keep, keep)].copy()
np.fill_diagonal(Dm, False)
# 2. bidirected edge v <-> w if a confounding path exists with all
# intermediate vertices hidden. We test on the subgraph induced by
# {v, w} together with the hidden vertices, which is exactly the
# "all intermediates hidden" restriction.
Bm = np.zeros((k, k), bool)
for a in range(k):
for b in range(a + 1, k):
v, w = int(keep[a]), int(keep[b])
sub = sorted(set(hidden) | {v, w})
idx = np.array(sub)
sg = StatementGraph(len(sub), D[np.ix_(idx, idx)], Bd[np.ix_(idx, idx)])
if has_confounding_path(sg, sub.index(v), sub.index(w)):
Bm[a, b] = Bm[b, a] = True
return StatementGraph(k, Dm, Bm)
def sample_graphical_model(n: int, m: int, p: float, rng: np.random.Generator):
"""Sample ground truth and the correct graphical bivariate statements.
Steps 1-4 of the graphical procedure in Appendix D.1. The returned
statement graph is the union of the pairwise marginal ADMGs, and is by
construction graphically compatible (Lemma 3.5).
"""
N = n + m
perm = rng.permutation(N)
D = np.zeros((N, N), bool)
for a in range(N):
for b in range(a + 1, N):
if rng.random() < p:
D[perm[a], perm[b]] = True
Bd = np.zeros((N, N), bool)
hidden = rng.choice(N, size=m, replace=False)
keep = np.array(sorted(set(range(N)) - set(hidden.tolist())))
marg = marginal_admg(D, Bd, keep)
# Step 4: marginalise further onto each pair and take the union. Under a
# pairwise marginalisation every other vertex is hidden, so the directed
# part of the union is exactly the transitive closure of the observed
# graph, and a bidirected edge appears wherever a confounding path exists.
Bs = np.zeros((n, n), bool)
Ds = transitive_closure(marg.D)
for a in range(n):
for b in range(a + 1, n):
if has_confounding_path(marg, a, b):
Bs[a, b] = Bs[b, a] = True
return StatementGraph(n, Ds, Bs)
def inject_graph_errors(g: StatementGraph, k: int,
rng: np.random.Generator) -> StatementGraph:
"""D.1 step 5: toggle ``k`` uniformly chosen edge slots.
A slot is either one of the ``n(n-1)`` ordered pairs (a directed edge) or
one of the ``C(n,2)`` unordered pairs (a bidirected edge); the chosen slot
is deleted if present and inserted if absent. Slots are drawn without
replacement so that exactly ``k`` statements are altered.
"""
n = g.n
slots = [("d", u, v) for u in range(n) for v in range(n) if u != v]
slots += [("b", u, v) for u in range(n) for v in range(u + 1, n)]
pick = rng.choice(len(slots), size=k, replace=False)
D, Bd = g.D.copy(), g.Bd.copy()
for idx in pick:
kind, u, v = slots[int(idx)]
if kind == "d":
D[u, v] = not D[u, v]
else:
Bd[u, v] = Bd[v, u] = not Bd[u, v]
return StatementGraph(n, D, Bd)