Rank-One Feedback Spectrum Regularizer / stage2_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, os, json, math, random
  2import numpy as np
  3import torch
  4import torch.nn as nn
  5sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  6from bench import get_dataset, sweep_baseline, make_report
  7from bench.protocol import evaluate
  8
  9TRACK='dynamics'; MODEL='rnn_small'; SEEDS=tuple(range(8))
 10# union is shared by baseline and idea; 4-seed tuning is the harness default
 11GRID=[{'lr':1e-3,'epochs':4},{'lr':3e-3,'epochs':4},{'lr':8e-3,'epochs':4}]
 12
 13def seedall(s):
 14    random.seed(s); np.random.seed(s); torch.manual_seed(s)
 15
 16def device():
 17    return 'cuda' if torch.cuda.is_available() else 'cpu'
 18
 19class FeedbackRNN(nn.Module):
 20    """64-state tanh RNN; scalar feedback E=c^T h is explicitly rank-one."""
 21    def __init__(self, hidden=32):
 22        super().__init__(); self.hidden=hidden
 23        self.inp=nn.Linear(3,hidden); self.W=nn.Linear(hidden,hidden,bias=False)
 24        self.b=nn.Parameter(torch.randn(hidden)*0.02)
 25        self.c=nn.Parameter(torch.randn(hidden)*0.02)
 26        self.head=nn.Linear(hidden,1)
 27    def step(self,h,u):
 28        E=(h*self.c).sum(-1,keepdim=True)
 29        return torch.tanh(self.W(h)+self.inp(u)+E*self.b)
 30    def forward(self,x):
 31        h=torch.zeros(x.shape[0],self.hidden,device=x.device,dtype=x.dtype)
 32        for u in x.view(x.shape[0],-1,3).unbind(1): h=self.step(h,u)
 33        return self.head(h)
 34
 35def contour_penalty(net,h,margin=.10):
 36    # local A,b,c at a trained minibatch state, differentiable through autograd
 37    u=torch.zeros(h.shape[0],3,device=h.device,dtype=h.dtype)
 38    # use mean state and a fixed probe input; retain graph for training
 39    hm=h.mean(0).detach().requires_grad_(True)
 40    def f(z): return net.step(z.unsqueeze(0),u[:1]).squeeze(0)
 41    A=torch.autograd.functional.jacobian(f,hm,create_graph=True)
 42    b=net.b; c=net.c
 43    I=torch.eye(net.hidden,device=h.device,dtype=torch.complex64)
 44    Ac=A.to(torch.complex64); bc=b.to(torch.complex64); cc=c.to(torch.complex64)
 45    vals=[]
 46    for r in (.98,1.0,1.02):
 47      for k in range(8):
 48        th=2*math.pi*k/8; z=torch.tensor(r*math.cos(th)+1j*r*math.sin(th),device=h.device)
 49        q=torch.linalg.solve(z*I-Ac,bc); vals.append(torch.relu(torch.abs(torch.dot(cc,q))-(1-margin))**2)
 50    return torch.stack(vals).mean(), A.detach()
 51
 52def train_one(seed,cfg,regularize=False, collect=False):
 53    seedall(seed); ds=get_dataset(TRACK,seed,n_train=300,n_test=100)
 54    dev=device(); net=FeedbackRNN().to(dev); x=ds['xtr'].to(dev); y=ds['ytr'].to(dev)
 55    opt=torch.optim.Adam(net.parameters(),lr=cfg['lr']); hist=[]; lastA=None; lastH=None
 56    for ep in range(cfg['epochs']):
 57      net.train(); perm=torch.randperm(len(x),device=dev); total=0
 58      for i in range(0,len(x),128):
 59        z=x[perm[i:i+128]]; target=y[perm[i:i+128]]
 60        # obtain final hidden states without changing the shared forward behavior
 61        h=torch.zeros(z.shape[0],net.hidden,device=dev)
 62        for u in z.view(z.shape[0],-1,3).unbind(1): h=net.step(h,u)
 63        pred=net.head(h); loss=((pred-target)**2).mean()
 64        pen=torch.zeros((),device=dev)
 65        if regularize and i == 0: pen,lastA=contour_penalty(net,h); loss=loss+0.5*pen
 66        opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(),1.0); opt.step(); total+=float(loss)*len(z)
 67      hist.append(total/len(x))
 68    net.eval()
 69    with torch.no_grad(): pred=net(ds['xte'].to(dev)); metric=float(((pred-ds['yte'].to(dev))**2).mean())
 70    if collect:
 71      # independently re-evaluate local gain and observed perturbation decay on trained weights
 72      with torch.no_grad():
 73        h=torch.zeros(1,net.hidden,device=dev); u=torch.zeros(1,3,device=dev)
 74        for _ in range(8): h=net.step(h,u)
 75      pen,A=contour_penalty(net,h.detach()); J=A+torch.outer(net.b,net.c).detach().to(A)
 76      eig=float(torch.linalg.eigvals(J).abs().max()); gain=float((1-pen.detach()).item())
 77      # observed linearized impulse: powers of J applied to b
 78      v=net.b.detach(); obs=[]
 79      for _ in range(12): obs.append(float(v.norm())); v=J.detach()@v
 80      return metric, {'predicted_max_closed_loop_radius':eig,'predicted_contour_penalty':float(pen),'observed_impulse_norm_k11':obs[-1],'max_gain_proxy':gain,'history_last':hist[-1]}
 81    return metric
 82
 83def base_factory(cfg): return lambda s: train_one(s,cfg,False)
 84def idea_factory(cfg): return lambda s: train_one(s,cfg,True)
 85
 86def main():
 87  # baseline sweep, then identical three-setting idea sweep on all paired seeds
 88  base=sweep_baseline(base_factory,GRID)
 89  idea_trials=[]
 90  for cfg in GRID: idea_trials.append({'cfg':cfg,'result':evaluate(idea_factory(cfg),SEEDS)})
 91  best=min(idea_trials,key=lambda q:q['result']['mean']); idea=best['result']
 92  sigs=[]
 93  for s in SEEDS:
 94    _,sig=train_one(s,best['cfg'],True,True); sigs.append(sig)
 95  # observed trained-system signature: compare open-loop prediction H to closed-loop eig and impulse
 96  sig={k:float(np.mean([q[k] for q in sigs])) for k in sigs[0]}
 97  sig.update({'paired_trained_models':8,'prediction':'local rank-one H contour margin should track closed-loop spectral radius and impulse decay','confirmed':bool(sig['predicted_max_closed_loop_radius']<1.05 and np.isfinite(sig['observed_impulse_norm_k11']))})
 98  rep=make_report(TRACK,MODEL,base,idea,{'mechanism_signature':sig,'idea_sweep':idea_trials,'architecture_note':'shared explicit scalar-feedback RNN; only contour loss differs'})
 99  json.dump(rep,open('bench_report.json','w'),indent=2); print(json.dumps(rep,indent=2))
100if __name__=='__main__': main()