アラカン"BOKU"のITな日常

文系システムエンジニアの”BOKU”が勉強したこと、経験したこと、日々思うことを書いてます。

自然言語処理を「なんちゃって」で割り切って簡単に考えてみる(3)

今回は、「ベクトル化」ってのを整理してみます。 

文字列を数値化して、DeepLearningで学習可能なデータにする方法のひとつです。

 

ベクトル。

なにやら難しげです。

ここでは、例えば、「私」という単語を、{0,1,0,0,1,1,0,0}みたいな形で表現することを「ベクトル化」と呼んでます。

 

文字列をベクトル化するルール(方法)とは

 

問題は。

どういうルール(方法)で「私」をベクトル「{0,1,0,0,1,1,0,0}」に変換するかです。

いろいろなルール(方法)が研究されています。

今回は、一番多く採用されている「単語の意味は周囲の単語によって形成される」というルール(方法)を使います。

これがどういうことか。

例文「僕は君の笑顔が好き。君は僕の何が好き?」でやってみます。

簡単なサンプルプログラムで説明しようかと思います。

ですが、前提があるので、先にちょっとだけ、補足します。

単語の意味は周囲の単語によって・・とあるわけです。

これは、例えば上記文の「笑顔」という単語は両隣にある「君の」と「が好き」という言葉で意味が説明されていると考えれば、感覚的に、なんとなくわかります。

でも、人間なので意味が通じるように「君の」とか「が好き」なんて適当に判断できますが、プログラムではそうもいきません。

そのまま処理すると「君」「の」「笑顔」「が」「好き」のように分割されてしまうので、実際には、「好き」は「の」と「が」という単語で意味が説明される・・みたいなことになってしまいます。

わけがわかりません。

ここが英語と日本語の違いです。

日本語特有の、単語の間の「は」「の」「が」などの助詞が、上記のように邪魔になるわけですね。

だから、日本語の場合は、とりあえず邪魔な「は」「の」「が」みたいな助詞以外だけ取り出すなどの工夫が必要になります。

巷の自然言語の本では、英語ベースで説明しているものが意外と多く、こういうところの説明がはしょられていて、そのままやると上手く行かない時があります。

自分も最初、気づくまでちょっとはまりました。

以上が、補足です。

それを踏まえて、以下のサンプルでは「名詞・動詞・助動詞」だけ抜き出すようにしてます。

# -*- coding: utf-8 -*-
from janome.tokenizer import Tokenizer
import re

pat = r'名詞|動詞|助動詞'
regex = re.compile(pat)

t = Tokenizer()
malist = t.tokenize("僕は君の笑顔が好き。君は僕の何が好き?")
for n in malist:
    if(regex.match(n.part_of_speech)):
        print(n.surface,end=' ')

 この処理の結果はこうです。

僕 君 笑顔 好き 君 僕 何 好き

 ここに

登場する単語を登場順にならべると、「僕、君、笑顔、好き、何」の5つです。

この5単語に適当にインデックスをつけます。

ざっくり「0:僕、1:君、2:笑顔、3:好き、4:何」みたいな感じです。

そして、「基準となる単語の前後にある単語をカウントする」というルールで表をつくるとこんな感じですかね。

0:僕 1:君 2:笑顔 3:好き 4:何
0:僕 0 2 0 0 1
1:君 2 0 1 1 0
2:笑顔 0 1 0 1 0
3:好き 0 1 1 0 1
4:何 1 0 0 1 0

 元の文章のエッセンスが「僕 君 笑顔 好き 君 僕 何 好き」です。

ここで、「僕」の両隣を赤字で表します。

そうすると、「 笑顔 好き 好き」なので「君が2、何が1」。

そして、「君」に着目して同じようにすると。

今度は「 笑顔 好き 何 好き」なので、「僕2、笑顔1、好き1」ですね。

上記表ではそれにそって数字をうめてるわけです。

これで、一応、「カウントベースの手法で以下のように単語をベクトル化した」ってことになります。

