からっぽのしょこ

読んだら書く!書いたら読む!同じ事は二度調べ(たく)ない

6.2.3-6:勾配消失とLSTM【ゼロつく2のノート(実装)】

はじめに

 『ゼロから作るDeep Learning 2――自然言語処理編』の初学者向け【実装】攻略ノートです。『ゼロつく2』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。

 本の内容を1つずつ確認しながらゆっくりと組んでいきます。

 この記事は、6.2節「勾配消失とLSTM」の内容です。LSTM内のゲートの機能を解説して、Pythonで再現します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

6.2 勾配消失とLSTM

 RNNレイヤで生じる勾配消失問題の回避策としてゲート付きRNNを用います。6章では、ゲート付きRNNとしてLSTMを扱います。

# 6.2節で利用するライブラリ
import numpy as np


 LSTMレイヤの処理では、1.2.1項で実装したsigmoid()を利用します。関数定義を再実行するか、次の方法でマスターデータから読み込む必要があります。

# 読み込み用の設定
import sys
sys.path.append('C://Users//「ユーザ名」//Documents//・・・//deep-learning-from-scratch-2-master')

# 実装済みの関数
from common.functions import sigmoid


 これまでと同様に、$T$個の時系列データを処理するために$T$個のLSTMレイヤをまとめたレイヤを、Time LSTMレイヤと呼ぶことにします。この節では、Time LSTMレイヤ内の1つのLSTMレイヤの計算(処理)を1つずつ確認していきます。

 変数についてもこれまでと同様に、Time LSTMレイヤの入力データを$\mathbf{xs} = (\mathbf{x}_0, \cdots, \mathbf{x}_{T-1})$とします。$\mathbf{xs}$は、Time Embedレイヤの出力データであり、$(N \times T \times D)$のテンソル(3次元配列)です。$\mathbf{xs}$内の$t$番目の要素$\mathbf{x}_t$が、$t$番目のLSTMレイヤに入力します。各LSTMレイヤの入力データ$\mathbf{x}_t$に対応する重みを$\mathbf{W}_{\mathbf{x}}$とします。$\mathbf{W}_{\mathbf{x}}$は、全てのLSTMレイヤで共有されるパラメータで、$(D \times H)$の行列です。
 $t$番目のLSTMレイヤの出力データを$\mathbf{h}_t$とし、隠れ状態とも呼びます。$\mathbf{h}_t$は、次($t+1$番目)のLSTMレイヤに入力します。また、全てのLSTMレイヤの出力データをまとめた$\mathbf{hs} = (\mathbf{h}_0, \cdots, \mathbf{h}_{T-1})$が、Time Affineレイヤの入力データになります。$\mathbf{hs}$は、$(N \times T \times H)$のテンソルです。$\mathbf{h}_t$に対応する重みを$\mathbf{W}_{\mathbf{h}}$とします。$\mathbf{W}_{\mathbf{h}}$もLSTMレイヤ間で共有され、$(H \times H)$の行列です。

 ここで$N$はバッチサイズ、$T$は時間サイズ、$D$は単語ベクトルの要素数(Embedレイヤの中間層のニューロン数)、$H$は隠れ状態の要素数(Affineレイヤの中間層のニューロン数)です。

 ここまではTime RNNレイヤのときと同じです。Time LSTMレイヤでは、各LSTMレイヤにおいて$\mathbf{h}_t$の計算に用いる変数の値(情報)を調整します。この調整機能をゲートと呼び、各LSTMレイヤは3つのゲートを持ちます。また3つ目の入力として、記憶セル$\mathbf{c}_t$を導入します。$\mathbf{c}_t$は、($\mathbf{h}_t$と同じ)$(N \times H)$の行列で、Time LSTMレイヤ内だけで扱う変数です。

 Time LSTMレイヤに入力する変数(データ)を作成します。

# データとパラメータの形状に関する値を指定
N = 5 # バッチサイズ
T = 7 # 時間サイズ
D = 12 # 単語ベクトル(Embedレイヤの中間層)のサイズ
H = 10 # 隠れ状態(Affineレイヤの中間層)のサイズ

