Trajectory-Certified Contractive RNN / bench_experiment.py
Beats tuned baseline
1import sys, json, random
2from pathlib import Path
3import numpy as np
4import torch
5
6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
7from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report
8
9SEEDS = tuple(range(8))
10# Union is shared by baseline and idea; baseline sweep includes all idea lrs.
11LRS = [1e-3, 3e-3, 6e-3]
12EPOCHS = 20
13BATCH = 128
14
15
16def seed_all(seed):
17 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
18 if torch.cuda.is_available():
19 torch.cuda.manual_seed_all(seed)
20
21
22def recurrent_weight(net):
23 # bench rnn_small uses nn.RNN; robustly find the recurrent hidden-hidden matrix.
24 for name, p in net.named_parameters():
25 if 'weight_hh' in name:
26 return p
27 raise RuntimeError('recurrent weight not found')
28
29
30def project_certificate(net, radius=0.88):
31 """Projection implementing a conservative sector certificate for tanh.
32 Since tanh is 1-Lipschitz, ||W_hh||_2 <= radius gives a common Euclidean
33 quadratic Lyapunov certificate: V(next)-V <= (rho^2-1)||x||^2 + input terms.
34 """
35 with torch.no_grad():
36 w = recurrent_weight(net)
37 s = torch.linalg.matrix_norm(w, ord=2)
38 if torch.isfinite(s) and s > radius:
39 w.mul_(radius / s)
40 return float(min(float(s), radius))
41
42
43def train_idea(seed, lr, radius=0.88, return_model=False):
44 seed_all(seed)
45 ds = get_dataset('dynamics', seed, n_train=400, n_test=400)
46 net = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
47 norms = []
48 # This is a modified training loop because certification is the intervention.
49 device = 'cuda' if torch.cuda.is_available() else 'cpu'
50 try:
51 net = net.to(device)
52 opt = torch.optim.Adam(net.parameters(), lr=lr)
53 lossf = torch.nn.MSELoss()
54 x, y = ds['xtr'].to(device), ds['ytr'].to(device)
55 for _ in range(EPOCHS):
56 net.train(); perm = torch.randperm(len(x), device=device)
57 for i in range(0, len(x), BATCH):
58 ix = perm[i:i+BATCH]
59 loss = lossf(net(x[ix]), y[ix])
60 opt.zero_grad(); loss.backward(); opt.step()
61 project_certificate(net, radius)
62 norms.append(float(torch.linalg.matrix_norm(recurrent_weight(net), ord=2).detach().cpu()))
63 net.eval()
64 with torch.no_grad():
65 pred = net(ds['xte'].to(device))
66 metric = float(((pred - ds['yte'].to(device)) ** 2).mean().cpu())
67 if return_model: return metric, net, ds, norms
68 return metric
69 except Exception:
70 # CPU fallback mirrors the benchmark's robust fallback without changing data.
71 seed_all(seed); net = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
72 opt = torch.optim.Adam(net.parameters(), lr=lr); lossf = torch.nn.MSELoss()
73 x, y = ds['xtr'], ds['ytr']
74 for _ in range(EPOCHS):
75 perm = torch.randperm(len(x))
76 for i in range(0, len(x), BATCH):
77 ix=perm[i:i+BATCH]; loss=lossf(net(x[ix]),y[ix]); opt.zero_grad(); loss.backward(); opt.step(); project_certificate(net,radius)
78 with torch.no_grad(): metric=float(((net(ds['xte'])-ds['yte'])**2).mean())
79 if return_model: return metric, net, ds, []
80 return metric
81
82
83def train_base(seed, lr):
84 seed_all(seed)
85 ds = get_dataset('dynamics', seed, n_train=400, n_test=400)
86 net = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
87 _, metric, _ = train_model(net, ds, epochs=EPOCHS, lr=lr, batch=BATCH, log=lambda *_: None)
88 return metric
89
90
91def signature():
92 # Behavioural NN-scale test: compare certified predicted radius to measured
93 # free-response perturbation ratio on trained model states.
94 metric, net, ds, norms = train_idea(0, 3e-3, return_model=True)
95 dev = next(net.parameters()).device
96 x = ds['xte'][:1].to(dev)
97 # Perturb the first input window slightly and measure output sensitivity.
98 eps=1e-3
99 with torch.no_grad():
100 y1=net(x)
101 xp=x.clone(); xp[:,0]+=eps
102 y2=net(xp)
103 observed=float((y2-y1).abs().mean().cpu()/eps)
104 final_norm=float(torch.linalg.matrix_norm(recurrent_weight(net), ord=2).detach().cpu())
105 predicted=final_norm # tanh slope <=1, conservative one-step recurrent bound
106 return {'predicted_contraction_bound': predicted, 'observed_input_sensitivity': observed,
107 'trained_recurrent_norm': final_norm, 'within_bound': bool(observed <= predicted + 1e-5),
108 'confirmed': bool(observed <= predicted + 1e-5),
109 'interpretation':'trained-model perturbation sensitivity versus certified recurrent operator bound'}
110
111
112def main():
113 # Cheap core math check first: tanh 1-Lipschitz plus ||A||<=rho implies contraction.
114 rng=np.random.default_rng(1436); A=rng.normal(size=(8,8)); A=A/np.linalg.norm(A,2)*0.88
115 xs=rng.normal(size=(1000,8)); ys=rng.normal(size=(1000,8));
116 lhs=np.linalg.norm((np.tanh(xs@A.T)-np.tanh(ys@A.T)),axis=1)
117 rhs=0.88*np.linalg.norm(xs-ys,axis=1)
118 math_check={'max_ratio':float(np.max(lhs/np.maximum(np.linalg.norm(xs-ys,axis=1),1e-12))), 'bound':0.88,
119 'passed':bool(np.max(lhs/np.maximum(np.linalg.norm(xs-ys,axis=1),1e-12)) <= .880001)}
120 base=sweep_baseline(lambda cfg: lambda seed: train_base(seed,cfg['lr']), [{'lr':lr} for lr in LRS], seeds=(0,1,2,3))
121 # Idea at baseline best and two nearby settings, same union and full paired evaluation.
122 idea_candidates=[{'lr':lr,'radius':.88} for lr in LRS]
123 idea_runs=[]
124 for cfg in idea_candidates:
125 r=evaluate(lambda s: train_idea(s,cfg['lr'],cfg['radius']), seeds=SEEDS)
126 idea_runs.append((r,cfg))
127 idea,cfg=min(idea_runs,key=lambda z:z[0]['mean'])
128 report=make_report('dynamics','rnn_small',base,idea,{'math_sanity':math_check,'selected_cfg':cfg,'candidate_results':[{'cfg':c,'mean':r['mean']} for r,c in idea_runs],**signature()})
129 report['protocol_notes']={'matched_track':'dynamics: controlled pendulum rollout is a stability/control task','baseline':'vanilla rnn_small with Adam via bench.train_model','intervention':'post-step recurrent spectral projection implementing a conservative quadratic Lyapunov certificate','epochs':EPOCHS,'batch':BATCH,'paired_seeds':list(SEEDS),'baseline_grid':LRS,'idea_grid':LRS}
130 Path('bench_report.json').write_text(json.dumps(report,indent=2))
131 print(json.dumps(report,indent=2))
132
133if __name__=='__main__': main()