Safe Receding-Horizon Neural Topology Switching / stage2_bench.py
Mechanism confirmed, baseline not beaten
1import json, random
2import numpy as np
3import torch
4from torch import nn
5import sys
6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
7from bench import get_dataset, evaluate, sweep_baseline, make_report
8
9SEEDS = tuple(range(8))
10LRS = [0.0015, 0.003, 0.006]
11EPOCHS = 18
12NTR, NTE = 400, 200
13BATCH = 128
14EPS = 0.02
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
22class SwitchGRU(nn.Module):
23 """The bench rnn_small architecture with a differentiable timestep mask."""
24 def __init__(self, mask=None):
25 super().__init__()
26 self.rnn = nn.GRU(3, 64, batch_first=True)
27 self.head = nn.Linear(64, 1)
28 self.register_buffer('mask', torch.ones(8) if mask is None else torch.tensor(mask, dtype=torch.float32))
29
30 def forward(self, x, mask=None):
31 seq = x.view(x.shape[0], 8, 3)
32 m = self.mask if mask is None else mask
33 seq = seq * m.view(1, 8, 1)
34 _, h = self.rnn(seq)
35 return self.head(h[-1])
36
37
38def device_or_cpu():
39 return 'cuda' if torch.cuda.is_available() else 'cpu'
40
41
42def mse_on(model, x, y, device, mask=None):
43 model.eval()
44 with torch.no_grad():
45 return float(torch.mean((model(x.to(device), mask=mask) - y.to(device)) ** 2).item())
46
47
48def _train_one(kind, lr, seed, return_info=False, forced_device=None):
49 seed_all(seed)
50 ds = get_dataset('dynamics', seed=seed, n_train=NTR, n_test=NTE)
51 xtr, ytr = ds['xtr'].float(), ds['ytr'].float().reshape(-1, 1)
52 xte, yte = ds['xte'].float(), ds['yte'].float().reshape(-1, 1)
53 device = forced_device or device_or_cpu()
54 model = SwitchGRU().to(device)
55 opt = torch.optim.Adam(model.parameters(), lr=float(lr))
56 rng = np.random.default_rng(seed + 991)
57 cal_idx = torch.as_tensor(rng.choice(len(xtr), size=min(96, len(xtr)), replace=False), dtype=torch.long)
58 cx, cy = xtr[cal_idx], ytr[cal_idx]
59 current = np.ones(8, dtype=np.float32)
60 target = np.array([1, 1, 1, 1, 0, 0, 0, 0], dtype=np.float32)
61 rejected = 0
62 accepted_violations = 0
63 transitions = []
64 # The exact filter is evaluated at every planned intermediate state.
65 for epoch in range(EPOCHS):
66 model.train()
67 perm = torch.randperm(len(xtr))
68 for st in range(0, len(xtr), BATCH):
69 ii = perm[st:st+BATCH]
70 pred = model(xtr[ii].to(device))
71 loss = torch.mean((pred - ytr[ii].to(device)) ** 2)
72 opt.zero_grad(); loss.backward(); opt.step()
73 if kind == 'idea' and epoch >= 4 and not np.allclose(current, target):
74 base_mask = torch.tensor(current, dtype=torch.float32, device=device)
75 base_loss = mse_on(model, cx, cy, device, base_mask)
76 # Receding horizon: try the largest remaining interpolation first.
77 accepted = None
78 for alpha in (1.0, 0.75, 0.5, 0.25):
79 cand = torch.tensor((1-alpha)*current + alpha*target, dtype=torch.float32, device=device)
80 exact = mse_on(model, cx, cy, device, cand)
81 if exact <= base_loss + EPS:
82 accepted = (alpha, exact)
83 break
84 rejected += 1
85 if accepted is not None:
86 alpha, exact = accepted
87 current = (1-alpha)*current + alpha*target
88 transitions.append({'epoch': epoch, 'alpha': float(alpha), 'calibration_mse': exact, 'baseline_mse': base_loss})
89 if exact > base_loss + EPS + 1e-7:
90 accepted_violations += 1
91 model.eval()
92 with torch.no_grad():
93 metric = float(torch.mean((model(xte.to(device), mask=torch.tensor(current, device=device)) - yte.to(device)) ** 2).item())
94 if return_info:
95 return metric, model, ds, {'mask': current.tolist(), 'rejected': rejected, 'accepted_violations': accepted_violations, 'transitions': transitions}
96 return metric
97
98
99def train_one(kind, lr, seed, return_info=False):
100 try:
101 return _train_one(kind, lr, seed, return_info, None)
102 except (RuntimeError, torch.cuda.OutOfMemoryError) as exc:
103 if torch.cuda.is_available() and ('cuda' in str(exc).lower() or 'cudnn' in str(exc).lower() or isinstance(exc, torch.cuda.OutOfMemoryError)):
104 try:
105 torch.cuda.empty_cache()
106 except Exception:
107 pass
108 return _train_one(kind, lr, seed, return_info, 'cpu')
109 raise
110
111
112def base_factory(cfg):
113 return lambda seed: train_one('baseline', cfg['lr'], seed)
114
115
116def idea_factory(cfg):
117 return lambda seed: train_one('idea', cfg['lr'], seed)
118
119
120def mechanism_signature():
121 # Re-test the prediction on trained systems, not an analytic toy.
122 bm, bnet, ds, _ = train_one('baseline', 0.003, 0, True)
123 im, inet, ids, inf = train_one('idea', 0.003, 0, True)
124 device = next(bnet.parameters()).device
125 x, y = ds['xtr'][:96], ds['ytr'][:96].reshape(-1, 1)
126 full = np.ones(8, dtype=np.float32); target = np.array([1,1,1,1,0,0,0,0], dtype=np.float32)
127 unfiltered = []
128 base = mse_on(bnet, x, y, device, torch.tensor(full, device=device))
129 for a in np.linspace(0, 1, 9):
130 m = torch.tensor((1-a)*full+a*target, device=device)
131 unfiltered.append(mse_on(bnet, x, y, device, m) > base + EPS)
132 observed_unfiltered = int(sum(unfiltered))
133 observed_filtered = int(inf['accepted_violations'])
134 return {
135 'prediction': 'direct topology interpolation can transiently violate calibration accuracy; exact receding-horizon filtering accepts no violating step',
136 'trained_baseline_unfiltered_violations_of_9': observed_unfiltered,
137 'trained_idea_accepted_step_violations': observed_filtered,
138 'trained_idea_rejected_candidates': int(inf['rejected']),
139 'trained_idea_transition_steps': len(inf['transitions']),
140 'predicted_zero_accepted_violations': True,
141 'confirmed': bool(observed_unfiltered > 0 and observed_filtered == 0)
142 }
143
144
145def main():
146 # Search-space parity: every idea lr is swept for baseline as well.
147 grid = [{'lr': x} for x in LRS]
148 base = sweep_baseline(base_factory, grid, seeds=(0,1,2,3))
149 idea_trials = [{'cfg': c, 'result': evaluate(idea_factory(c), SEEDS)} for c in grid]
150 best = min(idea_trials, key=lambda z: z['result']['mean'])
151 rep = make_report('dynamics', 'rnn_small', base, best['result'], {
152 'idea_config': best['cfg'], 'idea_sweep': idea_trials,
153 'mechanism_signature': mechanism_signature(),
154 'epsilon_calibration_mse': EPS,
155 'custom_track': None
156 })
157 # Keep the required signature at the top level and add explicit protocol metadata.
158 rep['mechanism_signature'] = rep.pop('mechanism_signature')
159 rep['protocol'] = {'paired_seeds': list(SEEDS), 'baseline_grid': grid, 'idea_grid': grid,
160 'epochs': EPOCHS, 'n_train': NTR, 'n_test': NTE}
161 with open('bench_report.json', 'w') as f: json.dump(rep, f, indent=2)
162 print(json.dumps(rep, indent=2))
163
164if __name__ == '__main__':
165 main()