ゼロから作るDeep Learning ❷ ―自然言語処理編 斎藤康毅 (著) の読書メモです。

RNN

2 図の出典 単語と図で理解する自然言語処理(word2vec, RNN, LSTM)後編

各記号の意味
記号 意味 形状( ミニバッチ数 N )
$x_t$ 時刻 $t$ の入力 $(N, E)$
$h_{t-1}$ 一つ前の隠れ状態 $(N, H)$
$W_x$ 入力に対する重み行列 $(E, H)$
$W_h$ 隠れ状態に対する重み行列 $(H, H)$
$b$ バイアス $(0, H)$
$h_t$ 現在の隠れ状態(出力) $(N, H)$
  • $E$:入力の分散表現の次元数
  • $H$:隠れ状態の分散表現の次元数

RNN レイヤの t 時刻( time step )の順伝播は入力として t 時刻の単語 $x_t$ と t-1 時刻の 隠れ状態 $h_{t-1}$ を受け取って t 時刻の隠れ状態 $h_t$ を出力します 。

t-1 時刻の隠れ状態 $h_{t-1}$ は、時刻 0 〜 t-1 までに入力された単語列の情報(文脈)を圧縮して保持したベクトルです(隠れ状態は英語では hidden state です)。

活性化関数に tanh を用いた RNN セル( 入力 $x_t$ と $h_{t-1}$ から $h_t$ を出力する単位)の順伝播は以下のとおりです。

\[\begin{aligned} h_t = tanh(h_{t-1} \cdot W_h + x_t \cdot W_x + b) \end{aligned}\]

1 データを自然にバッチサイズ $N$ のミニバッチに拡張できます。

  • 1データ:$\vec{h_t}: (1, H), W_h: (H, H), \vec{x_t}: (1, E), W_x: (E, H)$
  • ミニバッチ: $h_t: (N, H), W_h: (H, H), x_t: (N, E), W_x: (E, H)$ 。

逆伝播は Affine 変換用に変数 $a_t$ を定義して 2 段階で考えると分かりやすくなります。

\[\begin{aligned} a_t = h_{t-1} \cdot W_h + x_t \cdot W_x + b \\ h_t = tanh(a_t) \end{aligned}\]

記法

本ドキュメントは以下の記法で統一します。

記号 意味
$\cdot$ 行列積(ドット積)
$\odot$ 要素積(アダマール積、element-wise product)

前提

  • corpus size: 1000
  • V( vocab_size ):コーパスから重複を排除した単語 ID の数(語彙数)
    • ソースコードは 418
  • N:ミニバッチサイズ
    • ソースコードは 10
  • T:系列長(sequence length)
    • ソースコードは 5
  • E( wordvec_size ):単語の分散表現の次元数(要素数)
    • ソースコードは 100
  • H( hidden_size ): RNNの隠れ状態の次元数(要素数)
    • ソースコードは 100

※ ソースコードの E, H はともに 100 ですが異なる値を設定できます。

順伝播

概要

  1. コーパスの単語 ID を corpus に格納 - corpus のシェイプ: $(1000,)$
  2. 学習データ corpus[:-1] と 教師データ(正解データ) ` corpus[1:]` を準備
    • 学習 / 教師データのシェイプ: $(999,)$
  3. 学習データからバッチサイズが N, 系列長が T で各要素が単語 ID のミニバッチを作成
    • ミニバッチのシェイプ $(N, T)$
  4. (Time)Embedding レイヤ で単語 ID のミニバッチ $(N, T)$ から単語の分散表現のミニバッチ( 以下 $x$ )を出力
    • 単語の分散表現ミニバッチのシェイプ: $(N, T, E)$
  5. (Time)RNN レイヤで t 時刻の単語データ $x_t$ と t-1 時刻の隠れ状態 $h_{t-1}$ から t 時刻の隠れ状態 $h_t$ を出力
    • 隠れ状態のミニバッチのシェイプ:$(N, T, H)$
  6. (Time)Affine レイヤで t 時刻の隠れ状態 $h_t$ から次にくる単語のスコアを出力
    • $(N, T, V)$
  7. Softmax 関数でスコアを確率に正規化
  8. 教師データと該当する確率から損失(交差エントロピー誤差 Cross Entropy Error )を算出
  9. 損失の平均を算出( $L$ )

