からっぽのしょこ

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

5.4.2.3:Time Softmax with Lossレイヤの実装【ゼロつく2のノート(実装)】

はじめに

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

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

 この記事は、5.4.2.3項「Time Softmax with Lossレイヤの実装」の内容です。Time Softmax with Lossレイヤを解説して、Pythonで実装します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

5.4.2.3 Time Softmax with Lossレイヤの実装

 $T$個の時系列データを処理するSofmaxレイヤとCross Entropy Errorレイヤ(Lossレイヤ)あわせたTime Sofmax with Lossレイヤを実装します。Sofmaxレイヤについては「1巻の3.5節」、交差エントロピー誤差については「1巻の4.2節」、Sofmax with Lossレイヤについては「1巻の5.6.3項」も参照してください。

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


 Time Sofmax with Lossレイヤの実装には、「1.3.1項」で実装したsoftmax()を利用します。関数定義を再実行するか、次の方法でマスターデータから読み込む必要があります。

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

# 実装済みのクラス
from common.functions import softmax


・数式による順伝播の計算の確認

 まずは、Softmax関数の計算について確認します。

 Time Softmaxレイヤの順伝播における入力データを$\mathbf{ss} = (\mathbf{s}_0, \mathbf{s}_1, \cdots, \mathbf{s}_{T-1})$とします。これは、Time Affineレイヤの順伝播の出力データです。$\mathbf{ss}$は3次元配列で、各次元の要素数はそれぞれバッチサイズ$N$、時間サイズ$T$、語彙数$V$です。$n,\ t$に関するインデックスは入力した単語に対応しており、3次元方向に並ぶ要素$v$は予測する次の単語に対応しています。対応しているとは、インデックスと単語IDが同じという意味です。

 最初($t = 0$)に入力したミニバッチの内、0番目のデータを$\mathbf{s}_{00} = (s_{0,0,0}, s_{0,0,1}, \cdots, s_{0,0,V-1})$とすると、Softmax関数の計算は次の式で表せます(1.3.1項の式(1.6)より)。

$$ \mathbf{y}_{00} = \frac{ \exp(s_{00v}) }{ \sum_{v'=0}^{V-1} \exp(s_{00v'}) } $$

 この関数の計算は、入力データの3次元方向の要素について、次の条件を満たすように変換しています。

$$ 0 \leq y_{ntv} \leq 1,\ \sum_{v=0}^{V-1} y_{ntv} = 1 $$

 つまり各要素は0から1までの値となり、3次元方向の要素の和が1となるように正規化しています。これが確率の条件を満たすことから、$\mathbf{ys}$の3次元方向に並ぶ要素は、入力したデータの次の単語としての各語彙の出現確率と解釈できる。つまり確率が最大の(インデックスに対応する)語彙を予測結果とする。ただしSofmax関数による変換は大小関係が変わらないため、確率が最大の要素は変換前の$\mathbf{ss}$の時点で最大の要素である。

 次に、損失関数の計算について確認します。損失として交差エントロピー誤差を用います。

 教師ラベルを$\mathbf{ts} = (\mathbf{t}_0, \mathbf{t}_1, \cdots, \mathbf{t}_{T-1})$とします。時刻$t$に関して取り出すと、$\mathbf{t}_t$は$N \times V$の行列となり、各行は正解ラベルを1とするone-hotベクトルである。

 これを用いて、時刻$t$のデータに対する交差エントロピー誤差は、次の式で計算できます(1.3.1項の式(1.8)より)。

$$ L_t = - \frac{1}{N} \sum_{n=0}^{N-1} \sum_{v=0}^{V-1} t_{ntv} \log y_{ntv} $$

 $- \log y_{ntv}$を情報量と呼び、$\sum_{v=0}^{V-1}$は正解の語彙に関する項を抽出するためでした。また$\sum_{n=0}^{N-1}$はミニバッチ全体の情報量を合計し、バッチサイズ$N$で割ることで平均損失を求めています。1データあたりの損失を求めることで、値がデータ数に依存しないようにするためでした。
 同様に求めた時刻$0$から$T-1$までの交差エントロピー誤差を合計することで、全ての時刻のミニバッチの損失とすることにします。ただし合計するだけだと、損失が$T$の大きさに影響してしまうため、$T$で割った値を損失$L$とします。これを式で表すと次のようになります。

