拡張カルマンフィルタ(EKF)の理論とPython実装

拡張カルマンフィルタ(Extended Kalman Filter)のアルゴリズムをPythonで実装します。ヤコビ行列による線形化の仕組みから、非線形追跡問題での実装例を解説します。

拡張カルマンフィルタとは

カルマンフィルタ(KF)は線形ガウスシステムに対する最適な状態推定手法ですが、実世界のシステムは多くの場合非線形です。拡張カルマンフィルタ(Extended Kalman Filter, EKF)は、非線形関数をヤコビ行列(一次のテイラー展開)によって局所的に線形化することで、カルマンフィルタの枠組みを非線形システムに拡張します。

EKFは非線形フィルタリングの最も基本的な手法であり、以下のような発展的手法の出発点となります:

非線形状態空間モデル

以下の非線形状態空間モデルを考えます:

\[ \mathbf{x}_k = f(\mathbf{x}_{k-1}) + \mathbf{w}_{k-1}, \quad \mathbf{w}_{k-1} \sim \mathcal{N}(\mathbf{0}, \mathbf{Q}) \tag{1} \]\[ \mathbf{z}_k = h(\mathbf{x}_k) + \mathbf{v}_k, \quad \mathbf{v}_k \sim \mathcal{N}(\mathbf{0}, \mathbf{R}) \tag{2} \]

ここで \(f\) は非線形の状態遷移関数、\(h\) は非線形の観測関数です。線形カルマンフィルタでは \(f(\mathbf{x}) = \mathbf{A}\mathbf{x}\)、\(h(\mathbf{x}) = \mathbf{H}\mathbf{x}\) のように行列で表現されますが、EKFではこれらが一般の非線形関数になります。

ヤコビ行列による線形化

EKFの核となるアイデアは、非線形関数 \(f\) と \(h\) を現在の推定値の周りでテイラー展開し、一次項までを保持して線形近似することです。

状態遷移関数 \(f\) のヤコビ行列は、前回の推定値 \(\hat{\mathbf{x}}_{k-1|k-1}\) の周りで計算されます:

\[ \mathbf{F}_k = \frac{\partial f}{\partial \mathbf{x}}\bigg|_{\mathbf{x}=\hat{\mathbf{x}}_{k-1|k-1}} = \begin{bmatrix} \frac{\partial f_1}{\partial x_1} & \cdots & \frac{\partial f_1}{\partial x_n} \\\ \vdots & \ddots & \vdots \\\ \frac{\partial f_n}{\partial x_1} & \cdots & \frac{\partial f_n}{\partial x_n} \end{bmatrix} \tag{3} \]

観測関数 \(h\) のヤコビ行列は、予測値 \(\hat{\mathbf{x}}_{k|k-1}\) の周りで計算されます:

\[ \mathbf{H}_k = \frac{\partial h}{\partial \mathbf{x}}\bigg|_{\mathbf{x}=\hat{\mathbf{x}}_{k|k-1}} \tag{4} \]

この線形化により、共分散の伝播にはヤコビ行列 \(\mathbf{F}_k, \mathbf{H}_k\) を使いつつ、状態や観測の計算には元の非線形関数 \(f, h\) をそのまま使うという、EKFの特徴的な構造が生まれます。

EKFアルゴリズム

予測ステップ

状態の予測には非線形関数 \(f\) をそのまま使います:

\[ \hat{\mathbf{x}}_{k|k-1} = f(\hat{\mathbf{x}}_{k-1|k-1}) \tag{5} \]

共分散の予測にはヤコビ行列 \(\mathbf{F}_k\) を使って線形近似します:

\[ \mathbf{P}_{k|k-1} = \mathbf{F}_k \mathbf{P}_{k-1|k-1} \mathbf{F}_k^T + \mathbf{Q} \tag{6} \]

更新ステップ

観測残差(イノベーション)の計算には非線形関数 \(h\) を使います:

\[ \mathbf{y}_k = \mathbf{z}_k - h(\hat{\mathbf{x}}_{k|k-1}) \tag{7} \]

イノベーション共分散とカルマンゲインの計算にはヤコビ行列 \(\mathbf{H}_k\) を使います:

