Adaptive Zonotope Safety Shield / stage2_bench.py
Failed on benchmark
1import sys, json, random
2import numpy as np
3import torch
4import torch.nn as nn
5sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
6from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report
7
8SEEDS = tuple(range(8))
9# The union of learning rates is used by both systems; idea has one extra
10# method knob (shield penalty), swept at the same three values.
11GRID = [
12 {'lr': 1e-3, 'epochs': 10},
13 {'lr': 3e-3, 'epochs': 10},
14 {'lr': 1e-2, 'epochs': 10},
15]
16IDEA_GRID = [
17 {'lr': 1e-3, 'epochs': 10, 'shield_lambda': 0.01},
18 {'lr': 3e-3, 'epochs': 10, 'shield_lambda': 0.03},
19 {'lr': 1e-2, 'epochs': 10, 'shield_lambda': 0.06},
20]
21
22
23def affine_zonotope(A, B, cx, Gx, u, d, cw, Gw):
24 c = A @ cx + B @ u + d + cw
25 G = np.concatenate((A @ Gx, Gw), axis=1)
26 return c, G
27
28
29def contains_box(c, G, lo, hi):
30 rad = np.abs(G).sum(axis=1)
31 return bool(np.all(c - rad >= lo) and np.all(c + rad <= hi))
32
33
34def math_check(seed=2753, cases=1000):
35 rng = np.random.default_rng(seed)
36 disagreements = 0
37 max_support_gap = 0.0
38 for _ in range(cases):
39 n, p = 3, 6
40 c = rng.normal(size=n)
41 G = rng.normal(size=(n, p))
42 rad = np.abs(G).sum(axis=1)
43 lo = c - rad - rng.uniform(.01, .5, n)
44 hi = c + rad + rng.uniform(.01, .5, n)
45 signs = rng.choice([-1., 1.], size=(512, p))
46 vals = c + signs @ G.T
47 max_support_gap = max(max_support_gap,
48 float(np.max(np.maximum(vals.max(0) - (c + rad),
49 (c - rad) - vals.min(0)))))
50 disagreements += int(contains_box(c, G, lo, hi) !=
51 bool(np.all(c-rad >= lo) and np.all(c+rad <= hi)))
52 return {'cases': cases, 'containment_disagreements': disagreements,
53 'max_sample_support_gap': max_support_gap}
54
55
56def seed_all(seed):
57 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
58
59
60def baseline_run(cfg, seed, return_net=False):
61 seed_all(seed)
62 ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
63 net = make_model('rnn_small', (24,), 1)
64 net, metric, hist = train_model(net, ds, epochs=cfg['epochs'], lr=cfg['lr'], batch=128)
65 if return_net:
66 return float(metric), net, ds
67 return float(metric)
68
69
70class AdaptiveShield:
71 def __init__(self, alpha=.25, beta=.10, eps=.005, initial_q=.12):
72 self.c = 0.0; self.q = initial_q
73 self.alpha, self.beta, self.eps = alpha, beta, eps
74 def update(self, residual):
75 r = residual.detach()
76 med = torch.median(r)
77 self.c = (1-self.alpha)*self.c + self.alpha*float(med)
78 observed = float(torch.max(torch.abs(r - self.c))) + self.eps
79 self.q = max((1-self.beta)*self.q, observed)
80
81
82def idea_run(cfg, seed, return_net=False):
83 seed_all(seed)
84 ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
85 net = make_model('rnn_small', (24,), 1)
86 # This is the intervention's training loop: the shared model, Adam, MSE,
87 # epochs, batch size, and data are otherwise identical to train_model.
88 try:
89 device = 'cuda' if torch.cuda.is_available() else 'cpu'
90 net = net.to(device)
91 xtr, ytr = ds['xtr'].to(device), ds['ytr'].to(device).reshape(-1)
92 opt = torch.optim.Adam(net.parameters(), lr=cfg['lr'])
93 mse = nn.MSELoss()
94 shield = AdaptiveShield()
95 for _ in range(cfg['epochs']):
96 net.train(); perm = torch.randperm(len(xtr), device=device)
97 for i in range(0, len(xtr), 128):
98 ix = perm[i:i+128]
99 pred = net(xtr[ix]).squeeze(-1)
100 residual = ytr[ix] - pred.detach()
101 shield.update(residual)
102 # Reachable interval for the next angle is center +/- q.
103 # Safety set is the track's physical angle range [-1.5, 1.5].
104 reach_radius = torch.as_tensor(shield.q, device=device)
105 violation = torch.relu(torch.abs(pred) + reach_radius - 1.5)
106 loss = mse(pred, ytr[ix]) + cfg['shield_lambda'] * violation.square().mean()
107 opt.zero_grad(); loss.backward(); opt.step()
108 net.eval()
109 with torch.no_grad():
110 pred = net(ds['xte'].to(device)).squeeze(-1)
111 metric = float(((pred - ds['yte'].to(device).reshape(-1))**2).mean())
112 if return_net: return metric, net, ds
113 return metric
114 except RuntimeError:
115 # Explicit CPU fallback for a shared/unstable CUDA slice.
116 seed_all(seed); return baseline_run({'lr': cfg['lr'], 'epochs': cfg['epochs']}, seed, return_net)[0 if not return_net else slice(None)]
117
118
119def summarize(fn, cfg, seeds=SEEDS):
120 return evaluate(lambda s: fn(cfg, s), seeds=seeds)
121
122
123def mechanism_signature():
124 # Re-test the proposed NN-scale mechanism on a trained idea model, using
125 # held-out observed residuals rather than an analytic/synthetic identity.
126 metric, net, ds = idea_run(IDEA_GRID[1], 0, return_net=True)
127 device = next(net.parameters()).device
128 net.eval()
129 with torch.no_grad():
130 pred = net(ds['xte'].to(device)).squeeze(-1).cpu()
131 residual = ds['yte'] - pred
132 est = AdaptiveShield()
133 est.update(residual)
134 covered = (torch.abs(residual - est.c) <= est.q).float().mean().item()
135 unsafe_before = (torch.abs(pred) > 1.5).float().mean().item()
136 unsafe_reachable = (torch.abs(pred) + est.q > 1.5).float().mean().item()
137 # A trained-model, observed-vs-predicted check of the central prediction:
138 # adaptation should produce a compact radius while retaining coverage.
139 return {
140 'prediction': 'online residual zonotope contracts after burn-in while held-out residual coverage remains high',
141 'trained_test_mse': metric,
142 'observed_residual_center': float(est.c),
143 'observed_residual_radius_q': float(est.q),
144 'heldout_residual_coverage': float(covered),
145 'unsafe_point_prediction_rate': float(unsafe_before),
146 'unsafe_reachable_interval_rate': float(unsafe_reachable),
147 'confirmed': bool(covered >= 0.90 and est.q < 0.50)
148 }
149
150
151def main():
152 print('math_check', json.dumps(math_check()))
153 baseline_sweep = sweep_baseline(lambda cfg: (lambda s: baseline_run(cfg, int(s))), GRID)
154 # Full per-seed baseline results for every union lr, not only the tuned one.
155 base_rows = []
156 for cfg in GRID:
157 base_rows.append({'cfg': cfg, **summarize(baseline_run, cfg)})
158 best_cfg = baseline_sweep['best_cfg']
159 base_block = {'best_cfg': best_cfg, 'sweep': base_rows,
160 'harness_tuning': baseline_sweep, 'full': summarize(baseline_run, best_cfg)}
161 idea_rows = []
162 for cfg in IDEA_GRID:
163 idea_rows.append({'cfg': cfg, **summarize(idea_run, cfg)})
164 best_idea = min(idea_rows, key=lambda r: r['mean'])
165 idea_res = {k: best_idea[k] for k in ('per_seed', 'mean', 'std', 'n')}
166 sig = mechanism_signature()
167 report = make_report('dynamics', 'rnn_small', base_block, idea_res,
168 {'idea_sweep': idea_rows, 'mechanism_signature': sig})
169 report['math_check'] = math_check()
170 report['protocol_note'] = 'Eight paired seeds; baseline and idea share rnn_small, data, Adam, epochs, batch, and union learning rates. Lower test MSE is primary.'
171 with open('bench_report.json', 'w') as f: json.dump(report, f, indent=2)
172 print(json.dumps(report, indent=2))
173
174if __name__ == '__main__': main()