カルマンフィルターの例題として、何らかの物理的な物体があり、内部状態として位置xと速度vがあり、それぞれがノイズの影響を受け、観測できるのはxに観測誤差を加えたzのみとし、位置xを一定の目標値に制御したいとする。

次のコードのPhysicalModelを制御対象とする。xを取得するメソッドがあるが、制御する際に観測できるのはzだけであり、xの本当の値は観測できないとする。

import numpy as np
import matplotlib.pyplot as plt

class PhysicalModel(object):
    x_std = 1.0  # 位置xのノイズの標準偏差
    v_std = 0.5  # 速度vのノイズの標準偏差
    z_std = 2.0  # xの観測誤差の標準偏差

    def __init__(self, timesteps, init_x, init_v, random_seed=1):
        np.random.seed(random_seed)
        self.x = np.zeros(timesteps)  # 位置x(便宜上過去の値を保存)
        self.v = np.zeros(timesteps)  # 速度v
        self.a = np.zeros(timesteps)  # 加速度a
        self.z = np.zeros(timesteps)  # xの観測値z

        # t=0
        self.t = 0  # 時刻
        self.x[0] = init_x
        self.v[0] = init_v
        self.z[0] = self.x[0]

    # 時刻tを1つ進める
    def step(self):
        self.t += 1
        self.x[t] = self.x[t-1] + 0.99 * self.v[t-1] + 1/2 * self.a[t-1] + self.x_std * np.random.randn(1)[0]
        self.v[t] =               0.98 * self.v[t-1] +       self.a[t-1] + self.v_std * np.random.randn(1)[0]
        self.z[t] = self.x[t] + self.z_std * np.random.randn(1)[0]
        return self.z[t]  # 1つ進めた時刻の観測値を返す

    # 時刻tの加速度を入力する
    def set_accel(self, a):
        self.a[t] = min(10, max(-10, a))  # 実際には -10 <= a <= 10 に制限されるとする

    # t=offset以降のxの目標値からの平均二乗誤差
    def MSE(self, mean, offset=0):
        return sum((self.x[offset:] - x_target) ** 2) / self.x[offset:].shape[0]

    # グラフ表示用
    def get_x_history(self):
        return self.x[:t+1]
    
    def get_v_history(self):
        return self.v[:t+1]
    
    def get_a_history(self):
        return self.a[:t+1]
    
    def get_z_history(self):
        return self.z[:t+1]

まず、カルマンフィルターを用いず、観測値zを使って制御してみる。
xの初期値は20、目標値は5とする。
制御入力は、ノイズやvの減衰を無視すると
xt+1 = xt + vt + (1/2) at
なので
at = 2 (xt+1 - (xt + vt))
であり、次の時刻(t+1)で目標値に到達させるなら
at = 2 (x_target - (xt + vt))
だが、観測値zから推測したx,vはノイズを含むので、その影響を小さくする為、入力するaは上の式の半分とする。(実際、上の式の2を色々変えてみても1.0前後くらいが良さそうだった。時刻(t+2)で目標値に到達させるべく、
xt+2 = xt + 2vt + 2 atから
at = (1/2) (x_target - (xt + 2vt))
とするのも有効そうだったが、今回は単純な例で試す。)

●コード
timesteps = 50
init_x = 20
x_target = 5
model = PhysicalModel(timesteps, init_x=init_x, init_v=0)

estimated_v = [0]  # vの推定値、グラフ表示用に保存する
z_prev = init_x  # t=0の観測値
a = 0  # t=0の加速度

for t in range(1, timesteps):
    z = model.step()

    # x,vの推定
    # z = z_prev + v_prev + a/2
    # v = v_prev + a
    # -> v = z - z_prev - a/2 + a
    x = z
    v = z - z_prev + a/2
    estimated_v.append(v)

    # 制御入力
    a = 1.0 * (x_target - (x + v))
    model.set_accel(a)

    z_prev = z

print("MSE =", model.MSE(x_target, offset=10))

fig = plt.figure(figsize=(10,4))
ax1 = fig.gca()
ax2 = ax1.twinx()
ax1.plot(model.get_z_history(), label='observed')
ax1.plot(model.get_x_history(), color='r', linewidth=2, label='true_x')
ax2.plot(model.get_v_history(), 'y', alpha=0.5, label='true_v')
ax2.plot(estimated_v, 'y', linestyle='--', label='estimated_v')
ax1.grid(axis='y')
ax1.set_ylim((-2, 22))
ax1.set_xlabel('time')
ax1.set_ylabel('x')
ax2.set_ylabel('v')
ax1.legend(loc='upper right', bbox_to_anchor=(1, 0.7))
ax2.legend(loc='upper right')
ax1.set_title("Controlling x to 5")
plt.show()

