codexkalman-local-level-tracking.py
kalman-local-level-tracking.py
"""Illustrate Kalman filtering under a stated, seeded local-level model.
Run from the desired output directory. The generated record is synthetic;
the plot is a mechanism illustration, not an empirical accuracy study.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def filter_record(observations, process_variance, measurement_variance=1.0):
mean, variance = 10.0, 4.0
means, variances, gains = [], [], []
for observation in observations:
predicted_variance = variance + process_variance
gain = predicted_variance / (predicted_variance + measurement_variance)
mean += gain * (observation - mean)
variance = (1 - gain) * predicted_variance
means.append(mean)
variances.append(variance)
gains.append(gain)
return np.array(means), np.array(variances), np.array(gains)
def main():
plt.rcParams.update({"font.size": 11, "axes.titlesize": 13})
rng = np.random.default_rng(20261012)
count, process_variance, measurement_variance = 60, 0.25, 1.0
initial_state = rng.normal(10.0, 2.0)
states = initial_state + np.cumsum(
rng.normal(0.0, np.sqrt(process_variance), count)
)
observations = states + rng.normal(0.0, np.sqrt(measurement_variance), count)
means, variances, gains = filter_record(observations, process_variance)
_, _, static_gains = filter_record(observations, 0.0)
time = np.arange(1, count + 1)
steady_gain = (np.sqrt(17) - 1) / 8
fig, axes = plt.subplots(2, 1, figsize=(11.8, 7.0), dpi=100, sharex=True,
gridspec_kw={"height_ratios": [1.55, 1]})
ax = axes[0]
ax.fill_between(time, means - 2 * np.sqrt(variances),
means + 2 * np.sqrt(variances),
color="#2867a0", alpha=0.15, label="Posterior mean ± 2 SD")
ax.plot(time, states, color="#333333", linewidth=1.9,
label="Synthetic unobserved state")
ax.scatter(time, observations, s=18, color="#a66026", alpha=0.65,
label="Noisy observations")
ax.plot(time, means, color="#2867a0", linewidth=2.2,
label="Filtered posterior mean")
ax.set_title("New process noise adds uncertainty at every transition")
ax.set_ylabel("Scalar state (illustrative units)")
ax.legend(loc="upper left", ncol=2, fontsize=9.5, framealpha=0.95)
ax.grid(alpha=0.18)
ax = axes[1]
ax.plot(time, gains, color="#2867a0", linewidth=2.3, label="Moving-state model: Q = 1/4")
ax.plot(time, static_gains, color="#a66026", linestyle="--", linewidth=2.0,
label="Constant-state assumption: Q = 0")
ax.axhline(steady_gain, color="#555555", linestyle=":", linewidth=1.4)
ax.text(39, steady_gain + 0.035, f"Steady gain = {steady_gain:.6f}",
fontsize=10, color="#333333")
ax.set_ylim(0, 0.90)
ax.set_xlim(0.3, count + 0.7)
ax.set_ylabel("Kalman gain")
ax.set_xlabel("Observation index t")
ax.grid(alpha=0.18)
ax.legend(loc="upper right", fontsize=9.5)
fig.suptitle("A changing state keeps the filter receptive to new measurements",
fontsize=15, y=0.985)
fig.text(0.5, 0.025,
"Seeded Gaussian random walk; x₀ ~ N(10, 4), Q = 1/4, R = 1. "
"One illustrative record, not measured data.",
ha="center", fontsize=10)
fig.subplots_adjust(left=0.075, right=0.98, top=0.90, bottom=0.12, hspace=0.24)
fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"),
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()