Coverage for src/causalspyne/dag_gen_topo_order.py: 56%
45 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-05-26 05:29 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-05-26 05:29 +0000
1import numpy as np
4def _can_reach(mat, src, dst):
5 """Return True if there is a directed path from src to dst in mat."""
6 n = mat.shape[0]
7 visited = set()
8 stack = [src]
9 while stack:
10 node = stack.pop()
11 if node == dst:
12 return True
13 if node in visited:
14 continue
15 visited.add(node)
16 # children of node: columns where mat[:, node] != 0
17 for child in np.where(mat[:, node] != 0)[0]:
18 if child not in visited:
19 stack.append(child)
20 return False
23class RandTopoOrderDAG:
24 """
25 Generate a random DAG by sampling a random topological order, then
26 independently including each forward edge with probability
27 degree / (num_nodes - 1).
29 Guaranteed to produce a valid DAG: edges only go from earlier to
30 later positions in the sampled order, so no cycle is possible.
32 Matrix convention matches Erdos_Renyi_PLP: entry (i, j) means j -> i.
33 """
35 def __init__(self, rng):
36 self.rng = rng
38 def __call__(self, num_nodes, degree):
39 prob = float(degree) / (num_nodes - 1)
40 topo_order = self.rng.permutation(num_nodes) # topo_order[k] = node at position k
41 mat = np.zeros((num_nodes, num_nodes))
42 for k in range(num_nodes):
43 for l in range(k + 1, num_nodes):
44 if self.rng.random() < prob:
45 src, dst = topo_order[k], topo_order[l]
46 mat[dst, src] = 1.0 # src -> dst
47 return mat
50class RootConfounderDAG(RandTopoOrderDAG):
51 """
52 Extends RandTopoOrderDAG so that every root node (no incoming edges)
53 is guaranteed to be a confounder (at least 2 outgoing edges).
55 After base generation, any root with fewer than 2 children gets extra
56 edges added to nodes that cannot reach the root (cycle-safe by construction).
57 """
59 def __call__(self, num_nodes, degree):
60 mat = super().__call__(num_nodes, degree)
61 root_nodes = list(np.where(mat.sum(axis=1) == 0)[0])
62 for root in root_nodes:
63 current_children = list(np.where(mat[:, root] > 0)[0])
64 needed = max(0, 2 - len(current_children))
65 if needed == 0:
66 continue
67 # exclude nodes that can reach root (adding root->n would create a cycle)
68 candidates = [n for n in range(num_nodes)
69 if n != root
70 and n not in current_children
71 and not _can_reach(mat, n, root)]
72 if len(candidates) < needed:
73 continue
74 targets = self.rng.choice(candidates, size=needed, replace=False)
75 for t in targets:
76 mat[t, root] = 1.0
77 return mat