File size: 7,855 Bytes
177308a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
"""
CORRECTNESS GATES.  Everything downstream depends on two primitives:
  (a) the d-separation oracle          -> cross-checked against networkx
  (b) the DAG -> CPDAG routine (Meek)  -> cross-checked against causal-learn
plus two re-derivations of results the literature already establishes:
  (c) Verma-Pearl: the CPDAG's directed edges are exactly the edges that are
      invariant over the Markov equivalence class (checked by brute-force
      enumeration of every DAG with the same skeleton & v-structures);
  (d) the global Markov property of the simulated linear-Gaussian SCM
      (d-separation  <=>  vanishing partial correlation), which gates the
      finite-sample experiments.
Run:  python3 gates.py
"""
import itertools, json, os, warnings
import numpy as np
import networkx as nx
from evosel import (DG, SEL, dsep, cpdag, random_static_dag, clique_augmented,
                    evolutionary_graph, simulate_evolution, _traits, sem_params)

warnings.filterwarnings('ignore')
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'outputs')
os.makedirs(OUT, exist_ok=True)
res = {}


def to_nx(g):
    G = nx.DiGraph()
    G.add_nodes_from(g.nodes)
    G.add_edges_from(g.edges())
    return G


# --------------------------------------------------------------- (a) d-sep
def gate_dsep(n_models=400, seed=0):
    rng = np.random.default_rng(seed)
    n_q = 0
    bad = 0
    for _ in range(n_models):
        d = int(rng.integers(3, 8))
        G = random_static_dag(d, rng, avg_deg=float(rng.uniform(1.0, 3.0)))
        T = int(rng.integers(1, 4))
        g = evolutionary_graph(G, d, T)
        nxg = to_nx(g)
        nodes = list(g.nodes)
        for _ in range(30):
            k = rng.integers(0, 3, size=len(nodes))
            A = [v for v, kk in zip(nodes, k) if kk == 0]
            B = [v for v, kk in zip(nodes, k) if kk == 1]
            C = [v for v, kk in zip(nodes, k) if kk == 2]
            if not A or not B:
                continue
            mine = dsep(g, A, B, C)
            theirs = nx.is_d_separator(nxg, set(A), set(B), set(C))
            n_q += 1
            bad += (mine != theirs)
    return {'queries': n_q, 'disagreements': bad}


# --------------------------------------------------------------- (b) CPDAG
def gate_cpdag(n_models=600, seed=1):
    from causallearn.graph.Dag import Dag
    from causallearn.graph.GraphNode import GraphNode
    from causallearn.utils.DAG2CPDAG import dag2cpdag
    from causallearn.graph.Endpoint import Endpoint
    rng = np.random.default_rng(seed)
    bad = 0
    n = 0
    for _ in range(n_models):
        d = int(rng.integers(3, 9))
        G = random_static_dag(d, rng, avg_deg=float(rng.uniform(1.0, 3.5)))
        gp = clique_augmented(G, d)
        mine_d, mine_u = cpdag(gp)
        # causal-learn oracle
        nds = [GraphNode('V%d' % i) for i in range(d)]
        dag = Dag(nds)
        for (u, v) in gp.edges():
            dag.add_directed_edge(nds[u], nds[v])
        cp = dag2cpdag(dag)
        cl_d, cl_u = set(), set()
        for e in cp.get_graph_edges():
            i = int(e.get_node1().get_name()[1:])
            j = int(e.get_node2().get_name()[1:])
            e1, e2 = e.get_endpoint1(), e.get_endpoint2()
            if e1 == Endpoint.TAIL and e2 == Endpoint.ARROW:
                cl_d.add((i, j))
            elif e1 == Endpoint.ARROW and e2 == Endpoint.TAIL:
                cl_d.add((j, i))
            else:
                cl_u.add(frozenset((i, j)))
        n += 1
        bad += (mine_d != cl_d or mine_u != cl_u)
    return {'models': n, 'disagreements': bad}


# ------------------------------------------- (c) Verma-Pearl invariance check
def all_dags(d):
    """All labelled DAGs on d nodes (exhaustive)."""
    pairs = list(itertools.combinations(range(d), 2))
    out = []
    for code in itertools.product((0, 1, 2), repeat=len(pairs)):
        g = DG(range(d))
        for (a, b), c in zip(pairs, code):
            if c == 1:
                g.add(a, b)
            elif c == 2:
                g.add(b, a)
        if g.is_acyclic():
            out.append(g)
    return out