\[ \mathbf{S}_k = \mathbf{H}_k \mathbf{P}_{k|k-1} \mathbf{H}_k^T + \mathbf{R} \tag{8} \]\[ \mathbf{K}_k = \mathbf{P}_{k|k-1} \mathbf{H}_k^T \mathbf{S}_k^{-1} \tag{9} \]

状態と共分散の更新:

\[ \hat{\mathbf{x}}_{k|k} = \hat{\mathbf{x}}_{k|k-1} + \mathbf{K}_k \mathbf{y}_k \tag{10} \]\[ \mathbf{P}_{k|k} = (\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1} \tag{11} \]

KFとの重要な違いは、状態の伝播と観測予測には非線形関数 \(f, h\) を使い、共分散の伝播にはヤコビ行列 \(\mathbf{F}_k, \mathbf{H}_k\) を使うという点です。

Python実装

2次元の追跡問題を使ってEKFを実装します。対象は2次元平面上を移動する物体で、観測者は原点から距離と方位角を計測します。

問題設定

状態ベクトル \(\mathbf{x} = [p_x, p_y, v_x, v_y]^T\)(位置と速度)に対して、状態遷移は等速直線運動モデル:

\[ f(\mathbf{x}) = \begin{bmatrix} p_x + v_x \Delta t \\\ p_y + v_y \Delta t \\\ v_x \\\ v_y \end{bmatrix} \]

観測モデルは、原点にいる観測者が距離 \(r\) と方位角 \(\theta\) を計測する非線形関数です:

\[ h(\mathbf{x}) = \begin{bmatrix} \sqrt{p_x^2 + p_y^2} \\\ \arctan(p_y / p_x) \end{bmatrix} \]

観測関数 \(h\) のヤコビ行列は、\(r = \sqrt{p_x^2 + p_y^2}\) として:

\[ \mathbf{H} = \begin{bmatrix} p_x / r & p_y / r & 0 & 0 \\\ -p_y / r^2 & p_x / r^2 & 0 & 0 \end{bmatrix} \]

モデルの定義

import numpy as np
import matplotlib.pyplot as plt

dt = 1.0  # タイムステップ

def f(x):
    """状態遷移関数(等速直線運動モデル)"""
    px, py, vx, vy = x
    return np.array([px + vx * dt, py + vy * dt, vx, vy])

def jacobian_F(x):
    """状態遷移関数のヤコビ行列"""
    return np.array([
        [1, 0, dt, 0],
        [0, 1, 0, dt],
        [0, 0, 1,  0],
        [0, 0, 0,  1]
    ])

def h(x):
    """観測関数(距離と方位角)"""
    px, py = x[0], x[1]
    r = np.sqrt(px**2 + py**2)
    theta = np.arctan2(py, px)
    return np.array([r, theta])

def jacobian_H(x):
    """観測関数のヤコビ行列"""
    px, py = x[0], x[1]
    r = np.sqrt(px**2 + py**2)
    return np.array([
        [px / r,      py / r,  0, 0],
        [-py / r**2,  px / r**2, 0, 0]
    ])

EKFの実装

class EKF:
    def __init__(self, x0, P0, Q, R):
        """
        Parameters
        ----------
        x0 : array, 初期状態推定値
        P0 : array, 初期共分散行列
        Q  : array, プロセスノイズ共分散
        R  : array, 観測ノイズ共分散
        """
        self.x = x0.copy()
        self.P = P0.copy()
        self.Q = Q
        self.R = R

    def predict(self):
        """予測ステップ(式5, 6)"""
        F = jacobian_F(self.x)
        self.x = f(self.x)           # 非線形関数fで状態を予測
        self.P = F @ self.P @ F.T + self.Q  # ヤコビFで共分散を伝播
        return self.x.copy()

    def update(self, z):
        """更新ステップ(式7-11)"""
        H = jacobian_H(self.x)
        y = z - h(self.x)            # 非線形関数hで観測残差を計算
        # 方位角の差を [-pi, pi] に正規化
        y[1] = (y[1] + np.pi) % (2 * np.pi) - np.pi

        S = H @ self.P @ H.T + self.R
        K = self.P @ H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        self.P = (np.eye(len(self.x)) - K @ H) @ self.P
        return self.x.copy()

シミュレーション

