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:

  1. Noise Covariance Estimation - How to set Q and R

  2. Initialization - Starting state and covariance selection

  3. Tuning Strategies - Systematic approaches

  4. Diagnostics - How to detect filter issues

  5. 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:

# Q: process noise covariance (uncertainty in motion model)
# For constant velocity model: position doubles each timestep if uncontrolled
Q = np.diag([
    dt**4/4,  # Position uncertainty (m²)
    dt**2/2,  # Velocity uncertainty (m²/s²)
])

# R: measurement noise covariance (sensor accuracy)
# If sensor has ±0.5m accuracy:
R = np.array([[0.5**2]])  # Position measurement variance

Estimation Methods

Method 1: From Datasheets

Use manufacturer specifications:

# GPS accuracy: ±5 meters 95% confidence (~2.5σ)
# So 1σ ≈ 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:

import numpy as np

# True positions: x_true (from ground truth)
# Measurements: z_measured
residuals = z_measured - x_true[:len(z_measured)]

# Estimate R from measurement variance
R = np.cov(residuals.T)
print(f"Estimated R:\n{R}")

Method 3: Adaptive Estimation

Update noise covariances online using innovations (measurement residuals):

def adaptive_kalman_filter(x, P, z, F, H, Q, R):
    """EKF with adaptive Q"""
    # Standard EKF prediction
    x_pred = F @ x
    P_pred = F @ P @ F.T + Q

    # Innovation
    y = z - H @ x_pred
    S = H @ P_pred @ H.T + R

    # Adaptive: increase Q if innovations are large
    innovation_magnitude = np.linalg.norm(y)
    if innovation_magnitude > 3 * np.sqrt(np.trace(S)):
        Q_adapt = Q * 1.5  # Increase process noise
    else:
        Q_adapt = Q

    # Continue with standard update...
    return x_new, P_new, Q_adapt

Initialization

Initial State (x₀):

Start with best estimate of target position and velocity:

# Option 1: Use first measurement
x0 = np.array([
    z0[0],     # Initial position from first measurement
    0.0,       # Initial velocity (unknown)
])

# Option 2: Use two measurements to estimate velocity
z0 = measurements[0]
z1 = measurements[1]
dt = time_steps[1] - time_steps[0]

x0 = np.array([
    z0[0],
    (z1[0] - z0[0]) / dt,  # Velocity estimate
])

Initial Covariance (P₀):

Reflects uncertainty in initial state:

import numpy as np

# High uncertainty in velocity (we don't know it yet)
P0 = np.diag([
    10.0,      # Position uncertainty: ±√10 ≈ ±3.2m
    100.0,     # Velocity uncertainty: ±√100 = ±10 m/s
])

# Conservative: Square of initial position uncertainty
# If measurement has ±5m accuracy:
pos_uncertainty = 5.0
P0 = np.diag([pos_uncertainty**2, 1000.0])

Systematic Tuning Strategy

Step 1: Characterize Measurements

import numpy as np
from scipy import stats

# Analyze measurement noise
residuals = measurements - true_positions  # If you have ground truth

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}")

# Look for outliers (> 3σ)
outliers = np.abs(residuals) > 3 * np.std(residuals)
print(f"Outliers: {np.sum(outliers)} / {len(residuals)}")

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

Innovations are the differences between predicted and measured values. They should be zero-mean:

def compute_innovations(measurements, x_history, P_history, H):
    """Compute normalized innovations for tuning diagnosis"""
    innovations = []
    for z, x, P in zip(measurements, x_history, P_history):
        # Innovation: y = z - H@x
        y = z - H @ x

        # Innovation covariance: S = H@P@H' + R
        S = H @ P @ H.T + R

        # Normalized innovation (should be ~ N(0,1))
        normalized = y / np.sqrt(np.diag(S))
        innovations.append(normalized)

    return np.array(innovations)

# Innovations should have mean ≈ 0, std ≈ 1
innov = compute_innovations(z_meas, x_hist, P_hist, H)
print(f"Innovation mean: {np.mean(innov):.4f} (should be ≈ 0)")
print(f"Innovation std: {np.std(innov):.4f} (should be ≈ 1)")

Step 4: Adjust Based on Residuals

  • If innovations mean > 0: Filter lags (increase Q or decrease R)

  • If innovations std > 1: Filter too confident (decrease Q or increase R)

  • If innovations have outliers: robust H∞ filter needed

Step 5: Validation

