Koopman-generator HJB critic / experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, random
2from pathlib import Path
3import numpy as np
4from scipy.linalg import solve_continuous_are
5import torch
6import torch.nn as nn
7
8SEED=2147
9np.random.seed(SEED); random.seed(SEED); torch.manual_seed(SEED)
10
11def l2(x): return float(np.sqrt(np.mean(np.asarray(x)**2)))
12
13def main():
14 # Stable, controllable continuous-time LQR system.
15 A=np.array([[0.,1.],[-1.0,-0.35]])
16 B=np.array([[0.],[1.]])
17 Q=np.diag([1., .4]); R=np.array([[1.]]) ; rho=.2
18 Ar=A-rho/2*np.eye(2)
19 P=solve_continuous_are(Ar,B,Q,R)
20 K=np.linalg.solve(R,B.T@P)
21
22 # Prediction 1: generator finite differences converge linearly in dt.
23 # Observable phi(x)=[x1^2, x1*x2, sin(x1)+x2^2], evaluated on exact Euler-free flow.
24 def phi(x): return np.stack([x[:,0]**2, x[:,0]*x[:,1], np.sin(x[:,0])+x[:,1]**2],1)
25 def dphi(x):
26 f=x@A.T
27 return np.stack([2*x[:,0]*f[:,0], f[:,0]*x[:,1]+x[:,0]*f[:,1], np.cos(x[:,0])*f[:,0]+2*x[:,1]*f[:,1]],1)
28 xs=np.random.default_rng(SEED+1).uniform(-1,1,(1000,2))
29 # sufficiently accurate RK4 flow for each batch point
30 def flow(x,dt):
31 k1=x@A.T; k2=(x+.5*dt*k1)@A.T; k3=(x+.5*dt*k2)@A.T; k4=(x+dt*k3)@A.T
32 return x+dt*(k1+2*k2+2*k3+k4)/6
33 dts=np.array([.2,.1,.05,.025,.0125])
34 gen_err=[]
35 for dt in dts:
36 gen_err.append(l2((phi(flow(xs,dt))-phi(xs))/dt-dphi(xs)))
37 slope_gen=float(np.polyfit(np.log(dts),np.log(gen_err),1)[0])
38
39 # Prediction 2: finite-data affine generator identification improves as N^-1/2
40 # with fixed observation noise. Data are independently sampled states and controls.
41 rng=np.random.default_rng(SEED+2); dt=.01; noise=.02
42 Ns=[32,64,128,256,512,1024]
43 id_err=[]
44 for N in Ns:
45 vals=[]
46 for rep in range(12):
47 x=rng.uniform(-1,1,(N,2)); u=rng.normal(0,1,(N,1))
48 y=x@A.T+u@B.T + rng.normal(0,noise,(N,2))
49 Z=np.concatenate([x,u],1)
50 W=np.linalg.solve(Z.T@Z+1e-5*np.eye(3),Z.T@y)
51 xt=rng.uniform(-1,1,(1000,2)); ut=rng.normal(0,1,(1000,1))
52 pred=np.concatenate([xt,ut],1)@W
53 truth=xt@A.T+ut@B.T
54 vals.append(l2(pred-truth))
55 id_err.append(float(np.mean(vals)))
56 slope_id=float(np.polyfit(np.log(Ns),np.log(id_err),1)[0])
57
58 # Koopman/EDMD-style lifted generator: regress finite-difference observable
59 # derivatives against the lifted dictionary and control, then test holdout.
60 def psi(x):
61 return np.stack([x[:,0], x[:,1], x[:,0]**2, x[:,0]*x[:,1],
62 x[:,1]**2, np.sin(x[:,0])], 1)
63 xl=rng.uniform(-1,1,(800,2)); ul=rng.normal(0,1,(800,1))
64 dt_l=.002
65 yl=psi(flow(xl + 0*ul,dt_l)) # autonomous dictionary sanity check
66 # controlled one-step flow uses the constant sampled control over dt
67 xc=xl + dt_l*(xl@A.T + ul@B.T)
68 zd=(psi(xc)-psi(xl))/dt_l
69 Z=np.concatenate([psi(xl),ul],1)
70 C=np.linalg.solve(Z.T@Z+1e-6*np.eye(Z.shape[1]),Z.T@zd)
71 xt=rng.uniform(-1,1,(1000,2)); ut=rng.normal(0,1,(1000,1))
72 xnext=xt+dt_l*(xt@A.T+ut@B.T)
73 lift_err=l2(np.concatenate([psi(xt),ut],1)@C-(psi(xnext)-psi(xt))/dt_l)
74
75 # Prediction 3: exact LQR value has zero HJB residual; multiplicative G error
76 # produces first-order residual in epsilon near zero.
77 xx=rng.uniform(-2,2,(3000,2)); grad=xx@P.T
78 q=.5*np.sum((xx@Q)*xx,1); V=.5*np.sum((xx@P)*xx,1)
79 eps=np.array([0,.01,.02,.05,.1,.2])
80 res=[]
81 for e in eps:
82 Bh=(1+e)*B
83 fh=xx@A.T; gh=grad@Bh
84 rr=q-rho*V+np.sum(grad*fh,1)-.5*np.sum(gh*gh,1)
85 res.append(l2(rr))
86 slope_res=float(np.polyfit(np.log(eps[1:]),np.log(res[1:]),1)[0])
87
88 # Small neural comparison: HJB residual training versus one-step TD regression.
89 torch.set_num_threads(2); device='cuda' if torch.cuda.is_available() else 'cpu'
90 try:
91 dev=torch.device(device); _=torch.zeros(1,device=dev)
92 except Exception:
93 dev=torch.device('cpu')
94 X=torch.tensor(rng.uniform(-1.5,1.5,(512,2)),dtype=torch.float32,device=dev)
95 Pt=torch.tensor(P,dtype=torch.float32,device=dev); At=torch.tensor(A,dtype=torch.float32,device=dev)
96 Bt=torch.tensor(B,dtype=torch.float32,device=dev); Qt=torch.tensor(Q,dtype=torch.float32,device=dev)
97 class VNet(nn.Module):
98 def __init__(self):
99 super().__init__(); self.net=nn.Sequential(nn.Linear(2,32),nn.Tanh(),nn.Linear(32,32),nn.Tanh(),nn.Linear(32,1))
100 def forward(self,x): return self.net(x).squeeze(-1)
101 def train_hjb():
102 torch.manual_seed(SEED); net=VNet().to(dev); opt=torch.optim.Adam(net.parameters(),lr=3e-3)
103 for _ in range(220):
104 xb=X.detach().clone().requires_grad_(True); v=net(xb); g=torch.autograd.grad(v.sum(),xb,create_graph=True)[0]
105 qv=.5*torch.sum((xb@Qt)*xb,1); f=xb@At.T; gb=g@Bt
106 rr=qv-rho*v+torch.sum(g*f,1)-.5*torch.sum(gb*gb,1)
107 loss=(rr**2).mean()+1e-3*(v[X[:,0].abs()<.03]**2).mean()
108 opt.zero_grad(); loss.backward(); opt.step()
109 with torch.no_grad(): pred=net(X); target=.5*torch.sum((X@Pt)*X,1)
110 return l2((pred-target).cpu().numpy())
111 def train_td():
112 torch.manual_seed(SEED); net=VNet().to(dev); opt=torch.optim.Adam(net.parameters(),lr=3e-3)
113 # Standard discounted one-step TD target under optimal linear policy, same states.
114 xnp=X.detach().cpu().numpy(); u=-(xnp@K.T); xn=xnp+0.02*(xnp@A.T+u@B.T)
115 xn=torch.tensor(xn,dtype=torch.float32,device=dev); un=torch.tensor(u,dtype=torch.float32,device=dev)
116 cost=.02*(.5*np.sum((xnp@Q)*xnp,1)+.5*np.sum((u@R)*u,1))
117 cost=torch.tensor(cost,dtype=torch.float32,device=dev)
118 gamma=math.exp(-rho*.02)
119 for _ in range(220):
120 target=cost+gamma*net(xn).detach(); loss=((net(X)-target)**2).mean()
121 opt.zero_grad(); loss.backward(); opt.step()
122 with torch.no_grad(): pred=net(X); target=.5*torch.sum((X@Pt)*X,1)
123 return l2((pred-target).cpu().numpy())
124 hjb_err=train_hjb(); td_err=train_td()
125 out={'system':{'A':A.tolist(),'B':B.tolist(),'P':P.tolist(),'rho':rho},
126 'predictions':{
127 'generator':{'claim':'finite-difference generator error is O(dt)','parameter':dts.tolist(),'observed_error':gen_err,'observed_loglog_slope':slope_gen,'predicted_slope':1.0},
128 'identification':{'claim':'fixed-noise regression error is approximately O(N^-1/2)','parameter':Ns,'observed_error':id_err,'observed_loglog_slope':slope_id,'predicted_slope':-0.5},
129 'lifted_generator':{'claim':'EDMD lifted generator predicts observable derivatives', 'dictionary_dim':6, 'holdout_rms_derivative_error':lift_err},
130 'hjb_sensitivity':{'claim':'zero at exact dynamics and first-order in multiplicative G error','parameter':eps.tolist(),'observed_rms_residual':res,'zero_error_residual':res[0],'observed_loglog_slope':slope_res,'predicted_slope':1.0}},
131 'comparison':{'metric':'RMS value error against discounted CARE solution after 220 updates','hjb':hjb_err,'td_baseline':td_err,'device':str(dev)}}
132 Path('results.json').write_text(json.dumps(out,indent=2))
133 print(json.dumps(out,indent=2))
134if __name__=='__main__': main()