IMM Stale-Feedback Detector / imm_experiment.py

Failed on benchmark

Raw ⬇ ZIP
  1import json
  2import math
  3import numpy as np
  4
  5# IMM stale-feedback detector for the known scalar linear process
  6# z[t+1] = a*z[t] + w[t], y[t] = z[t-delay] + v[t].
  7# In this verification, the process state history is known to isolate the
  8# paper's exact likelihood/posterior mechanism from online system-ID error.
  9
 10def trial(delay_true, noise, a=0.85, q=0.04, D=4, n=1200, seed=0,
 11          persistence=0.985, threshold=0.01):
 12    rng = np.random.default_rng(seed)
 13    z = np.zeros(n + D + 1)
 14    # Stationary-ish initialization avoids an artificial transient.
 15    z[0] = rng.normal(0, math.sqrt(q / (1 - a*a)))
 16    for t in range(n + D):
 17        z[t + 1] = a * z[t] + rng.normal(0, math.sqrt(q))
 18    y = np.array([z[D + t - delay_true] + rng.normal(0, math.sqrt(noise))
 19                  for t in range(n)])
 20
 21    m = D + 1
 22    T = np.full((m, m), (1 - persistence) / (m - 1))
 23    np.fill_diagonal(T, persistence)
 24    pi = np.ones(m) / m
 25    posterior = []
 26    logodds = []
 27    kl = []
 28    llr = []
 29    norm_error = []
 30    alarm = None
 31    run = 0
 32    for t in range(n):
 33        # IMM mode mixing prior c_i = sum_j T_ji*pi_j.
 34        c = T.T @ pi
 35        means = z[D + t - np.arange(m)]
 36        ll = -0.5 * ((y[t] - means)**2 / noise + math.log(2 * math.pi * noise))
 37        un = c * np.exp(ll - np.max(ll))
 38        pi = un / un.sum()
 39        posterior.append(pi.copy())
 40        norm_error.append(abs(pi.sum() - 1.0))
 41        logodds.append(math.log(max(pi[delay_true], 1e-300) /
 42                              max(pi[0], 1e-300)))
 43        # Equal-variance Gaussian innovation KL for true vs no-delay mode.
 44        delta = means[delay_true] - means[0]
 45        kl.append(delta * delta / (2 * noise))
 46        # Raw cumulative innovation log-likelihood ratio avoids posterior saturation.
 47        llr.append(((y[t] - means[0])**2 - (y[t] - means[delay_true])**2) / (2 * noise))
 48        run = run + 1 if pi[0] <= threshold else 0
 49        if alarm is None and run >= 3:
 50            alarm = t + 1
 51
 52    posterior = np.asarray(posterior)
 53    logodds = np.asarray(logodds)
 54    kl = np.asarray(kl)
 55    llr = np.asarray(llr)
 56    start = 10
 57    # Use an early/mid window before numerical posterior saturation.
 58    stop = min(n, 250)
 59    slope = float(np.polyfit(np.arange(start, stop), logodds[start:stop], 1)[0])
 60    cumulative_llr = np.cumsum(llr)
 61    llr_slope = float(np.polyfit(np.arange(start, stop), cumulative_llr[start:stop], 1)[0])
 62    mean_kl = float(np.mean(kl[start:stop]))
 63    dom = next((i + 1 for i, p in enumerate(posterior)
 64                if p[delay_true] > .5 and p[delay_true] > p[0]), None)
 65    return dict(slope=slope, llr_slope=llr_slope, mean_kl=mean_kl, ratio=llr_slope / max(mean_kl, 1e-12),
 66                alarm=alarm, dominant=dom, final_true=float(posterior[-1, delay_true]),
 67                final_zero=float(posterior[-1, 0]), max_norm_error=max(norm_error))
 68
 69
 70def aggregate(delay, noise, reps=30, **kw):
 71    vals = [trial(delay, noise, seed=7000 + k, **kw) for k in range(reps)]
 72    def med(k):
 73        x = [v[k] for v in vals if v[k] is not None]
 74        return None if not x else float(np.median(x))
 75    return {"delay": delay, "noise": noise, "slope": med("slope"),
 76            "mean_KL": med("mean_kl"), "cumulative_llr_slope": med("llr_slope"), "slope_over_KL": med("ratio"),
 77            "alarm_median": med("alarm"), "dominant_median": med("dominant"),
 78            "final_true_posterior": med("final_true"),
 79            "final_zero_posterior": med("final_zero"),
 80            "max_normalization_error": max(v["max_norm_error"] for v in vals)}
 81
 82
 83def main():
 84    # Prediction 1: persistent delay separates from no-delay.
 85    delayed = aggregate(2, .03)
 86    clean = aggregate(0, .03)
 87    # Prediction 2: expected log posterior-odds slope is the innovation KL.
 88    kl_sweep = [aggregate(2, r) for r in (.015, .03, .06, .12)]
 89    # Prediction 3: lower KL takes longer to cross the fixed alarm threshold.
 90    detect_sweep = [aggregate(2, r, reps=20) for r in (.015, .03, .06, .12)]
 91    out = {
 92      "prediction_checks": {
 93        "P1_mode_separation": {
 94          "prediction": "persistent d=2 gives p(d=2)>0.5 while clean d=0 retains p(d=0)",
 95          "delayed": delayed, "clean": clean},
 96        "P2_KL_slope": {
 97          "prediction": "early cumulative innovation log-likelihood slope approximately equals mean innovation KL",
 98          "noise_sweep": kl_sweep},
 99        "P3_detection_scaling": {
100          "prediction": "alarm time increases as KL decreases (larger observation noise)",
101          "noise_sweep": detect_sweep},
102        "P4_normalization": {
103          "prediction": "posterior sums to one to numerical precision",
104          "max_abs_sum_error": max(x["max_normalization_error"] for x in [delayed, clean] + kl_sweep)}
105      },
106      "settings": {"a": .85, "q": .04, "D": 4, "persistence": .985,
107                   "steps": 1200, "threshold": .01, "replicates": 30,
108                   "likelihood": "exact Gaussian innovation with known state history"}
109    }
110    print(json.dumps(out, indent=2))
111
112if __name__ == '__main__':
113    main()