カルマンフィルタの理論とPython実装

カルマンフィルタ(Kalman Filter)の理論・状態空間モデル設計・Python実装を解説。predict/update の2ステップを持つ KalmanFilter クラスを実装し、np.linalg.inv でカルマンゲイン、np.eye で共分散更新、np.random.multivariate_normal でノイズ生成。1次元等速運動モデルの追跡例で ±2σ 信頼区間の評価と Q・R チューニングの指針まで整理します。

カルマンフィルタとは

カルマンフィルタ(Kalman Filter)は、線形ガウスシステムにおける最適な再帰的状態推定アルゴリズムです。ノイズを含む観測データから、システムの内部状態を逐次的に推定します。1960年にRudolf E. Kalmanによって提案されて以来、航法、制御、信号処理、経済学など幅広い分野で利用されています。

カルマンフィルタは、以下の拡張手法の基礎となります:

各手法の概要は フィルタリング手法の基礎 で紹介しています。本記事では、最も基本的なカルマンフィルタの理論と実装を解説します。

状態空間モデル

カルマンフィルタは、以下の線形状態空間モデルを前提とします。

状態遷移モデル:

\[ \mathbf{x}_k = A \mathbf{x}_{k-1} + B \mathbf{u}_{k-1} + \mathbf{w}_{k-1}, \quad \mathbf{w}_{k-1} \sim \mathcal{N}(\mathbf{0}, Q) \tag{1} \]

観測モデル:

\[ \mathbf{z}_k = H \mathbf{x}_k + \mathbf{v}_k, \quad \mathbf{v}_k \sim \mathcal{N}(\mathbf{0}, R) \tag{2} \]

各変数の意味は以下の通りです:

記号意味
\(\mathbf{x}_k\)時刻 \(k\) の状態ベクトル
\(A\)状態遷移行列
\(B\)制御入力行列
\(\mathbf{u}_{k-1}\)制御入力
\(\mathbf{w}_{k-1}\)プロセスノイズ(共分散 \(Q\) )
\(\mathbf{z}_k\)観測ベクトル
\(H\)観測行列
\(\mathbf{v}_k\)観測ノイズ(共分散 \(R\) )

アルゴリズム

カルマンフィルタは、予測ステップ更新ステップの2段階を交互に繰り返します。

予測ステップ

前時刻の推定値から、現時刻の状態を予測します。

予測状態:

\[ \hat{\mathbf{x}}_{k|k-1} = A \hat{\mathbf{x}}_{k-1|k-1} + B \mathbf{u}_{k-1} \tag{3} \]

予測共分散:

\[ P_{k|k-1} = A P_{k-1|k-1} A^T + Q \tag{4} \]

更新ステップ

新しい観測を取り込み、予測を修正します。

イノベーション(観測残差):

\[ \mathbf{y}_k = \mathbf{z}_k - H \hat{\mathbf{x}}_{k|k-1} \tag{5} \]

イノベーション共分散:

\[ S_k = H P_{k|k-1} H^T + R \tag{6} \]

カルマンゲイン:

\[ K_k = P_{k|k-1} H^T S_k^{-1} \tag{7} \]

更新状態:

\[ \hat{\mathbf{x}}_{k|k} = \hat{\mathbf{x}}_{k|k-1} + K_k \mathbf{y}_k \tag{8} \]

更新共分散:

\[ P_{k|k} = (I - K_k H) P_{k|k-1} \tag{9} \]

カルマンゲイン \(K_k\) は、観測ノイズとプロセスノイズのバランスに応じて予測と観測の信頼度を自動的に調整します。観測ノイズが小さい(\(R\) が小さい)場合は観測を重視し、プロセスノイズが小さい(\(Q\) が小さい)場合はモデル予測を重視します。

カルマンゲインの導出:なぜ \(K_k\) は最適なのか

前節では式(7)のカルマンゲイン \(K_k = P_{k|k-1} H^T S_k^{-1}\) を天下り的に与えました。ここでは、この \(K_k\) が「事後共分散のトレース \(\mathrm{tr}(P_{k|k})\) (=各状態成分の分散の総和、MMSE基準)を最小化する」という意味で最適であることを、(a) 直接最小化と (b) 直交原理の2通りで導出します。

事後誤差共分散の一般形(Joseph形式)

ゲイン \(K_k\) をまだ未知の行列として、更新式を

\[ \hat{\mathbf{x}}_{k|k} = \hat{\mathbf{x}}_{k|k-1} + K_k (\mathbf{z}_k - H \hat{\mathbf{x}}_{k|k-1}) \tag{10} \]

