import sys, os, json, math, random import numpy as np import torch import torch.nn as nn sys.path.insert(0, '/home/maxwelhelp/all/math2nn') from bench import get_dataset, sweep_baseline, make_report from bench.protocol import evaluate TRACK='dynamics'; MODEL='rnn_small'; SEEDS=tuple(range(8)) # union is shared by baseline and idea; 4-seed tuning is the harness default GRID=[{'lr':1e-3,'epochs':4},{'lr':3e-3,'epochs':4},{'lr':8e-3,'epochs':4}] def seedall(s): random.seed(s); np.random.seed(s); torch.manual_seed(s) def device(): return 'cuda' if torch.cuda.is_available() else 'cpu' class FeedbackRNN(nn.Module): """64-state tanh RNN; scalar feedback E=c^T h is explicitly rank-one.""" def __init__(self, hidden=32): super().__init__(); self.hidden=hidden self.inp=nn.Linear(3,hidden); self.W=nn.Linear(hidden,hidden,bias=False) self.b=nn.Parameter(torch.randn(hidden)*0.02) self.c=nn.Parameter(torch.randn(hidden)*0.02) self.head=nn.Linear(hidden,1) def step(self,h,u): E=(h*self.c).sum(-1,keepdim=True) return torch.tanh(self.W(h)+self.inp(u)+E*self.b) def forward(self,x): h=torch.zeros(x.shape[0],self.hidden,device=x.device,dtype=x.dtype) for u in x.view(x.shape[0],-1,3).unbind(1): h=self.step(h,u) return self.head(h) def contour_penalty(net,h,margin=.10): # local A,b,c at a trained minibatch state, differentiable through autograd u=torch.zeros(h.shape[0],3,device=h.device,dtype=h.dtype) # use mean state and a fixed probe input; retain graph for training hm=h.mean(0).detach().requires_grad_(True) def f(z): return net.step(z.unsqueeze(0),u[:1]).squeeze(0) A=torch.autograd.functional.jacobian(f,hm,create_graph=True) b=net.b; c=net.c I=torch.eye(net.hidden,device=h.device,dtype=torch.complex64) Ac=A.to(torch.complex64); bc=b.to(torch.complex64); cc=c.to(torch.complex64) vals=[] for r in (.98,1.0,1.02): for k in range(8): th=2*math.pi*k/8; z=torch.tensor(r*math.cos(th)+1j*r*math.sin(th),device=h.device) q=torch.linalg.solve(z*I-Ac,bc); vals.append(torch.relu(torch.abs(torch.dot(cc,q))-(1-margin))**2) return torch.stack(vals).mean(), A.detach() def train_one(seed,cfg,regularize=False, collect=False): seedall(seed); ds=get_dataset(TRACK,seed,n_train=300,n_test=100) dev=device(); net=FeedbackRNN().to(dev); x=ds['xtr'].to(dev); y=ds['ytr'].to(dev) opt=torch.optim.Adam(net.parameters(),lr=cfg['lr']); hist=[]; lastA=None; lastH=None for ep in range(cfg['epochs']): net.train(); perm=torch.randperm(len(x),device=dev); total=0 for i in range(0,len(x),128): z=x[perm[i:i+128]]; target=y[perm[i:i+128]] # obtain final hidden states without changing the shared forward behavior h=torch.zeros(z.shape[0],net.hidden,device=dev) for u in z.view(z.shape[0],-1,3).unbind(1): h=net.step(h,u) pred=net.head(h); loss=((pred-target)**2).mean() pen=torch.zeros((),device=dev) if regularize and i == 0: pen,lastA=contour_penalty(net,h); loss=loss+0.5*pen opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(),1.0); opt.step(); total+=float(loss)*len(z) hist.append(total/len(x)) net.eval() with torch.no_grad(): pred=net(ds['xte'].to(dev)); metric=float(((pred-ds['yte'].to(dev))**2).mean()) if collect: # independently re-evaluate local gain and observed perturbation decay on trained weights with torch.no_grad(): h=torch.zeros(1,net.hidden,device=dev); u=torch.zeros(1,3,device=dev) for _ in range(8): h=net.step(h,u) pen,A=contour_penalty(net,h.detach()); J=A+torch.outer(net.b,net.c).detach().to(A) eig=float(torch.linalg.eigvals(J).abs().max()); gain=float((1-pen.detach()).item()) # observed linearized impulse: powers of J applied to b v=net.b.detach(); obs=[] for _ in range(12): obs.append(float(v.norm())); v=J.detach()@v 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]} return metric def base_factory(cfg): return lambda s: train_one(s,cfg,False) def idea_factory(cfg): return lambda s: train_one(s,cfg,True) def main(): # baseline sweep, then identical three-setting idea sweep on all paired seeds base=sweep_baseline(base_factory,GRID) idea_trials=[] for cfg in GRID: idea_trials.append({'cfg':cfg,'result':evaluate(idea_factory(cfg),SEEDS)}) best=min(idea_trials,key=lambda q:q['result']['mean']); idea=best['result'] sigs=[] for s in SEEDS: _,sig=train_one(s,best['cfg'],True,True); sigs.append(sig) # observed trained-system signature: compare open-loop prediction H to closed-loop eig and impulse sig={k:float(np.mean([q[k] for q in sigs])) for k in sigs[0]} 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']))}) 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'}) json.dump(rep,open('bench_report.json','w'),indent=2); print(json.dumps(rep,indent=2)) if __name__=='__main__': main()