GECC-Gated Loop-Aware Message Passing / gecc_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, random
  2from pathlib import Path
  3import numpy as np
  4import torch
  5import torch.nn as nn
  6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  7from bench import train_model, evaluate, sweep_baseline, make_report
  8
  9META = {'name': 'gecc_loop_graph', 'domain': 'graph-nn',
 10        'description': 'Classify stochastic graphs with triangle-rich and inconsistent local overlaps using node features.'}
 11N = 18
 12D = 5
 13
 14def _adj(seed):
 15    rng = np.random.RandomState(seed + 173)
 16    y = np.arange(N) % 2
 17    A = np.zeros((N, N), dtype=np.float32)
 18    # Two regimes: same-community triangles and cross-community noisy closures.
 19    for i in range(N):
 20        for j in range(i + 1, N):
 21            p = 0.42 if y[i] == y[j] else 0.13
 22            if rng.rand() < p: A[i, j] = A[j, i] = 1
 23    for c in range(0, N, 3):
 24        ids = [c, c + 1, c + 2]
 25        for i in ids:
 26            for j in ids:
 27                if i < j: A[i, j] = A[j, i] = 1
 28    np.fill_diagonal(A, 0)
 29    return A
 30
 31def _stats(A):
 32    sets = [set(np.flatnonzero(A[i]).tolist()) | {i} for i in range(N)]
 33    common, C, r = {}, {}, {}
 34    for u in range(N):
 35        for v in np.flatnonzero(A[u]).astype(int):
 36            I = sorted(sets[u] & sets[v])
 37            common[u, v] = I
 38            C[u, v] = 2.0 * len(I) / (len(sets[u]) + len(sets[v]) + 1e-12)
 39            r[u, v] = len(I) / (len(sets[u]) * len(sets[v]) + 1e-12)
 40    return common, C, r
 41
 42def get_dataset(seed, n_train, n_test):
 43    # Every seed has one deterministic graph; samples differ in node evidence.
 44    A = _adj(seed)
 45    rng = np.random.RandomState(seed + 991)
 46    def make(n):
 47        labels = rng.randint(0, 2, size=n).astype(np.int64)
 48        root = (2 * labels - 1).astype(np.float32)
 49        x = rng.normal(0, 0.95, (n, N, D)).astype(np.float32)
 50        # Evidence is locally coherent on one class and deliberately noisy on the other.
 51        x[:, :, 0] += root[:, None] * 0.28
 52        x[:, ::2, 1] += root[:, None] * 0.23
 53        x[:, 1::2, 2] += root[:, None] * 0.10
 54        return x, labels
 55    xtr, ytr = make(n_train); xte, yte = make(n_test)
 56    common, C, r = _stats(A)
 57    return {'xtr': xtr, 'ytr': ytr, 'xte': xte, 'yte': yte,
 58            'task': 'classification', 'metric': 'error', 'input_shape': (N, D),
 59            'out_dim': 2, 'adjacency': A, 'common': common, 'closure': C, 'density': r}
 60
 61class GraphLayer(nn.Module):
 62    def __init__(self, d, idea=False):
 63        super().__init__(); self.idea = idea
 64        self.selfp = nn.Linear(d, d); self.msg = nn.Linear(d, d)
 65        self.loop = nn.Linear(d, d) if idea else None
 66        self.gate = nn.Parameter(torch.tensor([-1.0, 2.0, 0.2])) if idea else None
 67    def forward(self, h, A, common, closure, density, force_gate=None):
 68        # Algebraically identical vectorization of the directed-edge sum.
 69        dev = h.device; n = A.shape[0]
 70        at = torch.as_tensor(A, dtype=h.dtype, device=dev)
 71        if not self.idea:
 72            return torch.relu(self.selfp(h) + torch.einsum('uv,bvd->bud', at, self.msg(h)))
 73        c = torch.as_tensor(closure, dtype=h.dtype, device=dev)
 74        r = torch.as_tensor(density, dtype=h.dtype, device=dev)
 75        if force_gate is None: alpha = torch.sigmoid(self.gate[0] + self.gate[1]*c + self.gate[2]*r) * at
 76        else: alpha = torch.full_like(c, float(force_gate)) * at
 77        # Q[u,w] counts gated generalized-edge occurrences of w in intersections.
 78        q = torch.zeros((n,n), dtype=h.dtype, device=dev)
 79        for u in range(n):
 80            for v in np.flatnonzero(A[u]).astype(int):
 81                if common[u,v]: q[u, common[u,v]] += alpha[u,v] / len(common[u,v])
 82        ordinary = torch.einsum('uv,bvd->bud', at-alpha, self.msg(h))
 83        overlap = torch.einsum('uw,bwd->bud', q, self.loop(h))
 84        return torch.relu(self.selfp(h) + ordinary + overlap)
 85
 86class GraphNet(nn.Module):
 87    def __init__(self, d=D, hidden=24, idea=False):
 88        super().__init__(); self.idea = idea; self.inp = nn.Linear(d, hidden)
 89        self.layer = GraphLayer(hidden, idea); self.head = nn.Linear(hidden, 2)
 90    def forward(self, x):
 91        h = torch.relu(self.inp(x))
 92        h = self.layer(h, self._A, self._common, self._closure, self._density)
 93        return self.head(h.mean(1))
 94    def bind(self, ds):
 95        self._A = ds['adjacency']; self._common = ds['common']
 96        self._closure = np.zeros((N,N), dtype=np.float32); self._density = np.zeros((N,N), dtype=np.float32)
 97        for (u,v), val in ds['closure'].items(): self._closure[u,v] = val
 98        for (u,v), val in ds['density'].items(): self._density[u,v] = val
 99        return self