とおきます。事後誤差 \(\mathbf{e}_k = \mathbf{x}_k - \hat{\mathbf{x}}_{k|k}\) を、事前誤差 \(\mathbf{e}_k^- = \mathbf{x}_k - \hat{\mathbf{x}}_{k|k-1}\) (共分散 \(P_{k|k-1}\) )と観測ノイズ \(\mathbf{v}_k\) で書き直すと、

\[ \mathbf{e}_k = \mathbf{e}_k^- - K_k(H \mathbf{e}_k^- + \mathbf{v}_k) = (I - K_k H)\mathbf{e}_k^- - K_k \mathbf{v}_k \tag{11} \]

\(\mathbf{e}_k^-\) と \(\mathbf{v}_k\) は無相関(\(\mathbf{v}_k\) は時刻 \(k\) の観測ノイズで、それ以前の推定誤差とは独立)なので、共分散は交差項が消えて

\[ P_{k|k}(K_k) = (I - K_k H) P_{k|k-1} (I - K_k H)^T + K_k R K_k^T \tag{12} \]

となります。これは任意の \(K_k\) (最適でなくても)に対して成り立つ一般式で、Joseph安定化形式と呼ばれます。\(K_k\) に丸め誤差があっても \(P_{k|k}\) が対称・準正定値に保たれるため数値的に頑健で、実装で好んで使われます(詳しくは後述のエッジケースを参照)。

トレース最小化によるカルマンゲインの導出

MMSE基準は \(\mathrm{tr}(P_{k|k}(K_k))\) の最小化です。式(12)を展開し、トレースの巡回性 \(\mathrm{tr}(AB)=\mathrm{tr}(BA)\) を使うと、

\[ \mathrm{tr}(P_{k|k}) = \mathrm{tr}(P_{k|k-1}) - 2\,\mathrm{tr}(K_k H P_{k|k-1}) + \mathrm{tr}(K_k S_k K_k^T) \tag{13} \]

(\(S_k = H P_{k|k-1} H^T + R\) )が得られます。行列微分の公式 \(\partial\, \mathrm{tr}(K_k H P_{k|k-1})/\partial K_k = P_{k|k-1} H^T\) (\(P_{k|k-1}\) は対称)と \(\partial\, \mathrm{tr}(K_k S_k K_k^T)/\partial K_k = 2 K_k S_k\) (\(S_k\) も対称)を使って \(K_k\) で微分しゼロと置くと、

\[ \frac{\partial\, \mathrm{tr}(P_{k|k})}{\partial K_k} = -2 P_{k|k-1} H^T + 2 K_k S_k = 0 \tag{14} \]

これを \(K_k\) について解くと、まさに式(7)の

\[ K_k = P_{k|k-1} H^T S_k^{-1} \tag{15} \]

が得られます。\(\partial^2 \mathrm{tr}(P_{k|k})/\partial K_k^2 = 2 S_k \succ 0\) (\(S_k\) は正定値)なので、これは最小値であり鞍点ではありません。

この \(K_k\) を式(12)に戻すと、正規方程式 \(K_k S_k = P_{k|k-1}H^T\) の関係を使って

\[ P_{k|k} = P_{k|k-1} - K_k H P_{k|k-1} - P_{k|k-1}H^TK_k^T + K_kS_kK_k^T = P_{k|k-1} - K_k H P_{k|k-1} = (I - K_k H)P_{k|k-1} \tag{16} \]

(\(P_{k|k-1}H^TK_k^T = K_kS_kK_k^T\) を代入して中央2項が打ち消し合う)となり、式(9)の共分散更新式が導かれます。

直交原理による別証明

もう一つの見方は、線形MMSE推定の一般論である直交原理です。最適な線形推定量の誤差 \(\mathbf{e}_k\) は、推定に使った観測(の線形関数)と無相関でなければなりません。特に、イノベーション \(\mathbf{y}_k\) は「過去の観測からは予測できない真に新しい情報」なので、\(E[\mathbf{e}_k \mathbf{y}_k^T] = 0\) を要求します。式(11)より \(\mathbf{e}_k = \mathbf{e}_k^- - K_k \mathbf{y}_k\) なので、

\[ E[\mathbf{e}_k \mathbf{y}_k^T] = E[\mathbf{e}_k^- \mathbf{y}_k^T] - K_k E[\mathbf{y}_k \mathbf{y}_k^T] = P_{k|k-1}H^T - K_k S_k = 0 \tag{17} \]

