Cubature Kalman Filter(CKF)の理論とPython実装

Cubature Kalman Filter(キュバチャカルマンフィルタ)のアルゴリズムをPythonで実装します。UKFとの違いやシグマポイントの選び方を数式とともに解説します。

Cubature Kalman Filterとは

Unscented変換(UT) では、非線形変換後の確率変数の平均と共分散を、少数のシグマポイントを用いて効率的に推定する方法を紹介しました。Unscented Kalman Filter(UKF)はこのUTをカルマンフィルタの予測・更新ステップに適用したものです。

しかし、UKFには以下の問題があります:

  • パラメータ \(\alpha, \beta, \kappa\) の調整が必要
  • 高次元になると重みが負になり、共分散行列の正定値性が保証されない

Cubature Kalman Filter(CKF) は、球面-放射キュバチャ則(spherical-radial cubature rule)に基づいてシグマポイント(キュバチャポイント)を選択することで、これらの問題を解決します。CKFはパラメータのチューニングが不要で、すべての重みが正であるため、数値的に安定です。

参考文献:Arasaratnam, I., & Haykin, S. (2009). “Cubature Kalman Filters.” IEEE Transactions on Automatic Control, 54(6), 1254-1269.

理論

ガウス加重積分

非線形フィルタリングでは、以下のガウス加重積分を計算する必要があります:

\[ I = \int_{\mathbb{R}^n} f(\mathbf{x}) \cdot \mathcal{N}(\mathbf{x}; \boldsymbol{\mu}, \mathbf{P}) \, d\mathbf{x} \]

変数変換 \(\mathbf{x} = \sqrt{\mathbf{P}} \mathbf{z} + \boldsymbol{\mu}\) により、標準正規分布上の積分に変換できます:

\[ I = \int_{\mathbb{R}^n} f(\sqrt{\mathbf{P}} \mathbf{z} + \boldsymbol{\mu}) \cdot \mathcal{N}(\mathbf{z}; \mathbf{0}, \mathbf{I}) \, d\mathbf{z} \]

球面-放射キュバチャ則

標準正規分布上の積分を、球面座標と放射座標に分離します。\(\mathbf{z} = r \mathbf{s}\) と変換すると( \(r = \|\mathbf{z}\|\) , \(\mathbf{s} = \mathbf{z}/\|\mathbf{z}\|\) ):

\[ I = \int_0^{\infty} \int_{U_n} f(r \mathbf{s}) \cdot r^{n-1} e^{-r^2/2} \, d\sigma(\mathbf{s}) \, dr \cdot \frac{1}{(2\pi)^{n/2}} \]

ここで \(U_n\) は \(n\) 次元単位球面、\(d\sigma(\mathbf{s})\) は球面上の面積要素です。

3次キュバチャ則では、球面上の積分点として \(2n\) 個の点を使います:

\[ \boldsymbol{\xi}_i = \sqrt{n} \, \mathbf{e}_i, \quad i = 1, \ldots, 2n \]

ここで \(\mathbf{e}_i\) は以下のように定義されます:

\[ \mathbf{e}_i = \begin{cases} \text{第 } i \text{ 単位ベクトル} & i = 1, \ldots, n \\\ -\text{第 } (i-n) \text{ 単位ベクトル} & i = n+1, \ldots, 2n \end{cases} \]

各キュバチャポイントの重みはすべて等しく:

\[ w_i = \frac{1}{2n}, \quad i = 1, \ldots, 2n \tag{1} \]

3次キュバチャ則の証明:なぜこの \(2n\) 点で十分か

上式のキュバチャ点と重みが「3次キュバチャ則」と呼ばれる理由を、天下り的にではなく直接確認します。示すべきことは、標準正規分布 \(\mathcal{N}(\mathbf{z}; \mathbf{0}, \mathbf{I}_n)\) に関する多項式の期待値のうち、総次数が3以下のものを、\(2n\) 点・等重み \(1/(2n)\) のこの離散近似が厳密に再現し、4次以上では一般に厳密でなくなる、という事実です。

キュバチャ点 \(\boldsymbol{\xi}_i = \pm\sqrt{n}\,\mathbf{e}_j\) (\(j=1,\ldots,n\) )は、いずれか1つの座標だけが \(\pm\sqrt n\) で、残り \(n-1\) 個の座標はすべて0であるという単純な構造を持ちます。この構造だけから、次の2つが従います。

(a) 2つ以上の座標にまたがる単項式は両辺とも0になる。 総次数3以下の単項式 \(z_1^{a_1}\cdots z_n^{a_n}\) (\(\sum_k a_k \le 3\) )が2つ以上の座標に依存する場合(例:\(z_iz_j\) 、\(z_iz_j^2\) 、\(z_iz_jz_k\) )、任意のキュバチャ点は高々1つの座標しか非ゼロにできないため、その単項式の値はどの点でも0であり、近似側の和は恒等的に0です。真の期待値も、標準正規分布の座標独立性から \(E[z_i]E[z_j]=0\) などとなり厳密に0なので、両辺は一致します。

(b) 単一座標のみに依存する単項式は次数ごとに直接計算できる。 \(z_j^k\) の形の単項式は、軸 \(j\) 上の2点 \(\pm\sqrt n\, \mathbf{e}_j\) からの寄与だけが残ります。

