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

RNN

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, D), W_x: (D, H)$
  • ミニバッチ: $h_t: (N, H), W_h: (H, H), x_t: (N, D), W_x: (D, 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
  • D( wordvec_size ):単語の分散表現の次元数(要素数)
    • ソースコードは 100
  • H( hidden_size ): RNNの隠れ状態の次元数(要素数)
    • ソースコードは 100

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

学習

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

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=20, time_size=35,
            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)

順伝播

0. 概要

  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, D)$
  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. 学習データ / 教師データを準備 ( main )

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. ミニバッチデータを準備( Trainer.fit / get_batch )

各要素が単語 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]]

以降、学習データの順伝播に絞ってデータの流れを説明します。
学習データは SimpleRnnLm.forward(xs, ts) を経由して各レイヤにデータが渡されます。

3. 単語 ID から分散表現ベクトルを取得( TimeEmbedding / Embedding レイヤ)

順伝播

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

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

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

[!NOTE]
ソースコードの D, 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}$ と読み替えてください。

各記号の意味

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

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 = 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 )を確率に変換します。
変換後のシェイプは変換前のシェイプ $(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)$ です。

正解確率が高いほど損失は小さくなります( $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. 全サンプルの平均損失

全サンプルについて計算した Cross Entropy Error の平均を算出ます。   この値が損失値 L(*カラー値)になりなります1

\[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 レイヤの逆伝播

逆伝播

Deep Learning の文脈の勾配は損失 L に関する勾配 $\frac{\partial L}{\partial a_t}$ 、 $\frac{\partial L}{\partial W_x}$ 、 $\frac{\partial L}{\partial W_h}$ 、 $\frac{\partial L}{\partial h_{t-1}}$ 、 $\frac{\partial L}{\partial b}$ です。

逆伝播は 2 ステップで考ます。

Step 1. $\frac{\partial L}{\partial a_t}$ を求める(要素積 2 のみ):

\[\frac{\partial L}{\partial a_t} = \frac{\partial L}{\partial h_{t}} \odot \frac{\partial h_t}{\partial a_t} = dh_t \odot (1 - h_t \odot h_t) \quad \text{shape: } (N, H)\]
記号 意味 shape
$\odot$ 要素積(element-wise)
$dh_t$ 上流からの勾配 $(N, H)$
$h_t \odot h_t$ $h_t$ の各要素を2乗 $(N, H)$
$1$ 全要素が1の行列 $(N, H)$

Step 2. $\frac{\partial L}{\partial a_t}$ を起点に各勾配を求める(ドット積):

勾配 shape確認
$\frac{\partial L}{\partial W_x}$ $x_{t}^\top \cdot \frac{\partial L}{\partial a_t}$ $(D,N)(N,H) = (D,H)$
$\frac{\partial L}{\partial W_h}$ $h_{t-1}^\top \cdot \frac{\partial L}{\partial a_t}$ $(H,N)(N,H) = (H,H)$
$\frac{\partial L}{\partial x_t}$ $\frac{\partial L}{\partial a_t} \cdot W_x^\top$ $(N,H)(H,D) = (N,D)$
$\frac{\partial L}{\partial h_{t-1}}$ $\frac{\partial L}{\partial a_t} \cdot W_h^\top$ $(N,H)(H,H) = (N,H)$
$\frac{\partial L}{\partial b}$ $\sum_{n=1}^{N} \frac{\partial L}{\partial a_t}$ $(N,H) \to (H,)$ 数学的には $(1,H)$ だがコードの実装上は $(H,)$。以下 $(H,)$ と記載する

※ 数学的には行列積の定義より $(D, N)(N, H) = (D, H)$ が必ず成り立ちますが、真ん中の $N$ が消える(縮約される)意味をミニバッチ全員分の損失を重みに集約しているからと解釈すると理解が深まります。

パターン:重み行列の勾配は「forward で掛けた相手を転置して左から掛ける」、
入力・隠れ状態の勾配は「forward で掛けた重み行列を転置して右から掛ける」。

$\frac{\partial L}{\partial a_t}$

\[\frac{\partial L}{\partial a_t} = \frac{\partial L}{\partial h_t} \odot \frac{\partial h_t}{\partial a_t} = dh_t \odot (1 - h_t \odot h_t) \quad \text{shape: } (N, H)\]

このとき $dh_t$ は以下の勾配の和です。

  • 出力層から来た勾配
  • 未来時刻から来た勾配
    • $dh_next$ は未来時刻から伝播してきた隠れ状態の勾配である。 この勾配には t+1 時刻の損失だけでなく、t+2, t+3, … の損失から再帰的に伝播してきた勾配も含まれている

TimeRNN.backward() ではdh = dhs[:, t, :] + dh_nextとして両者を加算しいます。

同じ $h_t$ は

  • 出力層( TimeAffine )
  • 次時刻のRNN

の2方向へ影響を与えているため、逆伝播では両経路から来た勾配を加算する。

これが時刻方向に順次伝わることで BPTT が実現されます。

$\frac{\partial L}{\partial W_x}$

\[\frac{\partial L}{\partial W_x} = x_{t}^\top \cdot \frac{\partial L}{\partial a_t}\]
  • $\frac{\partial a_t}{\partial W_x} = x$ : $a_t = h_{t-1} \cdot W_h + x_{t} \cdot W_x + b$ を $W_x$ で微分
  • forward で $x$ を左から掛けていたので、転置して $x_{t}^\top$ を左から掛ける

[!NOTE]
なぜ $x_{t}^\top$ を左から掛けるのか

forward では:

$a_t = x \cdot W_x \quad (N,D)(D,H) = (N,H)$

逆伝播の原則は「forward で左にいた行列を転置して左から掛ける」です。
forward で $x$ は $W_x$ の左にいたので $x_{t}^\top$ を左から掛けます。

$\frac{\partial L}{\partial W_x} = x_{t}^\top \cdot da \quad (D,N)(N,H) = (D,H)$

$\frac{\partial L}{\partial W_h}$

\[\frac{\partial L}{\partial W_h} = h_{t-1}^\top \cdot \frac{\partial L}{\partial a_t}\]
  • $\frac{\partial a_t}{\partial W_h} = h_{t-1}$ : $a_t = h_{t-1} \cdot W_h + x \cdot W_x + b$ を $W_h$ で微分
  • forward で $h_{t-1}$ を左から掛けていたので、転置して $h_{t-1}^\top$ を左から掛ける

$\frac{\partial L}{\partial x_t}$

\[\frac{\partial L}{\partial x} = \frac{\partial L}{\partial a_t} \cdot W_x^\top\]
  • $\frac{\partial a_t}{\partial x} = W_x$ : $a_t = h_{t-1} \cdot W_h + x \cdot W_x + b$ を $x$ で微分
  • forward で $W_x$ を右から掛けていたので、転置して $W_x^\top$ を右から掛ける

$\frac{\partial L}{\partial h_{t-1}}$

\[\frac{\partial L}{\partial h_{t-1}} = \frac{\partial L}{\partial a_t} \cdot W_h^\top\]
  • $\frac{\partial a_t}{\partial h_{t-1}} = W_h$ : $a_t = h_{t-1} \cdot W_h + x \cdot W_x + b$ を $h_{t-1}$ で微分
  • forward で $W_h$ を右から掛けていたので、転置して $W_h^\top$ を右から掛ける
  • この勾配が1つ前の時刻 $t-1$ の RNN へ伝播することで BPTT が実現されます。

$\frac{\partial L}{\partial b}$

\[\frac{\partial L}{\partial b} = \sum_{n=1}^{N} \frac{\partial L}{\partial a_t} \quad \text{shape: } (N,H) \to (H,)\]
  • $\frac{\partial a_t}{\partial b} = 1$ : $a_t = h_{t-1} \cdot W_h + x \cdot W_x + b$ を $b$ で微分
  • $b$ はミニバッチ $N$ 個全員に加算されるため、逆伝播では $N$ 方向に合算する
ブロードキャストと数学的根拠

順伝播で $b$ が各データに加算されるのは:

$a_n = h_{t-1}^{(n)} W_h + x^{(n)} W_x + b \quad n = 1, \ldots, N$

$b$ は $N$ 個の式すべてに同一の値として登場します。
これは数学的に「$b$ は $N$ 個の出力すべてに依存している」ことを意味します。

全データをまとめると:

$A = \begin{pmatrix} a_1 \ a_2 \ \vdots \ a_N \end{pmatrix} \quad \text{shape: } (N, H)$

各 $a_n$ は横ベクトル $(1, H)$ で、それが $N$ 行積み重なった行列が $A$ です。

バイアス $b$ の逆伝播

$L$ は全データの損失の合計なので、$b$ による偏微分は全データ分の寄与を足し合わせます:

\[\frac{\partial L}{\partial b} = \sum_{n=1}^{N} \frac{\partial L}{\partial a_n} \cdot \frac{\partial a_n}{\partial b} = \sum_{n=1}^{N} \frac{\partial L}{\partial a_n} \cdot 1 = \sum_{n=1}^{N} \frac{\partial L}{\partial a_t}\]

これは多変数の連鎖律そのもの。

ブロードキャストとの関係
  内容
数学 $b$ が $N$ 個の式に共有されるので勾配を総和する
ブロードキャスト $(1,H)$ の $b$ を $(N,H)$ に自動拡張して計算する実装上の仕組み

ブロードキャストは「数学的に同じ値を $N$ 個使う」という操作を効率的に実装したもの。
NumPy がなくても数学的な根拠は成立しています。

BPTT(Backpropagation Through Time)

概要

BPTT は RNN の逆伝播アルゴリズムです。通常の逆伝播を時刻方向に展開したもので、 「時刻を遡りながら勾配を伝播する」という点が特徴です。

時刻方向の勾配伝播

順伝播では隠れ状態が $t=0$ → $t=1$ → … → $t=4$ と前向きに伝播します。 逆伝播ではその逆方向、$t=4$ → $t=3$ → … → $t=0$ と後ろ向きに勾配が伝播します。

各時刻の勾配

各時刻の RNN セルに届く勾配 $dh_t$ は 2つの経路の和です。

勾配 経路 意味
dhs[:,t,:] 出力層(TimeAffine)から 時刻 $t$ の予測誤差
dh_next 未来時刻 $t+1$ から $t+1$ 以降の損失から再帰的に伝播してきた勾配

dh_next には $t+1$ の損失だけでなく $t+2, t+3, \ldots$ の損失から再帰的に伝播してきた 勾配も含まれています。これが「Through Time」の意味です。

TimeRNN.backward() の流れ

dh_next = 0  # t=4 の次時刻からの勾配は 0 で初期化

for t in reversed(range(T)):          # t = 4, 3, 2, 1, 0 の順
    dh = dhs[:, t, :] + dh_next       # 2経路の勾配を加算
    dh_next = rnn_layer.backward(dh)  # t-1 へ渡す勾配を計算

dh_next を次のループへ引き渡すことで、勾配が時刻をさかのぼって伝播します。

Truncated BPTT

本実装では time_size = 5 のため、1回の逆伝播で遡れるのは 5 ステップ分のみです。 これを Truncated BPTT(打ち切り BPTT)と呼びます。

  順伝播 逆伝播
stateful=True 前バッチの文脈を引き継ぐ 勾配は time_size ステップのみ遡る
stateful=False 毎回ゼロ初期化 同上

なぜ打ち切るのか

全時刻分の勾配を遡ると計算量が膨大になります。 time_size で打ち切ることで計算量を一定に保ちながら、 stateful=True による隠れ状態の引き継ぎで長期の文脈は順伝播側で保持します。

勾配消失・爆発

BPTT では勾配が時刻を遡るたびに tanh の微分($1 - h_t^2$)が掛け算されます。

t=4  →  × (1 - h4²)
t=3  →  × (1 - h3²)
t=2  →  × (1 - h2²)
...

$\tanh$ の微分は最大でも 1 のため、ステップ数が増えるほど勾配が小さくなり 勾配消失が起きやすくなります。これが素の RNN の限界であり、 LSTM・GRU が導入された背景です。

TimeSoftmaxWithLoss の逆伝播

TimeSoftmaxWithLoss.backward の出力(各時刻・各単語に対する softmax+loss の勾配)は、そのレイヤー自身の重みを更新するためのものではなく、連鎖律( chain rule )に従って一つ前の層(下流、例えば Affine レイヤーや LSTM/RNN レイヤー)に逆伝播させるための勾配です。

このレイヤー自体には学習可能な重み(パラメータ)が存在しないため、paramsやgradsも持たないのが一般的( softmax と loss の計算だけで構成される)。 逆伝播では $\text{Softmax} + \text{CrossEntropy}$ をまとめて微分できます。

\[\frac{\partial L}{\partial s} = p - y\]
  • $p$ :softmax の出力確率
  • $y$ :one-hot 表現の正解ラベル

例えば予測確率が [0.23, 0.05, 0.72] で正解( one-hot )が [0, 0, 1] なら [0.23, 0.05, -0.28] が勾配になります。

実装との対応

ゼロから作る Deep Learning 2 の実装は以下のとおりです。

dx = mask.sum()
# ys.shape = (N*T, V)
# ys は次にくる単語のスコア( logits )
ts, ys, mask, (N, T, V) = self.cache

# ts.shape = (N*T, ) 教師データの単語 ID
# ys.shape = (N*T, V)
dx = ys

# W := W − η * (∂W / ∂L)
# 正解    <= 0 なので重みは増加
# 不正解  >  0 なので重みは減少
dx[np.arange(N * T), ts] -= 1
dx *= dout
dx /= mask.sum() # /= は「除算代入演算子」で、dx = dx / mask.sum() と同じ意味
dx *= mask[:, np.newaxis]  # ignore_labelに該当するデータは勾配を0にする

dx = dx.reshape((N, T, V))

return dx

![NOTE]
実際のコードは汎用性を持たせるために ignore-label の処理をするが上記コードでは 簡単にするために無視する( loss = mask.sum() にしている) #ls = np.log(ys[np.arange(N * T), ts]) ls *= mask # ignore_labelに該当するデータは損失を0にする loss = -np.sum(ls) loss /= mask.sum()

以下で $p - y$ を算出します。

dx[np.arange(N*T), ts] -= 1
  • 正解:p_i -> p_i - 1
  • 正解以外: p_i

その後、$dx /= mask.sum()$ によって平均損失に対応する勾配へ変換します。

[!NOTE]
dx = np.array([ [0.23, 0.05, 0.72], [0.75, 0.20, 0.05], [0.40, 0.50, 0.10]]);

dx[[1,2]] 行に関して抽出 [[0.75, 0.2 , 0.05], [0.4 , 0.5 , 0.1 ]]

対象の要要を一括で抽出 dx[[1,2],[0,1]] [0.75, 050]

numpy.arange 関数 numpy.arange(start, stop, step) は等間隔の数値配列を生成する関数です。

np.arange(5) # [0, 1, 2, 3, 4] np.arange(1, 5) # [1, 2, 3, 4] np.arange(0, 10, 2) # [0, 2, 4, 6, 8] np.arange(0, 1, 0.25) # [0, 0.25, 0.5, 0.75] stopは含まれない(半開区間)。浮動小数点を使うと誤差が出ることがあるため、その場合はnumpy.linspaceの使用が推奨されます。

例えば予測確率 $p$ が [0.23, 0.05, 0.72] で正解が 2 なら $y$ は [0, 0, 1] なので $p - y$ は [0.23, 0.05, -0.28] になり正解クラスだけ負になります。

SGD の更新式は $W := W - \eta \frac{\partial L}{\partial W}$ です。

正解クラス

勾配が負 -0.28 なので W - η(-0.28) = W + η0.28 となり、そのクラスのスコア( logit )が上がる方向に更新されます。

結果として次回の予測では正解クラスの確率が高くなります。

不正解クラス

例えばクラス0の勾配は +0.23 です。
更新すると W - η(0.23) となるため、そのクラスのスコアは下がる方向へ更新されます。

結果として不正解クラスの確率は低くなります。

  • 正解クラス → 勾配が負 → スコアを上げる方向に更新
  • 不正解クラス → 勾配が正 → スコアを下げる方向に更新

そのため p - y は「正解クラスの確率を上げ、不正解クラスの確率を下げるための誤差信号」として解釈できます。

shape の流れ

処理 shape
TimeAffine出力 $(N, T , V)$
reshape後 $(N * T, V)$
Softmax出力 $(N * T, V)$
backwardの勾配 $(N * T, V)$
reshapeして戻す $(N, T, V)$

上記より TimeSoftmaxWithLoss が返す勾配はそのまま TimeAffine.backward() に渡されます。

クラスの役割

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

補足

stateful=True による文脈の引き継ぎ

この実装では TimeRNN を以下のように生成しています。

TimeRNN(rnn_Wx, rnn_Wh, rnn_b, stateful=True)

stateful=True の場合、順伝播は前回のミニバッチの最後の隠れ状態を次回のミニバッチへ引き継ぎます。
コードでは TimeRNN.forward() の冒頭で以下の処理が行われます。

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

stateful=True の場合は self.h が保持されるため、次回の forward() 呼び出し時にも前回の隠れ状態が利用されます。

ミニバッチ内での状態遷移

1つのミニバッチ( T = 5 )の内部では以下のように隠れ状態が伝播します。

通常の RNN と同じく、各時刻の隠れ状態が次時刻へ渡されます。

ミニバッチ間での状態遷移

さらに stateful=True では、最後の隠れ状態 h_4 が次のミニバッチの初期状態として利用されます。

Batch2 の最初の計算は以下のとおりです。

\[h_{0}^{(\mathrm{Batch2})} = tanh(h_4^{(\mathrm{Batch1})} \cdot W_h + x_5 \cdot W_x + b)\]

概念的には以下のようになります。

\(h_{init}^{(\mathrm{Batch2})} = h_4^{(\mathrm{Batch1})}\)

なぜ必要なのか

本サンプルでは time_size = 5 なので、1 回の順伝播では 5 単語分しか RNN を展開しません。
しかし stateful=True によって隠れ状態が保持されるため、実際には前のミニバッチで学習した文脈を次のミニバッチへ引き継げます。

例えば

Batch1
the stock market crashed

Batch2
because investors feared ...

のような場合でも、Batch2 は Batch1 の文脈を含んだ隠れ状態から開始できます。

そのため、RNN は time_size より長い文脈を利用した言語モデルとして振る舞います。
※ 順伝播では長い文脈を保持できるが、勾配は time_size ステップ分しか遡りません。

stateful=False の場合

stateful=False の場合は毎回隠れ状態をゼロ初期化します。 $h_0 = 0$ のため各ミニバッチは独立して扱われて前回の文脈は引き継がれません。

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

  2. アダマール積とも呼ぶ。