(\(E[\mathbf{e}_k^- \mathbf{y}_k^T] = E[\mathbf{e}_k^-(H\mathbf{e}_k^- + \mathbf{v}_k)^T] = P_{k|k-1}H^T\) 、\(E[\mathbf{y}_k\mathbf{y}_k^T]=S_k\) を使用)。これを解くと同じ \(K_k = P_{k|k-1}H^T S_k^{-1}\) が得られ、トレース最小化と直交原理が同一の解を与えることが確認できます。

数値的検証

上記の導出が正しいことを、解析解のまわりにランダムな摂動を加えて数値的に確認します。

import numpy as np

rng = np.random.default_rng(3)

P_minus = np.array([[2.0, 0.5], [0.5, 1.0]])
H = np.array([[1.0, 0.0]])
R = np.array([[0.5]])
S = H @ P_minus @ H.T + R
K_star = P_minus @ H.T @ np.linalg.inv(S)  # 式(15)の解析解

def trace_P(K):
    """式(12): Joseph形式によるP_{k|k}のトレース"""
    I = np.eye(2)
    P = (I - K @ H) @ P_minus @ (I - K @ H).T + K @ R @ K.T
    return np.trace(P)

print(f"K* (解析解) = {K_star.ravel()}")
print(f"tr(P) at K* = {trace_P(K_star):.6f}")

worse_count = 0
for _ in range(20000):
    dK = rng.normal(scale=0.05, size=K_star.shape)
    if trace_P(K_star + dK) < trace_P(K_star) - 1e-9:
        worse_count += 1
print(f"K* より tr(P) を下げる摂動の数(20000試行中): {worse_count}")

実行結果は次の通りです:

K* (解析解) = [0.8 0.2]
tr(P) at K* = 1.300000
K* より tr(P) を下げる摂動の数(20000試行中): 0

\(K_k\) をランダムに20000通り摂動させても、解析解 \(K^*=[0.8,\ 0.2]\) より \(\mathrm{tr}(P_{k|k})\) を下げる方向は1つも見つかりません。式(15)が局所最小値であることの数値的裏付けです。

Python実装

1次元の等速運動モデルを使ってカルマンフィルタを実装します。

状態空間モデルの定義

状態ベクトル \(\mathbf{x} = [p, v]^T\) (位置、速度)として、以下のモデルを考えます:

\[ A = \begin{bmatrix} 1 & \Delta t \\\ 0 & 1 \end{bmatrix}, \quad H = \begin{bmatrix} 1 & 0 \end{bmatrix} \]

位置のみが観測可能で、速度はフィルタによって推定されます。

import numpy as np
import matplotlib.pyplot as plt

# ---- パラメータの設定 ----
dt = 1.0  # タイムステップ [s]

# 状態遷移行列(等速運動モデル)
A = np.array([[1, dt],
              [0,  1]])

# 観測行列(位置のみ観測可能)
H = np.array([[1, 0]])

# プロセスノイズ共分散(加速度ノイズから導出)
q = 0.1  # 加速度ノイズの分散
Q = q * np.array([[dt**3 / 3, dt**2 / 2],
                  [dt**2 / 2, dt]])

# 観測ノイズ共分散
R = np.array([[1.0]])

カルマンフィルタの実装

class KalmanFilter:
    """線形カルマンフィルタ"""

    def __init__(self, A, H, Q, R, x0, P0):
        self.A = A
        self.H = H
        self.Q = Q
        self.R = R
        self.x = x0.copy()
        self.P = P0.copy()

    def predict(self, u=None, B=None):
        """予測ステップ(式3, 4)"""
        self.x = self.A @ self.x
        if u is not None and B is not None:
            self.x += B @ u
        self.P = self.A @ self.P @ self.A.T + self.Q
        return self.x.copy(), self.P.copy()

    def update(self, z):
        """更新ステップ(式5-9)"""
        # イノベーション
        y = z - self.H @ self.x
        S = self.H @ self.P @ self.H.T + self.R

        # カルマンゲイン
        K = self.P @ self.H.T @ np.linalg.inv(S)

        # 状態と共分散の更新
        self.x = self.x + K @ y
        I = np.eye(len(self.x))
        self.P = (I - K @ self.H) @ self.P
        return self.x.copy(), self.P.copy()

シミュレーション

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

# 真の初期状態
x_true = np.array([0.0, 1.0])  # 位置0, 速度1