# 別の制御方式との比較用
true_x_1 = model.get_x_history()
●実行結果
MSE = 5.654622082289254

赤線が制御対象のxである。一応、xが5辺りに制御されている。

次に、カルマンフィルターを用いてx,vを推定しながら制御してみる。
制御対象を状態空間モデルで表すと、このようになる。
\left\{
\begin{array}{rcll}
X_{t+1} & = & F X_t + u_t + w_t & (w_t \sim N(0, Q))\\
Z_t & = & H X_t + V_t & (V_t \sim N(0, R))\\
\end{array}
\right.
上の式が状態方程式、下の式が観測方程式と呼ばれるものである。
ここでのXは位置xと速度vを含む状態ベクトル、
X = \left( \begin{array}{c} x\\ v\\ \end{array} \right)
であり、時刻tからt+1の間にシステム行列Fによる状態遷移と、制御入力uと、平均0、分散Qの多変量正規分布に従うノイズwが加わるとする。
Zは状態Xの観測値で、Xが観測行列Hにより変換され、平均0、分散Rの多変量正規分布に従うノイズVが加わるとする。(このVは小文字のvで表記されることが多いが、今回の例では速度のvと紛らわしいので、ここでは大文字で記す)
制御入力uは加速度aにより、vの減衰を無視すると、ノイズを除いた部分は
xt+1 = xt + vt + (1/2) at
vt+1 = vt + at
となるので、
F = \left( \begin{array}{cc} 1 & 1\\ 0 & 1\\ \end{array} \right)
u = \left( \begin{array}{c} \frac{1}{2}a\\ a\\ \end{array} \right)
となる。
状態はxしか観測できないので、観測行列Hは[1 0]となる。
Q,Rは最小二乗法で推定する方法があるらしいが、今回は経験的にこれくらいだと知っているものとして、それぞれ[[1, 0], [0, 0.1]]と[5]とする。

次のコードはpykalmanを用いた例である。

●コード
# pykalmanのカルマンフィルターを使う例
from pykalman import KalmanFilter

model = PhysicalModel(timesteps, init_x=init_x, init_v=0)

kf = KalmanFilter(
    transition_matrices=[[1, 1], [0, 1]],
    observation_matrices=[1, 0],
    transition_covariance=[[1, 0], [0, 0.1]],
    observation_covariance=[5],
#     initial_state_mean=[init_x, 0],  # 今回の使い方では関係無かった
#     initial_state_covariance=[[0, 0], [0, 0]]  # 何にしても変わらない
)

filtered_state = np.zeros((timesteps, 2))  # x,vの推定値、グラフ表示用に保存する
filtered_state[0] = [init_x, 0]
filtered_state_cov = np.zeros((timesteps, 2, 2))  # x,vの推定値の分散

a = 0  # t=0の加速度

for t in range(1, timesteps):
    z = model.step()

    # x,vの推定
    filtered_state[t], filtered_state_cov[t] = kf.filter_update(
        filtered_state[t-1],
        filtered_state_cov[t-1],
        z,
        transition_offset=np.array([1/2 * a, a])
    )
    x, v = filtered_state[t]

    # 制御入力
    a = 1.0 * (x_target - (x + v))
    model.set_accel(a)
    
print("MSE =", model.MSE(x_target, offset=10))

# 安定確認
plt.figure(figsize=(8,2))
plt.plot(filtered_state_cov[:,0,0], label='filtered_state_cov_x')
plt.plot(filtered_state_cov[:,1,1], label='filtered_state_cov_v')
plt.legend()
plt.show()

fig = plt.figure(figsize=(10,4))
ax1 = fig.gca()
ax2 = ax1.twinx()
ax1.plot(model.get_z_history(), label='observed')
ax1.plot(filtered_state[:,0], linestyle='--',  label='filtered_x')
ax1.plot(true_x_1, color='r', alpha=0.3, label='true_x(prev)')
ax1.plot(model.get_x_history(), color='r', linewidth=2, label='true_x')
ax2.plot(model.get_v_history(), 'y', alpha=0.5, label='true_v')
ax2.plot(filtered_state[:,1], 'y', linestyle='--', label='filtered_v')
ax1.grid(axis='y')
ax1.set_ylim((-2, 22))
ax1.set_xlabel('time')
ax1.set_ylabel('x')
ax2.set_ylabel('v')
ax1.legend(loc='upper right', bbox_to_anchor=(1, 0.7))
ax2.legend(loc='upper right')
ax1.set_title("Controlling x to 5")
plt.show()
●実行結果
MSE = 3.238811619518345

濃い赤線が制御対象のx、青線が観測値、オレンジの点線がxの推測値である。全体的には推定値が観測値より本当のxに近いように見える。薄い赤線が上のカルマンフィルターを使わない例のxであり、何となく、今回の方がよりx=5の近くに制御されているように見える。また、黄色の実線が本当のv、黄色の点線が推測したvであり、観測していないvがそれにりに正しく推測され、本当のvも0付近で安定している。
出力のt≧10のMSEで見ても、誤差が小さくなっている。

統計検定準1級合格

1/6(土)に統計検定準1級のCBT試験を受けて合格した。
参考になるかどうかわからないが、一応、経緯や感想を残す。

■筆者のバックグラウンド
  • 49.9歳
  • 子供の頃から数学好き
  • 大学時代は理系の学部
    統計学に興味があり、教養課程と大学院の修士課程で統計学を計3回履修したが、推定・検定がさっぱり理解できず
  • 大学卒業後はソフトウェア開発の仕事で全く統計学に触れず
  • 35歳の頃にふと思い立って入門書で統計学を復習
    推定・検定をやっと理解して本weblogに色々書いたが、結局初学者に少し毛が生えた程度
  • 46歳の頃に仕事がデータ分析に変わる
    ある時、会議で推定か検定を使った資料が出てきて、質問しようとしたが、すっかり忘れており、「信頼区間」「有意水準」という言葉すら出て来なかった
  • 47歳の頃に統計検定2級合格

■準1級受験の目的
個人的興味。
昔から統計学に興味があるものの、理屈がわかっていない所が多く、実用的なレベルまで理解できていないのでもっと勉強したいと思っており、勉強する為の丁度良い目標だと思ったから。
2級と違って会社とは関係無し。ただ、あわよくば何かの役に立てば良いなと思っている。

■使用した教材
・統計学実践ワークブック(日本統計学会公式認定テキスト)
・統計検定 1級・準1級 公式問題集[2018~2019年]
今回は余裕が無かったので、「統計学の時間 | 統計WEB」は使わなかった。

■準1級受験までの経緯
2021年8月に2級に合格した直後に教材を購入したものの、色々あって1年以上放置して、ほとんどを忘れてしまった。

2023年1月〜3月、「統計学実践ワークブック」で勉強開始
 GWに受験しようと12章まで拾い読みしたが、理解不十分で到底無理と判断
4月〜5月、「統計学実践ワークブック」を8章までしっかり読む
6月〜8月、将棋大会と会社の研修優先の為中断
9月〜12月、「統計学実践ワークブック」を一通り読み終える

勉強時間は、やっている時は週末毎に4〜6時間くらいのペースで、おそらく計150時間くらいだった。「統計学実践ワークブック」の内容がページ数の割に多く、ノートにメモしながら文字通り一言一句理解するつもりで読み進めると、この本だけでそれだけ時間がかかってしまった。
しっかり理解しながら進めたのだが、2週間前に勉強したことはノートを見返してもほとんど思い出せないような状態が続き、最後まで読み切ることを優先したので、これから30時間くらいかけて復習してやっと合格ラインかなという感覚である。

しかし、仕事関係で他に勉強することが多々あるのと、40代の内にせめて準1級までは取りたい(当初は40代の内に1級合格が目標だった)という思いがあり、タイムリミットが迫っていたので、現在の実力を知る為に一度受験することにした。

試験を申し込んだ後の年末年始に時間無制限で過去問(記述問題あり)をやってみると、自己採点の正解率は、2018年分が55%、2019年分が70%で、制限時間の2倍以上の時間がかかったので、やっぱり1回では厳しいかなと思った。

■試験当日
2日前から風邪気味で頭が少しボーッとしており、一夜漬けの復習ができなかった。当日もその調子で、最後の復習をした喫茶店でも試験会場までの道のりでも鼻水が止まらなかった。
全て選択問題と思っていたが、試験会場に着いて受験のしおりを読むと、一部記述式の問題ありと書いてあって、これはダメだなと思った。試験の直前には、帰りに問題集を買おうなどと、40代の残り1ヶ月の作戦を考え始めていた。
ついでに、なんと前日に鞄に入れたつもりだった電卓が無く(家にあった)、会場でも貸出していないとのことだったので、諦めがついた。もはや電卓を忘れたことを悔やむ気も起きなかった。

試験が始まると、大問が21問と出てきて、少し安心した。2級の時は同じ90分で大問が32問で、全然時間が足りなかった。21問なら1問4分以上ある。
しかし、第1問(小問2つ)の条件付き独立を含む確率の計算問題でいきなり、計算した結果が選択肢に無く、8分くらい使って結局解けず、パスするしかなくなった。(帰ってからゆっくり力技でやったら完全に解けたのだが、やはりその選択肢が無かったような...問題を誤読したのだろうか。)
大問の中には、筆者には時間を使って考えようがない、最も適切な記述を選ぶ問題あり(確認方法がわかっていれば数式を使って確認できたのだろうが)、計算方法がわからない問題多数あり、また、電卓が無かったので小数点以下第1位まで入力せよという問題2つは飛ばしたので、何か考えられそうな問題に絞ってそれぞれ8分くらい使っても、時間は余ってしまった。
結局、自信のある回答は2割くらい、やや自信ありも2割くらいで、残りは山勘で選ぶか空欄にしてしまった。

結果は71点で合格だった。(60点で合格)

「統計学実践ワークブック」の中で見た覚えが無い、知らない単語は1つか2つくらいしか出なかった。

受験のしおりには、一部記述式とか穴埋め式と書いてあったように思ったが、記述式の問題は数字しか使わなかった。記述式の問題の中に何故か、選択肢の番号を数字で記入させる、選択問題と変わらない問題が複数あった。

2級の時は長文が多く、画面一杯が文字で埋まるくらいの長文もあって、パソコンの画面で読むのに苦労したが、今回はそれほどの長文が無く、問題を画面で見ることに関して苦労したことは無かった。1回、数式中に何でそこに「'」(プライム記号)が付くのかがわからず、2x3ピクセルくらいだったので何か文字が潰れているのかと思って拡大して確認した時があったが、やっぱり「'」で、問題文をスクロールしていくと、「なお、『'』(プライム記号)はXXXを表す」のような補足があった。

■感想
結果は50点と表示されても違和感無かったので、細かい所は覚えてなくても、しっかり勉強して勘が鍛えられていたのかなと思う。

今回、つくづく思ったのは、年齢的にきつかった。2級を受験した2年前にも増して、記憶力と集中力と体力の低下を感じた。2週間前に1時間とか2時間とか頑張って理解したはずの内容を全然思い出せないことがあったのは筆者としてはショックで、それが何回もあったので、自分にはこういうのはもう無理かなと思った。それに加えて、平日の夜は仕事の疲れで全く勉強できず、土日も疲れで集中できず、机の前に座っても時間だけが過ぎていったりして、歳だなと思った。

それから、準1級の出題範囲=「統計学実践ワークブック」の内容の分量が思ったより多かった。この内容を全て覚えるのはかなり難しいと思う。少なくとも筆者の頭脳では、統計学を専門にしない限り、人生においてこの内容を全て覚えられる可能性があった時期は無かったんじゃないかとすら思う。

今回受けた試験の感想として、一番気になったのは、「統計学実践ワークブック」の例題や2018年〜2019年の過去問と同じ解き方で解ける問題が1問も無かったように感じたことだった(筆者が忘れてるだけかも知れないが)。出題範囲としては「統計学実践ワークブック」に紹介されている範囲内だったと思うが、出題傾向が全然違ったと感じた。筆記試験じゃなくCBT試験なので難易度を上げざるを得ないのかなと想像するが、これだけやれば大丈夫、でないのは残念だった。
元々「統計学実践ワークブック」は準1級の出題範囲を紹介するのが目的の書籍であり、これ1冊だけでは足りないという評判があったのは、その通りだと思った。満点を狙うのであれば、もっともっと多くの応用問題を解けるようにならないといけないのだろう。

一方、準1級のCBT試験は選択式の問題が大半で、ある程度の理解があれば山勘でもいくらか当たってしまうことと、合格ラインが60点と低めであることを考えれば、合格するだけなら難しくないと思った。
今回は半分以上の問題を山勘で回答してしまったが、その中の3つか4つくらいは、公式や基礎知識を復習しておけばきちんと解けた。特に、モーメント母関数を使って確率分布の再生性を確認するのはこの1年で20回以上やったはずだが、そのやり方さえ思い出せれば簡単に解ける問題を解けなかったのは痛恨だった。

matplotlibで判別境界を描画する

ちょっとPython+matplotlibで判別境界を描画したくなって、昔やってたやり方を思い出した。
他の方法は調べていない。
次のコードは、線形判別分析と2次判別分析とSVMの判別境界を並べて描画したものである。

●コード
import numpy as np

import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap

from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis
from sklearn.svm import SVC

def draw_contour(clf, x1_range, x2_range, ax=None):
    cmap = ListedColormap(['k'])
    if ax is None: ax = plt.gca()

    margin1 = (x1_range[1] - x1_range[0]) * 0.05  # 5% of original range
    margin2 = (x2_range[1] - x2_range[0]) * 0.05
    xx1, xx2 = np.meshgrid(np.arange(x1_range[0] - margin1, x1_range[1] + margin1, 0.01),
                           np.arange(x2_range[0] - margin2, x2_range[1] + margin2, 0.01))

    Z = clf.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    Z = Z.reshape(xx1.shape)
    ax.contour(xx1, xx2, Z, cmap=cmap)

def draw_meshgrid(clf, x1_range, x2_range, ax=None):
    cmap = ListedColormap(['lightgreen', 'lightpink'])
    if ax is None: ax = plt.gca()

    margin1 = (x1_range[1] - x1_range[0]) * 0.05
    margin2 = (x2_range[1] - x2_range[0]) * 0.05
    xx1, xx2 = np.meshgrid(np.arange(x1_range[0] - margin1, x1_range[1] + margin1, 0.01),
                           np.arange(x2_range[0] - margin2, x2_range[1] + margin2, 0.01))

    Z = clf.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    Z = Z.reshape(xx1.shape)
    ax.contourf(xx1, xx2, Z, cmap=cmap)

def draw_sample(X, y, ax=None):
    if ax is None: ax = plt.gca()
    ax.scatter(X[y == 0, 0], X[y == 0, 1], color='b', marker='o')
    ax.scatter(X[y == 1, 0], X[y == 1, 1], color='r', marker='x')

# sample data
np.random.seed(0)
X = np.vstack([
    np.random.randn(10, 2) + [1.0, 1.0],  # 10 points around (1.0, 1.0)
    np.random.randn(10, 2) + [3.0, 2.5]   # 10 points around (3.0, 2.5)
])
y = np.array([0] * 10 + [1] * 10)  # class labels

fig, ax = plt.subplots(2, 3, figsize=(10,6))

# 線形判別分析
clf_lda = LinearDiscriminantAnalysis()
clf_lda.fit(X, y)
draw_contour(clf_lda, (X[:,0].min(), X[:,0].max()), (X[:,1].min(), X[:,1].max()), ax=ax[0,0])
draw_sample(X, y, ax=ax[0,0])
draw_meshgrid(clf_lda, (X[:,0].min(), X[:,0].max()), (X[:,1].min(), X[:,1].max()), ax=ax[1,0])
draw_sample(X, y, ax=ax[1,0])
ax[0, 0].set_title("LDA")

# 2次判別分析
clf_qda = QuadraticDiscriminantAnalysis()
clf_qda.fit(X, y)
draw_contour(clf_qda, (X[:,0].min(), X[:,0].max()), (X[:,1].min(), X[:,1].max()), ax=ax[0,1])
draw_sample(X, y, ax=ax[0,1])
draw_meshgrid(clf_qda, (X[:,0].min(), X[:,0].max()), (X[:,1].min(), X[:,1].max()), ax=ax[1,1])
draw_sample(X, y, ax=ax[1,1])
ax[0, 1].set_title("QDA")

# SVM
clf_svm = SVC()
clf_svm.fit(X, y)
draw_contour(clf_svm, (X[:,0].min(), X[:,0].max()), (X[:,1].min(), X[:,1].max()), ax=ax[0,2])
draw_sample(X, y, ax=ax[0,2])
draw_meshgrid(clf_svm, (X[:,0].min(), X[:,0].max()), (X[:,1].min(), X[:,1].max()), ax=ax[1,2])
draw_sample(X, y, ax=ax[1,2])
ax[0, 2].set_title("SVM")

plt.show()

●結果