はじめに
『ゼロから作るDeep Learning 2――自然言語処理編』の初学者向け【実装】攻略ノートです。『ゼロつく2』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。
本の内容を1つずつ確認しながらゆっくりと組んでいきます。
この記事は、4.2.2項「多値分類から二値分類へ」と4.2.4項「多値分類から二値分類へ(実装編)」の内容です。出力層に用いるEmbedding Dotレイヤを説明して、Pythonで実装します。
前節の記事とあわせて読んでください。
【前節の内容】
【他の節の内容】
【この節の内容】
4.2.2 多値分類から二値分類へ
これまでは、「0から9の数字」や「テキストに含まれる全ての単語」について推論を行ってきました。このような問題は、対象となった文字や単語を複数のクラスから特定のクラスに分類するといえるので、多値分類と呼びます。しかしこの多値分類を行う手法では、対象の単語が「どの語彙であるか」推論するために語彙(クラス)ごとに確率を計算する必要があります。これを、「ある語彙であるかないか」を推論することで効率的に処理することにします。この問題は、対象の単語が「ある語彙である」・「ある語彙でない」の2つのクラスに分類するといえることから、二値分類と呼びます。
・数式による確認
まずは前節と同様に、出力層の重み$\mathbf{W}_{\mathrm{out}}$から対象となる単語に関する重みを抽出します。前節で扱った入力層では、コンテキストの単語$\mathbf{c}$に対応する行を抽出しました。出力層では、ターゲットの単語$\mathbf{t}$に対応する行を抽出します。
3.2.1項と同様に、出力層の重みを次の形状とします。
ここで各行は中間層のニューロン、各列は単語IDに対応しています。3.2.1項の例では、列の添字に単語IDではなく単語そのものを使って説明しました。
またこの例では、ターゲット(教師ラベル)をone-hot表現ではなく次のように各要素が単語IDの値を持ちます。
添字は各単語がテキストに出現した順番を表します。ただし最初と最後からウィンドウサイズ分の単語は含めません。
Embeddingレイヤの順伝播により、ターゲットの単語に関する行を抽出(重複を許して並び替え)した重みは、次のようになります。
各列はターゲットの各要素に対応しており、列番号はターゲットの各要素が持つ単語ID(値)になります。
この抽出した重み$\mathbf{W}_{\mathrm{target}}$の「各列」と、3.1.3項で求めた中間層のニューロン
の「各行」との内積を求めます。ここで$\mathbf{h}$の行番号は、コンテキスト$\mathbf{c}$の各要素(単語)のインデックスに対応します。
つまりコンテキスト(入力データ)の0番目の単語に関するスコア$s_0$の計算は、$\mathbf{h}$の0行目と$\mathbf{W}_{\mathrm{target}}$の0列目の内積で計算できます。
他の要素(単語)も同様に計算できるので、スコア$\mathbf{s}$は次のようになります。
このスコア$\mathbf{s}$の各要素のインデックスは、コンテキスト$\mathbf{c}$の各要素のインデックスなので、ターゲットの各要素のインデックスにも対応します。つまりスコアの各要素は、入力した各コンテキストに対応する正解ラベルの単語に関するスコアになります。
この一連の処理を行う層を、Embedding Dotレイヤと呼びます。またここまでの処理が順伝播です。
続いて逆伝播を考えます。Embedding Dotレイヤの逆伝播では、中間層のニューロンに関する勾配$\frac{\partial L}{\partial \mathbf{h}}$と出力層の重みに関する勾配$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{out}}}$を求めます。$\frac{\partial L}{\partial \mathbf{h}}$は入力層(Embeddingレイヤ)に伝播し、$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{out}}}$は重みの更新に用います。
順伝播のときのように、まずは$\frac{\partial L}{\partial \mathbf{h}}$の0行目について考えます。
更にこの0番目の要素は、連鎖率より次のように計算できます。
$\frac{\partial L}{\partial s_0}$は、次項で扱うSigmoid with Lossレイヤから伝播してくる値なので、このレイヤで計算する必要はありません。また順伝播で求めたスコアの0番目の要素$s_0 = \sum_{i=0}^2 h_{0i} w_{i1}$を代入すると
0番目のターゲット「say(単語ID:1)」のスコア$s_0$を、中間層のニューロンの0行0列目の要素$h_{00}$で偏微分すると、Embeddingレイヤにより展開した重み$\mathbf{W}_{\mathrm{target}}$の0行0列目の要素(ただし列番号はsayの単語ID:1の)$w_{01}$となります。
つまり中間層のニューロンの要素$h_{00}$に関する勾配は、逆伝播の入力$\frac{\partial L}{\partial s_0}$と対応する重み$w_{01}$の積になることが分かりました。
他の要素も同様に計算できるので、Embedding Dotレイヤの逆伝播の出力は
となります。
続いて、Embeddingレイヤにより変換した出力層の重み$\mathbf{W}_{\mathrm{target}}$に関する勾配を計算します。先ほどと同様に、$\mathbf{W}_{\mathrm{target}}$に関する勾配の0行0列目の要素$\frac{\partial L}{\partial w_{01}}$は、連鎖律により
逆伝播の入力$\frac{\partial L}{\partial s_0}$と、0番目のスコア$s_0$の0行0列目の重み$w_{01}$による偏微分との積で計算できることが分かります。これを計算すると次のようになります。
他の要素も同様に計算できるので
となります。
これを更にEmbeddingレイヤの逆伝播の処理によって、出力層の重みに関する勾配$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{out}}}$を計算します。
以上で出力層の計算を確認できました。次項では活性化層と損失層を確認します。
4.2.4 多値分類から二値分類へ(実装編)
出力層においてターゲットの単語のみを処理するEnbedding Dotレイヤを実装します。
・処理の確認
本来の出力層の重み$\mathbf{W}_{\mathrm{out}}$は、中間層のニューロン数hidden_size
行、語彙数vocab_size
列の行列でした。しかし前節で実装したEmbedding
クラスで処理できるように、語彙数vocab_size
行、中間層のニューロン数hidden_size
列の行列とします。つまりW_out
は、$\mathbf{W}_{\mathrm{out}}$を転置した状態になります。これにより列ごとではなく、必要な行を取り出して並べます。
また出力層で取り出す単語のインデックスは、ターゲットtarget
です。
この処理は前節で実装したEmbedding
クラスの順伝播メソッドで行います。
# 出力層の重みをランダムに生成 W_out = np.random.randn(vocab_size, hidden_size) print(np.round(W_out, 2)) print(W_out.shape) # Embeddingレイヤのインスタンスを作成 embed_layer_out = Embedding(W_out) # 入力層の順伝播を計算 target_W = embed_layer_out.forward(target) print(target) print(np.round(target_W, 2)) print(target_W.shape)
[[ 1.64 -0.14 -0.19]
[ 1. -0.91 -1.34]
[ 0.87 -0.31 0.93]
[-0.32 0.11 -0.4 ]
[ 0.68 2.03 -0.42]
[ 0.9 -2.56 0.92]
[ 1.79 0.95 0.99]]
(7, 3)
[1 2 3 4 1 5]
[[ 1. -0.91 -1.34]
[ 0.87 -0.31 0.93]
[-0.32 0.11 -0.4 ]
[ 0.68 2.03 -0.42]
[ 1. -0.91 -1.34]
[ 0.9 -2.56 0.92]]
(6, 3)
target_W
は、target
の値に従って、W_out
を行ごとに(単語が重複する場合は複製しながら)並べ替えたものになります。よってtarget_W
も、$\mathbf{W}_{\mathrm{target}}$を転置した状態になります。
抽出した重みと前項で計算したh
との内積を計算します。target_W
は$\mathbf{W}_{\mathrm{target}}$を転置した状態であるため、target * h
で同じ位置の要素同士の掛け算を行います。更にnp.sum(axis=1)
で行ごとに和をとります。
この計算結果がスコアとなります。
# スコア(内積)を計算 s = np.sum(target_W * h, axis=1) print(np.round(s, 2)) print(s.shape)
[ 0.05 0.65 -0.07 -0.29 0.42 -1.81]
(6,)
スコア(出力データ)の要素数は、コンテキス(入力データ)のデータ数なのでつまりターゲット数になります。各データにおいて正解ラベルの要素を取り出したものになります。Sigmoidレイヤに伝播し、それぞれの要素を0から1の値になるように正規化します。各要素は独立したものなので、正規化しても総和が1にはならない点に注意してください。
以上がEmbedding Dotレイヤ順伝播の処理です。
続いて、逆伝播の処理を確認します。
逆伝播の入力$\frac{\partial L}{\partial \mathbf{s}}$は、順伝播の出力$\mathbf{s}$と同じ形状になります。簡単に計算できるように、要素を縦に並べた2次元配列に変換しておきます。
# 逆伝播の入力を適当に生成 ds = np.random.randn(target.shape[0]) print(ds) print(ds.shape) # 1次元配列を2次元配列に変換 ds = ds.reshape(ds.shape[0], 1) print(ds) print(ds.shape)
[-1.60212118 -0.86946314 1.3290934 0.94355095 0.34452969 -0.05639633]
(6,)
[[-1.60212118]
[-0.86946314]
[ 1.3290934 ]
[ 0.94355095]
[ 0.34452969]
[-0.05639633]]
(6, 1)
$\frac{\partial L}{\partial \mathbf{h}}$は、転置した$\mathbf{W}_{\mathrm{target}}$の列ごとに$\mathbf{s}$を掛けるごとで計算できます。target_W
は既に転置した状態であるため、ブロードキャストを利用して*
で計算できます。
# 中間層のニューロンに関する勾配を計算 dh = ds * target_W print(dh.shape)
(6, 3)
この値が、入力層に伝播します。
$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{target}}}$は、$\mathbf{h}$の行ごとに$\frac{\partial L}{\partial \mathbf{s}}$を掛けて計算します。ただしdtarget_W
も転置した状態であるため、ブロードキャストを利用してh
の列ごとにds
を掛けることで計算します。h
は前節で計算したものをそのまま使います。
その計算結果をEmbdedding
クラスの逆伝播メソッドで、$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{out}}}$に変換します。
# ターゲットの重みに関する勾配を計算 dtarget_W = ds * h print(dtarget_W.shape) # 出力層の重みに関する勾配を計算 embed_layer_out.backward(dtarget_W) # 出力層の重みに関する勾配を取得 dW_out, = embed_layer_out.grads print(dW_out.shape)
(6, 3)
(7, 3)
この値を用いて出力層の重みを更新します。
・実装
処理の確認ができたので、Embedding Dotレイヤをクラスとして実装します。
# Embedding Dotレイヤの実装 class EmbeddingDot: # 初期化メソッドの定義 def __init__(self, W): # Embeddingレイヤを作成 self.embed = Embedding(W) self.params = self.embed.params # パラメータ self.grads = self.embed.grads # 勾配 self.cache = None # 一時データ # 順伝播メソッドの定義 def forward(self, h, idx): # ターゲットの重みを抽出 target_W = self.embed.forward(idx) # スコア(内積)を計算 out = np.sum(target_W * h, axis=1) # 一時データを保存 self.cache = (h, target_W) return out # 逆伝播メソッドの定義 def backward(self, dout): # 一時データを取得 h, target_W = self.cache # 1次元配列を2次元配列に変換 dout = dout.reshape(dout.shape[0], 1) # 重みに関する勾配を計算 dtarget_W = dout * h self.embed.backward(dtarget_W) # 中間層のニューロンに関する勾配を計算 dh = dout * target_W return dh
実装したクラスを試してみましょう。
# Embedding Dotレイヤのインスタンスを作成 embed_dot_layer = EmbeddingDot(W_out) # 順伝播の処理(スコアを計算) s = embed_dot_layer.forward(h, target) print(np.round(s, 2)) print(s.shape)
[ 0.05 0.65 -0.07 -0.29 0.42 -1.81]
(6,)
続いて逆伝播を計算します。
# 逆伝播の処理(勾配を計算) dh = embed_dot_layer.backward(ds) dW_out, = embed_dot_layer.grads print(dh.shape) print(dW_out.shape)
(6, 3)
(7, 3)
以上で効率的にスコアを計算できました。このスコアを確率に変換して、損失を計算するのでした。次はそのためのサンプリングについて説明します。
参考文献
おわりに
難しい計算がある訳ではないのですが、どのように処理しているのかを理解するのに手間取りました。重みが転置していると気付けば、なるほどなという感じでした。
【次節の内容】