Particle Filter Python Implementation: Comparing Resampling Methods

A Python implementation of the particle filter with systematic, residual, and stratified resampling, demonstrated on a nonlinear state estimation problem.

What is a Particle Filter?

The particle filter (also known as the Sequential Monte Carlo method, SMC) is a nonlinear filtering technique that represents the posterior distribution of the state using a set of weighted samples called particles. Unlike Gaussian-approximation filters, it requires no distributional assumptions and can handle arbitrary nonlinear and non-Gaussian systems.

Gaussian-approximation filters such as the Cubature Kalman Filter (CKF) and the Unscented Transform-based Unscented Kalman Filter (UKF) degrade in accuracy when the posterior distribution is multimodal or the system exhibits strong nonlinearities. The particle filter remains effective in these situations.

The particle filter shares its Monte Carlo sampling foundation with the Cross-Entropy Method (CEM), but whereas CEM targets optimization problems, the particle filter targets sequential state estimation in time series.

Algorithm Overview

State-Space Model

Consider the following nonlinear state-space model:

\[ x_k = f(x_{k-1}) + w_{k-1}, \quad w_{k-1} \sim p_w \]\[ y_k = h(x_k) + v_k, \quad v_k \sim p_v \]

where \(f\) is the state transition function, \(h\) is the observation function, and \(w_{k-1}\), \(v_k\) are the process and observation noise respectively. The noise distributions \(p_w, p_v\) need not be Gaussian.

Sequential Monte Carlo Framework

The particle filter approximates the posterior distribution \(p(x_k | y_{1:k})\) using \(N\) weighted particles \(\{x_k^{(i)}, w_k^{(i)}\}_{i=1}^{N}\). Each step consists of three phases.

1. Prediction: Propagate each particle through the state transition model:

\[ x_k^{(i)} \sim p(x_k | x_{k-1}^{(i)}) \]

In practice, this is implemented as \(x_k^{(i)} = f(x_{k-1}^{(i)}) + w_{k-1}^{(i)}\) by adding process noise.

2. Weight Update: Compute the likelihood of the new observation \(y_k\) for each particle and update the weights:

\[ w_k^{(i)} \propto p(y_k | x_k^{(i)}) \]

3. Normalization: Normalize the weights so they sum to one:

\[ \tilde{w}_k^{(i)} = \frac{w_k^{(i)}}{\sum_{j=1}^{N} w_k^{(j)}} \]

The state estimate is then computed as a weighted average:

\[ \hat{x}_k = \sum_{i=1}^{N} \tilde{w}_k^{(i)} x_k^{(i)} \]

Effective Sample Size and Degeneracy

A major challenge in particle filtering is weight degeneracy: over time, most particles acquire negligibly small weights while a few particles dominate. This degeneracy is quantified by the Effective Sample Size (ESS):

\[ N_{\text{eff}} = \frac{1}{\sum_{i=1}^{N} (\tilde{w}_k^{(i)})^2} \]

\(N_{\text{eff}}\) ranges from 1 to \(N\). When all weights are equal (no degeneracy), \(N_{\text{eff}} = N\). Resampling is triggered when \(N_{\text{eff}}\) drops below a threshold, typically \(N/2\).

def effective_sample_size(weights):
    """Compute the effective sample size."""
    return 1.0 / np.sum(weights ** 2)

Resampling Methods

Resampling reconstructs the particle set by duplicating high-weight particles and discarding low-weight ones. Below are three widely used methods.

Systematic Resampling

Systematic resampling generates a single random number \(u_0\) and places equally spaced points along the cumulative distribution function (CDF). It has \(O(N)\) complexity and low variance, making it the most commonly used method in practice.

def systematic_resampling(weights):
    """Systematic resampling."""
    N = len(weights)
    positions = (np.random.random() + np.arange(N)) / N
    cumsum = np.cumsum(weights)
    indices = np.zeros(N, dtype=int)
    i, j = 0, 0
    while i < N:
        if positions[i] < cumsum[j]:
            indices[i] = j
            i += 1
        else:
            j += 1
    return indices

Residual Resampling

Residual resampling first deterministically copies each particle \(\lfloor N \tilde{w}^{(i)} \rfloor\) times, then samples the remaining particles probabilistically from the residual weights. The deterministic component reduces variance compared to purely random methods.

