Kalman Filter Tuning Guide
Overview
This guide explains how to tune Kalman filters for your tracking problem. Proper tuning is critical for filter performance - incorrect noise covariances lead to divergence, missed detections, or excessive smoothing.
Key Sections:
Noise Covariance Estimation - How to set Q and R
Initialization - Starting state and covariance selection
Tuning Strategies - Systematic approaches
Diagnostics - How to detect filter issues
Common Problems - Recognition and solutions
Noise Covariance Fundamentals
Process Noise (Q):
Controls how much we expect the target state to deviate from the motion model.
Too Small (Q → 0): Filter trusts model too much, lags behind maneuvers
Too Large (Q → ∞): Filter trusts model too little, noisy estimates
Measurement Noise (R):
Characterizes sensor measurement accuracy.
Too Small (R → 0): Filter trusts sensors too much, jerky tracking
Too Large (R → ∞): Filter ignores measurements, smooth but inaccurate
Rule of Thumb:
For a constant velocity model driven by white acceleration noise with standard
deviation sigma_a, the discrete process noise is
per spatial dimension. Note the \(\sigma_a^2\) factor: Q is a covariance,
so the noise variance scales it, not the standard deviation. Use the shipped
builders from pytcl.dynamic_models instead of hand-rolling this:
import numpy as np
from pytcl.dynamic_models import q_constant_velocity
dt = 1.0
sigma_a = 0.5 # acceleration noise std [m/s^2]
# State [x, vx]
Q = q_constant_velocity(T=dt, sigma_a=sigma_a, num_dims=1)
print(Q)
# [[0.0625 0.125 ]
# [0.125 0.25 ]]
# R: measurement noise covariance (sensor accuracy)
# If sensor has +/-2.5 m (1-sigma) accuracy:
R = np.array([[2.5**2]])
q_constant_acceleration, q_singer, and q_coord_turn_2d cover
higher-order and maneuvering models.
Estimation Methods
Method 1: From Datasheets
Use manufacturer specifications:
# GPS accuracy: +/-5 meters 95% confidence (~2 sigma)
# So 1 sigma ~ 2.5 meters
gps_accuracy = 2.5 # meters
R_gps = np.diag([
gps_accuracy**2, # X position
gps_accuracy**2, # Y position
])
Method 2: From Historic Data
Analyze residuals between true state and measurements, e.g. from a calibration run against a surveyed target:
rng = np.random.default_rng(7)
true_range = 50.0 # surveyed target position [m]
z_measured = true_range + rng.normal(0.0, 2.5, size=2000)
residuals = z_measured - true_range
# Estimate R from measurement variance
R = np.atleast_2d(np.var(residuals))
print(f"Estimated R: {R[0, 0]:.3f} (true value 2.5**2 = 6.25)")
# Estimated R: 6.060 (true value 2.5**2 = 6.25)
Method 3: Adaptive Estimation
Inflate R online when the innovation is implausibly large under the current
model (a chi-squared test on the normalized innovation squared). Built from
the real kf_predict / kf_update in pytcl.dynamic_estimation.kalman:
from pytcl.assignment_algorithms import chi2_gate_threshold
from pytcl.dynamic_estimation.kalman import kf_predict, kf_update
from pytcl.dynamic_models import f_constant_velocity
def adaptive_r_step(x, P, z, F, Q, H, R, inflate=10.0):
"""One KF cycle; inflates R when the innovation is an outlier."""
pred = kf_predict(x, P, F, Q)
y = z - H @ pred.x
S = H @ pred.P @ H.T + R
nis_value = float(y @ np.linalg.inv(S) @ y)
if nis_value > chi2_gate_threshold(0.99, len(y)):
R_used = R * inflate # distrust this measurement
else:
R_used = R
upd = kf_update(pred.x, pred.P, z, H, R_used)
return upd.x, upd.P, R_used
dt = 1.0
F = f_constant_velocity(dt, num_dims=1) # state [x, vx]
Q = q_constant_velocity(dt, sigma_a=0.5, num_dims=1)
H = np.array([[1.0, 0.0]])
R = np.array([[6.25]])
rng = np.random.default_rng(0)
z_track = 10.0 * np.arange(100) + rng.normal(0.0, 2.5, size=100)
z_track[[30, 60]] += 40.0 # two multipath spikes
# Initialize position and velocity from the first two measurements
x = np.array([z_track[1], (z_track[1] - z_track[0]) / dt])
P = np.diag([6.25, 12.5])
inflated_at = []
for k, z in enumerate(z_track[2:], start=2):
x, P, R_used = adaptive_r_step(x, P, np.atleast_1d(z), F, Q, H, R)
if R_used[0, 0] > R[0, 0]:
inflated_at.append(k)
print(f"R inflated at steps: {inflated_at}")
# R inflated at steps: [30, 60]
print(f"Final state: {np.round(x, 2)}")
# Final state: [987. 9.16]
Initialization
Initial State (x0):
Start with best estimate of target position and velocity:
# Option 1: Use first measurement, unknown velocity
x0 = np.array([
z_track[0], # initial position from first measurement
0.0, # initial velocity (unknown)
])
# Option 2: Use two measurements to estimate velocity
x0 = np.array([
z_track[0],
(z_track[1] - z_track[0]) / dt, # velocity estimate
])
print(np.round(x0, 2))
# [0.31 9.36]
Initial Covariance (P0):
Reflects uncertainty in initial state:
# High uncertainty in velocity (we don't know it yet)
P0 = np.diag([
10.0, # position uncertainty: +/-sqrt(10) ~ +/-3.2 m
100.0, # velocity uncertainty: +/-sqrt(100) = +/-10 m/s
])
# Conservative: square of initial position uncertainty
# If measurement has +/-5 m accuracy:
pos_uncertainty = 5.0
P0 = np.diag([pos_uncertainty**2, 1000.0])
Systematic Tuning Strategy
Step 1: Characterize Measurements
# Analyze measurement noise (calibration data from Method 2)
print(f"Mean: {np.mean(residuals):.3f}")
print(f"Std Dev: {np.std(residuals):.3f}")
print(f"RMS: {np.sqrt(np.mean(residuals**2)):.3f}")
# Mean: -0.100
# Std Dev: 2.462
# RMS: 2.464
# Look for outliers (> 3 sigma)
outliers = np.abs(residuals) > 3 * np.std(residuals)
print(f"Outliers: {np.sum(outliers)} / {len(residuals)}")
# Outliers: 2 / 2000
Step 2: Start Conservative
Begin with high process noise (trusts measurements) and low measurement noise (trusts sensor):
dt = 0.1 # 10 Hz measurement rate
Q = np.eye(2) * 1.0 # high process noise (high uncertainty in model)
R = np.eye(1) * 0.1 # low measurement noise (trust sensor)
Step 3: Monitor Innovations (NIS)
Innovations are the differences between predicted and measured values. The
Normalized Innovation Squared (NIS) should be chi-squared distributed with
the measurement dimension m as degrees of freedom, so its mean should be
close to m. kf_update already returns the innovation y and its
covariance S; feed them to nis_sequence():
from pytcl.performance_evaluation import consistency_test, nis_sequence
# Simulate a 1D constant velocity target and run the filter
dt = 1.0
sigma_a, sigma_z = 0.5, 2.5
rng = np.random.default_rng(42)
n_steps = 200
truth = np.zeros((n_steps, 2)) # [x, vx]
truth[0] = [0.0, 1.0]
for k in range(1, n_steps):
accel = rng.normal(0.0, sigma_a)
truth[k, 0] = truth[k - 1, 0] + truth[k - 1, 1] * dt + 0.5 * accel * dt**2
truth[k, 1] = truth[k - 1, 1] + accel * dt
z_meas = truth[:, 0] + rng.normal(0.0, sigma_z, size=n_steps)
F = f_constant_velocity(dt, num_dims=1)
Q = q_constant_velocity(T=dt, sigma_a=sigma_a, num_dims=1)
H = np.array([[1.0, 0.0]])
R = np.array([[sigma_z**2]])
x = np.array([z_meas[0], 0.0])
P = np.diag([sigma_z**2, 10.0])
x_hist, P_hist, innovations, innovation_covs = [], [], [], []
for z in z_meas[1:]:
pred = kf_predict(x, P, F, Q)
upd = kf_update(pred.x, pred.P, np.atleast_1d(z), H, R)
x, P = upd.x, upd.P
x_hist.append(upd.x)
P_hist.append(upd.P)
innovations.append(upd.y)
innovation_covs.append(upd.S)
x_hist, P_hist = np.array(x_hist), np.array(P_hist)
innovations = np.array(innovations)
innovation_covs = np.array(innovation_covs)
nis_values = nis_sequence(innovations, innovation_covs)
print(f"NIS mean: {np.mean(nis_values):.3f} (should be close to m = 1)")
# NIS mean: 0.959 (should be close to m = 1)
result = consistency_test(nis_values, df=1)
print(f"Consistent: {result.is_consistent}, "
f"95% bounds: [{result.lower_bound:.3f}, {result.upper_bound:.3f}]")
# Consistent: True, 95% bounds: [0.813, 1.206]
Step 4: Adjust Based on Innovations
If NIS mean > m: filter is overconfident (increase Q or R)
If NIS mean < m: filter is underconfident (decrease Q or R)
If NIS has isolated spikes: gate outliers (see Problem 4 below)
Step 5: Validation (NEES)
With ground truth (simulation or instrumented test range), check the
Normalized Estimation Error Squared. A consistent filter has average NEES
close to the state dimension n:
from pytcl.performance_evaluation import average_nees, nees_sequence
nees_values = nees_sequence(truth[1:], x_hist, P_hist)
print(f"NEES mean: {average_nees(truth[1:], x_hist, P_hist):.3f} "
f"(should be close to n = 2)")
# NEES mean: 1.948 (should be close to n = 2)
result = consistency_test(nees_values, df=2)
print(f"Consistent: {result.is_consistent}, "
f"95% bounds: [{result.lower_bound:.3f}, {result.upper_bound:.3f}]")
# Consistent: True, 95% bounds: [1.732, 2.287]
Note
The consistency_test bounds assume independent samples. NEES values
from a single filter run are correlated, so treat single-run results as
indicative; for a rigorous test, average NEES across independent Monte
Carlo runs.
Diagnostic Tools
Plotting Innovations:
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
# Plot 1: Innovations over time
axes[0, 0].plot(innovations[:, 0])
axes[0, 0].axhline(0, color='r', linestyle='--')
axes[0, 0].set_ylabel('Innovation')
axes[0, 0].set_title('Innovations (should be ~0 mean)')
# Plot 2: Histogram of innovations
axes[0, 1].hist(innovations.flatten(), bins=30, density=True)
axes[0, 1].set_title('Innovation Distribution')
# Plot 3: Tracking error
axes[1, 0].plot(truth[1:, 0] - x_hist[:, 0])
axes[1, 0].set_ylabel('Error (m)')
axes[1, 0].set_title('Position Error')
# Plot 4: Filter consistency (NEES)
axes[1, 1].plot(nees_values)
axes[1, 1].axhline(2, color='r', linestyle='--')
axes[1, 1].set_ylabel('NEES')
axes[1, 1].set_title('Filter Consistency (should be ~n)')
plt.tight_layout()
plt.show()
Common Problems & Solutions
Problem 1: Filter Divergence (Error Grows)
Symptoms: NEES >> n, innovations increasing
# Solution: Increase process noise Q
Q_old = Q
Q = Q * 10 # Start with 10x increase
# Re-run filter and check NEES
Problem 2: Lag Behind Maneuvers
Symptoms: Consistent prediction error after direction change
# Solution: Increase process noise Q
# Or use adaptive Q that increases when innovations are large
# Alternative: Better motion model (higher-order)
# Constant velocity -> constant acceleration
from pytcl.dynamic_models import f_constant_acceleration, q_constant_acceleration
F_ca = f_constant_acceleration(T=1.0, num_dims=1) # state [x, vx, ax]
Q_ca = q_constant_acceleration(T=1.0, sigma_j=0.1, num_dims=1)
Problem 3: Jerky Tracking (Follows Noise)
Symptoms: High-frequency noise in state estimates
# Solution 1: Increase measurement noise R
R = R * 10
# Solution 2: Smooth with post-filter
from scipy.signal import savgol_filter
x_smooth = savgol_filter(x_hist, window_length=5, polyorder=2, axis=0)
Problem 4: Outlier Measurements
Symptoms: Occasional spikes drag the estimate away
# Solution: Gate measurements before updating.
# The threshold is a chi-squared quantile for the measurement dimension.
from pytcl.assignment_algorithms import chi2_gate_threshold, ellipsoidal_gate
gate = chi2_gate_threshold(0.99, num_dimensions=1)
print(round(gate, 2)) # 6.63
pred = kf_predict(x, P, F, Q)
S = H @ pred.P @ H.T + R
z_pred = H @ pred.x
for offset in (1.0, -2.0, 40.0):
z = z_pred + offset
if ellipsoidal_gate(z - z_pred, S, gate):
print(f"offset {offset:+.1f}: accept")
else:
print(f"offset {offset:+.1f}: reject (outlier)")
# offset +1.0: accept
# offset -2.0: accept
# offset +40.0: reject (outlier)
Problem 5: Q or R Values Too Hard to Choose
Solution: learn them from data with the Expectation-Maximization (EM)
algorithm. This is not built into pytcl (or filterpy); the pykalman
library ships it as KalmanFilter.em(). Alternatively, grid-search Q and R
and pick the combination whose NIS/NEES statistics are closest to their
expected chi-squared behavior (Steps 3 and 5 above).
Filter Selection Guide
Choose appropriate filter based on motion model linearity. “GPU” means a
CUDA implementation exists in pytcl.gpu (kalman, ekf, ukf,
particle_filter); the cubature filter is CPU-only
(ckf_predict / ckf_update in pytcl.dynamic_estimation).
Filter Type |
Linear |
Nonlinear |
GPU |
|---|---|---|---|
Kalman Filter |
✓ |
✗ |
Yes |
Extended KF |
✓ |
✓ |
Yes |
Unscented KF |
✓ |
✓ |
Yes |
Cubature KF |
✓ |
✓ |
No |
Particle Filter |
✓ |
✓ |
Yes (large N) |
Example: Tuning for GPS Tracking
from pytcl.dynamic_models import f_constant_velocity, q_constant_velocity
# 2D position tracking with GPS
# State: [x, vx, y, vy]
# Measurement: [x, y] (GPS)
dt = 1.0 # 1 second between measurements
# Motion model: constant velocity
F = f_constant_velocity(T=dt, num_dims=2)
# Process noise: target acceleration uncertainty.
# Q scales with sigma_a**2 (variance), built per spatial dimension.
sigma_a = 0.5 # m/s^2 acceleration noise
Q = q_constant_velocity(T=dt, sigma_a=sigma_a, num_dims=2)
# Measurement model: observe position only
H = np.array([
[1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
])
# Measurement noise: GPS accuracy +/-5 m (1 sigma)
sigma_gps = 5.0
R = np.diag([sigma_gps**2, sigma_gps**2])
gps_fixes = np.array([
[2.1, -3.7],
[11.8, 4.2],
[19.5, 10.6],
[30.2, 16.1],
[41.0, 24.3],
])
# Initialize from the first fix, with high velocity uncertainty
x = np.array([gps_fixes[0, 0], 0.0, gps_fixes[0, 1], 0.0])
P = np.diag([sigma_gps**2, 100.0, sigma_gps**2, 100.0])
for z in gps_fixes[1:]:
pred = kf_predict(x, P, F, Q)
upd = kf_update(pred.x, pred.P, z, H, R)
x, P = upd.x, upd.P
print(f"position: ({x[0]:6.2f}, {x[2]:6.2f}) "
f"velocity: ({x[1]:5.2f}, {x[3]:5.2f})")
# position: ( 10.18, 2.88) velocity: ( 6.47, 5.27)
# position: ( 18.87, 10.06) velocity: ( 7.74, 6.36)
# position: ( 29.05, 16.20) velocity: ( 8.78, 6.27)
# position: ( 39.71, 23.56) velocity: ( 9.42, 6.64)
References
Bar-Shalom, Y., Li, X. R., & Kirubarajan, T. (2001). Estimation with Applications to Tracking and Navigation
Simon, D. (2006). Optimal State Estimation: Kalman, H∞, and Nonlinear Approaches
Bierman, G. J. (1977). Factorization Methods for Discrete Sequential Estimation
See Also
GPU Acceleration Guide - GPU-accelerated filtering
Module:
pytcl.dynamic_estimation.kalmanModule:
pytcl.performance_evaluation(NEES/NIS consistency tools)Examples:
examples/kalman_filter_comparison.py