Positive-cycle Jacobian penalty / stage2_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  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()