以下に詳細を記載します。分かりやすさ(実装コードの区切り)を優先するので章番号は上の数字と一致しません。

1. 学習データ / 教師データを準備

PTB コーパスを変数 corpus に単語 ID の 1 次元配列として読み込みます。

corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus = corpus[:1000] # (1000,)
xs = corpus[:-1]       # 学習データ( 0 - 998 の 1 次元配列)
ts = corpus[1:]        # 教師データ( 1 - 999 の 1 次元配列)

2. ミニバッチデータを準備

各要素が単語 ID の 2 次元配列 $(N = 10, T = 5)$ を取得します。

# batch_x
# [[  0   1   2   3   4]
#  [ 42  76  77  64  78]
#  [ 26  26  98  56  40]
#  [ 24  32  26 175  98]
#  [208 209  80 197  32]
#  [ 26  79  26  80  32]
#  [274 275 276  42  61]
#  [ 88 303  26 304  26]
#  [ 42  35  72 350  64]
#  [339 359 181 328 386]]
#
# batch_t
# [[  1   2   3   4   5]
#  [ 76  77  64  78  79]
#  [ 26  98  56  40 128]
#  [ 32  26 175  98  61]
#  [209  80 197  32  82]
#  [ 79  26  80  32 241]
#  [275 276  42  61  24]
#  [303  26 304  26  32]
#  [ 35  72 350  64  27]
#  [359 181 328 386 387]]

以降、学習データの順伝播に絞ってデータの流れを説明します。

