SOS Backup Shield for Learned Policies / sos_shield_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1from pathlib import Path
  2import json, math, random, sys
  3import numpy as np
  4import torch
  5from torch import nn
  6sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
  7from bench import train_model, evaluate, sweep_baseline, make_report
  8
  9META = {"name": "sos_backup_shield_policy", "domain": "dynamics", "description": "Torque-limited pendulum action regression with a learned policy wrapped by a quadratic finite-horizon backup shield."}
 10
 11# Polynomial pendulum-like local model: theta_dot=omega, omega_dot=u.
 12# This is the explicit quadratic certificate used as the finite-horizon SOS-style MVP.
 13P = np.array([[1.25, .25], [.25, .375]], dtype=np.float64)
 14U_MAX = 1.0
 15TH_MAX, OM_MAX = 1.4, 2.0
 16CERT_RADIUS = 0.12
 17
 18
 19def math_check():
 20    A = np.array([[0., 1.], [-2., -2.]])
 21    Q = -(A.T @ P + P @ A)
 22    identity_error = float(np.max(np.abs(Q - np.eye(2))))
 23    lam = float(np.max(np.linalg.eigvalsh(P)))
 24    # Boundary samples of V=1, where -Vdot/V should be >= 1/lambda_max(P).
 25    vals = []
 26    max_u = 0.
 27    for a in np.linspace(0, 2*np.pi, 4096, endpoint=False):
 28        z = np.array([np.cos(a), np.sin(a)])
 29        x = CERT_RADIUS * z / np.sqrt(z @ P @ z)
 30        vals.append(float(x @ Q @ x))
 31        max_u = max(max_u, abs(-2*x[0] - 2*x[1]))
 32    observed = min(vals) / (CERT_RADIUS**2)
 33    predicted = 1.0 / lam
 34    return {"identity_error": identity_error, "observed_min_neg_vdot_over_V": observed,
 35            "predicted_bound": predicted, "max_backup_u_on_V1": max_u,
 36            "certificate_actuator_feasible": bool(max_u <= U_MAX)}
 37
 38
 39def get_dataset(seed, n_train=400, n_test=400):
 40    rng = np.random.RandomState(seed)
 41    def make(n):
 42        th = rng.uniform(-1.35, 1.35, n)
 43        om = rng.uniform(-2.0, 2.0, n)
 44        # Stabilizing target action, clipped to the actuator polytope.
 45        u = np.clip(-1.8 * th - 0.65 * om, -1., 1.)
 46        return np.stack([th, om], 1).astype("float32"), u[:, None].astype("float32")
 47    xtr, ytr = make(n_train); xte, yte = make(n_test)
 48    return {"xtr": xtr, "ytr": ytr, "xte": xte, "yte": yte,
 49            "task": "regression", "metric": "mse", "out_dim": 1,
 50            "input_shape": (2,)}
 51
 52
 53def seed_all(seed):
 54    random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
 55
 56
 57def tensors(d):
 58    return {**d, **{k: torch.as_tensor(d[k]) for k in ("xtr", "ytr", "xte", "yte")}}
 59
 60
 61class Policy(nn.Module):
 62    """Same base MLP for both systems; shield is the sole intervention."""
 63    def __init__(self, shield=False, margin=0.0):
 64        super().__init__()
 65        self.shield, self.margin = shield, float(margin)
 66        self.net = nn.Sequential(nn.Linear(2, 32), nn.Tanh(), nn.Linear(32, 32), nn.Tanh(), nn.Linear(32, 1))
 67
 68    def forward(self, x):
 69        nominal = self.net(x)
 70        if not self.shield:
 71            return torch.clamp(nominal, -U_MAX, U_MAX)
 72        # h=TH_MAX^2-theta^2; hdot+alpha h >= 0 gives admissible interval.
 73        th, om = x[:, 0:1], x[:, 1:2]
 74        h = TH_MAX**2 - th.square()
 75        alpha = 0.8
 76        # hdot=-2 theta omega; hdot + alpha*h + 2 theta*u >= margin.
 77        # For theta near zero, use the Lyapunov backup endpoint directly.
 78        lo = (self.margin + 2*th*om - alpha*h) / (2*th + 1e-6)
 79        hi = lo
 80        # Solve interval robustly by evaluating the affine barrier inequality.
 81        rhs = self.margin + 2*th*om - alpha*h
 82        pos_lo = rhs / (2*th + 1e-6)
 83        # inequality 2 theta u >= rhs; interval endpoint is lower for theta>0,
 84        # upper for theta<0. Pair with backup action and certificate gate.
 85        backup = torch.clamp(-2.0*th - 2.0*om, -U_MAX, U_MAX)
 86        feasible = (th.abs() > 0.04) & (rhs.abs() <= 2*th.abs()*U_MAX)
 87        safe_nom = torch.clamp(nominal, -U_MAX, U_MAX)
 88        # project toward the safe side, then choose backup outside the certificate.
 89        projected = torch.where(th > 0, torch.maximum(safe_nom, pos_lo), torch.minimum(safe_nom, pos_lo))
 90        V = 1.25*th.square() + .5*th*om + .375*om.square()
 91        inside = V <= CERT_RADIUS**2
 92        return torch.clamp(torch.where(feasible & inside, projected, backup), -U_MAX, U_MAX)
 93
 94
 95def train_metric(seed, cfg, keep=False):
 96    seed_all(seed); d = get_dataset(seed)
 97    model = Policy(shield=cfg.get("margin", 0.) > 0, margin=cfg.get("margin", 0.))
 98    model, metric, hist = train_model(model, tensors(d), epochs=cfg["epochs"], lr=cfg["lr"], batch=128, log=lambda *_: None)
 99    return (metric, model, d) if keep else metric
