Pythonで学ぶ拡張カルマン・フィルタ(EKF)入門 ― 「非線形」な現実世界を「ヤコビ行列」で攻略する

Python
この記事は約20分で読めます。

こんにちは、JS2IIUです。

これまで、「1次元カルマン・フィルタ」で基礎を固め、「2次元カルマン・フィルタ」で行列演算を用いたマルチトラッキングを体験してきました。ここまでは順調でしたね。しかし、私たちが生きているこの現実世界には、ある残酷な事実があります。

それは、「世界は直線でできていない」ということです。

前回のモデルでは「等速直線運動」を仮定しましたが、現実の車はハンドルを切ればカーブします。ドローンは複雑に姿勢を変えます。ロボットアームは円弧を描いて動きます。これらはいずれも、入力と出力が比例関係にない「非線形システム (Non-linear System)」です。

線形(直線)を前提とした標準的なカルマン・フィルタに、カーブ全開のデータを入力するとどうなるでしょうか?予測は破綻し、推定値はあさっての方向へ飛んでいきます。

そこで登場するのが、実務レベルのロボット制御や自動運転でデファクトスタンダードとして君臨する「拡張カルマン・フィルタ(Extended Kalman Filter: EKF)」です。

この記事では、EKFがどのようにして非線形な現象を扱っているのか、その数学的なトリックである「線形化」「ヤコビ行列」の正体を暴き、Pythonコードで実際にカーブするロボットを追跡してみます。今回もよろしくお願いします

1. 概念編

なぜ標準カルマン・フィルタではダメなのか?

標準のカルマン・フィルタは、以下の式(状態空間モデル)が成り立つことを前提としています。

$$
\mathbf{x}_{t+1} = F \mathbf{x}_t + B \mathbf{u}_t
$$

ここで \( F \) は定数の行列です。これは「次の状態は、今の状態の定数倍(線形結合)になる」と言っています。
しかし、例えば車がハンドルを角度 \( \theta \) だけ切って速度 \( v \) で進む場合、位置の変化は以下のように三角関数を含んだ形になります。

$$
x_{t+1} = x_t + v \cos(\theta) \Delta t \
y_{t+1} = y_t + v \sin(\theta) \Delta t
$$

\( \cos \) や \( \sin \) は非線形関数です。これを無理やり (F\mathbf{x}) という行列の掛け算だけで表現することは不可能です。ここで標準カルマン・フィルタは無力化されます。

解決策:曲がっているなら、ズームすればいい

この問題に対するEKFの解決策は非常にシンプルで、かつ強力です。
「どんなに曲がっている曲線でも、顕微鏡で拡大して見れば、その一瞬だけは直線に見えるはずだ」

これが線形化 (Linearization) の考え方です。
数学的には、ある一点における接線を引くことに相当します。これをテーラー展開 (Taylor Series Expansion) の1次近似と呼びます。

EKFは、予測を行うたびに「今のこの場所において、カーブはどの方向へ向かっているか(接線の傾き)」を計算し、「その一瞬だけ有効な行列 \(F\) 」 を動的に作り出します。この「動的な傾き行列」こそが、今回の主役であるヤコビ行列 (Jacobian Matrix) です。

「ヤコビ行列」の正体

「ヤコビ行列」と聞くと身構えてしまうかもしれませんが、恐れる必要はありません。
1変数の関数 \( y = f(x) \) の傾きは、微分 \( \frac{dy}{dx} \) で求められました。
ヤコビ行列は、これを多変数(ベクトル)に拡張しただけのものです。

入力が複数( \( x, y, \theta \) )、出力も複数(次の \( x, y, \theta \) )あるシステムにおいて、「\( x \) がちょっと変わったら出力はどう変わる?」「 \( \theta \) がちょっと変わったら?」という「変化率の総当たり表」。それがヤコビ行列です。

2. 実装準備:ロボットの動きをモデル化する

今回は、ロボット工学で最も一般的な「対向2輪モデル(差動二輪)」を扱います。ルンバのような、左右のタイヤの速度差で旋回するロボットをイメージしてください。

状態ベクトルと制御入力

ロボットの状態 \( \mathbf{x} \) と、ロボットへの指令(制御入力)\( \mathbf{u} \) を以下のように定義します。

  • 状態 \( \mathbf{x} \): \([x, y, \theta]^T \) (位置X, 位置Y, 向いている角度)
  • 入力 \( \mathbf{u} \): \([v, \omega]^T \) (並進速度, 回頭角速度)

状態方程式(非線形モデル)

時間 \( \Delta t \) 後の状態を計算する関数 \( f(\mathbf{x}, \mathbf{u}) \) は以下のようになります。

$$
\begin{bmatrix} x’ \\ y’ \\ \theta’ \end{bmatrix} =
\begin{bmatrix} x \\ y \\ \theta \end{bmatrix} +
\begin{bmatrix} v \cos(\theta) \Delta t \\ v \sin(\theta) \Delta t \\ \omega \Delta t \end{bmatrix}
$$

