深層学習／ゼロから作るDeep Learning２ - GRUを動かしてみる

#1.はじめに
　名著、**「ゼロから作るDeep Learning２」**を読んでいます。**GRU** については、この本では付録で紹介していますが、実際に動かす場面は出てきません。せっかくなので今回は、６章でやった様に、**BetterRnnlmクラス**を使って **GRU** に **PTBデータセット**の単語の並び順を学習させ、パープレキシティで学習度合いを測定してみたいと思います。

＊実は、`common/time_layers.py` に GRU と TimeGRU のコードは一応ありますが、分かりやすさを優先した実装のため、BetterRnnlmクラスではそのまま使えません。また、バイアス項も考慮されていません。

#2.GRUの実装
　
![スクリーンショット 2020-06-02 18.24.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/f2d67268-84fb-b515-6192-4fcf9d85d1f1.png)

　これは、GRUの計算グラフです。LSTMにあった記憶セルはなく、時間方向の伝播は隠れ状態のhだけです。ゲートは、**resetゲート** と **updateゲート** の２つです。

　**resetゲート**は、過去の隠れ状態をどれだけ無視するかを決定します。もし、r がゼロであれば、$h_{hat}$は入力のみから決定され、過去の隠れ状態は無視されます。

　**updateゲート**は、LSTM の forgetゲートと inputゲートの２つを兼ねています。forgetゲートとして機能するのは、 $(1-z)\odot h_{t-1}$の部分です。この計算によって、過去の隠れ状態から忘れるべき情報を消去します。

　そして、inputゲートとして機能するのは、$z\odot h_{hat}$の部分です。この計算によって、新しく追加する情報について重み付けが行われます。

　それでは、実装する前に、重みとバイアスを整理しておきます。

![スクリーンショット 2020-06-02 18.35.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/45ac88fd-917f-76a5-c5bf-73c4f9adaff5.png)
　Wxz, Wxr, Wxh をまとめて **Wx** (D×3H)、Whz, Whr, Whh をまとめて **Wh** (H×3H)、bz, br, bh をまとめて **b** (3H)とします。

```python
from common.np import *  # import numpy as np (or import cupy as np)
from common.layers import *
from common.functions import softmax, sigmoid

class GRU:
    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
        H = Wh.shape[0]
        Wxz, Wxr, Wxh = Wx[:, :H], Wx[:, H:2 * H], Wx[:, 2 * H:]
        Whz, Whr, Whh = Wh[:, :H], Wh[:, H:2 * H], Wh[:, 2 * H:]
        bz, br, bh = b[:H], b[H:2 * H], b[2 * H:]  

        z = sigmoid(np.dot(x, Wxz) + np.dot(h_prev, Whz) + bz)
        r = sigmoid(np.dot(x, Wxr) + np.dot(h_prev, Whr) + br)
        h_hat = np.tanh(np.dot(x, Wxh) + np.dot(r*h_prev, Whh) + bh)
        h_next = (1-z) * h_prev + z * h_hat

        self.cache = (x, h_prev, z, r, h_hat)

        return h_next
```
　BetterRnnlmクラスでそのまま扱えるように、パラメータを **self.params**, 勾配を **self.grad** という形で取り扱います。個別の値は、H幅で分割すれば良いわけです。

　さて、少しややこしい逆伝播です。まず、**self.params** を分解して各値を取得し、それ以外の状態を **cache** から復元する部分です。

```python
    def backward(self, dh_next):
        Wx, Wh, b = self.params  
        H = Wh.shape[0]
        Wxz, Wxr, Wxh = Wx[:, :H], Wx[:, H:2 * H], Wx[:, 2 * H:]
        Whz, Whr, Whh = Wh[:, :H], Wh[:, H:2 * H], Wh[:, 2 * H:]
        x, h_prev, z, r, h_hat = self.cache
```
　ここから逆伝播を４つの部分に分けて実装して行きます。まず、Tanhと２つのsigmoidと関係のない部分です。