# (簡易的に)Time LSTMレイヤの入力データを作成
xs = np.random.rand(N, T, D) * 2 # Time Embedレイヤの出力
hs = np.random.rand(N, T, H) * 2 # Time LSTMレイヤの出力
c_prev = np.random.rand(N, H) * 2 # Time LSTMレイヤの記憶セル
print(np.round(xs[:, 0, :], 2)) # 0時刻の入力データ
[[1.49 1.38 1.38 0.75 1.34 0.68 1.15 0.65 0.89 0.12 0.49 1.94]
 [0.58 1.48 0.32 1.39 1.68 1.45 0.72 1.45 0.28 0.63 0.84 1.75]
 [1.39 0.78 0.09 0.88 0.74 1.19 0.27 0.46 1.63 0.53 0.82 1.87]
 [1.07 1.38 0.18 0.86 0.2  0.28 0.   0.42 0.43 1.03 1.14 1.65]
 [0.17 0.73 0.98 0.57 1.07 0.42 0.99 1.06 1.35 1.49 1.46 0.85]]

 $\mathbf{c}_t$は、LSTMレイヤ間でやり取りされるものなので、$T$個分をまとめる必要はありません。

 続いてforgetゲート、記憶セルの計算、inputゲート、outputゲートの順番に説明します。それぞれ重み$\mathbf{W}_{\mathbf{x}},\ \mathbf{W}_{\mathbf{h}}$とバイアス$\mathbf{b} = (b_0, \cdots, b_{H-1})$を持ちます。実際の流れをイメージしやすいように本とは扱う順番を変えます。

6.2.4 forgetゲート

 forgetゲート(忘却ゲート)は、1つ前のLSTMレイヤから入力する記憶セル$\mathbf{c}_{t-1}$から不要な記憶を消去(値を調整)するゲートです。右肩に$(\mathbf{f})$を付けることでforgetゲートのパラメータを表すことにします。

 forgetゲートの出力$\mathbf{f}$は、記憶セルの各要素の情報を残す(値を伝播する)割合を表します。次の式で計算します。

$$ \mathbf{f} = \sigma \Bigl( \mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(\mathbf{f})} + \mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(\mathbf{f})} + \mathbf{b}^{(\mathbf{f})} \Bigr) \tag{6.1} $$

 $\mathbf{f}$は$(N \times H)$の行列です。各要素の値は、sigmoid関数により0から1の値に変換されます。sigmoid関数については、「1.2.1項」または「1巻の3.2.4項」を参照してください。

 時刻を指定して、指定したLSTMレイヤに入力するデータを取り出します。ただし0番目のレイヤに関しては、1つ前のレイヤが存在しないため処理が異なります。そこでここでは、0以外の値を指定することにします。

 forgetゲートのパラメータを生成します。初期値についてはこれまでと同じです。

# 時刻を指定
t = 1

# t番目のLSTMレイヤの入力を取得
x = xs[:, t, :]
h_prev = hs[:, t-1, :]

# forgetゲートのパラメータを生成
Wx_f = np.random.randn(D, H)
Wh_f = np.random.randn(H, H)
b_f = np.zeros(H)


 式(6.1)の計算をして$\mathbf{f}$を求めます。sigmoid関数の計算には、1.2.1項で実装したsigmoid()を使います。

## forgetゲートの計算

# 重み付き和を計算
f = np.dot(x, Wx_f)
f += np.dot(h_prev, Wh_f)
f += b_f
print(np.round(f, 2))

# 活性化
f = sigmoid(f)
print(np.round(f, 2))
print(f.shape)
[[ -4.43   1.97  -0.93  -3.36   0.63   3.94  -1.51   6.15  -0.23   2.65]
 [ -4.55  -0.98  -7.62   1.07   2.12   3.69  -2.07   8.2    0.66   1.42]
 [ -5.02   3.2   -0.44   0.6   -0.63   4.12   0.1    5.57  -2.25   4.39]
 [-11.58   2.34  -0.09  -7.42   0.24   5.31   0.8    5.87  -3.25   4.12]
 [-11.77   6.48   1.27  -7.96  -1.75   9.86  -0.12   9.26  -0.84   1.89]]
[[0.01 0.88 0.28 0.03 0.65 0.98 0.18 1.   0.44 0.93]
 [0.01 0.27 0.   0.74 0.89 0.98 0.11 1.   0.66 0.8 ]
 [0.01 0.96 0.39 0.65 0.35 0.98 0.52 1.   0.1  0.99]
 [0.   0.91 0.48 0.   0.56 1.   0.69 1.   0.04 0.98]
 [0.   1.   0.78 0.   0.15 1.   0.47 1.   0.3  0.87]]
