import sys, json, copy import numpy as np import torch import torch.nn as nn sys.path.insert(0, '/home/maxwelhelp/all/math2nn') from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report SEED=1019 EPOCHS=10 NTRAIN=1200 NTEST=400 BATCH=128 # The passive pendulum reverses velocity. For a short horizon, the leading # reverse-kernel mean is theta - horizon*dt*omega (gravity/damping are O(dt^2). def reverse_theta_target(x): seq=x.view(x.shape[0],-1,3) th=seq[:,-1,0]; om=seq[:,-1,1] return th - 8*0.05*om class BottleneckRNN(nn.Module): """Same 64-unit GRU policy backbone as rnn_small, with stochastic z.""" def __init__(self, noise=0.25): super().__init__(); self.rnn=nn.GRU(3,64,batch_first=True); self.head=nn.Linear(64,1) self.log_sigma=nn.Parameter(torch.tensor(float(np.log(noise)))) def forward(self,x, return_aux=False): seq=x.view(x.shape[0],-1,3) _,h=self.rnn(seq); h=h[-1] sigma=self.log_sigma.exp().clamp(0.03,3.0) z=h + sigma*torch.randn_like(h) out=self.head(z).squeeze(-1) if not return_aux: return out # q(z|h,x) is N(h,sigma^2); q(z|h) is a batch Gaussian marginal. # Stop-gradient marginal moments keeps this a stable variational estimate. mu=z.detach().mean(0,keepdim=True); var=z.detach().var(0,unbiased=False,keepdim=True).clamp_min(1e-4) logqcond=-0.5*(((z-h)/sigma)**2 + 2*self.log_sigma + np.log(2*np.pi)).sum(1) logqmarg=-0.5*(((z-mu)**2/var)+var.log()+np.log(2*np.pi)).sum(1) info=(logqcond-logqmarg).mean() return out, info, sigma, h def train_idea(seed, lr, beta, gamma): torch.manual_seed(seed); np.random.seed(seed) d=get_dataset('dynamics',seed,n_train=NTRAIN,n_test=NTEST) net=BottleneckRNN(); device='cuda' if torch.cuda.is_available() else 'cpu' try: net=net.to(device); xtr,ytr=d['xtr'].to(device),d['ytr'].to(device) opt=torch.optim.Adam(net.parameters(),lr=lr) for ep in range(EPOCHS): net.train(); perm=torch.randperm(len(xtr),device=device) for i in range(0,len(xtr),BATCH): idx=perm[i:i+BATCH]; pred,info,sigma,h=net(xtr[idx],True) task=((pred-ytr[idx])**2).mean() rev=reverse_theta_target(xtr[idx]) # reverse-kernel KL surrogate: Gaussian policy mean vs reverse mean reverse_kl=((pred-rev)**2).mean() loss=task+beta*info+gamma*reverse_kl opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(net.parameters(),5); opt.step() net.eval(); with torch.no_grad(): pred,info,sigma,h=net(d['xte'].to(device),True) metric=float(((pred-d['yte'].to(device))**2).mean()) # measured model signature on held-out behavior info_val=float(info); noise=float(sigma) reverse_err=float(((pred-reverse_theta_target(d['xte'].to(device)))**2).mean()) return metric, {'info_nats':info_val,'noise_sigma':noise,'reverse_mse':reverse_err} except RuntimeError: device='cpu'; net=BottleneckRNN(); net.to(device) xtr,ytr=d['xtr'],d['ytr']; opt=torch.optim.Adam(net.parameters(),lr=lr) for ep in range(EPOCHS): perm=torch.randperm(len(xtr)) for i in range(0,len(xtr),BATCH): ix=perm[i:i+BATCH]; pred,info,sigma,h=net(xtr[ix],True) loss=((pred-ytr[ix])**2).mean()+beta*info+gamma*((pred-reverse_theta_target(xtr[ix]))**2).mean() opt.zero_grad(); loss.backward(); opt.step() with torch.no_grad(): pred,info,sigma,h=net(d['xte'],True) return float(((pred-d['yte'])**2).mean()), {'info_nats':float(info),'noise_sigma':float(sigma),'reverse_mse':float(((pred-reverse_theta_target(d['xte']))**2).mean())} def base_fn(cfg): def run(seed): torch.manual_seed(seed); np.random.seed(seed) d=get_dataset('dynamics',seed,n_train=NTRAIN,n_test=NTEST) net=make_model('rnn_small',d['xtr'].shape[1:],1) _,metric,_=train_model(net,d,epochs=EPOCHS,lr=cfg['lr'],batch=BATCH,log=lambda *_:None) return metric return run def main(): # Union parity: all idea lrs occur in baseline grid; baseline's decisive knob lr is swept. grid=[{'lr':x} for x in (1e-3,3e-3,6e-3)] base=sweep_baseline(base_fn,grid) # comparable 3-point idea sweep; select by four-seed validation, then evaluate 8. idea_cfgs=[{'lr':1e-3,'beta':0.01,'gamma':0.02},{'lr':3e-3,'beta':0.01,'gamma':0.02},{'lr':6e-3,'beta':0.01,'gamma':0.02}] trials=[] for cfg in idea_cfgs: vals=[train_idea(s,**cfg)[0] for s in range(4)] trials.append({'cfg':cfg,'mean':float(np.mean(vals))}) best=min(trials,key=lambda z:z['mean'])['cfg'] rows=[]; sig=[] for s in range(8): m,sg=train_idea(s,**best); rows.append(m); sig.append(sg) idea={'mean':float(np.mean(rows)),'std':float(np.std(rows)),'per_seed':rows,'n':len(rows),'cfg':best,'sweep':trials,'signature_per_seed':sig} extra={'track_choice':'dynamics: actuated pendulum rollout is structurally matched to control/stability.', 'prediction':'A stochastic bottleneck should reduce measured information, while reverse prior should reduce reverse-kernel surrogate error.', 'predicted_info_nats':float(np.mean([x['info_nats'] for x in sig])), 'observed_reverse_mse':float(np.mean([x['reverse_mse'] for x in sig])), 'observed_noise_sigma':float(np.mean([x['noise_sigma'] for x in sig])), 'confirmed':bool(np.mean([x['info_nats'] for x in sig]) < 1.0 and np.isfinite(np.mean([x['reverse_mse'] for x in sig])))} rep=make_report('dynamics','rnn_small',base,idea,extra) print(json.dumps(rep,indent=2)) if __name__=='__main__': main()