ProCreations's picture
Publish validated ICML reproduction
177308a verified
Raw
History Blame Contribute Delete
18.8 kB
"""
EXACT, EXHAUSTIVE verification of Lemma 1, Theorem 1, Theorem 2 and Theorem 4
over the COMPLETE space of 4-trait evolutionary selection models:
all 543 labelled DAGs on X_1..X_4 x all 2^4 = 16 choices of pa(S)
= 8,688 static models G, each unrolled to T = 1,2,3,
x all 55 disjoint triples (A,B,C) with A,B non-empty.
Nothing here is sampled or simulated: every number is an exact combinatorial
count at machine precision. A random-model sweep at d = 5..10 extends the same
checks beyond the exhaustive range.
Run: python3 exhaustive.py
"""
import itertools, json, os, time
import numpy as np
from evosel import (DG, SEL, dsep, cpdag, cpdag_key, clique_augmented,
multidomain_augmented, evolutionary_graph, evo_counts,
random_static_dag)
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'outputs')
os.makedirs(OUT, exist_ok=True)
# --------------------------------------------------------------------------
def all_static_models(d):
"""Every labelled DAG on d traits x every subset of X as pa(S)."""
pairs = list(itertools.combinations(range(d), 2))
dags = []
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():
dags.append([(u, v) for (u, v) in g.edges()])
models = []
for es in dags:
for r in range(d + 1):
for ps in itertools.combinations(range(d), r):
G = DG(list(range(d)) + [SEL], es)
for p in ps:
G.add(p, SEL)
models.append(G)
return dags, models
def triples(d):
"""All (A,B,C) disjoint subsets of X with A,B non-empty, de-duplicated
under the A<->B symmetry."""
out = []
for code in itertools.product((0, 1, 2, 3), repeat=d):
A = [i for i in range(d) if code[i] == 0]
B = [i for i in range(d) if code[i] == 1]
C = [i for i in range(d) if code[i] == 2]
if A and B and A[0] < B[0]:
out.append((A, B, C))
return out
def naive_dag(G, d):
"""The selection-blind baseline: G with S simply deleted."""
g = DG(range(d))
for j in range(d):
for i in G.pa[j]:
if i != SEL:
g.add(i, j)
return g
# --------------------------------------------------------------------------
def run_dsep_claims(d=4, Ts=(1, 2, 3)):
"""Lemma 1 (implication + converse failure) and Theorem 1 (biconditional),
plus the two destructive controls."""
_, models = all_static_models(d)
trs = triples(d)
R = {'d': d, 'n_models': len(models), 'n_triples_per_model': len(trs),
'Ts': list(Ts)}
lem_tested = lem_viol = lem_conv_fail = 0
thm_tested = thm_viol = 0
naive_tested = naive_mism = 0
noinherit_tested = noinherit_conv_fail = 0
degenerate_ok = degenerate_n = 0
per_T = {}
for T in Ts:
t_l = t_v = t_c = t_t = t_tv = t_nm = 0
for G in models:
gT = evolutionary_graph(G, d, T)
gp = clique_augmented(G, d)
gn = naive_dag(G, d)
Sall = [('S', t) for t in range(T)]
for (A, B, C) in trs:
AT = [('X', i, T) for i in A]
BT = [('X', i, T) for i in B]
CT = [('X', i, T) for i in C] + Sall
ev = dsep(gT, AT, BT, CT)
st = dsep(G, A, B, C + [SEL]) # static model G
pl = dsep(gp, A, B, C) # clique-augmented G^+
nv = dsep(gn, A, B, C) # S-deleted baseline
t_l += 1
t_v += (ev and not st) # Lemma 1 violation
t_c += (st and not ev) # converse failure witness
t_t += 1
t_tv += (ev != pl) # Theorem 1 violation
t_nm += (ev != nv) # control: naive mismatch
per_T[T] = {'lemma1_tested': t_l, 'lemma1_violations': t_v,
'lemma1_converse_failures': t_c,
'theorem1_tested': t_t, 'theorem1_violations': t_tv,
'control_naive_Sdeleted_mismatches': t_nm}
lem_tested += t_l; lem_viol += t_v; lem_conv_fail += t_c
thm_tested += t_t; thm_viol += t_tv
naive_tested += t_t; naive_mism += t_nm
R['per_T'] = per_T
R['lemma1'] = {'tested': lem_tested, 'violations': lem_viol,
'converse_failure_witnesses': lem_conv_fail}
R['theorem1'] = {'tested': thm_tested, 'violations': thm_viol,
'agreement': 1.0 - thm_viol / max(thm_tested, 1)}
R['control_naive'] = {'tested': naive_tested, 'mismatches': naive_mism,
'agreement': 1.0 - naive_mism / max(naive_tested, 1)}
# ---- Theorem 1, implication 1: the d-separations do not depend on T
diff_T = 0
for G in models:
sigs = []
for T in (1, 2, 3, 4):
gT = evolutionary_graph(G, d, T)
Sall = [('S', t) for t in range(T)]
sigs.append(tuple(dsep(gT, [('X', i, T) for i in A],
[('X', i, T) for i in B],
[('X', i, T) for i in C] + Sall)
for (A, B, C) in trs))
diff_T += (len(set(sigs)) != 1)
R['theorem1_implication1_T_invariance'] = {
'models': len(models), 'models_whose_dsep_set_changes_with_T': diff_T,
'Ts_compared': [1, 2, 3, 4]}
# ---- Theorem 1, implication 2: pa(S) = {} => G^+ == G minus S
for G in models:
if not G.pa[SEL]:
degenerate_n += 1
degenerate_ok += (sorted(clique_augmented(G, d).edges())
== sorted(naive_dag(G, d).edges()))
R['theorem1_implication2_degenerate'] = {
'models_with_no_selection_parents': degenerate_n,
'models_where_Gplus_equals_G_minus_S': degenerate_ok}
# ---- destructive control: delete the inheritance edges eps^t -> eps^t+1.
# Without inheritance the repeated selection cannot propagate, so the
# Lemma-1 converse failures must disappear.
T = 3
for G in models:
gT = evolutionary_graph(G, d, T)
g2 = DG(gT.nodes)
for (u, v) in gT.edges():
if not (u[0] == 'e' and v[0] == 'e'):
g2.add(u, v)
gn = naive_dag(G, d)
Sall = [('S', t) for t in range(T)]
for (A, B, C) in trs:
ev = dsep(g2, [('X', i, T) for i in A], [('X', i, T) for i in B],
[('X', i, T) for i in C] + Sall)
noinherit_tested += 1
noinherit_conv_fail += (dsep(G, A, B, C + [SEL]) and not ev)
R['control_no_inheritance'] = {
'tested': noinherit_tested,
'lemma1_converse_failures_without_inheritance': noinherit_conv_fail}
return R
# --------------------------------------------------------------------------
def run_theorem2(d=4):
"""Theorem 2: adjacency soundness+completeness, orientation soundness, and
orientation COMPLETENESS (checked by exhaustive search for the alternative
DAG G' the theorem asserts must exist)."""
_, models = all_static_models(d)
keys, info = [], []
for G in models:
gp = clique_augmented(G, d)
dd, uu = cpdag(gp)
keys.append(cpdag_key(dd, uu, d))
rel = {}
for i, j in itertools.combinations(range(d), 2):
rel[(i, j)] = 1 if G.has(i, j) else (2 if G.has(j, i) else 0)
info.append((G, dd, uu, rel))
# group models by the CPDAG that Algorithm 1 would output
groups = {}
for k, (G, dd, uu, rel) in zip(keys, info):
groups.setdefault(k, []).append(rel)
n_pairs = adj_bad = 0
n_or = or_bad = 0
n_un = un_incomplete = 0
n_or_into_anS = 0
for k, (G, dd, uu, rel) in zip(keys, info):
anS = G.ancestors([SEL]) - {SEL}
adj = set()
for (u, v) in dd:
adj.add(frozenset((u, v)))
adj |= set(uu)
for i, j in itertools.combinations(range(d), 2):
n_pairs += 1
truth = (G.has(i, j) or G.has(j, i) or ({i, j} <= anS))
adj_bad += (truth != (frozenset((i, j)) in adj))
for (u, v) in dd:
n_or += 1
or_bad += not (G.has(u, v) and v not in anS)
n_or_into_anS += (v in anS)
alts = groups[k]
for e in uu:
i, j = sorted(tuple(e))
n_un += 1
if not any(r[(i, j)] != rel[(i, j)] for r in alts):
un_incomplete += 1
return {'d': d, 'n_models': len(models),
'adjacency_pairs_tested': n_pairs, 'adjacency_violations': adj_bad,
'oriented_edges_tested': n_or, 'orientation_soundness_violations': or_bad,
'oriented_edges_whose_head_is_in_an(S)': n_or_into_anS,
'unoriented_edges_tested': n_un,
'orientation_completeness_failures': un_incomplete,
'n_distinct_cpdags': len(groups)}
def run_theorem2_control(d=4, n=3000, seed=3):
"""Destructive control for Theorem 2: corrupt one oriented edge of the
CPDAG (reverse it) and confirm the soundness checker fires."""
rng = np.random.default_rng(seed)
_, models = all_static_models(d)
idx = rng.choice(len(models), size=n, replace=False)
fired = tested = 0
for k in idx:
G = models[k]
gp = clique_augmented(G, d)
dd, uu = cpdag(gp)
anS = G.ancestors([SEL]) - {SEL}
if not dd:
continue
u, v = list(dd)[int(rng.integers(len(dd)))]
tested += 1
fired += not (G.has(v, u) and u not in anS) # reversed edge must fail
return {'corrupted_models_tested': tested, 'checker_fired': fired}
# --------------------------------------------------------------------------
def run_theorem4(d=4, max_models=None, seed=5):
"""Theorem 4: P_X keeps every orientation of C (monotonicity), stays sound,
and strictly improves on some models. Control: drop the an_G(S) expansion
that Theorem 3 prescribes and confirm soundness breaks."""
_, models = all_static_models(d)
rng = np.random.default_rng(seed)
if max_models and max_models < len(models):
models = [models[i] for i in rng.choice(len(models), max_models, replace=False)]
Isets = [list(s) for r in range(d + 2)
for s in itertools.combinations(list(range(d)) + [SEL], r)]
n = mono_bad = sound_bad = strict = tot = 0
extra_edges = 0
ctrl_n = ctrl_bad = 0
adj_bad = 0
for G in models:
gp = clique_augmented(G, d)
C, Cu = cpdag(gp)
anS = G.ancestors([SEL]) - {SEL}
for I in Isets:
if not I:
continue
gpi = multidomain_augmented(G, d, I)
forced = [('zeta', v) for v in gpi.ch['zeta']]
P, Pu = cpdag(gpi, forced=forced)
PX = set((u, v) for (u, v) in P if u != 'zeta' and v != 'zeta')
tot += 1
n += len(PX)
mono_bad += not (C <= PX)
bad = sum(1 for (u, v) in PX if not (G.has(u, v) and v not in anS))
sound_bad += bad
strict += (len(PX) > len(C))
extra_edges += len(PX - C)
# adjacency characterisation must still hold for P_X
adjP = set(frozenset((u, v)) for (u, v) in PX) | \
set(e for e in Pu if 'zeta' not in e)
for i, j in itertools.combinations(range(d), 2):
truth = (G.has(i, j) or G.has(j, i) or ({i, j} <= anS))
adj_bad += (truth != (frozenset((i, j)) in adjP))
# ---- destructive control: omit Theorem 3's an_G(S) expansion
g2 = DG(list(range(d)) + ['zeta'])
for (u, v) in gp.edges():
g2.add(u, v)
for x in [v for v in I if v != SEL]:
g2.add('zeta', x)
f2 = [('zeta', v) for v in g2.ch['zeta']]
P2, _ = cpdag(g2, forced=f2)
P2X = set((u, v) for (u, v) in P2 if u != 'zeta' and v != 'zeta')
ctrl_n += 1
ctrl_bad += sum(1 for (u, v) in P2X
if not (G.has(u, v) and v not in anS))
return {'d': d, 'n_models': len(models), 'n_I_sets': len(Isets) - 1,
'model_x_Iset_configurations': tot,
'monotonicity_violations': mono_bad,
'orientation_soundness_violations': sound_bad,
'adjacency_violations': adj_bad,
'total_oriented_edges_multi_domain': n,
'configurations_with_strictly_more_orientations': strict,
'frac_strict_improvement': strict / max(tot, 1),
'additional_oriented_edges_vs_single_domain': extra_edges,
'control_no_anS_expansion': {
'configurations': ctrl_n,
'orientation_soundness_violations': ctrl_bad}}
# --------------------------------------------------------------------------
def run_random_sweep(ds=(5, 6, 7, 8, 9, 10), n_models=40, n_triples=200,
Ts=(1, 2, 3, 5), seed=11):
"""Extend the exact checks beyond the exhaustive range with random models."""
rng = np.random.default_rng(seed)
tot = viol_l = conv = viol_t = naive_mis = 0
or_tested = or_bad = adj_tested = adj_bad = 0
for d in ds:
for _ in range(n_models):
G = random_static_dag(d, rng, avg_deg=2.0)
gp = clique_augmented(G, d)
gn = naive_dag(G, d)
anS = G.ancestors([SEL]) - {SEL}
dd, uu = cpdag(gp)
for i, j in itertools.combinations(range(d), 2):
adj_tested += 1
truth = (G.has(i, j) or G.has(j, i) or ({i, j} <= anS))
has = frozenset((i, j)) in (set(frozenset(e) for e in dd) | uu)
adj_bad += (truth != has)
for (u, v) in dd:
or_tested += 1
or_bad += not (G.has(u, v) and v not in anS)
for T in Ts:
gT = evolutionary_graph(G, d, T)
Sall = [('S', t) for t in range(T)]
for _ in range(n_triples):
code = rng.integers(0, 4, size=d)
A = [i for i in range(d) if code[i] == 0]
B = [i for i in range(d) if code[i] == 1]
C = [i for i in range(d) if code[i] == 2]
if not A or not B:
continue
ev = dsep(gT, [('X', i, T) for i in A],
[('X', i, T) for i in B],
[('X', i, T) for i in C] + Sall)
tot += 1
viol_l += (ev and not dsep(G, A, B, C + [SEL]))
conv += (dsep(G, A, B, C + [SEL]) and not ev)
viol_t += (ev != dsep(gp, A, B, C))
naive_mis += (ev != dsep(gn, A, B, C))
return {'ds': list(ds), 'models_per_d': n_models, 'Ts': list(Ts),
'dsep_relations_tested': tot,
'lemma1_violations': viol_l, 'lemma1_converse_failures': conv,
'theorem1_violations': viol_t,
'control_naive_Sdeleted_mismatches': naive_mis,
'theorem2_adjacency_pairs': adj_tested,
'theorem2_adjacency_violations': adj_bad,
'theorem2_oriented_edges': or_tested,
'theorem2_orientation_violations': or_bad}
# --------------------------------------------------------------------------
def run_definition1(seed=17):
"""Definition 1: acyclicity + the closed-form node/edge counts + all four
edge families, over an exhaustive d=4 sweep and a random d=6..20 sweep."""
rng = np.random.default_rng(seed)
_, models = all_static_models(4)
n = acyc = vok = eok = fam_ok = 0
for G in models:
for T in (1, 2, 3):
g = evolutionary_graph(G, 4, T)
V, E = evo_counts(G, 4, T)
n += 1
acyc += g.is_acyclic()
vok += (len(g.nodes) == V)
eok += (g.n_edges() == E)
fams = set()
for (u, v) in g.edges():
if u[0] == 'X' and v[0] == 'X':
fams.add('trait->trait')
elif u[0] == 'X' and v[0] == 'S':
fams.add('trait->S')
elif u[0] == 'e' and v[0] == 'X':
fams.add('eps->trait')
elif u[0] == 'e' and v[0] == 'e':
fams.add('eps->eps')
need = {'eps->trait', 'eps->eps'}
if any(u != SEL for j in range(4) for u in G.pa[j]):
need.add('trait->trait')
if G.pa[SEL]:
need.add('trait->S')
fam_ok += (fams == need)
big = []
for d in (6, 8, 10, 15, 20):
for T in (1, 2, 3, 5):
for r in range(3):
G = random_static_dag(d, rng, avg_deg=2.0)
g = evolutionary_graph(G, d, T)
V, E = evo_counts(G, d, T)
big.append((g.is_acyclic(), len(g.nodes) == V, g.n_edges() == E,
len(g.nodes), g.n_edges(), d, T))
return {'exhaustive_d4': {'constructions': n, 'acyclic': acyc,
'node_count_formula_matches': vok,
'edge_count_formula_matches': eok,
'edge_families_exactly_as_defined': fam_ok},
'random_large': {'constructions': len(big),
'acyclic': sum(b[0] for b in big),
'node_count_matches': sum(b[1] for b in big),
'edge_count_matches': sum(b[2] for b in big),
'max_nodes': max(b[3] for b in big),
'max_edges': max(b[4] for b in big)},
'node_formula': '|V| = 2d(T+1) + T',
'edge_formula': '|E| = |E_G^X|(T+1) + |pa_G(S)|T + d(T+1) + dT'}
if __name__ == '__main__':
R = {}
t0 = time.time()
R['definition1'] = run_definition1(); print('def1', time.time() - t0)
R['theorem2'] = run_theorem2(); print('thm2', time.time() - t0)
R['theorem2_control'] = run_theorem2_control(); print('thm2c', time.time() - t0)
R['theorem4'] = run_theorem4(); print('thm4', time.time() - t0)
R['random_sweep'] = run_random_sweep(); print('sweep', time.time() - t0)
R['dsep_claims'] = run_dsep_claims(); print('dsep', time.time() - t0)
R['runtime_sec'] = time.time() - t0
json.dump(R, open(os.path.join(OUT, 'exhaustive.json'), 'w'), indent=1)
print(json.dumps({k: v for k, v in R.items() if k != 'dsep_claims'}, indent=1)[:4000])