\[ \frac{1}{2n}\sum_{i=1}^{2n} (\boldsymbol{\xi}_i)_j^k = \frac{1}{2n}\Big[(\sqrt n)^k + (-\sqrt n)^k\Big] \]

これを次数ごとに評価すると:

次数 \(k\)近似値真の値 \(E[z_j^k]\)一致?
0\(1\)\(1\)一致
1\(0\) (符号が打ち消し合う)\(0\)一致
2\(\frac{1}{2n}(n+n)=1\)\(1\)一致
3\(0\) (符号が打ち消し合う)\(0\)一致
4\(\frac{1}{2n}(n^2+n^2)=n\)\(3\)\(n=3\) のときのみ一致

\(k=0,1,2,3\) まではキュバチャ点の符号対称性(\(+\sqrt n\) と \(-\sqrt n\) が対になっている)によって奇数次のモーメントが自動的に0になり、\(k=2\) は \(\sqrt n\) というスケーリングによってちょうど分散1を再現するように設計されています。(a)と(b)を合わせると、総次数3以下の任意の多項式について近似が厳密に一致することが示されました。

一方、\(k=4\) の行が示す通り、4次モーメントの近似値は \(n\) となり、真の値である \(3\) (標準正規分布の尖度に相当する定数)から一般に乖離します。これが「3次キュバチャ則」と呼ばれる理由であり、次数が上がるほど誤差が状態次元 \(n\) に比例して増大することを意味します。実際にPythonで確認します。

import numpy as np

def cubature_points_std(n):
    """標準座標系(mu=0, P=I)でのキュバチャ点"""
    pts = np.zeros((2 * n, n))
    for i in range(n):
        pts[i, i] = np.sqrt(n)
        pts[n + i, i] = -np.sqrt(n)
    return pts

for n in [2, 3, 5, 8]:
    pts = cubature_points_std(n)
    w = 1.0 / (2 * n)

    cov_approx = w * (pts.T @ pts)          # 2次モーメント(単位行列に一致するはず)
    m4_approx = w * np.sum(pts[:, 0] ** 4)  # 4次モーメント E[z_1^4]

    print(f"n={n}: max|Cov - I| = {np.abs(cov_approx - np.eye(n)).max():.2e}, "
          f"cubature E[z1^4] = {m4_approx:.4f}  (exact Gaussian value = 3.0)")

実行結果:

n=2: max|Cov - I| = 2.22e-16, cubature E[z1^4] = 2.0000  (exact Gaussian value = 3.0)
n=3: max|Cov - I| = 2.22e-16, cubature E[z1^4] = 3.0000  (exact Gaussian value = 3.0)
n=5: max|Cov - I| = 2.22e-16, cubature E[z1^4] = 5.0000  (exact Gaussian value = 3.0)
n=8: max|Cov - I| = 2.22e-16, cubature E[z1^4] = 8.0000  (exact Gaussian value = 3.0)

2次モーメント(分散共分散)は機械精度で単位行列に一致する一方、4次モーメントの近似値は厳密に \(n\) となり(\(n=3\) のときだけ偶然真値と一致)、次数が上がるにつれて誤差が次元数に比例して拡大することが数値的にも確認できます。カルマンフィルタの予測・更新ステップは平均(1次モーメント)と共分散(2次モーメント)だけを必要とするため、この3次キュバチャ則で要件を満たしますが、非線形性が強く高次モーメントが結果に大きく影響する問題では、後述する高次キュバチャ則が必要になります。

キュバチャポイントの生成

状態 \(\mathbf{x}\) の平均 \(\boldsymbol{\mu}\) と共分散 \(\mathbf{P}\) が与えられたとき、キュバチャポイントは以下で計算されます:

\[ \mathbf{X}_i = \sqrt{\mathbf{P}} \, \boldsymbol{\xi}_i + \boldsymbol{\mu}, \quad i = 1, \ldots, 2n \tag{2} \]

ここで \(\sqrt{\mathbf{P}}\) はコレスキー分解による行列平方根です。

UKFとの比較

UKFCKF
ポイント数\(2n + 1\)\(2n\)
パラメータ\(\alpha, \beta, \kappa\)なし
重み負になりうるすべて正(\(1/2n\) )
理論的根拠ヒューリスティックキュバチャ則

CKFはUKFの特殊ケース(\(\alpha=1, \beta=0, \kappa=0\) )とも解釈できます。以下、この対応関係を厳密に証明し、高次元における数値安定性の違いを定量的に検討します。

UKFの特殊ケースとしてのCKF(証明と数値的検証)

UKFの記事 で導入されたシグマ点の重み(\(\lambda=\alpha^2(n+\kappa)-n\) をスケーリングパラメータとする)は次の通りでした。

\[ W_0^{(m)} = \frac{\lambda}{n+\lambda}, \qquad W_0^{(c)} = \frac{\lambda}{n+\lambda} + (1-\alpha^2+\beta) \] \[ W_i^{(m)} = W_i^{(c)} = \frac{1}{2(n+\lambda)}, \quad i=1,\ldots,2n \]