Test on independent data:

def compute_nees(x_true, x_est, P):
    """Normalized Estimation Error Squared - should be ~1 on average"""
    errors = x_true - x_est
    nees_values = []
    for err, cov in zip(errors, P):
        nees = err.T @ np.linalg.inv(cov) @ err
        nees_values.append(nees)
    return np.array(nees_values)

nees = compute_nees(x_true, x_estimates, P_estimates)
print(f"NEES mean: {np.mean(nees):.4f}")
print(f"NEES std: {np.std(nees):.4f}")

# Should be close to 1.0; > 3 indicates inconsistency

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)
axes[0, 0].axhline(0, color='r', linestyle='--')
axes[0, 0].set_ylabel('Innovation')
axes[0, 0].set_title('Innovations (should be ~0)')

# 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
errors = x_true - x_estimates
axes[1, 0].plot(errors)
axes[1, 0].set_ylabel('Error (m)')
axes[1, 0].set_title('Tracking Error')

# Plot 4: Filter consistency (NEES)
nees = compute_nees(x_true, x_estimates, P_estimates)
axes[1, 1].plot(nees)
axes[1, 1].axhline(1, color='r', linestyle='--')
axes[1, 1].set_ylabel('NEES')
axes[1, 1].set_title('Filter Consistency (should be ~1)')

plt.tight_layout()
plt.show()

Common Problems & Solutions

Problem 1: Filter Divergence (Error Grows)

Symptoms: NEES >> 1, 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

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_estimates, window_length=5, polyorder=2, axis=0)

Problem 4: Missed Detections

Symptoms: Occasional measurements not used

# Solution: Gate measurements
# Only use if innovation is within expected range

chi2_threshold = 9.21  # 99% confidence for 2-DOF

for z in measurements:
    innovation = z - H @ x_pred
    S = H @ P_pred @ H.T + R
    chi2 = innovation @ np.linalg.inv(S) @ innovation.T

    if chi2 < chi2_threshold:
        # Use measurement
        pass
    else:
        # Outlier - skip

Problem 5: Q or R Values Too Hard to Choose

Solution: Use Expectation-Maximization (EM) algorithm to learn them automatically:

# This is complex - use specialized libraries:
# Option 1: filterpy (Python control filtering library)
# Option 2: PyMC3 (Bayesian inference)

from filterpy.kalman import KalmanFilter
# filterpy has built-in EM for Q/R estimation

Filter Selection Guide

Choose appropriate filter based on motion model linearity:

Filter Type

Linear

Nonlinear

GPU Ready

Kalman Filter

Yes

Extended KF

Yes

Unscented KF

Yes

Cubature KF

Yes

Particle Filter

Yes (large N)

Example: Tuning for GPS Tracking

import numpy as np
from pytcl.dynamic_estimation.kalman import extended_kalman_filter

# 2D position tracking with GPS
# State: [x, y, vx, vy]
# Measurement: [x, y] (GPS)

dt = 1.0  # 1 second between measurements

# Motion model: constant velocity
F = np.array([
    [1, 0, dt, 0],
    [0, 1, 0, dt],
    [0, 0, 1, 0],
    [0, 0, 0, 1],
])

# Measurement model: observe position only
H = np.array([
    [1, 0, 0, 0],
    [0, 1, 0, 0],
])

# Process noise: target acceleration uncertainty
sigma_a = 0.5  # m/s² acceleration noise
Q = np.diag([
    (sigma_a * dt**4) / 4,
    (sigma_a * dt**4) / 4,
    (sigma_a * dt**2) / 2,
    (sigma_a * dt**2) / 2,
])

# Measurement noise: GPS accuracy ±5m
sigma_gps = 5.0
R = np.diag([sigma_gps**2, sigma_gps**2])

# Initial state and covariance
x0 = np.array([0, 0, 0, 0])  # Start at origin, no velocity
P0 = np.diag([25, 25, 100, 100])  # High uncertainty in velocity

# Run filter
x = x0
P = P0
for z in measurements:
    x, P = extended_kalman_filter(
        x, P, z,
        state_transition_fn=lambda s: F @ s,
        measurement_fn=lambda s: H @ s,
        process_cov=Q,
        measurement_cov=R,
    )
    print(f"Position: ({x[0]:.2f}, {x[1]:.2f}), Velocity: ({x[2]:.2f}, {x[3]:.2f})")

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.kalman

  • Examples: examples/kalman_filter_comparison.py