Positive-cycle Jacobian penalty / stage2_bench.py
Mechanism confirmed, baseline not beaten
1import os, sys, json, random, time
2import numpy as np
3import torch
4import torch.nn as nn
5sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
6from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report
7
8SEEDS = tuple(range(8))
9SWEEP_SEEDS = (0,1,2,3)
10H = 64
11
12def seed_all(s):
13 random.seed(s); np.random.seed(s); torch.manual_seed(s)
14 if torch.cuda.is_available(): torch.cuda.manual_seed_all(s)
15
16def device_for():
17 try:
18 d = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
19 if d.type == 'cuda': torch.zeros(1, device=d)
20 return d
21 except Exception:
22 return torch.device('cpu')
23
24def transition(model, x, h):
25 """PyTorch GRUCell equations, preserving parameter graph."""
26 wih, whh = model.rnn.weight_ih_l0, model.rnn.weight_hh_l0
27 bih = model.rnn.bias_ih_l0
28 bhh = model.rnn.bias_hh_l0
29 gi = torch.nn.functional.linear(x, wih, bih)
30 gh = torch.nn.functional.linear(h, whh, bhh)
31 ir, iz, inn = gi.chunk(3, -1); hr, hz, hnn = gh.chunk(3, -1)
32 r = torch.sigmoid(ir + hr)
33 z = torch.sigmoid(iz + hz)
34 n = torch.tanh(inn + r * hnn)
35 return (1-z)*n + z*h
36
37def block_jacobian(model, x, h):
38 # Two contiguous hidden blocks; exact Jacobian is only 64x64 on one sample.
39 def f(v): return transition(model, x, v)
40 J = torch.autograd.functional.jacobian(f, h, create_graph=True, vectorize=True)
41 # J[output,input], aggregate absolute derivative magnitudes by blocks.
42 a = J[:H//2,:H//2].abs().mean(); b = J[:H//2,H//2:].abs().mean()
43 c = J[H//2:,:H//2].abs().mean(); d = J[H//2:,H//2:].abs().mean()
44 K = torch.stack((torch.stack((a,b)), torch.stack((c,d))))
45 return K, J
46
47def cycle_penalty(model, xb, tau=1.0):
48 seq = xb[:1].view(1,-1,3)
49 h = torch.zeros(1,H,device=xb.device)
50 # Build a representative hidden state using the same recurrent equations.
51 for t in range(seq.shape[1]-1): h = transition(model, seq[:,t], h)
52 K, J = block_jacobian(model, seq[0,-1], h[0])
53 tr2 = torch.trace(K @ K)
54 return tr2/(tau*tau*2.0), float(tr2.detach().cpu()), float(torch.linalg.matrix_norm(J).detach().cpu())
55
56def train_cycle(seed, cfg, return_sig=False):
57 seed_all(seed); ds=get_dataset('dynamics', seed, n_train=200, n_test=100)
58 model=make_model('rnn_small', ds['input_shape'], ds['out_dim'])
59 dev=device_for()
60 try:
61 model.to(dev); xtr,ytr=ds['xtr'].to(dev),ds['ytr'].to(dev)
62 opt=torch.optim.Adam(model.parameters(),lr=cfg['lr'],weight_decay=cfg['wd'])
63 lossf=nn.MSELoss()
64 for ep in range(cfg['epochs']):
65 perm=torch.randperm(len(xtr),device=dev)
66 for i in range(0,len(xtr),64):
67 ix=perm[i:i+64]; pred=model(xtr[ix]); task=lossf(pred,ytr[ix])
68 pen,_,_=cycle_penalty(model,xtr[ix],cfg['tau'])
69 opt.zero_grad(); (task+cfg['lam']*pen).backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),5); opt.step()
70 with torch.no_grad(): metric=float(((model(ds['xte'].to(dev))-ds['yte'].to(dev))**2).mean())
71 if return_sig:
72 pen,tr2,jn=cycle_penalty(model,xtr[:1],cfg['tau'])
73 return metric, {'closed_walk_trace2':tr2,'jacobian_frobenius':jn,'penalty':float(pen.detach().cpu())}
74 return metric
75 except RuntimeError:
76 # Explicit CPU fallback for shared/limited CUDA environments.
77 seed_all(seed); os.environ['CUDA_VISIBLE_DEVICES']=''
78 return train_cycle_cpu(seed,cfg,return_sig)
79
80def train_cycle_cpu(seed,cfg,return_sig=False):
81 seed_all(seed); ds=get_dataset('dynamics',seed,n_train=200,n_test=100); model=make_model('rnn_small',ds['input_shape'],ds['out_dim'])
82 xtr,ytr=ds['xtr'],ds['ytr']; opt=torch.optim.Adam(model.parameters(),lr=cfg['lr'],weight_decay=cfg['wd']); lossf=nn.MSELoss()
83 for ep in range(cfg['epochs']):
84 perm=torch.randperm(len(xtr))
85 for i in range(0,len(xtr),64):
86 ix=perm[i:i+64]; task=lossf(model(xtr[ix]),ytr[ix]); pen,_,_=cycle_penalty(model,xtr[ix],cfg['tau']); opt.zero_grad(); (task+cfg['lam']*pen).backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),5); opt.step()
87 metric=float(((model(ds['xte'])-ds['yte'])**2).mean())
88 if return_sig:
89 pen,tr2,jn=cycle_penalty(model,xtr[:1],cfg['tau']); return metric, {'closed_walk_trace2':tr2,'jacobian_frobenius':jn,'penalty':float(pen.detach())}
90 return metric
91
92def base_fn(cfg):
93 def run(seed):
94 seed_all(seed); ds=get_dataset('dynamics',seed,n_train=200,n_test=100); m=make_model('rnn_small',ds['input_shape'],ds['out_dim'])
95 _,metric,_=train_model(m,ds,epochs=cfg['epochs'],lr=cfg['lr'],batch=64,weight_decay=cfg['wd'],log=lambda *_:None)
96 return metric
97 return run
98
99def main():
100 # tr(K^2)=2ab is the core closed-walk identity; threshold is tested numerically.
101 a,b=1.8,0.8; K=np.array([[0,a],[b,0]],float); identity=float(np.trace(K@K)); rho=np.sqrt(a*b)
102 sanity={'trace2':identity,'expected_2ab':2*a*b,'abs_error':abs(identity-2*a*b),'rho':float(rho),'critical_gain':float(1/rho)}
103 lrs=[0.0015,0.003,0.006]; wds=[0.0,1e-4]; epochs=3
104 grid=[{'lr':lr,'wd':wd,'epochs':epochs} for lr in lrs for wd in wds]
105 base=sweep_baseline(base_fn,grid,seeds=SWEEP_SEEDS)
106 # Same lr/wd union; lambda is the sole extra method knob.
107 ideas=[{'lr':lr,'wd':wd,'epochs':epochs,'lam':lam,'tau':tau} for lr in lrs for wd in wds for lam in [0.001,0.005,0.02] for tau in [1.0]]
108 rows=[]; best=None
109 for cfg in ideas:
110 r=evaluate(lambda s,cfg=cfg: train_cycle(s,cfg),seeds=SEEDS); rows.append({'cfg':cfg,'mean':r['mean']})
111 if best is None or r['mean']<best['mean']: best=dict(r); best['cfg']=cfg
112 vals=[train_cycle(s,best['cfg'],True) for s in SEEDS]
113 best['per_seed']=[v[0] for v in vals]; best['mean']=float(np.mean(best['per_seed'])); best['std']=float(np.std(best['per_seed'])); best['n']=8
114 # Signature is measured from independently trained baseline and idea systems.
115 bcfg=base['best_cfg']; bvals=[]
116 for s in SEEDS:
117 seed_all(s); ds=get_dataset('dynamics',s,n_train=200,n_test=100); m=make_model('rnn_small',ds['input_shape'],ds['out_dim']); train_model(m,ds,epochs=bcfg['epochs'],lr=bcfg['lr'],batch=64,weight_decay=bcfg['wd'],log=lambda *_:None)
118 dev=device_for(); m.to(dev); bvals.append(cycle_penalty(m,ds['xtr'][:1].to(dev),1.0)[1])
119 ivals=[v[1]['closed_walk_trace2'] for v in vals]
120 sig={'prediction':'Training the positive-cycle penalty suppresses measured short closed-walk gain in the recurrent hidden Jacobian.','observed_baseline_mean_trace2':float(np.mean(bvals)),'observed_idea_mean_trace2':float(np.mean(ivals)),'relative_change_pct':float((np.mean(ivals)/np.mean(bvals)-1)*100),'math_sanity':sanity,'confirmed':bool(np.mean(ivals)<np.mean(bvals))}
121 report=make_report('dynamics','rnn_small',base,best,{'idea_sweep':rows,'mechanism_signature':sig})
122 # Preserve required top-level signature location too.
123 report['mechanism_signature']=sig
124 report['idea_sweep']=rows
125 with open('bench_report.json','w') as f: json.dump(report,f,indent=2)
126 print(json.dumps(report,indent=2))
127if __name__=='__main__': main()