からっぽのしょこ

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

3.5:word2vecに関する補足【ゼロつく2のノート(実装)】

はじめに

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

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

 この記事は、3.5節「word2vecに関する補足」の内容です。確率の基礎・負の対数尤度とCBOWモデル・skip-gramとの対応関係を説明して、skip-gramをPythonで実装します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

3.5.1 CBOWモデルと確率

・同時確率と条件付き確率

 簡単に同時確率と条件付き確率について確認しておきましょう。

 6面のサイコロを振ったときの出目について考えます。$P(\cdot)$で、括弧の中となる確率を表すことにします。例えば出目が「1」の確率は、$P(1) = \frac{1}{6}$と表記します。

 サイコロを2回振ったとき、次の2つの事象から同時確率を考えます。

  • $A$:1回目が1である
  • $B$:2回目が3である

 「$A$」の確率は$P(A) = \frac{1}{6}$、「$B$」の確率も$P(B) = \frac{1}{6}$ですね。
 では「1回目が1で2回目が3」となる確率はどうでしょうか。つまり「$A$かつ$B$」の確率です。このような確率を同時確率と呼び、$P(A, B)$と表記することにします。サイコロを2回振ったときの出目の組み合わせは$6^2$通りあることから、$P(A, B) = \frac{1}{36}$ですね。

 $A$と$B$の同時確率$P(A, B)$は、次のように$A$の確率と$B$の確率の積でも計算できます。

$$ \begin{aligned} P(A, B) &= P(A) P(B) \\ &= \frac{1}{6} \frac{1}{6} \\ &= \frac{1}{36} \end{aligned} $$

 ただしこの関係が成り立つのは、それぞれの事象が独立に生じる場合に限ります。サイコロの出目は、振ったタイミングなどに影響されずに決まるため、このように求められます。

 続いて、条件付き確率について考えます。条件付き確率とは、例えばサイコロを1回振り出目を自分で見る前に「偶数」であるという情報が与えられたとき、「3以下」の目である確率です。「偶数」の目は3通りで、その内「3以下」の目は1つなので、「偶数」という条件の下で「3以下」となる確率は$\frac{1}{3}$になります。

 この2つの事象を次のように表し整理しましょう。

  • $C$:3以下である
  • $D$:偶数である

 「$C$」の確率は$P(C) = \frac{3}{6} = \frac{1}{2}$、「$D$」の確率も$P(D) = \frac{3}{6} = \frac{1}{2}$ですね。「$C$かつ$D$」つまり「3以下の偶数」の確率は、$P(C, D) = \frac{1}{6}$です。$C$と$D$は独立ではないため、$P(C, D) \neq P(C) P(D)$です。

 では例として上げた「$D$」の情報が与えられた下での「$C$」の確率を、$P(C | D) = \frac{1}{3}$と表すことにします。$D$という条件のあるなしによって、$C$の確率が$\frac{1}{2}$から$\frac{1}{3}$に変わることが分かりました。

 この条件付き確率$P(C | D)$は、次の関係が成り立ちます。

$$ \begin{aligned} P(C | D) &= \frac{P(C, D)}{P(D)} \\ &= \frac{\frac{1}{6}}{\frac{1}{2}} \\ &= \frac{1}{6} \frac{2}{1} \\ &= \frac{1}{3} \end{aligned} $$

 つまり$C$と$D$の同時確率$P(C, D)$と、条件$D$の確率$P(D)$から求められます。

 また1行目の式から、次の関係式が成り立つことも分かります。

$$ P(C | D) P(D) = P(C, D) $$

 $D$を条件とした$C$の確率と、条件$D$の確率の積は、$C$と$D$の同時確率になります。

 ところで、同時確率の式について次のように変形すると

$$ \begin{aligned} P(A, B) &= P(A) P(B) \\ \frac{P(A, B)}{P(B)} &= P(A) \\ P(A | B) &= P(A) = \frac{1}{6} \end{aligned} $$

$B$という条件に関わらず$A$の確率が決まることが分かります。言い換えると、$A$は$B$の影響を受けないということです。この関係が成り立つとき、$A$と$B$は独立であると言えます。

 その他条件付き独立などについては、確率の入門書などを参照してください。

 それでは、これらの確率の定義をCBOWモデルに当てはめて考えていきます。