$$ L = \frac{1}{T} (L_0 + L_1 + \cdots, L_{T-1}) \tag{5.11} $$

 まとめると、次の式で損失$L$を計算します。

$$ L = - \frac{1}{N T} \sum_{n=0}^{N-1} \sum_{t=0}^{T-1} \sum_{v=0}^{V-1} t_{ntv} \log y_{ntv} $$


・数式による逆伝播の計算の確認

 入力データ$\mathbf{ss}$に関する勾配$\frac{\partial L}{\partial \mathbf{ss}}$の各項は、次の式で計算できます(1巻の付録A.2節より)。

$$ \frac{\partial L}{\partial s_{ntv}} = \frac{1}{T} \frac{1}{N} (y_{ntv} - t_{ntv}) $$

 ただし$\mathbf{ts}$は、$v$に関してone-hotベクトルでした。なので、実装上は$y_{ntv}$が正解の語彙であれば1を引いて$NT$で割ることで、正解でなければそのままの値を$NT$で割ることで求められます。

・順伝播の処理の確認

 Time Sofmax with Lossレイヤの順伝播の処理を確認していきます。

 まずは、ハイパーパラメータ(変数の形状に関する値)を指定して、Time Softmaxレイヤの順伝播における入力データ$\mathbf{ss}$を簡易的に作成します。これは本来ここまでに計算されているものなので、ここではnp.random.choice()を使ってランダムに値を生成することにします。

# バッチサイズを指定
batch_size = 3

# 時間サイズ(繰り返し回数)を指定
time_size = 4

# 語彙数(単語の種類数)を指定
vocab_size = 5

# (ランダムに)Softmaxレイヤの入力データを作成
ss = np.random.choice(
    np.arange(0, 10, 0.1), size=batch_size*time_size*vocab_size, replace=True
).reshape((batch_size, time_size, vocab_size))
print(ss)
print(ss.shape)
[[[7.  1.8 1.  1.8 1.7]
  [5.  8.3 2.7 9.2 7.8]
  [9.  8.2 1.9 3.  7.5]
  [1.7 8.7 1.4 7.2 0.4]]

 [[7.6 9.1 7.  4.3 4.1]
  [2.4 7.9 5.7 4.6 7.5]
  [9.8 2.  2.4 8.3 1.7]
  [3.9 1.9 4.  0.  9.5]]

 [[4.  9.6 5.3 5.1 9.9]
  [8.  4.5 2.4 3.9 1.1]
  [1.3 5.1 6.3 0.6 4.6]
  [7.7 5.4 0.1 7.7 2.8]]]
(3, 4, 5)


  前項のように、効率よく処理するために$N T \times V$の2次元配列に変換します。変換後の入力データを$\mathbf{rx}$と表記することにします。

# 入力データを2次元配列に変換
rs = ss.reshape((batch_size * time_size, vocab_size))
print(rs)
print(rs.shape)
[[7.  1.8 1.  1.8 1.7]
 [5.  8.3 2.7 9.2 7.8]
 [9.  8.2 1.9 3.  7.5]
 [1.7 8.7 1.4 7.2 0.4]
 [7.6 9.1 7.  4.3 4.1]
 [2.4 7.9 5.7 4.6 7.5]
 [9.8 2.  2.4 8.3 1.7]
 [3.9 1.9 4.  0.  9.5]
 [4.  9.6 5.3 5.1 9.9]
 [8.  4.5 2.4 3.9 1.1]
 [1.3 5.1 6.3 0.6 4.6]
 [7.7 5.4 0.1 7.7 2.8]]
(12, 5)


 次に、教師ラベル$\mathbf{ts}$を簡易的に作成します。多項分布に従う乱数生成メソッドnp.random.multinomial()を使って、one-hot表現のオブジェクトを作成します。試行回数引数をn=1とすることでone-hotベクトルを出力します。

