粒子フィルタの仕組みを理解してPythonで実装する

粒子フィルタ(パーティクルフィルタ、Particle Filter)は、非線形・非ガウスのシステムにおける逐次ベイズ推定を実現する手法です。カルマンフィルタが線形ガウスモデルに限定されるのに対し、粒子フィルタはより一般的な問題に適用できます。

今回は粒子フィルタの仕組みについて、理論からPython実装まで解説します。

本記事の内容

  • 粒子フィルタの基本的な考え方
  • 重要度サンプリングとリサンプリング
  • アルゴリズムの定式化
  • Python での実装と可視化

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

粒子フィルタの基本的な考え方

カルマンフィルタでは、状態の分布をガウス分布の平均と共分散で表現しました。粒子フィルタでは、状態の分布を多数の粒子(パーティクル)とその重みで近似的に表現します。

$$ \begin{equation} p(\bm{x}_t|\bm{y}_{1:t}) \approx \sum_{i=1}^{N_p} w_t^{(i)} \delta(\bm{x}_t – \bm{x}_t^{(i)}) \end{equation} $$

ここで $\bm{x}_t^{(i)}$ は $i$ 番目の粒子の状態、$w_t^{(i)}$ はその重み、$N_p$ は粒子数、$\delta$ はディラックのデルタ関数です。

イメージとしては、確率分布を大量の「点の集まり」で表現し、点が密集している領域は確率が高く、疎な領域は確率が低いと解釈します。

粒子フィルタのアルゴリズム

粒子フィルタ(Sequential Importance Resampling; SIR)のアルゴリズムは次のとおりです。

ステップ1: 初期化

$N_p$ 個の粒子を初期分布 $p(\bm{x}_0)$ からサンプリングし、重みを均等に設定します。

$$ \bm{x}_0^{(i)} \sim p(\bm{x}_0), \quad w_0^{(i)} = \frac{1}{N_p} $$

ステップ2: 予測

各粒子を状態遷移モデルに従って伝播させます。

$$ \begin{equation} \tilde{\bm{x}}_t^{(i)} \sim p(\bm{x}_t|\bm{x}_{t-1}^{(i)}) \end{equation} $$

ステップ3: 重み更新

新しい観測 $\bm{y}_t$ に基づいて、各粒子の重みを尤度で更新します。

$$ \begin{equation} \tilde{w}_t^{(i)} = p(\bm{y}_t|\tilde{\bm{x}}_t^{(i)}) \end{equation} $$

重みを正規化します。

$$ \begin{equation} w_t^{(i)} = \frac{\tilde{w}_t^{(i)}}{\sum_{j=1}^{N_p} \tilde{w}_t^{(j)}} \end{equation} $$

ステップ4: リサンプリング

重みが偏りすぎると、少数の粒子だけが有効になり推定精度が低下します(退化問題)。これを防ぐために、重みに比例した確率で粒子を再選択し、重みをリセットします。

Python での実装

1次元の位置推定問題を例に、粒子フィルタを実装しましょう。

import numpy as np
import matplotlib.pyplot as plt

def particle_filter(y_obs, N_p, F, H, Q, R, x0_mean, x0_var):
    """粒子フィルタの実装"""
    T = len(y_obs)
    particles = np.random.normal(x0_mean, np.sqrt(x0_var), N_p)
    weights = np.ones(N_p) / N_p

    x_est = np.zeros(T)
    x_particles_history = np.zeros((T, N_p))

    for t in range(T):
        # 予測ステップ: 状態遷移モデルに従って粒子を伝播
        particles = F * particles + np.random.normal(0, np.sqrt(Q), N_p)

        # 重み更新: 観測尤度で重みを計算
        likelihood = (1.0 / np.sqrt(2 * np.pi * R)) * np.exp(
            -0.5 * (y_obs[t] - H * particles)**2 / R
        )
        weights = likelihood
        weights += 1e-300  # アンダーフロー防止
        weights /= weights.sum()  # 正規化

        # 状態推定(重み付き平均)
        x_est[t] = np.sum(weights * particles)
        x_particles_history[t] = particles

        # リサンプリング(系統リサンプリング)
        cumsum = np.cumsum(weights)
        u = (np.arange(N_p) + np.random.uniform()) / N_p
        indices = np.searchsorted(cumsum, u)
        particles = particles[indices]
        weights = np.ones(N_p) / N_p

    return x_est, x_particles_history

