Descent-Certified LMO Sign Switching / bench_switch.py
Failed on benchmark
1import os, sys, json, math
2import numpy as np
3import torch
4import torch.nn as nn
5
6sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
7from bench import get_dataset, make_model, evaluate, sweep_baseline, make_report
8
9SEED0 = 1616
10BATCH = 64
11EPOCHS = 18
12TAUS = [0.0, 0.05, 0.10]
13LRS = [1e-3, 3e-3, 6e-3]
14BETAS = [0.8, 0.9]
15
16
17def polar(x):
18 # Batched-free, exact polar factor for a 2-D weight/gradient matrix.
19 u, _, vh = torch.linalg.svd(x, full_matrices=False)
20 return u @ vh
21
22
23def matrix_sign(x):
24 return torch.where(x >= 0, torch.ones_like(x), -torch.ones_like(x))
25
26
27def direction(m, residual, tau, heldout, gated):
28 post = matrix_sign(polar(m))
29 pre = polar(matrix_sign(m + residual))
30 rho = torch.sum(heldout * post) / (torch.linalg.vector_norm(heldout) *
31 torch.linalg.vector_norm(post) + 1e-8)
32 if gated and float(rho.detach()) < tau:
33 return pre, post, pre, float(rho.detach()), False
34 return post, post, pre, float(rho.detach()), True
35
36
37def train_one(seed, lr, beta, method, tau=0.05, collect=False):
38 # Explicit CPU fallback also avoids inheriting a failed CUDA context.
39 torch.manual_seed(seed); np.random.seed(seed)
40 ds = get_dataset('tabular', seed, n_train=400, n_test=200)
41 device = 'cuda' if torch.cuda.is_available() else 'cpu'
42 try:
43 net = make_model('mlp_tiny', ds['input_shape'], ds['out_dim']).to(device)
44 except Exception:
45 device = 'cpu'
46 net = make_model('mlp_tiny', ds['input_shape'], ds['out_dim']).to(device)
47 x, y = ds['xtr'].to(device), ds['ytr'].to(device)
48 xt, yt = ds['xte'].to(device), ds['yte'].to(device)
49 lossf = nn.MSELoss()
50 # SignMuon-like state only for 2-D weight matrices; vectors use Adam-style SGD.
51 mom = {p: torch.zeros_like(p) for p in net.parameters() if p.ndim == 2}
52 residual = {p: torch.zeros_like(p) for p in net.parameters() if p.ndim == 2}
53 rng = np.random.default_rng(seed + 991)
54 logs = {'post_fraction': [], 'rho': [], 'post_negative': [], 'chosen_negative': [], 'predicted_fraction': []}
55 net.train()
56 n = x.shape[0]
57 for epoch in range(EPOCHS):
58 order = rng.permutation(n)
59 # The next cyclic minibatch is an independent routing estimate.
60 for bi in range(0, n, BATCH):
61 ids = order[bi:bi+BATCH]
62 hid = order[(bi+BATCH) % n:(bi+2*BATCH) % n] if bi+BATCH < n else order[:min(BATCH,n)]
63 # avoid accidental empty/wrapped slices
64 if len(hid) == 0: hid = order[:BATCH]
65 net.zero_grad(set_to_none=True)
66 pred = net(x[ids].view(len(ids), -1))
67 lossf(pred, y[ids]).backward()
68 # Save current gradients, then independently estimate heldout gradients.
69 grads = {p: (p.grad.detach().clone() if p.grad is not None else torch.zeros_like(p))
70 for p in net.parameters()}
71 net.zero_grad(set_to_none=True)
72 lossf(net(x[hid].view(len(hid), -1)), y[hid]).backward()
73 held = {p: (p.grad.detach().clone() if p.grad is not None else torch.zeros_like(p))
74 for p in net.parameters()}
75 with torch.no_grad():
76 for p in net.parameters():
77 g = grads[p]
78 if p.ndim == 2:
79 mom[p].mul_(beta).add_(g, alpha=1-beta)
80 r = residual[p]
81 d, post, pre, rho, use_post = direction(mom[p], r, tau, held[p], method == 'switch')
82 p.add_(d, alpha=-lr / math.sqrt(max(1, p.shape[1])))
83 if method == 'switch' and not use_post:
84 residual[p].copy_(mom[p] + r - matrix_sign(mom[p] + r))
85 elif method == 'pre':
86 residual[p].copy_(mom[p] + r - matrix_sign(mom[p] + r))
87 if collect:
88 logs['post_fraction'].append(float(use_post))
89 logs['rho'].append(rho)
90 logs['post_negative'].append(float(torch.sum(held[p]*post).item() < 0))
91 logs['chosen_negative'].append(float(torch.sum(held[p]*d).item() < 0))
92 logs['predicted_fraction'].append(float(rho >= tau))
93 else:
94 # Same simple gradient-side update for all non-matrix parameters.
95 p.add_(g, alpha=-lr)
96 net.eval()
97 with torch.no_grad():
98 metric = float(lossf(net(xt.view(len(xt), -1)), yt).cpu())
99 if collect:
100 logs = {k: float(np.mean(v)) if v else 0.0 for k,v in logs.items()}
101 logs['n_observations'] = int(len(order) * EPOCHS / BATCH * max(1, len(mom)))
102 return metric, logs
103
104
105def fn(cfg, method):
106 return lambda seed: train_one(seed, cfg['lr'], cfg['beta'], method, cfg.get('tau', 0.05))[0]
107
108
109def main():
110 # Union parity: every idea lr/beta is included in baseline's grid.
111 grid = [{'lr': lr, 'beta': beta} for lr in LRS for beta in BETAS]
112 base = sweep_baseline(lambda c: fn(c, 'post'), grid)
113 idea_grid = [dict(c, tau=t) for c in grid for t in TAUS]
114 # Evaluate the idea's 3 tau choices at the selected baseline settings and two nearby lr settings.
115 bcfg = base['best_cfg']; nearby = sorted(set([bcfg['lr']] + [v for v in LRS if v != bcfg['lr']]))[:3]
116 candidates = [{'lr': lr, 'beta': bcfg['beta'], 'tau': tau} for lr in nearby for tau in TAUS]
117 tried = []
118 for c in candidates:
119 r = evaluate(fn(c, 'switch'))
120 tried.append((c, r))
121 best_cfg, idea = min(tried, key=lambda z: z[1]['mean'])
122 sigs = [train_one(s, best_cfg['lr'], best_cfg['beta'], 'switch', best_cfg['tau'], True)[1] for s in range(8)]
123 sig = {k: float(np.mean([z[k] for z in sigs])) for k in sigs[0] if k != 'n_observations'}
124 sig['confirmed'] = bool(abs(sig['post_fraction'] - sig['predicted_fraction']) < 1e-9 and sig['chosen_negative'] <= sig['post_negative'])
125 base['sweep'] = base['sweep']
126 report = make_report('tabular', 'mlp_tiny', base, idea, {
127 'prediction': 'heldout alignment gating routes post when rho>=tau and reduces negative heldout alignment',
128 'observed': sig, 'tau': best_cfg['tau'], 'trained_models': True
129 })
130 report['idea_sweep'] = [{'cfg': c, 'mean': r['mean'], 'per_seed': r['per_seed']} for c,r in tried]
131 report['custom_track'] = None
132 with open('bench_report.json','w') as f: json.dump(report, f, indent=2)
133 print(json.dumps(report, indent=2))
134
135if __name__ == '__main__': main()