Barrier-Certified Neural Policy Training / barrier_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json, math, random
  2from pathlib import Path
  3import numpy as np
  4import torch
  5import torch.nn as nn
  6
  7SEED = 2081
  8random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
  9torch.set_num_threads(4)
 10
 11# Plant: xdot = u, safe set h(x)=x >= 0. Thus r(x)=u(x)+alpha*x.
 12ALPHA = 1.0
 13EPS = 0.08
 14
 15class Policy(nn.Module):
 16    def __init__(self):
 17        super().__init__()
 18        self.net = nn.Sequential(nn.Linear(1, 24), nn.Tanh(), nn.Linear(24, 24), nn.Tanh(), nn.Linear(24, 1))
 19    def forward(self, x):
 20        return 1.5 * torch.tanh(self.net(x))
 21
 22def certification_sweep():
 23    # For each uniform delta-net, construct a smooth residual that equals EPS
 24    # at net points and dips between them.  r=EPS+A(cos(2*pi*x/s)-1),
 25    # s=2*delta and A=delta/pi.  Its true Lipschitz constant is 1 and
 26    # the dense gap is exactly 2*delta/pi: a direct O(delta) prediction.
 27    dense = np.linspace(0, 1, 200001)
 28    rows=[]
 29    for n in [3, 5, 9, 17, 33, 65, 129]:
 30        s = 1/(n-1)
 31        delta = s/2
 32        A = delta/np.pi
 33        r_dense = EPS + A*(np.cos(2*np.pi*dense/s)-1)
 34        grid = np.linspace(0, 1, n)
 35        r_grid = EPS + A*(np.cos(2*np.pi*grid/s)-1)
 36        L = 1.0
 37        actual_min = float(r_dense.min())
 38        gap = float(EPS - actual_min)
 39        bound = EPS - L*delta
 40        rows.append(dict(n=n, delta=delta, L=L, predicted_lower=bound,
 41                         actual_min=actual_min, observed_gap=max(0.0,gap),
 42                         positive_certificate=(bound > 0), actual_safe=(actual_min >= 0)))
 43    x = np.linspace(0,1,1001)
 44    # Check the claimed global Lipschitz inequality numerically.
 45    max_pair_excess = 0.0
 46    for n in [3, 5, 9, 17, 33]:
 47        s=1/(n-1); A=(s/2)/np.pi
 48        r=EPS+A*(np.cos(2*np.pi*x/s)-1)
 49        for i in range(0,1001,7):
 50            for j in range(0,1001,11):
 51                max_pair_excess=max(max_pair_excess, abs(r[i]-r[j])-abs(x[i]-x[j]))
 52    bound_ok = all(row['actual_min'] + 1e-7 >= row['predicted_lower'] for row in rows)
 53    ratios = [row['observed_gap']/row['delta'] for row in rows]
 54    scaling_ok = max(abs(q-2/np.pi) for q in ratios) < 2e-4
 55    ds=np.array([q['delta'] for q in rows]); gs=np.array([q['observed_gap'] for q in rows])
 56    slope=float(np.polyfit(np.log(ds), np.log(gs), 1)[0])
 57    predicted_boundary=EPS/1.0
 58    observed_boundary=max(q['delta'] for q in rows if q['actual_safe'])
 59    return dict(L=1.0, rows=rows, predicted_boundary_delta=predicted_boundary,
 60                observed_safe_boundary_delta=observed_boundary,
 61                pairwise_lipschitz_excess=max_pair_excess,
 62                prediction_checks={'lower_bound_holds':bound_ok,
 63                                   'gap_ratio_predicted':2/np.pi,
 64                                   'gap_ratio_max_error':max(abs(q-2/np.pi) for q in ratios),
 65                                   'observed_loglog_slope':slope,
 66                                   'predicted_slope':1.0,
 67                                   'boundary_prediction_conservative': observed_boundary >= predicted_boundary,
 68                                   'all_checks': bool(bound_ok and scaling_ok and max_pair_excess <= 1e-8) },
 69                observed_gap_over_delta=ratios)
 70
 71def rollout(policy, x0, horizon=40, dt=0.05):
 72    x=x0
 73    xs=[]; us=[]; rs=[]
 74    for _ in range(horizon):
 75        x.requires_grad_(True)
 76        u=policy(x)
 77        # h=x, grad h=1, so residual is u+alpha*x.
 78        r=u + ALPHA*x
 79        xs.append(x); us.append(u); rs.append(r)
 80        x=x + dt*u
 81    return torch.cat(xs), torch.cat(us), torch.cat(rs)
 82
 83def train(use_barrier, steps=700):
 84    torch.manual_seed(SEED + (1 if use_barrier else 0))
 85    p=Policy()
 86    opt=torch.optim.Adam(p.parameters(), lr=3e-3)
 87    x0=torch.tensor([[0.20]])
 88    for _ in range(steps):
 89        xs,us,rs=rollout(p,x0)
 90        # Deliberately unsafe target demonstrates safety/task tradeoff.
 91        task=(xs[-1] - (-0.45)).pow(2) + 0.01*us.pow(2).mean()
 92        barrier=torch.relu(EPS-rs).pow(2).mean()
 93        loss=task + (1000.0*barrier if use_barrier else 0.0)
 94        opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(p.parameters(), 10.0); opt.step()
 95    with torch.no_grad():
 96        xs,us,rs=rollout(p,x0)
 97        return {'final_x':float(xs[-1]), 'min_x':float(xs.min()), 'min_r':float(rs.min()),
 98                'task_terminal_error':float((xs[-1]+0.45).abs()),
 99                'barrier_violation_fraction':float((rs<EPS).float().mean()),
100                'dense_unseen_min_r':float((p(torch.linspace(0,1,2001).reshape(-1,1)).flatten()+ALPHA*torch.linspace(0,1,2001)).min()),
101                'policy':p}
102
103def main():
104    cert=certification_sweep()
105    base=train(False); idea=train(True)
106    # remove model object from serialization
107    base.pop('policy'); idea.pop('policy')
108    out={'seed':SEED,'system':'xdot=u, h=x, alpha=1', 'epsilon':EPS,
109         'certificate':cert, 'training':{'baseline_task_only':base,'barrier_certified':idea}}
110    Path('results.json').write_text(json.dumps(out, indent=2))
111    print(json.dumps(out, indent=2))
112
113if __name__=='__main__': main()