python3
gensim
tfidf

gensimのtfidfあれこれ【追記あり】

Pythonのライブラリの一つであるgensimのtfidf計算について、使用しているうちにいくつか気になることがあったので、そのまとめです。
【追記 2018/6/19】id表示から単語表示に戻す際、わざわざ逆変換用の辞書を作る必要がなかったことが発覚したので修正しました。

環境

  • Python 3.5.2
  • gensim 2.3.0

tfidfのテストコード

とりあえずコードがあった方が説明しやすいので、gensimのtfidfのテストコードを。これはこちらの記事のコードをお借りしpython3用に変更と一部コメントの追加をしたものです。

tfidf_test.py
from gensim import corpora
from gensim import models
from pprint import pprint # オブジェクトを整えて表示するライブラリ. gensimのtfidf計算には不要

# 文書
documents = ['a b c a',
'c b c',
'b b a',
'a c c',
'c b a']

# gensim用に成形
texts = list(map(lambda x:x.split(),documents))

# 単語->id変換の辞書作成
dictionary = corpora.Dictionary(texts)
print('===単語->idの変換辞書===')
pprint(dictionary.token2id)

# textsをcorpus化
corpus = list(map(dictionary.doc2bow,texts))
print('===corpus化されたtexts===')
pprint(corpus)

# tfidf modelの生成
test_model = models.TfidfModel(corpus)

# corpusへのモデル適用
corpus_tfidf = test_model[corpus]

# 表示
print('===結果表示===')
for doc in corpus_tfidf:
    print(doc)
結果
===単語->idの変換辞書===
{'a': 2, 'b': 1, 'c': 0}
===corpus化されたtexts===
[[(0, 1), (1, 1), (2, 2)],
 [(0, 2), (1, 1)],
 [(1, 2), (2, 1)],
 [(0, 2), (2, 1)],
 [(0, 1), (1, 1), (2, 1)]]
===結果表示===
[(0, 0.408248290463863), (1, 0.408248290463863), (2, 0.816496580927726)]
[(0, 0.894427190999916), (1, 0.447213595499958)]
[(1, 0.894427190999916), (2, 0.447213595499958)]
[(0, 0.894427190999916), (2, 0.447213595499958)]
[(0, 0.5773502691896257), (1, 0.5773502691896257), (2, 0.5773502691896257)]

ひとまずtfidfは計算されたようですが、人間の目からするとなんとも分かり辛いですね。

計算結果をID表示ではなく単語にする

gensimではtfidfを計算する際、単語にidを割り振りidの状態で計算を行います。単に文書間の類似度を求めるだけであればid表示でも問題ありませんが、何かの理由があって単語の値を見たい場合はid表示のままでは不便です。
とはいえそれ用の機能が用意されているわけでは無い(はず)なので、自力で「id->単語」に変換するための辞書を用意します。普通にありました。というか、ちょっと色々と便利機能が多いだけの辞書でした。穴があったら入りたいとはまさにこのことですね。そもそもDictionaryってダイレクトに辞書のことなんだから、単純に考えるべきでしたハイ。
ちなみに単語に割り振られるidは毎回異なるので、記事中の結果に表示される単語とidの対応が毎回異なるのはご了承ください。

tfidf_test.py
...

# corpusへのモデル適用
corpus_tfidf = test_model[corpus]

# id->単語へ変換
texts_tfidf = [] # id -> 単語表示に変えた文書ごとのTF-IDF
for doc in corpus_tfidf:
    text_tfidf = []
    for word in doc:
        text_tfidf.append([dictionary[word[0]],word[1]])
    texts_tfidf.append(text_tfidf)

# 表示
print('===結果表示===')
for text in texts_tfidf:
    print(text)
結果
===単語->idの変換辞書===
{'a': 0, 'b': 1, 'c': 2}
===corpus化されたtexts===
[[(0, 2), (1, 1), (2, 1)],
 [(1, 1), (2, 2)],
 [(0, 1), (1, 2)],
 [(0, 1), (2, 2)],
 [(0, 1), (1, 1), (2, 1)]]
===結果表示===
[['a', 0.816496580927726], ['b', 0.408248290463863], ['c', 0.408248290463863]]
[['b', 0.447213595499958], ['c', 0.894427190999916]]
[['a', 0.447213595499958], ['b', 0.894427190999916]]
[['a', 0.447213595499958], ['c', 0.894427190999916]]
[['a', 0.5773502691896257], ['b', 0.5773502691896257], ['c', 0.5773502691896257]]

正規化の解除

こちらの記事で触れられている通り、gensimはtfidfの結果をデフォルトでは正規化して返してきます。正規化の内容についてはここでは触れません。 
正規化の解除方法は簡単で、tfidf model作成時に引数normalizeをFalseにするだけです。

tfidf_test.py
...

# tfidf modelの生成
test_model = models.TfidfModel(corpus,normalize=False) # デフォルトでは normalize=True

...

結果
===結果表示===
[['c', 0.32192809488736235], ['b', 0.32192809488736235], ['a', 0.6438561897747247]]
[['c', 0.6438561897747247], ['b', 0.32192809488736235]]
[['b', 0.6438561897747247], ['a', 0.32192809488736235]]
[['c', 0.6438561897747247], ['a', 0.32192809488736235]]
[['c', 0.32192809488736235], ['b', 0.32192809488736235], ['a', 0.32192809488736235]]

idf値が0になって単語が消える

まず、gensimの文書jにおける単語iの重み$weight_{i,j}$の計算式は以下の通りです。

\begin{align}
weight_{i,j}&=frequency_{i,j}*\log_2\frac{D}{doc\_freq_i} \\
tf&=frequency_{i,j} \\
idf&=\log_2\frac{D}{doc\_freq_i}
\end{align}