\(\alpha=1,\ \kappa=0\) とすると \(\lambda=1^2\cdot(n+0)-n=0\) となり、以下が成り立ちます。

  • シグマ点の座標:広がりの係数は \(\gamma=\sqrt{n+\lambda}=\sqrt n\) となり、シグマ点 \(\bar{\mathbf x}\pm\gamma(\sqrt{\mathbf P})_i=\bar{\mathbf x}\pm\sqrt n(\sqrt{\mathbf P})_i\) は、CKFのキュバチャ点(式2)と完全に同じ座標になります。
  • 平均の重み:\(W_0^{(m)}=0/n=0\) 、\(W_i^{(m)}=1/(2n)\) (\(i=1,\ldots,2n\) )となり、中心点は平均計算に一切寄与せず、残り \(2n\) 点の等重み平均はCKFの式5と一致します。
  • 共分散の重み:\(W_0^{(c)}=0+(1-1+\beta)=\beta\) 、\(W_i^{(c)}=1/(2n)\) となります。

ここで見落としやすい注意点があります。中心点の平均への寄与は \(\alpha=1,\kappa=0\) のとき常に0ですが、共分散への寄与は \(\beta\) の値そのものであり、\(\beta=0\) を追加で仮定しない限り消えません。非線形変換後、中心点の像 \(f(\bar{\mathbf x})\) は一般に変換後の重み付き平均 \(\bar{\mathbf y}\) と一致しないため(非線形写像では中心点を通した値と加重平均が食い違う)、\(\beta\ne0\) である限り \(\beta\,(f(\bar{\mathbf x})-\bar{\mathbf y})(f(\bar{\mathbf x})-\bar{\mathbf y})^T\) という余分な項が共分散に加算され続けます。したがって、

\[ \mathrm{UKF}(\alpha=1,\ \beta=0,\ \kappa=0) \equiv \mathrm{CKF} \]

が厳密に成り立つのは、\(\beta=0\) まで指定した場合のみです(\(\beta=2\) はガウス分布に対して分散を最適に一致させる推奨既定値であり、UKFの記事や本記事のPython実装でも既定値として使われています)。

この等価性は、後述のPython実装の節で数値的に検証します。結論を先取りすると、 UKFの記事 と同じレンジ・ベアリング追跡問題に対して \(\alpha=1,\beta=0,\kappa=0\) のUKFを実行し、更新ステップでのシグマ点・キュバチャ点の再生成規約をCKFの定義(式7)と揃えると、80ステップ全体でCKFの推定値との最大絶対誤差はちょうど \(0.0\) (両者は数値的に完全に一致)となりました。一方、UKF側が予測ステップで伝播済みのシグマ点を更新ステップでそのまま使い回す一般的な実装(再生成を省略する計算量削減の最適化。 UKFの記事 自体もこの流儀です)を採用すると、\(\beta=0\) であっても最大絶対誤差は \(0.14\) 程度生じます。つまりCKFとUKFの等価性は、パラメータ(\(\alpha,\beta,\kappa\) )だけでなく、更新ステップの実装規約(点を再生成するか、伝播済みの点を使い回すか)にも依存する、という実務上重要な注意点があります。

高次元における数値安定性

UKFの重み \(W_0^{(c)}\) や \(W_i\) は \(\lambda=\alpha^2(n+\kappa)-n\) に依存するため、状態次元 \(n\) が大きくなると次の問題が顕在化します。

(1) 中心点の共分散重みの符号反転。 Julierの伝統的な推奨則 \(\kappa=3-n\) (スカラーの場合に4次モーメントを一致させる目的で導かれ、多次元へヒューリスティックに拡張されたもの)を用いると、\(\alpha=1\) のとき \(\lambda=3-n\) 、\(n+\lambda=3\) (\(n\) によらず一定)となるため、

\[ W_0^{(c)} = \frac{3-n}{3} + (1-\alpha^2+\beta) = 3 - \frac{n}{3} \quad (\beta=2\ \text{のとき}) \]

は \(n\) の増加とともに単調に減少し、\(n=9\) でちょうど0を横切り、\(n>9\) では負になります(この \(n=9\) という値は近似ではなく上式から代数的に厳密に導かれる値で、後述のPython実験でも確認します)。共分散はいくつかの階数1の外積の重み付き和として計算されるため(式6, 10)、負の重みを持つ項は他の項から分散を「差し引く」ように働き、その大きさが十分であれば、結果として得られる共分散行列 \(\mathbf P_{k|k-1}\) が正定値性を失う可能性があります。

(2) 桁落ちによる数値誤差の増大。 \(\lambda<0\) かつ \(|\lambda|\) が大きい場合(例えば教科書でしばしば紹介される既定値 \(\alpha=10^{-3},\kappa=0\) では \(n\) 次元で \(\lambda\approx-n\) )、\(n+\lambda\) は0に近い小さな値になり、重み \(W_i=1/(2(n+\lambda))\) は極めて大きな正の値に、中心点の重みは極めて大きな負の値になります。理論上は互いに打ち消し合って正しい値になるはずのこれらの大きな数同士の加減算は、浮動小数点演算では桁落ちを起こしやすく、数値誤差によって共分散が正定値性を満たさなくなるリスクが高まります。

