ISS-CLF/RCBF Neural Policy Shield / bench_shield.py
Failed on benchmark
1import sys, json, random
2from pathlib import Path
3import numpy as np
4import torch
5import torch.nn.functional as F
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))
11NTR, NTE, EPOCHS, BATCH = 400, 200, 12, 128
12LRS = (1e-3, 3e-3, 1e-2)
13WBAR_GRID = (0.05, 0.15, 0.30)
14C_V, C_H, LIMIT, DT = 0.4, 1.0, 1.5, 0.05
15
16
17def seed_all(seed):
18 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
19 if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)
20
21
22def shield(z, theta, omega, wbar):
23 """Scalar robust CLF/CBF projection with differentiable soft fallback.
24
25 z is the RNN proposed next angle. The nominal rate is (z-theta)/DT.
26 CLF V=.5 theta^2+.05 omega^2 and CBF h=LIMIT^2-theta^2.
27 The closest feasible point is the interval projection; when empty, the
28 action remains bounded and hinge slacks quantify infeasibility.
29 """
30 V = 0.5 * theta.square() + 0.05 * omega.square()
31 # CLF: theta*(z-theta)/DT + wbar|theta| + cV V <= 0.
32 eps = torch.as_tensor(1e-5, device=z.device, dtype=z.dtype)
33 denom = torch.where(theta.abs() > eps, theta, torch.ones_like(theta))
34 clf_bound = theta - DT * (wbar * theta.abs() + C_V * V) / denom
35 # CBF: -2 theta*(z-theta)/DT - 2wbar|theta| + cH h >= 0.
36 h = LIMIT * LIMIT - theta.square()
37 cbf_bound = theta + DT * (C_H * h - 2.0 * wbar * theta.abs()) / (2.0 * denom)
38 lo = torch.minimum(clf_bound, cbf_bound)
39 hi = torch.maximum(clf_bound, cbf_bound)
40 # The interval orientation depends on theta; use conservative intersection
41 # of the two one-sided constraints via midpoint and smooth hinge penalty.
42 lo = torch.clamp(lo, -LIMIT, LIMIT)
43 hi = torch.clamp(hi, -LIMIT, LIMIT)
44 proj = torch.minimum(torch.maximum(z, lo), hi)
45 # If the nominal inequalities disagree, retaining proj is a bounded QP
46 # relaxation; report exact robust residuals and nonnegative slacks.
47 clf_res = theta * (proj-theta) / DT + wbar*theta.abs() + C_V*V
48 cbf_res = -2*theta*(proj-theta) / DT - 2*wbar*theta.abs() + C_H*h
49 sv = torch.relu(clf_res)
50 sh = torch.relu(-cbf_res)
51 return proj, sv, sh, clf_res, cbf_res
52
53
54def train_idea(model, ds, epochs, lr, wbar):
55 # Custom loop is necessary because the intervention modifies the training
56 # forward path; all other settings match bench.train_model.
57 device = 'cuda' if torch.cuda.is_available() else 'cpu'
58 try:
59 model = model.to(device)
60 opt = torch.optim.Adam(model.parameters(), lr=lr)
61 x, y = ds['xtr'].to(device), ds['ytr'].to(device)
62 for _ in range(epochs):
63 model.train(); perm = torch.randperm(len(x), device=device)
64 for i in range(0, len(x), BATCH):
65 ix = perm[i:i+BATCH]; raw = model(x[ix]).squeeze(-1)
66 seq = x[ix].view(-1, 8, 3); th, om = seq[:, -1, 0], seq[:, -1, 1]
67 pred, sv, sh, _, _ = shield(raw, th, om, wbar)
68 loss = F.mse_loss(pred, y[ix].squeeze(-1)) + 0.01*(sv.square().mean()+sh.square().mean())
69 opt.zero_grad(); loss.backward(); opt.step()
70 model.eval()
71 with torch.no_grad():
72 xx=ds['xte'].to(device); raw=model(xx).squeeze(-1)
73 seq=xx.view(-1,8,3); pred,*_=shield(raw,seq[:,-1,0],seq[:,-1,1],wbar)
74 metric=float(F.mse_loss(pred,ds['yte'].to(device).squeeze(-1)))
75 return metric
76 except RuntimeError:
77 return float('nan')
78
79
80def baseline_fn(cfg):
81 def run(seed):
82 seed_all(seed); ds=get_dataset('dynamics',seed,NTR,NTE)
83 net=make_model('rnn_small',ds['input_shape'],ds['out_dim'])
84 _, metric, _=train_model(net,ds,epochs=EPOCHS,lr=cfg['lr'],batch=BATCH,log=lambda *_:None)
85 return metric
86 return run
87
88
89def idea_fn(cfg):
90 def run(seed):
91 seed_all(seed); ds=get_dataset('dynamics',seed,NTR,NTE)
92 return train_idea(make_model('rnn_small',ds['input_shape'],ds['out_dim']),ds,EPOCHS,cfg['lr'],cfg['wbar'])
93 return run
94
95
96def signature():
97 # NN-scale re-test: train one actual model and measure predicted robust
98 # margin slope versus observed residual as wbar changes.
99 seed_all(0); ds=get_dataset('dynamics',0,NTR,NTE)
100 net=make_model('rnn_small',ds['input_shape'],1)
101 train_model(net,ds,epochs=EPOCHS,lr=3e-3,batch=BATCH,log=lambda *_:None)
102 dev='cuda' if torch.cuda.is_available() else 'cpu'; net.eval().to(dev); x=ds['xte'].to(dev)
103 with torch.no_grad():
104 z=net(x).squeeze(-1); q=x.view(-1,8,3); th,om=q[:,-1,0],q[:,-1,1]
105 vals=[]
106 for wb in [0.,.1,.2,.3]:
107 _,_,_,res,_=shield(z,th,om,wb); vals.append(float(res.mean()))
108 slope=float(np.polyfit([0.,.1,.2,.3],vals,1)[0])
109 predicted=float(torch.abs(th).mean())
110 return {'quantity':'mean robust CLF residual versus disturbance','predicted_slope_abs_theta':predicted,'observed_slope':slope,'relative_error':abs(slope-predicted)/(abs(predicted)+1e-8),'confirmed':bool(abs(slope-predicted)/(abs(predicted)+1e-8)<0.20)}
111
112
113def main():
114 baseline_grid=[{'lr':lr,'wbar':wb} for lr in LRS for wb in WBAR_GRID]
115 # Baseline receives union of every idea lr and the method's central knob;
116 # wbar is inert for standard training but retained for explicit parity.
117 base=sweep_baseline(baseline_fn,baseline_grid,seeds=(0,1,2,3))
118 idea_cfgs=[{'lr':base['best_cfg']['lr'],'wbar':base['best_cfg']['wbar']}, {'lr':1e-3,'wbar':.15}, {'lr':1e-2,'wbar':.30}]
119 idea_runs=[]
120 for cfg in idea_cfgs:
121 r=evaluate(idea_fn(cfg),seeds=SEEDS); idea_runs.append({'cfg':cfg,'result':r})
122 best=min(idea_runs,key=lambda q:q['result']['mean'])
123 rep=make_report('dynamics','rnn_small',base,best['result'],{'chosen_cfg':best['cfg'],'cV':C_V,'predicted_envelope':'Vdot <= -cV V + disturbance margin','signature':signature()})
124 rep['idea_sweep']=idea_runs
125 Path('bench_report.json').write_text(json.dumps(rep,indent=2))
126 print(json.dumps(rep,indent=2))
127
128if __name__=='__main__': main()