# カルマンフィルタの初期化
x0 = np.array([0.0, 0.0])     # 初期推定(速度を知らないと仮定)
P0 = np.diag([1.0, 1.0])      # 初期共分散
kf = KalmanFilter(A, H, Q, R, x0, P0)

# 記録用配列
true_positions = [x_true[0]]
true_velocities = [x_true[1]]
measurements = []
est_positions = [x0[0]]
est_velocities = [x0[1]]
P_history = [P0.copy()]

for k in range(T):
    # 真の状態の更新
    x_true = A @ x_true + np.random.multivariate_normal([0, 0], Q)
    true_positions.append(x_true[0])
    true_velocities.append(x_true[1])

    # 観測の生成
    z = H @ x_true + np.random.multivariate_normal([0], R)
    measurements.append(z[0])

    # カルマンフィルタ
    kf.predict()
    x_est, P_est = kf.update(z)
    est_positions.append(x_est[0])
    est_velocities.append(x_est[1])
    P_history.append(P_est.copy())

true_positions = np.array(true_positions)
true_velocities = np.array(true_velocities)
measurements = np.array(measurements)
est_positions = np.array(est_positions)
est_velocities = np.array(est_velocities)
P_history = np.array(P_history)

# ---- 結果のプロット ----
time = np.arange(T + 1)
sigma_pos = np.sqrt(P_history[:, 0, 0])

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

# 位置の推定
axes[0].plot(time, true_positions, "b-", linewidth=1.5, label="True position")
axes[0].scatter(time[1:], measurements, c="gray", s=15, alpha=0.5,
                label="Measurements", zorder=3)
axes[0].plot(time, est_positions, "r--", linewidth=1.2, label="KF estimate")
axes[0].fill_between(time,
                      est_positions - 2 * sigma_pos,
                      est_positions + 2 * sigma_pos,
                      color="red", alpha=0.15, label="$\pm 2\sigma$")
axes[0].set_ylabel("Position")
axes[0].set_title("Kalman Filter - 1D Constant Velocity Tracking")
axes[0].legend(loc="upper left", fontsize=9)
axes[0].grid(True, alpha=0.3)

# 速度の推定
sigma_vel = np.sqrt(P_history[:, 1, 1])
axes[1].plot(time, true_velocities, "b-", linewidth=1.5, label="True velocity")
axes[1].plot(time, est_velocities, "r--", linewidth=1.2, label="KF estimate")
axes[1].fill_between(time,
                      est_velocities - 2 * sigma_vel,
                      est_velocities + 2 * sigma_vel,
                      color="red", alpha=0.15, label="$\pm 2\sigma$")
axes[1].set_xlabel("Time step $k$")
axes[1].set_ylabel("Velocity")
axes[1].legend(loc="upper left", fontsize=9)
axes[1].grid(True, alpha=0.3)

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

# RMSE計算(フィルタなしの生観測とも比較)
rmse_pos = np.sqrt(np.mean((true_positions[1:] - est_positions[1:]) ** 2))
rmse_meas = np.sqrt(np.mean((true_positions[1:] - measurements) ** 2))
rmse_vel = np.sqrt(np.mean((true_velocities[1:] - est_velocities[1:]) ** 2))
print(f"Position RMSE (KF): {rmse_pos:.4f}")
print(f"Position RMSE (raw measurement): {rmse_meas:.4f}")
print(f"Velocity RMSE (KF): {rmse_vel:.4f}")
print(f"KF position error reduction vs raw measurement: {(1 - rmse_pos/rmse_meas)*100:.1f}%")

カルマンフィルタによる1次元等速運動の追跡結果。上段: 位置の真値・観測値・推定値と±2σ信頼区間。下段: 直接観測されない速度もフィルタにより精度良く推定されている

結果の考察

上記コードを実行すると、位置RMSEはKF推定0.6920に対し生観測(フィルタなし)は1.0473となり、カルマンフィルタが誤差を33.9%削減しています(速度RMSEは0.3752)。カルマンフィルタは数ステップで収束し、ノイズの多い観測から真の状態を精度良く推定できています。図の \(\pm 2\sigma\) 信頼区間が真の状態をよく覆っていることから、フィルタが推定の不確かさを適切に評価していることがわかります。

速度は直接観測されていないにもかかわらず、位置の観測から間接的に推定されている点がカルマンフィルタの特徴です。

