前回の記事から引き続き、gensim の phrases モデルの話です。
参考(前回の記事): gensimでフレーズ抽出
参考(公式ドキュメント): models.phrases – Phrase (collocation) detection — gensim
参考(Githubのソース): gensim/phrases.py at master · RaRe-Technologies/gensim · GitHub
前回の記事でこのモデルの使い方を紹介しました。このモデルは連続して出現した単語のペアに対してスコアを計算して、そのスコアが指定した閾値を超えたらその単語のペアをフレーズとして抽出しているのでした。この時に使われているスコアの計算式を具体的に見ていこうという記事です。このスコアの計算式は用意されているものが2種類と、後自分で作ったオリジナルの関数もつかえますが一旦用意されている2種類を見ていきます。
データは前回の記事と同じものを使います。形態素解析(分かち書き)まで終わったデータが、データフレームに格納されているものとします。
計算に使われる引数について
当然計算式はそれぞれ違うのですが、関数に渡される引数は共通です。(とはいえ、Pythonの実装上引数として渡されるだけで、両方が全部使っているわけではありません。)
それらの値について最初に見ておきましょう。
ドキュメント、もしくはソースコードを見ると以下のように6個の値を受け取っていますね。
def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略
def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略
説明と動作確認のため、適当にモデルを学習させておきます。
from gensim.models.phrases import Phrases
phrase_model = Phrases(
sentences=df["tokens"], # 学習するデータ
min_count=20, # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
scoring='default', # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
threshold=1000, # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)
それでは、順番に引数を見ていきます。
まず、 worda_count / wordb_count はそれぞれ1つ目、2つ目の単語が出てきた回数です。
モデルでいうと、 phrase_model.vocab から取得されます。それぞれの単語が出てきたテキスト数ではなく、出てきた回数であるところが注意が必要です。(1つのテキストに5回出てきたらそれで5と数えられます。)
bigram_count は 1つ目の単語と2つ目の単語が連続して登場した回数です。これも同様に連続して登場したテキスト数ではなく回数です。
len_vocab は vocab 辞書の要素数です。コードでいうとlen(phrase_model.vocab)。ユニグラムとバイグラムを両方数えた語彙数になります。前回の記事でいうと、{“キン”: 81, “ドル”: 111, “キン_ドル”: 81} となっていたらこれで3と数えます。 モデルを学習する時、 min_count で一定回数以下しか出現しなかった単語を足切りしますが、このvocab 作成時は足切りが行われません。1回でも出現したユニグラム、バイグラムが全部数えられるので注意が必要です。
min_count はシンプルに、モデル学習時に指定したバイグラムの最低出現回数です。ちなみに、min_count と全く同じ回数だけ出現した単語は対象に含まれるようですが、スコア関数がデフォルトのoriginal_scoreの場合は定義からスコアが絶対に0になるので結果的に抽出されません。
corpus_word_count は学習したテキストの単語の数を単純に足したものです。次のコードの二つの値が一致することからわかります。
print(phrase_model.corpus_word_count)
# 592490
print(df.tokens.apply(len).sum())
# 592490
さて、スコア計算に使われる6個の値が確認できたところで、順番にスコアの定義を見ていきましょう。
オリジナルスコア
scoring=’default’ と指定された時に採用されるのが、original_scorer です。
以下の論文で提唱されたものをベースとしています。
参考: Distributed Representations of Words and Phrases and their Compositionality
ドキュメントによると次の式で定義されています。
$$
\frac{(\text{bigram_count}-\text{min_count})\times \text{len_vocab}}{\text{worda_count}\times\text{wordb_count}}
$$
シンプルでわかりやすいですね。バイグラムが最小出現回数に比べてたくさん出現するほどスコアが伸びるようになっています。一方でユニグラムでの出現回数が増えるとスコアは下がります。出現した時は高確率で連続して出現するという場合に高くなるスコアです。
len_vobabを掛けているのはイマイチ意図が読めないですね。両単語が全く関係ないテキストをコーパスに追加していくとスコアが伸びていってしまいます。
また、min_countがスコアの計算に使われているので、min_countを変えると足切りラインだけでなくスコアも変わる点も個人的にはちょっとイマイチかなと思いました。
ソースコードも一応見ましたが、定義そのままですね。
def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
denom = worda_count * wordb_count
if denom == 0:
return NEGATIVE_INFINITY
return (bigram_count - min_count) / float(denom) * len_vocab
もう何回も使っている”キン_ドル”を例に、モデルが計算した結果と、定義通りの計算結果が一致することを見ておきましょう。
# モデルが実際に計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 1062.89667445223
# 計算に使われた値たち
print(len(phrase_model.vocab))
# 156664
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.min_count)
# 20
# 定義に沿った計算結果
print((81-20)*156664/(81*111))
# 1062.89667445223
以上で、デフォルトのオリジナルスコアが確認できました。次はNPMIスコアです。
NPMIスコア
scoring=’npmi’ と指定すると使われるのがnpmi_scorerです。
以下の論文で提唱されたものをもとにしています。
参考: Normalized (Pointwise) Mutual Information in Colocation Extraction
ドキュメントによると定義は次の式です。
$$
\frac{\ln{(prob(\text{worda}, \text{wordb}) / (prob(\text{worda})\times prob(\text{wordb}))))}}{-\ln{(prob(\text{worda}, \text{wordb}))}}
$$
ここで、
$$
prob(\text{word}) = \frac{\text{word_count}}{\text{corpus_word_count}}
$$
だそうです。
僕は、だいたい想像つくけど、$prob(\text{worda}, \text{wordb})$の定義も書けや、と思いました。
ソースを読んだ限りでは、以下の定義のようです。
$$
prob(\text{worda}, \text{wordb}) = \frac{\text{bigram_count}}{\text{corpus_word_count}}
$$
分子と分母の両方にバイグラムの出現割合が登場するので直感的には少しわかりにくいですね。分母にマイナスがついているのも理解をややこしくしています。
注意点として、このスコアは-1〜1の範囲(もしくは-inf)で値を返します。モデルの閾値はデフォルト10ですが、これは明らかにoriginal_scorerを使うことを想定しているので、これを使う時は閾値も合わせて調整しなければなりません。
一応ソースコードも見ておきましょう。
def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
if bigram_count >= min_count:
corpus_word_count = float(corpus_word_count)
pa = worda_count / corpus_word_count
pb = wordb_count / corpus_word_count
pab = bigram_count / corpus_word_count
try:
return log(pab / (pa * pb)) / -log(pab)
except ValueError: # some of the counts were zero => never a phrase
return NEGATIVE_INFINITY
else:
# Return -infinity to make sure that no phrases will be created
# from bigrams less frequent than min_count.
return NEGATIVE_INFINITY
これも一応、モデルの計算結果と自分で計算した値を突き合わせておきます。
import numpy as np # logを使うためにimport
# npmi をtukausetteidemoderuwogakusyuu
phrase_model = Phrases(
sentences=df["tokens"], # 学習するデータ
min_count=20, # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
scoring='npmi', # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
threshold=0.8, # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)
# モデルが計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 0.9645882456014411
# 計算に使われた値たち
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.corpus_word_count)
# 592490
# 定義に沿って計算
pa = 81/592490
pb = 111/592490
pab = 81/592490
print(np.log(pab / (pa * pb)) / -np.log(pab))
# 0.9645882456014411
想定通りの結果になりましたね。
これで、gensimのphrasesモデルでフレーズ抽出に使われているスコアの計算式が理解できました。
どういうスコアなのかが分かればそれをもとに閾値を適切に決めれるのでは、という期待があったのですが、正直、この計算式だからこうだみたいな目安はまだあまり見えてきませんでした。
一回 min_countと閾値を両方ともものすごく低い値にして学習し、スコアの分布を見たり、だいたい何単語くらい抽出したいのかといったことをかがえて何パターンか試して使っていくのが良いのかなと思います。