からっぽのしょこ

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

5.3.1:RNNレイヤの実装【ゼロつく2のノート(実装)】

はじめに

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

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

 この記事は、5.3.1項「RNNレイヤの実装」の内容です。RNNレイヤを説明して、Pythonで実装します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

5.3.1 RNNレイヤの実装

 テキストのように前後の要素が影響し合う時系列データを扱えるRNN(リカレントニューラルネットワーク)レイヤを実装します。

# 5.3.1項で利用するライブラリ
import numpy as np


・順伝播の処理の確認

 まずは、RNNレイヤの順伝播の処理を確認していきます。

 RNNレイヤにおける順伝播の入力データを$\mathbf{xs} = (\mathbf{x}_0, \mathbf{x}_1, \cdots, \mathbf{x}_{T-1})$と表記することにします。これは、5.4.2.1項で扱うTime Embeddingレイヤにおける順伝播の出力です。Embedレイヤでは、単語IDに対応したone-hot表現の単語ベクトルを入力して、何かしらの情報を内包した単語ベクトルに変換するのでした(4章)。つまり、$\mathbf{xs}$の各要素の添字はテキストに登場した順序に対応しており、$t$番目の単語(ベクトル)は$D$次元ベクトル$\mathbf{x}_t = (x_{t0}, x_{t1}, \cdots, x_{tD-1})$です。ここで$D$は、Embedレイヤの中間層のニューロン数であり、wordvec_sizeと呼ぶことにします(詳しくは5.4.2.1項で解説します)。

 $t$番目のRNNレイヤの順伝播では、$\mathbf{x}_t$を入力として次の計算を行います。

$$ \mathbf{h}_t = \mathrm{tanh}( \mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}} + \mathbf{x}_t \mathbf{W}_{\mathbf{x}} + \mathbf{b} ) \tag{5.9} $$

 ここで$\mathrm{tanh}(\cdot)$は、tanh関数です。これを$T$個のRNNレイヤで求めて、$\mathbf{hs} = (\mathbf{h}_0, \mathbf{h}_1, \cdots, \mathbf{h}_{T-1})$を次のTime Affineレイヤに出力します。

 では最初の計算から順番に考えましょう。

 0番目の入力データ$\mathbf{x}_0$を簡易的に作成します。ただし最終的に実装するRNNLMでは、ミニバッチデータを入力します。なのでここでは、バッチサイズbatch_sizeが1の入力データとして扱うことにします。

# バッチサイズ
batch_size = 1

# Embedレイヤの中間層のニューロン数を指定
wordvec_size = 10

# (簡易的に)RNNレイヤの入力データを作成
x0 = np.arange(batch_size * wordvec_size).reshape((batch_size, wordvec_size))
print(x0.shape)
(1, 10)


 入力データ$\mathbf{xs}$に対応する重み$\mathbf{W}_{\mathbf{x}}$とバイアス$\mathbf{b}$を生成します。このパラメータは、全ての入力データに対して共通のパラメータです。$\mathbf{W}_{\mathbf{x}}$は$D \times H$の行列で、$\mathbf{b}$は$D$次元ベクトルです。ここで$H$は、次のレイヤであるAffineレイヤの中間層のニューロン数であり、hidden_sizeで表すことにします。

# Affineレイヤの中間層のニューロン数を指定
hidden_size = 5

# 入力データに対応する重みとバイアスを生成
Wx = np.random.randn(wordvec_size, hidden_size)
b = np.zeros(hidden_size)
print(Wx.shape)
print(b.shape)
(10, 5)
(5,)


 これで0番目のRNNレイヤの計算に必要な入力データとパラメータがそろったので、重み付き和を計算します。

$$ \mathbf{a}_0 = \mathbf{x}_0 \mathbf{W}_{\mathbf{x}} + \mathbf{b} $$
# 重み付き和の計算
a0 = np.dot(x0, Wx) + b
print(a0.shape)
(1, 5)

 (実装例では変数名がtとされていますが、ややこしいのでaを使います。)

 これをtanh関数を用いて活性化します。

$$ \mathbf{h}_t = \mathrm{tanh}(\mathbf{a}_0) $$

 tanh関数の計算には、np.tanh()を使います。

