からっぽのしょこ

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

4.1:word2vecの改良①【ゼロつく2のノート(実装)】

はじめに

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

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

 この記事は、4.1節「word2vecの改良①」の内容です。入力層と出力層において効率よくMatMulレイヤの処理をするためのEmbeddingレイヤを説明して、Pythonで実装します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

4.1.1 Embeddingレイヤ

 「3.1-2:シンプルなword2vec【ゼロつく2のノート(実装)】 - からっぽのしょこ」で確認した通り、入力層では「one-hot表現のコンテキスト$\mathbf{c}_{\mathrm{you}}$」と「重み$\mathbf{W}_{\mathrm{in}}$」の計算結果が、「コンテキストの単語に関する重み$\mathbf{w}_{\mathrm{you}}$」になるのでした。つまり重みから対象となる単語のID行目の要素を抽出すれば、同じ結果が得られます。この方法だと一部の要素だけを用いるため、簡単に処理できるようになります。
 計算結果はMatMul(行列の積)レイヤと同じですが、この処理によって実行する場合はEmbeddingレイヤと呼ぶことにします。

 逆伝播の処理も、順伝播で取り出した行と同じ行に逆伝播の入力の要素をそのまま伝播するだけです。これも本来のMatMulレイヤの処理から分かります。
 こちらも3.1.3項の例を使って説明します。入力層の逆伝播では、重み$\mathbf{W}_{\mathrm{in}}$に関する勾配$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{in}}}$を求めます。$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{in}}}$は逆伝播の出力であり、重みと同じ形状になるのでした。また逆伝播の入力を$\frac{\partial L}{\partial \mathbf{h}}$、one-hot表現のコンテキスト$\mathbf{c}_{\mathrm{you}}$の逆行列を$\mathbf{c}_{\mathrm{you}}^{\mathrm{T}}$とすると、$\frac{\partial L}{\partial \mathbf{W}_{\mathrm{in}}}$は次の式で計算できます(1.3.4.5項)。

$$ \begin{aligned} \frac{\partial L}{\partial \mathbf{W}_{\mathrm{in}}} &= \mathbf{c}_{\mathrm{you}}^{\mathrm{T}} \frac{\partial L}{\partial \mathbf{h}} \\ &= \begin{pmatrix} c_{00} \\ c_{01} \\ \vdots \\ c_{06} \end{pmatrix} \begin{pmatrix} \frac{\partial L}{\partial h_{00}} & \frac{\partial L}{\partial h_{01}} & \frac{\partial L}{\partial h_{02}} \end{pmatrix} \\ &= \begin{pmatrix} c_{00} \frac{\partial L}{\partial h_{00}} & c_{00} \frac{\partial L}{\partial h_{01}} & c_{00} \frac{\partial L}{\partial h_{02}} \\ c_{01} \frac{\partial L}{\partial h_{00}} & c_{01} \frac{\partial L}{\partial h_{01}} & c_{01} \frac{\partial L}{\partial h_{02}} \\ \vdots & \vdots & \vdots \\ c_{06} \frac{\partial L}{\partial h_{00}} & c_{06} \frac{\partial L}{\partial h_{01}} & c_{06} \frac{\partial L}{\partial h_{02}} \end{pmatrix} \end{aligned} $$

 ここで$\mathbf{c}_{\mathrm{you}}$の要素を詳しく見ると、次にようになります。ただし添字はPythonの仕様に合わせて0から始めています。

$$ \begin{aligned} \mathbf{c}_{\mathrm{you}} &= \begin{pmatrix} c_{00} & c_{01} & \cdots & c_{06} \end{pmatrix} \\ &= \begin{pmatrix} 1 & 0 & \cdots & 0 \end{pmatrix} \end{aligned} $$

 行番号はコンテキストcontexts[:, n]のインデックスであり、つまり何番目のターゲットに関するコンテキストなのかを表します。列番号は単語IDの通し番号に対応します。3.1.3項の例では、ターゲットを1つとしたため行番号は用いず、また列番号はイメージしやすいように単語IDではなく単語自体を添字として表記しました。
 この例はコンテキストの0番目(1つ目)の単語「you」のみを考えているので、1行の行列になります。複数の場合にも対応できるように、行番号を明示しておきます。また「you」の単語IDは0なので、0行0列目の要素が1でそれ以外は0になります。

 よって上の計算結果は、$c_{00}$との積の項はそのまま残り、それ以外の項は全て0になります。

