Pythonと機械学習

Pythonも機械学習も初心者ですが、頑張ってこのブログで勉強してこうと思います。

RNNの実装

目次

はじめに

  • ここでは、前回勉強したRNNをPythonで実装してみようと思います。

  • 前回の記事はこちらを参照。

  • 実はかなり前にコードを書いてたのですが、放置状態になっていました。

  • 勉強のログをブログに残しておくと、頭の中で考えが整理されて記憶に残るので、更新はゆっくりでも頑張って続けていきたいですね。

学習データ

  • 例として、位相が遅れてノイズが乗った2つのsin波 f_{d1}(t)f_{d2}(t) から、元のsin波 f(t) を学習させる事を考えていきます。

  • グラフの青い線が元のsin波で、オレンジと緑が位相が遅れてノイズが乗ったsin波です。

  • オレンジと緑の5この時間データをサンプルデータ、青の同じ時刻の5個の時間データをターゲットデータとして学習させます。

  • 1つのサンプルxとターゲットyを式で書くと以下のようになってます。


\displaystyle
\begin{eqnarray*}
x &=&
\left[
    \begin{array}{cc}
        f_{d1}(t-4) & f_{d2}(t-4) \\
        f_{d1}(t-3) & f_{d2}(t-3) \\
        f_{d1}(t-2) & f_{d2}(t-2) \\
        f_{d1}(t-1) & f_{d2}(t-1) \\
        f_{d1}(t) & f_{d2}(t) \\
    \end{array}
\right]
\end{eqnarray*}
\tag{1}


\displaystyle
\begin{eqnarray*}
y &=&
\left[
    \begin{array}{c}
        f(t-4) \\
        f(t-3) \\
        f(t-2) \\
        f(t-1) \\
        f(t) \\
    \end{array}
\right]
\end{eqnarray*}
\tag{2}

  • 時間データ数5個、特徴量数2個のデータをRNNに入力して、時間データ数5個、クラス数1個のデータを出力させるようにします。

  • 上のグラフですが、各サンプルとターゲットが5個の時間データを持っており、そのままグラフにプロットするのは難しいので、最終時間のデータだけプロットしています。

kerasによる実装

  • 先ずkerasを使ってどんな感じになるのか見ていきましょう。

  • kerasには、SimpleRNNというクラスがあるのでそれを使います。

return_sequences=True

  • SimpleRNNの引数にreturn_sequencesというのがあります。return_sequencesをTrueに設定すると、時間データを全部出力します。Falseにすると最後の時間だけしか出力されません。

  • True時の出力は(サンプル数, 時間, ユニット数)の3階のテンソルになっていますが、Falseの時は時間インデックスが1つしか無いので省略されて(サンプル数, ユニット数)の2階のテンソルになります。

  • デフォルトではreturn_sequences=Falseになっています。今回はターゲットデータも5個の時間を持つようにしたいのでTrueにしておきます。

  • SimpleRNNの後ろには出力とターゲットのユニット数を合わせるために、調整用のDenseを入れておきます。

  • 全部で100個あるサンプルデータの最初の80個をトレーニングデータ、後の20個を評価データとして学習させます。

  • 以下lossの履歴と、学習後のprediction結果です。time 0.8 以降が評価データですが、中々いい感じで予測できてますね。

return_sequences=False

  • return_sequences=Falseにした場合は、最終時間しか出力されないので、SimpleRNNの出力の形は時間インデックスが無くなり2階のテンソルになります。

  • その後のDenseレイヤーでも2階のテンソルとして演算されるので、ターゲットデータもそれに合わせて、最終時間のみにしてやります。

  • 以下lossの履歴と、学習後のprediction結果です。return_sequences=True時と比較してみました。

  • return_sequences=False時は、True時よりもlossがちょっと下がっていますね。

  • 学習後の予測結果では、最終時間だけ見るとreturn_sequences=FalseとTrueでそんなに違いはないみたいですね。

