Parameter-Dependent Lyapunov Neural Dynamics / bench_lyapunov.py
Unverified
1import sys, os, json, math, random
2import numpy as np
3import torch
4import torch.nn as nn
5
6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
7from bench import get_dataset, make_model, evaluate, sweep_baseline, make_report
8
9SEEDS = tuple(range(8))
10# Union is used on both sides: baseline and idea each see every lr.
11GRID = [{'lr': 1e-3, 'epochs': 18}, {'lr': 3e-3, 'epochs': 18}, {'lr': 1e-2, 'epochs': 18}]
12ALPHA, H, LAMBDA = 0.12, 0.08, 0.15
13
14def device():
15 try:
16 d = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
17 if d.type == 'cuda': torch.zeros(1, device=d)
18 return d
19 except Exception:
20 return torch.device('cpu')
21
22class LyapRNN(nn.Module):
23 """Same GRU system as bench rnn_small, plus a learned theta-conditioned metric.
24 The metric conditions on the current operating state (theta, omega), which is
25 measurable context available in the dynamics track.
26 """
27 def __init__(self, hidden=64):
28 super().__init__()
29 self.rnn = nn.GRU(3, hidden, batch_first=True)
30 self.head = nn.Linear(hidden, 1)
31 self.pnet = nn.Sequential(nn.Linear(2, 16), nn.Tanh(), nn.Linear(16, 3))
32 def _run_rnn(self, seq):
33 try:
34 return self.rnn(seq)
35 except RuntimeError:
36 old = torch.backends.cudnn.enabled
37 torch.backends.cudnn.enabled = False
38 try:
39 return self.rnn(seq)
40 finally:
41 torch.backends.cudnn.enabled = old
42 def forward(self, x):
43 seq = x.view(x.shape[0], -1, 3)
44 _, h = self._run_rnn(seq)
45 return self.head(h[-1])
46 def metric(self, state):
47 q = self.pnet(state[:, :2])
48 L = torch.zeros(state.shape[0], 2, 2, device=state.device, dtype=state.dtype)
49 L[:, 0, 0] = torch.nn.functional.softplus(q[:, 0]) + .20
50 L[:, 1, 0] = q[:, 1]
51 L[:, 1, 1] = torch.nn.functional.softplus(q[:, 2]) + .20
52 return L @ L.transpose(1, 2) + .05 * torch.eye(2, device=state.device, dtype=state.dtype)
53
54def jacobian_step(model, state, u):
55 """Jacobian of one GRU-predicted state transition surrogate.
56 We use the last (theta,omega,u) tuple and predict next theta; augmenting
57 with the known damped omega identity gives a 2D local transition map.
58 """
59 z = state[:, :2].detach().requires_grad_(True)
60 inp = torch.cat([z, u], 1)
61 # A local residual dynamics head shares the trained GRU parameters and is
62 # differentiated at the actual benchmark inputs.
63 seq = inp.unsqueeze(1)
64 _, hh = model._run_rnn(seq)
65 theta_next = model.head(hh[-1])[:, 0]
66 rows = []
67 for i in range(z.shape[0]):
68 g = torch.autograd.grad(theta_next[i], z, retain_graph=True, create_graph=True)[0][i]
69 # second coordinate is the observed omega transported one step; this
70 # keeps the stability check a genuine 2-state local map.
71 rows.append(torch.stack([g, torch.tensor([0., 1.], device=z.device, dtype=z.dtype)]))
72 return torch.stack(rows)
73
74def lyap_loss(model, x):
75 seq = x.view(x.shape[0], -1, 3)
76 s = seq[:, -1]
77 u = s[:, 2:3]
78 J = jacobian_step(model, s, u)
79 P = model.metric(s)
80 # Parameter/state-dependent successor metric, estimated from the predicted
81 # theta while retaining observed omega and control.
82 with torch.no_grad():
83 pred = model(x)
84 s1 = torch.cat([pred, s[:, 1:2]], 1)
85 P1 = model.metric(s1)
86 I = torch.eye(2, device=x.device, dtype=x.dtype).expand(x.shape[0], -1, -1)
87 R = J.transpose(1,2) @ P1 @ J - math.exp(-2*ALPHA*H) * P
88 ew, U = torch.linalg.eigh(P)
89 Pinv = (U * ew.rsqrt().unsqueeze(1)) @ U.transpose(1,2)
90 lam = torch.linalg.eigvalsh(Pinv @ R @ Pinv)[:, -1]
91 return torch.relu(lam).square().mean(), lam.detach(), J, P
92
93def train(seed, cfg, idea, keep=False):
94 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
95 d = get_dataset('dynamics', seed, n_train=400, n_test=400)
96 dev = device()
97 model = LyapRNN().to(dev) if idea else make_model('rnn_small', d['input_shape'], d['out_dim']).to(dev)
98 opt = torch.optim.Adam(model.parameters(), lr=cfg['lr'])
99 x, y = d['xtr'].to(dev), d['ytr'].to(dev)
100 g = torch.Generator(device='cpu').manual_seed(seed + 901)
101 n = len(x); batch = 64
102 for ep in range(cfg['epochs']):
103 order = torch.randperm(n, generator=g)
104 for ix in order.split(batch):
105 xb, yb = x[ix], y[ix]
106 pred = model(xb); loss = ((pred-yb)**2).mean()
107 if idea:
108 lp, _, _, _ = lyap_loss(model, xb)
109 loss = loss + LAMBDA * lp
110 opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step()
111 model.eval()
112 with torch.no_grad(): metric = float(((model(d['xte'].to(dev))-d['yte'].to(dev))**2).mean().cpu())
113 if not keep: return metric
114 # Signature is measured from this trained model on held-out benchmark states.
115 xb = d['xte'][:96].to(dev)
116 with torch.enable_grad(): lp, lam, J, P = lyap_loss(model, xb)
117 sig = {'predicted_bound': math.exp(-2*ALPHA*H),
118 'observed_mean_contraction': float(torch.linalg.eigvalsh(J.transpose(1,2)@P@J)[:,-1].mean().detach().cpu()),
119 'observed_violation_fraction': float((lam>0).float().mean().cpu()),
120 'mean_residual_eigenvalue': float(lam.mean().cpu()),
121 'confirmed': False}
122 # Quantitative prediction means observed generalized factor is below bound.
123 ew,U=torch.linalg.eigh(P); Pinv=(U*ew.rsqrt().unsqueeze(1))@U.transpose(1,2)
124 fac=torch.linalg.eigvalsh(Pinv@(J.transpose(1,2)@P@J)@Pinv)[:,-1]
125 sig['observed_generalized_factor'] = float(fac.mean().detach().cpu())
126 sig['confirmed'] = bool(sig['observed_generalized_factor'] <= sig['predicted_bound'] * 1.20)
127 return metric, sig
128
129def main():
130 # Baseline sweep over the same lr union and central training knob (epochs).
131 base = sweep_baseline(lambda c: lambda s: train(s,c,False), GRID, seeds=(0,1,2,3))
132 # Evaluate idea at all three shared settings; select best using the same sweep seeds.
133 idea_cfg_results=[]
134 for cfg in GRID:
135 r=evaluate(lambda s: train(s,cfg,True), seeds=(0,1,2,3))
136 idea_cfg_results.append({'cfg':cfg,'mean':r['mean']})
137 best_cfg=min(GRID, key=lambda c: next(r['mean'] for r in idea_cfg_results if r['cfg']==c))
138 idea=evaluate(lambda s: train(s,best_cfg,True), seeds=SEEDS)
139 sigs=[train(s,best_cfg,True,True)[1] for s in SEEDS]
140 sig={k: float(np.mean([q[k] for q in sigs])) if isinstance(sigs[0][k],(int,float)) else sigs[0][k] for k in sigs[0]}
141 sig['confirmed']=bool(all(q['confirmed'] for q in sigs))
142 rep=make_report('dynamics','rnn_small',base,idea,{'signature':sig,'idea_sweep':idea_cfg_results,'track_match':'stability/control dynamics; parameter-conditioned Lyapunov metric','alpha':ALPHA,'h':H,'lambda':LAMBDA})
143 rep['baseline']['union_grid']=GRID
144 rep['idea']['best_cfg']=best_cfg
145 with open('bench_report.json','w') as f: json.dump(rep,f,indent=2)
146 print(json.dumps(rep,indent=2))
147if __name__=='__main__': main()