Horizon-Adaptive Neural Tube Rollouts / tube_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json, math, random
  2from pathlib import Path
  3import numpy as np
  4import torch
  5from torch import nn
  6
  7SEED = 2765
  8np.random.seed(SEED); random.seed(SEED); torch.manual_seed(SEED)
  9torch.set_num_threads(4)
 10
 11
 12def tube_rollout(Js, r0, ds):
 13    r = np.asarray(r0, dtype=float).copy()
 14    out = [r.copy()]
 15    for J, d in zip(Js, ds):
 16        r = np.abs(np.asarray(J)) @ r + np.asarray(d)
 17        out.append(r.copy())
 18    return np.asarray(out)
 19
 20
 21def toy_verification():
 22    # For J=gamma*lambda, the theory predicts q=1 boundary, slope log(q) above it,
 23    # and stationary radius d/(1-q) below it.
 24    lam = 0.8
 25    gamma_values = np.array([0.50, 0.90, 1.10, 1.30, 1.50])
 26    d = 0.01; H = 400
 27    rows = []
 28    for gamma in gamma_values:
 29        q = gamma * lam
 30        rs = tube_rollout([[[q]]]*H, [0.0], [[d]]*H)[:, 0]
 31        if q < 1:
 32            predicted_plateau = d/(1-q)
 33            observed_plateau = float(np.mean(rs[-10:]))
 34            relerr = abs(observed_plateau-predicted_plateau)/predicted_plateau
 35            slope_pred = 0.0
 36            slope_obs = 0.0
 37        else:
 38            predicted_plateau = float('inf')
 39            # Fit log radius after the transient; asymptotic slope should log(q).
 40            ix = np.arange(150, H+1)
 41            slope_obs = float(np.polyfit(ix, np.log(rs[150:]), 1)[0])
 42            slope_pred = math.log(q)
 43            relerr = abs(slope_obs-slope_pred)/abs(slope_pred)
 44        rows.append(dict(gamma=float(gamma), q=float(q), stable_pred=bool(q<1),
 45                         stable_observed=(bool(abs(slope_obs) <= 1e-10) if q >= 1 else bool(abs(slope_obs) <= 1e-10)), predicted_plateau=predicted_plateau,
 46                         observed_plateau=observed_plateau if q<1 else None,
 47                         predicted_log_slope=slope_pred, observed_log_slope=slope_obs,
 48                         relative_error=float(relerr)))
 49    # Disturbance scaling prediction at q=.72: equilibrium is linear in d.
 50    q=.72; ds=np.array([.002,.005,.01,.02]); final=[]
 51    for di in ds:
 52        rr=tube_rollout([[[q]]]*120,[0],[[di]]*120)[-1,0]
 53        final.append(float(rr))
 54    ratio=np.asarray(final)/ds
 55    scaling_error=float(np.max(np.abs(ratio-1/(1-q))/(1/(1-q))))
 56    # Locate transition empirically among a fine sweep: first q >= 1.
 57    fine=np.linspace(.70,1.50,161); empirical_boundary=float(fine[np.argmax(fine*lam>=1)])
 58    return {'gain_sweep':rows, 'predicted_boundary_gamma':1/lam,
 59            'observed_boundary_gamma':empirical_boundary, 'disturbances':ds.tolist(),
 60            'final_radii':final, 'radius_over_d':ratio.tolist(),
 61            'predicted_radius_over_d':1/(1-q), 'scaling_max_relative_error':scaling_error}
 62
 63
 64class Transition(nn.Module):
 65    def __init__(self, dim=2):
 66        super().__init__()
 67        self.net=nn.Sequential(nn.Linear(dim+1,24),nn.Tanh(),nn.Linear(24,dim))
 68    def forward(self,z,u): return z + 0.18*self.net(torch.cat([z,u],-1))
 69
 70
 71def make_data(n=900, horizon=12):
 72    zs=[]; us=[]; ys=[]
 73    for _ in range(n):
 74        z=np.random.uniform(-1,1,2).astype('float32')
 75        for k in range(horizon):
 76            u=np.random.uniform(-.7,.7,1).astype('float32')
 77            # nonlinear stable plant with transition noise
 78            y=np.array([.88*z[0]+.12*np.tanh(z[1])+ .08*u[0],
 79                        .80*z[1]+.10*np.sin(z[0])- .04*u[0]],dtype='float32')
 80            y += np.random.normal(0,.008,2).astype('float32')
 81            zs.append(z); us.append(u); ys.append(y); z=y
 82    return torch.tensor(np.asarray(zs)),torch.tensor(np.asarray(us)),torch.tensor(np.asarray(ys))
 83
 84
 85def neural_comparison():
 86    z,u,y=make_data(); n=len(z); split=int(.8*n)
 87    # Same initialization and training budget; tube penalty uses exact autograd Jacobian.
 88    def train(tube):
 89        torch.manual_seed(SEED); m=Transition(); opt=torch.optim.Adam(m.parameters(),lr=3e-3)
 90        for epoch in range(28):
 91            ix=torch.randperm(split)[:128]; zb,ub,yb=z[ix],u[ix],y[ix]
 92            pred=m(zb,ub); loss=((pred-yb)**2).mean()
 93            if tube:
 94                # Conservative residual radius: held-out-like fixed upper quantile estimate.
 95                r=torch.full((len(ix),2),.025)
 96                zz=zb.detach().clone().requires_grad_(True)
 97                pp=m(zz,ub)
 98                J=[]
 99                for j in range(2):
100                    grad=torch.autograd.grad(pp[:,j].sum(),zz,create_graph=False,retain_graph=True)[0]
101                    J.append(grad)
102                J=torch.stack(J,1).abs()
103                rnext=torch.bmm(J,r.unsqueeze(-1)).squeeze(-1)+.012
104                # nominal state constraint |z_i| <= 1, robust backoff.
105                robust_violation=torch.relu(torch.abs(pred)+rnext-1.0)
106                loss=loss + .015*rnext.mean() + .20*robust_violation.pow(2).mean()
107            opt.zero_grad(); loss.backward(); opt.step()
108        with torch.no_grad():
109            one=((m(z[split:],u[split:])-y[split:])**2).mean().item()
110        # Free-running 20-step rollout on test starts; compare state constraint violations.
111        viol=[]; mse=[]
112        for start in range(split,min(split+100, n-20),20):
113            zz=z[start:start+1].clone(); truth=z[start:start+20].clone();
114            for k in range(20):
115                uu=u[start+k:start+k+1]
116                zz=m(zz,uu); mse.append(float(((zz-truth[k:k+1])**2).mean()))
117                viol.append(float((torch.abs(zz)>1).any()))
118        return {'one_step_mse':one,'rollout_mse':float(np.mean(mse)),
119                'violation_rate':float(np.mean(viol))}
120    return {'baseline':train(False),'tube_regularized':train(True)}
121
122
123def main():
124    out={'seed':SEED,'toy_verification':toy_verification()}
125    try:
126        out['neural_comparison']=neural_comparison()
127        out['device']='cpu'
128    except Exception as e:
129        out['neural_error']=repr(e)
130    Path('results.json').write_text(json.dumps(out,indent=2))
131    print(json.dumps(out,indent=2))
132
133if __name__=='__main__': main()