Particle Filters & Non-Gaussian Estimation
Comprehensive guide to sequential Monte Carlo (SMC) methods for tracking non-Gaussian, nonlinear systems.
Particle filters represent probability distributions as weighted samples (particles), enabling estimation in highly nonlinear and non-Gaussian scenarios where Kalman filters fail. pytcl ships a complete functional particle filter toolkit in pytcl.dynamic_estimation.particle_filters.
Table of Contents:
Particle Filter Fundamentals
Bootstrap Particle Filter
Resampling Strategies
Degeneracy Detection & Solutions
Sequential Importance Sampling
Practical Implementation
Performance & Efficiency
Common Issues & Solutions
Best Practices
Particle Filter Fundamentals
Why Particle Filters?
Kalman filters assume: - Linear systems (or EKF/UKF with differentiability) - Gaussian noise - Unimodal distributions
Particle filters work with: - Arbitrary nonlinear models - Non-Gaussian and multimodal distributions - Discontinuous measurement likelihoods - Heavy-tailed distributions and outliers
Particle Filter Concept:
Represent state uncertainty as a set of \(N_p\) weighted samples:
Where:
\(x_k^{(i)}\): i-th particle (state sample)
\(w_k^{(i)}\): weight of i-th particle
\(\delta(\cdot)\): Dirac delta function
Weights normalize to 1: \(\sum_i w_k^{(i)} = 1\)
Key Equations:
Prediction: Sample from motion model
\[x_k^{(i)} \sim p(x_k \mid x_{k-1}^{(i)})\]Update: Compute importance weights
\[w_k^{(i)} \propto p(z_k \mid x_k^{(i)}) \cdot w_{k-1}^{(i)}\]Normalize:
\[\hat{w}_k^{(i)} = \frac{w_k^{(i)}}{\sum_j w_k^{(j)}}\]Resample (if needed): Generate new particles from weighted distribution
Bootstrap Particle Filter
Algorithm: Sequential Importance Resampling (SIR)
pytcl provides the complete SIR cycle as bootstrap_pf_step, which performs
predict, update, and adaptive resampling in one call. Particles and weights
travel together in a ParticleState named tuple.
import numpy as np
from pytcl.dynamic_estimation.particle_filters import (
bootstrap_pf_step,
initialize_particles,
particle_covariance,
particle_mean,
)
rng = np.random.default_rng(42)
# 1D constant-velocity state: [position, velocity]
dt = 0.1
def f(x):
"""Motion model: x_{k+1} = f(x_k)."""
return np.array([x[0] + x[1] * dt, x[1]])
def h(x):
"""Measurement model: observe position only."""
return np.array([x[0]])
def Q_sample(n, rng):
"""Draw n process-noise samples, shape (n, 2)."""
return rng.normal(0.0, [0.02, 0.10], size=(n, 2))
R = np.array([[0.25]]) # measurement noise covariance
# Initialize 500 particles from a Gaussian prior
state = initialize_particles(
x0=np.array([0.0, 1.0]), P0=np.diag([1.0, 0.25]), N=500, rng=rng
)
# Simulate and filter
true_x = np.array([0.0, 1.0])
for k in range(50):
true_x = f(true_x)
z = h(true_x) + rng.normal(0.0, 0.5, size=1)
state = bootstrap_pf_step(
state.particles, state.weights, z, f, h, Q_sample, R, rng=rng
)
x_hat = particle_mean(state.particles, state.weights)
P_hat = particle_covariance(state.particles, state.weights)
print(f"true: pos={true_x[0]:.3f} vel={true_x[1]:.3f}")
print(f"estimate: pos={x_hat[0]:.3f} vel={x_hat[1]:.3f}")
print(f"pos std: {np.sqrt(P_hat[0, 0]):.3f}")
# true: pos=5.000 vel=1.000
# estimate: pos=5.081 vel=1.137
# pos std: 0.213
Predict and Update as Separate Steps
When you need finer control (e.g. multiple measurements per scan, custom
likelihoods), use the individual building blocks. bootstrap_pf_update
takes any likelihood function likelihood_func(z, x) -> float;
gaussian_likelihood covers the common Gaussian-measurement case.
from pytcl.dynamic_estimation.particle_filters import (
bootstrap_pf_predict,
bootstrap_pf_update,
gaussian_likelihood,
)
# Predict: propagate particles through f and add process noise
particles = bootstrap_pf_predict(state.particles, f, Q_sample, rng=rng)
# Update: reweight particles by measurement likelihood
z = np.array([5.2])
def likelihood(z, x):
return gaussian_likelihood(z, h(x), R)
weights, log_lik = bootstrap_pf_update(particles, state.weights, z, likelihood)
print(f"weights sum to {weights.sum():.6f}")
print(f"log marginal likelihood: {log_lik:.3f}")
# weights sum to 1.000000
# log marginal likelihood: -0.325
Resampling Strategies
Problem: Particle Degeneracy
As the filter runs, most particles get zero weight (weight collapse).
Solution: resample. pytcl ships three resamplers; each takes (particles,
weights) and returns a new equally-weighted particle set.
Set up a degenerate particle set to compare them:
from pytcl.dynamic_estimation.particle_filters import (
effective_sample_size,
resample_multinomial,
resample_residual,
resample_systematic,
)
# Highly non-uniform weights: most mass on a few particles
N = 500
demo_particles = rng.normal(0.0, 1.0, size=(N, 2))
raw = np.exp(-0.5 * (demo_particles[:, 0] - 2.0) ** 2 / 0.1)
demo_weights = raw / raw.sum()
print(f"ESS before resampling: {effective_sample_size(demo_weights):.1f}")
# ESS before resampling: 43.0
1. Multinomial Resampling (Simple)
Sample with replacement proportionally to weight. Highest variance.
resampled = resample_multinomial(demo_particles, demo_weights, rng=rng)
print(f"unique ancestors kept: {len(np.unique(resampled[:, 0]))}")
# unique ancestors kept: 71
2. Systematic Resampling (Best for Most Cases)
Deterministic positions with a single random offset. Low variance, O(N).
resampled = resample_systematic(demo_particles, demo_weights, rng=rng)
print(f"unique ancestors kept: {len(np.unique(resampled[:, 0]))}")
# unique ancestors kept: 70
3. Stratified Resampling
Note
pytcl does not ship a stratified resampler. The implementation below is a
self-contained reference; in practice resample_systematic is the
recommended low-variance choice.
def resample_stratified(particles, weights, rng):
"""Stratified resampling: one uniform draw per stratum."""
N = len(weights)
u = (np.arange(N) + rng.uniform(size=N)) / N
indices = np.searchsorted(np.cumsum(weights), u)
return particles[indices]
resampled = resample_stratified(demo_particles, demo_weights, rng)
print(f"unique ancestors kept: {len(np.unique(resampled[:, 0]))}")
# unique ancestors kept: 70
4. Residual Resampling (Semi-Deterministic)
Keep floor(N * w_i) copies of each particle deterministically, then fill
the remaining slots stochastically from the fractional remainders.
resampled = resample_residual(demo_particles, demo_weights, rng=rng)
print(f"unique ancestors kept: {len(np.unique(resampled[:, 0]))}")
# unique ancestors kept: 72
Resampling Comparison:
Function |
Complexity |
Variance |
Stability |
|---|---|---|---|
|
O(N log N) |
High |
Unstable |
|
O(N) |
Lowest |
Robust |
stratified (hand-rolled) |
O(N) |
Low |
Very Good |
|
O(N log N) |
Very low |
Excellent |
Recommended: resample_systematic for most applications.
Degeneracy Detection & Solutions
Effective Sample Size (ESS):
ESS ranges from 1 (all weight on one particle) to \(N\) (uniform
weights). Resample when \(N_{\text{eff}} < \tau N\) (typically
\(\tau = 0.5\)). pytcl computes it with effective_sample_size:
def resample_if_needed(particles, weights, threshold=0.5, rng=None):
"""Adaptive resampling: resample only when degenerate."""
N = len(weights)
if effective_sample_size(weights) < threshold * N:
particles = resample_systematic(particles, weights, rng=rng)
weights = np.full(N, 1.0 / N)
return particles, weights
particles, weights = resample_if_needed(
demo_particles, demo_weights, rng=rng
)
print(f"ESS after adaptive resampling: {effective_sample_size(weights):.1f}")
# ESS after adaptive resampling: 500.0
Solutions to Degeneracy:
Adaptive Resampling: Resample only when needed (above). This is what
bootstrap_pf_stepdoes internally via itsresample_thresholdargument.Regularization: Add jitter to particles after resampling
def resample_with_regularization(particles, weights, rng, bandwidth=1.0):
"""Resample and add regularization noise."""
resampled = resample_systematic(particles, weights, rng=rng)
# Adaptive bandwidth from particle spread
h_bw = bandwidth * particles.std(axis=0)
return resampled + rng.normal(0.0, h_bw, size=resampled.shape)
regularized = resample_with_regularization(
demo_particles, demo_weights, rng
)
print(f"distinct particles: {len(np.unique(regularized[:, 0]))}")
# distinct particles: 500
Sequential Importance Sampling (SIS)
More General Particle Filter Framework:
The bootstrap filter is SIS with the prior \(p(x_k \mid x_{k-1})\) as proposal plus adaptive resampling. Composing the pytcl building blocks by hand exposes each SIS stage, which is useful when you need a custom proposal or likelihood:
def sis_step(particles, weights, z, likelihood, rng):
"""One SIS cycle: propose, reweight, adaptively resample."""
# 1. Propose from the motion model (bootstrap proposal)
particles = bootstrap_pf_predict(particles, f, Q_sample, rng=rng)
# 2. Reweight by measurement likelihood
weights, _ = bootstrap_pf_update(particles, weights, z, likelihood)
# 3. Resample if degenerate
return resample_if_needed(particles, weights, rng=rng)
state = initialize_particles(
x0=np.array([0.0, 1.0]), P0=np.diag([1.0, 0.25]), N=500, rng=rng
)
particles, weights = state.particles, state.weights
true_x = np.array([0.0, 1.0])
for k in range(30):
true_x = f(true_x)
z = h(true_x) + rng.normal(0.0, 0.5, size=1)
particles, weights = sis_step(particles, weights, z, likelihood, rng)
x_hat = particle_mean(particles, weights)
print(f"true pos={true_x[0]:.3f} estimate pos={x_hat[0]:.3f}")
# true pos=3.000 estimate pos=2.954
Practical Implementation
Complete Tracking System:
Range-only tracking is a classic nonlinear problem where the measurement function makes EKF linearization fragile:
# 2D constant-velocity state: [x, y, vx, vy]
def f_cv(x):
dt = 0.5
return np.array([x[0] + x[2] * dt, x[1] + x[3] * dt, x[2], x[3]])
def h_range(x):
"""Range from origin: strongly nonlinear in state."""
return np.array([np.hypot(x[0], x[1])])
def Q_sample_cv(n, rng):
return rng.normal(0.0, [0.05, 0.05, 0.20, 0.20], size=(n, 4))
R_range = np.array([[4.0]])
rng = np.random.default_rng(7)
state = initialize_particles(
x0=np.array([100.0, 50.0, -2.0, 1.0]),
P0=np.diag([100.0, 100.0, 1.0, 1.0]),
N=2000,
rng=rng,
)
truth = np.array([105.0, 45.0, -2.5, 1.2])
pos_errors, range_errors = [], []
for k in range(60):
truth = f_cv(truth)
z = h_range(truth) + rng.normal(0.0, 2.0, size=1)
state = bootstrap_pf_step(
state.particles, state.weights, z, f_cv, h_range,
Q_sample_cv, R_range, rng=rng,
)
x_hat = particle_mean(state.particles, state.weights)
pos_errors.append(np.hypot(*(x_hat[:2] - truth[:2])))
range_errors.append(abs(np.hypot(*x_hat[:2]) - np.hypot(*truth[:2])))
print(f"mean position error: {np.mean(pos_errors):.2f}")
print(f"mean range error: {np.mean(range_errors):.2f}")
# mean position error: 7.39
# mean range error: 2.72
Range-only measurements observe bearing only weakly (through motion over time), so the position error stays several times larger than the range error, which converges to the measurement noise floor. The particle cloud spreads along the range arc: exactly the non-Gaussian behavior a Kalman filter cannot represent.
Multi-Target Extension:
Run one ParticleState per target and update each with its associated
measurement:
targets = {
tid: initialize_particles(x0, np.diag([25.0, 25.0, 1.0, 1.0]),
N=1000, rng=rng)
for tid, x0 in {
0: np.array([100.0, 50.0, -2.0, 1.0]),
1: np.array([-40.0, 80.0, 1.5, -0.5]),
}.items()
}
# associations: list of (measurement, target_id) pairs from a data
# association stage (e.g. GNN); see :doc:`assignment_association`
associations = [(np.array([111.0]), 0), (np.array([90.0]), 1)]
for z, tid in associations:
s = targets[tid]
targets[tid] = bootstrap_pf_step(
s.particles, s.weights, z, f_cv, h_range,
Q_sample_cv, R_range, rng=rng,
)
for tid, s in targets.items():
x_hat = particle_mean(s.particles, s.weights)
print(f"target {tid}: range estimate {np.hypot(*x_hat[:2]):.1f}")
# target 0: range estimate 111.0
# target 1: range estimate 89.7
Performance & Efficiency
Computational Complexity:
Prediction: O(N_p x d) where d = state dimension
Update: O(N_p) for likelihood evaluation
Resampling: O(N_p) to O(N_p log N_p) depending on method
Overall: O(N_p) per cycle (most efficient SMC method)
Memory Considerations:
def estimate_memory_usage(num_particles, state_dim, num_timesteps):
"""Estimate memory for storing particle histories."""
bytes_per_particle = state_dim * 8 # double precision
bytes_per_weight = 8
bytes_per_step = (bytes_per_particle + bytes_per_weight) * num_particles
return bytes_per_step * num_timesteps / (1024**2)
mb = estimate_memory_usage(1000, 10, 1000)
print(f"1000 particles, 10 states, 1000 steps: {mb:.0f} MB")
# 1000 particles, 10 states, 1000 steps: 84 MB
Scaling with Particle Count:
Monte Carlo error shrinks like \(1/\sqrt{N_p}\). Measure it directly on the 1D tracking problem from the first section:
# One fixed measurement sequence shared by every trial, so that
# trial-to-trial spread isolates the Monte Carlo error
data_rng = np.random.default_rng(123)
true_x = np.array([0.0, 1.0])
truths, zs = [], []
for _ in range(30):
true_x = f(true_x)
truths.append(true_x.copy())
zs.append(h(true_x) + data_rng.normal(0.0, 0.5, size=1))
def run_trial(n_particles, rng):
"""Run the 1D tracker once, return mean position error."""
state = initialize_particles(
np.array([0.0, 1.0]), np.diag([1.0, 0.25]), n_particles, rng=rng
)
errs = []
for tx, z in zip(truths, zs):
state = bootstrap_pf_step(
state.particles, state.weights, z, f, h, Q_sample, R, rng=rng
)
x_hat = particle_mean(state.particles, state.weights)
errs.append(abs(x_hat[0] - tx[0]))
return np.mean(errs)
for n in [50, 200, 1000]:
errors = [run_trial(n, np.random.default_rng(seed))
for seed in range(20)]
print(f"N={n:5d}: mean_error={np.mean(errors):.4f} "
f"std={np.std(errors):.4f}")
# N= 50: mean_error=0.1592 std=0.0171
# N= 200: mean_error=0.1480 std=0.0121
# N= 1000: mean_error=0.1473 std=0.0050
The mean error approaches the noise-limited floor while the trial-to-trial standard deviation drops roughly as \(1/\sqrt{N_p}\).
Common Issues & Solutions
Problem: Particle Collapse (All Weight on Few Particles)
Symptom: ESS drops to < 10% of N
Solutions:
Increase resampling threshold (e.g., resample at 0.5*N instead of 0.2*N)
Use regularization/jitter
Increase number of particles
Check measurement likelihood (may be too informative)
def diagnose_collapse(weights):
"""Analyze weight distribution."""
sorted_w = np.sort(weights)[::-1]
top_10_percent = np.sum(sorted_w[: len(weights) // 10])
print(f"Top 10% have {top_10_percent * 100:.1f}% of weight")
if top_10_percent > 0.8:
print("WARNING: Significant weight collapse")
print(" - Consider more particles")
print(" - Add regularization noise")
print(" - Increase resampling threshold")
diagnose_collapse(demo_weights)
# Top 10% have 93.9% of weight
# WARNING: Significant weight collapse
# - Consider more particles
# - Add regularization noise
# - Increase resampling threshold
Problem: Particle Impoverishment (After Heavy Resampling)
Symptom: Many identical particles, filter stuck
Solutions:
Use regularization with adaptive bandwidth
Mix in samples from proposal distribution
Lower resampling threshold (resample less often)
def regularized_resample(particles, weights, rng, scale=1.0):
"""Resample with adaptive regularization (Silverman's rule)."""
new_particles = resample_systematic(particles, weights, rng=rng)
n = len(particles)
h_bw = scale * n ** (-1 / 5) * new_particles.std(axis=0)
return new_particles + rng.normal(0.0, h_bw, size=new_particles.shape)
jittered = regularized_resample(demo_particles, demo_weights, rng)
print(f"distinct particles: {len(np.unique(jittered[:, 0]))}")
# distinct particles: 500
Problem: Filter Diverges (State Estimates Far from Reality)
Solutions:
Check likelihood calculation for bugs
Verify process noise is appropriate
Increase particles
Use likelihood annealing (temper a sharp likelihood in stages)
def annealed_update(particles, weights, z, likelihood_fn, rng,
num_stages=5):
"""Incorporate a sharp likelihood gradually (tempering).
At each stage, apply the likelihood raised to an increment of
beta so that the full likelihood is absorbed after all stages,
resampling between stages to keep the particle set healthy.
"""
beta_prev = 0.0
for stage in range(1, num_stages + 1):
beta = stage / num_stages
lik = np.array([likelihood_fn(z, p) for p in particles])
weights = weights * lik ** (beta - beta_prev)
weights = weights / weights.sum()
particles, weights = resample_if_needed(
particles, weights, rng=rng
)
beta_prev = beta
return particles, weights
def sharp_likelihood(z, x):
return gaussian_likelihood(z, x[:1], np.array([[0.01]]))
z_sharp = np.array([2.0])
annealed, w = annealed_update(
demo_particles, np.full(len(demo_particles), 1 / len(demo_particles)),
z_sharp, sharp_likelihood, rng,
)
print(f"ESS after annealed update: {effective_sample_size(w):.1f}")
# ESS after annealed update: 284.4
Best Practices
Choose Particle Count Wisely
Start with N=500 for 3D problems
Rule of thumb: N ~ 10^(d/2) for quick estimates
Monitor ESS; if < 100 typically, increase N
Adaptive Parameters
Resampling threshold (
resample_thresholdinbootstrap_pf_step): 0.3-0.7 of NRegularization bandwidth: use Silverman’s rule
Process noise: match actual motion uncertainty
Monitor Filter Health
Track
effective_sample_sizeover time (should oscillate, not decay monotonically)Check innovation sequences (should be white noise)
Visualize particle cloud (should spread over state space)
Use Systematic Resampling
Better variance than multinomial
Negligible overhead vs bootstrap
The default
resample_methodinbootstrap_pf_step
Combine with Other Methods
Use Kalman filter as proposal distribution (proposal-guided PF)
Use particle filter for initialization of EKF
Hybrid systems for mixed Gaussian/non-Gaussian
For Real-Time Systems
Use smallest N that maintains accuracy (test offline first)
Compute ESS during update, resample only when needed
Profile particle filter speed vs system requirements
Troubleshooting
Diagnostic Checklist:
def diagnose_particle_filter(particles, weights, spread_floor=0.01):
"""Basic particle filter health checks."""
issues = []
# Check 1: effective sample size
ess = effective_sample_size(weights)
if ess < 0.2 * len(weights):
issues.append("Severe weight collapse - increase particles")
# Check 2: particle spread
spread = particles.std(axis=0)
if np.any(spread < spread_floor):
issues.append("Particles too concentrated - increase noise")
return issues
print(diagnose_particle_filter(demo_particles, demo_weights))
# ['Severe weight collapse - increase particles']
print(diagnose_particle_filter(state.particles, state.weights))
# []
Beyond these structural checks, validate estimates against ground truth in simulation and check innovation whiteness; see Troubleshooting Guide.
See Also
Common Use Cases & Recipes - Ready-to-use particle filter examples
Troubleshooting Guide - Common particle filter issues
Kalman Filter Tuning Guide - Parameter selection strategies
API:
pytcl.dynamic_estimation.particle_filtersmodule for implementations