# tanh関数による活性化
h0 = np.tanh(a0)
print(h0.shape)
(1, 5)

 0番目の出力$\mathbf{h}_0$が求まりました。これは、次(1番目)のRNNレイヤと、$\mathbf{hs}$の0番目の要素としてTime Affineレイヤに出力します。

 では1番目のRNNレイヤの計算を行いましょう。このレイヤには、1番目の入力データ$\mathbf{x}_1$と、1つ前(0番目)の出力$\mathbf{h}_0$が入力します。$\mathbf{x}_1$と、$\mathbf{h}_0$に対応する重み$\mathbf{W}_{\mathbf{h}}$を生成します。$\mathbf{W}_{\mathbf{h}}$は、$H$次元ベクトル$\mathbf{h}_0$と、Affineレイヤの中間層のニューロン数$H$に対応するため、$H \times H$の行列です。

# (簡易的に)RNNレイヤの入力データを作成
x1 = np.arange(batch_size * wordvec_size).reshape((batch_size, wordvec_size))
print(x1.shape)

# 1つ前の出力に対応する重みを生成
Wh = np.random.randn(hidden_size, hidden_size)
print(Wh.shape)
(1, 10)
(5, 5)


 これで1番目のRNNレイヤの計算に必要な変数がそろったので、重み付き和を計算します。

$$ \mathbf{a}_1 = \mathbf{h}_0 \mathbf{W}_{\mathbf{h}} + \mathbf{x}_1 \mathbf{W}_{\mathbf{x}} + \mathbf{b} $$
# 重み付き和の計算
a1 = np.dot(h0, Wh) + np.dot(x1, Wx) + b
print(a1.shape)
(1, 5)


 先ほどと同様に、tanh関数により活性化します。

$$ \mathbf{h}_1 = \mathrm{tanh}(a_1) $$
# tanh関数による活性化
h1 = np.tanh(a1)
print(h1.shape)
(1, 5)

 $\mathbf{h}_1$が求まりました。これが更に、Time Affineレイヤと次(2番目)のRNNレイヤに出力し、$\mathbf{h}_2$を計算します。

 これを$T$回繰り返すのがRNNレイヤの順伝播の処理です。

・逆伝播の処理の確認

 次に図5-20を参考に、逆伝播の計算を確認していきます。

 連鎖律により、各変数の勾配は、順伝播で辿ったレイヤの微分の積で求められるのでした。この性質を利用して、誤差逆伝播法ではLossレイヤから目的の変数までの各レイヤの微分の積を伝播していくのでした。

 では先ほど計算した1番目のRNNレイヤについて考えます。「1番目のRNNレイヤ」には、「2番目のRNNレイヤ」と「Time Affineレイヤ」から勾配$\frac{\partial L}{\partial \mathbf{h}_1}$が伝播してきます(入力します)。この2つの和を「2番目のRNNレイヤの逆伝播における入力$\frac{\partial L}{\partial \mathbf{h}_1}$」とします。$\mathbf{h}_1$に関する勾配$\frac{\partial L}{\partial \mathbf{h}_1}$は、「損失$L$」を「2番目のRNNレイヤの順伝播における入力$\mathbf{h}_1$」で微分したものです。詳しくは1巻の5章を参照してください。

 $\frac{\partial L}{\partial \mathbf{h}_1}$はここまでに計算されているものなので、ここではdh1として簡易的に作成します。$\frac{\partial L}{\partial \mathbf{h}_1}$は、$\mathbf{h}_1$と同じ形状になります。

# 逆伝播の入力を生成
dh1 = np.random.randn(batch_size, hidden_size)
print(dh1.shape)
(1, 5)

 これが1番目のRNNレイヤの「tanh関数」に入力します(逆伝播します)。

 続いて、各変数の勾配を求めていきます。

 「1番目のtanh関数の入力$\mathbf{a}_1$」による「1番目のtanh関数の出力$\mathbf{h}_1$」の微分$\frac{\partial \mathbf{h}_1}{\partial \mathbf{a}_1}$を求めます。tanh関数の微分は、付録のA.2節より次の式で計算できます。

