Excitation-Gated Neural Calibration / excitation_gated_calibration.py
Mechanism confirmed, baseline not beaten
1"""Toy verification of excitation-gated neural calibration.
2
3The calibration model is the paper's local linearization:
4 y_j = t + psi * S u_j + noise,
5where t is an unknown translation nuisance and S is the 90-degree rotation.
6After eliminating t, Fisher information for psi is S_u / sigma^2.
7"""
8import csv
9import json
10from pathlib import Path
11import numpy as np
12
13SEED = 2192
14RNG = np.random.default_rng(SEED)
15
16
17def centered_spread(u):
18 u = np.asarray(u, dtype=float)
19 return float(np.sum((u - u.mean(axis=0)) ** 2))
20
21
22def fisher_calibration(u, sigma):
23 """Jacobian Fisher matrix for [translation_x, translation_y, yaw]."""
24 u = np.asarray(u, dtype=float)
25 J = np.zeros((len(u), 2, 3))
26 J[:, :, :2] = np.eye(2)
27 J[:, :, 2] = np.stack((-u[:, 1], u[:, 0]), axis=1)
28 F = np.einsum("nai,nbj->ij", J, J) / (sigma * sigma)
29 return F, J
30
31
32def estimate_yaw(u, sigma, trials=1000, rng=None):
33 """OLS estimate and empirical variance; translation is fitted, not known."""
34 if rng is None:
35 rng = np.random.default_rng(0)
36 u = np.asarray(u, dtype=float)
37 # Design rows for two coordinates: y = tx,ty + psi*S*u.
38 X = np.zeros((2 * len(u), 3))
39 for k, (ux, uy) in enumerate(u):
40 X[2*k:2*k+2] = [[1, 0, -uy], [0, 1, ux]]
41 true = np.array([0.37, -0.22, 0.15])
42 pinv = np.linalg.pinv(X)
43 estimates = []
44 for _ in range(trials):
45 estimates.append(pinv @ (X @ true + rng.normal(0, sigma, 2 * len(u))))
46 estimates = np.asarray(estimates)
47 return float(np.var(estimates[:, 2], ddof=1)), float(np.mean(estimates[:, 2]))
48
49
50def inverse_information_sweep():
51 sigma = 0.08
52 L = 20
53 rows = []
54 # Amplitude controls spread while keeping the window shape fixed.
55 for amp in [0.25, 0.5, 1.0, 2.0, 4.0]:
56 x = np.linspace(-1, 1, L)[:, None]
57 u = np.concatenate([amp * x, np.zeros_like(x)], axis=1)
58 spread = centered_spread(u)
59 empirical, _ = estimate_yaw(u, sigma, trials=1200, rng=np.random.default_rng(SEED + int(amp*10)))
60 predicted = sigma * sigma / spread
61 rows.append({"amplitude": amp, "spread": spread, "fisher": spread/sigma**2,
62 "empirical_var": empirical, "predicted_var": predicted,
63 "ratio_empirical_to_predicted": empirical/predicted})
64 return rows
65
66
67def threshold_sweep():
68 sigma = 0.08
69 L = 20
70 epsilon = 0.10
71 gamma = epsilon ** -2
72 required_spread = gamma * sigma**2
73 rows = []
74 for amp in np.linspace(0.15, 1.50, 10):
75 x = np.linspace(-1, 1, L)[:, None]
76 u = np.concatenate([amp*x, np.zeros_like(x)], axis=1)
77 spread = centered_spread(u)
78 F, _ = fisher_calibration(u, sigma)
79 # The 3x3 F contains translation gauge; its calibration Schur complement
80 # is precisely the yaw information. This is the scalar certificate here.
81 yaw_info = spread / sigma**2
82 rows.append({"amplitude": float(amp), "spread": spread, "yaw_fisher": yaw_info,
83 "certified": bool(yaw_info >= gamma),
84 "predicted_certified": bool(spread >= required_spread),
85 "lambda_min_calibration_schur": yaw_info})
86 return {"epsilon": epsilon, "gamma": gamma, "required_spread": required_spread, "rows": rows}
87
88
89def rolling_window_sweep():
90 """Verify that certification is forgotten exactly as old excitation leaves."""
91 sigma = 0.08
92 L = 20
93 gamma = 100.0
94 # First 20 samples have spread 20 (information 3125), then constant input.
95 x = np.linspace(-1, 1, L)
96 stream = np.r_[x, np.zeros(40)]
97 cert = []
98 infos = []
99 for k in range(len(stream)):
100 window = stream[max(0, k-L+1):k+1, None]
101 info = centered_spread(window) / sigma**2 if len(window) > 1 else 0.0
102 infos.append(info)
103 cert.append(info >= gamma)
104 first_cert = next((i for i, c in enumerate(cert) if c), None)
105 first_loss = next((i for i in range(first_cert or 0, len(cert)) if not cert[i]), None)
106 # Predicted loss: once the initial spread exits, at k=L (zero-based).
107 return {"window": L, "first_certified_step": first_cert,
108 "observed_loss_step": first_loss, "predicted_loss_step": 2 * L - 1,
109 "information_at_peak": max(infos), "information_tail": infos[-1]}
110
111
112def acquisition_comparison():
113 """Compare decaying exploration with feedback-gated, alternating probes.
114
115 Alternating signs make the centered spread nonzero while e remains
116 orthogonal to the nominal task direction [1, 0]. Certification is checked
117 on every rolling window, so the gated controller re-excites after forgetting.
118 """
119 sigma, L, gamma, steps, trials = 0.08, 20, 100.0, 80, 300
120 outcomes = {"fixed_decay": [], "gated": []}
121 for trial in range(trials):
122 for method in outcomes:
123 us = []
124 first_cert = None
125 certified_count = 0
126 for k in range(steps):
127 recent = np.asarray(us[-L:])
128 info = centered_spread(recent) / sigma**2 if len(recent) > 1 else 0.0
129 certified = info >= gamma
130 if certified:
131 certified_count += 1
132 if first_cert is None:
133 first_cert = k
134 # e alternates sign, preserving task direction while creating spread.
135 if method == "gated" and not certified:
136 amp = 0.65
137 elif method == "fixed_decay":
138 amp = 0.65 * np.exp(-k / 3.0)
139 else:
140 amp = 0.0
141 us.append([1.0, amp * (1.0 if k % 2 == 0 else -1.0)])
142 outcomes[method].append({"first_cert": first_cert,
143 "certified_at_40": first_cert is not None and first_cert < 40,
144 "certified_fraction": certified_count / steps})
145 return {k: {"certification_rate_by_step_40": float(np.mean([x["certified_at_40"] for x in v])),
146 "median_first_certification": float(np.nanmedian([x["first_cert"] if x["first_cert"] is not None else np.nan for x in v])),
147 "mean_certified_fraction": float(np.mean([x["certified_fraction"] for x in v])),
148 "trials": trials} for k, v in outcomes.items()}
149
150
151def main():
152 result = {"seed": SEED, "inverse_information": inverse_information_sweep(),
153 "threshold": threshold_sweep(), "rolling_window": rolling_window_sweep(),
154 "acquisition": acquisition_comparison()}
155 Path("results.json").write_text(json.dumps(result, indent=2))
156 with open("inverse_information.csv", "w", newline="") as f:
157 rows = result["inverse_information"]
158 writer = csv.DictWriter(f, fieldnames=rows[0].keys())
159 writer.writeheader(); writer.writerows(rows)
160 print(json.dumps(result, indent=2))
161
162
163if __name__ == "__main__":
164 main()