![スクリーンショット 2020-06-03 08.49.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/f774976f-fe9d-7e27-14aa-d7feab4dce26.png)

```python
        dh_hat =dh_next * z
        dh_prev = dh_next * (1-z)
```
＋と×だけの組み合わせなので、シンプルです。続いて、tanh廻りです。

![スクリーンショット 2020-06-03 08.50.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/a3c40480-4c6a-e1e8-2180-4e441b52828b.png)

```python
        # tanh
        dt = dh_hat * (1 - h_hat ** 2)
        dbh = np.sum(dt, axis=0) 
        dWhh = np.dot((r * h_prev).T, dt)
        dhr = np.dot(dt, Whh.T)
        dWxh = np.dot(x.T, dt)
        dx = np.dot(dt, Wxh.T)
        dh_prev += r * dhr
```
　dh_prev は、先程計算しているので、ここからは dh_prev +=　でその結果に加算してゆきます。次に、updateゲートのz廻りです。

![スクリーンショット 2020-06-03 09.08.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/a2f44540-cb2c-9242-329f-2f9692a5baa6.png)

```python
        # update gate(z)
        dz = dh_next * h_hat - dh_next * h_prev
        dt = dz * z * (1-z)
        dbz = np.sum(dt, axis=0) 
        dWhz = np.dot(h_prev.T, dt)
        dh_prev += np.dot(dt, Whz.T)
        dWxz = np.dot(x.T, dt)
        dx += np.dot(dt, Wxz.T)
```
　dx は、既にtanhで計算しているので、ここからは　dx += で、その結果に加算して行きます。続いて、Resetゲートのr廻りです。

![スクリーンショット 2020-06-03 8.53.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/255d963a-e713-ed24-7373-2c28fa89374b.png)

```python
        # rest gate(r)
        dr = dhr * h_prev
        dt = dr * r * (1-r)
        dbr = np.sum(dt, axis=0) 
        dWhr = np.dot(h_prev.T, dt)
        dh_prev += np.dot(dt, Whr.T)
        dWxr = np.dot(x.T, dt)
        dx += np.dot(dt, Wxr.T)
```
　これで各勾配の計算は完了しましたので、gradsにまとめます。


```python
        self.dWx = np.hstack((dWxz, dWxr, dWxh))
        self.dWh = np.hstack((dWhz, dWhr, dWhh))
        self.db = np.hstack((dbz, dbr, dbh)) 
        
        self.grads[0][...] = self.dWx
        self.grads[1][...] = self.dWh
        self.grads[2][...] = self.db  

        return dx, dh_prev

```
 　ここまでで、GRUの実装は完了です。
#3.TimeGRUの実装

![スクリーンショット 2020-06-03 17.20.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/42e61706-930a-e04b-8c04-eeeb85af4bf3.png)

　**TimeGRU** の順伝播は、１時刻毎に、３次元データ **xs** を切り出して **GRU** へ入力し、**GRU** からの出力を再び３次元データ **hs** にまとめるものです。

```python
class TimeGRU:
    def __init__(self, Wx, Wh, b, stateful=False):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.layers = None
        self.h, self.dh = None, None
        self.stateful = stateful

    def forward(self, xs):
        Wx, Wh, b = self.params
        H = Wh.shape[0]        
        N, T, D = xs.shape
        self.layers = []
        hs = np.empty((N, T, H), dtype='f')

        if not self.stateful or self.h is None:
            self.h = np.zeros((N, H), dtype='f')

        for t in range(T):
            layer = GRU(*self.params)
            self.h = layer.forward(xs[:, t, :], self.h)
            hs[:, t, :] = self.h
            self.layers.append(layer)

        return hs
```
　出力を保管する箱 **hs** (N, T, H)を用意します。また、必要に応じてゼロ行列 **self.h** (N, H) を用意します。そして、データ **xs** から1時刻分を切り出して **GRU** へ入力し、GRUからの出力 **self.h** を **hs** に格納して行きます。この時同時に、layerを時刻T分アペンドします（これはbackwardで使います）。

