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

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

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

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

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

  • EKF: ヤコビ行列による一次線形化
  • UKF: シグマ点(Unscented変換) による非線形変換の直接近似
  • CKF : 球面キュバチャ則に基づくシグマ点の選択
  • 粒子フィルタ : モンテカルロサンプリングによる任意分布の近似

非線形状態空間モデル

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

\[ \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の特徴的な構造が生まれます。

なぜKFの更新式がそのまま使えるのか:誤差伝播の導出

前節ではヤコビ行列 \(\mathbf{F}_k, \mathbf{H}_k\) を天下り的に定義しましたが、なぜこの線形化によって式(6)や式(8)〜(11)という線形カルマンフィルタと全く同じ形の更新式がそのまま使えるのかを、推定誤差の伝播式から導出します。

推定誤差を \(\mathbf{e}_{k-1|k-1} = \mathbf{x}_{k-1} - \hat{\mathbf{x}}_{k-1|k-1}\) (平均ゼロ、共分散 \(\mathbf{P}_{k-1|k-1}\) )と定義し、状態遷移(式1)に代入します。多変数のテイラーの定理により、\(f\) の各成分 \(f_i\) を \(\hat{\mathbf{x}}_{k-1|k-1}\) の周りで2次の項まで展開すると、

\[ f_i(\hat{\mathbf{x}}_{k-1|k-1} + \mathbf{e}_{k-1|k-1}) = f_i(\hat{\mathbf{x}}_{k-1|k-1}) + \nabla f_i^T \mathbf{e}_{k-1|k-1} + \frac{1}{2} \mathbf{e}_{k-1|k-1}^T \mathbf{H}_{f_i} \mathbf{e}_{k-1|k-1} + O(\|\mathbf{e}_{k-1|k-1}\|^3) \tag{12} \]

となります。ここで \(\mathbf{H}_{f_i}\) は \(f_i\) のヘッセ行列です。全成分をまとめてベクトル表記すると、

\[ \mathbf{x}_k = f(\hat{\mathbf{x}}_{k-1|k-1}) + \mathbf{F}_k \mathbf{e}_{k-1|k-1} + \mathbf{w}_{k-1} + \mathbf{r}_k \tag{13} \]

となります。\(\mathbf{r}_k\) は打ち切られた2次以降の項(各成分が \(\frac{1}{2}\mathbf{e}^T \mathbf{H}_{f_i}\mathbf{e} + O(\|\mathbf{e}\|^3)\) )であり、これが線形化誤差の正体です。EKFの予測ステップは \(\hat{\mathbf{x}}_{k|k-1} = f(\hat{\mathbf{x}}_{k-1|k-1})\) で定義されるため、予測誤差は

\[ \mathbf{e}_{k|k-1} := \mathbf{x}_k - \hat{\mathbf{x}}_{k|k-1} = \mathbf{F}_k \mathbf{e}_{k-1|k-1} + \mathbf{w}_{k-1} + \mathbf{r}_k \tag{14} \]

と書けます。ここで \(\mathbf{r}_k\) を無視する(これがEKFの近似そのものです)と、\(\mathbf{e}_{k|k-1} \approx \mathbf{F}_k \mathbf{e}_{k-1|k-1} + \mathbf{w}_{k-1}\) は、遷移行列 \(\mathbf{F}_k\) とノイズ \(\mathbf{w}_{k-1}\) を持つ線形システムの誤差伝播式そのものです。したがって共分散を取ると、

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

となり、式(6)と完全に一致します。つまり式(6)は天下り的な近似ではなく、「1次のテイラー展開で誤差伝播を近似する」という仮定を認めた上で導かれる厳密な帰結です。同様に観測モデル(式2)を \(\hat{\mathbf{x}}_{k|k-1}\) の周りで展開すると、イノベーション \(\mathbf{y}_k\) について

\[ \mathbf{y}_k = \mathbf{z}_k - h(\hat{\mathbf{x}}_{k|k-1}) \approx \mathbf{H}_k \mathbf{e}_{k|k-1} + \mathbf{v}_k + \mathbf{s}_k \tag{16} \]

が成り立ちます(\(\mathbf{s}_k\) は \(h\) の2次以降の打ち切り項)。\(\mathbf{s}_k\) を無視すれば、これは線形KFのイノベーションモデル \(\mathbf{y}_k = \mathbf{H}_k \mathbf{e}_{k|k-1} + \mathbf{v}_k\) そのものであり、式(8)〜(11)のカルマンゲイン・共分散更新式がそのまま導出されます。

この導出から、次の2点が明らかになります。

  1. 線形化誤差の正体: 打ち切られた項 \(\mathbf{r}_k, \mathbf{s}_k\) は、状態遷移・観測関数の曲率(ヘッセ行列)誤差の2乗に比例します。スカラー出力の場合 \(E[\mathbf{e}^T \mathbf{H}\mathbf{e}] = \mathrm{tr}(\mathbf{H}\mathbf{P})\) であり、一般にゼロではありません。したがってEKFはプロセスノイズ・観測ノイズのような平均ゼロの確率変数とは異なり、系統的なバイアスを暗黙に内包しています。
  2. 発散の必要条件: このバイアスは、(a) 誤差共分散 \(\mathbf{P}\) (現在の推定誤差の大きさ)が大きいほど、(b) \(f, h\) の曲率(ヘッセ行列のノルム)が大きいほど増大します。後述の「線形化誤差の実演」では、この2条件がそろったときに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 はシグマ点の選択をキュバチャ則に基づいて行うことで数値的安定性を向上させ、 粒子フィルタ はガウス分布の仮定自体を排除しています。

線形化誤差の実演:EKFが発散するケース

前節の導出から、EKFの線形化誤差は「誤差 \(\mathbf{P}\) が大きい」かつ「\(f, h\) の曲率(非線形性)が強い」ときに顕在化することがわかりました。この2条件を意図的に満たす、非線形フィルタリング分野で標準的なベンチマークモデル(Kitagawa 1996; Arulampalam et al. 2002のパーティクルフィルタ・チュートリアルでも採用されている)を使い、EKFが実際に発散する様子を実演します。

対象は次のスカラー非線形系です。

\[ x_k = 0.5 x_{k-1} + \frac{25 x_{k-1}}{1+x_{k-1}^2} + 8\cos(1.2(k-1)) + w_{k-1} \tag{17} \] \[ z_k = \frac{x_k^2}{20} + v_k \tag{18} \]

状態遷移関数 \(f\) は強い非線形性を持ち、観測関数 \(h(x)=x^2/20\) が偶関数(\(h(x)=h(-x)\) )である点が重要です。\(h\) のヤコビアン \(\partial h/\partial x = x/10\) は \(x=0\) 付近でゼロに近づくため、EKFはこの領域でほとんど観測から情報を得られません。さらに \(h\) が偶関数であることから、観測値だけでは \(x\) の符号を区別できず、一次線形化はその時点での符号を「決め打ち」してしまいます。真の状態が符号を跨いで大きく変動すると、EKFの線形近似はヤコビアンの符号ごと真の状態からズレ、式(15)(16)で無視した2次項が支配的になって、推定は真の軌道から乖離したまま収束しなくなります。

import numpy as np
import matplotlib.pyplot as plt

def f(x, k):
    """強い非線形性を持つ状態遷移関数(式17)"""
    return 0.5 * x + 25 * x / (1 + x**2) + 8 * np.cos(1.2 * k)

def df_dx(x, k):
    """f のヤコビアン(スカラー)"""
    return 0.5 + 25 * (1 - x**2) / (1 + x**2)**2

def h(x):
    """偶関数の観測関数(式18)"""
    return x**2 / 20.0

def dh_dx(x):
    """h のヤコビアン。x=0 近傍でゼロに近づく"""
    return x / 10.0

np.random.seed(3)
T = 50
Q = 10.0  # プロセスノイズ分散
R = 1.0   # 観測ノイズ分散

# 真の軌跡と観測を生成
x_true = 0.1
true_states = [x_true]
zs = []
for k in range(T):
    x_true = f(x_true, k) + np.random.normal(0, np.sqrt(Q))
    true_states.append(x_true)
    zs.append(h(x_true) + np.random.normal(0, np.sqrt(R)))
true_states = np.array(true_states)
zs = np.array(zs)

# スカラーEKFの実行
x_est = 0.1
P = 5.0
ekf_est = [x_est]
for k in range(T):
    F = df_dx(x_est, k)
    x_pred = f(x_est, k)
    P_pred = F * P * F + Q

    H = dh_dx(x_pred)
    y = zs[k] - h(x_pred)
    S = H * P_pred * H + R
    K = P_pred * H / S
    x_est = x_pred + K * y
    P = (1 - K * H) * P_pred
    ekf_est.append(x_est)
ekf_est = np.array(ekf_est)

rmse = np.sqrt(np.mean((true_states - ekf_est)**2))
max_err = np.abs(true_states - ekf_est).max()
print(f"RMSE: {rmse:.4f}, max abs error: {max_err:.4f}")

実行すると、EKFのRMSEは12.27、最大絶対誤差は52.29に達しました(状態自体は概ね \([-25,25]\) の範囲で変動する系であり、この誤差は状態のスケールに対して非常に大きいものです)。詳細を追うと、例えば \(k=10\) では真値 \(x_{10}=-8.54\) に対しEKFの推定値は \(+12.79\) と符号が反転しており、\(k=41\) 付近では誤差が最大52まで拡大しています。

強い非線形性を持つベンチマークモデルにおけるEKFの発散

左図は真の状態とEKF推定値の時系列、右図は絶対誤差の推移です。\(k=10, 20, 31\) 〜\(34, 41\) 付近で誤差が急拡大しているのが分かります。これらはいずれも、真の状態が \(h\) の曲率が大きい \(x\approx 0\) 近傍を通過する、あるいは符号が反転するタイミングと一致します。式(16)で無視した2次項 \(\mathbf{s}_k\) が、\(h\) の非線形性と誤差の大きさに応じて増大し、線形近似が実際の観測モデルと乖離することで、EKFが真の軌道を見失う様子が確認できます。

このベンチマークは1状態のスカラー系ですが、 UKFの記事 で示されている通り、多次元のレンジ・ベアリング追跡でも観測関数の曲率が大きい原点近傍ではEKFの誤差がUKFを上回ることが確認されており、本質的に同じメカニズム(曲率×誤差の2乗に比例する線形化誤差)が働いています。

Iterated EKF (IEKF):観測更新の反復による精度改善

式(7)〜(11)のEKF更新ステップは、観測のヤコビアン \(\mathbf{H}_k\) を予測値 \(\hat{\mathbf{x}}_{k|k-1}\) の1点でのみ評価します。しかし更新後の推定値 \(\hat{\mathbf{x}}_{k|k}\) は一般に \(\hat{\mathbf{x}}_{k|k-1}\) から離れた点に移動するため、\(\mathbf{H}_k\) を予測点で評価することは、本来評価すべき点(真の状態に近い点)でのヤコビアンとズレることになります。Iterated EKF (IEKF) は、この評価点のズレを解消するために更新ステップを反復し、線形化点を逐次改善する手法です。

IEKFの更新ステップは、反復変数 \(\boldsymbol{\eta}^{(i)}\) (\(\boldsymbol{\eta}^{(0)} = \hat{\mathbf{x}}_{k|k-1}\) )を用いて次のように書けます。

\[ \mathbf{H}^{(i)} = \frac{\partial h}{\partial \mathbf{x}}\bigg|_{\mathbf{x}=\boldsymbol{\eta}^{(i)}} \tag{19} \] \[ \mathbf{K}^{(i)} = \mathbf{P}_{k|k-1} (\mathbf{H}^{(i)})^T \left[ \mathbf{H}^{(i)} \mathbf{P}_{k|k-1} (\mathbf{H}^{(i)})^T + \mathbf{R} \right]^{-1} \tag{20} \] \[ \boldsymbol{\eta}^{(i+1)} = \hat{\mathbf{x}}_{k|k-1} + \mathbf{K}^{(i)} \Big[ \mathbf{z}_k - h(\boldsymbol{\eta}^{(i)}) - \mathbf{H}^{(i)} \left( \hat{\mathbf{x}}_{k|k-1} - \boldsymbol{\eta}^{(i)} \right) \Big] \tag{21} \]

式(21)は、現在の反復点 \(\boldsymbol{\eta}^{(i)}\) の周りで観測モデルを再線形化し、その線形近似のもとでの1ステップ分のガウス・ニュートン更新を計算しています。\(\mathbf{H}^{(i)}(\hat{\mathbf{x}}_{k|k-1} - \boldsymbol{\eta}^{(i)})\) の項は、線形化点が予測値からずれていることによる補正項です。これを収束するまで(あるいは固定回数)反復し、最終的な \(\hat{\mathbf{x}}_{k|k} = \boldsymbol{\eta}^{(i_{\max})}\) を採用します。共分散は収束後のヤコビアンを使って通常のEKFと同様に更新します。IEKFは本質的に観測の対数尤度を最大化するガウス・ニュートン反復であり、\(h\) の非線形性が強く予測点と真の状態が離れている場合に、標準EKFより高精度な推定を与えることが知られています(反復回数1回で打ち切れば通常のEKFに一致します)。

UKF記事CKF記事 と同一のレンジ・ベアリング追跡シナリオ(目標がセンサーから距離1まで接近する軌道、初期推定を真値から大きくずらした追尾再獲得シナリオ、乱数シード42)を使って、通常のEKFとIEKF(反復回数5回)を比較します。本節は自己完結したコードとして再掲します(関数名は UKF記事 と揃えています)。

import numpy as np

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

def jacobian_F(dt):
    return np.array([
        [1, 0, dt, 0],
        [0, 1, 0, dt],
        [0, 0, 1, 0],
        [0, 0, 0, 1],
    ])

def observation(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],
    ])

