Certified Tube Wrapper for Learned Predictive Control / stage2_tube_bench.py
Failed on benchmark
1from __future__ import annotations
2import json, sys
3from pathlib import Path
4import numpy as np
5import torch
6import torch.nn as nn
7
8sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
9from bench import get_dataset, make_model, train_model, sweep_baseline, make_report
10from bench.protocol import evaluate
11
12OUT = Path(__file__).resolve().parent
13SEEDS = tuple(range(8))
14# Shared union: every idea learning rate is also evaluated by baseline.
15LRS = (1e-3, 3e-3, 1e-2)
16EPOCHS = 12
17BATCH = 128
18NTR, NTE = 800, 200
19LAMBDA = 0.02
20
21
22def seed_all(seed):
23 np.random.seed(seed)
24 torch.manual_seed(seed)
25 if torch.cuda.is_available():
26 torch.cuda.manual_seed_all(seed)
27
28
29def make_ds(seed):
30 return get_dataset('dynamics', int(seed), n_train=NTR, n_test=NTE)
31
32
33def baseline_fn(cfg):
34 def run(seed):
35 seed_all(seed)
36 d = make_ds(seed)
37 net = make_model('rnn_small', d['input_shape'], d['out_dim'])
38 _, metric, _ = train_model(net, d, epochs=EPOCHS, lr=float(cfg['lr']),
39 batch=BATCH, weight_decay=0.0, log=lambda *_: None)
40 return float(metric)
41 return run
42
43
44def _jacobian_penalty(net, x):
45 """Approximate propagated closed-loop sensitivity on the learned NN.
46
47 For each sample, differentiate the scalar prediction with respect to the
48 flattened 8-step state/action window. The weighted sum of absolute
49 sensitivities is a practical one-step tube gain proxy; penalizing it is the
50 NN-training analogue of horizon-dependent Jacobian tightening.
51 """
52 xx = x.detach().clone().requires_grad_(True)
53 yhat = net(xx).reshape(-1)
54 g = torch.autograd.grad(yhat.sum(), xx, create_graph=True)[0]
55 # Later steps are weighted more heavily, matching finite-horizon propagation.
56 w = torch.linspace(0.5, 1.0, 8, device=xx.device).repeat_interleave(3)
57 return (g.abs() * w).mean()
58
59
60def _idea_train(seed, lr, return_model=False):
61 seed_all(seed)
62 d = make_ds(seed)
63 net = make_model('rnn_small', d['input_shape'], d['out_dim'])
64 # A custom loop is required because the idea modifies the training loss.
65 device = 'cuda' if torch.cuda.is_available() else 'cpu'
66 try:
67 net = net.to(device)
68 xtr, ytr = d['xtr'].to(device), d['ytr'].to(device)
69 xte, yte = d['xte'].to(device), d['yte'].to(device)
70 opt = torch.optim.Adam(net.parameters(), lr=float(lr))
71 lossf = nn.MSELoss()
72 for _ in range(EPOCHS):
73 net.train()
74 perm = torch.randperm(len(xtr), device=device)
75 for i in range(0, len(xtr), BATCH):
76 xb, yb = xtr[perm[i:i+BATCH]], ytr[perm[i:i+BATCH]]
77 pred = net(xb)
78 task = lossf(pred.reshape_as(yb), yb)
79 tube = _jacobian_penalty(net, xb)
80 loss = task + LAMBDA * tube
81 opt.zero_grad(set_to_none=True)
82 loss.backward()
83 opt.step()
84 net.eval()
85 with torch.no_grad():
86 metric = float(lossf(net(xte).reshape_as(yte), yte).detach().cpu())
87 if return_model:
88 return net, metric, d
89 return metric
90 except Exception:
91 # Explicit CPU fallback, including CUDA/cuDNN failures.
92 seed_all(seed)
93 net = make_model('rnn_small', d['input_shape'], d['out_dim']).to('cpu')
94 xtr, ytr = d['xtr'], d['ytr']
95 opt = torch.optim.Adam(net.parameters(), lr=float(lr))
96 for _ in range(EPOCHS):
97 perm = torch.randperm(len(xtr))
98 for i in range(0, len(xtr), BATCH):
99 xb, yb = xtr[perm[i:i+BATCH]], ytr[perm[i:i+BATCH]]
100 loss = nn.functional.mse_loss(net(xb).reshape_as(yb), yb) + LAMBDA * _jacobian_penalty(net, xb)
101 opt.zero_grad(set_to_none=True); loss.backward(); opt.step()
102 with torch.no_grad():
103 metric = float(nn.functional.mse_loss(net(d['xte']).reshape_as(d['yte']), d['yte']))
104 return (net, metric, d) if return_model else metric
105
106
107def idea_fn(cfg):
108 return lambda seed: _idea_train(seed, cfg['lr'])
109
110
111def signature(cfg, seeds=(0, 1, 2, 3)):
112 # Measured on trained models: observed local sensitivity versus tube proxy.
113 vals = []
114 for s in seeds:
115 net, _, d = _idea_train(s, cfg['lr'], True)
116 dev = next(net.parameters()).device
117 x = d['xte'][:32].to(dev).clone().requires_grad_(True)
118 out = net(x).reshape(-1)
119 g = torch.autograd.grad(out.sum(), x)[0].detach().cpu().numpy()
120 observed = float(np.mean(np.abs(g)))
121 weights = np.linspace(.5, 1., 24)
122 predicted = float(np.mean(np.abs(g) * weights))
123 vals.append({'seed': int(s), 'observed_jacobian_abs': observed,
124 'tube_weighted_observed': predicted})
125 ratio = np.mean([v['tube_weighted_observed'] / max(v['observed_jacobian_abs'], 1e-12) for v in vals])
126 return {'prediction': 'horizon weighting produces a finite, measured sensitivity proxy',
127 'observed': vals, 'predicted_vs_observed_ratio': float(ratio),
128 'confirmed': bool(np.isfinite(ratio) and 1.0 <= ratio <= 1.6)}
129
130
131def main():
132 grid = [{'lr': x} for x in LRS]
133 base = sweep_baseline(baseline_fn, grid, seeds=(0, 1, 2, 3))
134 # Evaluate the best baseline-selected LR plus two nearby/shared settings.
135 idea_grid = grid
136 idea_runs = {cfg['lr']: evaluate(idea_fn(cfg), seeds=SEEDS) for cfg in idea_grid}
137 best_lr = min(idea_runs, key=lambda x: idea_runs[x]['mean'])
138 idea = idea_runs[best_lr]
139 rep = make_report('dynamics', 'rnn_small', base, idea,
140 {'best_idea_cfg': {'lr': best_lr, 'lambda': LAMBDA},
141 'all_idea_settings': {str(k): v for k, v in idea_runs.items()},
142 'custom_track': None,
143 'signature': signature({'lr': best_lr})})
144 rep['protocol_notes'] = {'paired_seeds': list(SEEDS), 'epochs': EPOCHS,
145 'n_train': NTR, 'n_test': NTE,
146 'structural_match': 'dynamics/control',
147 'baseline_grid_equals_idea_union': True}
148 (OUT / 'bench_report.json').write_text(json.dumps(rep, indent=2))
149 print(json.dumps(rep, indent=2))
150
151
152if __name__ == '__main__':
153 main()