(3) より根本的な破綻:シグマ点生成自体の失敗。 さらに \(\kappa\) の選び方によっては \(n+\lambda<0\) となる場合があります。このときシグマ点の生成には行列平方根 \(\sqrt{(n+\lambda)\mathbf P}\) が必要ですが、\(\mathbf P\) が正定値である限り \((n+\lambda)\mathbf P\) は負定値行列となり、実数の範囲では平方根(コレスキー分解)が数学的に存在しません。シグマ点そのものが生成不能になるという、数値誤差ではなく理論的な破綻です。

これに対しCKFのキュバチャ点は常に \(\sqrt{n\mathbf P}\) (\(n\) は正の整数である状態次元、\(\mathbf P\) は正定値共分散)から生成されるため、チューニング可能なパラメータに一切依存せず、\(\mathbf P\) が正定値である限り常に実数の行列平方根が存在します。また重みは常に \(1/(2n)>0\) であるため、共分散は非負のランク1行列の和(+プロセスノイズ \(\mathbf Q\succeq 0\) )として構成され、理論上常に半正定値であることが保証されます。これがCKFが「高次元で数値的に安定」と言われる正確な理由です。実際の数値実験は後述のPython実装の節で行います。

5次以上の高次キュバチャ則と近年の研究動向

本記事で扱った3次キュバチャ則は \(2n\) 点で平均・共分散(2次モーメントまで)を厳密に再現しますが、非線形性が強く高次モーメントの近似精度が問題になる場面では、5次以上の高次キュバチャ則(high-degree cubature rule)が提案されています。Jia, Xin, & Cheng (2013) の “High-degree cubature Kalman filter”(Automatica, 49(2), 510-518)は、球面-放射キュバチャ則をより高次に拡張し、点数を増やすことで高次モーメントまでの近似精度を高める枠組みを与えました。以降、必要点数を理論的下界に近づける5次則の改良(例えば “A Novel Fifth-Degree Cubature Kalman Filter Approaching the Lower Bound on the Number of Cubature Points”)や、行列平方根の形で共分散を伝播させることで数値安定性をさらに高めるSquare-Root CKFなど、多数の派生手法が発展しています。高次キュバチャ則は近似精度が上がる代わりに必要点数が状態次元の増加に対して急激に増える(3次則の点数が \(n\) に対して線形(\(2n\) )であるのに対し、高次則は \(n\) の多項式でより急速に増加する)ため、精度と計算コストのトレードオフを踏まえて選択する必要があります。

CKF自体についても、2023年以降ロボティクス・航法分野での応用・拡張が引き続き活発に報告されています。例えば、車両協調測位に向けた最大コレントロピー規準に基づくロバストな平方根CKF、INS/CNS統合航法向けにリサンプリング不要のシグマ点更新枠組みと経験モード分解を組み合わせた適応的CKF(“An Adaptive Cubature Kalman Filter Based on Resampling-Free Sigma-Point Update Framework and Improved Empirical Mode Decomposition for INS/CNS Navigation”)、欠測観測に対してHuberのM推定を統合したロバストCKFによる移動目標追跡、遅延観測に対応した衛星相対航法向けCKFなどが報告されています。これらはいずれも本記事で扱った基本的なCKFのアルゴリズムを土台に、ロバスト性や計算効率を改善する方向の発展であり、個々の手法の詳細な性能主張については原論文を参照してください。

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}) \] \[ \mathbf{y}_k = h(\mathbf{x}_k) + \mathbf{v}_k, \quad \mathbf{v}_k \sim \mathcal{N}(\mathbf{0}, \mathbf{R}) \]

予測ステップ

1. キュバチャポイントの生成:

\[ \mathbf{X}_{i,k-1} = \sqrt{\mathbf{P}_{k-1}} \, \boldsymbol{\xi}_i + \hat{\mathbf{x}}_{k-1}, \quad i = 1, \ldots, 2n \tag{3} \]

2. キュバチャポイントの伝播:

\[ \mathbf{X}_{i,k|k-1}^{*} = f(\mathbf{X}_{i,k-1}) \tag{4} \]

3. 予測平均:

\[ \hat{\mathbf{x}}_{k|k-1} = \frac{1}{2n} \sum_{i=1}^{2n} \mathbf{X}_{i,k|k-1}^{*} \tag{5} \]

4. 予測共分散:

\[ \mathbf{P}_{k|k-1} = \frac{1}{2n} \sum_{i=1}^{2n} \mathbf{X}_{i,k|k-1}^{*} (\mathbf{X}_{i,k|k-1}^{*})^T - \hat{\mathbf{x}}_{k|k-1} \hat{\mathbf{x}}_{k|k-1}^T + \mathbf{Q} \tag{6} \]

更新ステップ

5. キュバチャポイントの再生成:

\[ \mathbf{X}_{i,k|k-1} = \sqrt{\mathbf{P}_{k|k-1}} \, \boldsymbol{\xi}_i + \hat{\mathbf{x}}_{k|k-1}, \quad i = 1, \ldots, 2n \tag{7} \]

6. 観測のキュバチャポイント:

\[ \mathbf{Y}_{i,k|k-1} = h(\mathbf{X}_{i,k|k-1}) \tag{8} \]

7. 予測観測平均:

\[ \hat{\mathbf{y}}_{k|k-1} = \frac{1}{2n} \sum_{i=1}^{2n} \mathbf{Y}_{i,k|k-1} \tag{9} \]

