Orbital-Stable Dancing RNN / bench_experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, random
2import numpy as np
3import torch
4from torch import nn
5
6import sys
7sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
8from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report
9
10SEED = 2841
11EPOCHS = 12
12NTRAIN, NTEST = 1200, 400
13LR_GRID = [{"lr": 1e-3}, {"lr": 3e-3}, {"lr": 1e-2}]
14
15class OrbitalRNN(nn.Module):
16 """Phase-only recurrent replacement for bench rnn_small.
17
18 The directed ring plus two-step skip graph is fixed and positive. Input
19 drive, common frequency, and readout are learned end-to-end; amplitudes
20 are fixed at one, as in the MVP implementation plan.
21 """
22 def __init__(self, hidden=64, dt=0.15, kappa=0.8):
23 super().__init__()
24 self.hidden, self.dt, self.kappa = hidden, dt, kappa
25 self.inp = nn.Linear(3, hidden, bias=False)
26 self.omega = nn.Parameter(torch.randn(hidden) * 0.05 + 0.12)
27 A = torch.zeros(hidden, hidden)
28 for i in range(hidden):
29 A[i, (i + 1) % hidden] = 0.35
30 A[i, (i + 2) % hidden] = 0.15
31 self.register_buffer("A", A)
32 self.head = nn.Linear(2 * hidden, 1)
33
34 def phase_step(self, theta, drive):
35 # d_ij = theta_j - theta_i; A[i,j] is directed i <- j.
36 diff = theta[:, None, :] - theta[:, :, None]
37 coupling = torch.einsum("bij,ij->bi", torch.sin(diff), self.A)
38 return theta + self.dt * (self.omega + 0.08 * drive + self.kappa * coupling)
39
40 def forward_with_theta(self, x, theta0=None, return_states=False):
41 b = x.shape[0]
42 seq = x.view(b, -1, 3)
43 theta = torch.zeros(b, self.hidden, device=x.device) if theta0 is None else theta0
44 states = []
45 for k in range(seq.shape[1]):
46 theta = self.phase_step(theta, self.inp(seq[:, k]))
47 states.append(torch.cat((torch.cos(theta), torch.sin(theta)), dim=1))
48 out = self.head(states[-1])
49 return (out, torch.stack(states, dim=1), theta) if return_states else out
50
51 def forward(self, x):
52 return self.forward_with_theta(x)
53
54
55def seed_all(seed):
56 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
57 if torch.cuda.is_available():
58 torch.cuda.manual_seed_all(seed)
59
60
61def train_one(kind, lr, seed, keep_model=False):
62 seed_all(seed)
63 ds = get_dataset("dynamics", seed, n_train=NTRAIN, n_test=NTEST)
64 model = make_model("rnn_small", ds["input_shape"], ds["out_dim"]) if kind == "baseline" else OrbitalRNN()
65 try:
66 _, metric, hist = train_model(model, ds, epochs=EPOCHS, lr=lr, batch=128, log=lambda *_: None)
67 except Exception:
68 # train_model already has a CUDA fallback; this protects unusual driver errors.
69 model = model.cpu(); ds = {k: (v.cpu() if torch.is_tensor(v) else v) for k, v in ds.items()}
70 _, metric, hist = train_model(model, ds, epochs=EPOCHS, lr=lr, batch=128, log=lambda *_: None)
71 if metric is None: raise RuntimeError("benchmark training failed")
72 return (float(metric), model, ds) if keep_model else float(metric)
73
74
75def baseline_factory(cfg):
76 return lambda seed: train_one("baseline", float(cfg["lr"]), seed)
77
78def idea_factory(cfg):
79 return lambda seed: train_one("idea", float(cfg["lr"]), seed)
80
81
82def mechanism_signature(model, ds):
83 """Retest orbital prediction on trajectories produced by the trained model.
84
85 At each observed phase state, compute the transverse Jacobian spectral
86 abscissa and compare it with finite-perturbation decay of the trained cell.
87 """
88 device = next(model.parameters()).device
89 x = ds["xte"][:64].to(device)
90 model.eval()
91 with torch.no_grad():
92 _, _, theta = model.forward_with_theta(x, return_states=True)
93 th = theta[0].detach().cpu().numpy()
94 A = model.A.detach().cpu().numpy(); k = model.kappa
95 J = np.zeros((model.hidden, model.hidden))
96 for i in range(model.hidden):
97 for j in range(model.hidden):
98 if i != j:
99 J[i, j] = k * A[i, j] * math.cos(float(th[j] - th[i]))
100 J[i, i] = -np.sum([k * A[i, j] * math.cos(float(th[j] - th[i])) for j in range(model.hidden) if j != i])
101 eig = np.linalg.eigvals(J)
102 transverse = [z.real for z in eig if abs(z) > 1e-6]
103 predicted = float(max(transverse)) if transverse else 0.0
104 # Empirical cell perturbation on the same trained model and observed input.
105 xone = x[:1]
106 theta0 = torch.zeros(1, model.hidden, device=device)
107 d = torch.linspace(-1, 1, model.hidden, device=device).unsqueeze(0)
108 d = d - d.mean(); d = 1e-5 * d / d.norm()
109 norms = []
110 with torch.no_grad():
111 for t in range(8):
112 theta0 = model.phase_step(theta0, model.inp(xone[:, t*3:(t+1)*3]))
113 # Compare a perturbed parallel rollout at this same input step.
114 # Recompute from initial perturbation for a direct finite response.
115 if t == 0: pert = d.clone()
116 pert = model.phase_step(pert, model.inp(xone[:, t*3:(t+1)*3]))
117 norms.append(float((pert - theta0).norm().cpu()))
118 # fit log decay after first point; positive slope means instability.
119 observed = float(np.polyfit(np.arange(len(norms))[1:], np.log(np.maximum(norms, 1e-30))[1:], 1)[0])
120 # Euler predicts log multiplier log(1 + dt*alpha)/dt.
121 predicted_euler = math.log(max(1e-8, 1.0 + model.dt * predicted)) / model.dt
122 confirmed = bool(abs(observed - predicted_euler) < 0.35 and predicted < 0)
123 return {"predicted_transverse_alpha": predicted, "predicted_euler_slope": predicted_euler,
124 "observed_perturbation_slope": observed, "max_edge_angle_rad": float(np.max(np.abs(((th[:,None]-th[None,:]+np.pi)%(2*np.pi))-np.pi))),
125 "confirmed": confirmed}
126
127
128def main():
129 torch.set_num_threads(4)
130 # Same union of method step sizes on both sides; baseline is selected on 4 seeds.
131 base = sweep_baseline(baseline_factory, LR_GRID)
132 best_lr = float(base["best_cfg"]["lr"])
133 idea_grid = LR_GRID
134 # Explicitly evaluate all three idea settings, then report the best setting.
135 idea_runs = []
136 for cfg in idea_grid:
137 r = evaluate(idea_factory(cfg))
138 idea_runs.append({"cfg": cfg, "result": r})
139 best_idea = min(idea_runs, key=lambda z: z["result"]["mean"])
140 # Retain the trained best model on seed 0 for behavior signature.
141 _, trained, ds = train_one("idea", float(best_idea["cfg"]["lr"]), 0, keep_model=True)
142 report = make_report("dynamics", "rnn_small", base, best_idea["result"], {
143 "prediction": "stable transverse perturbations should contract with slope near log(1+dt*alpha)/dt",
144 "trained_model": "orbital phase RNN, seed 0, best benchmark lr",
145 **mechanism_signature(trained, ds)
146 })
147 report["idea_sweep"] = idea_runs
148 report["protocol"] = {"epochs": EPOCHS, "n_train": NTRAIN, "n_test": NTEST,
149 "paired_seeds": list(range(8)), "lr_union": [c["lr"] for c in LR_GRID],
150 "baseline_best_lr": best_lr, "idea_best_lr": best_idea["cfg"]["lr"]}
151 with open("bench_report.json", "w") as f: json.dump(report, f, indent=2)
152 print(json.dumps(report, indent=2))
153
154if __name__ == "__main__": main()