# シミュレーション設定
np.random.seed(42)
T = 80
F = 1.0    # 状態遷移
H = 1.0    # 観測
Q = 0.5    # プロセスノイズ
R = 2.0    # 観測ノイズ
N_p = 500  # 粒子数

# 非線形の真の状態を生成
x_true = np.zeros(T)
y_obs = np.zeros(T)
x_true[0] = 0.0
for t in range(1, T):
    x_true[t] = F * x_true[t-1] + 0.5 * np.sin(0.1 * t) + np.random.normal(0, np.sqrt(Q))
y_obs = H * x_true + np.random.normal(0, np.sqrt(R), T)

# 粒子フィルタの実行
x_est_pf, particles_hist = particle_filter(y_obs, N_p, F, H, Q, R, 0, 1.0)

# 可視化
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

# 上段: 状態推定結果
ax1 = axes[0]
ax1.plot(x_true, 'k-', linewidth=1.5, label='True state')
ax1.scatter(range(T), y_obs, c='gray', s=15, alpha=0.5, label='Observations')
ax1.plot(x_est_pf, 'r-', linewidth=1.5, label='Particle filter estimate')
ax1.set_xlabel('Time step', fontsize=12)
ax1.set_ylabel('State', fontsize=12)
ax1.set_title(f'Particle Filter (N_p={N_p})', fontsize=13)
ax1.legend(fontsize=10)
ax1.grid(True, alpha=0.3)

# 下段: 粒子の分布の推移(一部の時刻で表示)
ax2 = axes[1]
show_times = [5, 20, 40, 60]
colors = ['blue', 'green', 'orange', 'red']
for i, t_show in enumerate(show_times):
    ax2.scatter(np.full(N_p, t_show), particles_hist[t_show],
                alpha=0.05, s=5, color=colors[i])
    ax2.scatter(t_show, x_est_pf[t_show], color=colors[i], s=80,
                marker='D', edgecolors='black', zorder=5, label=f't={t_show}')
ax2.plot(x_true, 'k-', linewidth=1, alpha=0.5)
ax2.set_xlabel('Time step', fontsize=12)
ax2.set_ylabel('State', fontsize=12)
ax2.set_title('Particle Distribution at Selected Time Steps', fontsize=13)
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# 推定精度の評価
rmse = np.sqrt(np.mean((x_true - x_est_pf)**2))
print(f"RMSE (Particle Filter): {rmse:.4f}")

上段のグラフでは、粒子フィルタによる推定値が真の状態をよく追跡していることが確認できます。下段のグラフでは、特定の時刻における粒子の分布を表示しています。

カルマンフィルタとの比較

特性 カルマンフィルタ 粒子フィルタ
モデルの制約 線形ガウス 任意(非線形・非ガウス可)
分布の表現 平均と共分散 粒子と重み
計算量 $O(D^3)$ $O(N_p)$
精度 仮定内で最適 粒子数に依存

まとめ

本記事では、粒子フィルタについて解説しました。

  • 粒子フィルタは確率分布を多数の粒子(サンプル)とその重みで近似表現する手法
  • 予測→重み更新→リサンプリングの3ステップを繰り返すことで、逐次ベイズ推定を実現する
  • カルマンフィルタと異なり、非線形・非ガウスのシステムにも適用可能
  • リサンプリングにより粒子の退化問題を緩和する

次のステップとして、以下の記事も参考にしてください。