(5, 10)

 全ての要素が0から1の値に変換されています。これは、入力$\mathbf{x}_t,\ \mathbf{h}_{t-1}$とパラメータ$\mathbf{W}_{\mathbf{x}}^{(\mathbf{f})},\ \mathbf{W}_{\mathbf{h}}^{(\mathbf{f})},\ \mathbf{b}^{(\mathbf{f})}$の情報を基にして、記憶セル$\mathbf{c}_{t-1}$から情報を抽出する割合を決めていることを意味します。

 1つ前のレイヤで求めた記憶セル$\mathbf{c}_{t-1}$の各要素に、このレイヤで求めた$\mathbf{f}$の対応する要素を掛けます。割り引いた値(不要な情報を忘却した)$\mathbf{c}_{t-1}$を、このレイヤの出力データ$\mathbf{h}_t$の計算に用います。割り引いた記憶セルを$\mathbf{c}_{t-1}^{'}$で表すことにします(この項だけで用いる表現です)。

$$ \mathbf{c}_{t-1}^{'} = \mathbf{c}_{t-1} \odot \mathbf{f} $$

 ここで$\odot$はアダマール積と言い、(行列の積ではなく)要素ごとの積を表します。つまり$\mathbf{c}_{t-1}$と$\mathbf{f}$は同じ形状の行列です。

# t番目のLSTMレイヤに入力する記憶セル(過去の記憶セル)
print(np.round(c_prev, 2))

# 過去の記憶セルから不要な情報を忘却(必要な情報を抽出)
c_prev *= f
print(np.round(c_prev, 2))
print(c_prev.shape)
[[0.53 1.3  0.51 1.59 0.69 1.92 0.35 1.43 1.71 1.74]
 [1.04 1.1  0.59 1.71 1.52 1.03 1.01 1.84 1.12 0.19]
 [0.22 1.53 1.05 1.11 0.87 0.05 1.61 0.76 0.93 0.88]
 [0.27 1.93 1.92 0.02 0.11 0.77 1.96 0.96 1.85 0.68]
 [0.99 1.66 1.32 0.61 0.91 1.25 0.8  0.4  0.45 1.48]]
[[0.01 1.14 0.15 0.05 0.45 1.88 0.06 1.43 0.76 1.63]
 [0.01 0.3  0.   1.28 1.36 1.   0.11 1.84 0.74 0.15]
 [0.   1.47 0.41 0.72 0.3  0.04 0.84 0.76 0.09 0.87]
 [0.   1.76 0.92 0.   0.06 0.77 1.35 0.96 0.07 0.67]
 [0.   1.66 1.03 0.   0.13 1.25 0.38 0.4  0.14 1.29]]
(5, 10)

 全ての要素から値を割り引く(ゲートで情報を絞る)ので、どの要素も値が小さくなります。

 これが入力した記憶セル$\mathbf{c}_{t-1}$に対する処理です。次は入力データ$\mathbf{x}_t$に対する処理を説明します。

6.2.5 新しいセル

 この項では、入力データ$\mathbf{x}_t$と隠れ状態(過去の情報)$\mathbf{h}_{t-1}$から、新たな記憶セル$\mathbf{c}_t$として用いる情報を抽出することを考えます。このノードで用いるパラメータは、右肩に$(\mathbf{g})$を付けることで表します。

 $\mathbf{x}_t$と$\mathbf{h}_{t-1}$から抽出した情報を$\mathbf{g}$とします。$\mathbf{g}$は、このノードのパラメータ$\mathbf{W}_{\mathbf{x}}^{(\mathbf{g})},\ \mathbf{W}_{\mathbf{h}}^{(\mathbf{g})},\ \mathbf{b}^{(\mathbf{g})}$を用いた重み付き和を、tanh関数で変換して計算します。

$$ \mathbf{g} = \mathrm{tanh} \Bigl( \mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(\mathbf{g})} + \mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(\mathbf{g})} + \mathbf{b}^{(\mathbf{g})} \Bigr) \tag{6.4} $$

 $\mathbf{g}$も$(N \times H)$の行列です。各要素は-1から1の値をとります。

 先ほどと同様に、パラメータを生成して重み付き和を計算して、np.tanh()で活性化します。

# 新たな情報の抽出に用いるパラメータを生成
Wx_g = np.random.randn(D, H)
Wh_g = np.random.randn(H, H)
b_g = np.zeros(H)

## 入力データから新たな情報を抽出

# 重み付き和を計算
g = np.dot(x, Wx_g)
g += np.dot(h_prev, Wh_g)
g += b_g
print(np.round(g, 2))