これをPythonコードで書くとこうなります。

Python
import numpy as np

def motion_model(x, u, dt):
    """
    非線形な移動モデル
    x: 状態ベクトル [x, y, theta]
    u: 制御入力 [v, omega]
    """
    theta = x[2]
    v = u[0]
    omega = u[1]

    # 状態遷移 (非線形)
    x_next = x[0] + v * np.cos(theta) * dt
    y_next = x[1] + v * np.sin(theta) * dt
    theta_next = theta + omega * dt

    return np.array([x_next, y_next, theta_next])

ヤコビ行列の導出

さて、ここでEKFのためにヤコビ行列 \( J_F \) を計算する必要があります。
上記の式を、状態変数 \( x, y, \theta \) でそれぞれ偏微分 (Partial Differentiation) します。

$$
J_F = \frac{\partial f}{\partial \mathbf{x}} =
\begin{bmatrix}
\frac{\partial x’}{\partial x} & \frac{\partial x’}{\partial y} & \frac{\partial x’}{\partial \theta} \\
\frac{\partial y’}{\partial x} & \frac{\partial y’}{\partial y} & \frac{\partial y’}{\partial \theta} \\
\frac{\partial \theta’}{\partial x} & \frac{\partial \theta’}{\partial y} & \frac{\partial \theta’}{\partial \theta}
\end{bmatrix}=
\begin{bmatrix}
1 & 0 & -v \sin(\theta) \Delta t \\
0 & 1 & v \cos(\theta) \Delta t \\
0 & 0 & 1
\end{bmatrix}
$$

この行列が、「現在の角度 \( \theta \) と速度 \( v \) における、次の瞬間の変化の傾向」を表しています。これをコードに落とし込みます。

Python
def jacobian_f(x, u, dt):
    """
    状態遷移関数のヤコビ行列を計算
    """
    theta = x[2]
    v = u[0]

    # 上記の数式をそのままコードにする
    # 3x3 行列
    J = np.array([
        [1.0, 0.0, -v * np.sin(theta) * dt],
        [0.0, 1.0,  v * np.cos(theta) * dt],
        [0.0, 0.0,  1.0]
    ])
    return J

コラム:数式が苦手なあなたへ(自動微分の活用)
「偏微分なんて手計算したくない!」という方もいるでしょう。現代のAI開発では、PyTorchJAXSymPyといったライブラリを使えば、関数を定義するだけで自動的にヤコビ行列を計算してくれます(自動微分)。今回は基本を理解するためにNumPyで書き下しますが、実務ではそれらを使うのが賢い選択です。

3. 実践編:PythonによるEKFの実装

それでは、EKFのクラスを作成します。
標準KFとの最大の違いは、predictメソッド内で「単純な行列の掛け算」ではなく「非線形関数の実行」と「ヤコビ行列の計算」を行う点です。

Python
class ExtendedKalmanFilter:
    def __init__(self, dt, initial_state, initial_cov, process_noise, measurement_noise):
        self.dt = dt
        self.x = initial_state # [x, y, theta]
        self.P = initial_cov   # 共分散行列
        self.Q = process_noise # プロセスノイズ Q
        self.R = measurement_noise # 観測ノイズ R

    def predict(self, u):
        """
        予測ステップ (Time Update)
        """
        # 1. 非線形関数による状態予測
        # x = f(x, u)
        self.x = motion_model(self.x, u, self.dt)

        # 2. ヤコビ行列 F の計算
        F = jacobian_f(self.x, u, self.dt)

        # 3. 誤差共分散の予測
        # P = FPF^T + Q (Fはヤコビ行列)
        self.P = F @ self.P @ F.T + self.Q

    def update(self, z):
        """
        更新ステップ (Measurement Update)
        ここでは観測モデルは線形と仮定 (GPSで直接 x, y を測れる)
        もし観測も非線形(レーダーの距離と角度など)なら、ここでもヤコビ行列 H が必要。
        """
        # 観測行列 H (3次元の状態から x, y だけを取り出す)
        H = np.array([
            [1, 0, 0],
            [0, 1, 0]
        ])

        # 観測残差
        y = z - H @ self.x

        # --- 以下は標準KFと同じ ---
        S = H @ self.P @ H.T + self.R
        K = self.P @ H.T @ np.linalg.inv(S)

        # 推定値の更新
        self.x = self.x + K @ y

        # 共分散の更新
        I = np.eye(len(self.x))
        self.P = (I - K @ H) @ self.P

比較実験:8の字走行

EKFの威力を確認するため、ロボットに「8の字」を描くような複雑な動きをさせてみましょう。
比較対象として、オドメトリ(制御入力の積み上げ)のみの場合と、EKFで観測値を取り入れた場合を表示します。

Python
import matplotlib.pyplot as plt

# --- シミュレーション設定 ---
dt = 0.1
steps = 1000

