Positive-envelope stability for complex state updates / bench_experiment.py
Mechanism confirmed, baseline not beaten
1import sys, json, time, random
2from pathlib import Path
3import numpy as np
4import torch
5import torch.nn as nn
6
7sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
8from bench import get_dataset, make_report, evaluate, sweep_baseline
9
10HERE = Path(__file__).resolve().parent
11DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
12
13class FactorRNN(nn.Module):
14 def __init__(self, hidden=32):
15 super().__init__()
16 self.inp = nn.Linear(3, hidden)
17 self.f1 = nn.Parameter(0.42 * torch.eye(hidden) + 0.035 * torch.randn(hidden, hidden))
18 self.f2 = nn.Parameter(0.92 * torch.eye(hidden) + 0.035 * torch.randn(hidden, hidden))
19 self.head = nn.Linear(hidden, 1)
20
21 def transition(self):
22 return self.f2 @ self.f1
23
24 def envelope(self):
25 return self.f2.abs() @ self.f1.abs()
26
27 def forward(self, x):
28 seq = x.view(x.shape[0], -1, 3)
29 h = torch.zeros(x.shape[0], self.f1.shape[0], device=x.device, dtype=x.dtype)
30 A = self.transition()
31 for t in range(seq.shape[1]):
32 h = torch.tanh(h @ A.T + self.inp(seq[:, t]))
33 return self.head(h)
34
35
36def seed_all(seed):
37 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
38 if torch.cuda.is_available():
39 torch.cuda.manual_seed_all(seed)
40
41
42def fit(seed, lr, epochs, idea=False, beta=0.0, target=0.92):
43 seed_all(seed)
44 ds = get_dataset('dynamics', seed, n_train=400, n_test=200)
45 model = FactorRNN().to(DEVICE)
46 opt = torch.optim.Adam(model.parameters(), lr=lr)
47 xtr, ytr = ds['xtr'].to(DEVICE), ds['ytr'].to(DEVICE)
48 xte, yte = ds['xte'].to(DEVICE), ds['yte'].to(DEVICE)
49 # Equal full-batch budget for both systems; CPU fallback handles CUDA errors.
50 def run(dev):
51 nonlocal model, opt, xtr, ytr, xte, yte
52 model = model.to(dev); xtr, ytr, xte, yte = xtr.to(dev), ytr.to(dev), xte.to(dev), yte.to(dev)
53 opt = torch.optim.Adam(model.parameters(), lr=lr)
54 model.train()
55 for _ in range(epochs):
56 opt.zero_grad(set_to_none=True)
57 pred = model(xtr)
58 loss = (pred-ytr).pow(2).mean()
59 if idea:
60 P = model.envelope()
61 # Cheap conservative infinity-norm envelope constraint plus cancellation reward.
62 rows = P.sum(dim=1)
63 env_pen = torch.relu(rows.max() - target).pow(2)
64 A = model.transition()
65 cancel = (P.sum() - A.abs().sum()) / (P.sum() + 1e-6)
66 loss = loss + 0.08 * env_pen + beta * cancel
67 if not torch.isfinite(loss):
68 raise RuntimeError('nonfinite loss')
69 loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 10.0); opt.step()
70 model.eval()
71 with torch.no_grad():
72 metric = float((model(xte)-yte).pow(2).mean().cpu())
73 return metric
74 try:
75 metric = run(DEVICE)
76 except Exception:
77 DEVICE_CPU = 'cpu'
78 metric = run(DEVICE_CPU)
79 return metric, model, ds
80
81
82def train_fn(cfg, idea=False):
83 def f(seed):
84 return fit(seed, cfg['lr'], cfg['epochs'], idea, cfg.get('beta', 0.0), cfg.get('target', 0.92))[0]
85 return f
86
87
88def signature(seed, cfg):
89 metric, model, ds = fit(seed, cfg['lr'], cfg['epochs'], True, cfg.get('beta', 0.0), cfg.get('target', 0.92))
90 with torch.no_grad():
91 A = model.transition(); P = model.envelope()
92 # Re-test the NN-scale prediction |Ax| <= P|x| on observed hidden probes.
93 rng = torch.Generator(device=next(model.parameters()).device).manual_seed(seed+991)
94 x = torch.randn(256, A.shape[0], generator=rng, device=A.device)
95 lhs = (x @ A.T).abs(); rhs = x.abs() @ P.T
96 violation = float((lhs-rhs).max().cpu())
97 ratio = float((lhs/(rhs+1e-8)).max().cpu())
98 row_env = float(P.sum(1).max().cpu())
99 observed_A = float(A.abs().sum().cpu()); observed_P = float(P.sum().cpu())
100 return {'metric': metric, 'max_component_violation': violation,
101 'max_observed_ratio': ratio, 'predicted_bound': 'nonpositive violation',
102 'observed_envelope_row_sum': row_env, 'observed_sum_abs_A': observed_A,
103 'observed_sum_P': observed_P,
104 'confirmed': bool(violation <= 2e-5 and ratio <= 1.00002)}
105
106
107def main():
108 # Shared architecture and equal epochs. The union of learning rates is evaluated
109 # on both systems; baseline tuning uses the harness four-seed sweep, followed by
110 # full eight-seed evaluations for every shared lr.
111 base_grid = [{'lr': lr, 'epochs': 18, 'beta': 0.0}
112 for lr in (0.0015, 0.003, 0.006)]
113 idea_grid = [{'lr': lr, 'epochs': 18, 'beta': beta, 'target': 0.92}
114 for lr, beta in ((0.0015, 0.005), (0.003, 0.01), (0.006, 0.02))]
115 seeds = tuple(range(8))
116 t0 = time.perf_counter()
117 base = sweep_baseline(train_fn, base_grid)
118 # Full paired evaluations at every shared lr establish search-space parity.
119 base_full_by_lr = []
120 for cfg in base_grid:
121 r = evaluate(train_fn(cfg, False), seeds=seeds)
122 base_full_by_lr.append({'cfg': cfg, **r})
123 best_base_entry = min(base_full_by_lr, key=lambda z: z['mean'])
124 base['full_all_union'] = base_full_by_lr
125 base['best_cfg'] = best_base_entry['cfg']
126 base['full'] = {k: best_base_entry[k] for k in ('mean', 'std', 'per_seed', 'n')}
127
128 idea_all = []
129 for cfg in idea_grid:
130 r = evaluate(train_fn(cfg, True), seeds=seeds)
131 idea_all.append({'cfg': cfg, **r})
132 best_idea = min(idea_all, key=lambda z: z['mean'])
133 idea = {k: best_idea[k] for k in ('mean', 'std', 'per_seed', 'n')}
134 idea_cfg = best_idea['cfg']
135 sig = signature(0, idea_cfg)
136 report = make_report('dynamics', 'factor_rnn', base, idea,
137 {'track_structure': 'controlled pendulum multi-step dynamics', **sig,
138 'config': idea_cfg, 'wall_seconds': time.perf_counter()-t0})
139 report['idea_grid'] = idea_all
140 report['device'] = DEVICE
141 report['note'] = ('Baseline and idea are end-to-end trained systems with identical '
142 'FactorRNN architecture; only envelope loss differs. All shared '
143 'learning rates were evaluated on the same eight paired seeds.')
144 (HERE/'bench_report.json').write_text(json.dumps(report, indent=2))
145 print(json.dumps(report, indent=2))
146
147if __name__ == '__main__':
148 main()