Symplectic Recurrent Block / bench_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  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()