Symplectic Recurrent Block / bench_experiment.py
Mechanism confirmed, baseline not beaten
1import sys, json, random
2from pathlib import Path
3import numpy as np
4import torch
5from torch import nn
6
7sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
8from bench import get_dataset, make_model, train_model, sweep_baseline, make_report
9
10TRACK, MODEL = "dynamics", "rnn_small"
11SEEDS = tuple(range(8))
12# Same learning-rate union is evaluated for baseline and idea.
13LR_GRID = (1e-3, 3e-3, 5e-3)
14EPOCHS, NTRAIN, NTEST = 12, 400, 200
15BATCH = 128
16
17class SymplecticRNN(nn.Module):
18 """Hamiltonian recurrent replacement for the GRU in bench.rnn_small.
19
20 q and p are width-64 hidden channels. At each observed (theta,omega,u),
21 a learned separable potential U(q,x) and diagonal kinetic energy K(p)
22 receive one leapfrog step. The readout is identical to rnn_small.
23 """
24 def __init__(self, input_dim=3, hidden=64, step=0.08, substeps=1):
25 super().__init__()
26 self.hidden, self.step, self.substeps = hidden, step, substeps
27 self.potential = nn.Sequential(
28 nn.Linear(hidden + input_dim, 48), nn.Tanh(),
29 nn.Linear(48, 48), nn.Tanh(), nn.Linear(48, 1))
30 self.log_mass = nn.Parameter(torch.zeros(hidden))
31 self.init_q = nn.Linear(input_dim, hidden)
32 self.init_p = nn.Linear(input_dim, hidden)
33 self.head = nn.Linear(hidden, 1)
34
35 def hamiltonian(self, q, p, x):
36 u = self.potential(torch.cat((q, x), dim=-1)).squeeze(-1)
37 mass = torch.nn.functional.softplus(self.log_mass) + 1e-3
38 return u + 0.5 * (p * p / mass).sum(-1)
39
40 def transition(self, q, p, x):
41 # train_model evaluates under no_grad; Hamiltonian derivatives must still run.
42 with torch.enable_grad():
43 return self._transition_grad(q, p, x)
44
45 def _transition_grad(self, q, p, x):
46 # Gradients are exact derivatives of the scalar learned Hamiltonian.
47 for _ in range(self.substeps):
48 q = q.requires_grad_(True); p = p.requires_grad_(True)
49 h = self.hamiltonian(q, p, x)
50 gq, gp = torch.autograd.grad(h.sum(), (q, p), create_graph=True)
51 p = p - 0.5 * self.step * gq
52 q = q + self.step * gp
53 q = q.requires_grad_(True); p = p.requires_grad_(True)
54 h2 = self.hamiltonian(q, p, x)
55 gq = torch.autograd.grad(h2.sum(), q, create_graph=True)[0]
56 p = p - 0.5 * self.step * gq
57 return q, p
58
59 def forward(self, x):
60 seq = x.view(x.shape[0], -1, 3)
61 q = torch.tanh(self.init_q(seq[:, 0]))
62 p = self.init_p(seq[:, 0]) * 0.05
63 for t in range(seq.shape[1]):
64 q, p = self.transition(q, p, seq[:, t])
65 return self.head(q)
66
67def seed_all(seed):
68 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
69
70def train_one(kind, seed, lr, step=0.08):
71 seed_all(seed)
72 ds = get_dataset(TRACK, seed=seed, n_train=NTRAIN, n_test=NTEST)
73 if kind == "baseline":
74 net = make_model(MODEL, ds["input_shape"], ds["out_dim"])
75 else:
76 net = SymplecticRNN(step=step)
77 _, metric, _ = train_model(net, ds, epochs=EPOCHS, lr=lr, batch=BATCH,
78 weight_decay=0.0, log=lambda *_: None)
79 return float(metric)
80
81def base_factory(cfg):
82 return lambda seed: train_one("baseline", seed, cfg["lr"])
83
84def idea_factory(cfg):
85 return lambda seed: train_one("idea", seed, cfg["lr"], cfg["step"])
86
87def signature(seed, lr, step):
88 """Measure transition Jacobian determinants on trained systems, not toy math."""
89 seed_all(seed)
90 ds = get_dataset(TRACK, seed=seed, n_train=NTRAIN, n_test=NTEST)
91 b = make_model(MODEL, ds["input_shape"], ds["out_dim"])
92 b, _, _ = train_model(b, ds, epochs=EPOCHS, lr=lr, batch=BATCH, log=lambda *_: None)
93 s = SymplecticRNN(step=step)
94 s, _, _ = train_model(s, ds, epochs=EPOCHS, lr=lr, batch=BATCH, log=lambda *_: None)
95 # Signature probing is intentionally CPU-only to avoid shared-GPU/cuDNN allocation failures.
96 b = b.cpu(); s = s.cpu()
97 x = ds["xte"][:1].view(1, -1, 3)
98 with torch.no_grad():
99 q = torch.tanh(s.init_q(x[:, 0])); p = s.init_p(x[:, 0]) * .05
100 # One trained symplectic transition, Jacobian of [q',p'] wrt [q,p].
101 z = torch.cat((q.detach().flatten(), p.detach().flatten())).requires_grad_(True)
102 xx = x[:, 0]
103 def f(zv):
104 qq, pp = zv[:64].view(1,64), zv[64:].view(1,64)
105 qo, po = s.transition(qq, pp, xx)
106 return torch.cat((qo.flatten(), po.flatten()))
107 jac = torch.autograd.functional.jacobian(f, z, vectorize=True)
108 # Full 128x128 determinant is numerically stable here; report log abs det.
109 sign, logabs = torch.linalg.slogdet(jac)
110 idea_logdet = float(logabs.detach())
111 # Baseline trained behavior: recurrent hidden transition determinant on one step.
112 with torch.no_grad():
113 seq = x
114 _, h = b.rnn(seq[:, :1]); h0 = h[-1, 0].detach()
115 # GRU's one-step map with fixed input, measured by autograd.
116 zz = h0.requires_grad_(True)
117 inp = x[:, 0].unsqueeze(1)
118 def bf(v):
119 # GRU input shape is [batch,time,3], hidden [1,batch,64].
120 _, hh = b.rnn(inp, v.view(1,1,64))
121 return hh[-1,0]
122 bj = torch.autograd.functional.jacobian(bf, zz, vectorize=True)
123 _, blogdet = torch.linalg.slogdet(bj)
124 return {"prediction": "symplectic transition det should remain near 1 (log|det| near 0)",
125 "observed_idea_log_abs_det": idea_logdet,
126 "observed_baseline_log_abs_det": float(blogdet.detach()),
127 "absolute_logdet_error_idea": abs(idea_logdet),
128 "absolute_logdet_error_baseline": abs(float(blogdet.detach())),
129 "confirmed": abs(idea_logdet) < 0.15 and abs(idea_logdet) < abs(float(blogdet.detach()))}
130
131def main():
132 # Baseline sweep has the same three lrs used by idea; full selection is harness-owned.
133 baseline_grid = [{"lr": lr} for lr in LR_GRID]
134 base = sweep_baseline(base_factory, baseline_grid, seeds=(0,1,2,3))
135 best_lr = float(base["best_cfg"]["lr"])
136 # Three idea settings: baseline-best lr plus two nearby leapfrog step sizes.
137 idea_grid = [{"lr": best_lr, "step": st} for st in (0.04, 0.08, 0.12)]
138 idea_trials = []
139 for cfg in idea_grid:
140 vals = [train_one("idea", s, cfg["lr"], cfg["step"]) for s in SEEDS]
141 idea_trials.append({"cfg": cfg, "result": {"mean": float(np.mean(vals)), "std": float(np.std(vals)), "per_seed": vals, "n": len(vals)}})
142 best = min(idea_trials, key=lambda z: z["result"]["mean"])
143 sig = signature(0, best["cfg"]["lr"], best["cfg"]["step"])
144 report = make_report(TRACK, MODEL, base, best["result"], extra={
145 "idea_sweep": idea_trials,
146 "mechanism_signature": sig,
147 "audit": {"n_train": NTRAIN, "n_test": NTEST, "epochs": EPOCHS,
148 "matched_architecture_role": "GRU replaced by symplectic hidden transition",
149 "baseline_lr_union": list(LR_GRID)}})
150 Path("bench_report.json").write_text(json.dumps(report, indent=2))
151 print(json.dumps(report, indent=2))
152
153if __name__ == "__main__": main()