パラメータの調整について:

  • \(Q\) (プロセスノイズ)を大きくする:モデルへの信頼が低下し、観測をより重視するため応答が速くなりますが、推定がノイズの影響を受けやすくなります
  • \(R\) (観測ノイズ)を大きくする:観測への信頼が低下し、モデル予測をより重視するため推定が滑らかになりますが、真の変化への追従が遅れます

カルマンフィルタは線形ガウスシステムに限定されます。非線形システムに対しては、ヤコビ行列による線形化を用いるEKF、シグマポイントを用いるUKFや CKF 、モンテカルロサンプリングに基づく 粒子フィルタ が用いられます。

エッジケースと実務上の落とし穴

理論上完璧なカルマンフィルタも、以下の3つの状況では実装や適用に注意が必要です。

イノベーション共分散 \(S\) の特異性

式(6)の \(S_k = H P_{k|k-1}H^T + R\) が特異または悪条件(ill-conditioned)になると、式(7)の \(S_k^{-1}\) の計算が数値的に不安定になります。代表的な原因は、(1) 観測ノイズを \(R=0\) と設定した場合(センサーが完全にノイズフリーだと仮定)、(2) 複数のセンサーが実質的に同じ量を重複して観測している場合、です。次のコードで挙動を確認します。

import numpy as np

np.random.seed(0)

P_minus = np.array([[1.0, 0.3], [0.3, 0.8]])

# 2つのセンサーがどちらも状態1番目(位置)を観測する「重複センサー」
H_dup = np.array([[1.0, 0.0],
                  [1.0, 0.0]])

for r_val, label in [(0.0, "R=0(重複ノイズフリーセンサー)"),
                     (1e-10, "R=1e-10(ほぼ重複、微小ノイズ)"),
                     (1e-2, "R=1e-2(典型的、良条件)")]:
    R_dup = r_val * np.eye(2)
    S = H_dup @ P_minus @ H_dup.T + R_dup
    cond = np.linalg.cond(S)
    print(f"--- {label} ---")
    print(f"cond(S) = {cond:.3e}")
    try:
        K = P_minus @ H_dup.T @ np.linalg.inv(S)
        print("np.linalg.inv(S) 成功")
    except np.linalg.LinAlgError as e:
        print(f"np.linalg.inv(S) 失敗: {e}")
    K_pinv = P_minus @ H_dup.T @ np.linalg.pinv(S)
    print(f"np.linalg.pinv(S) によるK =\n{K_pinv}\n")

実行結果:

--- R=0(重複ノイズフリーセンサー) ---
cond(S) = 1.394e+17
np.linalg.inv(S) 失敗: Singular matrix
np.linalg.pinv(S) によるK =
[[0.5  0.5 ]
 [0.15 0.15]]

--- R=1e-10(ほぼ重複、微小ノイズ) ---
cond(S) = 2.000e+10
np.linalg.pinv(S) によるK =
[[0.49999905 0.50000095]
 [0.14999967 0.15000018]]

--- R=1e-2(典型的、良条件) ---
cond(S) = 2.010e+02
np.linalg.pinv(S) によるK =
[[0.49751244 0.49751244]
 [0.14925373 0.14925373]]

\(R=0\) では条件数 \(\mathrm{cond}(S)\approx1.4\times10^{17}\) となり、np.linalg.inv は特異行列エラーで失敗します(\(R=10^{-10}\) でも条件数 \(2\times10^{10}\) と非常に悪条件です)。実務上の対策は次の通りです。

  • 観測ノイズを厳密に0にしない:数値的な下限(例えば想定分散の \(10^{-6}\) 〜\(10^{-9}\) 倍程度)を \(R\) に加える正則化(jitter)を行う
  • np.linalg.inv の代わりに np.linalg.solve(S, ...) を使う(逆行列を明示的に計算しないため高速かつ安定)
  • 重複・線形従属な観測が既知の場合は、事前に観測を統合・間引きしてから更新する
  • 病的なケースでは np.linalg.pinv(ムーア・ペンローズ逆行列)を使うと、特異行列でも破綻せず最小ノルム解が得られる(ただし最適性の保証は失われる)

フィルタの発散:プロセスノイズ \(Q\) の過小評価

カルマンフィルタが「自分の予測を過信し、新しい観測を無視するようになる」現象を**フィルタ発散(filter divergence)**と呼びます。典型的な原因は、真のシステムに存在するプロセスノイズより小さい \(Q\) をフィルタに設定してしまうことです。式(4)の \(P_{k|k-1}=AP_{k-1|k-1}A^T+Q\) が \(Q\) を過小評価すると、更新を繰り返すうちに \(P\) が実際の誤差より小さい値に収束し、式(7)のカルマンゲイン \(K_k\) がゼロに近づいて観測をほぼ無視するようになります。これは正のフィードバックループで、一度発散が始まると自己修復しません。

