Finite-Horizon Lyapunov Risk Monitor / experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import math, json, random
  2import numpy as np
  3import torch
  4import torch.nn as nn
  5
  6SEED=1469
  7np.random.seed(SEED); random.seed(SEED); torch.manual_seed(SEED)
  8try:
  9    device=torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 10    if device.type=='cuda':
 11        torch.zeros(1,device=device)
 12except Exception:
 13    device=torch.device('cpu')
 14
 15# Exact stochastic scalar map: x[t+1]=exp(mu+sigma*eps[t])*x[t].
 16# Its FTLE is exactly Normal(mu, sigma^2/T), making this a direct sanity check.
 17def toy_check():
 18    rng=np.random.default_rng(SEED)
 19    mu=-0.08; z=1.645; n=200000
 20    rows=[]
 21    for T in [4,16,64]:
 22        for sigma in [0.05,0.15,0.30]:
 23            lam=mu + sigma*rng.standard_normal((n,))/math.sqrt(T)
 24            obs_mu=float(lam.mean()); obs_sd=float(lam.std(ddof=1))
 25            p=float((lam>0).mean())
 26            pred_p=0.5*math.erfc((-mu)/(math.sqrt(2)*sigma/math.sqrt(T)))
 27            rows.append({'T':T,'sigma':sigma,'mu_obs':obs_mu,'mu_pred':mu,
 28                         'sd_obs':obs_sd,'sd_pred':sigma/math.sqrt(T),
 29                         'p_obs':p,'p_pred':pred_p})
 30    # Boundary sweep at T=16: UCB changes sign at sigma=-mu*sqrt(T)/z.
 31    T=16; boundary=-mu*math.sqrt(T)/z
 32    boundary_rows=[]
 33    for sigma in [0.12,0.18,0.195,0.23,0.30]:
 34        lam=mu + sigma*rng.standard_normal((n,))/math.sqrt(T)
 35        ucb=float(lam.mean()+z*lam.std(ddof=1))
 36        boundary_rows.append({'sigma':sigma,'ucb_obs':ucb,
 37                              'p_obs':float((lam>0).mean()),
 38                              'ucb_pred':mu+z*sigma/math.sqrt(T)})
 39    return {'predictions':{
 40        'variance_scaling':'sd(lambda_T)=sigma/sqrt(T)',
 41        'tail':'p_+=Phi(mu*sqrt(T)/sigma)',
 42        'boundary':f'ucb=0 at sigma={boundary:.5f} for mu={mu}, T={T}, z={z}'},
 43        'scaling_rows':rows,'boundary_rows':boundary_rows}
 44
 45class NoisyRNN(nn.Module):
 46    def __init__(self,h=12):
 47        super().__init__(); self.h=h
 48        self.W=nn.Parameter(torch.randn(h,h)*0.45)
 49        self.U=nn.Parameter(torch.randn(h,2)*0.35)
 50        self.b=nn.Parameter(torch.zeros(h)); self.out=nn.Linear(h,1)
 51    def run(self,x, noise_sigma=0.0, return_ftle=False, K=4):
 52        B,T,_=x.shape; h=torch.zeros(B,self.h,device=x.device)
 53        if return_ftle:
 54            qs=[torch.randn(B,self.h,device=x.device) for _ in range(K)]
 55            qs=[q/(q.norm(dim=1,keepdim=True)+1e-8) for q in qs]
 56            sums=[torch.zeros(B,device=x.device) for _ in range(K)]
 57        for t in range(T):
 58            eps=torch.randn(K,self.h,self.h,device=x.device) if noise_sigma else None
 59            Wt=self.W + (noise_sigma*eps[0] if eps is not None else 0)
 60            pre=h@Wt.T + x[:,t]@self.U.T + self.b
 61            h=torch.tanh(pre)
 62            if return_ftle:
 63                deriv=1-torch.tanh(pre)**2
 64                new_qs=[]; new_sums=[]
 65                for k in range(K):
 66                    wk=self.W + (noise_sigma*eps[k] if eps is not None else 0)
 67                    q=(qs[k]@wk.T)*deriv
 68                    norm=q.norm(dim=1)+1e-8
 69                    new_qs.append(q/norm[:,None])
 70                    new_sums.append(sums[k]+torch.log(norm))
 71                qs, sums=new_qs, new_sums
 72        y=self.out(h).squeeze(-1)
 73        if return_ftle:
 74            return y, torch.stack(sums,dim=0)/T
 75        return y
 76
 77def make_batch(B=64,T=20):
 78    x=torch.rand(B,T,2,device=device)
 79    # Two marker positions, target is sum of the marked values in channel 0.
 80    p1=torch.randint(0,T//2,(B,),device=device); p2=torch.randint(T//2,T,(B,),device=device)
 81    x[:,:,1]=0
 82    x[torch.arange(B,device=device),p1,1]=1
 83    x[torch.arange(B,device=device),p2,1]=1
 84    y=x[:,:,0].gather(1,p1[:,None]).squeeze(1)+x[:,:,0].gather(1,p2[:,None]).squeeze(1)
 85    return x,y
 86
 87def train_variant(kind, steps=220, T=20, sigma=.08, rho=.35):
 88    torch.manual_seed(SEED+{'base':0,'mean':1,'ucb':2}[kind])
 89    model=NoisyRNN().to(device); opt=torch.optim.Adam(model.parameters(),lr=3e-3)
 90    z=1.645; history=[]
 91    for step in range(steps):
 92        x,y=make_batch(64,T)
 93        opt.zero_grad(); pred,lams=model.run(x,sigma,True,K=4)
 94        task=((pred-y)**2).mean(); mu=lams.mean(); sd=lams.std(unbiased=True)
 95        risk=torch.relu(mu + (z*sd if kind=='ucb' else 0.0))**2 if kind!='base' else mu*0
 96        # mean variant penalizes only positive estimated mean FTLE.
 97        loss=task + rho*risk
 98        loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),5.0); opt.step()
 99        history.append((float(task.detach()),float(mu.detach()),float(sd.detach()),float(loss.detach())))
100    # Clean task and independent noisy FTLE evaluation.
101    with torch.no_grad():
102        vals=[]; ys=[]; ts=[]
103        for _ in range(12):
104            x,y=make_batch(64,T); pred,lams=model.run(x,sigma,True,K=8)
105            vals.append(float(((pred-y)**2).mean())); ts.append(lams.cpu().numpy().ravel())
106        arr=np.concatenate(ts)
107    return {'final_train':history[-1], 'cleanish_mse':float(np.mean(vals)),
108            'ftle_mean':float(arr.mean()),'ftle_sd':float(arr.std(ddof=1)),
109            'positive_fraction':float((arr>0).mean()),
110            'ucb':float(arr.mean()+z*arr.std(ddof=1))}
111
112def main():
113    global device
114    out={'device':str(device),'toy':toy_check(),'rnn':{}}
115    for k in ['base','mean','ucb']:
116        try: out['rnn'][k]=train_variant(k)
117        except Exception as e:
118            if device.type=='cuda':
119                device=torch.device('cpu')
120                out['rnn'][k]={'error':str(e),'fallback':'cpu'}
121            else: raise
122    with open('results.json','w') as f: json.dump(out,f,indent=2)
123    print(json.dumps(out,indent=2))
124if __name__=='__main__': main()