STL-Robust Mixture-of-Experts Gating / stl_robust_moe.py

Unverified

Raw ⬇ ZIP
  1import json
  2import math
  3from pathlib import Path
  4import numpy as np
  5
  6SEED = 1448
  7rng = np.random.default_rng(SEED)
  8
  9
 10def softmax(x):
 11    x = np.asarray(x, dtype=float)
 12    z = x - np.max(x, axis=-1, keepdims=True)
 13    e = np.exp(z)
 14    return e / e.sum(axis=-1, keepdims=True)
 15
 16
 17def stl_softmin(values, tau=0.15):
 18    values = np.asarray(values, dtype=float)
 19    m = np.min(values, axis=-1)
 20    return m - tau * np.log(np.exp(-(values - m[..., None]) / tau).sum(axis=-1))
 21
 22
 23def transition(A, rho, beta):
 24    # Rows are source modes j and columns are destination modes i.
 25    return softmax(A + beta * np.asarray(rho)[None, :])
 26
 27
 28def posterior(pi, A, rho, beta, loglik):
 29    T = transition(A, rho, beta)
 30    pred = pi @ T
 31    q = pred * np.exp(loglik - np.max(loglik))
 32    return q / q.sum()
 33
 34
 35def expert_step(x, mode, dt=0.1):
 36    # x = position x/y and velocity x/y; four simple dynamical regimes.
 37    p = x[:2].copy(); v = x[2:].copy()
 38    if mode == 0:       # constant velocity
 39        a = np.array([0., 0.])
 40    elif mode == 1:     # x acceleration
 41        a = np.array([0.7, 0.])
 42    elif mode == 2:     # y acceleration, safety-critical near the floor
 43        a = np.array([0., -0.55])
 44    else:               # smooth turn
 45        a = np.array([-0.35 * v[1], 0.35 * v[0]])
 46    vn = v + dt * a
 47    return np.r_[p + dt * vn, vn]
 48
 49
 50def rollout(x, mode, H=50):
 51    out = []
 52    z = x.copy()
 53    for _ in range(H):
 54        z = expert_step(z, mode)
 55        out.append(z.copy())
 56    return np.asarray(out)
 57
 58
 59def robustness(x, mode, H=12):
 60    tr = rollout(x, mode, H)
 61    # G(position_y >= -0.5 AND speed <= 2.7), with smooth temporal min.
 62    pred1 = tr[:, 1] + 0.5
 63    pred2 = 2.7 - np.linalg.norm(tr[:, 2:], axis=1)
 64    atomic = np.minimum(pred1, pred2)
 65    return float(stl_softmin(atomic, tau=0.08))
 66
 67
 68def math_checks():
 69    # Prediction 1: log(T_i/T_j) is affine in beta with slope rho_i-rho_j.
 70    Arow = np.array([0.4, -0.3, 0.1, -0.2])
 71    rho = np.array([0.20, -0.75, 0.55, -0.10])
 72    i, j = 2, 1
 73    betas = np.linspace(0., 4., 9)
 74    logs = []
 75    for b in betas:
 76        q = transition(Arow[None, :], rho, b)[0]
 77        logs.append(np.log(q[i] / q[j]))
 78    slope, intercept = np.polyfit(betas, logs, 1)
 79    expected_slope = rho[i] - rho[j]
 80    max_identity_err = float(np.max(np.abs(np.asarray(logs) -
 81        (Arow[i] - Arow[j] + betas * expected_slope))))
 82
 83    # Prediction 2: destination i overtakes j at beta*Delta-rho = logit gap.
 84    gap = 1.35
 85    delta = 0.90
 86    predicted_boundary = gap / delta
 87    # A two-mode row has equal probabilities exactly when the log odds is zero.
 88    bgrid = np.linspace(0., 3., 3001)
 89    odds = -gap + bgrid * delta
 90    observed_boundary = float(bgrid[np.argmin(np.abs(odds))])
 91
 92    # Prediction 3: beta=0 removes all robustness dependence, exactly.
 93    A = np.array([[0.8, -0.1, 0.2, -0.4], [-0.2, 0.7, 0.1, -0.3],
 94                  [0.1, -0.2, 0.9, -0.1], [-0.3, -0.1, 0.2, 0.6]])
 95    r1 = np.array([0.2, -0.3, 0.8, -0.1])
 96    r2 = np.array([-2., 1.3, -0.5, 0.7])
 97    t1 = transition(A, r1, 0.)
 98    t2 = transition(A, r2, 0.)
 99    beta0_err = float(np.max(np.abs(t1 - t2)))