・負の対数尤度

 これまで扱ってきた「You say goodbye and I say hello.」を使って考えます。このテキストを(ピリオドも1語として)単語に分解したものをコーパスとしました。この例では、語彙数(単語の種類数)が7、単語数が8でコーパスが構成されています。単語を$w$、出現した順番を下付き添字で示すこととし、それぞれ$w_1, w_2, \cdots, w_8$で表すします。つまりテキストの最初の単語「you」は$w_1$です。

 注目する単語をターゲット、その前後の単語をコンテキストと呼ぶのでした。3番目の単語$w_3$をターゲットとするのであれば、コンテキストは$w_2$と$w_4$ですね。また「コンテキストの単語」の情報を得た上で、確率が最大となる「ターゲットの単語」を求めることを推論と呼ぶのでした。つまり「コンテキスト$w_2,\ w_4$」の情報が与えられた下での「未知のターゲット$w_2$」の確率です。これは条件付き確率

$$ P(w_3 | w_2, w_4) \tag{3.1} $$

で表せます。

 3.2.1項で確認したように順伝播では、コンテキスト$\mathbf{c}$を入力として2層の全結合層によってスコア$\mathbf{s} = (s_1, s_2, \cdots, s_7)$を計算します。この添字は単語IDです。このスコアをSoftmax関数によって正規化(確率に変換)したものを$\mathbf{y} = (y_1, y_2, \cdots, y_7)$とします。この出力$\mathbf{y}$とone-hot表現のターゲット(教師ラベル)$\mathbf{t} = (t_1, t_2, \cdots, t_7)$から、次の式により交差エントロピー誤差を計算します。交差エントロピー誤差についての詳細は「4.2.1:2乗和誤差の実装【ゼロつく1のノート(実装)】 - からっぽのしょこ」を参照してください。

$$ L = - \sum_{k=1}^6 t_k \log y_k \tag{1.7} $$

 この値を損失とするのでした。

 3番目の単語$w_3 = \mathrm{you}$の単語IDは3なので、ターゲット$\mathbf{t}$は3番目の要素が1で他の要素は0です。すると式(1.7)は

$$ \begin{aligned} L &= -(0 \log y_1 + 0 \log y_2 + 1 \log y_3 + 0 \log y_4 + 0 \log y_5 + 0 \log y_6) \\ &= - \log y_3 \end{aligned} $$

正解ラベル(ターゲット)に関する確率のみに注目することになります。また$y_3$は「you」のコンテキスト$\mathbf{c}$から求めた確率なので、式(3.1)のことです。従って置き換えると、損失は

$$ L = - \log P(w_3 | w_2, w_4) \tag{3.2} $$

で表せます。またこの式を負の対数尤度と呼びます。

 ここまでは1つの単語$w_3$について考えました。複数の単語$w_1, \cdots, w_8$を同時に計算する場合は、バッチデータ版の交差エントロピー誤差

$$ L = - \frac{1}{8} \sum_{t=1}^8 \sum_{k=1}^7 t_{tk} \log y_{tk} \tag{1.8} $$

を用います。

 $(t_{11}, \cdots, t_{87})$はtargetに対応します。つまり各行が$w_1, \cdots, w_8$に、各列が単語ID順に並んだ単語に対応します。$(y_1, \cdots, y_{87})$も同様です。
 従って先ほどと同様に、正解ラベルつまり実際の単語$w_1, \cdots, w_8$に関する確率の項のみが残るので

$$ L = - \frac{1}{8} \sum_{t=1}^8 \log P(w_t | w_{t-1}, w_{t+1}) \tag{3.3} $$

となります。

 以上の考え方は次のskip-gramでも同様です。

3.5.2 skip-gram

 skip-gramをクラスとして実装し、学習を行います。

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

# 実装済みのクラスをインポート
#from common.trainer import Trainer
from common.optimizer import Adam

# その他必要なライブラリ
import numpy as np
import matplotlib.pyplot as plt


 基本的な実装はCBOWモデルと同じです。

 skip-gramでは入力層が1つのため、重み付き和の平均をとる必要がなくなります。逆に出力が2つになるため、交差エントロピー誤差の和を損失とします。それに伴い、出力層とSoftmax with Lossレイヤの間に分岐ノード(1.3.4.2項)の処理(和をとる)を行います。

