Adaptive SOS Lyapunov Certificate Ladder / sos_ladder.py

Mechanism failed

Raw ⬇ ZIP
  1import json, math, os
  2import numpy as np
  3from scipy.optimize import minimize, brentq
  4from itertools import product
  5
  6rng = np.random.default_rng(7)
  7
  8def mons(n, deg):
  9    out=[]
 10    def rec(rem,k,p):
 11        if k==n-1: out.append(tuple(p+[rem])); return
 12        for a in range(rem+1): rec(rem-a,k+1,p+[a])
 13    rec(deg,0,[]); return out
 14
 15def poly_add(a,b,scale=1.):
 16    c=dict(a)
 17    for e,v in b.items(): c[e]=c.get(e,0.)+scale*v
 18    return {e:v for e,v in c.items() if abs(v)>1e-14}
 19
 20def poly_mul(a,b):
 21    c={}
 22    for x,v in a.items():
 23      for y,w in b.items():
 24        e=tuple(i+j for i,j in zip(x,y)); c[e]=c.get(e,0.)+v*w
 25    return c
 26
 27def power(p,k):
 28    q={(0,)*len(next(iter(p))):1.}
 29    for _ in range(k): q=poly_mul(q,p)
 30    return q
 31
 32def gram_system(poly,n,deg):
 33    mm=mons(n,deg); pairs=[(i,j) for i in range(len(mm)) for j in range(i,len(mm))]
 34    exps=sorted(set(tuple(mm[i][k]+mm[j][k] for k in range(n)) for i,j in pairs)|set(poly))
 35    rows=[]; rhs=[]
 36    for e in exps:
 37      row=np.zeros(len(pairs))
 38      for k,(i,j) in enumerate(pairs):
 39        ee=tuple(mm[i][z]+mm[j][z] for z in range(n))
 40        if ee==e: row[k]=1 if i==j else 2
 41      rows.append(row); rhs.append(poly.get(e,0.))
 42    return mm,pairs,np.asarray(rows),np.asarray(rhs)
 43
 44def solve_sos(poly,n,r,tries=1):
 45    # Fast approximate SDP: alternate projection onto coefficient affine space and PSD cone.
 46    norm2={tuple(2 if i==j else 0 for i in range(n)):1. for j in range(n)}
 47    lifted=poly_mul(power(norm2,r),poly)
 48    deg=max(sum(e) for e in lifted)//2
 49    mm,pairs,C,b=gram_system(lifted,n,deg); m=len(mm)
 50    # map symmetric matrix to the upper-triangular coefficient vector
 51    x=np.linalg.lstsq(C,b,rcond=None)[0]
 52    def unpack(v):
 53      Q=np.zeros((m,m))
 54      for a,(i,j) in zip(v,pairs): Q[i,j]=Q[j,i]=a
 55      return Q
 56    def pack(Q): return np.array([Q[i,j] for i,j in pairs])
 57    # affine projection, with a PSD projection between corrections
 58    G=np.linalg.pinv(C@C.T,rcond=1e-10)
 59    for _ in range(100):
 60      Q=unpack(x); w,U=np.linalg.eigh((Q+Q.T)/2); Q=(U*np.maximum(w,0.))@U.T
 61      x=x+C.T@(G@(b-C@pack(Q)))
 62    Q=unpack(x); ev=np.linalg.eigvalsh((Q+Q.T)/2)
 63    return {'success':bool(ev.min()>=-2e-5 and np.max(np.abs(C@x-b))<2e-5),
 64            'margin':float(ev.min()),'residual':float(np.max(np.abs(C@x-b))),
 65            'status':'alternating-projection'}
 66
 67def normpow(n,k):
 68    return power({tuple(2 if i==j else 0 for i in range(n)):1. for j in range(n)},k)
 69
 70def shifted_motzkin(eps):
 71    # Motzkin form plus eps*||x||_2^6; coefficients are accumulated explicitly.
 72    p={(4,2,0):1.,(2,4,0):1.,(0,0,6):1.,(2,2,2):-3.}
 73    return poly_add(p,normpow(3,3),scale=eps)
 74
 75def threshold(n,r):
 76    # Coarse threshold scan avoids expensive repeated nonlinear solves.
 77    grid=[0.,.05,.15,.35,1.0]
 78    vals=[]
 79    for e in grid:
 80      rec=solve_sos(shifted_motzkin(e),n,r,tries=1)
 81      vals.append((e,rec['margin']))
 82      if rec['margin']>=-2e-5: return e,rec['margin']
 83    return grid[-1],vals[-1][1]
 84
 85def rollout_boundary():
 86    # scalar z+=gamma*a*z; assess stability from 250-step bounded rollout.
 87    rows=[]
 88    for a in [0.5,0.8,1.0,1.2,1.5]:
 89      g=np.linspace(.1,2.2/a,401); empirical=[]
 90      for gamma in g:
 91        z=1.;
 92        for _ in range(250): z*=gamma*a
 93        empirical.append(np.isfinite(z) and abs(z)<=1.0001)
 94      stable=[x for x,ok in zip(g,empirical) if ok]
 95      last=max(stable,default=0.)
 96      rows.append({'a':a,'predicted_gamma':1/a,'observed_gamma':float(last),
 97                   'relative_error':abs(last-1/a)/(1/a)})
 98    return rows
 99
100def main():
101    # Mechanism test: Motzkin is nonnegative, but not level-0 SOS; multiplier levels improve margin.
102    levels=[]
103    for r in range(2):
104      rec=solve_sos(shifted_motzkin(0.),3,r,tries=1); levels.append({'r':r,**rec})
105    thresholds=[]
106    for r in range(2):
107      th,mg=threshold(3,r); thresholds.append({'r':r,'epsilon':th,'margin':mg})
108    boundary=rollout_boundary()
109    result={'levels':levels,'thresholds':thresholds,'boundary':boundary,
110      'predictions':{'threshold_monotone':all(thresholds[i]['epsilon']>=thresholds[i+1]['epsilon']-2e-3 for i in range(len(thresholds)-1)),
111                     'level_improves':levels[-1]['margin']>levels[0]['margin']+1e-4,
112                     'boundary_relative_error_max':max(abs(x['observed_gamma']-x['predicted_gamma'])/x['predicted_gamma'] for x in boundary)}}
113    open('results.json','w').write(json.dumps(result,indent=2))
114    print(json.dumps(result,indent=2))
115if __name__=='__main__': main()