![スクリーンショット 2020-06-03 17.00.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/657ae146-3209-c60c-0318-23041e955af2.png)

　さて、TimeGRUの逆伝播です。逆伝播時は、GRUレイヤに $dh_t+dh_{next}$ が入力されます。

```python
    def backward(self, dhs):
        Wx, Wh, b = self.params   
        N, T, H = dhs.shape
        D = Wx.shape[0]

        dxs = np.empty((N, T, D), dtype='f')

        dh = 0
        grads = [0, 0, 0]  
        for t in reversed(range(T)):
            layer = self.layers[t]
            dx, dh = layer.backward(dhs[:, t, :] + dh)
            dxs[:, t, :] = dx
            
            for i, grad in enumerate(layer.grads):  
                grads[i] += grad     
        
        for i, grad in enumerate(grads):    
            self.grads[i][...] = grad       
        
        self.dh = dh
        return dxs

    def set_state(self, h):
        self.h = h

    def reset_state(self):
        self.h = None
```
　逆伝播の出力を保管する箱 ***dxs*** (N, T, D)を用意します。また、勾配を一時保管するリスト **grads** を用意します。

　**dhsからの１時刻分の切り出し＋ひとつ未来からの勾配dh**を入力として、forwardでアペンドしたGRUレイヤを逆順で呼び出してbackwardを掛けます。そして、backwardの結果 **dx** を **dxs** へ格納して行きます。

　`dx, dh = layer.backward(dhs[:, t, :] + dh)`の式で、右辺のdhはいわゆる$dh_{next}$、左辺のdhはいわゆる$dh_{prev}$です。

　そして、各レイヤでの重みの勾配を加算して行き、最終結果を **self.grads** にまとめます。

　さて、ここまでで、GRU及びTimeGRUの実装が完了しましたので、`ch09`というフォルダーを作成し、`time_layers_gru.py`というファイル名で保存します。


#4.better_rnnlmの修正
　次に、ネットワークモデルを生成する、`better_rnnlm.py` を修正します。

```python
import sys
sys.path.append('..')
from common.time_layers import TimeEmbedding, TimeAffine, TimeSoftmaxWithLoss, TimeDropout  # 読み込むレイヤを指定
from time_layers_gru import *  # GRUだけは、ここから読み込む    
from common.np import *  # import numpy as np
from common.base_model import BaseModel

class BetterRnnlm(BaseModel):

    def __init__(self, vocab_size=10000, wordvec_size=650, 
                 hidden_size=650, dropout_ratio=0.5):
        V, D, H = vocab_size, wordvec_size, hidden_size
        rn = np.random.randn

        embed_W = (rn(V, D) / 100).astype('f')
        gru_Wx1 = (rn(D, 3 * H) / np.sqrt(D)).astype('f')
        gru_Wh1 = (rn(H, 3 * H) / np.sqrt(H)).astype('f')
        gru_b1 = np.zeros(3 * H).astype('f')
        gru_Wx2 = (rn(H, 3 * H) / np.sqrt(H)).astype('f')
        gru_Wh2 = (rn(H, 3 * H) / np.sqrt(H)).astype('f')
        gru_b2 = np.zeros(3 * H).astype('f')
        affine_b = np.zeros(V).astype('f')

        self.layers = [
            TimeEmbedding(embed_W),
            TimeDropout(dropout_ratio),
            TimeGRU(gru_Wx1, gru_Wh1, gru_b1, stateful=True),  
            TimeDropout(dropout_ratio),
            TimeGRU(gru_Wx2, gru_Wh2, gru_b2, stateful=True),  
            TimeDropout(dropout_ratio),
            TimeAffine(embed_W.T, affine_b)  
        ]
        self.loss_layer = TimeSoftmaxWithLoss()
        self.gru_layers = [self.layers[2], self.layers[4]]  
        self.drop_layers = [self.layers[1], self.layers[3], self.layers[5]]

        self.params, self.grads = [], []
        for layer in self.layers:
            self.params += layer.params
            self.grads += layer.grads

    def predict(self, xs, train_flg=False):
        for layer in self.drop_layers:
            layer.train_flg = train_flg

        for layer in self.layers:
            xs = layer.forward(xs)
        return xs

    def forward(self, xs, ts, train_flg=True):
        score = self.predict(xs, train_flg)
        loss = self.loss_layer.forward(score, ts)
        return loss

    def backward(self, dout=1):
        dout = self.loss_layer.backward(dout)
        for layer in reversed(self.layers):
            dout = layer.backward(dout)
        return dout

    def reset_state(self):
        for layer in self.gru_layers:  
            layer.reset_state()
```
　冒頭で、`common/time_layers.py`　からインポートするのは指定したレイヤだけにして、GRUは 先程カレントディレクトリに保存した `time_layers_gru.py` からインポートする様に変更しています。