RNNの実装

  • では実際にRNNを実装していきたいと思います。

  • 入力 → RNNブロック → Dense → 出力の全部で4層のネットワークを作成していきます。

  • 基本的な構成は、以前多層パーセプトロンを実装した時と同じです。

  • 多層パーセプトロンの実装に関してはこちら。

  • kerasに合わせてreturn_sequencesの切り替え機能も付けたいと思います。

活性化関数と全結合

  • 以下活性化関数と全結合のクラスです。多層パーセプトロン実装時と一緒です。

活性化関数

全結合

RNNブロック

  • 多層パーセプトロンの時との違いは、再帰入力の重み  V の追加と、順伝播関数forward_propと逆伝播関数back_propで時間ループを回していることです。

順伝播の時間ループ

  • 以下の順伝播の式を全ての時間で計算するため、時間でforループを回す必要があります。

\displaystyle
\mathbf{Z}^{(\lambda)}_{t}
=
\mathbf{\Phi}^{(\lambda)}_{t}\mathbf{W}^{(\lambda)}+
\mathbf{\Phi}^{(\lambda+1)}_{t-1}\mathbf{V}^{(\lambda)}
+\mathbf{b}^{(\lambda)}
\tag{3}


\displaystyle
\mathbf{\Phi}^{(\lambda+1)}_{t}
= f^{(\lambda)}(\mathbf{Z}^{(\lambda)}_{t})
\tag{4}

  • forward_prop関数内の以下の55~60行目の記述が相当します。
for t in range(T):
    if t==0:
        Z[:,t,:] = np.dot(Phi[:,t,:], self.W)  + self.b
    else:
        Z[:,t,:] = np.dot(Phi[:,t,:], self.W) + np.dot(Phi_next[:,t-1,:], self.V) + self.b
    Phi_next[:,t,:] = self.act_func.forward_prop(Z[:,t,:])
  • 戻り値は、return_sequencesで場合分けしてFalseの時は最終時間のみを出力させてます。64~67行目です。
if self.return_sequences:
    return Phi_next
else:
    return Phi_next[:,-1,:]

逆伝播の時間ループ

  • 以下逆伝播の式です。こちらも全ての時間で算出してやります。時間に関しても逆伝播するので、未来から過去に向かってforループを回してやります。