$$ \frac{\partial L}{\partial \mathbf{W}_{\mathrm{in}}} = \begin{pmatrix} \frac{\partial L}{\partial h_{00}} & \frac{\partial L}{\partial h_{01}} & \frac{\partial L}{\partial h_{02}} \\ 0 & 0 & 0 \\ \vdots & \vdots & \vdots \\ 0 & 0 & 0 \end{pmatrix} $$

となります。

 つまり逆伝播においても数式通り行列の積の計算を行う必要はなく、全ての要素が0の変数dWを作成して、逆伝播の入力doutの各行を、それぞれdWの対応する行に代入すればいいことが分かります。対応する行とは、doutの0行目であれば、dWの「コンテキストの0番目の単語のID」行目のことです。

 数式からEmbeddingレイヤの処理を確認ができたので、次はPythonを使って確認して実装します。

4.1.2 Embeddingレイヤの実装

 MatMulレイヤの計算にone-hot表現を用いる場合に、効率的に処理を行うEmbeddingレイヤを実装します。

# NumPyをインポート
import numpy as np

・処理の確認

 まずは3章で行ったテキストの前処理を行います。ただしone-hot表現に変換する必要はありません。この処理には、これまでに実装した関数preprocess(2.3.1項)とcreate_contexts_target(3.3.1項)の定義を再実行するか、次の方法で読み込む必要があります。

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

# 実装済みの関数をインポート
from common.util import preprocess
from common.util import create_contexts_target


# テキストを設定
text = 'You say goodbye and I say hello.'

# 単語と単語IDに変換
corpus, word_to_id, id_to_word = preprocess(text)
print(word_to_id)
print(id_to_word)
print(corpus)

# 語彙数(単語の種類数)を取得
vocab_size = len(word_to_id)
print(vocab_size)