# 活性化
g = np.tanh(g)
print(np.round(g, 2))
print(g.shape)
[[  2.39   1.45  -7.02   1.53  -5.39   4.41  -4.18  -3.77  -7.03  -3.14]
 [ -1.56   2.69  -4.62  -3.44  -3.72   0.84  -5.7   -0.89  -8.49  -0.36]
 [ -3.09   1.58 -11.43   3.13 -10.06   4.62  -2.7   -2.35  -6.97  -3.91]
 [  5.17   5.07  -5.82  -3.32  -2.28   5.7   -8.33  -5.96 -11.1   -0.55]
 [  5.04   2.82  -8.1   -2.18  -9.7    9.48 -11.95  -6.73 -10.92  -8.31]]
[[ 0.98  0.9  -1.    0.91 -1.    1.   -1.   -1.   -1.   -1.  ]
 [-0.92  0.99 -1.   -1.   -1.    0.69 -1.   -0.71 -1.   -0.35]
 [-1.    0.92 -1.    1.   -1.    1.   -0.99 -0.98 -1.   -1.  ]
 [ 1.    1.   -1.   -1.   -0.98  1.   -1.   -1.   -1.   -0.5 ]
 [ 1.    0.99 -1.   -0.97 -1.    1.   -1.   -1.   -1.   -1.  ]]
(5, 10)

 全ての要素が-1から1の値に変換されています。

 新たな情報$\mathbf{g}$を抽出できました。次は、$\mathbf{g}$から記憶セル$\mathbf{c}_t$として残す情報を抽出する(不要な情報を落とす)ためのinputゲートを説明します。

6.2.6 inputゲート

 inputゲート(入力ゲート)は、新たな情報$\mathbf{g}$から新たに記憶セル$\mathbf{c}_t$として追加する情報を抽出するためのゲートです。inputゲートで用いるパラメータは、右肩に$(\mathbf{i})$を付けることで表します。

 inputゲートの出力を$\mathbf{i}$とします。$\mathbf{i}$は、$\mathbf{g}$の各要素から$\mathbf{c}_t$として追加する割合を表し、次の式で計算します。

$$ \mathbf{i} = \sigma \Bigl( \mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(\mathbf{i})} + \mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(\mathbf{i})} + \mathbf{b}^{(\mathbf{i})} \Bigr) \tag{6.5} $$

 $\mathbf{i}$も$(N \times H)$の行列で、各要素が0から1の値になります。

 forgetゲートのときと同様に計算します。

# inputゲートのパラメータを生成
Wx_i = np.random.randn(D, H)
Wh_i = np.random.randn(H, H)
b_i = np.zeros(H)

# inputゲートを計算
i = np.dot(x, Wx_i)
i += np.dot(h_prev, Wh_i)
i += b_i
i = sigmoid(i)
print(np.round(i, 2))
print(i.shape)
[[0.26 0.12 1.   0.01 0.73 0.12 0.91 0.99 0.   0.25]
 [0.89 0.03 1.   0.01 0.99 0.66 0.28 1.   0.   0.01]
 [0.82 0.11 0.99 0.   0.05 0.   0.95 1.   0.   0.25]
 [0.99 0.21 1.   0.11 0.98 0.23 0.28 0.94 0.   0.43]
 [0.31 0.   1.   0.   0.26 0.09 0.89 1.   0.   0.75]]