# (ランダムに)one-hot表現の教師ラベルを作成
ts = np.random.multinomial(
    n=1, pvals=np.repeat(1/vocab_size, vocab_size), size=batch_size*time_size
).reshape((batch_size, time_size, vocab_size))
print(ts)
print(ts.shape)
[[[0 1 0 0 0]
  [0 0 0 0 1]
  [1 0 0 0 0]
  [0 0 1 0 0]]

 [[0 1 0 0 0]
  [0 0 0 0 1]
  [0 1 0 0 0]
  [1 0 0 0 0]]

 [[0 0 0 0 1]
  [0 0 0 0 1]
  [0 0 1 0 0]
  [1 0 0 0 0]]]
(3, 4, 5)


 効率的に処理するために、one-hotベクトルではなく単語IDで正解ラベルを表すように変換します。それぞれ3次元方向に並ぶ要素の内、値が1の要素のインデックスが単語IDです。そこで、np.argmax()を使って最大値のインデックスを取得します。

# 単語IDに変換
ts = np.argmax(ts, axis=2)
print(ts)
print(ts.shape)
[[1 4 0 2]
 [1 4 1 0]
 [4 4 2 0]]
(3, 4)


 値が-1の要素を検索します。-1以外(0から$V$の整数)であればTrue-1であればFalseとして保持しておきます。(これは何の操作?)

# マスクする要素を検索
mask = ts != -1
print(mask)
[[ True  True  True  True]
 [ True  True  True  True]
 [ True  True  True  True]]

 True1False0として計算に利用できます。

 こちらも2次元配列に変換します。

# 1次元配列に変換
ts = ts.reshape((batch_size * time_size))
mask = mask.reshape((batch_size * time_size))
print(ts)
print(ts.shape)
print(mask)
print(mask.shape)
print(np.sum(mask))
[1 4 0 2 1 4 1 0 4 4 2 0]
(12,)
[ True  True  True  True  True  True  True  True  True  True  True  True]
(12,)
12

 maskの総和が(計算に用いる)入力データ数(単語数)$N T$を表します。

 必要な変数を作成できたので、順伝播の計算を行います。

# Softmax関数による正規化
ry = softmax(rs)
print(np.round(ry, 2))
print(np.sum(ry, axis=1))
print(ry.shape)
[[0.98 0.01 0.   0.01 0.  ]
 [0.01 0.24 0.   0.6  0.15]
 [0.6  0.27 0.   0.   0.13]
 [0.   0.82 0.   0.18 0.  ]
 [0.16 0.73 0.09 0.01 0.  ]
 [0.   0.55 0.06 0.02 0.37]
 [0.82 0.   0.   0.18 0.  ]
 [0.   0.   0.   0.   0.99]
 [0.   0.42 0.01 0.   0.57]
 [0.95 0.03 0.   0.02 0.  ]
 [0.   0.2  0.67 0.   0.12]
 [0.47 0.05 0.   0.47 0.  ]]
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
(12, 5)

 各行の和が1になるのを確認できました。正規化するだけなので、形状はそのままです。

 これがTime Softmaxレイヤの出力データ$\mathbf{ys}$を行列に変換した$\mathbf{ry}$です。これがLossレイヤに出力します。

 続いて、Time Lossレイヤの順伝播の処理を確認します。

 np.arange(batch_size * time_size)が全ての行、tsが正解ラベルのインデックスです。これを添字として正解ラベルの要素だけを取り出して、np.log()で対数をとります。この処理が$\sum_{v=0}^{V-1} t_{ntv} \log y_{ntv}$の計算です。

 またmaskを掛けることで不要な要素が0となり、損失に影響しないようにします。

# 正解の要素を抽出
print(np.round(ry[np.arange(batch_size * time_size), ts], 2))

# 損失の計算
ls = np.log(ry[np.arange(batch_size * time_size), ts])
ls *= mask
print(np.round(ls, 2))
print(ls.shape)
[0.01 0.15 0.6  0.   0.73 0.37 0.   0.   0.57 0.   0.67 0.47]
[-5.22 -1.91 -0.52 -7.5  -0.31 -1.   -8.   -5.61 -0.57 -6.95 -0.4  -0.75]
(12,)


 全ての要素の和をとり、符号を反転させます。これは$- \sum_{n=0}^{N-1} \sum_{t=0}^{T-1} \sum_{v=0}^{V-1} t_{ntv} \log y_{ntv}$の計算です。

# 損失の合計を計算
loss = - np.sum(ls)
print(np.round(loss, 2))
38.73


 最後にmaskの総和で割ります。これは$\frac{1}{NT}$の計算です。