100
101
102def train_fn(cfg):
103    return lambda seed: train_metric(seed, cfg)
104
105
106def mechanism_signature(cfg, seeds=tuple(range(8))):
107    # Measured on outputs of trained models, not an analytic-only calculation.
108    shifts, dissipation, interventions, accepted = [], [], [], []
109    for s in seeds:
110        _, model, d = train_metric(s, cfg, keep=True)
111        model = model.cpu()
112        model.eval(); x = torch.as_tensor(d["xte"])
113        with torch.no_grad():
114            nominal = model.net(x); shielded = model(x)
115        th, om = x[:,0:1], x[:,1:2]
116        V = 1.25*th.square()+.5*th*om+.375*om.square()
117        u = shielded
118        # Euler certificate derivative for actual trained shield output.
119        vdot = th*om + (0.5*th + .75*om)*u
120        backup = torch.clamp(-2*th-2*om, -1., 1.)
121        shifts.append(float(torch.mean(torch.abs(shielded-nominal)).item()))
122        dissipation.append(float(torch.mean(vdot[V <= CERT_RADIUS**2]).item()))
123        interventions.append(float(torch.mean(torch.abs(shielded-backup)).item()))
124        accepted.append(float(torch.mean(((V <= CERT_RADIUS**2) & (torch.abs(shielded-nominal)<1e-5)).float()).item()))
125    # Prediction: inside the certified set the shield should be non-increasing;
126    # observed mean derivative should be non-positive on trained-model outputs.
127    obs = float(np.mean(dissipation))
128    return {"prediction": {"certificate_vdot_mean_on_inside": "<=0", "backup_intervention_nonzero": True},
129            "observed_from_trained_models": {"mean_vdot_inside": obs, "mean_action_shift": float(np.mean(shifts)),
130              "mean_distance_to_backup": float(np.mean(interventions)), "mean_nominal_acceptance": float(np.mean(accepted)), "n_models": len(seeds)},
131            "confirmed": bool(obs <= 1e-6 and float(np.mean(interventions)) > 1e-4)}
132
133
134def main():
135    check = math_check()
136    print(json.dumps({"math_check": check}))
137    if check["identity_error"] > 1e-10 or not check["certificate_actuator_feasible"]:
138        raise RuntimeError("certificate sanity check failed")
139    epochs = 15
140    lrs = [1e-3, 3e-3, 6e-3]
141    # Union parity: baseline sees every lr used by idea; method knob margin is swept
142    # on the idea side and zero-margin standard clipping is baseline.
143    base_grid = [{"lr": lr, "margin": 0.0, "epochs": epochs} for lr in lrs]
144    idea_grid = [{"lr": lr, "margin": margin, "epochs": epochs} for lr, margin in zip(lrs, [0.02, 0.05, 0.10])]
145    base = sweep_baseline(train_fn, base_grid)
146    idea_sweep = [{"cfg": c, "mean": evaluate(train_fn(c), seeds=(0,1,2,3))["mean"]} for c in idea_grid]
147    best = min(idea_sweep, key=lambda z: z["mean"])["cfg"]
148    idea = evaluate(train_fn(best))
149    report = make_report("sos_backup_shield_policy", "local_mlp_tiny", base, idea,
150                         mechanism_signature(best))
151    report["idea"]["selection_sweep"] = idea_sweep
152    report["custom_track"] = {"name": META["name"], "file": "sos_shield_bench.py", "domain": "dynamics"}
153    report["math_check"] = check
154    Path("bench_report.json").write_text(json.dumps(report, indent=2))
155    print(json.dumps(report, indent=2))
156
157if __name__ == "__main__": main()