Robust Physics-Sparse Neural Dynamics / bench_experiment.py
Failed on benchmark
1import sys, json, random
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, make_model, train_model, evaluate, sweep_baseline, make_report
9
10SEEDS = tuple(range(8))
11# Same union of learning rates on both sides; baseline sweep is deliberately small/equal.
12LRS = [1e-3, 3e-3, 1e-2]
13EPOCHS = 18
14BATCH = 128
15
16
17def seed_all(s):
18 random.seed(s); np.random.seed(s); torch.manual_seed(s)
19 if torch.cuda.is_available():
20 try: torch.cuda.manual_seed_all(s)
21 except Exception: pass
22
23
24def baseline_fn(cfg):
25 def run(seed):
26 seed_all(seed)
27 d = get_dataset('dynamics', seed, n_train=400, n_test=160)
28 net = make_model('rnn_small', d['input_shape'], d['out_dim'])
29 _, metric, _ = train_model(net, d, epochs=EPOCHS, lr=cfg['lr'], batch=BATCH, log=lambda *_: None)
30 return metric
31 return run
32
33
34class HybridRNN(nn.Module):
35 """GRU state encoder with a known stable relaxation/control readout and sparse residual.
36 The intervention is trained end-to-end; no baseline weights are reused."""
37 def __init__(self):
38 super().__init__()
39 self.rnn = nn.GRU(3, 64, batch_first=True)
40 self.head = nn.Linear(64, 1)
41 # Known dynamics terms: theta' = omega, omega' = -g sin(theta)-c omega+u.
42 # Small learnable sparse library coefficients correct finite-horizon mapping.
43 self.coeff = nn.Parameter(torch.zeros(6))
44 self._no_cudnn = False
45
46 def forward(self, x):
47 seq = x.view(x.shape[0], -1, 3)
48 try:
49 _, h = self.rnn(seq)
50 except RuntimeError:
51 self._no_cudnn = True
52 if self._no_cudnn:
53 old = torch.backends.cudnn.enabled; torch.backends.cudnn.enabled = False
54 try: _, h = self.rnn(seq)
55 finally: torch.backends.cudnn.enabled = old
56 z = h[-1]
57 raw = self.head(z).squeeze(-1)
58 last = seq[:, -1]
59 th, om, u = last[:, 0], last[:, 1], last[:, 2]
60 # stable known first-order relaxation/control structure, Euler over horizon.
61 dt = 0.08
62 g, damp, horizon = 9.81, 0.18, 8
63 tq, wq = th, om
64 for _ in range(horizon):
65 wq = wq + dt * (-g * torch.sin(tq) - damp*wq + u)
66 tq = tq + dt * wq
67 lib = torch.stack([th, om, u, th*om, th*th, torch.tanh(om)], 1)
68 # residual is bounded to prevent unconstrained unstable readout.
69 sparse = 0.15 * (lib * self.coeff).sum(1)
70 return (tq + 0.08 * raw + sparse).unsqueeze(1)
71
72
73def idea_fn(cfg):
74 def run(seed):
75 seed_all(seed)
76 d = get_dataset('dynamics', seed, n_train=400, n_test=160)
77 # Preserve dataset tensor/shape contract, but train the intervention's own system.
78 net = HybridRNN()
79 class DS(dict): pass
80 # train_model expects tensors and uses the model directly.
81 _, metric, _ = train_model(net, d, epochs=EPOCHS, lr=cfg['lr'], batch=BATCH, log=lambda *_: None)
82 return metric
83 return run
84
85
86def fit_signature(seed=0):
87 """NN-scale behavioral signature: measured Jacobian spectral radius and rollout growth.
88 This tests the promised local-stability relation on trained models, not an analytic toy."""
89 seed_all(seed); d=get_dataset('dynamics', seed, n_train=400, n_test=160)
90 b=make_model('rnn_small', d['input_shape'], d['out_dim'])
91 i=HybridRNN()
92 b,_,_=train_model(b,d,epochs=EPOCHS,lr=3e-3,batch=BATCH,log=lambda *_:None)
93 i,_,_=train_model(i,d,epochs=EPOCHS,lr=3e-3,batch=BATCH,log=lambda *_:None)
94 device=next(b.parameters()).device
95 x=d['xte'][:32].to(device).clone().requires_grad_(True)
96 out=[]
97 for net in (b,i):
98 vals=[]
99 for q in range(len(x)):
100 jac=torch.autograd.functional.jacobian(lambda z: net(z.unsqueeze(0)).squeeze(0), x[q], create_graph=False)
101 vals.append(float(torch.linalg.norm(jac).detach().cpu()))
102 # observed one-step behavioral proxy: output perturbation amplification.
103 with torch.no_grad():
104 eps=1e-3; delta=torch.randn_like(x); y0=net(x.detach()); yp=net(x.detach()+eps*delta);
105 amp=float(torch.linalg.norm(yp-y0)/(eps*torch.linalg.norm(delta)+1e-8))
106 out.append({'mean_jacobian_norm':float(np.mean(vals)), 'max_jacobian_norm':float(np.max(vals)), 'perturbation_proxy':amp})
107 # Prediction is qualitative: bounded known transition should have smaller local amplification.
108 confirmed=out[1]['mean_jacobian_norm'] < out[0]['mean_jacobian_norm']
109 return {'prediction':'known stable relaxation/readout reduces local rollout amplification', 'baseline':out[0], 'idea':out[1], 'confirmed':bool(confirmed)}
110
111
112def main():
113 base=sweep_baseline(baseline_fn, [{'lr':lr} for lr in LRS])
114 # Idea at best baseline lr plus two nearby settings; because union is identical, all are fair.
115 idea_runs=[]
116 for lr in LRS:
117 r=evaluate(idea_fn({'lr':lr}), seeds=SEEDS)
118 idea_runs.append((r,lr))
119 idea, best_lr=max(idea_runs, key=lambda z: -z[0]['mean'])
120 # max with negative key selects lowest mean
121 idea_cfg={'lr':best_lr}
122 sig=fit_signature(0)
123 report=make_report('dynamics','rnn_small',base,idea,{'nn_scale_stability':sig, 'idea_cfg':idea_cfg})
124 report['protocol_note']='8 paired seeds; 400/160 samples; 18 epochs; baseline and idea evaluated on identical lr union.'
125 Path('bench_report.json').write_text(json.dumps(report,indent=2))
126 print(json.dumps(report,indent=2))
127
128if __name__=='__main__': main()