np.random.seed(42)
T = 100  # タイムステップ数

# ノイズパラメータ
Q = np.diag([0.1, 0.1, 0.01, 0.01])
R = np.diag([0.5, 0.01])  # 距離ノイズ, 方位角ノイズ

# 真の初期状態(円弧状の軌跡を描くように速度を設定)
x_true = np.array([10.0, 0.0, 0.5, 1.0])

# 真の軌跡と観測を生成
true_states = [x_true.copy()]
measurements = []

for k in range(T):
    # 速度方向を少しずつ回転させる(緩やかなカーブ)
    omega_true = 0.03
    vx, vy = x_true[2], x_true[3]
    x_true[2] = vx * np.cos(omega_true) - vy * np.sin(omega_true)
    x_true[3] = vx * np.sin(omega_true) + vy * np.cos(omega_true)

    x_true = f(x_true) + np.random.multivariate_normal(np.zeros(4), Q)
    true_states.append(x_true.copy())

    z = h(x_true) + np.random.multivariate_normal(np.zeros(2), R)
    measurements.append(z.copy())

true_states = np.array(true_states)
measurements = np.array(measurements)

# EKFの実行
x0 = np.array([10.5, -0.5, 0.0, 0.0])
P0 = np.diag([2.0, 2.0, 1.0, 1.0])
ekf = EKF(x0, P0, Q, R)

estimates = [x0.copy()]
for k in range(T):
    ekf.predict()
    ekf.update(measurements[k])
    estimates.append(ekf.x.copy())

estimates = np.array(estimates)

# 観測値をデカルト座標に変換(可視化用)
obs_x = measurements[:, 0] * np.cos(measurements[:, 1])
obs_y = measurements[:, 0] * np.sin(measurements[:, 1])

結果の可視化

plt.figure(figsize=(10, 8))
plt.plot(true_states[:, 0], true_states[:, 1], "b-",
         linewidth=1.5, label="True trajectory")
plt.scatter(obs_x, obs_y, c="gray", s=10, alpha=0.5,
            label="Measurements (Cartesian)")
plt.plot(estimates[:, 0], estimates[:, 1], "r--",
         linewidth=1.5, label="EKF estimate")
plt.plot(true_states[0, 0], true_states[0, 1], "go",
         markersize=10, label="Start")
plt.xlabel("x")
plt.ylabel("y")
plt.title("Extended Kalman Filter - Range-Bearing Tracking")
plt.legend()
plt.axis("equal")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("ekf_result.png", dpi=150)
plt.show()

# RMSE計算
rmse_x = np.sqrt(np.mean((true_states[1:, 0] - estimates[1:, 0])**2))
rmse_y = np.sqrt(np.mean((true_states[1:, 1] - estimates[1:, 1])**2))
print(f"Position RMSE: x={rmse_x:.4f}, y={rmse_y:.4f}")

EKFの限界

EKFは非線形フィルタリングの基本手法ですが、以下の限界があります:

  1. 線形化誤差: テイラー展開の一次項のみを使用するため、非線形性が強い場合に近似精度が低下します。特に、観測関数の曲率が大きい領域では推定が不安定になります。

  2. 初期推定への依存: 初期推定値が真値から大きく離れている場合、線形化の精度が悪くフィルタが発散する可能性があります。

  3. 解析的ヤコビ行列の必要性: 複雑なシステムでは \(f\) や \(h\) のヤコビ行列を解析的に導出することが困難な場合があります。数値微分で代用することもできますが、計算コストと精度のトレードオフが生じます。

  4. ガウス分布の仮定: EKFは事後分布がガウス分布であることを仮定しているため、多峰性を持つ分布には対応できません。

これらの問題を解決するために、Unscented変換ではシグマ点を使って非線形変換を直接近似するUKFが提案されています。また、CKFはシグマ点の選択をキュバチャ則に基づいて行うことで数値的安定性を向上させ、粒子フィルタはガウス分布の仮定自体を排除しています。

関連記事

参考文献

  • Thrun, S., Burgard, W., & Fox, D. (2005). “Probabilistic Robotics.” MIT Press.
  • Simon, D. (2006). “Optimal State Estimation: Kalman, H-infinity, and Nonlinear Approaches.” Wiley.