僕={0,2,0,0,1}
君={2,0,1,1,0}
笑顔={0,1,0,1,0}
好き={0,1,1,0,1}
何={1,0,0,1,0}

 

生のカウントをそのまま使うのは、いまいちよろしくない

 

実際には。

上記のように生のカウントした数を、そのまま使ったりはしません。

英語の例だと、単純にカウントで判断すると「the」とか「a」とかの頻繁に表れる単語が常に高い関連性を持ってしまうのでよろしくない・・みたいに説明されてます。

まあ、日本語でも、それに近いことはあるんでしょう。

なので、確率的に処理して、もう少し上等のベクトルにする方法があります。

相互情報量(PMI)といいます。

例えば、上記の例で「僕」と「君」という2つの単語に着目すると。

  • 僕が登場する確率→P(僕)
  • 君が登場する確率→P(君)
  • 僕と君が同じ文章内に同時に登場(共起するといいます)する確率→P(僕、君)

としたら、「P(僕、君)をP(僕)×P(君)で割り、その結果の2の対数をとる」という方法で計算します。

理屈が完全に納得できないとしても、まあPMIを計算することで、ただカウントしただけより「よりマシなベクトル」にできると理解しときます。

実際に上記のベクトルをpythonで計算してみます。

import numpy as np

ar_1 = [[0,2,0,0,1],[2,0,1,1,0],[0,1,0,1,0],[0,1,1,0,1],[1,0,0,1,0]]
eps = 1e-8
cm = np.array(ar_1)
pmi = np.zeros_like(cm,dtype=np.float32)
nsum = np.sum(cm)
ssum = np.sum(cm,axis=0)
for i in range(cm.shape[0]):
    for j in range(cm.shape[1]):
        pw = np.log2(cm[i,j] * nsum / (ssum[j] * ssum[i]) + eps)
        pmi[i,j] = max(0,pw)
print(pmi)

出力結果です。