def wrap(y):
    """方位角の残差を [-pi, pi] に正規化"""
    y = y.copy()
    y[1] = (y[1] + np.pi) % (2 * np.pi) - np.pi
    return y

# --- シミュレーション設定(UKF記事・CKF記事と同一) ---
np.random.seed(42)
dt = 1.0
T = 80

t = np.arange(0, T + 1) * dt
px_true = np.full(T + 1, 1.0)
py_true = -10.0 + 0.25 * t
true_states = np.column_stack([
    px_true, py_true, np.zeros(T + 1), np.full(T + 1, 0.25)
])

Q = np.diag([0.05, 0.05, 0.01, 0.01])
R = np.diag([0.3, 0.2])

measurements = np.array([
    observation(true_states[k]) + np.random.multivariate_normal([0, 0], R)
    for k in range(1, T + 1)
])

x0 = np.array([6.0, -3.0, 0.0, 0.1])
P0 = np.diag([16.0, 16.0, 1.0, 1.0])
class EKF:
    def __init__(self, x0, P0, Q, R, dt):
        self.x = x0.copy(); self.P = P0.copy()
        self.Q, self.R, self.dt = Q, R, dt

    def predict(self):
        F = jacobian_F(self.dt)
        self.x = state_transition(self.x, self.dt)
        self.P = F @ self.P @ F.T + self.Q

    def update(self, z):
        H = jacobian_H(self.x)
        y = wrap(z - observation(self.x))
        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(4) - K @ H) @ self.P