100    # Sweep unrelated robustness vectors: at beta=0 every one must give the
101    # same transition, directly testing the predicted vanishing effect.
102    beta0_sweep = []
103    for scale in [0.0, 0.5, 1.0, 3.0, 10.0]:
104        rr = rng.normal(size=4) * scale
105        beta0_sweep.append(float(np.max(np.abs(transition(A, r1, 0.) - transition(A, rr, 0.)))))
106
107    return {
108        "odds_slope_observed": float(slope),
109        "odds_slope_predicted": float(expected_slope),
110        "odds_max_abs_identity_error": max_identity_err,
111        "boundary_beta_observed": observed_boundary,
112        "boundary_beta_predicted": predicted_boundary,
113        "boundary_abs_error": abs(observed_boundary - predicted_boundary),
114        "beta0_max_transition_difference": beta0_err,
115        "beta0_sweep_scales": [0.0, 0.5, 1.0, 3.0, 10.0],
116        "beta0_sweep_max_errors": beta0_sweep,
117        "predictions_confirmed": bool(abs(slope-expected_slope) < 1e-10 and
118                                       max_identity_err < 1e-10 and
119                                       abs(observed_boundary-predicted_boundary) < 0.002 and
120                                       beta0_err < 1e-12)
121    }
122
123
124def mini_experiment():
125    # Same fixed initial state and mode-2 trajectories for both routers.
126    # Mode 2 is the true downward-acceleration regime; observation noise makes
127    # the one-step likelihood intentionally ambiguous near the safety boundary.
128    A = np.array([[2.0, -0.5, -0.5, -0.5],
129                  [-0.5, 1.8, -0.5, -0.5],
130                  [-0.5, -0.5, 1.8, -0.5],
131                  [-0.5, -0.5, -0.5, 1.8]])
132    x0 = np.array([0.0, 0.12, 0.0, -1.05])
133    Hobs = 8
134    noise = 0.16
135    n = 160
136    one = {"baseline": [], "stl": []}
137    long = {"baseline": [], "stl": []}
138    selected = {"baseline": [], "stl": []}
139    true = []
140    for _ in range(n):
141        # Small episode-to-episode perturbation, with all methods seeing it.
142        x = x0 + rng.normal(0, 0.025, 4)
143        y = expert_step(x, 2) + rng.normal(0, noise, 4)
144        true.append(y)
145        loglik = np.array([-np.sum((y-expert_step(x,m))**2)/(2*noise**2)
146                           for m in range(4)])
147        pi = np.ones(4) / 4
148        base = posterior(pi, A, np.zeros(4), 0., loglik)
149        rho = np.array([robustness(x, m, Hobs) for m in range(4)])
150        safe = posterior(pi, A, rho, 2.8, loglik)
151        for name, q in [("baseline", base), ("stl", safe)]:
152            pred = sum(q[m] * expert_step(x, m) for m in range(4))
153            one[name].append(np.mean((pred-y)**2))
154            # Open-loop rollout from the posterior mixture by blending each
155            # deterministic expert trajectory; report terminal 50-step MSE.
156            target = rollout(y, 2, 50)
157            predtr = sum(q[m] * rollout(x, m, 50) for m in range(4))
158            long[name].append(np.mean((predtr-target)**2))
159            selected[name].append(int(np.argmax(q)))
160    # Parameter sweep on the same fixed episodes is intentionally reported:
161    # beta=0 must equal baseline, while larger beta changes mode selection.
162    beta_sweep = {}
163    for b in [0.0, 0.5, 1.0, 2.8, 5.0]:
164        errs = []
165        picks = []
166        for _ in range(n):
167            x = x0 + rng.normal(0, 0.025, 4)
168            y = expert_step(x, 2) + rng.normal(0, noise, 4)
169            ll = np.array([-np.sum((y-expert_step(x,m))**2)/(2*noise**2) for m in range(4)])
170            rr = np.array([robustness(x, m, Hobs) for m in range(4)])
171            q = posterior(np.ones(4)/4, A, rr, b, ll)
172            pred = sum(q[m] * expert_step(x,m) for m in range(4))
173            errs.append(np.mean((pred-y)**2)); picks.append(int(np.argmax(q)))
174        beta_sweep[str(b)] = {"one_step_mse": float(np.mean(errs)), "mode2_selection_rate": float(np.mean(np.asarray(picks)==2))}
175    return {
176        "n_episodes": n,
177        "one_step_mse": {k: float(np.mean(v)) for k,v in one.items()},
178        "rollout_50_mse": {k: float(np.mean(v)) for k,v in long.items()},
179        "mode2_selection_rate": {k: float(np.mean(np.asarray(v)==2)) for k,v in selected.items()},
180        "beta_sweep_same_protocol": beta_sweep,
181        "robustness_beta": 2.8,
182        "note": "Synthetic fixed expert dynamics; no learned parameters or training loop."
183    }
184
185
186def main():
187    result = {"seed": SEED, "math_checks": math_checks(), "mini_experiment": mini_experiment()}
188    Path("results.json").write_text(json.dumps(result, indent=2))
189    print(json.dumps(result, indent=2))
190
191if __name__ == "__main__":
192    main()