Adaptive Zonotope Safety Shield / adaptive_shield_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json
  2import math
  3import random
  4from dataclasses import dataclass
  5import numpy as np
  6
  7SEED = 2753
  8np.random.seed(SEED)
  9random.seed(SEED)
 10
 11@dataclass
 12class AdaptiveDisturbance:
 13    c: np.ndarray
 14    q: np.ndarray
 15    alpha: float = 0.25
 16    beta: float = 0.10
 17    eps: float = 0.005
 18
 19    def update(self, residuals):
 20        residuals = np.asarray(residuals, dtype=float).reshape(-1, self.c.size)
 21        med = np.median(residuals, axis=0)
 22        # The specified update uses the newly updated center in the radius.
 23        self.c = (1.0 - self.alpha) * self.c + self.alpha * med
 24        observed = np.max(np.abs(residuals - self.c), axis=0) + self.eps
 25        self.q = np.maximum((1.0 - self.beta) * self.q, observed)
 26        return self.c.copy(), self.q.copy()
 27
 28
 29def propagate_zonotope(A, B, x_center, x_generators, u, d, w_center, w_generators):
 30    """Exact affine zonotope propagation in generator form."""
 31    c = A @ x_center + B @ u + d + w_center
 32    G = np.concatenate((A @ x_generators, w_generators), axis=1)
 33    return c, G
 34
 35
 36def box_contains(c, G, lower, upper):
 37    radius = np.sum(np.abs(G), axis=1)
 38    return bool(np.all(c - radius >= lower - 1e-12) and np.all(c + radius <= upper + 1e-12))
 39
 40
 41def interval_prediction_check():
 42    # For an affine zonotope and axis-aligned box, support in coordinate j is
 43    # c_j +/- sum_k |G_jk|; this is both an over-approximation and exact.
 44    rng = np.random.default_rng(SEED)
 45    errors = []
 46    disagreements = 0
 47    for _ in range(3000):
 48        n, p = 3, 7
 49        c = rng.normal(size=n)
 50        G = rng.normal(size=(n, p))
 51        lo = c - np.sum(np.abs(G), axis=1) - rng.uniform(0, 1, n)
 52        hi = c + np.sum(np.abs(G), axis=1) + rng.uniform(0, 1, n)
 53        predicted = np.maximum(np.abs(G), 0).sum(axis=1)
 54        # Enumerating vertices is exact for each coordinate support; sample
 55        # random signs as an independent numerical check.
 56        signs = rng.choice([-1.0, 1.0], size=(1000, p))
 57        sampled = np.max(c[None, :] + signs @ G.T, axis=0)
 58        sampled_min = np.min(c[None, :] + signs @ G.T, axis=0)
 59        errors.append(float(np.max(np.maximum(sampled - (c + predicted),
 60                                              (c - predicted) - sampled_min))))
 61        a = box_contains(c, G, lo, hi)
 62        b = bool(np.all(c - predicted >= lo - 1e-12) and np.all(c + predicted <= hi + 1e-12))
 63        disagreements += int(a != b)
 64    return {"max_sample_support_gap": max(errors), "containment_disagreements": disagreements,
 65            "cases": 3000}
 66
 67
 68def stationary_scaling_sweep():
 69    eps = 0.005
 70    amp_list = [0.02, 0.05, 0.10, 0.20, 0.40]
 71    rows = []
 72    for amp in amp_list:
 73        est = AdaptiveDisturbance(np.zeros(1), np.zeros(1), alpha=0.25, beta=0.10, eps=eps)
 74        rng = np.random.default_rng(SEED + int(1000 * amp))
 75        # Batch maxima make the intended q update visible without assuming
 76        # an unobserved true bound.
 77        for _ in range(40):
 78            residuals = rng.uniform(-amp, amp, size=(64, 1))
 79            est.update(residuals)
 80        heldout = rng.uniform(-amp, amp, size=(10000, 1))
 81        covered = np.abs(heldout[:, 0] - est.c[0]) <= est.q[0]
 82        predicted = amp + eps
 83        rows.append({"amplitude": amp, "predicted_q": predicted,
 84                     "observed_q": float(est.q[0]),
 85                     "q_error": float(est.q[0] - predicted),
 86                     "coverage": float(np.mean(covered))})
 87    max_error = max(abs(r["q_error"]) for r in rows)
 88    # With finite batches q can be below amp+eps, but should remain close and
 89    # coverage should be high; report rather than silently changing the rule.
 90    return {"rows": rows, "max_abs_q_prediction_error": max_error,
 91            "minimum_coverage": min(r["coverage"] for r in rows)}
 92
 93
 94def jump_recovery_check():
 95    eps = 0.005
 96    est = AdaptiveDisturbance(np.zeros(1), np.zeros(1), alpha=0.25, beta=0.10, eps=eps)
 97    rng = np.random.default_rng(SEED + 99)
 98    for _ in range(12):
 99        est.update(rng.uniform(-0.10, 0.10, size=(64, 1)))