def residual_resampling(weights):
    """Residual resampling."""
    N = len(weights)
    indices = []

    # Deterministic copies
    num_copies = (N * weights).astype(int)
    for i in range(N):
        indices.extend([i] * num_copies[i])

    # Handle residuals
    residual_weights = N * weights - num_copies
    residual_weights /= residual_weights.sum()
    num_remaining = N - len(indices)
    if num_remaining > 0:
        cumsum = np.cumsum(residual_weights)
        # Apply systematic resampling to the residual part
        positions = (np.random.random() + np.arange(num_remaining)) / num_remaining
        i, j = 0, 0
        while i < num_remaining:
            if positions[i] < cumsum[j]:
                indices.append(j)
                i += 1
            else:
                j += 1

    return np.array(indices)

Stratified Resampling

Stratified resampling divides the \([0, 1)\) interval into \(N\) equal strata and draws an independent uniform random number within each stratum to sample from the CDF. It is similar to systematic resampling but uses independent random numbers per stratum.

def stratified_resampling(weights):
    """Stratified resampling."""
    N = len(weights)
    positions = (np.random.random(N) + np.arange(N)) / N
    cumsum = np.cumsum(weights)
    indices = np.zeros(N, dtype=int)
    i, j = 0, 0
    while i < N:
        if positions[i] < cumsum[j]:
            indices[i] = j
            i += 1
        else:
            j += 1
    return indices
PropertySystematicResidualStratified
ComplexityO(N)O(N)O(N)
VarianceLowLowestLow
Random draws1Remaining onlyN
Deterministic componentNoYes (floor copies)No

Nonlinear System Demo

Benchmark Model

To evaluate particle filter performance, we use the widely adopted Univariate Nonstationary Growth Model. This model exhibits strong nonlinearity that challenges Gaussian-approximation filters.

State transition model:

\[ x_k = \frac{x_{k-1}}{2} + \frac{25 x_{k-1}}{1 + x_{k-1}^2} + 8\cos(1.2k) + w_k, \quad w_k \sim \mathcal{N}(0, \sigma_w^2) \]

Observation model:

\[ y_k = \frac{x_k^2}{20} + v_k, \quad v_k \sim \mathcal{N}(0, \sigma_v^2) \]

A key feature of this model is that the observation function \(h(x) = x^2/20\) is an even function, so positive and negative state values are indistinguishable from observations alone. This can produce a bimodal posterior distribution.

Particle Filter Implementation

import numpy as np
import matplotlib.pyplot as plt

# ---- Model definition ----
sigma_w = np.sqrt(10.0)  # process noise std
sigma_v = np.sqrt(1.0)   # observation noise std

def state_transition(x, k):
    """State transition function."""
    return x / 2.0 + 25.0 * x / (1.0 + x ** 2) + 8.0 * np.cos(1.2 * k)

def observation(x):
    """Observation function."""
    return x ** 2 / 20.0

def log_likelihood(y, x):
    """Log-likelihood log p(y|x)."""
    diff = y - observation(x)
    return -0.5 * (diff ** 2) / (sigma_v ** 2)

# ---- Particle filter ----
def particle_filter(y_obs, N_particles, resample_fn, resample_threshold=0.5):
    """
    Run the particle filter.

    Parameters
    ----------
    y_obs : array, observation sequence
    N_particles : int, number of particles
    resample_fn : callable, resampling function
    resample_threshold : float, ESS threshold (ratio of N_particles)

    Returns
    -------
    x_est : array, state estimate sequence
    ess_history : array, ESS history
    """
    T = len(y_obs)
    threshold = resample_threshold * N_particles

    # Initialize: sample from prior
    particles = np.random.normal(0, np.sqrt(5.0), N_particles)
    weights = np.ones(N_particles) / N_particles

    x_est = np.zeros(T)
    ess_history = np.zeros(T)

    for k in range(T):
        # Prediction: state transition + process noise
        particles = state_transition(particles, k + 1) \
                    + sigma_w * np.random.randn(N_particles)

        # Weight update: compute in log domain for numerical stability
        log_w = log_likelihood(y_obs[k], particles)
        log_w -= np.max(log_w)  # numerical stabilization
        weights = np.exp(log_w)
        weights /= np.sum(weights)

        # State estimate
        x_est[k] = np.sum(weights * particles)

        # ESS computation
        ess = effective_sample_size(weights)
        ess_history[k] = ess

        # Resampling (ESS threshold-based)
        if ess < threshold:
            indices = resample_fn(weights)
            particles = particles[indices]
            weights = np.ones(N_particles) / N_particles

    return x_est, ess_history

