HOCBF Safety Shield for Neural Policies / bench_hocbf.py
Failed on benchmark
1import sys, json, math
2from pathlib import Path
3import numpy as np
4import torch
5import torch.nn as nn
6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
7from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report
8
9# Fixed a priori settings. The benchmark's input is 8 (theta, omega, u) tuples;
10# the model predicts a constant control for the next target rollout.
11DT = 0.05 / 4.0
12SUBSTEPS = 32 # eight benchmark intervals, four integration substeps each
13THETA_SAFE = 1.40
14K1, K2 = 2.0, 2.0
15U_MAX = 1.5
16
17class RolloutSystem(nn.Module):
18 def __init__(self, input_shape, shield=False, k1=K1, k2=K2):
19 super().__init__()
20 self.base = make_model('rnn_small', input_shape, 1)
21 self.shield = shield
22 self.k1, self.k2 = float(k1), float(k2)
23
24 def project(self, th, om, u_nom):
25 # h = theta_safe^2 - theta^2; hdot=-2 theta omega.
26 # psi2 = Lf2 h + LgLf h*u + k1*Lf h + k2*psi1 >= 0.
27 h = THETA_SAFE**2 - th * th
28 hd = -2.0 * th * om
29 # hessian contribution: -2*om^2; drift contribution from pendulum.
30 drift = -2.0 * om * om + 2.0 * th * (9.81 / 10.0) * torch.sin(th)
31 psi1 = hd + self.k1 * h
32 b = drift + self.k1 * hd + self.k2 * psi1
33 a = -4.0 * th # Lg Lf h
34 # Projection onto [-U_MAX,U_MAX] intersecting a*u+b >= 0.
35 unclipped = torch.clamp(u_nom, -U_MAX, U_MAX)
36 req = -b
37 # scalar exact projection; where a is nearly zero, retain bounded action.
38 candidate = torch.where(a.abs() > 1e-7, req / a, unclipped)
39 candidate = torch.clamp(candidate, -U_MAX, U_MAX)
40 feasible = (a * candidate + b >= 0)
41 # If actuator bounds make the constraint infeasible, safest available bound.
42 safe = torch.where(a > 0, torch.full_like(candidate, U_MAX),
43 torch.full_like(candidate, -U_MAX))
44 safe = torch.where(a.abs() > 1e-7, safe, unclipped)
45 candidate = torch.where(feasible, candidate, safe)
46 # The min/max composition gives an exact scalar QP solution while remaining
47 # differentiable almost everywhere for train_model's autograd.
48 return torch.where((a * unclipped + b >= 0), unclipped, candidate)
49
50 def forward(self, x):
51 # rnn_small accepts flattened windows in the bench implementation.
52 u_nom = torch.tanh(self.base(x)).reshape(-1) * U_MAX
53 th = x[:, -3]
54 om = x[:, -2]
55 for _ in range(SUBSTEPS):
56 u = self.project(th, om, u_nom) if self.shield else u_nom
57 om = om + (-9.81 / 10.0 * torch.sin(th) - 0.25 * om + 2.0 * u) * DT
58 th = th + om * DT
59 return th.reshape(-1, 1)
60
61def train_one(seed, lr, shield, k1=K1, k2=K2, epochs=12):
62 torch.manual_seed(seed); np.random.seed(seed)
63 ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
64 model = RolloutSystem(ds['input_shape'], shield=shield, k1=k1, k2=k2)
65 _, metric, _ = train_model(model, ds, epochs=epochs, lr=lr, batch=128, log=lambda *_: None)
66 return float(metric) if metric is not None else float('inf')
67
68def run_cfg(cfg, shield):
69 return lambda seed: train_one(seed, cfg['lr'], shield, cfg.get('k1', K1), cfg.get('k2', K2))
70
71def signature(seed, lr, k1, k2):
72 # Measure trained-model behavior, not an analytic toy identity.
73 torch.manual_seed(seed); np.random.seed(seed)
74 ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
75 m = RolloutSystem(ds['input_shape'], shield=True, k1=k1, k2=k2)
76 m, _, _ = train_model(m, ds, epochs=12, lr=lr, batch=128, log=lambda *_: None)
77 m.eval(); x = ds['xte'].to(next(m.parameters()).device)
78 with torch.no_grad():
79 u_nom = torch.tanh(m.base(x)).reshape(-1)*U_MAX
80 th=x[:,-3]; om=x[:,-2]; raw=[]; sh=[]
81 for _ in range(SUBSTEPS):
82 h=THETA_SAFE**2-th*th; hd=-2*th*om
83 drift=-2*om*om+2*th*(9.81/10)*torch.sin(th)
84 psi1=hd+k1*h; b=drift+k1*hd+k2*psi1; a=-4*th
85 raw.append((a*u_nom+b).cpu().numpy())
86 u=m.project(th,om,u_nom); sh.append((a*u+b).cpu().numpy())
87 om=om+(-9.81/10*torch.sin(th)-.25*om+2*u)*DT; th=th+om*DT
88 raw=np.concatenate(raw); sh=np.concatenate(sh)
89 predicted = float(np.mean(raw < 0))
90 observed = float(np.mean(sh < -1e-5))
91 return {'trained_model_raw_constraint_violation_rate':predicted,
92 'trained_model_shielded_constraint_violation_rate':observed,
93 'projection_activation_rate':float(np.mean(np.abs(raw-sh)>1e-5)),
94 'predicted_vs_observed': {'predicted': predicted, 'observed': observed},
95 'confirmed': bool(observed <= 0.01 and predicted > observed + 0.02)}
96
97def main():
98 # Union of all idea learning rates is included in baseline sweep: parity.
99 lrs=[1e-3,3e-3,1e-2]
100 baseline_grid=[{'lr':lr} for lr in lrs]
101 base=sweep_baseline(lambda c: run_cfg(c, False), baseline_grid, seeds=tuple(range(4)))
102 # Re-evaluate the selected baseline on all eight paired seeds.
103 base['full']=evaluate(run_cfg(base['best_cfg'], False), seeds=tuple(range(8)))
104 # Same three-config budget for idea; includes baseline-best and nearby settings.
105 idea_cfg=[{'lr':lr,'k1':k1,'k2':k2} for lr,k1,k2 in [(base['best_cfg']['lr'],2.,2.),(1e-3,2.,2.),(1e-2,2.,2.)]]
106 idea_trials=[]
107 for c in idea_cfg:
108 r=evaluate(run_cfg(c, True), seeds=tuple(range(4)))
109 idea_trials.append({'cfg':c,'mean':r['mean']})
110 best_idea_cfg=min(idea_cfg, key=lambda c: next(t['mean'] for t in idea_trials if t['cfg']==c))
111 idea=evaluate(run_cfg(best_idea_cfg, True), seeds=tuple(range(8)))
112 rep=make_report('dynamics','rnn_small',{'best_cfg':base['best_cfg'],'sweep':base['sweep'],'full':base['full']},idea,signature(0,best_idea_cfg['lr'],2.,2.))
113 rep['idea']['sweep']=idea_trials; rep['custom_track']=None
114 Path('bench_report.json').write_text(json.dumps(rep,indent=2))
115 print(json.dumps(rep,indent=2))
116if __name__=='__main__': main()