# 平均損失を計算
loss /= np.sum(mask)
print(np.round(loss, 2))
3.23

 計算結果は、スカラになります。

 ここまでがTime Sofmax with Lossレイヤの順伝播の処理です。これでRNNLMの最終的な出力である損失$L$が求まりました。続いて、逆伝播の処理を確認していきます。

・逆伝播の処理の確認

 厳密に連鎖律を行うと、Time Softmax with Lossレイヤの順伝播における入力データ$\mathbf{ss}$に関する勾配は、$\frac{\partial L}{\mathbf{ss}} = \frac{\partial L}{\partial L} \frac{\partial L}{\partial \mathbf{ss}}$で計算します。$\frac{\partial L}{\partial L} = 1$であり、これがTime Sofmax with Lossレイヤ、またRNNLMの逆伝播における入力データです。

# Sofmax with Lossレイヤの逆伝播の入力
dL = 1


 こちらも効率化のために、2次元配列に変換した状態で計算します。受け皿のdrsryの値を代入して、正解ラベルの要素からのみ1を引きます。これは、$y_{ntv} - t_{ntv}$の計算です。

# 括弧内の計算
drs = ry
drs[np.arange(batch_size * time_size), ts] -= 1
print(np.round(drs, 2))
[[ 0.98 -0.99  0.    0.01  0.  ]
 [ 0.01  0.24  0.    0.6  -0.85]
 [-0.4   0.27  0.    0.    0.13]
 [ 0.    0.82 -1.    0.18  0.  ]
 [ 0.16 -0.27  0.09  0.01  0.  ]
 [ 0.    0.55  0.06  0.02 -0.63]
 [ 0.82 -1.    0.    0.18  0.  ]
 [-1.    0.    0.    0.    0.99]
 [ 0.    0.42  0.01  0.   -0.43]
 [ 0.95  0.03  0.    0.02 -1.  ]
 [ 0.    0.2  -0.33  0.    0.12]
 [-0.53  0.05  0.    0.47  0.  ]]


 全ての要素にdLを掛けます。これは、$\frac{\partial L}{\partial L} \frac{\partial L}{\partial \mathbf{rs}}$の計算です。

# 連鎖律の計算
drs *= dL
print(np.round(drs, 2))
[[ 0.98 -0.99  0.    0.01  0.  ]
 [ 0.01  0.24  0.    0.6  -0.85]
 [-0.4   0.27  0.    0.    0.13]
 [ 0.    0.82 -1.    0.18  0.  ]
 [ 0.16 -0.27  0.09  0.01  0.  ]
 [ 0.    0.55  0.06  0.02 -0.63]
 [ 0.82 -1.    0.    0.18  0.  ]
 [-1.    0.    0.    0.    0.99]
 [ 0.    0.42  0.01  0.   -0.43]
 [ 0.95  0.03  0.    0.02 -1.  ]
 [ 0.    0.2  -0.33  0.    0.12]
 [-0.53  0.05  0.    0.47  0.  ]]


 全ての要素をmaskの合計で割ります。これは、$\frac{1}{NT}$の計算です。

 またマスクした要素は、順伝播していないので逆伝播もしません。よってFalse(0)を掛けます。

# 入力データの勾配の計算
drs /= np.sum(mask)
drs *= mask[:, np.newaxis]  # maskデータを0にする
print(np.round(drs, 2))
[[ 0.08 -0.08  0.    0.    0.  ]
 [ 0.    0.02  0.    0.05 -0.07]
 [-0.03  0.02  0.    0.    0.01]
 [ 0.    0.07 -0.08  0.02  0.  ]
 [ 0.01 -0.02  0.01  0.    0.  ]
 [ 0.    0.05  0.01  0.   -0.05]
 [ 0.07 -0.08  0.    0.02  0.  ]
 [-0.08  0.    0.    0.    0.08]
 [ 0.    0.04  0.    0.   -0.04]
 [ 0.08  0.    0.    0.   -0.08]
 [ 0.    0.02 -0.03  0.    0.01]
 [-0.04  0.    0.    0.04  0.  ]]

 計算結果のdrsは、rsと同じ形状になります。

 これを本来の形状に戻します。

