読者です 読者をやめる 読者になる 読者になる

俵言

しがない社会人が書く、勉強とかゆるふわプロコンのこと

gensim の tfidf で正規化(normalize)に苦しんだ話

最近先輩に勧められて python の gensim というライブラリを使い始めたのですが、試しに tfidf やってみたらどうやって正規化してるのかわからなかったから調べたって話です。

かなり細かいことなのですが、同じことに苦しむ人がもしかしたらいるかもってことで記事にすることにしました。

gensim とは?

radimrehurek.com

gensim は python で提供されている自然言語処理のライブラリで、tfidf や、LSI や LDA みたいなトピックモデル、はたまた word2vec なんかも手軽に計算できる便利なツールです。これ2008年からあるらしいんですけど知らなかった...これの存在知ってたら僕の卒論の実装もっと楽になった気がする(--;)

まあ過去の話はさておき、このライブラリを試してみるべくまずはtfidfの計算をしようとしたわけです。

gensim を使った tfidf の計算

今回はあまり詳しいやり方は述べないので、興味のある方は 公式の TutorialCorpora and Vector SpacesTopics and Transformations を ご覧ください。本当は stop words の除去とかも触れられてるけどここでは省略します。

準備

まず gensim で tfidf を計算するには、ドキュメントのリスト: documents を corpus 化する必要があります。ここでいうcorpus化は、各ドキュメントを(出現する単語(のid), 出現回数)のタプルのリストの形式に直すことを指します。

言葉だと分かりにくいので実際にコードを示します。まず以下の四つのドキュメントを考えます。何のセンスも無いテキストですが、この方が後々見やすいので許してください。

documents = [
    "a b c a",
    "c b c",
    "b b a",
    "a c c",
    "c b a",
]

これらをまず単語ごとに区切ります。英語だと楽ですけど日本語だとMeCabとかで形態素解析する必要アリ。

from pprint import pprint
## 分割して単語単位に
texts = map(lambda x: x.split(), documents)

## 表示
pprint(texts)

< [['a', 'b', 'c', 'a'],
<  ['c', 'b', 'c'],
<  ['b', 'b', 'a'],
<  ['a', 'c', 'c'],
<  ['c', 'b', 'a']]

ちなみにpprintpython オブジェクトを整えて print してくれる便利なライブラリです。

次にこれらから辞書を作成します。具体的には、単語にidを振って管理しやすくします。

from gensim import corpora

dictionary = corpora.Dictionary(texts)
pprint(dictionary.token2id)

< {u'a': 0, u'b': 2, u'c': 1}

ここでは以下のように対応づいてますね。(なんでcが1なのか不思議。)

単語(token) 単語のid
"a" 0
"b" 2
"c" 1

で、この辞書を使って先ほどの texts をcorpus化します。

corpus = map(dictionary.doc2bow, texts)
pprint(corpus)

< [[(0, 2), (1, 1), (2, 1)],
<  [(1, 2), (2, 1)],
<  [(0, 1), (2, 2)],
<  [(0, 1), (1, 2)],
<  [(0, 1), (1, 1), (2, 1)]]

id になったので分かりにくいですが、各テキストにおける(出現単語, 出現回数) のタプルのリストになっています。これで corpus 化が完了。
何気にこのid振り -> documentの corpus 化(bag-of-words 化)がとっても楽です。

tfidf model を生成する

先ほど作ったcorpusでtfidfモデルを作ります。tfidfモデルって言葉になんか違和感覚えますが。

from gensim import models
test_model = models.TfidfModel(corpus)

で、作った corpus そのものをこのモデルに適用してあげると、

## corpusに適用
corpus_tfidf = test_model[corpus]

## 表示
for doc in corpus_tfidf:
    print doc

< [(0, 0.816496580927726), (1, 0.408248290463863), (2, 0.408248290463863)]
< [(1, 0.894427190999916), (2, 0.447213595499958)]
< [(0, 0.447213595499958), (2, 0.894427190999916)]
< [(0, 0.447213595499958), (1, 0.894427190999916)]
< [(0, 0.5773502691896257), (1, 0.5773502691896257), (2, 0.5773502691896257)]

よし、tfidfが出た..ぞ?ちょっと待って、この値どうやって出てるの?

gensim はどうやってtfidf値を出しているのか

そもそもtfidfって計算方法がいくつかあります。gensim はどんな方法をとっているんでしょう?

gensim: models.tfidfmodel – TF-IDF model

公式documentによれば、gensim における document j における 単語  i の tfidf 値  weight_{i,j} は以下のように計算されます。

 \displaystyle weight_{i,j} = TermFrequency(i,j) * \log_{2}\left(\frac{|D|}{DocumentFrequency(j)}\right)

 TermFrequency(i,j) : ドキュメント  i における 単語  j の出現回数( or 頻度)

 DocumentFrequency(j):単語 j の出現するドキュメントの数

 |D| : ドキュメントの総数

また気をつける点として、計算は以下の手順で行われます。

The main methods are:

  1. constructor, which calculates inverse document counts for all terms in the training corpus.
  2. the method, which transforms a simple count representation into the TfIdf space.

1.のモデル生成(初期化)の時点では、training corpus から idf だけを計算しておき、そのあとに渡されたテキストから tfidf 値を決定するってことです。だから、training corpus は tf の値に寄与しないし、[] にわたされるテキストは idf の値には寄与しないみたいですね。

さて、実際に上記の式で自分の作った corpus の tfidf 値を計算してみます。

# corpusの中身(再掲)
pprint(corpus)

< [[(0, 2), (1, 1), (2, 1)],
<  [(1, 2), (2, 1)],
<  [(0, 1), (2, 2)],
<  [(0, 1), (1, 2)],
<  [(0, 1), (1, 1), (2, 1)]]

