🔖

ゼロから作る DeepLearning 2 格闘日誌5/24 4章

に公開

ゼロから作るDeep Learning 2を友達と読んでいます.
自分用のまとめかつ,補充知識として重要なことを書いています.
読者対象はゼロから作るDeep Learning 実装が終わったくらいの方,Deep Learning 2を読んでいる方です.

4章 word2vecの高速化

CBOWモデルでは,コーパスの増加につれ計算時間コストが増大する
→word2vecの高速化に挑む

  • Embeddingレイヤを導入
  • 損失関数,Negative samplingを導入

https://zenn.dev/kumakuma_it/articles/94659eb3cccf88
において,CBOWモデルの構築を行った.
ボトルネックとなるのは,以下の処理.

  • 入力層のone-hot表現と重み行列W_{in}の積による計算(4.1)
  • 中間層と重み行列W_{out}の積及びSoftmaxレイヤの計算(4.2)

4.1 word2vecの改良1(Embeddingレイヤの導入)

4.4.1 Embedding レイヤ

One-hotベクトルと重み行列 W_{in} の積について:

One-hotベクトル \mathbf{x} のサイズ:語彙数 V
重み行列 W_{in} のサイズ:V \times D(Dは埋め込み次元)
本来,one-hotと行列の積は実質的に「行を選ぶだけ」の操作なので計算量は:

O(D)

実際の実装では,one-hotベクトルを生成せず,インデックスで重み行列から該当行(ベクトル)を抜き出す.

→Embeddingレイヤ:重みパラメータから単語IDに該当する列を抜き出すためのレイヤ

4.1.2 Embeddingレイヤの実装

import numpy as np
W = np.arange(21).reshape(7,3)
print(W)
print(W[2])
print(W[5])
実行結果
[[ 0  1  2]
 [ 3  4  5]
 [ 6  7  8]
 [ 9 10 11]
 [12 13 14]
 [15 16 17]
 [18 19 20]]

[6 7 8]
[15 16 17]
idx = [0,2,4,6]
print(W[idx])
実行結果
[[ 0  1  2]
 [ 6  7  8]
 [12 13 14]
 [18 19 20]]

Embeddingレイヤを実装した.

utils.py
class Embedding:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.idx = None #index
    
    def forward(self, idx):
        W, = self.params
        self.idx = idx
        out = W[idx]
        return out
    
    def backward(self, dout):
        dW, = self.grads
        dW[...] = 0
        dW[self.idx] = dout #悪い例
        return None

さて,悪い例の問題点を考える.
同じ行に加えたいとき(同じidxの指定が起きた時),2回書き換えが起こってしまうので,加算を行うことで解決

utils.py
    def backward(self, dout):
        dW, = self.grads
        dW[...] = 0 
        np.add.at(dW, self.idx, dout)
        #悪い例 dW[self.idx] = dout
        return None

以上よりEmbeddingレイヤの実装が完了した.

4.2 word2vecの改良2(Negative Samplingなど)

中間層と W_{out} の積および Softmax 計算について:
中間層ベクトルのサイズ:D
W_{out} のサイズ:D \times V

この計算のオーダーは:

O(D \cdot V)

そして Softmax の計算も語彙数 V に比例してかかる.

