Histogram-Controlled Cluster Updates for Iterative GNNs / stage2_bench.py
Mechanism confirmed, baseline not beaten
1import sys, json, math, time
2from pathlib import Path
3import numpy as np
4import torch
5import torch.nn as nn
6
7sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
8from bench import get_dataset, train_model, sweep_baseline, make_report
9
10SEEDS = tuple(range(8))
11# Union is shared by baseline and idea; baseline sweep covers every idea LR.
12GRID = [
13 {"lr": 1e-3, "epochs": 12},
14 {"lr": 3e-3, "epochs": 12},
15 {"lr": 1e-2, "epochs": 12},
16]
17HIDDEN = 32
18SWEEPS = 3
19CLUSTERS = 4
20ETA = 0.55
21R_BINS = 4
22A_MAX = 8.0
23
24class IterativeGraphNet(nn.Module):
25 """Small recurrent message-passing solver over the 8-step pendulum window."""
26 def __init__(self, mode="full", eta=ETA, clusters=CLUSTERS, sweeps=SWEEPS):
27 super().__init__()
28 self.mode, self.eta = mode, eta
29 self.clusters, self.sweeps = clusters, sweeps
30 self.inp = nn.Linear(3, HIDDEN)
31 self.msg = nn.Linear(HIDDEN, HIDDEN, bias=False)
32 self.self_proj = nn.Linear(HIDDEN, HIDDEN)
33 self.head = nn.Linear(HIDDEN, 1)
34
35 def one_update(self, h, x, ids):
36 # Gather all neighbors from the pre-update tensor, then scatter together.
37 old = h
38 left = torch.roll(old, 1, dims=1)
39 right = torch.roll(old, -1, dims=1)
40 neigh = 0.5 * (left + right)
41 z = torch.tanh(self.self_proj(old) + self.msg(neigh) + self.inp(x))
42 out = old.clone()
43 out[:, ids, :] = (1.0 - self.eta) * old[:, ids, :] + self.eta * z[:, ids, :]
44 return out
45
46 def histograms(self, h):
47 # Observable local mismatch: disagreement with ring-neighbor states.
48 d = 0.5 * (torch.roll(h, 1, 1) - h).norm(dim=-1) + \
49 0.5 * (torch.roll(h, -1, 1) - h).norm(dim=-1)
50 omega = torch.clamp((d / (d.detach().mean(dim=1, keepdim=True) + 1e-6) * 4).long(), 0, int(A_MAX))
51 q = torch.clamp((R_BINS * omega / A_MAX).long(), 0, R_BINS - 1)
52 hs = []
53 for a in range(self.clusters):
54 ids = list(range(a * 8 // self.clusters, (a + 1) * 8 // self.clusters))
55 hs.append(torch.stack([(q[:, ids] == r).float().mean(1) for r in range(R_BINS)], 1))
56 return torch.stack(hs, 1), d
57
58 def forward(self, x):
59 # x is [batch, 24], interpreted as 8 nodes with 3 features.
60 x = x.view(x.shape[0], 8, 3)
61 h = torch.tanh(self.inp(x))
62 if self.mode == "full":
63 for _ in range(self.sweeps):
64 h = self.one_update(h, x, list(range(8)))
65 else:
66 # Equal node-update budget: clusters*sweeps decisions.
67 for _ in range(self.sweeps * self.clusters):
68 hist, _ = self.histograms(h.detach())
69 action = hist[:, :, -1].argmax(1)
70 # A batch has one action per example; grouping preserves synchronous updates.
71 nxt = h.clone()
72 for a in range(self.clusters):
73 mask = action == a
74 if mask.any():
75 ids = list(range(a * 8 // self.clusters, (a + 1) * 8 // self.clusters))
76 nxt[mask] = self.one_update(h[mask], x[mask], ids)
77 h = nxt
78 return self.head(h[:, -1, :])
79
80def train_idea(seed, cfg):
81 torch.manual_seed(seed); np.random.seed(seed)
82 ds = get_dataset("dynamics", seed, n_train=400, n_test=100)
83 model = IterativeGraphNet("cluster")
84 device = "cuda" if torch.cuda.is_available() else "cpu"
85 try:
86 model = model.to(device)
87 opt = torch.optim.Adam(model.parameters(), lr=cfg["lr"])
88 lossf = nn.MSELoss(); x, y = ds["xtr"].to(device), ds["ytr"].to(device)
89 for _ in range(cfg["epochs"]):
90 model.train(); p = torch.randperm(len(x), device=device)
91 for i in range(0, len(x), 128):
92 ix = p[i:i+128]; loss = lossf(model(x[ix]), y[ix])
93 opt.zero_grad(); loss.backward(); opt.step()
94 model.eval()
95 with torch.no_grad():
96 metric = float(lossf(model(ds["xte"].to(device)), ds["yte"].to(device)))
97 return metric, model.cpu(), ds
98 except (RuntimeError, torch.cuda.CudaError):
99 # Required robust fallback; recreate model to avoid partial CUDA state.
100 model = IterativeGraphNet("cluster")
101 opt = torch.optim.Adam(model.parameters(), lr=cfg["lr"])
102 lossf = nn.MSELoss(); x, y = ds["xtr"], ds["ytr"]
103 for _ in range(cfg["epochs"]):
104 p = torch.randperm(len(x))
105 for i in range(0, len(x), 128):
106 ix = p[i:i+128]; loss = lossf(model(x[ix]), y[ix])
107 opt.zero_grad(); loss.backward(); opt.step()
108 with torch.no_grad(): metric = float(lossf(model(ds["xte"]), ds["yte"]))
109 return metric, model, ds
110
111def baseline_factory(cfg):
112 def run(seed):
113 torch.manual_seed(seed); np.random.seed(seed)
114 ds = get_dataset("dynamics", seed, n_train=400, n_test=100)
115 model = IterativeGraphNet("full")
116 _, metric, _ = train_model(model, ds, epochs=cfg["epochs"], lr=cfg["lr"], batch=128, log=lambda *_: None)
117 return metric
118 return run
119
120def idea_eval(cfg):
121 vals = []
122 for s in SEEDS:
123 v, _, _ = train_idea(s, cfg); vals.append(v)
124 return {"mean": float(np.mean(vals)), "std": float(np.std(vals)), "per_seed": vals, "n": len(vals)}
125
126def mechanism_signature(base_cfg, idea_cfg):
127 # Re-test eta^2 ordering prediction on trained models, not a toy identity.
128 b, bm, ds = train_idea(0, idea_cfg)
129 bm.eval(); x = ds["xte"][:32].view(32, 8, 3)
130 rows = []
131 with torch.no_grad():
132 for eta in (0.2, 0.4, 0.6, 0.8):
133 bm.eta = eta
134 h = torch.tanh(bm.inp(x)); old = h.clone()
135 ids = [0, 1]
136 # synchronous cluster update
137 sync = bm.one_update(old, x, ids)
138 # deliberately sequential ordering on the same trained update rule
139 seq = old.clone()
140 for i in ids: seq = bm.one_update(seq, x, [i])
141 err = float((sync[:, ids] - seq[:, ids]).abs().max())
142 rows.append({"eta": eta, "observed_linf": err, "eta2": eta * eta})
143 ratios = [r["observed_linf"] / max(r["eta2"], 1e-9) for r in rows]
144 # Prediction is quantitative scaling, assessed by normalized variation.
145 confirmed = bool(max(ratios) < 1.5 * max(min(ratios), 1e-9))
146 return {"prediction": "trained-model synchronous-vs-sequential discrepancy scales approximately eta^2", "rows": rows, "eta2_ratio_range": [float(min(ratios)), float(max(ratios))], "confirmed": confirmed}
147
148def main():
149 t0 = time.time()
150 base = sweep_baseline(baseline_factory, GRID)
151 idea_trials = [(cfg, idea_eval(cfg)) for cfg in GRID]
152 idea_cfg, idea = min(idea_trials, key=lambda z: z[1]["mean"])
153 rep = make_report("dynamics", "rnn_small", base, idea, {
154 "track_structure": "controlled pendulum multi-step dynamics; recurrent state updates",
155 "scheduler": "largest high-bin hidden-state disagreement histogram",
156 "clusters": CLUSTERS, "histogram_bins": R_BINS,
157 "signature": mechanism_signature(base["best_cfg"], base["best_cfg"])
158 })
159 rep["runtime_sec"] = time.time() - t0
160 rep["protocol_notes"] = {"paired_seeds": list(SEEDS), "dataset_sizes": [400, 100], "idea_grid": [{"cfg": c, "mean": r["mean"]} for c, r in idea_trials], "selected_idea_cfg": idea_cfg, "baseline_grid_union": GRID}
161 Path("bench_report.json").write_text(json.dumps(rep, indent=2))
162 print(json.dumps(rep, indent=2))
163
164if __name__ == "__main__": main()