class IEKF:
    """Iterated EKF: 更新ステップ内でHを反復点etaで逐次再評価する(式19-21)"""
    def __init__(self, x0, P0, Q, R, dt, n_iter=5):
        self.x = x0.copy(); self.P = P0.copy()
        self.Q, self.R, self.dt, self.n_iter = Q, R, dt, n_iter

    def predict(self):
        F = jacobian_F(self.dt)
        self.x_pred = state_transition(self.x, self.dt)
        self.P_pred = F @ self.P @ F.T + self.Q
        self.x, self.P = self.x_pred.copy(), self.P_pred

    def update(self, z):
        eta = self.x_pred.copy()
        for _ in range(self.n_iter):
            H = jacobian_H(eta)
            S = H @ self.P_pred @ H.T + self.R
            K = self.P_pred @ H.T @ np.linalg.inv(S)
            innovation = wrap(z - observation(eta)) - H @ (self.x_pred - eta)
            eta = self.x_pred + K @ innovation  # 式21
        self.x = eta
        H = jacobian_H(self.x)
        S = H @ self.P_pred @ H.T + self.R
        K = self.P_pred @ H.T @ np.linalg.inv(S)
        self.P = (np.eye(4) - K @ H) @ self.P_pred
ekf = EKF(x0.copy(), P0.copy(), Q, R, dt)
ekf_estimates = [x0.copy()]
for k in range(T):
    ekf.predict()
    ekf.update(measurements[k].copy())
    ekf_estimates.append(ekf.x.copy())
