「ゼロから作る Deep Learning 2 自然言語処理編」の読書まとめ。後で思い出すためのメモです。コードは以下。図は転記しません。本を片手に読む感じ。
3章 word2vec
いよいよ本番です。ニューラルネットワークを使った分散表現の取得です。
カウントベースの手法の問題点
計算コストが高い。語彙数が増えたときの計算量が現実的ではない点が大問題です。また全データを一括で処理せざるを得ず、データの更新時などやり直しになってしまいます。に対して推論ベースではデータを小分けにして処理できるのでちまちま更新でき、また並列計算も可能になります。
推論ベースの手法の概要
文字通り推論です。
you ? goodbye and I say hello.
の?を推論します。モデルを通してコンテキストとなる「you」と「goodbye」から「?」を推論します。この結果得られる重み係数が分散表現ということになります。
ニューラルネットワークにおける単語の処理方法
まずは単語を先ほどと同様に単語IDを使ったone-hot表現にします。ベクトルの大きさは語彙数になります。これをモデルの入力とするのでニューロンの数が固定となります。
これを全結合層で変換することを考えます。入力の重み付き和を計算し中間層に入れます。
ニューロン数が7つ(語彙数が7)で中間層のニューロンが3つの場合重みは7×3の行列になります。とはいえ入力のニューロンは0 or 1でしかも1は必ず1つなので、行列の積とはいえ1が立っている行を抜き取る作業にしかなっていません。行列の積では計算としてはやや冗長です。この先の工夫でここは是正します。
シンプルなword2vec
word2vecを実現するには、CBOW (continuous bag-of-words) とskip-gramという2つのモデルのニューラルネットワークがあります。word2vecは厳密にはモデルの名前ではありません。
CBOWモデルの推論処理
google翻訳させると[継続的な一言]だそうです。bag-of-wordsは一言一言って意味のようです。これは先ほどのように左右の単語から真ん中を推定するモデルになります。
入力層ではone-hotで表現された左側の単語と右側の単語の2つに対して、同じ重みW_inを用いて計算し、結果を平均(足して2で割る)します。絶対値に意味がなければ加算ととらえてもよいのだと思います。
出力層ではW_outの重みを用いて、はさまれた単語の確率を出します。セオリー通りSoftmaxを使います。
このモデルを学習することでW_inに分散表現が現れてきます。ここがスゴイところ。結果このW_inには単語の意味がうまくエンコードできているということになります。
モデルとしてはシンプルで行列積を3回とるだけのレイヤー構造になります。
c0 = np.array([[1, 0, 0, 0, 0, 0, 0]]) c1 = np.array([[0, 0, 1, 0, 0, 0, 0]]) # 重みの初期化 W_in = np.random.randn(7, 3) W_out = np.random.randn(3, 7) # レイヤの生成 in_layer0 = MatMul(W_in) in_layer1 = MatMul(W_in) out_layer = MatMul(W_out) # 順伝播 h0 = in_layer0.forward(c0) h1 = in_layer1.forward(c1) h = 0.5 * (h0 + h1) s = out_layer.forward(h)
シンプルで直感的です。このモデルには活性化関数が登場しません。そんなもんらしいです。トリッキーなところとしては入力が2つ(今回のコンテキストは左右1単語だから)で同じ重みを共有する点。
CBOWモデルの学習
最後出力を確率表現にするためにSoftmaxをはさみます。このようにして得られた確率と正解ラベルとの誤差を使って、W_inとW_outを学習させます。この学習の結果、単語の意味的、文法的な点で人間の直感と一致してくるようです。ただ学習データ(コーパス)次第なところもあり、「スポーツ」の記事と「音楽」の記事では得られる分散表現は異なってきます。
Lossは交差エントロピー誤差を使います。Softmaxとセットで使うのは定番です。
word2vecの重みと分散表現
学習の結果W_inとW_outの2つの分散表現が得られます。ただ分散表現としてはW_inの重みだけ使う。というのがポピュラーな使い方のようです。とはいえW_outが無意味というわけではなさそうですが。
学習データの準備
ここでは前述の You say goodbye and I say hello. をコーパスとして使います。
コンテキストとターゲット
ネットワークの入力はコンテキストで、正解ラベルはコンテキストに挟まれた単語(ターゲット)です。なので
[context] [target] [you, goodbye] [say] [say,and] [goodbye] [goodbye, I] [and] [and, say] [I] [I, hello] [say] [say, .] [hello]
こうなります。コンテキストからターゲットを推論します。単語じゃ扱えないので単語IDを付与します。sayだけ重複します。
[[0 2] [1 [1 3] 2 [2 4] 3 [3 1] 4 [4 5] 1 [1 6]] 5]
コンテキストは2次元です。ターゲットは1次元です。これを発生させるコードではwindows_sizeの変数で両隣だけではなく、その向こうもコンテキストとして扱えるようにしています。
コンテキストはID列なのでこれをone-hot表現に変換します。今回IDが7つなので、
[[0 2] [1 [[[1 0 0 0 0 0 0] [0 0 1 0 0 0 0]] [[0 1 0 0 0 0 0] [1 3] 2 [[0 1 0 0 0 0 0] [0 0 0 1 0 0 0]] [0 0 1 0 0 0 0] [2 4] 3 [[0 0 1 0 0 0 0] [0 0 0 0 1 0 0]] [0 0 0 1 0 0 0] [3 1] 4 [[0 0 0 1 0 0 0] [0 1 0 0 0 0 0]] [0 0 0 0 1 0 0] [4 5] 1 [[0 0 0 0 1 0 0] [0 0 0 0 0 1 0]] [0 1 0 0 0 0 0] [1 6]] 5] [[0 1 0 0 0 0 0] [0 0 0 0 0 0 1]]] [0 0 0 0 0 1 0]]
こうなります。次元数は(6,2,7)と(6,7)になります。6個のミニバッチの学習データはこれで完成です。
CBOWモデルの実装
コードを見るのが一番です。イニシャライザはひとまず置いといて、順伝播と逆伝播を読み解きます。まずは順伝播
def forward(self, contexts, target): h0 = self.in_layer0.forward(contexts[:, 0]) h1 = self.in_layer1.forward(contexts[:, 1]) h = (h0 + h1) * 0.5 score = self.out_layer.forward(h) loss = self.loss_layer.forward(score, target) return loss
layer0と1の結果を平均して、次のレイヤに渡している様子がわかります。学習データは6,2,7だったので、0番目と1番目のコンテキストをそれぞれのレイヤに渡しています。
続いて逆伝播
def backward(self, dout=1): ds = self.loss_layer.backward(dout) da = self.out_layer.backward(ds) da *= 0.5 self.in_layer1.backward(da) self.in_layer0.backward(da) return None
今度はLossを受けて、W_outの更新量を求め、その結果を半分にしたのちに同じ値をlayer0と1に渡しています。Noneを返す理由はわかりません。書籍の図と合わせて眺めると本当に同じことをしている様子がわかります。学習コードの読み解きはいったん省略。
CBOWモデルと確率
Lossの式の解釈の話です。今回クロスエントロピーでLossを求めていますが、これを確率の話として解釈して式をシンプルに理解しよう。という話です。
記述のルールとして\(P(A,B)\)を「AとBが同時に起こる確率」、\(P(A \mid B)\)は「Bという情報が与えられたときにAが起こる確率」ということにします。
左右の単語\(W_{t-1}\)と\(W_{t+1}\)が与えられたときに、真ん中が\(W_t\)である確率は、
$$ P(W_t\mid W_{t-1},W_{t+1}) $$
と表現できます。今度はLoss関数の定義に戻ります。クロスエントロピー誤差は
$$ L=-\sum_k t_k\log y_k $$
であり、\(y_k\)はsoftmaxを通過後の確率表現なので、k番目に対応する事象が起こる確率となります。\(t_k\)はone-hotで正解以外は0なので、\(W_t\)以外の要素がゼロになって、結果として、
$$ L=-\log P(W_t\mid W_{t-1},W_{t+1}) $$
となります。このケースでクロスエントロピー誤差は得られた確率にlogをとりマイナスにしたものであり、これを負の対数尤度と呼ばれます。尤度とは尤(もっと)もらしい度合を意味する表す概念で、確率よりも少し広い概念となります。合計1にならない点で確率よりは自由度が高いのかもしれません。マイナスを付けたのも最小化問題にしたかったため。掛け算連発になるのを足し算にしたいから対数をとった。などいろいろ合理的な理由があるようです。
skip-gramモデル
word2vecは2モデル提案されており、CBOWともう一つがskip-gramです。CBOWが左右から真ん中を推定するのに対して、skip-gramは真ん中から左右を推定するモデルです。モデルの形も逆向きになっていて、入力層が一つで出力層が2つになります。
モデルはひとまずよいとして、Loss関数に着目します。Lossは出力層のsoftmax結果に対してクロスエントロピー誤差を出し、それらを足します。足すでいいんか~い。というのは前述のLogで確率をくくっているからです。
中心から両側の単語を推定し、それらが同時に起こる確率が本来求めたい確率になります。式にすると、
$$ P(W_{t-1},W_{t+1}\mid W_t) $$
です。\(W_t\)という条件が与えられたときに\(W_{t-1}\)と\(W_{t+1}\)が同時に起こる確率です。この2つが独立の場合同時確率は掛け算で表現できます。
$$ P(W_{t-1},W_{t+1}\mid W_t) = P(W_{t-1}\mid W_t)P(W_{t+1}\mid W_t) $$
これをlogでくくれば掛け算が足し算になるというわけで、コーパスの大きさだけ掛け算が足し算になり、Lossを足し合わせるという一見乱暴な計算のつじつまが合います。
じゃあCBOWとskip-gramのどっちがいいのよ?という話ですが、得られる分散精度はskip-gramの方がよいとされているようです。ただ学習速度はCBOWの方が速いです。Lossの計算コストが半分なので。左右の単語を同時に推論しないといけないので、そりゃskip-gramの方がタフな問題に取り組んでますしね。
カウントベース v.s. 推論ベース
カウントベースは新しい単語が追加されたときに初めから計算やり直しになったりするのでその点イマイチです。また分散表現の性質としても、カウントベースでは類似度がエンコードされるのに対して、推論ベースでは類似度に加えて単語間のパターンもとらえられるようです。類似度に関しては優劣付けられないそうです。
この2つの手法で作られる行列の間には因果関係があるらしい。という論文もあるそうです。
4章 word2vecの高速化
語彙数が多くなるとこのやり方では計算量が多く実用的ではないです。具体的な課題は
・入力層での行列の積
・中間層の行列の積およびSoftmaxの計算
この2つ。一つ目はEmbeddingレイヤの導入、二つ目はNegative Samplingで解決させます。
Embeddingレイヤ
one-hotベクトルと行列の積なのでやっていることは特定の行を抜き出しているだけ。他がゼロなので。なので、乗算と加算をやめて抜き出すだけのレイヤを作る。ということになります。
順伝播は何もせずに抜き出し。となると逆伝播も乗算はなく、勾配をそのまま流すだけ。勾配を格納する行列の指定のindex(単語)に入れるだけです。ただし逆伝播ではバッチ単位で同じ単語の勾配が重複し得るので、代入ではなく要素の加算で行うようです。
def forward(self, idx): W, = self.params self.idx = idx out = W[idx] # 取り出してるだけ return out
計算に使ったindexをbackwardで使うために保持しています。1倍しかしないので使ったindexを覚えるだけでよいです。このidxも配列です。このコードで複数idx同時に抜き出しができます。Wは語彙数分だけ大きさがあります。
def backward(self, dout): dW, self.grad dW[…] = 0 for i, word_id in enumerate(self.idx): dW[word_id] += dout[i] return None
indexは重複を許す複数持つ配列です。dWも語彙数分あります。ちなみにテクニック的なところで
for i, word_id in enumerate(self.idx): dW[word_id] += dout[i] np.add.at(dW, self.idx, dout)
でも同じ計算がfor文なしで書けるそうです。
Negative Sampling
2つの単語youとgoodbyeの間単語sayを推論するにあたり、「sayである確率が1になり、且つほかのすべての語彙で確率が0になるように学習する。」というタスクから「sayである確率が大きくなり、他のいくつかの語彙で確率が小さくなるように学習する。」というタスクに切り替えることで計算量を激減させる方法です。
これにより中間の行列演算が小さくなる(正解といくつかの間違いの分)のに加え、全語彙数を使ったSoftmaxの計算をよりシンプルなSigmodに置き換えることができます。
何せ出力が語彙数だけあった行列が、正解と数個の間違いだけの行列になるのでかなり計算はさぼれます。直感的にこの限定した範囲でSoftmax使ってもいいんじゃないかなぁとも思うのですが、Sigmoidを使って計算するらしいです。
Softmax+クロスエントロピー誤差は恐ろしくシンプルな逆伝播になりますが、Sigmoid+クロスエントロピー誤差も同じようにシンプルになります。
順伝播は中間層のデータからW_outの正解となるindexの行とだけ積和すればよいので、
def forward(self, h, idx): target_W = self.embed.forward(idx) # 上のEmbeddingレイヤのforwardを取り出しに使っているだけ out = np.sum(target_W * h, axis=1) self.cache = (h, target_W) return out
こんな感じになります。特定の位置のWとh(サイズは同じ)を掛け算してsumをとっています。ミニバッチで同時に複数計算する前提でaxis=1の方向でsumをとっています。
def backward(self, dout): h, target_W = self.cach dout = dout.reshape(dout.shape[0], 1) dtarget_W = dout * h self.embed.backward(target_W) dh = dout * target_W return dh
これを正解では1を正解ラベルに、いくつかの間違いでは0を正解ラベルにして、Lossをそれぞれ求めそれらを加算したものを最終的なLossにするようです。
ではこのいくつかの間違い。をどのように選ぶかですが、完全ランダムではいい結果が出ません。そりゃまぁそんな気がします。激レア単語の出現頻度を0に向けて学習したところでうまくいかないです。なので頻出単語を重点にランダムに選ぶのがよいです。ただし今度はこれだと激レアが全然選ばれないのもそれはそれで問題なので、確率に細工するのがいいようです。具体的には確率に0.75乗するってのが使われるようです。確率の総和が1になるようにすべての発生確率に0.75乗したうえで、総和が1になるように正規化します。
これを組み合わせてCBOWのモデルを作るのですが、コードは省略。
このようにして作られた分散表現は類似度を求めるだけでなく、転移学習にも使えます。またベクトル化できればその出力をほかのモデルへの入力としても使えます。スパムメールの抽出とかそんな問題や分類などにもこの表現をもとにしたモデルを使うことができます。たいそう便利です。
コメント