次のコードで、真のプロセスノイズ \(q_\text{true}=0.1\) (本記事のメインの例と同じ)に対し、フィルタが4桁小さい \(q_\text{filter}=10^{-6}\) を誤って使った場合の挙動を確認します。

import numpy as np

np.random.seed(7)

dt = 1.0
A = np.array([[1, dt], [0, 1]])
H = np.array([[1, 0]])

q_true = 0.1
Q_true = q_true * np.array([[dt**3/3, dt**2/2], [dt**2/2, dt]])
R = np.array([[1.0]])

q_filter = 1e-6  # 真値より4桁小さい、誤ったQ
Q_filter = q_filter * np.array([[dt**3/3, dt**2/2], [dt**2/2, dt]])

class KalmanFilter:
    def __init__(self, A, H, Q, R, x0, P0):
        self.A, self.H, self.Q, self.R = A, H, Q, R
        self.x, self.P = x0.copy(), P0.copy()

    def predict(self):
        self.x = self.A @ self.x
        self.P = self.A @ self.P @ self.A.T + self.Q
        return self.x.copy(), self.P.copy()

    def update(self, z):
        y = z - self.H @ self.x
        S = self.H @ self.P @ self.H.T + self.R
        K = self.P @ self.H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        I = np.eye(len(self.x))
        self.P = (I - K @ self.H) @ self.P
        return self.x.copy(), self.P.copy(), K.copy()

T = 300
x_true = np.array([0.0, 1.0])
kf = KalmanFilter(A, H, Q_filter, R, np.array([0.0, 1.0]), np.diag([1.0, 1.0]))

true_pos, est_pos, P_pos, gains, nees = [x_true[0]], [0.0], [1.0], [], []
for k in range(T):
    x_true = A @ x_true + np.random.multivariate_normal([0, 0], Q_true)
    true_pos.append(x_true[0])
    z = H @ x_true + np.random.multivariate_normal([0], R)
    kf.predict()
    x_est, P_est, K = kf.update(z)
    est_pos.append(x_est[0])
    P_pos.append(P_est[0, 0])
    gains.append(K[0, 0])
    err = (x_true - x_est).reshape(-1, 1)
    nees.append(float((err.T @ np.linalg.inv(P_est) @ err).item()))

true_pos, est_pos, P_pos = np.array(true_pos), np.array(est_pos), np.array(P_pos)
nees = np.array(nees)

print(f"P_pos: k=10 -> {P_pos[10]:.4f}, k=100 -> {P_pos[100]:.4f}, k=299 -> {P_pos[299]:.4f}")
print(f"Kalman gain K_pos: k=10 -> {gains[10]:.4f}, k=100 -> {gains[100]:.4f}, k=299 -> {gains[299]:.4f}")
print(f"平均NEES(直近100ステップ): {nees[-100:].mean():.1f}(整合していれば1近傍、カイ二乗検定99%棄却域は6.63)")
print(f"RMSE(直近100ステップ): {np.sqrt(np.mean((true_pos[-100:]-est_pos[-100:])**2)):.4f}")
print(f"フィルタが報告するsigma(直近100ステップ平均): {np.sqrt(P_pos[-100:]).mean():.4f}")

実行結果:

P_pos: k=10 -> 0.3161, k=100 -> 0.0469, k=299 -> 0.0437
Kalman gain K_pos: k=10 -> 0.2934, k=100 -> 0.0467, k=299 -> 0.0437
平均NEES(直近100ステップ): 48924.8(整合していれば1近傍、カイ二乗検定99%棄却域は6.63)
RMSE(直近100ステップ): 11.9092
フィルタが報告するsigma(直近100ステップ平均): 0.2091

カルマンフィルタの発散デモ。上段: プロセスノイズを過小評価したフィルタの推定値(赤破線)が真の位置(青実線)から乖離していく。下段: 実際の推定誤差(赤、対数軸)とフィルタが報告する±2σ帯(青破線)を比較すると、報告される不確実性が実際の誤差を大きく下回っている(過信=発散の兆候)