8. イノベーション共分散と相互共分散:

\[ \mathbf{S}_k = \frac{1}{2n} \sum_{i=1}^{2n} \mathbf{Y}_{i,k|k-1} \mathbf{Y}_{i,k|k-1}^T - \hat{\mathbf{y}}_{k|k-1} \hat{\mathbf{y}}_{k|k-1}^T + \mathbf{R} \tag{10} \] \[ \mathbf{C}_k = \frac{1}{2n} \sum_{i=1}^{2n} \mathbf{X}_{i,k|k-1} \mathbf{Y}_{i,k|k-1}^T - \hat{\mathbf{x}}_{k|k-1} \hat{\mathbf{y}}_{k|k-1}^T \tag{11} \]

9. カルマンゲインと状態更新:

\[ \mathbf{K}_k = \mathbf{C}_k \mathbf{S}_k^{-1} \tag{12} \] \[ \hat{\mathbf{x}}_k = \hat{\mathbf{x}}_{k|k-1} + \mathbf{K}_k (\mathbf{y}_k - \hat{\mathbf{y}}_{k|k-1}) \tag{13} \] \[ \mathbf{P}_k = \mathbf{P}_{k|k-1} - \mathbf{K}_k \mathbf{S}_k \mathbf{K}_k^T \tag{14} \]

Python実装

2次元の等速旋回運動モデルを使ってCKFを実装します。

状態空間モデルの定義

状態ベクトル \(\mathbf{x} = [p_x, p_y, v, \theta]^T\) (位置、速度、方位角)として、以下の非線形遷移モデルを考えます:

\[ f(\mathbf{x}) = \begin{bmatrix} p_x + \frac{v}{\omega}\sin(\theta + \omega \Delta t) - \frac{v}{\omega}\sin\theta \\\ p_y - \frac{v}{\omega}\cos(\theta + \omega \Delta t) + \frac{v}{\omega}\cos\theta \\\ v \\\ \theta + \omega \Delta t \end{bmatrix} \]

観測モデルは位置のみ観測可能とします:\(h(\mathbf{x}) = [p_x, p_y]^T\) 。

import numpy as np
import scipy.linalg
import matplotlib.pyplot as plt

# ---- 状態空間モデルの定義 ----
dt = 1.0
omega = 0.05  # 旋回角速度 [rad/s]

def f(x):
    """非線形状態遷移関数(等速旋回運動モデル)"""
    px, py, v, theta = x
    if abs(omega) < 1e-6:
        return np.array([px + v * np.cos(theta) * dt,
                         py + v * np.sin(theta) * dt,
                         v, theta])
    return np.array([
        px + v / omega * (np.sin(theta + omega * dt) - np.sin(theta)),
        py - v / omega * (np.cos(theta + omega * dt) - np.cos(theta)),
        v,
        theta + omega * dt
    ])

def h(x):
    """観測関数(位置のみ観測可能)"""
    return np.array([x[0], x[1]])

n = 4   # 状態次元
m = 2   # 観測次元

# プロセスノイズと観測ノイズ
Q = np.diag([0.1, 0.1, 0.01, 0.001])
R = np.diag([1.0, 1.0])

CKFの実装

def generate_cubature_points(mu, P, n):
    """キュバチャポイントの生成(式2)"""
    num_points = 2 * n
    sqrt_P = scipy.linalg.cholesky(n * P, lower=True)

    points = np.zeros((n, num_points))
    for i in range(n):
        points[:, i] = mu + sqrt_P[:, i]       # +sqrt(nP) の列
        points[:, n + i] = mu - sqrt_P[:, i]   # -sqrt(nP) の列

    return points

def ckf_predict(x_est, P_est):
    """CKF予測ステップ"""
    # 式3: キュバチャポイント生成
    X = generate_cubature_points(x_est, P_est, n)
    num_points = 2 * n
    w = 1.0 / num_points  # 式1: 等しい重み

    # 式4: 非線形変換
    X_pred = np.zeros_like(X)
    for i in range(num_points):
        X_pred[:, i] = f(X[:, i])

    # 式5: 予測平均
    x_pred = w * np.sum(X_pred, axis=1)

    # 式6: 予測共分散
    P_pred = np.zeros((n, n))
    for i in range(num_points):
        diff = X_pred[:, i] - x_pred
        P_pred += w * np.outer(diff, diff)
    P_pred += Q

    return x_pred, P_pred

def ckf_update(x_pred, P_pred, y):
    """CKF更新ステップ"""
    # 式7: キュバチャポイント再生成
    X = generate_cubature_points(x_pred, P_pred, n)
    num_points = 2 * n
    w = 1.0 / num_points

    # 式8: 観測のキュバチャポイント
    Y = np.zeros((m, num_points))
    for i in range(num_points):
        Y[:, i] = h(X[:, i])

    # 式9: 予測観測平均
    y_pred = w * np.sum(Y, axis=1)

    # 式10: イノベーション共分散
    S = np.zeros((m, m))
    for i in range(num_points):
        dy = Y[:, i] - y_pred
        S += w * np.outer(dy, dy)
    S += R

    # 式11: 相互共分散
    C = np.zeros((n, m))
    for i in range(num_points):
        dx = X[:, i] - x_pred
        dy = Y[:, i] - y_pred
        C += w * np.outer(dx, dy)

    # 式12-14: カルマンゲインと状態更新
    K = C @ np.linalg.inv(S)
    x_est = x_pred + K @ (y - y_pred)
    P_est = P_pred - K @ S @ K.T

    return x_est, P_est

