Weakest-Direction Information Margin for Latent-State Training / bench_experiment.py
Mechanism confirmed, baseline not beaten
1import sys, json, random
2from pathlib import Path
3import numpy as np
4sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
5import torch
6import torch.nn as nn
7from bench import get_dataset, make_model, train_model, sweep_baseline, make_report
8
9SEEDS = tuple(range(8))
10LRS = [1e-3, 3e-3, 6e-3]
11EPOCHS = 8
12BATCH = 128
13PRIOR = 0.05
14BETA = 0.01
15TAU = 0.02
16
17
18def seed_all(seed):
19 random.seed(seed)
20 np.random.seed(seed)
21 torch.manual_seed(seed)
22 if torch.cuda.is_available():
23 torch.cuda.manual_seed_all(seed)
24
25
26def baseline_fn(cfg):
27 def run(seed):
28 seed_all(seed)
29 ds = get_dataset('dynamics', seed, n_train=300, n_test=150)
30 net = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
31 _, metric, _ = train_model(net, ds, epochs=EPOCHS, lr=cfg['lr'],
32 batch=BATCH, weight_decay=0.0,
33 log=lambda *_: None)
34 return float(metric) if metric is not None else float('inf')
35 return run
36
37
38def softmin(vals, tau):
39 return -tau * torch.logsumexp(-vals / tau, dim=0)
40
41
42def curvature_penalty(net, x):
43 # Input Jacobian is measured on the actual recurrent model and actual benchmark
44 # windows. For scalar output, J^T J is rank one per sample; prior makes H PD.
45 x = x.detach().requires_grad_(True)
46 out = net(x).reshape(-1)
47 rows = []
48 for i in range(len(out)):
49 # Samples are independent, so one gradient of the summed scalar
50 # yields every per-sample input Jacobian row.
51 g = torch.autograd.grad(out.sum(), x, retain_graph=True,
52 create_graph=True)[0]
53 rows = [g[j].reshape(-1) for j in range(len(g))]
54 J = torch.stack(rows)
55 H = PRIOR * torch.eye(J.shape[1], device=J.device, dtype=J.dtype) + J.T @ J / max(1, len(rows))
56 ev = torch.linalg.eigvalsh(H)
57 return softmin(ev, TAU), H.detach(), ev.detach()
58
59
60def idea_run(seed, lr):
61 seed_all(seed)
62 ds = get_dataset('dynamics', seed, n_train=300, n_test=150)
63 device = 'cuda' if torch.cuda.is_available() else 'cpu'
64 for dev in ([device, 'cpu'] if device == 'cuda' else ['cpu']):
65 try:
66 net = make_model('rnn_small', ds['input_shape'], ds['out_dim']).to(dev)
67 xtr, ytr = ds['xtr'].to(dev), ds['ytr'].to(dev)
68 opt = torch.optim.Adam(net.parameters(), lr=lr)
69 n = len(xtr)
70 for _ in range(EPOCHS):
71 net.train()
72 perm = torch.randperm(n, device=dev)
73 for start in range(0, n, BATCH):
74 idx = perm[start:start+BATCH]
75 pred = net(xtr[idx])
76 task = ((pred - ytr[idx]) ** 2).mean()
77 # Small subset keeps second-order autodiff affordable and fixed.
78 take = idx[:min(16, len(idx))]
79 margin, _, _ = curvature_penalty(net, xtr[take])
80 loss = task - BETA * margin
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(((net(ds['xte'].to(dev)) - ds['yte'].to(dev)) ** 2).mean())
87 return metric, net, dev
88 except RuntimeError:
89 if dev == 'cpu':
90 raise
91 torch.cuda.empty_cache()
92 raise RuntimeError('training failed')
93
94
95def idea_fn(cfg):
96 return lambda seed: idea_run(seed, cfg['lr'])[0]
97
98
99def measured_signature(base_lr, idea_lr):
100 # Re-train one paired seed and measure the trained systems' curvature and
101 # perturbation response using the same validation inputs.
102 seed = 0
103 seed_all(seed)
104 ds = get_dataset('dynamics', seed, n_train=300, n_test=150)
105 base = make_model('rnn_small', ds['input_shape'], ds['out_dim'])
106 base, _, _ = train_model(base, ds, epochs=EPOCHS, lr=base_lr, batch=BATCH, log=lambda *_: None)
107 imetric, idea, idev = idea_run(seed, idea_lr)
108 bdev = next(base.parameters()).device
109 xb = ds['xte'][:16].to(bdev)
110 xi = ds['xte'][:16].to(idev)
111 _, Hb, eb = curvature_penalty(base, xb)
112 _, Hi, ei = curvature_penalty(idea, xi)
113 # Parameter/input perturbation is observed model behaviour; compare output
114 # displacement under a fixed small perturbation of the input window.
115 eps = 1e-3
116 direction = torch.randn_like(xb)
117 direction = direction / direction.norm()
118 with torch.no_grad():
119 db = (base(xb + eps * direction) - base(xb)).norm().item()
120 di = (idea(xi + eps * direction.to(idev)) - idea(xi)).norm().item()
121 return {
122 'baseline_min_curvature': float(eb[0]),
123 'idea_min_curvature': float(ei[0]),
124 'baseline_trace_curvature': float(torch.trace(Hb)),
125 'idea_trace_curvature': float(torch.trace(Hi)),
126 'baseline_observed_perturbation': db,
127 'idea_observed_perturbation': di,
128 'predicted_inverse_margin_ratio': float(eb[0] / max(ei[0], 1e-12)),
129 'observed_perturbation_ratio': float(di / max(db, 1e-12)),
130 'confirmed': bool((ei[0] > eb[0]) and (di < db))
131 }
132
133
134def main():
135 grid = [{'lr': x} for x in LRS]
136 base = sweep_baseline(baseline_fn, grid, seeds=(0, 1, 2, 3))
137 best_lr = float(base['best_cfg']['lr'])
138 idea_grid = [{'lr': best_lr}, {'lr': LRS[max(0, LRS.index(best_lr)-1)]},
139 {'lr': LRS[min(len(LRS)-1, LRS.index(best_lr)+1)]}]
140 idea_trials = []
141 for cfg in idea_grid:
142 vals = [float(idea_fn(cfg)(s)) for s in SEEDS]
143 idea_trials.append({'cfg': cfg, 'result': {'mean': float(np.mean(vals)), 'std': float(np.std(vals)), 'per_seed': vals, 'n': len(vals)}})
144 chosen = min(idea_trials, key=lambda z: z['result']['mean'])
145 report = make_report('dynamics', 'rnn_small', base, chosen['result'],
146 {'mechanism_signature': measured_signature(best_lr, chosen['cfg']['lr']),
147 'idea_trials': idea_trials,
148 'structural_match': 'dynamics: recurrent actuated pendulum and stability/control task',
149 'intervention': 'soft-min of input Jacobian Gauss-Newton curvature plus fixed prior'})
150 Path('bench_report.json').write_text(json.dumps(report, indent=2))
151 print(json.dumps(report, indent=2))
152
153
154if __name__ == '__main__':
155 main()