| """ |
| 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' |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| def reachable(g, A, Z): |
| """Set of nodes d-connected to some a in A given Z.""" |
| |
| anZ, stack = set(), list(Z) |
| while stack: |
| y = stack.pop() |
| if y in anZ: |
| continue |
| anZ.add(y) |
| stack.extend(g.pa[y]) |
| |
| L = [(a, 1) for a in A] |
| 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)) |
|
|
|
|
| |
| |
| |
| 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): |
| |
| for j in range(d): |
| for i in G.pa[j]: |
| if i != SEL: |
| g.add(('X', i, t), ('X', j, t)) |
| |
| for i in range(d): |
| g.add(('e', i, t), ('X', i, t)) |
| for t in range(T): |
| |
| for i in G.pa[SEL]: |
| g.add(('X', i, t), ('S', t)) |
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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() |
| |
| 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)): |
| |
| 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 |
| |
| 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 |
| |
| 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 |
| |
| 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))) |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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 |
| else: |
| k = np.full(n, 3, dtype=int) |
| 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) |
|
|