"BOKU"のITな日常

BOKUが勉強したり、考えたことを頭の整理を兼ねてまとめてます。

Pythonでテキストの単語を「単語の意味は周囲の単語によって形成される」ルールでベクトル化する

f:id:arakan_no_boku:20210912183619p:plain

目次

ベクトル化ってどういうこと?

ベクトルは向きと大きさを持つ量です。

大きさだけを持つ量がスカラーです。

そのように学校では習いました。

でも、その定義を厳密に考え出すと迷うので、今回の「単語のベクトル化」で何をするかといえば、ある単語(例えば「私」みたいな)を[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}

生のカウントのベクトルを相互情報量を使って精度UP

上記のように生のカウントした数をそのまま使うのは、わかりやすいですが、ちょっと精度的に問題があり、普通は、確率的に処理して、もう少し上等のベクトルにします。

その方法を「相互情報量(PMI)」といいます。

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

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

としたら、「P(僕、君)をP(僕)×P(君)で割り、その結果の2の対数をとる」という方法で計算することで、データとしての精度をあげるわけです。

実際にpythonで計算してみます。

インプットは上記で作成したベクトルを使ってます。

僕={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}

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」はようするに「無駄」です。

こういう(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]]

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

単語と関連づけると。

僕  :[-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

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

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

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

今回のやり方はベクトル化では、一番わかりやすい方法なのですが、実践的な面では難があります。

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

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

なので、その制約を回避するWord2Vecのような「推論ベース」のベクトル化が実践的には使われることになるわけです。

ということで。

今回はこんなところで。

ではでは。