$frequency_{i,j}$:文書jにおける単語iの出現回数
$D$:全文書数
$docfreq$:単語jの出現文書数
gensimではidf値の計算時に+1を含めていないため、単語tが全文書に入っていた場合に$\log_2 1=0$となり、結果的にtfidf値が$0$になってしまうことがあります。例は以下の通り。

tfidf_test.py
...

# 二つ目の文書にaを追加. aが全文書に出現するようになる
documents = ['a b c a',
'c b c a',
'b b a',
'a c c',
'c b a']

...
結果
===単語->idの変換辞書===
{'a': 1, 'b': 0, 'c': 2}
===corpus化されたtexts===
[[(0, 1), (1, 2), (2, 1)],
 [(0, 1), (1, 1), (2, 2)],
 [(0, 2), (1, 1)],
 [(1, 1), (2, 2)],
 [(0, 1), (1, 1), (2, 1)]]
===結果表示===
[['b', 0.32192809488736235], ['c', 0.32192809488736235]]
[['b', 0.32192809488736235], ['c', 0.6438561897747247]]
[['b', 0.6438561897747247]]
[['c', 0.6438561897747247]]
[['b', 0.32192809488736235], ['c', 0.32192809488736235]]

corpus化された時点では残っているaが、tfidf値を表示する時点では確かに削除されていますね。しかし場合によってはどうしても単語を残したいこともあり、これでは困ります。
とりあえず先に結論だけ書くと、以下のようにすればidf計算時に$+1$されるようになり、単語が残ります。

tfidf_test.py
from gensim import corpora
from gensim import models
from operator import itemgetter
from pprint import pprint
import math # mathライブラリをインポート

# 関数new_idfを追加
def new_idf(docfreq, totaldocs, log_base=2.0, add=1.0):
    return add + math.log(1.0 * totaldocs / docfreq, log_base)

# 文書
documents = ['a b c a',
'c b c a',
'b b a',
'a c c',
'c b a']

...

# tfidf modelの生成
test_model = models.TfidfModel(corpus,wglobal=new_idf,normalize=False)

...
結果
===単語->idの変換辞書===
{'a': 0, 'b': 2, 'c': 1}
===corpus化されたtexts===
[[(0, 2), (1, 1), (2, 1)],
 [(0, 1), (1, 2), (2, 1)],
 [(0, 1), (2, 2)],
 [(0, 1), (1, 2)],
 [(0, 1), (1, 1), (2, 1)]]
===結果表示===
[['a', 2.0], ['c', 1.3219280948873624], ['b', 1.3219280948873624]]
[['a', 1.0], ['c', 2.643856189774725], ['b', 1.3219280948873624]]
[['a', 1.0], ['b', 2.643856189774725]]
[['a', 1.0], ['c', 2.643856189774725]]
[['a', 1.0], ['c', 1.3219280948873624], ['b', 1.3219280948873624]]

無事aが消えずに残りました。 
確認のために二つ目の文書'c b c a'を文書2として電卓で結果を計算してみます。

\begin{align}
weight_{a,2}&=frequency_{a,2}*\log_2\frac{D}{doc\_freq_a} \\
&=1*(1+\log_2\frac{5}{5}) \\
&=1*(1+0) \\
&=1 \\
weight_{b,2}&=frequency_{b,2}*\log_2\frac{D}{doc\_freq_b} \\
&=1*(1+\log_2\frac{5}{4}) \\
&=1*(1+0.32192809488736) \\
&=1.32192809488736 \\
weight_{c,2}&=frequency_{c,2}*\log_2\frac{D}{doc\_freq_c} \\
&=2*(1+\log_2\frac{5}{4}) \\
&=2*(1+0.32192809488736) \\
&=2.64385618977472 \\
\end{align}

ひとまず合っていそうですね。
TfidfModelのwglobalは、idf計算用の関数を指定する引数です。デフォルトではgensim.models.tfidfmodel.df2idfを利用しています。tfidfmodelに用いられるコードの中身はこちらから見ることができますが、ひとまず必要なのは関数df2idfなのでそれだけ取り出します。

gensim.models.tfidfmodel.py
def df2idf(docfreq, totaldocs, log_base=2.0, add=0.0):
    """
    Compute default inverse-document-frequency for a term with document frequency `doc_freq`::
      idf = add + log(totaldocs / doc_freq)
    """
    return add + math.log(1.0 * totaldocs / docfreq, log_base)

コメントを除けばretrunだけの簡単な関数です。見ての通り、idf計算式をそのまま実装していますね。 
ここで重要なのはデフォルト引数のaddです。logとは独立して足されているので、これがよくあるidf値の+1に当たります。しかしデフォルトでは0.0なので、このままでは意味を成しません。なので呼び出し時にadd=1.0とさえ設定してしまえれば良いのですが、そもそもdf2idfを呼び出すのがライブラリ内なので手出しもできません。(たぶん)
なので今回の解決策は、デフォルトでadd=1.0に設定したdf2idf関数の代わりなるnew_idf関数を用意し、それをidf計算に用いました。というか、new_idf自体df2idfからコメント消してデフォルトのaddの値を変えただけです。
ちなみにこれを利用すればlogの底も変更できます。使いどころがあるかはわかりませんが。

参考サイト

gensim: models.tfidfmodel - TF-IDF model
gensim/tfidfmodel.py at develop・RaRe-Technologies/gensim
gensimのtfidfで正規化(normalize)苦しんだ話 - 俵言
gensimソースコードリーディング - もょもとの技術ノート
TF-IDFで文書内の単語の重み付け | takuti.me
gensim: corpora.dictionary – Construct word<->id mappings