\displaystyle
\begin{eqnarray*}
\mathbf{\Delta}^{(\lambda)}_{t}
&\equiv&
\frac{\partial L}{\partial \mathbf{Z}^{(\lambda)}_{t}}
\\
&=&
\left(
\frac{\partial L}{\partial \mathbf{\Phi}^{(\lambda+1)}_{t}}
+
\mathbf{\Delta}^{(\lambda)}_{t+1}\mathbf{V}^{(\lambda)T}
\right)
\circ
f^{'(\lambda)}(\mathbf{Z}^{(\lambda)}_{t})
\end{eqnarray*}
\tag{5}


\displaystyle
\frac{\partial L}{\partial \mathbf{\Phi}^{(\lambda)}_{t}}
= \mathbf{\Delta}^{(\lambda)}_{t}\mathbf{W}^{(\lambda)T}
\tag{6}

  • back_prop関数内の87~92行目が相当する処理です。
for t in range(T-1, -1, -1): # Back Propagation Through Time
    if t==T-1:
        Delta[:,t,:] = self.act_func.back_prop(self.Z[:,t,:], dPhi_next[:,t,:])
    else:
        Delta[:,t,:] = self.act_func.back_prop(self.Z[:,t,:], dPhi_next[:,t,:] + np.dot(Delta[:,t+1,:], self.V))
    dPhi[:,t,:] = np.dot(Delta[:,t,:], self.W.T)
  • 以下は重み勾配の計算式です。こちらは全ての時間について足し込んでやります。

\displaystyle
\frac{\partial L}{\partial \mathbf{b}^{(\lambda)}}
=
\sum_{t=1}^{T}
\left[1 \cdots 1\right]\mathbf{\Delta}^{(\lambda)}_{t}
\tag{7}


\displaystyle
\frac{\partial L}{\partial \mathbf{W}^{(\lambda)}}
=
\sum_{t=1}^{T}
\mathbf{\Phi}^{(\lambda)T}_{t}\mathbf{\Delta}^{(\lambda)}_{t}
\tag{8}


\displaystyle
\frac{\partial L}{\partial \mathbf{V}^{(\lambda)}}
=
\sum_{t=1}^{T}
\mathbf{\Phi}^{(\lambda+1)T}_{t-1}\mathbf{\Delta}^{(\lambda)}_{t}
\tag{9}

  • 94~98行目の処理が相当します。
for t in range(T):
    if t!=0:
        self.dV += np.dot(self.Phi_next[:,t-1,:].T, Delta[:,t,:])/n_samples
    self.dW += np.dot(self.Phi[:,t,:].T, Delta[:,t,:])/n_samples
    self.db += np.dot(np.ones([1, n_samples]), Delta[:,t,:])/n_samples
  • return_sequences=False時は、逆伝播してくる2階のテンソルを、最終時間のみだけ値が入り、後の時間は全部ゼロになる様に3階のテンソルに変形しています。75~79行目が相当します。
if self.return_sequences:
    dPhi_next = _dPhi_next.copy()
else:
    dPhi_next = np.zeros([n_samples, T, n_units])
    dPhi_next[:,-1,:] = _dPhi_next.copy()
  • 最終時間以外はloss計算時に使ってないので微分  \frac{\partial L}{\partial \mathbf{\Phi}^{(\lambda+1)}_{t}}は全部ゼロになっているというわけです。

Dense中の時間ループ

  • return_sequence=True時は時間データも含めた3階のテンソルで処理する必要があるので、全結合演算でも、forward_propとback_prop関数内で時間ループを回す必要があります。

  • Denseを継承して、TimeSeriesDenseというクラスを作成し、時間ループの演算をするforward_propとback_prop関数をオーバーライドしておきます。

ネットワーク全体作成クラス

  • 入力 → RNNブロック → Dense → 出力の4層ネットワークの作成と学習をまとめたクラスです。

  • レイヤー作成のcreate_layers関数では、return_sequensesの値によって全結合クラスを変えています。True時はTimeSeriesDenseが呼ばれ、False時はDenseが呼ばれる様にしています。72~75行目の処理です。
if self.return_sequences:
    self.dense = TimeSeriesDense(units=n_classes, input_dim=self.rnn_units)
else:
    self.dense = Dense(units=n_classes, input_dim=self.rnn_units)
  • 順伝播関数forward_propと逆伝播関数back_propでは、各レイヤークラスのforward_propとback_prop関数をレイヤー順に呼ぶだけです。

  • lossに平均2乗誤差を用いるので、mean_square_error関数を用意しています。110~114行目。

def mean_square_error(self, X, Y):
    """平均2乗誤差"""
    Z, Phi = self.forward_prop(X)
    n_samples = Y.shape[0]
    return np.sum(0.5*np.power(Y - Phi[-1], 2))/n_samples

実行結果

return_sequences=True

  • 以下、自作版RNNの学習実行スクリプトです。学習後はlossの履歴とpredicton結果をグラフに出力してkeras版の結果と比較しています。

  • 以下lossの履歴です。自作版の方はlossの収束値が少し大きいですが、まあ許容範囲でしょう。

  • 以下prediction結果です。思ったよりkeras版と一致してますね。

return_sequences=False

  • 以下が自作版のreturn_sequences=Falseにした時の学習実施スクリプトです。こちらも学習後はloss履歴とprediction結果をグラフに出力してkeras版と比較しています。

  • 以下loss履歴です。自作版の方がkeras版よりlossがちょっと小さくなりました。

  • 以下prediction結果です。こちらもまあまあの一致。