Lyapunov-Budgeted Neural MPPI / lyapunov_mppi_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json, math, os
  2import numpy as np
  3from scipy.linalg import solve_discrete_are
  4
  5SEED = 7
  6rng_global = np.random.default_rng(SEED)
  7
  8# Small unstable LTI plant. The controller knows this model; process noise is external.
  9A = np.array([[1.10, 0.10], [0.00, 0.95]])
 10B = np.array([[0.0], [0.10]])
 11Q = np.diag([1.0, 0.2])
 12R = np.array([[0.08]])
 13P = solve_discrete_are(A, B, Q, R)
 14K = np.linalg.solve(R + B.T @ P @ B, B.T @ P @ A)
 15Acl = A - B @ K
 16D = P - Acl.T @ P @ Acl
 17alpha_P = np.min(np.linalg.eigvalsh(D))
 18
 19# A small learned residual surrogate: deliberately useful near the origin but imperfect.
 20def residual(x):
 21    return np.array([0.18 * np.tanh(1.5*x[0]) - 0.03*x[1]])
 22
 23def lqr(x):
 24    return (-K @ x).reshape(1)
 25
 26def rollout_model(x, u):
 27    return A @ x + (B @ np.asarray(u).reshape(1))[..., 0]
 28
 29def mppi_action(x, M, lam, H=8, sigma=0.55, rng=None):
 30    # Nominal residual policy around LQR, with receding-horizon sampled corrections.
 31    if rng is None: rng = np.random.default_rng()
 32    unom = float(lqr(x)[0] + residual(x)[0])
 33    # Constant nominal sequence is sufficient for the tiny sanity test.
 34    eps = rng.normal(0.0, sigma, size=(M, H))
 35    us = unom + eps
 36    xs = np.repeat(x[None, :], M, axis=0)
 37    costs = np.zeros(M)
 38    for t in range(H):
 39        costs += np.einsum('bi,ij,bj->b', xs, Q, xs) + R[0,0] * us[:,t]**2
 40        xs = xs @ A.T + us[:,t,None] * B.T
 41    costs += np.einsum('bi,ij,bj->b', xs, P, xs)
 42    # numerically stable importance weights
 43    z = -(costs - costs.min()) / lam
 44    w = np.exp(np.clip(z, -80, 0))
 45    sw = w.sum() + 1e-12
 46    vals = us[:,0]
 47    uhat = float(np.dot(w, vals) / sw)
 48    var = float(np.dot(w, (vals-uhat)**2) / sw)
 49    ess = float(sw*sw / (np.dot(w,w) + 1e-12))
 50    se = math.sqrt(max(var, 0.0) / (ess + 1e-12))
 51    return uhat, se, ess, unom
 52
 53def budgeted_action(x, lam, M0=16, Mmax=128, c=0.45, q=0.35, rng=None):
 54    if rng is None: rng = np.random.default_rng()
 55    # Conservative sample scaling from the supplied corollary (constant hidden factor omitted).
 56    scale = (np.linalg.norm(P)**2 * np.linalg.norm(B)**2 * np.linalg.norm(Acl)**2 /
 57             (alpha_P**2)) * math.log(2.0 / 0.05)
 58    # Start small, then spend computation if the estimated perturbation exceeds budget.
 59    M = M0
 60    attempts = 0
 61    while True:
 62        uhat, se, ess, unom = mppi_action(x, M, lam, rng=rng)
 63        ehat = se + abs(uhat-unom)*q
 64        budget = c * alpha_P * np.linalg.norm(x)
 65        accepted = np.linalg.norm(B[:,0]) * abs(ehat) <= budget
 66        attempts += 1
 67        if accepted or M >= Mmax:
 68            # At Mmax, safety takes precedence over refinement.
 69            if not accepted:
 70                return float(lqr(x)[0]), True, M, attempts, se, ess, scale
 71            return uhat, False, M, attempts, se, ess, scale
 72        M *= 2
 73
 74def one_episode(kind, rng, T=100, lam=0.35, fixed_M=64):
 75    x = np.array([1.8, 0.0]) + rng.normal(0, .05, 2)
 76    total = 0.; unstable = False; fallbacks = 0; samples = 0; sevals=[]
 77    for _ in range(T):
 78        if kind == 'lqr':
 79            u = float(lqr(x)[0]); Mused = 0
 80        elif kind == 'residual':
 81            u = float(lqr(x)[0] + residual(x)[0]); Mused = 0
 82        elif kind == 'fixed':
 83            u, se, _, _ = mppi_action(x, fixed_M, lam, rng=rng); Mused=fixed_M; sevals.append(se)
 84        else:
 85            u, fb, Mused, _, se, _, _ = budgeted_action(x, lam, rng=rng)
 86            fallbacks += int(fb); sevals.append(se)
 87        u = float(np.clip(u, -5, 5))
 88        total += float(x @ Q @ x + R[0,0]*u*u)
 89        x = A @ x + B[:,0]*u + rng.normal(0, .035, 2)
 90        samples += Mused
 91        if np.linalg.norm(x) > 8 or not np.all(np.isfinite(x)):
 92            unstable=True; total += 5000.; break
 93    return total, unstable, fallbacks, samples, np.mean(sevals) if sevals else 0.
 94
 95def scaling_check():
 96    # Repeated independent MPPI estimates at one state; report RMS error to a high-M reference.
 97    x=np.array([.2, -.05]); lam=.35
 98    ref=np.mean([mppi_action(x, 4096, lam, rng=np.random.default_rng(10000+i))[0] for i in range(20)])
 99    rows=[]