Softmax: O(V)(ただし事前のスコア計算も含めて O(D \cdot V)

Softmaxの代わりにNegative Samplingを行うことで,語彙数が多くなったとしても計算量を少なく一定に抑える.

4.2.1中間層以降の計算の問題点

  • 中間層のニューロンと重み行列W_{out}の積
  • Softmaxレイヤの計算→negative samplingで対象とする単語を絞る

4.2.2 多値分類から二値分類へ

Softmaxは全ての単語のスコアを基に,すべての単語の予測確率を算出→誤差を伝播していたのに対して,Negative Samplingは以下の戦略をとる.

  • 二値問題に絞る(特定の単語のYes/Noに絞る)
  • 間違いであると予測してほしい単語をいくつか選び,予測確率を算出→誤差を伝播することで不例についても学習させる

以下の低燃費化ストーリーが浮かんだ:
カウントベース(一気にすべての単語を学習)→推論ベース(≒Word2vec,バッチ単位で学習)→Negative Sampling(すべての単語について学習せず,複数の単語について学習)

4.2.3 シグモイド関数と交差エントロピー誤差

二値問題ならば簡単に解ける.
スコアをシグモイド関数で求め,交差エントロピー誤差によって損失を求めればいい.
シグモイド関数は

y = \frac{1}{1+ \exp{(-x)}}

交差エントロピー誤差(対象が2つであるだけで多値分類の場合と同じ)は

L = -(t \log{y} +(1-t)\log{(1-y)})

Sigmoid With Loss layerとして実装する.

4.2.2 多値分類から二値分類へ(実装編)

二値分類を行うword2vec(CBOWモデル)を実装する.
まずEmbeddingDotクラスを作成する

negative_sampling_layer.py
import numpy as np
from common.layers import Embedding

class EmbeddingDot:
    def __init__(self, W):
        self.embed = Embedding(W)
        self.params = self.embed.params
        self.grads = self.embed.grads
        self.cache = None
    
    def forward(self, h, idx):
        #ベクトルhとWのidx番目の行の内積計算
        target_W = self.embed.forward(idx)
        out = np.sum(target_W * h, axis=1)
        self.cache = (h, target_W) #backwardのために保存
        return out
    
    def backward(self, dout):
        h, target_W = self.cache
        dout = dout.reshape(dout.shape[0],1) #次元を追加


        dtarget_W = dout * h
        self.embed.backward(dtarget_W) #自分自身に変化分を適用
        dh = dout * target_W
        return dh #先のレイヤに誤差を伝播

4.2.5 Negative Sampling

誤った答え(不例)についても学習させる(予測確率を低下させる)ために,いくつかの不例についても同様の計算を行う.

4.2.6 Negative Samplingのサンプリング手法

確率的に多く登場する単語に対する不例はそれだけ多くカバーしたい
→ランダムサンプリングではなく,コーパス統計データに基づき登場する確率の高い単語を多くサンプリングする

出現確率の低い単語についてもサンプリングされるために確率分布に0.75乗する.(数字に理論的意味はない)

(本書の方針に従い,UnigramSamplerクラスを使用し,実装は行わない)
UnigramSampler(corpus, power, sample_size)
→sample_size個分の不例を,corpusに基づいて出力してくれる

4.2.7 Negative Samplingの実装

UnigramSamplerをもちいて不例をサンプリングできる.これを利用する.
正例と不例(with UnigramSampler)を用意し,順伝播(損失関数の計算)を以下の通り実装した.

negative_sampling_layer.py
class NegativeSamplingLoss:
    def __init__(self, W, corpus, power=0.75, sample_size=5):
        self.sample_size = sample_size
        self.sampler = UnigramSampler(corpus, power,sample_size)

        self.loss_layers = [SigmoidWithLoss() for _ in range(sample_size + 1)]
        self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]

        self.params, self.grads = [], []
        for layer in self.embed_dot_layers:
            self.params += layer.params
            self.grads += layer.grads
    
    def forward(self, h, target):
        batch_size = target.shape[0]
        negative_sample = self.sampler.get_negative_sample(target)

        #正例のforward
        score = self.embed_dot_layers[0].forward(h, target)
        correct_label = np.ones(batch_size, dtype=np.int32)
        loss = self.loss_layers[0].forward(score, correct_label)

        #不例のforward
        negative_label = np.zeros(batch_size, dtype=np.int32)
        for i in range(self.sample_size):
            negative_target = negative_sample[:, i]
            score = self.embed_dot_layers[1+i].forward(h, negative_target)
            loss += self.loss_layers[1+i].forward(score, negative_label) #ベクトル型
        
        return loss

forwardメソッドは中間層のニューロンh, 正例のターゲットtargetを受け取り,self.samplerで不例をサンプリングし,順伝播を行う.

repeatノードの場合、逆伝播は足し合わせる.(ノードの形を見れば一目瞭然)第一章の復習が必要だと感じた.これが分かればどんなノードでも実装できそう.

以上より,Negative Samplingの実装が完了した.

4.3 改良版word2vecの学習

PTBデータセットを用いて,学習を行ってみよう.

4.3.1 CBOWモデルの実装

ここまで作成してきたEmbeddingレイヤ(分散表現を取り出すのが高速)とNegative Sampling Lossレイヤ(すべての単語についての勾配を伝播するのをやめたので高速)を用いて,CBOWモデルのword2vecを実装する.
基本的には,前章で作成したCBOWモデルを拡張する形で実装する.

cbow.py
class CBOW:
    def __init__(self, vocab_size, hidden_size, window_size, corpus):
        V,H = vocab_size, hidden_size

        #init weight
        W_in = 0.01 * np.random.randn(V,H).astype('f')
        W_out = 0.01 * np.random.randn(V,H).astype('f') #注意!CBOWと違う形

        #init layer
        self.in_layers = []
        for i in range(2*window_size):
            layer = Embedding(W_in)
            self.in_layers.append(layer)
        self.ns_loss = NegativeSamplingLoss(W_out, corpus, power=0.75, sample_size=5)

        layers = self.in_layers + [self.ns_loss]
        self.params, self.grads = [],[]
        
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads
        
        self.word_vecs = W_in

cbowのイニシャライザが受け取る引数は,語彙数vocab_size,中間層のニューロン数hidden_size,単語のIDリストcorpus,コンテキストサイズwindow_sizeの4つである.
Embeddingレイヤは2* Window_size分(前側と後ろ側で2倍)である.
その後,Negative Sampling Lossレイヤを作成.