# 真の状態・推定値の保存用
true_states = []
dead_reckoning = [] # 推測航法(補正なし)
ekf_estimates = []
measurements = []

# 初期状態
state_true = np.array([0.0, 0.0, 0.0])
state_dr = np.array([0.0, 0.0, 0.0]) # Dead Reckoning用

# EKF初期化
Q = np.diag([0.1, 0.1, np.deg2rad(1.0)]) ** 2  # プロセスノイズ
R = np.diag([1.0, 1.0]) ** 2                   # 観測ノイズ (GPS精度)
ekf = ExtendedKalmanFilter(dt, np.zeros(3), np.eye(3), Q, R)

# --- ループ実行 ---
for i in range(steps):
    t = i * dt

    # 制御入力生成 (8の字を描くように角速度を変化させる)
    v = 1.0  # 一定速度
    omega = 0.2 * np.sin(t / 10.0) # 角速度を正弦波で揺らす
    u = np.array([v, omega])

    # 1. 真の状態を更新 (ノイズなし)
    state_true = motion_model(state_true, u, dt)
    true_states.append(state_true)

    # 2. 推測航法 (Dead Reckoning) - ノイズを含んだ入力のみで計算
    # 実際の制御にはノイズが乗る
    u_noisy = u + np.random.normal(0, [0.1, 0.05]) 
    state_dr = motion_model(state_dr, u_noisy, dt)
    dead_reckoning.append(state_dr)

    # 3. 観測データの生成 (GPS) - 真の位置にノイズを乗せる
    z = state_true[:2] + np.random.normal(0, 1.0, 2)
    measurements.append(z)

    # 4. EKFの実行
    ekf.predict(u_noisy) # ノイズありの入力で予測
    ekf.update(z)        # ノイズありの観測で修正
    ekf_estimates.append(ekf.x)

# --- NumPy配列へ変換 ---
true_states = np.array(true_states)
dead_reckoning = np.array(dead_reckoning)
ekf_estimates = np.array(ekf_estimates)
measurements = np.array(measurements)

# --- 可視化 ---
plt.figure(figsize=(10, 8))
plt.plot(true_states[:, 0], true_states[:, 1], 'k-', label="Ground Truth", linewidth=2)
plt.plot(dead_reckoning[:, 0], dead_reckoning[:, 1], 'r--', label="Dead Reckoning (No Correction)")
plt.scatter(measurements[:, 0], measurements[:, 1], c='gray', s=5, label="GPS Measurements", alpha=0.5)
plt.plot(ekf_estimates[:, 0], ekf_estimates[:, 1], 'b-', label="EKF Estimate", linewidth=3)

plt.title("EKF Robot Tracking (Figure 8 Path)")
plt.xlabel("X [m]")
plt.ylabel("Y [m]")
plt.legend()
plt.grid(True)
plt.axis("equal")
plt.show()

結果の考察

このコードを実行すると、以下の現象が確認できるはずです。

  1. 黒い線(真の値): きれいな8の字を描いています。
  2. 赤い点線(補正なし): 制御入力のノイズが累積し、時間とともに真の軌道からどんどん離れていき、形が崩れてしまいます。これがオドメトリ(推測航法)の限界です。
  3. 灰色の点(観測値): 真の軌道の周りにバラついていますが、全体的な形状は捉えています。
  4. 青い線(EKF): 赤い線のように発散することなく、かといって灰色の点のようにガタつくこともなく、滑らかに黒い線(真の値)を追従し続けています。

これこそが、非線形モデルとヤコビ行列を用いたEKFの効果です。「曲がる」というモデルの性質を正しく理解しているため、少ない情報から高い精度で位置を推定できるのです。

4. まとめと展望

今回は、カルマン・フィルタを実世界の問題に応用するための「拡張カルマン・フィルタ(EKF)」について解説しました。

今回のポイント

  • 非線形システム: 現実世界(ロボットや車)は直線だけでは表現できない。
  • 線形化: 「局所的には直線である」という仮定で近似する。
  • ヤコビ行列: 多変数の変化率を表す行列。これを毎回計算することで、非線形モデルを扱えるようになる。

さらに高度な技術へ

EKFは強力ですが、弱点もあります。ヤコビ行列の計算が複雑で面倒なことや、近似精度が悪くなると推定が破綻することです。
これらを解決するために、以下のような発展手法が存在します。

  • Unscented Kalman Filter (UKF): ヤコビ行列を計算せず、「シグマ点」という代表点を飛ばして分布の変化を近似する手法。実装が楽で精度も高い場合が多いです。
  • パーティクル・フィルタ (Particle Filter): ガウス分布すら仮定しない手法。確率の粒(粒子)を大量にばら撒いて推定します。計算量は多いですが、最強の柔軟性を持ちます。

しかし、すべての基礎は今回のEKFにあります。「モデルを作って、近似して、修正する」というエンジニアリングの神髄を、ぜひこのコードから感じ取ってください。

最後まで読んでいただきありがとうございました。

コメント

タイトルとURLをコピーしました