ProCreations's picture
Publish validated ICML reproduction
177308a verified
Raw
History Blame Contribute Delete
13.3 kB
"""
Core library for reproducing "Causal Modeling of Selection in Evolution"
(Dai, Tang, Spirtes, Zhang; ICML 2026; arXiv:2606.05689), OpenReview mOcTXKawFY.
Implements, verbatim from the paper:
* Definition 1 -- evolutionary selection model G^(T)
* Definition 2 -- clique-augmented DAG G^+
* Theorem 3 -- multi-domain clique-augmented DAG G^{+I}
plus a fast exact d-separation oracle, an exact CPDAG (Meek) routine, and the
linear-Gaussian evolutionary data-generating process of Section 5.1 / D.1.
Node conventions
----------------
Static graph G : nodes 0..d-1 are the traits X_1..X_d, node 'S' is the
(sink) selection / reproduction variable.
Unrolled G^(T): ('X', i, t), ('e', i, t), ('S', t).
"""
import itertools
import numpy as np
SEL = 'S'
# ----------------------------------------------------------------------------
# graph containers (dict of parent sets / child sets -- fast, hashable-free)
# ----------------------------------------------------------------------------
class DG:
"""Minimal directed graph: nodes list + parent/child adjacency sets."""
__slots__ = ('nodes', 'pa', 'ch')
def __init__(self, nodes, edges=()):
self.nodes = list(nodes)
self.pa = {v: set() for v in self.nodes}
self.ch = {v: set() for v in self.nodes}
for (u, v) in edges:
self.add(u, v)
def add(self, u, v):
self.pa[v].add(u)
self.ch[u].add(v)
def edges(self):
return [(u, v) for v in self.nodes for u in self.pa[v]]
def n_edges(self):
return sum(len(self.pa[v]) for v in self.nodes)
def has(self, u, v):
return u in self.pa[v]
def ancestors(self, targets):
"""an(targets) INCLUDING the targets themselves (paper's convention)."""
seen, stack = set(), list(targets)
while stack:
y = stack.pop()
if y in seen:
continue
seen.add(y)
stack.extend(self.pa[y])
return seen
def is_acyclic(self):
indeg = {v: len(self.pa[v]) for v in self.nodes}
q = [v for v in self.nodes if indeg[v] == 0]
n = 0
while q:
v = q.pop()
n += 1
for w in self.ch[v]:
indeg[w] -= 1
if indeg[w] == 0:
q.append(w)
return n == len(self.nodes)
def topo(self):
indeg = {v: len(self.pa[v]) for v in self.nodes}
q = sorted([v for v in self.nodes if indeg[v] == 0], key=str)
out = []
while q:
v = q.pop(0)
out.append(v)
for w in sorted(self.ch[v], key=str):
indeg[w] -= 1
if indeg[w] == 0:
q.append(w)
return out
# ----------------------------------------------------------------------------
# exact d-separation (Koller & Friedman Alg. 3.1 "reachable", Bayes-Ball)
# ----------------------------------------------------------------------------
def reachable(g, A, Z):
"""Set of nodes d-connected to some a in A given Z."""
# phase I: ancestors of Z
anZ, stack = set(), list(Z)
while stack:
y = stack.pop()
if y in anZ:
continue
anZ.add(y)
stack.extend(g.pa[y])
# phase II
L = [(a, 1) for a in A] # 1 = arriving "from a child" (going up)
V, R = set(), set()
Zs = set(Z)
while L:
y, dr = L.pop()
if (y, dr) in V:
continue
V.add((y, dr))
if y not in Zs:
R.add(y)
if dr == 1 and y not in Zs:
for z in g.pa[y]:
L.append((z, 1))
for z in g.ch[y]:
L.append((z, 0))
elif dr == 0:
if y not in Zs:
for z in g.ch[y]:
L.append((z, 0))
if y in anZ:
for z in g.pa[y]:
L.append((z, 1))
return R
def dsep(g, A, B, C):
"""True iff A _||_ B | C (d-separation) in DAG g."""
return not (reachable(g, A, C) & set(B))
# ----------------------------------------------------------------------------
# Definition 1: evolutionary selection model G^(T)
# ----------------------------------------------------------------------------
def evolutionary_graph(G, d, T):
"""Definition 1 verbatim. Returns DG over ('X',i,t), ('e',i,t), ('S',t)."""
nodes = ([('X', i, t) for t in range(T + 1) for i in range(d)]
+ [('e', i, t) for t in range(T + 1) for i in range(d)]
+ [('S', t) for t in range(T)])
g = DG(nodes)
for t in range(T + 1):
# (i) direct causal effects among traits within generations, t=0..T
for j in range(d):
for i in G.pa[j]:
if i != SEL:
g.add(('X', i, t), ('X', j, t))
# (iii) governing mechanisms of exogenous factors on traits, t=0..T
for i in range(d):
g.add(('e', i, t), ('X', i, t))
for t in range(T):
# (ii) effects of traits on that generation's reproduction, t=0..T-1
for i in G.pa[SEL]:
g.add(('X', i, t), ('S', t))
# (iv) inheritance / mutation of exogenous factors, t=0..T-1
for i in range(d):
g.add(('e', i, t), ('e', i, t + 1))
return g
def evo_counts(G, d, T):
"""Closed-form |V|, |E| of G^(T) implied by Definition 1."""
e_xx = sum(1 for j in range(d) for i in G.pa[j] if i != SEL)
e_xs = len(G.pa[SEL])
return (2 * d * (T + 1) + T,
e_xx * (T + 1) + e_xs * T + d * (T + 1) + d * T)
# ----------------------------------------------------------------------------
# Definition 2: clique-augmented DAG G^+
# ----------------------------------------------------------------------------
def clique_augmented(G, d, order=None):
"""Definition 2 verbatim: X_i -> X_j in G^+ iff X_i -> X_j in G, or
{X_i,X_j} subseteq an_G(S) and pi(X_i) < pi(X_j)."""
if order is None:
order = [v for v in G.topo() if v != SEL]
pos = {v: k for k, v in enumerate(order)}
anS = G.ancestors([SEL]) - {SEL}
gp = DG(range(d))
for j in range(d):
for i in G.pa[j]:
if i != SEL:
gp.add(i, j)
for a, b in itertools.combinations(sorted(anS, key=lambda v: pos[v]), 2):
if not gp.has(a, b):
gp.add(a, b)
return gp
def multidomain_augmented(G, d, I, order=None):
"""Theorem 3 verbatim. I subseteq X u {S} is the set of changed mechanisms.
G^{+I} = G^+ + zeta, with zeta -> X_i for X_i in I, and, if
an_G(S) n I != {}, zeta -> every member of an_G(S)\\{S}."""
gp = clique_augmented(G, d, order)
g = DG(list(range(d)) + ['zeta'])
for (u, v) in gp.edges():
g.add(u, v)
tgt = set(x for x in I if x != SEL)
anS = G.ancestors([SEL]) - {SEL}
if anS & set(I) or (SEL in I):
tgt |= anS
for x in sorted(tgt):
g.add('zeta', x)
return g
# ----------------------------------------------------------------------------
# CPDAG: v-structures + Meek's rules R1-R4 to closure
# ----------------------------------------------------------------------------
def cpdag(g, nodes=None, forced=()):
"""CPDAG of DAG g. `forced` = extra background-knowledge orientations
(u,v) applied before Meek closure (used for CDNOD's zeta root edges).
Returns (directed set, undirected set of frozensets)."""
nodes = list(g.nodes) if nodes is None else list(nodes)
adj = {v: set() for v in nodes}
for (u, v) in g.edges():
adj[u].add(v)
adj[v].add(u)
directed = set()
# v-structures
for b in nodes:
ps = sorted(g.pa[b], key=str)
for a, c in itertools.combinations(ps, 2):
if c not in adj[a]:
directed.add((a, b))
directed.add((c, b))
directed |= set(forced)
und = set(frozenset((u, v)) for (u, v) in g.edges()
if (u, v) not in directed and (v, u) not in directed)
_meek(nodes, adj, directed, und)
return directed, und
def _meek(nodes, adj, directed, und):
changed = True
while changed:
changed = False
for e in list(und):
a, b = tuple(e)
for (x, y) in ((a, b), (b, a)):
# R1: z -> x , x - y , z not adj y => x -> y
if any((z, x) in directed and z not in adj[y]
for z in adj[x] if z != y):
directed.add((x, y)); und.discard(e); changed = True; break
# R2: x -> z -> y and x - y => x -> y
if any((x, z) in directed and (z, y) in directed
for z in adj[x] & adj[y]):
directed.add((x, y)); und.discard(e); changed = True; break
# R3: x - z1, x - z2, z1 -> y, z2 -> y, z1 !adj z2, x - y
cs = [z for z in adj[x] & adj[y]
if (z, y) in directed and frozenset((x, z)) in und]
if any(z2 not in adj[z1] for z1, z2 in itertools.combinations(cs, 2)):
directed.add((x, y)); und.discard(e); changed = True; break
# R4: x - z1, z1 -> z2, z2 -> y, x - y, x - z2 (z1 !adj y)
ok = False
for z2 in adj[x] & adj[y]:
if (z2, y) not in directed:
continue
for z1 in adj[x] & adj[z2]:
if z1 != y and (z1, z2) in directed and \
frozenset((x, z1)) in und and y not in adj[z1]:
ok = True
break
if ok:
break
if ok:
directed.add((x, y)); und.discard(e); changed = True; break
def cpdag_key(directed, und, d):
"""Canonical hashable key of a CPDAG on 0..d-1."""
return (tuple(sorted(directed)), tuple(sorted(tuple(sorted(e)) for e in und)))
# ----------------------------------------------------------------------------
# random static models
# ----------------------------------------------------------------------------
def random_static_dag(d, rng, n_edges=None, avg_deg=2.0, n_sel_parents=None):
"""Erdos-Renyi DAG over d traits with average degree `avg_deg` (Section 5.1),
plus a selection variable S with `n_sel_parents` (default d/5) parents."""
if n_edges is None:
n_edges = int(round(avg_deg * d / 2))
perm = rng.permutation(d)
pairs = [(perm[i], perm[j]) for i in range(d) for j in range(i + 1, d)]
idx = rng.choice(len(pairs), size=min(n_edges, len(pairs)), replace=False)
G = DG(list(range(d)) + [SEL])
for k in idx:
G.add(*pairs[k])
k = int(d // 5) if n_sel_parents is None else n_sel_parents
if k > 0:
for i in rng.choice(d, size=min(k, d), replace=False):
G.add(int(i), SEL)
return G
# ----------------------------------------------------------------------------
# Section 5.1 / D.1 linear-Gaussian evolutionary data-generating process
# ----------------------------------------------------------------------------
def sem_params(G, d, rng):
"""Edge coefficients ~ U([-2,-0.5] u [0.5,2]); noise variances ~ U[1,4]."""
B = np.zeros((d, d))
for j in range(d):
for i in G.pa[j]:
if i != SEL:
mag = rng.uniform(0.5, 2.0)
B[i, j] = mag * (1 if rng.random() < .5 else -1)
w = np.zeros(d)
for i in G.pa[SEL]:
if i != SEL:
mag = rng.uniform(0.5, 2.0)
w[i] = mag * (1 if rng.random() < .5 else -1)
var = rng.uniform(1.0, 4.0, size=d)
return B, w, var
def _traits(B, eps):
"""Solve X = X B + eps for a linear SEM with upper-triangular-izable B."""
d = B.shape[0]
return eps @ np.linalg.inv(np.eye(d) - B)
def simulate_evolution(G, d, T, n, rng, selection=True, inherit=True,
B=None, w=None, var=None, s_noise=1.0):
"""Section 5.1 + Appendix D.1 verbatim:
- each generation ranks samples by S; ranks are cut into 6 uniform
segments giving 0,1,...,5 offspring (~2.5x growth), then the next
generation is randomly downsampled back to n;
- each offspring inherits eps^(t+1) = eps^(t) + N(0,1);
- X^(t+1) is generated from the same SEM.
Returns X^(T) of the surviving generation (n x d)."""
if B is None:
B, w, var = sem_params(G, d, rng)
eps = rng.normal(0, np.sqrt(var), size=(n, d))
X = _traits(B, eps)
for t in range(T):
if selection:
s = X @ w + rng.normal(0, s_noise, size=n)
rank = np.argsort(np.argsort(s))
k = (rank * 6) // n # 0..5 offspring
else:
k = np.full(n, 3, dtype=int) # reproduction completely at random
parent = np.repeat(np.arange(n), k)
if len(parent) == 0:
parent = np.arange(n)
if inherit:
eps = eps[parent] + rng.normal(0, 1.0, size=(len(parent), d))
else:
eps = rng.normal(0, np.sqrt(var), size=(len(parent), d))
X = _traits(B, eps)
keep = rng.choice(len(parent), size=n, replace=len(parent) < n)
X, eps = X[keep], eps[keep]
return X, (B, w, var)