ekf_estimates = np.array(ekf_estimates)

iekf = IEKF(x0.copy(), P0.copy(), Q, R, dt, n_iter=5)
iekf_estimates = [x0.copy()]
for k in range(T):
    iekf.predict()
    iekf.update(measurements[k].copy())
    iekf_estimates.append(iekf.x.copy())
iekf_estimates = np.array(iekf_estimates)

def rmse(est):
    return np.sqrt(np.mean(
        np.sum((true_states[1:, :2] - est[1:, :2])**2, axis=1)))

print(f"EKF Position RMSE: {rmse(ekf_estimates):.4f}")
print(f"IEKF Position RMSE: {rmse(iekf_estimates):.4f}")

実行すると、標準EKFのRMSEは1.7155UKF記事 と同一の実装・データで再現)であるのに対し、5回反復のIEKFのRMSEは1.4654となり、EKFに対して約14.6%の誤差削減を達成しました。興味深いことに、この値はシグマポイントを使うUKFのRMSE(1.5489)よりもさらに小さく、反復回数を3回にするとRMSEは1.4550まで改善します(反復回数1〜2回ではEKFからの改善は限定的で、3回以降は概ね頭打ちになります)。これは、この観測モデルの誤差が「1点でのヤコビアン評価のズレ」に起因する部分が大きく、そのズレを反復的な再線形化で解消するIEKFのアプローチが、シグマポイントによる分布近似(UKF)よりもこの特定の問題に対して効果的であることを示しています。ただし、UKF・CKFは状態遷移も含めた非線形変換全体を近似するのに対し、IEKFはあくまで観測更新ステップの線形化点を改善するだけである点には注意が必要です(本問題では状態遷移 \(f\) が線形なため、この違いは表面化しませんでした)。