k=10ではまだゲイン0.29程度で観測を取り込んでいますが、k=100以降はゲインが0.04程度まで下がり、以後ほぼ一定のまま観測をほとんど信頼しなくなります。結果として、フィルタが「自信を持って」報告する標準偏差は0.21程度にとどまるのに対し、実際のRMSEは11.9(50倍以上)に達し、平均NEES(正規化推定誤差二乗 \(\mathbf{e}_k^T P_{k|k}^{-1}\mathbf{e}_k\) )は整合したフィルタで期待される1近傍に対し約49000という異常値になっています。これは典型的な発散の兆候(実際の誤差は大きいのに、報告される共分散は小さいまま)です。対策としては、(1) プロセスノイズ \(Q\) を保守的に(やや大きめに)見積もる、(2) NEESやイノベーションの整合性検定(\(\chi^2\) 検定)でオンライン監視する、(3) 観測データから \(Q\) ・\(R\) を逐次推定する適応型カルマンフィルタを用いる、(4) 共分散膨張(covariance inflation。 アンサンブルカルマンフィルタ でよく使われる手法)で人為的に \(P\) を底上げする、などが実務で使われます。

可観測性:状態が収束しないケース

カルマンフィルタが真の状態に収束するには、系が**可観測(observable)**である必要があります。線形時不変系の可観測性は、可観測性行列

\[ \mathcal{O} = \begin{bmatrix} H \\ HA \\ HA^2 \\ \vdots \\ HA^{n-1} \end{bmatrix} \tag{18} \]

(\(n\) は状態次元)のランクで判定でき、\(\mathrm{rank}(\mathcal{O})=n\) なら可観測です。\(\mathrm{rank}(\mathcal{O})<n\) の場合、\(n-\mathrm{rank}(\mathcal{O})\) 次元の「不可観測な方向」が存在し、その方向の推定誤差は観測をいくら増やしても縮小しません。

具体例として、位置 \(p\) ・速度 \(v\) に加えて、センサーの一定バイアス \(b\) を状態に含めた3次元系を考えます。観測はバイアスを含んだ \(z_k=p_k+b_k+v_k\) (\(p\) 単体でも\(b\) 単体でもなく、和だけが観測される)とします。

import numpy as np

np.random.seed(11)

dt = 1.0
A = np.array([[1, dt, 0], [0, 1, 0], [0, 0, 1]])  # 状態 [p, v, b]。bは一定
H = np.array([[1, 0, 1]])  # p + b のみ観測、pもbも単体では観測されない

O = np.vstack([H @ np.linalg.matrix_power(A, i) for i in range(3)])
rank = np.linalg.matrix_rank(O)
print("可観測性行列 O:")
print(O)
print(f"rank(O) = {rank}  (状態次元 n=3)")
print("可観測" if rank == 3 else "不可観測")

実行結果:

可観測性行列 O:
[[1. 0. 1.]
 [1. 1. 1.]
 [1. 2. 1.]]
rank(O) = 2  (状態次元 n=3)
不可観測

\(\mathrm{rank}(\mathcal{O})=2<3\) なので不可観測です。実際、\(O\) の1列目(\(p\) )と3列目(\(b\) )は常に等しく、\(p\) と\(b\) のの方向を観測から分離することはできません(\(p+b\) という和の情報しか得られません)。フィルタを走らせて確認します。

class KalmanFilter:
    def __init__(self, A, H, Q, R, x0, P0):
        self.A, self.H, self.Q, self.R = A, H, Q, R
        self.x, self.P = x0.copy(), P0.copy()

    def predict(self):
        self.x = self.A @ self.x
        self.P = self.A @ self.P @ self.A.T + self.Q
        return self.x.copy(), self.P.copy()

    def update(self, z):
        y = z - self.H @ self.x
        S = self.H @ self.P @ self.H.T + self.R
        K = self.P @ self.H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        I = np.eye(len(self.x))
        self.P = (I - K @ self.H) @ self.P
        return self.x.copy(), self.P.copy()

Q = np.diag([0.01, 0.01, 1e-8])
R = np.array([[0.25]])
x_true = np.array([0.0, 1.0, 2.0])  # 真のバイアス b=2.0
kf = KalmanFilter(A, H, Q, R, np.array([0.0, 1.0, 0.0]), np.diag([1.0, 1.0, 4.0]))

P_b_hist, err_b_hist = [], []
for k in range(400):
    x_true = A @ x_true  # 真値はノイズなしで決定論的に遷移(可観測性のみに着目するため)
    z = H @ x_true + np.random.multivariate_normal([0], R)
    kf.predict()
    x_est, P_est = kf.update(z)
    P_b_hist.append(P_est[2, 2])
    err_b_hist.append(x_est[2] - x_true[2])