　あとは、コードのLSTMの部分をGRUに変更します。重みが４つから３つに減りますので、例えば `gru_Wx1 = (rn(D, 3 * H) / np.sqrt(D)).astype('f')`の中の `(D, 3 * H)` 様に、重みの数に関係する部分の修正忘れがないようにします。

　このコードを`ch09`フォルダに、`better_rnnlm_gru.py` という名前で保存します。

#5.学習用コード
　６章の学習用コードを元に、冒頭の `from better_rnnlm import BetterRnnlm` を　`from better_rnnlm_gru import BetterRnnlm`　に変更し、`train_better_rnnlm.py` というファイル名で`ch09`フォルダに保存します。

　ハイパーパラメータ lr = 20 のままで実行したら、初期段階で perplixity のバラツキが大きかったので、 lr = 10 に変更してから再度実行しました。

```python
import sys
sys.path.append('..')
from common import config
# GPUで実行する場合は下記のコメントアウトを消去（要cupy）
# ==============================================
config.GPU = True
# ==============================================
from common.optimizer import SGD
from common.trainer import RnnlmTrainer
from common.util import eval_perplexity, to_gpu
from dataset import ptb
from better_rnnlm_gru import BetterRnnlm  # 変更 

# ハイパーパラメータの設定
batch_size = 20
wordvec_size = 650
hidden_size = 650
time_size = 35
lr = 10  
max_epoch = 40  
max_grad = 0.25
dropout = 0.5

# 学習データの読み込み
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_val, _, _ = ptb.load_data('val')
corpus_test, _, _ = ptb.load_data('test')

if config.GPU:
    corpus = to_gpu(corpus)
    corpus_val = to_gpu(corpus_val)
    corpus_test = to_gpu(corpus_test)

vocab_size = len(word_to_id)
xs = corpus[:-1]
ts = corpus[1:]

model = BetterRnnlm(vocab_size, wordvec_size, hidden_size, dropout)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)

best_ppl = float('inf')
for epoch in range(max_epoch):
    trainer.fit(xs, ts, max_epoch=1, batch_size=batch_size,
                time_size=time_size, max_grad=max_grad)

    model.reset_state()
    ppl = eval_perplexity(model, corpus_val)
    print('valid perplexity: ', ppl)

    if best_ppl > ppl:
        best_ppl = ppl
        model.save_params()
    else:
        lr /= 4.0
        optimizer.lr = lr

    model.reset_state()
    print('-' * 50)


# テストデータでの評価
model.reset_state()
ppl_test = eval_perplexity(model, corpus_test)
print('test perplexity: ', ppl_test)

```
![result.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/22f957ab-1b2e-6f99-0b34-db4003768a76.png)
　６章のLSTMモデルの test perplexity は70台後半でしたが、GRUモデルでは80台前半に留まるようです。今回のデータセットの様に、90万語を超える長いコーパスの場合は、メモリセルを持ったLSTMモデルの方が有利な様です。