$$ \frac{\partial \mathbf{h}_1}{\partial \mathbf{a}_1} = 1 - \mathbf{h}_1^2 $$


 従って、$\mathbf{a}_1$に関する勾配$\frac{\partial L}{\partial \mathbf{a}_1}$は、次のようになります。

$$ \begin{aligned} \frac{\partial L}{\partial \mathbf{a}_1} &= \frac{\partial L}{\partial \mathbf{h}_1} \frac{\partial \mathbf{h}_1}{\partial \mathbf{a}_1} \\ &= \frac{\partial L}{\partial \mathbf{h}_1} (1 - \mathbf{h}_1^2) \end{aligned} $$
# tanh関数の入力の勾配を計算
da1 = dh1 * (1 - h1**2)
print(da1.shape)
(1, 5)

 $\frac{\partial L}{\partial \mathbf{a}_1}$は、$\mathbf{a}_1$と同じ形状になります。これがバイアスとの「加算ノード」に入力します。

 順伝播においてバイアス$\mathbf{b}$は、「加算ノード」に入力し、$N$個のデータそれぞれに分岐して加算され、$\mathbf{a}_1$が出力されます。つまりRepeatノード(1.3.4.3項)といえます。また加算ノードの(1.3.4項)の微分は1なので、$\frac{\partial \mathbf{a}_1}{\partial \mathbf{b}} = 1$です。従って、$\mathbf{b}$の勾配$\frac{\partial L}{\partial \mathbf{b}}$の各項は、次の式で計算できます。

$$ \begin{aligned} \frac{\partial L}{\partial b_h} &= \sum_{n=0}^{N-1} \frac{\partial L}{\partial a_{nh}^{(1)}} \frac{\partial a_{nh}^{(1)}}{\partial b_h} \\ &= \sum_{n=0}^{N-1} \frac{\partial L}{\partial a_{nh}^{(1)}} \end{aligned} $$

 添字が混同しないように$\mathbf{h}_1$を$\mathbf{h}^{(1)}$と表記しています。

 つまりこのノードの逆伝播の入力da1を行方向に加算(N行分の要素を加算)します。

# バイアスの勾配を計算
db = np.sum(da1, axis=0)
print(db.shape)
(5,)

 $\frac{\partial L}{\partial \mathbf{b}}$は、$\mathbf{b}$と同じ形状になります。$\frac{\partial L}{\partial \mathbf{b}}$は$\mathbf{b}$の更新に使い、次のノードへは$\frac{\partial L}{\partial \mathbf{a}_1}$がそのまま(厳密には1を掛けたもの)が2つの「MatMulノード」に入力します。

 重み付き和の計算は、MatMalノード(1.3.4.5項)です。従って、順伝播における1番目の入力データ$\mathbf{x}_1$の勾配$\frac{\partial L}{\partial \mathbf{x}_1}$は、次の式で

$$ \frac{\partial L}{\partial \mathbf{x}_1} = \frac{\partial L}{\partial \mathbf{a}_1} \mathbf{W}_{\mathbf{x}}^{\mathrm{T}} $$

重み$\mathbf{W}_{\mathbf{x}}$の勾配$\frac{\partial L}{\partial \mathbf{W}_{\mathbf{x}}}$は、次の式で計算できます。

$$ \frac{\partial L}{\partial \mathbf{W}_{\mathbf{x}}} = \mathbf{x}_1^{\mathrm{T}} \frac{\partial L}{\partial \mathbf{a}_1} $$
# 入力データの勾配を計算
dx1 = np.dot(da1, Wx.T)
print(dx1.shape)

# 入力データに対する重みの勾配を計算
dWx = np.dot(x1.T, da1)
print(dWx.shape)
(1, 10)
(10, 5)

 $\frac{\partial L}{\partial \mathbf{x}_1},\ \frac{\partial L}{\partial \mathbf{W}_{\mathbf{x}}}$は、それぞれ$\mathbf{x}_1,\ \mathbf{W}_{\mathbf{x}}$と同じ形状になります。$\frac{\partial L}{\partial \mathbf{W}_{\mathbf{x}}}$は、$\mathbf{W}_{\mathbf{x}}$の更新に使います。

 0番目のRNNレイヤから順伝播した$\mathbf{h}_0$に関する「MatMulノード」についても同様に、$\mathbf{h}_0$の勾配$\frac{\partial L}{\partial \mathbf{h}_0}$は