ここでは一番上のドキュメントだけやってみましょう。

  • "a"(idは0):  \displaystyle 2 * \log_2\left(\frac{5}{4}\right) = 0.6438561897747247

  • "b"(idは2):  \displaystyle 1 * \log_2\left(\frac{5}{4}\right) = 0.32192809488736235

  • "c"(idは1):  \displaystyle 1 * \log_2\left(\frac{5}{4}\right) = 0.32192809488736235

なんでや!値ちゃうやんけ!
原因を探るべくもうちょっとさっきの公式ページをちゃんと読むと、

Compute tf-idf by multiplying a local component (term frequency) with a global component (inverse document frequency), and normalizing the resulting documents to unit length. ・・・

unit length になるように正規化してる???って言われてもよくわかんないんですけど..ドキュメントの中で足し合わせても1になるわけではないし..

因みに、この正規化をなしにしたときの式が上の式だそうで、実際にやってみると

test_model2 = models.TfidfModel(corpus, normalize = False)
corpus_tfidf2 = test_model2[corpus]
for doc in corpus_tfidf2:
    print doc

< [(0, 0.6438561897747247), (1, 0.32192809488736235), (2, 0.32192809488736235)]
< [(1, 0.6438561897747247), (2, 0.32192809488736235)]
< [(0, 0.32192809488736235), (2, 0.6438561897747247)]
< [(0, 0.32192809488736235), (1, 0.6438561897747247)]
< [(0, 0.32192809488736235), (1, 0.32192809488736235), (2, 0.32192809488736235)]

一個目のドキュメントの各単語の tfidf 値がさっき計算したものと等しいことが確認できます。

原因を探す

まあよく分からないときはググるわけですが、どうも日本語の記事だとこのことに触れてない。大体の記事は「tfidfはこんな感じで計算できます。じゃあLSI(or LDA)行きましょう!」みたいな感じなんですよね。(だから記事を書くことにしたのですが。)

で、そんななかこんな記事を見つけます。いつもお世話になってるstackoverflowです。

stackoverflow.com

で、回答者の方が

if self.normalize:
        vector = matutils.unitvec(vector)

って gensim の source code に書いてあるっていうんですよね。いやなんだよこのmatutils.unitvec()...

で、このmatutils.unitvec()で検索すると、普通に gensim の公式ページがヒットします。

https://radimrehurek.com/gensim/matutils.html#gensim.matutils.unitvec

で、引数の部分にnorm = l2って書いてありました。つまりL2ノルム(別名ユークリッドノルム)による正規化です。やっとわかったよ..(参考:ノルム - wikipedia
result to unit length って単位長さになるようにってことみたいで、もうちょっというと指定したノルムが1になるようにってことなんですね。なので、各 document の tfidf 値のノルムを計算して、それで個々の要素を割ることで正規化しています。

実はさっきの stackoverflow の記事の二番目の回答者が考察から当ててました。すごい。

実際にさっきと同じドキュメント([(0, 2), (1, 1), (2, 1)])で計算してみます。このドキュメント(ベクトル)の正規化する前のtfidf値による L2ノルムは


\sqrt{(0.6438561897747247)^2 + (0.32192809488736235)^2 + ((0.32192809488736235)^2} \\
= 0.7885595663403238

そんでこいつで各単語のtfidf値を割ると、

  • "a"(idは0):  \displaystyle \frac{0.6438561897747247}{0.7885595663403238} = 0.816496580927726

  • "b"(idは2):  \displaystyle \frac{0.32192809488736235}{0.7885595663403238} = 0.408248290463863

  • "c"(idは1):  \displaystyle \frac{0.32192809488736235}{0.7885595663403238} = 0.408248290463863

正規化した場合の tfidf と比較してみると(一段目です)、

< [(0, 0.816496580927726), (1, 0.408248290463863), (2, 0.408248290463863)]
< [(1, 0.894427190999916), (2, 0.447213595499958)]
< [(0, 0.447213595499958), (2, 0.894427190999916)]
< [(0, 0.447213595499958), (1, 0.894427190999916)]
< [(0, 0.5773502691896257), (1, 0.5773502691896257), (2, 0.5773502691896257)]

うわあ..合っちゃったよ..

おわりに

まあぶっちゃけ僕に英語力あったら一発でわかったかもしれません、"result to unit length"。他にも、Tutorialの下の方にこんな記述があったり。

.. It can also optionally normalize the resulting vectors to (Euclidean) unit length.

ただこれがわかったのは多分L2ノルムでの正規化だってはっきり分かった後だからであって、最初に見てたとしても結局調べてたと思います。
まあ言い訳させてもらうと、tfidf のチュートリアルってその周辺にどうやって値出してるかの話書かれてなくて値だけ出てくるんですよね(そしてその値が通常の tfidf と微妙に違う値でぱっと見よくわからない)。しかもそれっぽい記述が出てきてもぼかして書かれてて、最終的にデフォルトのパラメータから発覚するっていう。

まあ気持ち悪いまま gensim を使わずに済むようになったので良しとします。使いこなせばとても便利なので、これから トピック解析とかもしていくつもりです。
結局いつもどおり長々と書いちゃいましたが、以上で終わりです。ああ英語力ほしいなあ..

露骨に gensim、tfidf、normalize って言葉をタイトルに入れておいたので、同じことを疑問に思った人がこの記事を見つけて役立ててくれれば幸いです。ではでは。  

追記(11/08 22:28)

友人から、「"result to unit length" を"単位ベクトル化"って読めばすごく自然なことなのでは」って言われて確かにその通りだと思いました。みんなそう読めているのだとすれば、この記事を書いた僕はただの馬鹿ですね、うん。英語力どころか頭の問題だったよ..