シミュレーション

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

# 真の初期状態
x_true = np.array([0.0, 0.0, 1.0, np.pi / 2])

# CKFの初期推定
x_est = np.array([0.5, -0.5, 0.8, np.pi / 2 + 0.1])
P_est = np.diag([1.0, 1.0, 0.5, 0.1])

# 記録用配列
true_states = [x_true.copy()]
measurements = []
estimates = [x_est.copy()]

for k in range(T):
    # 真の状態の更新
    x_true = f(x_true) + np.random.multivariate_normal(np.zeros(n), Q)
    true_states.append(x_true.copy())

    # 観測の生成
    y = h(x_true) + np.random.multivariate_normal(np.zeros(m), R)
    measurements.append(y.copy())

    # CKF
    x_est, P_est = ckf_predict(x_est, P_est)
    x_est, P_est = ckf_update(x_est, P_est, y)
    estimates.append(x_est.copy())

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

# ---- 結果のプロット ----
plt.figure(figsize=(10, 8))
plt.plot(true_states[:, 0], true_states[:, 1], "b-", label="True trajectory")
plt.scatter(measurements[:, 0], measurements[:, 1],
            c="gray", s=10, alpha=0.5, label="Measurements")
plt.plot(estimates[:, 0], estimates[:, 1], "r--", label="CKF estimate")
plt.xlabel("x")
plt.ylabel("y")
plt.title("Cubature Kalman Filter - Coordinated Turn Tracking")
plt.legend()
plt.axis("equal")
plt.grid(True)
plt.tight_layout()
plt.savefig("ckf_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}")

CKFはUKFと比較してパラメータ調整が不要であり、特に高次元の問題において数値的に安定な推定を実現できます。

UKFとの精度比較:レンジ・ベアリング観測モデル

理論編で導出したCKFとUKFの数理的な関係を、実際の推定精度で確認します。 UKFの記事 と全く同じ設定—状態ベクトル \(\mathbf x=[p_x,p_y,v_x,v_y]^T\) (位置・速度)、線形な等速直線運動モデル、原点のセンサーが距離と方位角を観測する非線形なレンジ・ベアリング観測モデル、同一の乱数シード・初期推定・ノイズ共分散—を用いて、UKFの記事と同一のUKF実装(更新ステップでは予測ステップで伝播済みのシグマ点を再利用する)と、本記事のCKF(更新ステップで予測共分散からキュバチャ点を再生成する、式7の規約)を直接比較します。

import numpy as np

# --- レンジ・ベアリング観測モデル(UKFの記事と同一) ---
def state_transition(x, dt):
    px, py, vx, vy = x
    return np.array([px + vx * dt, py + vy * dt, vx, vy])

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])

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)  # 式7: 予測共分散から再生成
        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 = Y[i] - y_pred
            dy[1] = (dy[1] + np.pi) % (2 * np.pi) - np.pi
            dx = X[i] - x_pred
            S += self.w * np.outer(dy, dy)
            C += self.w * np.outer(dx, dy)
        y = z - y_pred
        y[1] = (y[1] + np.pi) % (2 * np.pi) - np.pi
        K = C @ np.linalg.inv(S)
        return x_pred + K @ y, P_pred - K @ S @ K.T