# 元の形状に再変換
dss = drs.reshape((batch_size, time_size, vocab_size))
print(np.round(dss, 2))
print(dss.shape)
[[[ 0.08 -0.08  0.    0.    0.  ]
  [ 0.    0.02  0.    0.05 -0.07]
  [-0.03  0.02  0.    0.    0.01]
  [ 0.    0.07 -0.08  0.02  0.  ]]

 [[ 0.01 -0.02  0.01  0.    0.  ]
  [ 0.    0.05  0.01  0.   -0.05]
  [ 0.07 -0.08  0.    0.02  0.  ]
  [-0.08  0.    0.    0.    0.08]]

 [[ 0.    0.04  0.    0.   -0.04]
  [ 0.08  0.    0.    0.   -0.08]
  [ 0.    0.02 -0.03  0.    0.01]
  [-0.04  0.    0.    0.04  0.  ]]]
(3, 4, 5)

 計算結果のdssは、ssと同じ形状になります。

 ここでが逆伝播の処理でした。

・実装

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

# Time Softmax with Lossレイヤの実装
class TimeSoftmaxWithLoss:
    
    # 初期化メソッドの定義
    def __init__(self):
        self.params = [] # パラメータ
        self.grads = [] # 勾配
        self.cache = None
        self.ignore_label = -1
    
    # 順伝播メソッドの定義
    def forward(self, xs, ts):
        # 形状に関する値を取得
        N, T, V = xs.shape
        
        # 2次元配列(単語ID)に変換
        if ts.ndim == 3:  # one-hotベクトルの場合
            ts = ts.argmax(axis=2)
        
        # `-1`の要素のインデックスを取得
        mask = (ts != self.ignore_label)

        # バッチ分と時系列分をまとめる(reshape)
        xs = xs.reshape(N * T, V)
        ts = ts.reshape(N * T)
        mask = mask.reshape(N * T)

        ys = softmax(xs)
        ls = np.log(ys[np.arange(N * T), ts])
        ls *= mask  # ignore_labelに該当するデータは損失を0にする
        loss = -np.sum(ls)
        loss /= mask.sum()
        
        # 逆伝播の処理用に変数を保存
        self.cache = (ts, ys, mask, (N, T, V))
        return loss
    
    # 逆伝播メソッドの定義
    def backward(self, dout=1):
        # 変数を取得
        ts, ys, mask, (N, T, V) = self.cache
        
        # 勾配を計算
        dx = ys
        dx[np.arange(N * T), ts] -= 1
        dx *= dout
        dx /= mask.sum()
        dx *= mask[:, np.newaxis]  # ignore_labelに該当するデータは勾配を0にする
        
        # 元の形状に再変換
        dx = dx.reshape((N, T, V))

        return dx


 実装したクラスを試してみましょう。

# Time Softmax with Lossレイヤのインスタンスを作成
layer = TimeSoftmaxWithLoss()

# 順伝播の計算
L = layer.forward(ss, ts)
print(np.round(L, 2))

# 逆伝播の計算
dss = layer.backward(dout=1)
print(np.round(dss, 2))
print(dss.shape)
3.23
[[[ 0.08 -0.08  0.    0.    0.  ]
  [ 0.    0.02  0.    0.05 -0.07]
  [-0.03  0.02  0.    0.    0.01]
  [ 0.    0.07 -0.08  0.02  0.  ]]

 [[ 0.01 -0.02  0.01  0.    0.  ]
  [ 0.    0.05  0.01  0.   -0.05]
  [ 0.07 -0.08  0.    0.02  0.  ]
  [-0.08  0.    0.    0.    0.08]]

 [[ 0.    0.04  0.    0.   -0.04]
  [ 0.08  0.    0.    0.   -0.08]
  [ 0.    0.02 -0.03  0.    0.01]
  [-0.04  0.    0.    0.04  0.  ]]]
(3, 4, 5)


 以上でRNNLMに必要なレイヤを全て実装できました。次節では、RNNLMモデルを実装します。

参考文献

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

おわりに

 適当に名付けてるわけではないのですが、本にない変数名にするとややこしいったらないね。
 あとmaskしてるのって何ですか?後々出るんですよね。その時でいいです。

【次節の内容】

www.anarchive-beta.com