EKF・UKF・CKFの精度と計算コストの比較

UKFの記事 はEKFとUKFの推定精度を、 CKFの記事 はUKFとCKFの数理的な等価性を、それぞれ同一のレンジ・ベアリング追跡シナリオで検証済みです。ここでは同じシナリオ・同じ state_transition/observation/EKF/Q/R/dt/T/x0/P0/measurements/true_states(前節で定義したもの)を使って、UKF・CKFクラスを新たに定義し、EKF・UKF・CKFの3手法を実際に実行します。(1)既存記事の精度が再現されることを確認した上で、(2)これまで比較されていなかった計算時間を実測します。

import time

class UKF:
    def __init__(self, n, m, f, h, Q, R, alpha=1.0, beta=2, kappa=0):
        self.n, self.m, self.f, self.h = n, m, f, h
        self.Q, self.R = Q, R
        self.lam = alpha**2 * (n + kappa) - n
        self.gamma = np.sqrt(n + self.lam)
        self.Wm = np.full(2 * n + 1, 1 / (2 * (n + self.lam)))
        self.Wc = np.full(2 * n + 1, 1 / (2 * (n + self.lam)))
        self.Wm[0] = self.lam / (n + self.lam)
        self.Wc[0] = self.lam / (n + self.lam) + (1 - alpha**2 + beta)

    def sigma_points(self, x, P):
        L = np.linalg.cholesky(P)
        sigmas = np.zeros((2 * self.n + 1, self.n))
        sigmas[0] = x
        for i in range(self.n):
            sigmas[i + 1] = x + self.gamma * L[:, i]
            sigmas[i + 1 + self.n] = x - self.gamma * L[:, i]
        return sigmas

    def predict(self, x, P, dt):
        sigmas = self.sigma_points(x, P)
        sigmas_pred = np.array([self.f(s, dt) for s in sigmas])
        x_pred = np.dot(self.Wm, sigmas_pred)
        P_pred = self.Q.copy()
        for i in range(2 * self.n + 1):
            d = sigmas_pred[i] - x_pred
            P_pred += self.Wc[i] * np.outer(d, d)
        return x_pred, P_pred, sigmas_pred

    def update(self, x_pred, P_pred, sigmas_pred, z):
        sigmas_z = np.array([self.h(s) for s in sigmas_pred])
        z_pred = np.dot(self.Wm, sigmas_z)
        Pzz = self.R.copy()
        Pxz = np.zeros((self.n, self.m))
        for i in range(2 * self.n + 1):
            dz = wrap(sigmas_z[i] - z_pred)
            dx = sigmas_pred[i] - x_pred
            Pzz += self.Wc[i] * np.outer(dz, dz)
            Pxz += self.Wc[i] * np.outer(dx, dz)
        y = wrap(z - z_pred)
        K = Pxz @ np.linalg.inv(Pzz)
        return x_pred + K @ y, P_pred - K @ Pzz @ K.T