(5, 10)

 0から1の値が出力されます。

 新たな情報$\mathbf{g}$とinputゲートの出力$\mathbf{i}$の要素ごとの積を求めて、$\mathbf{c}_t^{'}$とします。

$$ \mathbf{c}_t^{'} = \mathbf{g} \odot \mathbf{i} $$

 $\mathbf{c}_t^{'}$は、inputゲートによって割り引かれた追加情報です。これをforgetゲートによって割り引かれた記憶セル(過去の情報)$\mathbf{c}_{t-1}^{'}$に加えます。

$$ \mathbf{c}_t = \mathbf{c}_{t-1}^{'} + \mathbf{c}_t^{'} $$

 $\mathbf{c}_t$がこのレイヤで得られた記憶セルであり、分岐して1つは次のLSTMレイヤに入力し、もう1つは隠れ状態$\mathbf{h}_t$の計算に用います。

# t番目のレイヤの記憶セルとして追加する情報を抽出
c_next = g * i

# t番目のレイヤの記憶セルを計算
c_next += c_prev
print(np.round(c_next, 2))
[[ 0.26  1.26 -0.85  0.06 -0.28  2.   -0.85  0.43  0.76  1.38]
 [-0.8   0.33 -1.    1.27  0.38  1.45 -0.17  1.13  0.74  0.15]
 [-0.81  1.57 -0.58  0.72  0.25  0.05 -0.1  -0.22  0.09  0.62]
 [ 0.99  1.97 -0.08 -0.11 -0.9   1.    1.07  0.02  0.07  0.46]
 [ 0.31  1.66  0.03 -0.   -0.13  1.34 -0.51 -0.6   0.14  0.54]]

 各ゲートのパラメータが学習されることで、記憶セルとして残す情報も最適化されていきます。

 ここまでで記憶セル$\mathbf{c}_t$が得られました。次は、$\mathbf{c}_t$から$\mathbf{h}_t$を求める計算を説明します。

6.2.3 outputゲート

 outputゲート(出力ゲート)は、このレイヤの記憶セル$\mathbf{c}_t$からこのレイヤの出力データ(隠れ状態)$\mathbf{h}_t$とする情報を抽出するためのゲートです。outputゲートで用いるパラメータは、右肩に$(\mathbf{o})$を付けることでを表します。

 これまでと同様に、隠れ状態の情報の抽出率$\mathbf{o}$を計算します。

$$ \mathbf{o} = \sigma \Bigl( \mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(\mathbf{o})} + \mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(\mathbf{o})} + \mathbf{b}^{(\mathbf{o})} \Bigr) \tag{6.1} $$

 $\mathbf{o}$も$(N \times H)$の行列で、各要素は0から1の値です。

 これまでと同様に計算します。

# outputゲートのパラメータを生成
Wx_o = np.random.randn(D, H)
Wh_o = np.random.randn(H, H)
b_o = np.zeros(H)

# outputゲートを計算
o = np.dot(x, Wx_o)
o += np.dot(h_prev, Wh_o)
o += b_o
o = sigmoid(o)
print(np.round(o, 2))
print(o.shape)
[[0.42 0.31 0.05 0.39 1.   0.94 0.96 1.   0.18 0.64]
 [0.08 0.01 0.05 0.01 1.   1.   1.   0.99 0.81 0.78]
 [0.6  0.15 0.06 0.01 1.   0.86 0.94 1.   0.02 0.27]
 [0.61 0.87 0.   0.55 1.   0.92 1.   0.97 0.01 0.91]
 [0.06 0.96 0.   0.55 1.   0.75 1.   0.99 0.01 0.93]]
(5, 10)


 このレイヤの記憶セル$\mathbf{c}_t$をtanh関数で活性化して、$\mathbf{o}$で割り引いた値(抽出した情報)がこのレイヤの出力データ$\mathbf{h}_t$です。

$$ \mathbf{h}_t = \mathbf{o} \odot \mathrm{tanh}(\mathbf{c}_t) \tag{6.2} $$

 $\mathbf{h}_t$も分岐して、1つは次のLSTMレイヤに入力し、もう1つはTime Affineレイヤに入力します。

# t番目のLSTMレイヤの出力データを計算
h_next = o * np.tanh(c_next)
print(np.round(h_next, 2))
[[ 0.11  0.26 -0.04  0.02 -0.27  0.91 -0.67  0.41  0.11  0.56]
 [-0.05  0.   -0.04  0.01  0.36  0.89 -0.17  0.81  0.51  0.11]
 [-0.4   0.13 -0.03  0.01  0.24  0.04 -0.09 -0.22  0.    0.15]
 [ 0.46  0.84 -0.   -0.06 -0.72  0.7   0.79  0.02  0.    0.39]
 [ 0.02  0.9   0.   -0.   -0.13  0.65 -0.47 -0.53  0.    0.46]]

 パラメータが学習されることによって、ここに保存される情報が有用なものになっていきます。

 最後に、求めたデータを元のリストに格納してます。

# t番目のLSTMレイヤの出力を格納
hs[:, t, :] = h_next


 以上がLSTMレイヤにおける順伝播の処理です。次項では、逆伝播の処理を説明します。

参考文献

  • 斎藤康毅『ゼロから作るDeep Learning 2――自然言語処理編』オライリー・ジャパン,2018年.

おわりに

 ややこしいですがやってることは面白いですね。次項の逆伝播は少々ハードなので飛ばしてもいいかと思います。気になるのであれば、記憶セルの伝播だけ確認するのがいいのではないでしょうか。

【次節の内容】

www.anarchive-beta.com