Hybrid-Zonotope Reachability Loss for Neural Closed Loops / stage2_bench.py
Failed on benchmark
1import sys, json, random, math
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
10SEED0 = 2686
11EPOCHS = 18
12BATCH = 128
13# Union of baseline and idea grids: each side is evaluated at all learning rates.
14LRS = [1e-3, 3e-3, 1e-2]
15WEIGHT_DECAYS = [0.0, 1e-4] # baseline method knob, swept fairly
16
17
18def math_check():
19 # Exact scalar zonotope radius recurrence r+=|lambda|r+gamma.
20 r0, gamma, n = .2, .03, 8
21 rows = []
22 for lam in [0., .2, .5, .8, 1., 1.2]:
23 observed = abs(lam)**n*r0 + gamma*sum(abs(lam)**i for i in range(n))
24 propagated = r0
25 for _ in range(n): propagated = abs(lam)*propagated + gamma
26 rows.append(abs(observed-propagated))
27 return {'max_radius_recurrence_error': float(max(rows)),
28 'confirmed': bool(max(rows) < 1e-12)}
29
30
31def device_net(model):
32 return model
33
34
35def interval_reach_loss(net, x, horizon=8, disturbance=.025):
36 """Differentiable interval-zonotope certificate around each training window.
37 State is (theta, omega); the learned RNN predicts terminal theta. We propagate
38 the known local pendulum bounds with interval Euler dynamics and penalize state
39 and terminal-set support violations plus non-contraction."""
40 b = x.shape[0]
41 # initial state uncertainty induced by a bounded set around first observed state
42 lo = x[:, :2] - torch.tensor([.04, .04], device=x.device)
43 hi = x[:, :2] + torch.tensor([.04, .04], device=x.device)
44 total = torch.zeros((), device=x.device)
45 radii = []
46 dt = .05
47 for k in range(horizon):
48 # conservative interval for acceleration: -sin(theta)-.1 omega + u + w
49 thl, thh = lo[:, 0], hi[:, 0]
50 oml, omh = lo[:, 1], hi[:, 1]
51 ul = x[:, 3*k+2] - .08
52 uh = x[:, 3*k+2] + .08
53 # sin range via endpoint plus extrema (bounds here are small enough, but sound)
54 candidates = [torch.sin(thl), torch.sin(thh)]
55 twopi = 2*math.pi
56 for m in range(-3, 4):
57 p = m*math.pi + math.pi/2
58 candidates.append(torch.where((thl <= p) & (p <= thh), torch.ones_like(thl)*math.sin(p), candidates[0]))
59 sl = torch.stack(candidates).amin(0); sh = torch.stack(candidates).amax(0)
60 # a=-sin(theta)-.1 omega+u plus bounded model disturbance
61 al = -sh - .1*omh + ul - disturbance
62 ah = -sl - .1*oml + uh + disturbance
63 nlo = lo.clone(); nhi = hi.clone()
64 nlo[:, 0] = lo[:, 0] + dt*oml; nhi[:, 0] = hi[:, 0] + dt*omh
65 nlo[:, 1] = lo[:, 1] + dt*al; nhi[:, 1] = hi[:, 1] + dt*ah
66 lo, hi = nlo, nhi
67 rad = (hi-lo).mean(0)
68 radii.append(rad)
69 # support of box against |theta|<=1.5, |omega|<=4
70 total = total + torch.relu(hi[:,0]-1.5).mean() + torch.relu(-lo[:,0]-1.5).mean()
71 total = total + .15*(torch.relu(hi[:,1]-4).mean()+torch.relu(-lo[:,1]-4).mean())
72 # terminal set and contraction deficit: radius should not grow beyond initial radius
73 terminal_rad = (hi-lo).abs().mean()
74 total = total + 2.0*(torch.relu(terminal_rad - .25))**2
75 if len(radii) >= 2:
76 total = total + .2*torch.relu(radii[-1].mean() - radii[0].mean())**2
77 # Couple certificate to the actual trained network's prediction on the set center.
78 center = (lo+hi)/2
79 pred = net(x)
80 total = total + .05*torch.relu(pred.abs().mean() - 1.5)**2
81 return total
82
83
84def train_idea(model, ds, epochs, lr, weight_decay, lam=.15):
85 """Only custom loop because the method changes the training loss."""
86 errs=[]
87 for dev in (['cuda','cpu'] if torch.cuda.is_available() else ['cpu']):
88 try:
89 net=model.to(dev); opt=torch.optim.Adam(net.parameters(), lr=lr, weight_decay=weight_decay)
90 lossf=nn.MSELoss(); xtr,ytr=ds['xtr'].to(dev),ds['ytr'].to(dev)
91 for ep in range(epochs):
92 net.train(); perm=torch.randperm(len(xtr),device=dev)
93 for i in range(0,len(xtr),BATCH):
94 ix=perm[i:i+BATCH]; xb,yb=xtr[ix],ytr[ix]
95 loss=lossf(net(xb),yb)+lam*interval_reach_loss(net,xb)
96 opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(),5.0); opt.step()
97 net.eval()
98 with torch.no_grad(): metric=float(((net(ds['xte'].to(dev))-ds['yte'].to(dev))**2).mean())
99 return metric
100 except RuntimeError as e: errs.append(str(e)); continue
101 raise RuntimeError(';'.join(errs))
102
103
104def baseline_fn(cfg):
105 def run(seed):
106 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
107 ds=get_dataset('dynamics',seed,n_train=400,n_test=200)
108 net=make_model('rnn_small', ds['input_shape'], ds['out_dim'])
109 _, metric, _=train_model(net,ds,epochs=EPOCHS,lr=cfg['lr'],batch=BATCH,weight_decay=cfg['weight_decay'],log=lambda *_:None)
110 return metric
111 return run
112
113def idea_fn(cfg):
114 def run(seed):
115 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
116 ds=get_dataset('dynamics',seed,n_train=400,n_test=200)
117 net=make_model('rnn_small', ds['input_shape'], ds['out_dim'])
118 return train_idea(net,ds,EPOCHS,cfg['lr'],cfg['weight_decay'],cfg['lambda'])
119 return run
120
121if __name__=='__main__':
122 print(json.dumps({'math_check':math_check()}, indent=2))
123 base_grid=[{'lr':lr,'weight_decay':wd} for lr in LRS for wd in WEIGHT_DECAYS]
124 base=sweep_baseline(baseline_fn,base_grid)
125 # Same 3 lr choices and the baseline's best weight-decay, plus the same penalty sweep.
126 idea_grid=[{'lr':lr,'weight_decay':base['best_cfg']['weight_decay'],'lambda':lam} for lr in LRS for lam in [.05,.15,.3]]
127 # Evaluate idea configs on sweep seeds, choose best; full result only for winner.
128 tried=[]
129 for cfg in idea_grid:
130 r=evaluate(idea_fn(cfg),seeds=(0,1,2,3)); tried.append({'cfg':cfg,'mean':r['mean']})
131 best=min(tried,key=lambda z:z['mean'])['cfg']; idea=evaluate(idea_fn(best))
132 base['idea_grid']=tried
133 rep=make_report('dynamics','rnn_small',base,idea,extra={
134 'prediction':'adding the interval-zonotope loss should change the trained model task metric',
135 'observed_nn':{
136 'baseline_mean_test_mse':base['full']['mean'],
137 'idea_mean_test_mse':idea['mean'],
138 'observed_delta':idea['mean']-base['full']['mean'],
139 'best_cfg':best},
140 'toy_numeric_check':math_check(),
141 'confirmed':False,
142 'note':'The arithmetic radius recurrence is exact, but this does not establish the mechanism on the trained benchmark models.'})
143 Path('bench_report.json').write_text(json.dumps(rep,indent=2))
144 print(json.dumps(rep,indent=2))