print(f"P_bias: k=10 -> {P_b_hist[9]:.4f}, k=50 -> {P_b_hist[49]:.4f}, k=399 -> {P_b_hist[398]:.4f}")
print(f"バイアス推定誤差: k=399 -> {err_b_hist[398]:.4f}")

実行結果:

P_bias: k=10 -> 0.9427, k=50 -> 0.9423, k=399 -> 0.9423
バイアス推定誤差: k=399 -> -0.1808

バイアスの分散 \(P_{bb}\) は最初の10ステップ程度で0.94付近まで下がりますが、そこから389ステップ分の追加観測を経てもまったく下がりません。バイアス推定誤差も \(-0.18\) 程度で下げ止まったまま、観測をいくら増やしても解消されません。一方、\(p+b\) (観測可能な組み合わせ)の分散はその後も単調に縮小し続けます。図に両者を示します。

カルマンフィルタの可観測性デモ。上段: 不可観測な方向であるバイアス推定誤差(赤)・位置推定誤差(紫)は0近傍で下げ止まる一方、観測可能な組み合わせ p+b の推定誤差(青破線)は継続的に縮小する。下段: バイアスの分散 P_bb(赤)はk≈10で下げ止まって以後変化しないが、p+b の分散(青破線)は縮小し続ける

これは、フィルタの実装が正しくても、モデル構造自体が可観測でなければ真値には収束しないことを示す典型例です。実務では、加速度計・ジャイロのバイアス推定、GPS/INS統合航法など「直接観測できない量」をあえて状態に含める設計が頻出するため、事前に可観測性行列のランクを確認することが推奨されます。不可観測な方向がある場合は、(1) 別のセンサーを追加して観測行列 \(H\) のランクを上げる、(2) 不可観測な状態を状態ベクトルから除いて別途キャリブレーションする、(3) 十分な励起(時間変化するダイナミクスを与え、運動させてからバイアスを推定するなど)を与えて時変システムとして可観測にする、といった対策が取られます。

最近の研究動向

カルマンフィルタは60年以上前に提案された手法ですが、理論・応用ともに活発な研究が続いています。

  • Shlezinger, Revach, Ghosh, Chatterjee, Tang, Imbiriba, Dunik, Straka, Closas, & Eldar (2024) の “AI-Aided Kalman Filters”(arXiv:2410.12289、2025年5月改訂版)は、状態空間モデルが不正確・不完全にしか分からない状況で深層ニューラルネットワークと古典的カルマンフィルタを融合させる手法群(KalmanNetなど)を体系的にレビューし、タスク指向型とモデル指向型の2系統に分類した上でそれぞれの強み・限界を整理しています。本記事で導出したカルマンゲイン \(K_k=P^-H^TS^{-1}\) (式15)をRNNが学習するゲインで置き換えるアプローチが代表例です。
  • Mortada, Falcon, Kahil, Clavaud, & Michel (2025) の “Recursive KalmanNet: Deep Learning-Augmented Kalman Filtering for State Estimation with Consistent Uncertainty Quantification”(arXiv:2506.11639)は、本記事で導出したJoseph形式の共分散再帰式(式12)をニューラルネットの学習に組み込み、非ガウス観測ノイズ下でも一貫した不確実性(共分散)推定を維持しながら、古典的カルマンフィルタや既存の深層学習ベース手法を上回る精度を達成したと報告しています。
  • Xie, Gan, & Liu (2024) の “Stability analysis of distributed Kalman filtering algorithm for stochastic regression model”(arXiv:2411.01198)は、中央集権的な融合ノードを持たない分散カルマンフィルタの安定性を、信号が独立・定常でない現実的な条件下で解析し、個々のセンサーだけでは可観測でない量も複数センサーの協調によって推定可能になる条件を示しています。本記事の可観測性の議論(式18)を多センサー系に拡張したものと捉えられます。

これらはいずれも、本記事で導出した最適ゲインの式(7)・共分散更新式(9)を出発点として、モデルの不確実性・非ガウス性・分散システムへと拡張する方向の研究です。

おすすめ書籍

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

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

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

関連記事

参考文献

  • Welch, G., & Bishop, G. (2006). “An Introduction to the Kalman Filter.” UNC Chapel Hill TR 95-041.
  • Thrun, S., Burgard, W., & Fox, D. (2005). “Probabilistic Robotics.” MIT Press.