# コンテキストとターゲットを作成
contexts, target = create_contexts_target(corpus, window_size=1)
print(target)
print(target.shape)
print(contexts)
print(contexts.shape)
{'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}
{0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}
[0, 1, 2, 3, 4, 1, 5, 6]
7
[1 2 3 4 1 5]
(6,)
[[0 2]
 [1 3]
 [2 4]
 [3 1]
 [4 5]
 [1 6]]
(6, 2)

 targetcontextsの値は単語IDでした。

 つまりcontextsの各列を取り出して入力層の重みW_inの添字として指定することで、各単語に対応する行を抽出できます。

# 中間層のニューロン数を指定
hidden_size = 3

# 入力層の重みをランダムに生成
W_in = np.random.randn(vocab_size, hidden_size)
print(np.round(W_in, 2))
print(W_in.shape)

# 入力層の計算(コンテキスとの単語に関する重みを抽出)
h = W_in[contexts[:, 0]]
print(contexts[:, 0])
print(np.round(h, 2))
print(h.shape)
[[ 0.74 -0.09 -0.9 ]
 [-0.08  1.62  1.17]
 [ 0.17  0.31 -0.98]
 [-1.27  0.9  -0.56]
 [ 0.8   0.68 -2.29]
 [-0.28  0.09 -0.99]
 [ 0.66 -0.68  0.63]]
(7, 3)
[0 1 2 3 4 1]
[[ 0.74 -0.09 -0.9 ]
 [-0.08  1.62  1.17]
 [ 0.17  0.31 -0.98]
 [-1.27  0.9  -0.56]
 [ 0.8   0.68 -2.29]
 [-0.08  1.62  1.17]]
(6, 3)

 ターゲットの1つ前の単語contexts[:, 0]の1番目と6番目の要素が1です。これは、単語IDが1の「say」のことです。よってhの1行目と6行目は、Wの1行目の要素になります。このように、行を重複して取り出すこともできます。hが入力層の出力(の1つ)で、ターゲットの単語数行、中間層のニューロン数列の行列になります。

 以上が順伝播で行う処理です。

 続いて、逆伝播の処理を確認します。

 入力層の逆伝播の入力$\frac{\partial L}{\partial \mathbf{h}}$は、順伝播の出力$\mathbf{h}$と同じ形状になるのでした。本来はLossレイヤから伝播してくる値ですが、ここでは処理をイメージしやすいように全ての要素を1としておきます。

# 分かりやすいように逆伝播の入力を作成
dout = np.ones_like(h)
print(dout)
print(dout.shape)

# 重みと同じ形状で全ての要素が0の行列を作成
dW_in = np.zeros_like(W_in)
print(dW_in)
print(dW_in.shape)

# 1語ずつ逆伝播の処理
for i, word_id in enumerate(contexts[:, 0]):
    dW_in[word_id] += dout[i]

print(dW_in)
[[1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]]
(6, 3)
[[0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]]
(7, 3)
[[1. 1. 1.]
 [2. 2. 2.]
 [1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]
 [0. 0. 0.]
 [0. 0. 0.]]

 重複していた「say」の行(1行目)が2回処理されていることが確認できます。

 np.add.at()関数でも、同じ処理を行えます。

# 重みと同じ形状で全ての要素が0の行列を作成
dW_in = np.zeros_like(W_in)
print(dW_in)

# 逆伝播の処理
np.add.at(dW_in, contexts[:, 0], dout)
print(dW_in)
[[0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]
 [0. 0. 0.]]
[[1. 1. 1.]
 [2. 2. 2.]
 [1. 1. 1.]
 [1. 1. 1.]
 [1. 1. 1.]
 [0. 0. 0.]
 [0. 0. 0.]]

 ちなみに重複した単語の場合に勾配を加算するのは、分岐ノード(1.3.4.2項)になっているからですね。

・実装

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

# Embeddingレイヤの実装
class Embedding:
    # 初期化メソッドの定義
    def __init__(self, W):
        self.params = [W] # パラメータ
        self.grads = [np.zeros_like(W)] # 勾配
        self.idx = None # ターゲットの単語ID
    
    # 順伝播メソッドの定義
    def forward(self, idx):
        
        # 重みを取得
        W, = self.params
        
        # ターゲットのインデックスを取得
        self.idx = idx
        
        # ターゲットに関する重みを抽出
        out = W[idx]
        return out
    
    # 逆伝播メソッドの定義
    def backward(self, dout):
        # 勾配を取得
        dW, = self.grads
        
        # ターゲットに関する重みに加算
        dW[...] = 0 # 値を初期化
        np.add.at(dW, self.idx, dout)


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

 ウィンドウサイズが1のときのコンテキストの場合の順伝播を行います。この場合は入力層が2つになるので、Embeddingレイヤのインスタンスも2つ作成します(3.2.1項)。

# Embeddingレイヤのインスタンスを作成
embed_layer0 = Embedding(W_in)
embed_layer1 = Embedding(W_in)

# 入力層の順伝播を計算
h0 = embed_layer0.forward(contexts[:, 0]) # ターゲットの前のコンテキスト
h1 = embed_layer1.forward(contexts[:, 1]) # ターゲットの後のコンテキスト
h = (h0 + h1) * 0.5
print(np.round(h, 2))
print(h.shape)
[[ 0.45  0.11 -0.94]
 [-0.67  1.26  0.31]
 [ 0.49  0.5  -1.64]
 [-0.67  1.26  0.31]
 [ 0.26  0.38 -1.64]
 [ 0.29  0.47  0.9 ]]
(6, 3)


 次は、逆伝播を行います。

# 逆伝播の入力を適当に生成
dout = np.random.randn(contexts.shape[0], hidden_size)
print(dout.shape)

# 入力層の逆伝播を計算
embed_layer0.backward(dout)
embed_layer1.backward(dout)

# 入力層の勾配を取得
dW_in0, = embed_layer0.grads
dW_in1, = embed_layer1.grads
print(dW_in0)
print(dW_in1)
(6, 3)
[[-0.08584457  0.8632444   0.30610412]
 [ 0.60563311 -3.13703351  0.95378212]
 [ 0.55591708  0.33309916 -1.33975842]
 [-0.07490906 -0.53735948 -0.37188545]
 [ 0.48331176 -0.01793102 -1.55644324]
 [ 0.          0.          0.        ]
 [ 0.          0.          0.        ]]
[[ 0.          0.          0.        ]
 [-0.07490906 -0.53735948 -0.37188545]
 [-0.08584457  0.8632444   0.30610412]
 [ 0.26905083 -2.60418452  0.2332773 ]
 [ 0.55591708  0.33309916 -1.33975842]
 [ 0.48331176 -0.01793102 -1.55644324]
 [ 0.33658228 -0.532849    0.72050482]]


 以上で入力層に関する改良を行えました。続いて出力層に関する改良を行います。

参考文献

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

おわりに

 トピックモデルの記事の修正作業が大詰めだったので前章から間が空きましたが、特に問題なく相変わらず順調です。

 2020年9月29日は、モーニング娘。10期メンバーの加入9周年の記念日です!!!!

 おめでとうございます!!!!10年目の天気組も楽しみっ!

【次節の内容】

www.anarchive-beta.com