class UKF:
    """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 = sigmas_z[i] - z_pred
            dz[1] = (dz[1] + np.pi) % (2 * np.pi) - np.pi
            dx = sigmas_pred[i] - x_pred
            Pzz += self.Wc[i] * np.outer(dz, dz)
            Pxz += self.Wc[i] * np.outer(dx, dz)
        y = z - z_pred
        y[1] = (y[1] + np.pi) % (2 * np.pi) - np.pi
        K = Pxz @ np.linalg.inv(Pzz)
        return x_pred + K @ y, P_pred - K @ Pzz @ K.T


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)])
R = np.diag([0.3, 0.2])
Q = np.diag([0.05, 0.05, 0.01, 0.01])
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])

def run_ukf(alpha=1.0, beta=2, kappa=0):
    ukf = UKF(4, 2, state_transition, observation, Q, R, alpha, beta, kappa)
    x, P = x0.copy(), P0.copy()
    est = [x.copy()]
    for k in range(T):
        x_pred, P_pred, sigmas_pred = ukf.predict(x, P, dt)
        x, P = ukf.update(x_pred, P_pred, sigmas_pred, measurements[k].copy())
        est.append(x.copy())
    return np.array(est)

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

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

ukf_est = run_ukf(alpha=1.0, beta=2, kappa=0)
ckf_est = run_ckf()
print(f"UKF Position RMSE: {rmse(ukf_est):.4f}")
print(f"CKF Position RMSE: {rmse(ckf_est):.4f}")
print(f"Relative difference: {(rmse(ckf_est) - rmse(ukf_est)) / rmse(ukf_est) * 100:.2f}%")

実行結果:

UKF Position RMSE: 1.5489
CKF Position RMSE: 1.5673
Relative difference: 1.19%

UKFのRMSEは UKFの記事 で報告されている値(1.5489)と完全に一致し、実装の一貫性が確認できます。CKFのRMSEはUKFよりわずかに大きく(約1.19%)、これは既定の \(\beta=2\) によってUKFの中心点が共分散に追加のプラス項を与えているためです(前節の証明を参照)。両者はほぼ同水準の推定精度を持ちつつ、CKFはパラメータのチューニングが一切不要である点が実務上の利点です。

この差が理論通り \(\beta\) の設定に起因することを、同じデータで確認します。

# beta=0 にすると理論的にはCKFに一致するはずだが、
# UKFの更新ステップは予測済みシグマ点を再利用する規約のままなので、
# CKF(キュバチャ点を再生成する規約)とは依然として規約が食い違う
ukf_b0_est = run_ukf(alpha=1.0, beta=0, kappa=0)
print(f"[規約が不一致] UKF(a=1,b=0,k=0, 再利用) vs CKF(再生成): "
      f"最大絶対誤差 = {np.abs(ukf_b0_est - ckf_est).max():.4f}")


class UKF_regen(UKF):
    """更新ステップでも予測共分散からシグマ点を再生成する変種(CKFの式7と同じ規約)"""
    def update(self, x_pred, P_pred, sigmas_pred_unused, z):
        sigmas = self.sigma_points(x_pred, P_pred)
        sigmas_z = np.array([self.h(s) for s in sigmas])
        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 = sigmas_z[i] - z_pred
            dz[1] = (dz[1] + np.pi) % (2 * np.pi) - np.pi
            dx = sigmas[i] - x_pred
            Pzz += self.Wc[i] * np.outer(dz, dz)
            Pxz += self.Wc[i] * np.outer(dx, dz)
        y = z - z_pred
        y[1] = (y[1] + np.pi) % (2 * np.pi) - np.pi
        K = Pxz @ np.linalg.inv(Pzz)
        return x_pred + K @ y, P_pred - K @ Pzz @ K.T


def run_ukf_regen(alpha=1.0, beta=0, kappa=0):
    ukf = UKF_regen(4, 2, state_transition, observation, Q, R, alpha, beta, kappa)
    x, P = x0.copy(), P0.copy()
    est = [x.copy()]
    for k in range(T):
        x_pred, P_pred, sigmas_pred = ukf.predict(x, P, dt)
        x, P = ukf.update(x_pred, P_pred, sigmas_pred, measurements[k].copy())
        est.append(x.copy())
    return np.array(est)

ukf_regen_est = run_ukf_regen(alpha=1.0, beta=0, kappa=0)
print(f"[規約が一致]   UKF(a=1,b=0,k=0, 再生成) vs CKF(再生成): "
      f"最大絶対誤差 = {np.abs(ukf_regen_est - ckf_est).max():.10f}")

実行結果:

[規約が不一致] UKF(a=1,b=0,k=0, 再利用) vs CKF(再生成): 最大絶対誤差 = 0.1419
[規約が一致]   UKF(a=1,b=0,k=0, 再生成) vs CKF(再生成): 最大絶対誤差 = 0.0000000000

パラメータを \(\alpha=1,\beta=0,\kappa=0\) に揃えるだけでは、更新ステップの規約(シグマ点を再利用するか再生成するか)が異なるために最大 \(0.14\) 程度の差が残ります。しかし、更新の規約までCKFと完全に一致させると、80ステップ全時刻にわたって推定値が小数点以下まで完全に一致(最大絶対誤差 \(0.0\) )しました。これは前節の理論的証明(UKFの中心点の重みが \(\alpha=1,\beta=0,\kappa=0\) のとき平均・共分散の両方でCKFと数学的に等価になる)を、実装レベルで裏付ける結果です。

高次元でのCKFとUKFの数値比較

最後に、理論編で述べた高次元での数値安定性の違いを、実際に計算して確認します。

(1) 重みの符号反転。 UKFの伝統的な推奨則 \(\kappa=3-n\) (\(\alpha=1,\beta=2\) )を用いて、状態次元 \(n\) を増やしながら中心点の共分散重み \(W_0^{(c)}\) を計算します。

def ukf_weight0_cov(n, alpha=1.0, beta=2.0):
    kappa = 3 - n
    lam = alpha**2 * (n + kappa) - n
    W0_cov = lam / (n + lam) + (1 - alpha**2 + beta)
    Wi = 1.0 / (2 * (n + lam))
    return W0_cov, Wi

print("kappa=3-n, alpha=1, beta=2 の場合:")
for n in [2, 4, 6, 8, 9, 10, 15, 20, 30, 50]:
    W0_cov, Wi = ukf_weight0_cov(n)
    print(f"  n={n:3d}: W0_cov={W0_cov:8.4f}  Wi={Wi:.5f}  CKFの重み=1/(2n)={1/(2*n):.5f}")

実行結果:

kappa=3-n, alpha=1, beta=2 の場合:
  n=  2: W0_cov=  2.3333  Wi=0.16667  CKFの重み=1/(2n)=0.25000
  n=  4: W0_cov=  1.6667  Wi=0.16667  CKFの重み=1/(2n)=0.12500
  n=  6: W0_cov=  1.0000  Wi=0.16667  CKFの重み=1/(2n)=0.08333
  n=  8: W0_cov=  0.3333  Wi=0.16667  CKFの重み=1/(2n)=0.06250
  n=  9: W0_cov=  0.0000  Wi=0.16667  CKFの重み=1/(2n)=0.05556
  n= 10: W0_cov= -0.3333  Wi=0.16667  CKFの重み=1/(2n)=0.05000
  n= 15: W0_cov= -2.0000  Wi=0.16667  CKFの重み=1/(2n)=0.03333
  n= 20: W0_cov= -3.6667  Wi=0.16667  CKFの重み=1/(2n)=0.02500
  n= 30: W0_cov= -7.0000  Wi=0.16667  CKFの重み=1/(2n)=0.01667
  n= 50: W0_cov=-13.6667  Wi=0.16667  CKFの重み=1/(2n)=0.01000

理論通り \(n=9\) でちょうど \(W_0^{(c)}=0\) となり、\(n>9\) では中心点の重みが負に転じ、\(n\) の増加とともにその絶対値も大きくなっていきます。一方、CKFの重みは常に正で、状態次元が増えるほど単調に小さくなるだけです。この関係を可視化したものが下図です。

次元 n の増加に伴う UKF の重みの符号反転と CKF の重みの安定性

赤線(UKFの中心点の共分散重み)は \(n\) とともに単調に減少して \(n=9\) で符号を反転し、以降は負の方向に発散していきます。橙線(UKFの非中心点の重み、この \(\kappa=3-n\) の設定では \(1/6\) で一定)と青線(CKFの重み)はどちらも小さく安定した正の値を保ち続けます。

(2) 高次元でのシグマ点生成そのものの失敗。 さらに、状態次元 \(n=20\) で、\(\kappa\) を意図的に \(n+\kappa<0\) となるよう選ぶと(\(\kappa=-(n+5)=-25\) )、\(n+\lambda=-5<0\) となり、UKFのシグマ点生成が要求する行列平方根 \(\sqrt{(n+\lambda)\mathbf P}\) が存在しなくなることを確認します。

import numpy as np

np.random.seed(0)
n = 20
kappa = -(n + 5)
alpha, beta = 1.0, 2.0
lam = alpha**2 * (n + kappa) - n
print(f"n={n}, kappa={kappa}: n+kappa={n + kappa}, lambda={lam}, n+lambda={n + lam}")

# ランダムだが正定値な共分散行列を用意
A = np.random.randn(n, n) * 0.3
P = A @ A.T + np.eye(n) * 0.5
print("P は正定値か:", np.all(np.linalg.eigvalsh(P) > 0))

# UKF: sqrt((n+lambda) P) が必要
M_ukf = (n + lam) * P
eigs_ukf = np.linalg.eigvalsh(M_ukf)
print(f"(n+lambda)P の固有値範囲: [{eigs_ukf.min():.3f}, {eigs_ukf.max():.3f}]")
try:
    np.linalg.cholesky(M_ukf)
    print("UKF: コレスキー分解に成功")
except np.linalg.LinAlgError as e:
    print(f"UKF: コレスキー分解が失敗 -> {e}")

# CKF: sqrt(n P) は P が正定値である限り常に存在
M_ckf = n * P
eigs_ckf = np.linalg.eigvalsh(M_ckf)
print(f"nP の固有値範囲: [{eigs_ckf.min():.3f}, {eigs_ckf.max():.3f}]")
L_ckf = np.linalg.cholesky(M_ckf)
print(f"CKF: コレスキー分解に成功, 形状={L_ckf.shape}")

実行結果:

n=20, kappa=-25: n+kappa=-5, lambda=-25.0, n+lambda=-5.0
P は正定値か: True
(n+lambda)P の固有値範囲: [-35.120, -2.597]
UKF: コレスキー分解が失敗 -> Matrix is not positive definite
nP の固有値範囲: [10.387, 140.480]
CKF: コレスキー分解に成功, 形状=(20, 20)

\(\mathbf P\) 自体は正定値であるにもかかわらず、\((n+\lambda)\mathbf P\) はスケール係数 \(n+\lambda=-5\) が負であるために固有値がすべて負となり、numpy.linalg.choleskyLinAlgError: Matrix is not positive definite を送出してUKFのシグマ点生成が完全に失敗します。これに対しCKFのキュバチャ点生成は \(n\mathbf P\) (\(n=20>0\) )のコレスキー分解であり、\(\mathbf P\) が正定値である限り固有値は必ず正であるため、\(20\) 次元でも問題なく成功します。これは、CKFがチューニング可能なスケーリングパラメータを持たないことの直接的な帰結です。

関連記事

参考文献

  • Arasaratnam, I., & Haykin, S. (2009). “Cubature Kalman Filters.” IEEE Transactions on Automatic Control, 54(6), 1254-1269.
  • Jia, B., Xin, M., & Cheng, Y. (2013). “High-degree cubature Kalman filter.” Automatica, 49(2), 510-518.
  • 片山 徹 (2011). 「非線形カルマンフィルタの基礎」 計測と制御, 50(9), 638-643.