4.3.2 CBOWモデルの学習コード

CBOWモデルの学習コードを実装した.

4_3_1cbow.py
import sys
sys.path.append('..')
import numpy as np
from common import config
import pickle
from common.trainer import Trainer
from common.optimizer import Adam

from cbow import CBOW
from common.utils import create_context_target, convert_one_hot, to_cpu, to_gpu
from dataset import ptb

#hyper param
window_size = 5
hidden_size = 100
batch_size = 100
max_epoch = 10

#load data
corpus, word_to_id, id_to_word = ptb.load_data('train') 
vocab_size = len(word_to_id)

contexts, target = create_context_target(corpus, window_size)

if config.GPU:
    contexts, target = to_gpu(contexts), to_gpu(target)

#generate model
model = CBOW(vocab_size,hidden_size,window_size, corpus)
optimizer =Adam()
trainer = Trainer(model, optimizer)

#learning
trainer.fit(contexts,target,max_epoch,batch_size)
trainer.plot()

word_vecs = model.word_vecs
if config.GPU:
    word_vecs = to_cpu(word_vecs)

params = {}
params['word_vecs'] = word_vecs.astype(np.float16)
params['word_to_id'] = word_to_id
params['id_to_word'] = id_to_word
pkl_file = 'cbow_params.pkl'
with open(pkl_file, 'wb') as f:
    pickle.dump(params, f, -1)

4.3.3 CBOWモデルの評価

以下のように,most_similarを用いてどのような単語と距離が近いか確認してみる.

4_3_3eval.py
import sys
sys.path.append('..')
from common.util import most_similar, analogy
import pickle

pkl_file = 'cbow_params.pkl'
with open(pkl_file, 'rb') as f:
    params = pickle.load(f)
    word_vecs = params['word_vecs']
    vocab_size = len(params['id_to_word'])
    word_to_id = params['word_to_id']

querys = ['you', 'year', 'car', 'toyota']
for query in querys:
    most_similar(query, word_to_id, id_to_word, word_vecs, top=5)

また,

4_3_3eval.py
analogy('king','man','queen',word_to_id,id_to_word,word_vecs)
analogy('take','took','go',word_to_id,id_to_word,word_vecs)
analogy('car','cars','child',word_to_id,id_to_word,word_vecs)
analogy('good','better','bad',word_to_id,id_to_word,word_vecs)

[query] you
 we: 0.7568359375
 i: 0.7109375
 anything: 0.6015625
 your: 0.5849609375
 they: 0.5830078125

[query] year
 month: 0.85205078125
 week: 0.77587890625
 summer: 0.76416015625
 spring: 0.73388671875
 decade: 0.68505859375

[query] car
 truck: 0.6328125
 luxury: 0.62060546875
 window: 0.61767578125
 auto: 0.60009765625
 cars: 0.5830078125

[query] toyota
 honda: 0.671875
 seita: 0.654296875
 engines: 0.64404296875
 chevrolet: 0.64111328125
 nissan: 0.60791015625

[analogy] king:man = queen:?
 woman: 5.08203125
 kid: 4.55859375
 wife: 4.53515625
 a.m: 4.453125
 naczelnik: 4.4296875

[analogy] take:took = go:?
 eurodollars: 4.3828125
 went: 4.25390625
 were: 4.24609375
 began: 4.10546875
 came: 3.9140625

[analogy] car:cars = child:?
 a.m: 6.93359375
 rape: 5.41796875
 children: 5.23046875
 incest: 4.94921875
 adults: 4.859375

[analogy] good:better = bad:?
 more: 5.71875
 rather: 5.32421875
 less: 5.0
 greater: 3.916015625
 faster: 3.81640625

car:cars = child:?
に対して
a.m: 6.93359375
rape: 5.41796875
children: 5.23046875
incest: 4.94921875
adults: 4.859375
が並んでいたり、突っ込みどころはあるがかなり良い分散表現が得られているといえるだろう.

4.4 word2vecに関する残りのテーマ

4.4.1 word2vecを使ったアプリケーションの例

転移学習:何らかの事前学習を行ったモデルを他のタスクに転用し,(ファインチューニングすること)
単語の分散表現の利点:総和を求めるなどして文章のベクトルを得ることなどができる

4.4.2 単語ベクトルの評価方法

文法/意味的な類推問題を出題し正答率による評価を行う.
アプリケーションの性能の良さとこの類推問題の成績は必ずしも相関するわけではないことに注意.

4.5 まとめ

  • Embedding レイヤ 単語の分散表現の抽出を効率化
  • Negative Samplingすべての単語の勾配を計算することをやめ,一部の単語を対象に計算,最適化
  • 転移学習に有用

Discussion