100    for M in [16,32,64,128,256]:
101        vals=np.array([mppi_action(x,M,lam,rng=np.random.default_rng(20000+100*M+j))[0]
102                       for j in range(30)])
103        rms=float(np.sqrt(np.mean((vals-ref)**2)))
104        rows.append((M,rms))
105    slope=float(np.polyfit(np.log([r[0] for r in rows]), np.log([r[1] for r in rows]), 1)[0])
106    return {'reference_action':float(ref), 'rows':rows, 'loglog_slope':slope}
107
108def main():
109    print('geometry', json.dumps({'P_norm':float(np.linalg.norm(P)), 'K':K.tolist(),
110      'Acl':Acl.tolist(), 'alpha_P':float(alpha_P),
111      'rho_Acl':float(max(abs(np.linalg.eigvals(Acl))))}, sort_keys=True))
112    scaling=scaling_check(); print('scaling',json.dumps(scaling))
113    n=40; results={}
114    for kind in ['lqr','residual','fixed','budgeted']:
115        vals=[]
116        for i in range(n): vals.append(one_episode(kind,np.random.default_rng(5000+i),lam=.35))
117        arr=np.array(vals)
118        results[kind]={'mean_cost':float(arr[:,0].mean()), 'std_cost':float(arr[:,0].std()),
119          'escape_rate':float(arr[:,1].mean()), 'fallback_rate_per_step':float(arr[:,2].sum()/(n*100)),
120          'mean_rollout_samples_per_step':float(arr[:,3].mean()/100), 'mean_action_se':float(arr[:,4].mean())}
121    # Temperature sweep for fixed and budgeted, same states/noise seeds per temperature.
122    temps={}
123    for lam in [.15,.35,.7]:
124        for kind in ['fixed','budgeted']:
125            vals=[one_episode(kind,np.random.default_rng(9000+i),lam=lam) for i in range(25)]
126            a=np.array(vals); temps[kind+str(lam)]={'cost':float(a[:,0].mean()),'escape':float(a[:,1].mean()),'samples':float(a[:,3].mean()/100)}
127    out={'geometry':{'P':P.tolist(),'K':K.tolist(),'Acl':Acl.tolist(),'alpha_P':float(alpha_P)},
128         'corollary_scale_without_hidden_constant':float((np.linalg.norm(P)**2*np.linalg.norm(B)**2*np.linalg.norm(Acl)**2/alpha_P**2)*math.log(2/.05)),
129         'scaling_check':scaling,'episodes':results,'temperature_sweep':temps}
130    with open('results.json','w') as f: json.dump(out,f,indent=2)
131    print(json.dumps(out,indent=2))
132if __name__=='__main__': main()