$$ \frac{\partial L}{\partial \mathbf{h}_0} = \frac{\partial L}{\partial \mathbf{a}_1} \mathbf{W}_{\mathbf{h}}^{\mathrm{T}} $$

で、重み$\mathbf{W}_{\mathbf{h}}$の勾配$\frac{\partial L}{\partial \mathbf{W}_{\mathbf{h}}}$は

$$ \frac{\partial L}{\partial \mathbf{W}_{\mathbf{h}}} = \mathbf{h}_0^{\mathrm{T}} \frac{\partial L}{\partial \mathbf{a}_1} $$

で計算します。

# 1つ前の回の出力データの勾配を計算
dh0 = np.dot(da1, Wh.T)
print(dh0.shape)

# 入力データに対する重みの勾配を計算
dWh = np.dot(h0.T, da1)
print(dWh.shape)
(1, 5)
(5, 5)

 $\frac{\partial L}{\partial \mathbf{h}_0},\ \frac{\partial L}{\partial \mathbf{W}_{\mathbf{h}}}$は、それぞれ$\mathbf{h}_0,\ \mathbf{W}_{\mathbf{h}}$と同じ形状になります。$\mathbf{W}_{\mathbf{h}}$は$\mathbf{W}_{\mathbf{h}}$の更新に使い、$\frac{\partial L}{\partial \mathbf{h}_0}$は0番目のRNNレイヤに逆伝播します。

 ここまでが1つのRNNレイヤの逆伝播の処理です。他のRNNレイヤにおいても同様の処理を行います。

・実装

 処理の確認ができたので、RNNレイヤをクラスとして実装します。

# RNNレイヤの実装
class RNN:
    # 初期化メソッドの定義
    def __init__(self, Wx, Wh, b):
        self.params = [Wx, Wh, b] # パラメータ
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)] # 勾配
        self.cache = None # 変数の保存用
    
    # 順伝播メソッドの定義
    def forward(self, x, h_prev):
        
        # パラメータを取得
        Wx, Wh, b = self.params
        
        # 順伝播を計算:式(5.10)
        t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b # 重み付き和
        h_next = np.tanh(t) # 活性化
        
        # 逆伝播に用いる変数を保存
        self.cache = (x, h_prev, h_next)
        
        return h_next
    
    # 逆伝播メソッドの定義
    def backward(self, dh_next):
        # 変数を取得
        Wx, Wh, b = self.params # パラメータ
        x, h_prev, h_next = self.cache # データ
        
        # 勾配を計算
        dt = dh_next * (1 - h_next ** 2)
        db = np.sum(dt, axis=0)
        dWh = np.dot(h_prev.T, dt)
        dh_prev = np.dot(dt, Wh.T)
        dWx = np.dot(x.T, dt)
        dx = np.dot(dt, Wx.T)
        
        # 結果を格納
        self.grads[0][...] = dWx
        self.grads[1][...] = dWh
        self.grads[2][...] = db
        
        return dx, dh_prev


 実装したクラスを試してみましょう。ただし初回の計算については、計算が異なるため省略します。これは次項で対応します。

# インスタンスを作成
layer = RNN(Wx, Wh, b)

# 順伝播の計算
h1 = layer.forward(x1, h0)
print(h1.shape)

# 逆伝播の計算
dx1, dh0 = layer.backward(dh1)
print(dx1.shape)
print(dh0.shape)
for grad in layer.grads:
    print(grad.shape)
(1, 5)
(1, 10)
(1, 5)
(10, 5)
(5, 5)
(5,)


 以上でRNNクラスを実装できました。次項では、この処理を繰り返し行うTime RNNレイヤを実装します。

参考文献

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

おわりに

 ベイズに沼ってて2か月振りにこちらも再開しました。今日から毎日投稿できればギリギリ年内に5章が終わる予定。果たして!?

【次節の内容】

www.anarchive-beta.com