class CKF:
    def __init__(self, n, m, f, h, Q, R):
        self.n, self.m, self.f, self.h = n, m, f, h
        self.Q, self.R = Q, R
        self.w = 1.0 / (2 * n)

    def cubature_points(self, x, P):
        L = np.linalg.cholesky(self.n * P)
        pts = np.zeros((2 * self.n, self.n))
        for i in range(self.n):
            pts[i] = x + L[:, i]
            pts[self.n + i] = x - L[:, i]
        return pts

    def predict(self, x, P, dt):
        X = self.cubature_points(x, P)
        X_pred = np.array([self.f(s, dt) for s in X])
        x_pred = self.w * X_pred.sum(axis=0)
        P_pred = self.Q.copy()
        for i in range(2 * self.n):
            d = X_pred[i] - x_pred
            P_pred += self.w * np.outer(d, d)
        return x_pred, P_pred

    def update(self, x_pred, P_pred, z):
        X = self.cubature_points(x_pred, P_pred)
        Y = np.array([self.h(s) for s in X])
        y_pred = self.w * Y.sum(axis=0)
        S = self.R.copy()
        C = np.zeros((self.n, self.m))
        for i in range(2 * self.n):
            dy = wrap(Y[i] - y_pred)
            dx = X[i] - x_pred
            S += self.w * np.outer(dy, dy)
            C += self.w * np.outer(dx, dy)
        y = wrap(z - y_pred)
        K = C @ np.linalg.inv(S)
        return x_pred + K @ y, P_pred - K @ S @ K.T
def run_ekf():
    ekf = EKF(x0.copy(), P0.copy(), Q, R, dt)
    est = [x0.copy()]
    for k in range(T):
        ekf.predict(); ekf.update(measurements[k].copy())
        est.append(ekf.x.copy())
    return np.array(est)

def run_ukf():
    ukf = UKF(4, 2, state_transition, observation, Q, R)
    x_est, P_est = x0.copy(), P0.copy()
    est = [x_est.copy()]
    for k in range(T):
        x_pred, P_pred, sig = ukf.predict(x_est, P_est, dt)
        x_est, P_est = ukf.update(x_pred, P_pred, sig, measurements[k].copy())
        est.append(x_est.copy())
    return np.array(est)

def run_ckf():
    ckf = CKF(4, 2, state_transition, observation, Q, R)
    x_est, P_est = x0.copy(), P0.copy()
    est = [x_est.copy()]
    for k in range(T):
        x_pred, P_pred = ckf.predict(x_est, P_est, dt)
        x_est, P_est = ckf.update(x_pred, P_pred, measurements[k].copy())
        est.append(x_est.copy())
    return np.array(est)

ekf_est, ukf_est, ckf_est = run_ekf(), run_ukf(), run_ckf()
print(f"EKF RMSE: {rmse(ekf_est):.4f}")
print(f"UKF RMSE: {rmse(ukf_est):.4f}")
print(f"CKF RMSE: {rmse(ckf_est):.4f}")

n_repeat = 200
for name, fn in [("EKF", run_ekf), ("UKF", run_ukf), ("CKF", run_ckf)]:
    t0 = time.perf_counter()
    for _ in range(n_repeat):
        fn()
    t1 = time.perf_counter()
    print(f"{name}: {(t1 - t0) / n_repeat * 1000:.4f} ms/run "
          f"({(t1 - t0) / n_repeat / T * 1e6:.2f} us/step)")

実行結果は次の通りです(80ステップ、乱数シード42、200回平均)。

手法位置RMSE実行時間(1系列あたり)1ステップあたり
EKF1.71551.40 ms17.5 μs
UKF1.54899.00 ms112.5 μs
CKF1.56739.03 ms112.8 μs