Running the Simulation

np.random.seed(42)
T = 100            # number of time steps
N_particles = 500  # number of particles

# Generate true state and observations
x_true = np.zeros(T)
y_obs = np.zeros(T)
x_true[0] = 0.1  # initial state

for k in range(1, T):
    x_true[k] = state_transition(x_true[k - 1], k) \
                + sigma_w * np.random.randn()

for k in range(T):
    y_obs[k] = observation(x_true[k]) + sigma_v * np.random.randn()

# Run particle filter with three resampling methods
resamplers = {
    "Systematic": systematic_resampling,
    "Residual": residual_resampling,
    "Stratified": stratified_resampling,
}

results = {}
for name, fn in resamplers.items():
    np.random.seed(42)  # same seed for fair comparison
    x_est, ess_hist = particle_filter(y_obs, N_particles, fn)
    rmse = np.sqrt(np.mean((x_true - x_est) ** 2))
    results[name] = {"estimate": x_est, "ess": ess_hist, "rmse": rmse}
    print(f"{name}: RMSE = {rmse:.4f}")

Visualization

fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True)

# State estimation comparison
axes[0].plot(x_true, "k-", linewidth=1.5, label="True state")
colors = {"Systematic": "tab:blue", "Residual": "tab:orange",
          "Stratified": "tab:green"}
for name, res in results.items():
    axes[0].plot(res["estimate"], "--", color=colors[name],
                 linewidth=1, label=f"{name} (RMSE={res['rmse']:.2f})")
axes[0].set_ylabel("State $x_k$")
axes[0].set_title("Particle Filter: Resampling Method Comparison")
axes[0].legend(loc="upper right", fontsize=9)
axes[0].grid(True, alpha=0.3)

# ESS over time
for name, res in results.items():
    axes[1].plot(res["ess"], color=colors[name], linewidth=0.8, label=name)
axes[1].axhline(y=N_particles / 2, color="red", linestyle=":",
                label=f"Threshold (N/2={N_particles // 2})")
axes[1].set_xlabel("Time step $k$")
axes[1].set_ylabel("Effective Sample Size")
axes[1].legend(loc="upper right", fontsize=9)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("particle_filter_comparison.png", dpi=150)
plt.show()

Particle filter comparison results

Particle Distribution Snapshots

The figure below shows the weighted particle distribution (histogram) at selected time steps. The red line indicates the true state, and the blue line shows the estimate.

Particle distribution snapshots

Comparison of Filtering Methods

The following table compares the particle filter with Gaussian-approximation filters.

PropertyParticle FilterUKFCKF
Distribution assumptionNone (arbitrary)GaussianGaussian
Computational cost\(O(N)\) (depends on particle count)\(O(n^3)\)\(O(n^3)\)
ParametersParticle count \(N\), ESS threshold\(\alpha, \beta, \kappa\)None
MultimodalitySupportedNot supportedNot supported
High dimensionsDifficult (curse of dimensionality)GoodGood
Implementation complexityModerateModerateLow

The particle filter’s key advantage is its freedom from distributional assumptions, but it faces the “curse of dimensionality” in high-dimensional problems where the required number of particles grows exponentially. For low-dimensional problems with strong nonlinearity or non-Gaussianity, the particle filter is the method of choice. For high-dimensional problems with moderate nonlinearity, CKF or UKF are more practical.

References

  • Arulampalam, M. S., Maskell, S., Gordon, N., & Clapp, T. (2002). “A Tutorial on Particle Filters for Online Nonlinear/Non-Gaussian Bayesian Tracking.” IEEE Transactions on Signal Processing, 50(2), 174-188.
  • Doucet, A., & Johansen, A. M. (2009). “A Tutorial on Particle Filtering and Smoothing: Fifteen Years Later.” Handbook of Nonlinear Filtering, 12(656-704), 3.
  • Douc, R., & Cappe, O. (2005). “Comparison of Resampling Schemes for Particle Filtering.” Proceedings of the 4th International Symposium on Image and Signal Processing and Analysis, 64-69.