100
101def train_one(seed, lr, idea):
102    torch.manual_seed(seed); np.random.seed(seed); random.seed(seed)
103    ds = get_dataset(seed, 400, 400)
104    for k in ('xtr','ytr','xte','yte'): ds[k] = torch.from_numpy(ds[k])
105    net = GraphNet(idea=idea).bind(ds)
106    net, metric, _ = train_model(net, ds, epochs=10, lr=lr, batch=64, weight_decay=1e-3, log=lambda *_: None)
107    return float(metric)
108
109def main():
110    seeds = tuple(range(8)); lrs = [1e-3, 3e-3, 1e-2]
111    grid = [{'lr': lr} for lr in lrs]
112    base = sweep_baseline(lambda cfg: (lambda s: train_one(s, cfg['lr'], False)), grid, seeds=seeds)
113    candidates = []
114    for cfg in grid:
115        candidates.append((cfg, evaluate(lambda s, c=cfg: train_one(s, c['lr'], True), seeds=seeds)))
116    best_cfg, idea = min(candidates, key=lambda z: z[1]['mean'])
117    # Signature from trained GECC systems: observed gate-vs-closure behavior and prediction effect.
118    ds = get_dataset(700, 128, 128)
119    for k in ('xtr','ytr','xte','yte'): ds[k] = torch.from_numpy(ds[k])
120    net = GraphNet(idea=True).bind(ds)
121    net, _, _ = train_model(net, ds, epochs=10, lr=best_cfg['lr'], batch=64, weight_decay=1e-3, log=lambda *_: None)
122    net = net.cpu(); net.eval(); bins = [[], [], []]
123    with torch.no_grad():
124        h = torch.relu(net.inp(ds['xte']))
125        for u in range(N):
126            for v in np.flatnonzero(ds['adjacency'][u]).astype(int):
127                c = ds['closure'][u,v]; b = 0 if c < .25 else (1 if c < .5 else 2)
128                alpha = float(torch.sigmoid(net.layer.gate[0] + net.layer.gate[1]*c + net.layer.gate[2]*ds['density'][u,v]))
129                ordinary = net.layer.msg(h[:,v]); I=ds['common'][u,v]
130                q=h[:,I].mean(1) if I else torch.zeros_like(ordinary)
131                corr=net.layer.loop(q)
132                bins[b].append((alpha, float((corr-ordinary).abs().mean())))
133    observed = [{'mean_C_bin': [0.125,0.375,0.75][i], 'mean_alpha': float(np.mean([z[0] for z in b])) if b else 0., 'mean_correction_gap': float(np.mean([z[1] for z in b])) if b else 0.} for i,b in enumerate(bins)]
134    monotone = observed[0]['mean_alpha'] < observed[1]['mean_alpha'] < observed[2]['mean_alpha']
135    extra = {'prediction': 'trained gate activates more on higher closure; correction is selectively weighted',
136             'observed_model_behavior': observed, 'predicted_alpha_order': 'low < medium < high',
137             'confirmed': bool(monotone), 'custom_track': {'name': META['name'], 'file': 'gecc_bench.py', 'domain': META['domain']}}
138    report = make_report('custom:gecc_loop_graph', 'GraphNet', base, idea, extra)
139    report['idea_grid'] = [{'cfg': c, 'mean': r['mean'], 'std': r['std'], 'per_seed': r['per_seed']} for c,r in candidates]
140    Path('bench_report.json').write_text(json.dumps(report, indent=2))
141    print(json.dumps(report, indent=2))
142if __name__ == '__main__': main()