Raw ⬇ ZIP
  1import os, json, math, random
  2import numpy as np
  3import torch
  4import torch.nn as nn
  5
  6SEED=17
  7random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
  8torch.set_num_threads(4)
  9try:
 10    device=torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 11    if device.type=='cuda': torch.zeros(1,device=device)
 12except Exception:
 13    device=torch.device('cpu')
 14
 15def sym_eigs(M): return torch.linalg.eigvalsh((M+M.transpose(-1,-2))/2)
 16
 17# Core numerical verification: direct finite-step LMI and energy ratio.
 18def exact_check():
 19    h=.08; alpha=.35
 20    # A is stable in a non-Euclidean metric; P changes with theta.
 21    P0=torch.tensor([[2.0,.35],[.35,1.0]])
 22    P1=P0.clone()
 23    # Construct A from a contracting similarity transform, then test the actual discrete condition.
 24    Q=torch.tensor([[-1.4, .25],[-.10,-.9]])
 25    L=torch.linalg.cholesky(P0)
 26    A=torch.linalg.solve(L.T, Q @ L.T) # gives A^T P + P A = L(Q^T+Q)L^T
 27    J=torch.eye(2)+h*A
 28    S=J.T@P1@J-math.exp(-2*alpha*h)*P0
 29    # Generalized normalized matrix via symmetric inverse square root
 30    ew,U=torch.linalg.eigh(P0); Pinv=(U*ew.rsqrt())@U.T
 31    lam=torch.linalg.eigvalsh(Pinv@S@Pinv).max().item()
 32    # simulate perturbations and compare exact metric energies
 33    d=torch.tensor([1.0,-.7]); v0=(d@P0@d).item(); d1=J@d; v1=(d1@P1@d1).item()
 34    ratio=v1/v0; bound=math.exp(-2*alpha*h)
 35    # A deliberately noncontracting control in same P.
 36    Jbad=torch.eye(2)+h*(A+1.8*torch.eye(2)); Sbad=Jbad.T@P1@Jbad-bound*P0
 37    lambad=torch.linalg.eigvalsh(Pinv@Sbad@Pinv).max().item()
 38    return {'device':str(device),'h':h,'alpha':alpha,'finite_lmi_lambda':lam,
 39            'energy_ratio':ratio,'bound':bound,'bad_lambda':lambad,
 40            'lmi_satisfied':bool(lam<=1e-7),'energy_satisfied':bool(ratio<=bound)}
 41
 42class Dyn(nn.Module):
 43    def __init__(self, hidden=32):
 44        super().__init__()
 45        self.net=nn.Sequential(nn.Linear(3,hidden),nn.Tanh(),nn.Linear(hidden,hidden),nn.Tanh(),nn.Linear(hidden,2))
 46        self.pnet=nn.Sequential(nn.Linear(1,16),nn.Tanh(),nn.Linear(16,3))
 47    def forward(self,z,theta): return z + .12*self.net(torch.cat([z,theta],-1))
 48    def metric(self,theta):
 49        x=self.pnet(theta); L=torch.zeros(x.shape[0],2,2,device=x.device)
 50        L[:,0,0]=torch.nn.functional.softplus(x[:,0])+0.15
 51        L[:,1,0]=x[:,1]; L[:,1,1]=torch.nn.functional.softplus(x[:,2])+0.15
 52        return L@L.transpose(-1,-2)+.05*torch.eye(2,device=x.device)
 53
 54def target(z,theta):
 55    # Two changing operating regimes, both stable but with different shear/rotation.
 56    th=theta[:,0]
 57    a=0.73+0.10*torch.sin(th); b=.18*torch.cos(th)
 58    M=torch.stack([torch.stack([a,b],-1),torch.stack([-b,a-.08*torch.sin(th)],-1)],-2)
 59    return torch.bmm(M,z.unsqueeze(-1)).squeeze(-1)
 60
 61def jacobian_batch(model,z,theta):
 62    # Explicit small-state Jacobians, retaining autograd for the penalty.
 63    outs=[]
 64    for i in range(z.shape[0]):
 65        zi=z[i:i+1].detach().requires_grad_(True); ti=theta[i:i+1]
 66        yi=model(zi,ti).squeeze(0)
 67        rows=[]
 68        for q in range(2): rows.append(torch.autograd.grad(yi[q],zi,create_graph=True,retain_graph=True)[0].squeeze(0))
 69        outs.append(torch.stack(rows))
 70    return torch.stack(outs)
 71
 72def lyap_penalty(model,z,theta,alpha=.22,h=.12):
 73    J=jacobian_batch(model,z,theta); P=model.metric(theta)
 74    # P_{k+1}: parameter changes between adjacent operating conditions (same batch shuffled partner).
 75    theta1=torch.roll(theta,1,0); P1=model.metric(theta1)
 76    E=torch.eye(2,device=z.device).expand(z.shape[0],-1,-1)
 77    R=J.transpose(1,2)@P1@J-math.exp(-2*alpha*h)*P
 78    # generalized max eigenvalue, normalized by P^{-1/2}; squared positive part.
 79    ev,U=torch.linalg.eigh(P); Pinv=(U*ev.rsqrt().unsqueeze(-2))@U.transpose(1,2)
 80    lam=torch.linalg.eigvalsh(Pinv@R@Pinv)[:,-1]
 81    return torch.relu(lam).square().mean(), lam.detach()
 82
 83def run(penalized, steps=120):
 84    torch.manual_seed(SEED+int(penalized)); model=Dyn().to(device)
 85    opt=torch.optim.Adam(model.parameters(),lr=2e-3)
 86    for step in range(steps):
 87        z=torch.randn(48,2,device=device); th=(torch.rand(48,1,device=device)*2-1)*math.pi
 88        y=target(z,th)
 89        pred=model(z,th); loss=((pred-y)**2).mean()
 90        if penalized:
 91            lp,_=lyap_penalty(model,z,th); loss=loss+0.8*lp
 92        opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),5); opt.step()
 93    with torch.no_grad():
 94        z=torch.randn(256,2,device=device); th=(torch.rand(256,1,device=device)*2-1)*math.pi
 95        mse=((model(z,th)-target(z,th))**2).mean().item()
 96    # Need jacobians with gradients enabled, no_grad cannot be used.
 97    z=torch.randn(128,2,device=device); th=(torch.rand(128,1,device=device)*2-1)*math.pi
 98    lp,lam=lyap_penalty(model,z,th)
 99    # independent same-regime P_{k+1}=P(theta), to isolate local contraction.
100    J=jacobian_batch(model,z,th); P=model.metric(th); ev,U=torch.linalg.eigh(P); Pinv=(U*ev.rsqrt().unsqueeze(-2))@U.transpose(1,2)
101    local=torch.linalg.eigvalsh(Pinv@(J.transpose(1,2)@P@J)@Pinv)[:,-1]
102    return {'mse':mse,'penalty_batch':lp.item(),'fraction_violating':(lam>0).float().mean().item(),
103            'mean_generalized_lambda':lam.mean().item(),'max_generalized_lambda':lam.max().item(),
104            'local_contraction_ratio_mean':local.mean().item(),'local_ratio_max':local.max().item()}
105
106if __name__=='__main__':
107    out={'exact_check':exact_check(),'baseline':run(False),'lyapunov':run(True)}
108    print(json.dumps(out,indent=2))
109    with open('results.json','w') as f: json.dump(out,f,indent=2)