Observer-Corrected Robust Optimizer / observer_optimizer_experiment.py
Mechanism confirmed, baseline not beaten
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()