RMSEはEKF=1.7155・UKF=1.5489が UKF記事 の数値と完全に一致し、再現性を確認できました。CKFのRMSEは1.5673で、UKFとほぼ同等ですが完全一致はしません。これは CKFの記事 で指摘されている通り、UKFとCKFの数値的な等価性は更新ステップでキュバチャ点/シグマ点を予測共分散から再生成するか、予測済みの点を使い回すかという実装規約に依存するためで、本実験ではその規約を揃えていません。

一方、計算時間には明確な差が現れました。EKFはUKF・CKFに対して約6.4倍高速です。これは、EKFが状態次元 \(n=4\) に対してヤコビ行列を1回評価するだけで済むのに対し、UKF・CKFは1ステップあたり \(2n+1=9\) 個(UKF)または \(2n=8\) 個(CKF)のシグマ点/キュバチャ点それぞれについて非線形関数 \(f, h\) を評価し、さらにコレスキー分解を計算する必要があるためです。 UKF記事 の「EKFとの比較」表が示す計算コストの理論的なオーダー(EKF: \(O(n^2)\) 、UKF: \(O(n^3)\) )を、実測時間としても裏付ける結果です。この問題設定ではUKF/CKFの精度向上は10%未満にとどまる一方、計算コストは6倍以上に増えるため、非線形性がさほど強くない、あるいは計算資源が制約される用途(組み込み機器でのリアルタイム推定など)では、EKFが実務上合理的な選択肢になり得ます。

近年の研究動向

EKFは1960年代に提案された最も古典的な非線形フィルタですが、その単純さと低い計算コストから、SLAM・自動運転・ロボット状態推定の分野で現在も広く使われ続けています。近年(2023年以降)の研究動向としては、EKFを置き換えるのではなく、ニューラルネットワークと組み合わせて拡張する方向の研究が目立ちます。

Liu, Lai, Bacsa, Chatzi (2024) は “Neural Extended Kalman Filters for Learning and Predicting Dynamics of Structural Systems”(Structural Health Monitoring 誌)で、状態遷移関数・観測関数のパラメータ化をニューラルネットワークに置き換えた「Neural EKF」を提案しています。従来の変分推論ベースの手法が推論と力学モデルを別々のネットワークで学習するのに対し、Neural EKFはEKFの逐次フィルタリング構造そのものにニューラルネットワークを埋め込んで end-to-end に学習することで、構造物のような複雑な物理系の力学を学習・予測する精度を向上させています。

また、Eang & Lee (2024) は “An Integration of Deep Neural Network-Based Extended Kalman Filter (DNN-EKF) Method in Ultra-Wideband (UWB) Localization for Distance Loss Optimization”(Sensors 誌)で、屋内ロボットの測位においてDNNとEKFを統合し、UWB測距誤差の低減による測位精度向上を報告しています。

これらの研究に共通するのは、本記事で導出した「ヤコビ行列による線形化」という枠組み自体は維持しつつ、非線形関数 \(f, h\) (あるいはその補正項)をニューラルネットワークで学習することで、線形化誤差の原因となる曲率をデータから捉えようとするアプローチです。EKFは半世紀以上前の手法でありながら、UKF・粒子フィルタ・深層学習ベースの手法が普及した現在でも、計算コストの低さゆえに実時間システムへの実装で選ばれ続けており、その拡張は今も活発な研究領域です。

おすすめ書籍

カルマンフィルタの基礎(足立修一・丸田一郎、東京電機大学出版局)

カルマンフィルタの導出から実装例までを日本語で丁寧に解説した定番書です。状態空間モデルの理解が深まります。

※ 上記は Amazon アソシエイトのリンクです。

関連記事

参考文献

  • Thrun, S., Burgard, W., & Fox, D. (2005). “Probabilistic Robotics.” MIT Press.
  • Simon, D. (2006). “Optimal State Estimation: Kalman, H-infinity, and Nonlinear Approaches.” Wiley.
  • 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.
  • Liu, W., Lai, Z., Bacsa, K., & Chatzi, E. (2024). “Neural Extended Kalman Filters for Learning and Predicting Dynamics of Structural Systems.” Structural Health Monitoring, 23(2), 1037-1052.
  • Eang, C., & Lee, S. (2024). “An Integration of Deep Neural Network-Based Extended Kalman Filter (DNN-EKF) Method in Ultra-Wideband (UWB) Localization for Distance Loss Optimization.” Sensors, 24(21), 6959.