CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: scikit-learn と色々な TF-IDF の定義について

自然言語処理にあまり馴染みがないのもあって、試しに TF-IDF (Term Frequency - Inverse Document Frequency) を自分で実装してみることにした。 その過程で知ったことについて書き残しておく。 端的に書いてしまうと、TF-IDF の定義は色々とある、ということ。

TF-IDF というのは、コーパス (全文書) に含まれる単語の重要度を評価するための手法。 その名の通り、文書単位で見た単語の頻度 (Term Frequency) と、コーパス単位で見た単語の頻度 (Inverse Document Frequency) を元に計算する。 これは例えば、ある文書の中で頻出する単語 (Term Frequency が高い) があれば、その単語はその文書の中では重要と考えられる。 しかし、その単語が別の文書でも同様によく登場する (Inverse Document Frequency が低い) のであれば、全体から見るとありふれた単語なので実は大して重要ではなくなる。

今回、TF-IDF を実装するにあたって scikit-learn の出力結果をお手本にすることにした。 しかし、やってみるとなかなか結果が一致しない。 原因を探るべくドキュメントやソースコードやチケットシステムを確認していくと、色々と事情が分かってきた。

動作を確認するのに使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.5
BuildVersion:   18F132
$ python -V                      
Python 3.7.3

下準備

まずは scikit-learn と pandas をインストールしておく。

$ pip install scikit-learn pandas

scikit-learn の TF-IDF を試す

まずはお手本となる scikit-learn の動作を確認しておこう。 scikit-learn の TF-IDF の実装は TfidfVectorizer にある。 コーパスは scikit-learn の例をそのまま使うことにした。 TF-IDF を計算する上で本来は必要となるはずの BoW (Bag of Words) については別途 CountVectorizer で集計している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer


def main():
    # データフレームを表示するときカラムを省略しない
    pd.set_option('display.max_columns', None)
    # 浮動小数点を表示するときは小数点以下 2 桁で揃える
    pd.options.display.float_format = '{:0.2f}'.format

    # 取り扱うコーパス
    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    # 単語の数をカウントする
    count_vectorizer = CountVectorizer()
    X_count = count_vectorizer.fit_transform(corpus)

    # 見やすさのために表示するときは pandas のデータフレームにする
    df = pd.DataFrame(data=X_count.toarray(),
                      columns=count_vectorizer.get_feature_names())
    print('--- BoW (Bag of Words) ---')
    print(df)

    # scikit-learn の TF-IDF 実装
    tfidf_vectorizer = TfidfVectorizer()
    X_tfidf = tfidf_vectorizer.fit_transform(corpus)

    # IDF を表示する
    print('--- IDF (Inverse Document Frequency) ---')
    df = pd.DataFrame(data=[tfidf_vectorizer.idf_],
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)

    # TF-IDF を表示する
    print('--- TF-IDF ---')
    df = pd.DataFrame(data=X_tfidf.toarray(),
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

実行結果は次の通り。 BoW (Bag of Words) は各文書に含まれる単語のカウント、IDF (Inverse Document Frequency) はコーパスにおける単語の珍しさを示す。 例えば and は文書 2 にしか含まれていないので珍しいけど、is は全ての文書に含まれているので珍しくない。 それが IDF の 1.921.00 という数値にあらわれている。 最終的に and は文書 2 で 0.51 という重要度で評価されており、is は文書によってバラつきがあるものの 0.27 ~ 0.38 と評価されている。 TF (Term Frequency) については TfidfVectorizer に取得するインターフェースが見当たらなかった。

$ python sktfidf.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 1.92      1.22   1.51 1.00 1.92    1.92 1.00   1.92  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38
1 0.00      0.69   0.00 0.28 0.00    0.54 0.28   0.00  0.28
2 0.51      0.00   0.00 0.27 0.51    0.00 0.27   0.51  0.27
3 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38

なお、一般的には andis といったどんな文書でも頻出する類の単語はストップワード (Stop Words) といって処理の対象外とする場合が多い。

自分で実装してみる

続いては TF-IDF を自分で実装してみよう。 TF-IDF の定義については、検索して上位にくるもの (日本語版 Wikipedia など) を用いた。 具体的には次の通り。

 TF(t_i, d_j) = \frac{文書 d_j に登場する単語 t_i の数}{文書 d_j に登場する全ての単語の数}

 IDF(t_i) = \log{\frac{全文書の数}{単語 t_i が含まれる文書の数}}

 TFIDF(t_i, d_j) = TF(t_i, d_j) \cdot IDF(t_i)

そして、上記の定義にもとづいたサンプルコードが次の通り。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    bow = count_vectorizer.fit_transform(corpus).toarray()
    print('--- BoW (Bag of Words) ---')
    df = pd.DataFrame(bow,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    # TF を計算してるところ (行方向の処理)
    print('--- TF (Term Frequency) ---')
    # 文書に含まれる単語の数をカウントする
    number_of_words = np.sum(bow, axis=1, keepdims=True)
    # 文書の中での単語の頻度を計算する
    tf = bow / number_of_words
    df = pd.DataFrame(tf,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    # IDF を計算してるところ (列方向の処理)
    print('--- IDF (Inverse Document Frequency) ---')
    # 文書の数をカウントする
    number_of_docs = len(corpus)
    # その単語が一つでも含まれる文書の数をカウントする
    number_of_docs_contain_word = np.count_nonzero(bow, axis=0)
    # 単語の珍しさを計算する
    idf = np.log(number_of_docs / number_of_docs_contain_word)
    df = pd.DataFrame([idf],
                      columns=count_vectorizer.get_feature_names())
    print(df)

    # TF-IDF を計算してるところ
    print('--- TF-IDF ---')
    # TF と IDF をかける
    tfidf = tf * idf
    df = pd.DataFrame(tfidf,
                      columns=count_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみよう。 先ほどと見比べると分かる通り、IDF と TF-IDF の値が一致していない。 なお、TF については取得するインターフェースがなかったので比較できない。

$ python mytfidf.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- TF (Term Frequency) ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
1 0.00      0.33   0.00 0.17 0.00    0.17 0.17   0.00  0.17
2 0.17      0.00   0.00 0.17 0.17    0.00 0.17   0.17  0.17
3 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 1.39      0.29   0.69 0.00 1.39    1.39 0.00   1.39  0.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.06   0.14 0.00 0.00    0.00 0.00   0.00  0.00
1 0.00      0.10   0.00 0.00 0.00    0.23 0.00   0.00  0.00
2 0.23      0.00   0.00 0.00 0.23    0.00 0.00   0.23  0.00
3 0.00      0.06   0.14 0.00 0.00    0.00 0.00   0.00  0.00

なんとか結果を一致させてみる

さて、scikit-learn の実装と Wikipedia などを参考にした実装の結果が一致しないことが分かった。 一体何がまずかったのだろうか。 実は、scikit-learn のデフォルトの TF-IDF と結果を一致させるには定義を次のように変更する必要がある。

 TF(t_i, d_j) = \frac{文書 d_j に登場する単語 t_i の数}{文書 d_j に登場する全ての単語の数}

 IDF(t_i) = \log{\frac{全文書の数 + 1}{単語 t_i が含まれる文書の数 + 1}} + 1

 TFIDF(t_i, d_j) = || TF(t_i, d_j) \cdot IDF(t_i) ||_2

先ほどからの変更点は二つある。 まず、一つ目は IDF を計算するときに分子、分母、そして最終的な結果に 1 を加えている。 そして二つ目は TF-IDF を計算するときに結果を L2 で正規化している。

上記にもとづいて変更を加えたサンプルコードが次の通り。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.preprocessing import normalize


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    bow = count_vectorizer.fit_transform(corpus).toarray()
    print('--- BoW (Bag of Words) ---')
    df = pd.DataFrame(bow,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- TF (Term Frequency) ---')
    number_of_words = np.sum(bow, axis=1, keepdims=True)
    tf = bow / number_of_words
    df = pd.DataFrame(tf,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- IDF (Inverse Document Frequency) ---')
    # 文書の数に 1 を足す
    number_of_docs = len(corpus) + 1
    # 単語が含まれる文書の数にも 1 を足す
    number_of_docs_contain_word = np.count_nonzero(bow, axis=0) + 1
    # 最終的な IDF にも 1 を足す
    idf = np.log(number_of_docs / number_of_docs_contain_word) + 1
    df = pd.DataFrame([idf],
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- TF-IDF ---')
    # 結果を L2 で正規化する
    tfidf = normalize(tf * idf)
    df = pd.DataFrame(tfidf,
                      columns=count_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python compatfidf.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- TF (Term Frequency) ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
1 0.00      0.33   0.00 0.17 0.00    0.17 0.17   0.00  0.17
2 0.17      0.00   0.00 0.17 0.17    0.00 0.17   0.17  0.17
3 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 1.92      1.22   1.51 1.00 1.92    1.92 1.00   1.92  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38
1 0.00      0.69   0.00 0.28 0.00    0.54 0.28   0.00  0.28
2 0.51      0.00   0.00 0.27 0.51    0.00 0.27   0.51  0.27
3 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38

今度はちゃんと IDF と TF-IDF が scikit-learn の出力した内容と揃っている。

色々な TF-IDF の定義がある

さて、ここまで見てきた通り同じコーパスなのに scikit-learn の実装と Wikipedia の定義で、二つの異なる結果が得られた。 どちらが正しい TF-IDF なんだろう、という疑問が湧くかもしれないけど、実はどちらも間違ってはいない。 というのも TF-IDF には色々な定義があるため。 この点は、以下のブログや英語版の Wikipedia の TF-IDF のページで詳しく解説されている。

yukinoi.hatenablog.com

en.wikipedia.org

どの TF-IDF の定義も、コーパスに含まれる単語の重要度を評価するという目的自体は変わらない。 ただ、その目的に至るまでの道のりが違っている。 ちなみに scikit-learn の TF-IDF の定義については、そのものずばりなものはおそらく上記にも載っていない。

たとえば IDF の定義は、全ての単語が含まれる仮想的な文書があるようにするところが IDF Smooth に近い。 これはゼロ除算が発生するのを防ぐのに有効な手法だと思う。 ただし、対数の外側でも 1 を加えるところは scikit-learn のオリジナルだと思う。

 IDF(t_i) = \log{\frac{全文書の数 + 1}{単語 t_i が含まれる文書の数 + 1}} + 1

この件はチケットも切られているんだけど、過去の経緯でそうなってるみたいで後方互換性を考えて直さないことになったっぽい。

github.com

最終的な結果を L2 で正規化するところも、ドキュメントを読むと分かる。

 TFIDF(t_i, d_j) = || TF(t_i, d_j) \cdot IDF(t_i) ||_2

L2 正規化と Smooth をしない

実は先ほどの Smooth と L2 正規化は TfidfVectorizer のオプションで無効にできる。

無効にしたサンプルコードは次の通り。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    X_count = count_vectorizer.fit_transform(corpus)

    df = pd.DataFrame(data=X_count.toarray(),
                      columns=count_vectorizer.get_feature_names())
    print('--- BoW (Bag of Words) ---')
    print(df)

    # 正規化しないし Smooth もしない
    tfidf_vectorizer = TfidfVectorizer(norm=None,
                                       smooth_idf=False)
    X_tfidf = tfidf_vectorizer.fit_transform(corpus)

    print('--- IDF (Inverse Document Frequency) ---')
    df = pd.DataFrame(data=[tfidf_vectorizer.idf_],
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)

    print('--- TF-IDF ---')
    df = pd.DataFrame(data=X_tfidf.toarray(),
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python nol2smooth.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 2.39      1.29   1.69 1.00 2.39    2.39 1.00   2.39  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00
1 0.00      2.58   0.00 1.00 0.00    2.39 1.00   0.00  1.00
2 2.39      0.00   0.00 1.00 2.39    0.00 1.00   2.39  1.00
3 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00

結果が先ほどと変わっていることが分かる。

L2 正規化と Smooth をしないパターンを自前で実装する

L2 正規化と Smooth をしないパターンについても、結果が揃う形で実装してみよう。

サンプルコードは次の通り。 IDF の分子と分母に 1 を加えないことで、全ての単語が含まれる仮想的な文書を想定していない。 また、TF-IDF を計算するところで TF として BoW をそのまま使っている。 これは TF の中でも Raw Frequency と呼ばれる定義のようだ。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    bow = count_vectorizer.fit_transform(corpus).toarray()
    print('--- BoW (Bag of Words) ---')
    df = pd.DataFrame(bow,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- IDF (Inverse Document Frequency) ---')
    # 全ての単語が含まれる仮想的な文書を加えない
    number_of_docs = len(corpus)
    number_of_docs_contain_word = np.count_nonzero(bow, axis=0)
    idf = np.log(number_of_docs / number_of_docs_contain_word) + 1
    df = pd.DataFrame([idf],
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- TF-IDF ---')
    # BoW をそのまま使う (TF = Raw Frequency)
    tfidf = bow * idf
    df = pd.DataFrame(tfidf,
                      columns=count_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python mynol2smooth.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 2.39      1.29   1.69 1.00 2.39    2.39 1.00   2.39  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00
1 0.00      2.58   0.00 1.00 0.00    2.39 1.00   0.00  1.00
2 2.39      0.00   0.00 1.00 2.39    0.00 1.00   2.39  1.00
3 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00

ちゃんと結果が揃っている。

まとめ

軽い気持ちで TF-IDF を実装してみようと思ったら、思ったより面白かった。 TF-IDF は定義が色々とあるので、実装によって返す値が全然違う可能性がある。 scikit-learn の実装は過去の経緯も一部あるけど、デフォルトで便利なように工夫されていると感じた。

いじょう。