# skip-gramモデルの実装
class SimpleSkipGram:
    # 初期化メソッドの定義
    def __init__(self, vocab_size, hidden_size):
        # ニューロン数を保存
        V = vocab_size  # 入力層と出力層
        H = hidden_size # 中間層

        # 重みの初期値を生成
        W_in = 0.01 * np.random.randn(V, H).astype('f')
        W_out = 0.01 * np.random.randn(H, V).astype('f')

        # レイヤを生成
        self.in_layer = MatMul(W_in)   # 入力層
        self.out_layer = MatMul(W_out) # 出力層
        self.loss_layer1 = SoftmaxWithLoss() # 損失層1
        self.loss_layer2 = SoftmaxWithLoss() # 損失層2

        # レイヤをリストに格納
        layers = [self.in_layer, self.out_layer]
        
        # 各レイヤのパラメータと勾配をリストに格納
        self.params = [] # パラメータ
        self.grads = []  # 勾配
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads

        # 単語の分散表現を保存
        self.word_vecs = W_in

    # 順伝播メソッドの定義
    def forward(self, contexts, target):
        # 重み付き和を計算
        h = self.in_layer.forward(target)
        
        # スコアを計算
        s = self.out_layer.forward(h)
        
        # 損失を計算
        l1 = self.loss_layer1.forward(s, contexts[:, 0])
        l2 = self.loss_layer2.forward(s, contexts[:, 1])
        loss = l1 + l2
        return loss

    # 逆伝播メソッドの定義
    def backward(self, dout=1):
        # Lossレイヤの勾配を計算
        dl1 = self.loss_layer1.backward(dout)
        dl2 = self.loss_layer2.backward(dout)
        ds = dl1 + dl2
        
        # 出力層の勾配を計算
        dh = self.out_layer.backward(ds)
        
        # 入力層の勾配を計算
        self.in_layer.backward(dh)
        return None


 実装したクラスを試してみましょう。やることは3.4.1項と同じです。

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

# 前処理
corpus, word_to_id, id_to_word = preprocess(text)
print(word_to_id)
print(id_to_word)
print(corpus)

# ウインドウサイズ
window_size = 1

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

# コンテキストとターゲットを作成
contexts, target = create_contexts_target(corpus, window_size)
print(contexts.shape)
print(target.shape)

# one-hot表現に変換
contexts = convert_one_hot(contexts, vocab_size)
target = convert_one_hot(target, vocab_size)
print(contexts.shape)
print(target.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
(6, 2)
(6,)
(6, 2, 7)
(6, 7)


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

# 2層のskypeのインスタンスを作成
model = SimpleSkipGram(vocab_size, hidden_size)

# 最適化手法のインスタンスを作成
optimizer = Adam()

# 学習処理のインスタンスを作成
trainer = Trainer(model, optimizer)

# 学習
trainer.fit(contexts, target, max_epoch, batch_size, eval_interval=2)
| epoch 1 | iter 2 / 3 | time 0[s] | loss 3.89
| epoch 2 | iter 2 / 3 | time 0[s] | loss 3.89
| epoch 3 | iter 2 / 3 | time 0[s] | loss 3.89
(省略)
| epoch 999 | iter 2 / 3 | time 0[s] | loss 2.14
| epoch 1000 | iter 2 / 3 | time 0[s] | loss 1.66


 損失の推移を見ましょう。

# 損失の推移をグラフ化
trainer.plot()

損失の推移


 単語の類似度を確認しましょう。

# 単語ベクトルを表示
word_vecs = model.word_vecs
for word_id, word in id_to_word.items():
    print(word, word_vecs[word_id])

# クエリを指定
query = 'you'

# 共起行列を用いた類似度の上位単語を表示
most_similar(query, word_to_id, id_to_word, model.word_vecs, top=6)
you [-0.00399093 -0.01740843  0.01117007  0.00167495 -0.00330359]
say [-1.4924841  -0.9265496  -0.06612136 -0.85639507  0.21594335]
goodbye [1.2914288  0.8205651  1.0868554  0.64892346 0.73790425]
and [-0.7078796 -1.2045625 -1.3328174 -1.2473851 -1.4656099]
i [1.2976283  0.7901488  1.0781299  0.6454606  0.77316916]
hello [-0.74642414  1.1192015  -0.25634232  1.364689    1.1631694 ]
. [-0.01523393  0.00943153  0.00037401  0.00478988  0.00889456]

[query] you
 say: 0.4562843441963196
 and: 0.1999540627002716
 i: -0.18465915322303772
 goodbye: -0.1908864974975586
 .: -0.27402305603027344
 hello: -0.4335971176624298

 データが少ないのでこの結果からは詳しいことは言えませんが、「I」との類似度が低いことからCBOWモデルよりも上手く表現できていないように思います。skip-gramの方がCBOWモデルよりも難易度の高い問題を解くため、必要なデータも多くなるのでしょう。

 以上でword2vecの基本的な仕組みを学びました。次章では、改良を加え本物のword2vecを実装します。

参考文献

おわりに

 3章完了!特に問題なし。

【次節の内容】

www.anarchive-beta.com