def gate_verma(d=4):
    dags = all_dags(d)
    groups = {}
    for g in dags:
        dd, uu = cpdag(g)
        key = (tuple(sorted(dd)), tuple(sorted(tuple(sorted(e)) for e in uu)))
        groups.setdefault(key, []).append(g)
    bad_dir = bad_und = 0
    for key, members in groups.items():
        dd = set(key[0])
        # every member must contain every compelled edge with the same direction
        for g in members:
            es = set(g.edges())
            if not dd <= es:
                bad_dir += 1
        # every undirected edge must be reversible somewhere in the class
        for e in key[1]:
            a, b = e
            if not (any(g.has(a, b) for g in members) and
                    any(g.has(b, a) for g in members)):
                bad_und += 1
    return {'n_dags': len(dags), 'n_equivalence_classes': len(groups),
            'compelled_edge_violations': bad_dir,
            'reversibility_violations': bad_und}


# --------------------------------------- (d) global Markov property of the SCM
def partial_corr(Sig, i, j, C):
    idx = [i, j] + list(C)
    M = np.linalg.inv(Sig[np.ix_(idx, idx)])
    return -M[0, 1] / np.sqrt(M[0, 0] * M[1, 1])


def gate_markov(seeds=(7, 8, 9), n=400000, d=6):
    """Population-level check on the *evolutionary* DGP: partial correlations
    must vanish for the d-separations of G^+ (Theorem 1) and not otherwise.
    Also a directly falsifiable control: triples that the selection-blind
    static graph (G with S deleted) declares independent but G^+ does not."""
    out = []
    for seed in seeds:
        rng = np.random.default_rng(seed)
        G = random_static_dag(d, rng, avg_deg=2.0, n_sel_parents=2)
        X, _ = simulate_evolution(G, d, T=3, n=n, rng=rng)
        Sig = np.cov(X.T)
        gp = clique_augmented(G, d)
        naive = DG(range(d))
        for j in range(d):
            for i in G.pa[j]:
                if i != SEL:
                    naive.add(i, j)
        sep, con, ctrl = [], [], []
        for i, j in itertools.combinations(range(d), 2):
            rest = [k for k in range(d) if k not in (i, j)]
            for r in range(len(rest) + 1):
                for C in itertools.combinations(rest, r):
                    pc = abs(partial_corr(Sig, i, j, C))
                    s_plus = dsep(gp, [i], [j], list(C))
                    (sep if s_plus else con).append(pc)
                    if (not s_plus) and dsep(naive, [i], [j], list(C)):
                        ctrl.append(pc)
        mx = float(max(sep)) if sep else 0.0
        out.append({
            'seed': seed, 'n_dsep_triples': len(sep), 'n_dconn_triples': len(con),
            'max_abs_pcorr_when_dseparated_in_Gplus': mx,
            'frac_dconnected_above_that_max': float(np.mean(np.array(con) > mx)),
            'median_abs_pcorr_when_dconnected': float(np.median(con)),
            'n_control_triples_static_says_indep': len(ctrl),
            'median_abs_pcorr_on_control_triples': float(np.median(ctrl)) if ctrl else None,
            'max_abs_pcorr_on_control_triples': float(max(ctrl)) if ctrl else None})
    return {'n_samples': int(n), 'd': d, 'T': 3, 'per_seed': out}


if __name__ == '__main__':
    res['gate_a_dseparation_vs_networkx'] = gate_dsep()
    print('a', res['gate_a_dseparation_vs_networkx'])
    res['gate_b_cpdag_vs_causallearn'] = gate_cpdag()
    print('b', res['gate_b_cpdag_vs_causallearn'])
    res['gate_c_verma_pearl_invariance'] = gate_verma()
    print('c', res['gate_c_verma_pearl_invariance'])
    res['gate_d_global_markov_of_simulated_scm'] = gate_markov()
    print('d', res['gate_d_global_markov_of_simulated_scm'])
    json.dump(res, open(os.path.join(OUT, 'gates.json'), 'w'), indent=1)
    print('written')