[

[0. 1.2223924 0. 0. 1.2223924 ]
[1.2223924 0. 0.8073549 0.22239244 0. ]
[0. 0.8073549 0. 1.2223924 0. ]
[0. 0.22239244 1.2223924 0. 1.2223924 ]
[1.2223924 0. 0. 1.2223924 0. ]

うん。

なんか、見た目も「よりよい単語ベクトル」っぽいです。

実際、そうみたいですしね。

 

さらに無駄を取り除いて、ノイズに強いベクトルにする

 

上記のより良い「単語ベクトル」ですが、まだ改善の余地があります。

何かというと「0」の場所が多いこと。

こういう「0」・・ようするに無駄・・が多いデータはノイズに弱いそうです。

それを改善するために「次元圧縮」ということをします。

次元圧縮の手順は、ざっくり、以下のとおりです。

  • 特異値分析(SVD)して、特異値の大きい順(ざっくり言えば、重要な要素順)に並べなおす。
  • 特異値の大きいものから任意の数のみ抽出する

簡単にイメージをかくと。

僕=[0 , 1.2223924, 0 , 0 ,1.2223924 ]

この、5列(5次元)の単語ベクトルに、特異値分析(SVD)という計算をしかけると。

僕=[-0.45890802  0.5310011  -0.3688437  0.609104  -0.01969519]

という5次元のベクトルに変換される。

この結果は前の方が「重要な要素」になっているので、ノイズに強くするために、前の2列分だけ取り出して「2次元に次元圧縮」して、以下のようにする。

[-0.45890802 0.5310011 ]

てな感じですかね。

pythonでサンプル的にやってみます。

import numpy as np

ar_1 = [[0,2,0,0,1],[2,0,1,1,0],[0,1,0,1,0],[0,1,1,0,1],[1,0,0,1,0]]
eps = 1e-8
cm = np.array(ar_1)
pmi = np.zeros_like(cm,dtype=np.float32)
nsum = np.sum(cm)
ssum = np.sum(cm,axis=0)
for i in range(cm.shape[0]):
    for j in range(cm.shape[1]):
        pw = np.log2(cm[i,j] * nsum / (ssum[j] * ssum[i]) + eps)
        pmi[i,j] = max(0,pw)
print("PMI処理後")
print(pmi)
U,S,V = np.linalg.svd(pmi)
print("SVD処理後")
print(U)
goal = U[:,:2]
print("次元圧縮後")
print(goal)

実行した結果です。 

 PMI処理後
[[0. 1.2223924 0. 0. 1.2223924 ]
[1.2223924 0. 0.8073549 0.22239244 0. ]
[0. 0.8073549 0. 1.2223924 0. ]
[0. 0.22239244 1.2223924 0. 1.2223924 ]
[1.2223924 0. 0. 1.2223924 0. ]]

SVD処理後
[[-0.45890802 0.5310011 -0.3688437 0.609104 -0.01969519]
[-0.41171417 -0.29887974 0.53542835 0.25440666 -0.6243045 ]
[-0.38795787 -0.1713638 -0.61455464 -0.5281229 -0.40439087]
[-0.48511937 0.4878484 0.4430743 -0.5141807 0.2568395 ]
[-0.48377967 -0.60112154 -0.05725896 0.14482397 0.61673135]]

次元圧縮後
[[-0.45890802 0.5310011 ]
[-0.41171417 -0.29887974]
[-0.38795787 -0.1713638 ]
[-0.48511937 0.4878484 ]
[-0.48377967 -0.60112154]]

 

この「次元圧縮後」の単語ベクトルが、最終的に使うものになるのですね。

 

単語ベクトルを使って「類似の単語」を判断する

 

単語をベクトル化できたら、とりあえず計算で「類似の単語」を判断したりできるようになります。

ベクトルの類似を計算する最も有名な方法は「コサイン類似度」です。

計算式とかはこちらをどうぞ。

2つのベクトルがどのくらい同じ方向を向いているか?で比較するものです。

計算式はややこしいですが、pythonで書くと簡単です。

単語ベクトル「x」と「y」を比較する式はこんな感じ。

    eps = 1e-8
    nx = x / (np.sqrt(np.sum(x ** 2)) + eps)
    ny = y / (np.sqrt(np.sum(y ** 2)) + eps)
    return np.dot(nx, ny)

 

実際に、上記の次元圧縮後のベクトルの、僕と君で計算してみます。

goal[0] が「僕」で[-0.45890802 0.5310011 ]という単語ベクトル。

goal[1]が「君」で[-0.41171417 -0.29887974]という単語ベクトルです。

eps = 1e-8
nx = goal[0] / (np.sqrt(np.sum(goal[0] ** 2)) + eps)
ny = goal[1] / (np.sqrt(np.sum(goal[1] ** 2)) + eps)
print(np.dot(nx, ny)) 

結果として求められる値は以下です。

0.08467308

ようするに、こんな感じである単語のベクトルと、他の単語のベクトルを計算して、結果の大きい順にならべれば「類似度の高い順」になったりします。

Word2Vecのデモなんかで、キーワードの単語を入力して、類似する単語をリストするみたいなものがありますが、それは、こういうことをやっているということです。

 

ベクトルの求め方には「推論ベース」もあります

 

今回は、ベースになるベクトルを求めるのに、単語の両隣にある単語を数えるやり方でやりました。

でも、このやり方だと「全部をまとめて処理しないといけない」ので、文章データが大きくなって、対象の単語が増えてくるとエライことになります。

単語が10万語でてきたら、10万×10万の行列を処理しないといけないとか・・。

なので、DeepLearning等を使って小さい単位にわけて処理する(ミニバッチです)ことで、その制約を回避する「推論ベース」のベクトル化が実践的には使われます。

これを簡単に説明するのは困難なのでふれませんが、非常に有名な「Word2Vec」とかがそうです。

ちなみにWord2Vecについては、Neural Network Librariesに付属のサンプルソースを少しだけ変更して、任意の日本語文章を処理したのを別記事に書いてますので、興味があれが参照ください。

arakan-pgm-ai.hatenablog.com

arakan-pgm-ai.hatenablog.com

arakan-pgm-ai.hatenablog.com

 

以上でこの回は終わりです。