Persistent Spectral Noise for Recurrent GNNs / spectral_noise_experiment.py
Failed on benchmark
1import json, math
2from pathlib import Path
3import numpy as np
4
5SEED = 1522
6
7
8def cycle_laplacian(n):
9 L = 2*np.eye(n)
10 for i in range(n):
11 L[i, (i-1) % n] = -1
12 L[i, (i+1) % n] = -1
13 return L
14
15
16def run_rollout(L, alpha, gamma, sigma, T, d=3, burn=0, reps=1, seed=0, initial=None):
17 """Linear contractive recurrent GNN: H[t+1]=alpha(I-gamma L)H[t]+sigma Xi[t]."""
18 local = np.random.default_rng(seed)
19 n = L.shape[0]
20 P = alpha * (np.eye(n) - gamma * L)
21 vals = []
22 for _ in range(reps):
23 H = np.zeros((n, d)) if initial is None else initial.copy()
24 es = []
25 for t in range(T):
26 H = P @ H + sigma * local.standard_normal((n, d))
27 if t >= burn:
28 es.append(0.5 * np.trace(H.T @ L @ H))
29 vals.append(np.mean(es))
30 return float(np.mean(vals)), float(np.std(vals, ddof=1) / math.sqrt(reps)) if reps > 1 else 0.0
31
32
33def stationary_energy_formula(eigs, alpha, gamma, sigma, d):
34 q = alpha * (1 - gamma * eigs)
35 return float(0.5 * d * np.sum(eigs[1:] * sigma**2 / (1-q[1:]**2)))
36
37
38def main():
39 alpha, gamma, d = 0.82, 0.20, 3
40 n = 12
41 L = cycle_laplacian(n)
42 eigs = np.linalg.eigvalsh(L)
43 rho = np.max(np.abs(alpha * (1 - gamma * eigs)))
44 assert rho < 1, rho
45
46 # Prediction 1: stationary energy is proportional to sigma^2, and agrees
47 # with the exact sum over graph Fourier modes.
48 sigmas = np.array([0.01, 0.02, 0.04, 0.08])
49 rows_sigma = []
50 for s in sigmas:
51 observed, se = run_rollout(L, alpha, gamma, s, T=3500, burn=1000, d=d, reps=80, seed=100+int(s*10000))
52 predicted = stationary_energy_formula(eigs, alpha, gamma, s, d)
53 rows_sigma.append({'sigma': float(s), 'sigma2_lambda2': float(s*s*eigs[1]), 'observed': observed, 'predicted': predicted, 'relative_error': abs(observed-predicted)/predicted, 'se': se})
54 x = sigmas**2
55 y = np.array([r['observed'] for r in rows_sigma])
56 slope = float(np.dot(x, y) / np.dot(x, x))
57 slope_r2 = float(1 - np.sum((y-slope*x)**2) / np.sum((y-y.mean())**2))
58
59 # Prediction 2: a pure lambda_2 mode decays as q_2^(2t), giving the
60 # half-life log(1/2)/log(q_2^2).
61 v2 = np.linalg.eigh(L)[1][:, 1]
62 initial = np.outer(v2, np.ones(d))
63 q2 = alpha * (1 - gamma * eigs[1])
64 pred_half = math.log(0.5) / math.log(q2*q2)
65 P = alpha * (np.eye(n) - gamma * L)
66 H = initial.copy(); energies = []
67 for t in range(80):
68 energies.append(0.5*np.trace(H.T@L@H)); H = P@H
69 target = energies[0]/2
70 obs_half = next((i for i,e in enumerate(energies) if e <= target), 80)
71
72 # Prediction 3: for one fixed graph topology, uniformly scaling edge
73 # weights increases lambda_2 and increases the stationary energy. This
74 # avoids confounding gap with graph size/mode count.
75 rows_gap = []
76 sigma = 0.04
77 for scale in [0.25, 0.5, 0.75, 1.0, 1.25, 1.5]:
78 LL = scale * L
79 ee = np.linalg.eigvalsh(LL)
80 observed, se = run_rollout(LL, alpha, gamma, sigma, T=4000, burn=1200, d=d, reps=50, seed=500+int(scale*100))
81 predicted = stationary_energy_formula(ee, alpha, gamma, sigma, d)
82 rows_gap.append({'scale': scale, 'lambda2': float(ee[1]), 'observed': observed, 'predicted': predicted, 'se': se})
83 gap_monotonic = all(rows_gap[i+1]['observed'] > rows_gap[i]['observed'] for i in range(len(rows_gap)-1))
84
85 # Baseline comparison: same initial nonconstant state, with and without
86 # persistent noise, at long horizon.
87 baseline_long, _ = run_rollout(L, alpha, gamma, 0.0, T=100, burn=99, d=d, reps=1, seed=7, initial=initial)
88 idea_energy, idea_se = run_rollout(L, alpha, gamma, 0.04, T=3500, burn=1000, d=d, reps=80, seed=777, initial=initial)
89
90 result = {
91 'setup': {'n': n, 'd': d, 'alpha': alpha, 'gamma': gamma, 'contraction_rho': float(rho), 'lambda2': float(eigs[1]), 'seed': SEED},
92 'prediction_1_sigma_squared_scaling': {'rows': rows_sigma, 'fit_slope_energy_per_sigma2': slope, 'R2_through_origin': slope_r2},
93 'prediction_2_deterministic_decay': {'q_lambda2': float(q2), 'predicted_half_life_steps': pred_half, 'observed_first_half_step': int(obs_half), 'initial_energy': float(energies[0]), 'energy_at_20': float(energies[20])},
94 'prediction_3_fixed_topology_gap_sweep': {'rows': rows_gap, 'strictly_monotone_observed': gap_monotonic},
95 'baseline_vs_persistent_noise': {'deterministic_energy_at_100': baseline_long, 'persistent_noise_stationary_energy': idea_energy, 'persistent_noise_se': idea_se},
96 }
97 Path('results.json').write_text(json.dumps(result, indent=2))
98 print(json.dumps(result, indent=2))
99
100if __name__ == '__main__':
101 main()