Observer-Corrected Robust Optimizer / observer_optimizer_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1"""Observer-Corrected Robust Optimizer: scalar quadratic MVP.
  2
  3For f(theta)=lambda*theta and disturbed gradient lambda*theta+d, the
  4feedforward controller applies -d_hat, hence
  5 theta_next = theta - h*(lambda*theta + d - d_hat).
  6The measured transition identifies d exactly in this toy plant.  d_hat is
  7then an EMA, which is the minimal discrete state observer for slowly varying
  8bias.  We test: (1) rho=|1-h*lambda| stability boundary, (2) EMA residual
  9frequency response, and (3) correction versus ordinary SGD on a biased
 10quadratic.
 11"""
 12import json
 13import pathlib
 14import numpy as np
 15
 16SEED = 1330
 17
 18
 19def spectral_radius(h, lam):
 20    return abs(1.0 - h * lam)
 21
 22
 23def ema_residual_ratio(alpha, omega):
 24    """Predicted RMS ratio of applied residual d-d_hat for sinusoid d."""
 25    a = 1.0 - alpha
 26    # d_hat[k] = alpha*d[k-1] + a*d_hat[k-1]; applied estimate is old d_hat.
 27    # Transfer from d[k] to d[k]-d_hat[k] has this exact magnitude.
 28    z = np.exp(-1j * omega)
 29    hhat = alpha * z / (1.0 - a * z)
 30    return float(abs(1.0 - hhat))
 31
 32
 33def run_observer(lam, h, alpha, disturbance, theta0=1.0):
 34    theta, d_hat = float(theta0), 0.0
 35    thetas, dhats, applied_residuals, estimates = [], [], [], []
 36    for d in disturbance:
 37        d = float(d)
 38        # Correction cancels the estimated component of gradient disturbance.
 39        applied_residuals.append(d - d_hat)
 40        theta_next = theta - h * (lam * theta + d - d_hat)
 41        # Transition residual: d_obs = -(delta)/h-lambda*theta+d_hat.
 42        d_obs = -(theta_next - theta) / h - lam * theta + d_hat
 43        d_hat = (1.0 - alpha) * d_hat + alpha * d_obs
 44        theta, thetas = theta_next, thetas + [theta_next]
 45        dhats.append(d_hat)
 46        estimates.append(d_obs)
 47    return {"theta": np.asarray(thetas), "dhat": np.asarray(dhats),
 48            "applied_residual": np.asarray(applied_residuals),
 49            "d_obs": np.asarray(estimates)}
 50
 51
 52def run_baseline(lam, h, disturbance, theta0=1.0):
 53    theta, vals = float(theta0), []
 54    for d in disturbance:
 55        theta = theta - h * (lam * theta + float(d))
 56        vals.append(theta)
 57    return np.asarray(vals)
 58
 59
 60def stability_sweep(lam):
 61    hs = np.linspace(0.02, 2.35 / lam, 240)
 62    stable = []
 63    for h in hs:
 64        x = run_baseline(lam, h, np.zeros(300))[-1]
 65        stable.append(spectral_radius(h, lam) < 1.0 and abs(x) < 1e8)
 66    last = hs[np.where(stable)[0][-1]]
 67    first = hs[np.where(~np.asarray(stable))[0][0]]
 68    predicted = 2.0 / lam
 69    observed = 0.5 * (last + first)
 70    return {"lambda": lam, "predicted_h_critical": predicted,
 71            "observed_h_critical": float(observed),
 72            "relative_error": float(abs(observed-predicted)/predicted),
 73            "bracket": [float(last), float(first)]}
 74
 75
 76def disturbance_sweep(lam=3.0, h=None):
 77    if h is None: h = 0.35 / lam
 78    n, burn = 3000, 1000
 79    t = np.arange(n)
 80    cases = {"constant": (np.ones(n), 0.0),
 81             "slow_sine": (np.sin(2*np.pi*t/300.0), 2*np.pi/300.0),
 82             # Non-grid frequency avoids the accidental zero of sin(pi*k).
 83             "high_sine": (np.sin(2*np.pi*t/2.3), 2*np.pi/2.3)}
 84    rows = []
 85    for name, (d, omega) in cases.items():
 86        base = run_baseline(lam, h, d)
 87        input_rms = float(np.sqrt(np.mean(d[burn:]**2)))
 88        for alpha in [0.05, 0.2, 0.5, 0.9, 1.0]:
 89            out = run_observer(lam, h, alpha, d)
 90            actual = float(np.sqrt(np.mean(out["applied_residual"][burn:]**2)))
 91            pred = 0.0 if omega == 0 else ema_residual_ratio(alpha, omega)
 92            rows.append({"case": name, "alpha": alpha,
 93              "input_rms": input_rms, "residual_rms": actual,
 94              "residual_ratio": actual/input_rms,
 95              "predicted_ratio": pred,
 96              "ratio_relative_error": (None if omega == 0 else abs(actual/input_rms-pred)/pred),
 97              "baseline_theta_rms": float(np.sqrt(np.mean(base[burn:]**2))),
 98              "observer_theta_rms": float(np.sqrt(np.mean(out["theta"][burn:]**2)))})
 99    return rows
100
101
102def main():
103    np.random.seed(SEED)
104    stability = [stability_sweep(lam) for lam in [0.5, 1.0, 3.0, 10.0]]
105    rows = disturbance_sweep()
106    headline = {}
107    for case, alpha in [("constant", .5), ("slow_sine", .5),
108                        ("high_sine", .05), ("high_sine", 1.0)]:
109        headline[f"{case}_alpha_{alpha}"] = next(r for r in rows if r["case"] == case and r["alpha"] == alpha)
110    summary = {"seed": SEED,
111      "predictions": {
112        "P1_stability": "h_critical=2/lambda from rho=|1-h*lambda|=1",
113        "P2_frequency_response": "applied residual/input RMS follows |1-alpha exp(-iw)/(1-(1-alpha)exp(-iw))|",
114        "P3_slow_bias": "constant/slow residual is strongly reduced, while high-frequency residual remains or grows"},
115      "stability": stability, "disturbance_rows": rows, "headline": headline}
116    pathlib.Path("observer_results.json").write_text(json.dumps(summary, indent=2))
117    print(json.dumps(summary, indent=2))
118
119if __name__ == "__main__": main()