100    before = float(est.q[0])
101    new_amp = 0.30
102    residuals = rng.uniform(-new_amp, new_amp, size=(64, 1))
103    est.update(residuals)
104    after = float(est.q[0])
105    # The max rule predicts expansion in the first batch containing the jump.
106    coverage = float(np.mean(np.abs(residuals[:, 0] - est.c[0]) <= est.q[0]))
107    return {"old_amplitude": 0.10, "new_amplitude": new_amp,
108            "q_before_jump": before, "q_after_one_batch": after,
109            "predicted_min_after_batch": new_amp + eps - 0.03,
110            "jump_batch_coverage": coverage,
111            "expanded_in_one_batch": after > before}
112
113
114def point_mass_comparison(episodes=250, horizon=40):
115    """Tiny shield comparison; rejected proposals use zero acceleration backup."""
116    rng = np.random.default_rng(SEED + 7)
117    dt = 1.0
118    pos_limit, vel_limit, u_limit = 1.0, 1.5, 0.45
119    true_amp = 0.12
120    fixed_q = np.array([0.0, true_amp + 0.005])
121    stats = {"fixed": [0, 0], "adaptive": [0, 0]} # rejects, violations
122    for method in stats:
123        for ep in range(episodes):
124            x = np.array([0.0, 0.0])
125            est = AdaptiveDisturbance(np.zeros(2), np.array([0.0, 0.35]), alpha=.25, beta=.10, eps=.005)
126            history = []
127            for t in range(horizon):
128                # Mildly aggressive policy, making the shield relevant.
129                proposed = np.clip(-0.55*x[0] - 0.25*x[1] + rng.normal(0, .08), -u_limit, u_limit)
130                A = np.array([[1., dt], [0., 1.]])
131                B = np.array([[0.], [dt]])
132                # Current point state, disturbance zonotope in acceleration.
133                q = fixed_q if method == "fixed" else est.q
134                wc = np.zeros(2) if method == "fixed" else est.c
135                W = np.diag(np.asarray(q, dtype=float).reshape(-1))
136                c, G = propagate_zonotope(A, B, x, np.zeros((2, 0)), np.array([proposed]),
137                                           np.zeros(2), wc, W)
138                safe = box_contains(c, G, np.array([-pos_limit, -vel_limit]),
139                                    np.array([pos_limit, vel_limit]))
140                u = proposed if safe else 0.0
141                stats[method][0] += int(not safe)
142                w = np.array([0., rng.uniform(-true_amp, true_amp)])
143                xn = (A @ np.asarray(x).reshape(2) + B[:, 0] * float(u) + w).reshape(2)
144                stats[method][1] += int(abs(xn[0]) > pos_limit or abs(xn[1]) > vel_limit)
145                if method == "adaptive":
146                    residual = xn - (A @ x + B * u)
147                    history.append(residual)
148                    if len(history) >= 8:
149                        est.update(np.array(history[-8:]))
150                x = xn
151    denom = episodes * horizon
152    return {m: {"rejection_rate": v[0] / denom, "violation_rate": v[1] / denom}
153            for m, v in stats.items()}
154
155
156def main():
157    result = {"seed": SEED,
158              "math_check": interval_prediction_check(),
159              "stationary_scaling": stationary_scaling_sweep(),
160              "jump_recovery": jump_recovery_check(),
161              "shield_comparison": point_mass_comparison()}
162    print(json.dumps(result, indent=2))
163
164if __name__ == "__main__":
165    main()