Commutator-Regularized Switched SSM / bench_commutator_dynamics.py
Running benchmark…
1import sys, json, 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, train_model, sweep_baseline, make_report
9
10SEED = 449
11EPOCHS = 12
12BATCH = 128
13WIDTH = 16
14MODES = 2
15TAU = 0.7
16LR_GRID = [1e-3, 3e-3, 6e-3]
17WD_GRID = [0.0, 1e-4]
18IDEA_GRID = [dict(lr=x, weight_decay=0.0, lambda_c=0.02)
19 for x in LR_GRID]
20
21
22def seed_all(seed):
23 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
24 if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)
25
26
27class SwitchedCore(nn.Module):
28 def __init__(self, width=WIDTH, modes=MODES):
29 super().__init__()
30 self.width, self.modes = width, modes
31 eye = torch.eye(width).unsqueeze(0).repeat(modes, 1, 1)
32 self.A = nn.Parameter(-0.35 * eye + 0.04 * torch.randn(modes, width, width))
33 self.U = nn.Parameter(0.10 * torch.randn(modes, width))
34 self.head = nn.Linear(width, 1)
35
36 def matrices(self):
37 B = [TAU / self.modes * self.A[i] for i in range(self.modes)]
38 E = [torch.matrix_exp(b) for b in B]
39 return B, E
40
41 def forward(self, x):
42 # dynamics input is [batch, 8*3], ordered as (theta, omega, u)
43 z = x.view(x.shape[0], -1, 3)
44 h = torch.zeros(x.shape[0], self.width, device=x.device, dtype=x.dtype)
45 B, E = self.matrices()
46 for t in range(z.shape[1]):
47 inp = z[:, t, 2:3]
48 for i in range(self.modes):
49 h = h @ E[i].T + (TAU / self.modes) * inp * self.U[i]
50 return self.head(h)
51
52 def commutator_penalty(self):
53 B, _ = self.matrices()
54 total = torch.zeros((), device=self.A.device)
55 for i in range(self.modes):
56 for j in range(i):
57 C = B[i] @ B[j] - B[j] @ B[i]
58 total = total + (C * C).sum()
59 return total
60
61
62def train_switched(ds, seed, lr, weight_decay=0.0, lambda_c=0.0):
63 seed_all(seed + 10000)
64 model = SwitchedCore()
65 # This custom loop is necessary because the intervention is a new loss.
66 try:
67 device = 'cuda' if torch.cuda.is_available() else 'cpu'
68 model = model.to(device)
69 xtr, ytr = ds['xtr'].to(device), ds['ytr'].to(device)
70 xte, yte = ds['xte'].to(device), ds['yte'].to(device)
71 opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
72 hist = []
73 for _ in range(EPOCHS):
74 model.train(); perm = torch.randperm(len(xtr), device=device); total = 0.0
75 for k in range(0, len(xtr), BATCH):
76 ix = perm[k:k+BATCH]
77 pred = model(xtr[ix])
78 loss = ((pred - ytr[ix]) ** 2).mean() + lambda_c * model.commutator_penalty()
79 opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step()
80 total += float(loss.detach()) * len(ix)
81 hist.append(total / len(xtr))
82 model.eval()
83 with torch.no_grad(): metric = float(((model(xte) - yte) ** 2).mean().cpu())
84 return model, metric, hist
85 except RuntimeError:
86 if device == 'cuda':
87 torch.cuda.empty_cache()
88 return train_switched_cpu(ds, seed, lr, weight_decay, lambda_c)
89 raise
90
91
92def train_switched_cpu(ds, seed, lr, weight_decay=0.0, lambda_c=0.0):
93 seed_all(seed + 10000)
94 model = SwitchedCore().cpu()
95 xtr, ytr, xte, yte = ds['xtr'], ds['ytr'], ds['xte'], ds['yte']
96 opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
97 for _ in range(EPOCHS):
98 perm = torch.randperm(len(xtr))
99 for k in range(0, len(xtr), BATCH):
100 ix = perm[k:k+BATCH]; pred = model(xtr[ix])
101 loss = ((pred-ytr[ix])**2).mean() + lambda_c*model.commutator_penalty()
102 opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step()
103 model.eval()
104 with torch.no_grad(): metric = float(((model(xte)-yte)**2).mean())
105 return model, metric, []
106
107
108def baseline_train(ds, seed, cfg):
109 # Standard practice: same switched recurrent architecture, no regularizer.
110 _, metric, _ = train_switched(ds, seed, cfg['lr'], cfg['weight_decay'], 0.0)
111 return metric
112
113
114def idea_train(ds, seed, cfg):
115 _, metric, _ = train_switched(ds, seed, cfg['lr'], cfg['weight_decay'], cfg['lambda_c'])
116 return metric
117
118
119def bch_check():
120 from scipy.linalg import expm, norm
121 rng = np.random.default_rng(SEED); X = rng.normal(size=(3,3)); Y = rng.normal(size=(3,3))
122 C = X@Y-Y@X; rows=[]
123 for s in [0.02,0.04,0.08,0.16]:
124 exact=expm(s*Y)@expm(s*X)
125 corr=expm(s*(X+Y)-0.5*s*s*C)
126 rows.append([s,norm(exact-expm(s*(X+Y)),'fro'),norm(exact-corr,'fro')])
127 a=np.polyfit(np.log(np.array(rows)[-3:,0]),np.log(np.array(rows)[-3:,1]),1)[0]
128 b=np.polyfit(np.log(np.array(rows)[-3:,0]),np.log(np.array(rows)[-3:,2]),1)[0]
129 return {'rows':rows,'uncorrected_slope':float(a),'corrected_slope':float(b),'confirmed':bool(b>a+0.5)}
130
131
132def signature(models, ds):
133 vals=[]
134 for label, model in models:
135 with torch.no_grad():
136 B,E=model.matrices(); phi=E[1]@E[0]; avg=torch.matrix_exp(B[0]+B[1])
137 comm=(B[1]@B[0]-B[0]@B[1]).norm().item()**2
138 mismatch=(phi-avg).norm().item()/max(avg.norm().item(),1e-12)
139 gain=torch.linalg.matrix_norm(phi,2).item()
140 vals.append({'system':label,'commutator':comm,'ordered_avg_mismatch':mismatch,'cycle_gain':gain})
141 return {'prediction':'lower commutator should accompany lower ordered-vs-averaged mismatch',
142 'observed':vals,'predicted_sign': 'positive','confirmed': bool(vals[1]['commutator'] < vals[0]['commutator'] and vals[1]['ordered_avg_mismatch'] < vals[0]['ordered_avg_mismatch']) if len(vals)==2 else False}
143
144
145def main():
146 track='dynamics'; model_name='rnn_small'
147 # Benchmark-compatible dataset; model is a matched end-to-end switched recurrent replacement.
148 datasets={s:get_dataset(track, s, n_train=400, n_test=400) for s in range(8)}
149 base_grid=[{'lr':lr,'weight_decay':wd} for lr in LR_GRID for wd in WD_GRID]
150 base_block=sweep_baseline(lambda cfg: (lambda s: baseline_train(datasets[s],s,cfg)), base_grid)
151 idea_results=[]; best_cfg=min(IDEA_GRID, key=lambda c: np.mean([idea_train(datasets[s],s,c) for s in range(4)]))
152 for s in range(8): idea_results.append(idea_train(datasets[s],s,best_cfg))
153 idea_res={'mean':float(np.mean(idea_results)),'std':float(np.std(idea_results)), 'per_seed':idea_results,'n':8}
154 models=[]
155 for s in range(2):
156 models.append(('baseline', train_switched(datasets[s],s, base_block['best_cfg']['lr'],base_block['best_cfg']['weight_decay'],0.0)[0]))
157 models.append(('idea', train_switched(datasets[s],s, best_cfg['lr'],0.0,best_cfg['lambda_c'])[0]))
158 report=make_report(track, model_name, base_block, idea_res, {'mechanism_signature':signature(models,datasets[0]),'bch_check':bch_check(),'idea_cfg':best_cfg})
159 report['structural_match']='Dynamics/control track matches stability and Lyapunov structure.'
160 Path('bench_report.json').write_text(json.dumps(report,indent=2))
161 print(json.dumps(report,indent=2))
162
163if __name__=='__main__': main()