3. 単語 ID を分散表現ベクトルに変換(TimeEmbedding レイヤ / Embedding レイヤ)

  • 入力:各要素が t 時刻の単語 ID のミニバッチで2次元配列 $(N = 10, T = 5)$ 
    • ミニバッチ $(N, T)$ を受け取った TimeEmbedding レイヤ は t 時刻(t 列)の単語 ID( 1 次元配列: $(10,)$ )を Embedding レイヤに渡してt 時刻の分散表現 $(N, E)$を得る
  • 出力:各要素が t 時刻の単語の分散表現のミニバッチで 3 次元配列 $(N = 10, T = 5, E = 100)$
    • $t = 1,…,T$ の各 t 時刻の出力をまとめたも( for i range(T) out[:, t, :] = layer.forward(xs[:, t])

4. 文脈情報を保持・更新(TimeRNN レイヤ / RNN レイヤ)

  • 入力:各要素が t 時刻の分散表現ベクトルのミニバッチで 3 次元配列 $(N = 10, T = 5, E = 100)$
    • TimeRNN は RNN に t 時刻ごとの入力 $x_t$ ( 2 次元配列: $(N = 10, E = 100)$ )と前回の RNN の出力である隠れ状態 $h_{t-1}$ ( 2 次元配列: $(N = 10, H = 100)$ )を渡して $h_t = tanh(h_{t-1} W_h + x_t W_x + b)$ を得る
      • RNN レイヤの出力 $h_t$ は $t+1$ の入力として RNN レイヤに渡されるとともに上流に渡される(つまり $h_t$ は 2 つの経路を持つ)
      • 初回ステップの $h_{t-1}$ はゼロベクトル $(N = 10, H = 100)$。ただし stateful=True の場合は前回ミニバッチの最終隠れ状態が利用される
  • 出力:文脈情報を保持・更新した隠れ状態のミニバッチで 3 次元配列 $(N = 10, T = 5, H = 100)$

NOTE
ソースコードの E, H はともに 100 ですが役割は全く異なるので混同しないように注意します。

\[h_t = tanh( h_{t-1} \cdot W_h + x_t \cdot W_x + b )\]

$\tanh$ に入力される直前の状態(アフィン変換の出力)を $a_t$ として分けると逆伝播を理解しやすくなります。

\[a_t = h_{t-1} \cdot W_h + x_t \cdot W_x + b \\ h_t = \tanh(a_t)\]

NOTE
書籍『ゼロから作るDeep Learning 2』のコードでは中間変数を t / dt と表記しています。 t は時刻を連想させるため若干混乱を招きやすいと考えて本ドキュメントは数学的に一般的な $a_t$(activation の略)に統一します。 コードを読む際は t = $a_t$、dt = $\frac{\partial L}{\partial a_t}$ と読み替えてください。

5. 隠れ状態から語彙サイズの出現スコアを取得( TimeAffine レイヤ)

  • 入力:文脈を学習した隠れ状態のミニバッチ:3 次元配列 $(N = 10, T = 5, H = 100)$
  • 出力:学習データのミニバッチに対して次に出現する単語 ID の出現スコアを要素とする 3 次元配列 $(N = 10, T = 5, V = 418)$
    • 内部では入力を $(N*T, H)$ へ reshape して行列積を計算し、出力を $(N, T, V)$ に戻す
    • 50 は(ミニバッチのサイズ $N$ × 系列長 $T$ )で語彙サイズは 418 。各要素は該当する単語 ID の出現スコア

TimeAffine レイヤ の出力は $(N, T, V)$ の 3 次元配列です。

$T = 5$ の場合、各時刻の出力は「次に出現する単語」の予測スコアを表しています。

時刻 TimeAffine レイヤの出力(予測) 教師データ ts(正解)
t=0 単語0( xs[:,0])の文脈 $\rightarrow$ 次単語スコア 単語1
t=1 単語0,1( xs[:,0], xs[:,1] )の文脈 $\rightarrow$ 次単語スコア 単語2
t=2 単語0,1,2( xs[:,0], xs[:,1], xs[:,2] )の文脈 $\rightarrow$ 次単語スコア 単語3
t=3 単語0〜3( xs[:,0],......, xs[:,3] )の文脈 $\rightarrow$ 次単語スコア 単語4
t=4 単語0〜4( xs[:,0],......, xs[:,4] )の文脈 $\rightarrow$ 次単語スコア 単語5

xs[:,0] は 0 列目のすべての行、, xs[:,1] は 1 列目のすべての行です。
xs = corpus[:-1]ts = corpus[1:] を使用しているため、入力単語列と正解単語列が 1 単語ずれて対応しています(自己教師あり学習 Self-Supervised Learning )。

※ 418 はソースコードで読み込まれているコーパスの語彙サイズが 418 のためです。

6 確率化 + 損失計算( TimeSoftmaxWithLoss )

  • 入力:学習データのミニバッチに対して次に出現する単語 ID の出現スコアを要素とする 3 次元配列 $(N = 10, T = 5, V = 418)$
  • 出力: ミニバッチ全体の平均損失(交差エントロピー誤差の平均)を表す損失値(スカラー)

Step 1. reshape $(N, T, V) \rightarrow (N*T, V)$

TimeAffine レイヤの出力である出現スコア $(N, T ,V)$ と教師データ $(N, T)$ を変形します。

xs = xs.reshape(N * T, V) # (50, 418)
ts = ts.reshape(N * T)    # (50, )

例えば出力 $xs$ が $(N = 10, T = 5, V = 418)$ の場合は reshape 後の $xs$ は $(50, 418)$ になります。
教師データ $ts$ が $(10, 5)$ の場合は reshape 後の $ts$ は $(50, )$ になります。

これにより「50 個の単語予測問題」として一括処理できます。

Step 2. Softmax で確率に変換 $(N*T, V)$

TimeAffine レイヤの出力である出現スコア( logits )を確率に変換します。
つまり t 時刻(まで)の次に出現する単語の確率分布になります。

変換後のシェイプは変換前のシェイプ $(N*T, V)$ と同じです。

例えばある時刻の出力が [2.1, 0.5, 3.2] とします。 Softmax を適用すると [0.23, 0.05, 0.72] のように全要素が $p_i \in [0, 1]$ 、合計が 1 の確率分布になります。

\[p_i = \frac{e^{s_i}}{Σ_{j=0}^{V-1} e^{s_j}} \quad \text{V は語彙サイズ}\]
  • $s_i$ :TimeAffine レイヤが出力した出現スコア( $\sum_{i=0}^{V-1}$ )
  • $p_i$ :単語 $i$ が次に出現する確率( $\sum_{i=0}^{V-1}$ )

Step 3. 交差エントロピー誤差(Cross Entropy Error)を計算

教師データが単語 ID 2 だったとします。 予測確率が [0.23, 0.05, 0.72] の場合、損失は $L = -\log(0.72)$ です。 逆に予測確率が [0.90, 0.05, 0.05] の場合、損失は $L = -\log(0.05)$ です。 

$-\log p_i$ は交差エントロピー誤差です。
交差エントロピーの定義は、対数の底を 2 とするのが一般的です。ただし Python ライブラリの多くは効率的に計算するために自然対数の底を使います。

正解確率が高いほど損失は小さくなります( $p_i \in [0, 1]$ および $-\log$ 負の対数関数の性質より)。

NOTE
y = log(x)
x の定義域 0 < x < 無限
0 < x < 1 => y はマイナス
x が 0 に近づくほど y は マイナス無限大に近づく
x=1.00 → log(1.00) = 0.00 # 確率1(完璧な予測)→ 損失0
x=0.90 → log(0.90) = -0.10 # 確率高い → 損失小
x=0.50 → log(0.50) = -0.69
x=0.10 → log(0.10) = -2.30 # 確率低い → 損失大
x=0.01 → log(0.01) = -4.60
x→0 → log(x) → -∞ # 確率0(最悪の予測)→ 損失無限大

Step 4. 全サンプルの平均損失を計算

全サンプルについて計算し交差エントロピー誤差の平均を算出ます。   この値が損失 L(*カラー値)になりなります。

\[L = -\frac{1}{NT}\sum_{i=1}^{NT}\log (p_i^{(\mathrm{correct})})\]

コード例では損失は 50 の損失値 $-\log p_i^{(\mathrm{correct})}$ の平均になります。

部分 意味
$p_i^{(\mathrm{correct})}$ サンプル $i$ における正解クラスの予測確率
$-\log(\cdot)$ 確率を損失に変換(確率が高いほど損失小)
$\sum_{i=1}^{NT}$ ミニバッチ全 $NT$ サンプルの合計
$\frac{1}{NT}$ サンプル数で割って平均損失にする

コード

順伝播は学習データのミニバッチと教師データのミニバッチを渡して平均損失(交差エントロピー誤差の平均:スカラー)を取得します。

loss = model.forward(batch_x, batch_t)

補足

クラスの役割

クラス 役割
RNN 0ステップ分のRNN計算
TimeRNN T ステップ分をまとめて処理
Embedding / TimeEmbedding 単語ID→ベクトル変換
TimeAffine 全結合層(時系列版)
TimeSoftmaxWithLoss 損失計算
SimpleRnnlm モデル全体の統括
RnnlmTrainer 学習ループ管理
SGD パラメータ更新

学習コード

1 回の学習 の Python コードです。

class RnnlmTrainer:
    def __init__(self, model, optimizer):
        # ......

    def get_batch(self, x, t, batch_size, time_size):
        # ......
        return batch_x, batch_t

    def fit(self, xs, ts, max_epoch=10, batch_size=10, time_size=5,
            max_grad=None, eval_interval=20):
        # 引数の xs, ts は main 関数で準備される
        # xs は学習データ 各要素が単語 ID の 1 次元配列( 要素数 999:添字 0 - 998) 
        # ts は教師データ 各要素が単語 ID の 1 次元配列( 要素数 999:添字 1 - 999)

        # max_epoch 10
        data_size = len(xs) # 999
        # data_size 999, batch_size 10, time_size 5
        # max_iters は 19 。 演算子 // は切り捨て除算
        max_iters = data_size // (batch_size * time_size)
        # ......
        
        for epoch in range(max_epoch):
            for iters in range(max_iters):
                # 1 ループ(学習)のバッチは要素が単語 ID の 2 次元配列( N = 10, T = 5 )
                batch_x, batch_t = self.get_batch(xs, ts, batch_size, time_size)
                # 勾配を求めてパラメータを更新
                loss = model.forward(batch_x, batch_t)
                model.backward()
                optimizer.update(params, grads)