Safe Receding-Horizon Neural Topology Switching / safe_switch.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1"""MVP for safe receding-horizon topology switching.
  2
  3The toy has correlated features x2=x1+noise.  The deployed model uses both
  4features with weights (1,1), while the target topology drops feature 2 and
  5uses target weight (2,0).  The endpoint is accurate, but immediately after
  6changing the mask, retaining the old active weight gives a large error.
  7"""
  8import json
  9import math
 10from dataclasses import dataclass
 11import numpy as np
 12
 13SEED = 217
 14
 15@dataclass
 16class Toy:
 17    x1: np.ndarray
 18    x2: np.ndarray
 19    y: np.ndarray
 20
 21    @property
 22    def a(self):
 23        # residual after topology switch and interpolation alpha:
 24        # (1+alpha)x1 - (x1+x2) = alpha*x1-x2
 25        return float(np.mean(self.x1 * self.x1))
 26    @property
 27    def b(self):
 28        return float(np.mean(self.x1 * self.x2))
 29    @property
 30    def c(self):
 31        return float(np.mean(self.x2 * self.x2))
 32    def loss_alpha(self, alpha):
 33        return self.a * alpha * alpha - 2*self.b*alpha + self.c
 34    def old_loss(self):
 35        return float(np.mean((self.x1 + self.x2 - self.y)**2))
 36    def target_loss(self):
 37        return self.loss_alpha(1.0)
 38    def predicted_root(self, eps):
 39        # smallest alpha in [0,1] with loss_alpha <= eps
 40        # positive root of a alpha^2 - 2b alpha + c-eps=0
 41        d = self.b*self.b - self.a*(self.c-eps)
 42        if d < 0: return None
 43        roots = [(self.b-math.sqrt(max(0,d)))/self.a,
 44                 (self.b+math.sqrt(max(0,d)))/self.a]
 45        feasible = [r for r in roots if 0 <= r <= 1]
 46        if self.loss_alpha(0) <= eps: return 0.0
 47        return min(feasible) if feasible else float('inf')
 48
 49def make_toy(noise, n=20000):
 50    rng = np.random.default_rng(SEED + int(round(noise*1000)))
 51    x1 = rng.normal(size=n)
 52    x2 = x1 + noise*rng.normal(size=n)
 53    return Toy(x1, x2, x1+x2)
 54
 55def certify(toy, alpha, eps):
 56    return toy.loss_alpha(alpha) - toy.old_loss() <= eps + 1e-12
 57
 58def filtered_switch(toy, eps, candidates):
 59    rejected = []
 60    for alpha in candidates:
 61        if certify(toy, alpha, eps):
 62            return alpha, rejected
 63        rejected.append(alpha)
 64    return None, rejected
 65
 66def run():
 67    # Core math verification: sweep noise and tolerance, compare quadratic root
 68    # to the observed first accepted planner candidate.
 69    rows = []
 70    candidate_grid = np.linspace(0.0, 1.0, 101)
 71    for noise in [0.05, 0.10, 0.20, 0.35]:
 72        toy = make_toy(noise)
 73        for eps in [0.10, 0.25, 0.50]:
 74            root = toy.predicted_root(eps)
 75            observed, rej = filtered_switch(toy, eps, candidate_grid)
 76            rows.append({
 77                'noise': noise, 'epsilon': eps,
 78                'predicted_alpha_min': root,
 79                'observed_alpha_min_grid': observed,
 80                'grid_error': None if observed is None or root is None else observed-root,
 81                'rejected_candidates': len(rej),
 82                'endpoint_loss': toy.target_loss(),
 83                'initial_new_mask_loss': toy.loss_alpha(0),
 84            })
 85
 86    # Prediction 1: residual follows the derived quadratic to numerical precision.
 87    toy = make_toy(0.20)
 88    alphas = np.linspace(0,1,51)
 89    empirical = np.array([toy.loss_alpha(a) for a in alphas])
 90    formula = toy.a*alphas**2 - 2*toy.b*alphas + toy.c
 91    quadratic_max_abs_error = float(np.max(np.abs(empirical-formula)))
 92
 93    # Prediction 2: larger tolerance monotonically lowers the required transition.
 94    roots = [toy.predicted_root(e) for e in [0.10,0.25,0.50]]
 95    tolerance_monotone = bool(all(roots[i] >= roots[i+1] for i in range(2)))
 96
 97    # Baselines at the same candidate schedule. One-shot has an unsafe
 98    # intermediate (alpha=0 under new mask); unfiltered gradual schedule also
 99    # violates until its first safe alpha. The filter executes only safe alpha.
100    eps = 0.25
101    one_shot = make_toy(0.20)
102    gradual = np.linspace(0,1,11)
103    unfiltered_violations = int(sum(not certify(one_shot,a,eps) for a in gradual))
104    accepted, rejected = filtered_switch(one_shot, eps, gradual)
105    filtered_violations = 0 if accepted is not None and certify(one_shot, accepted, eps) else 1
106
107    # Cut reuse: without cuts, each replan retries all unsafe candidates;
108    # with cuts, rejected alpha values are remembered and not evaluated again.
109    unsafe = [float(a) for a in gradual if not certify(one_shot,a,eps)]
110    replans = 5
111    no_cut_evals = replans * len(unsafe)
112    with_cut_evals = len(unsafe)
113
114    out = {
115        'seed': SEED,
116        'quadratic_max_abs_error': quadratic_max_abs_error,
117        'predicted_vs_observed': rows,
118        'prediction_checks': {
119            'quadratic_formula_exact': quadratic_max_abs_error < 1e-12,
120            'tolerance_lowers_required_alpha': tolerance_monotone,
121            'cut_reuse_reduces_rejected_evaluations': with_cut_evals < no_cut_evals,
122        },
123        'comparison': {
124            'epsilon': eps,
125            'one_shot_endpoint_loss': one_shot.target_loss(),
126            'one_shot_intermediate_violations': 1 if not certify(one_shot,0,eps) else 0,
127            'unfiltered_gradual_violations_of_11': unfiltered_violations,
128            'filtered_first_accepted_alpha': accepted,
129            'filtered_rejected_before_accept': len(rejected),
130            'filtered_accepted_step_violations': filtered_violations,
131            'cut_reuse_evaluations_over_5_replans': {'without_cuts': no_cut_evals, 'with_cuts': with_cut_evals},
132        },
133    }
134    print(json.dumps(out, indent=2, sort_keys=True))
135
136if __name__ == '__main__':
137    run()