scikit-learnでtf-idfを計算する


**この投稿は[現実逃避アドベントカレンダー2013](http://katryo.hatenablog.com/entry/2013/12/19/001823)の4日目の記事です。**

[2日目の記事](http://qiita.com/katryo/items/62291ba328de9d12bd30)でBing APIを使ってフェッチしたhtmlを使うので、[2日目](http://qiita.com/katryo/items/62291ba328de9d12bd30)を先に読んでおくと理解しやすいです。


## 本稿を3行でまとめる
- [scikit-learn](http://scikit-learn.org/stable/index.html)というPythonのライブラリを調べた
- [2日目](http://qiita.com/katryo/items/62291ba328de9d12bd30)で保存したhtml内の語のtf-idfを計算した
- 語とtfidfのマッピングを確認した

## 参考
[scikit-learn公式、テキストの素性抽出ドキュメント](http://scikit-learn.org/stable/modules/feature_extraction.html#text-feature-extraction)

[scikit-learnを使ってTweet中の単語のtfidf計算](http://blog.parosky.net/archives/2212)

## 完成品
https://github.com/katryo/tfidf_with_sklearn

Fork me!


## 理論
### tfidfの定義
- tf-idfは **tf * idf** の値。あるドキュメント（文書）集合において、あるドキュメントの、ある単語につけられる。tf-idfが高い語は重要と考えることができる。情報検索において、語への重みづけに使える。
- tf (Term Frequency)は **その単語 (Term) の、そのドキュメントでの出現回数 / そのドキュメントで出現したすべての単語の総数** 。単語がその文書で何度も使われていると大きくなる。
- idf (Inverse Document Frequency)はdfの逆数。ただし実際には計算しやすくするようにlogで対数を取る。なので **log(1/df)** となる。logの底は普通2だが、別にeでも10でもよい。はず。
- df (Document Frequency)は **その単語が出現したドキュメントの数 / 全ドキュメント数** 。単語が広いトピックで用いられていると大きくなる。「は」や「を」、英語だと “is” や “that” などで非常に大きくなる。あるドキュメント集合の、ある単語につけられる値。

## scikit-learnでの、テキストからの素性抽出
[scikit-learn公式、テキストの素性抽出ドキュメント](http://scikit-learn.org/stable/modules/feature_extraction.html#text-feature-extraction)の内容を一部訳しながら、自分なりの理解を加えて説明する。

scikit-learnでテキストから素性を抽出するとき、3つの処理が必要になる。

- tokenizing: テキストをbag-of-wordsに変換する。英語の場合はホワイトスペースで分割してから記号などノイズを除去するだけでOKだが、日本語でやる場合、MeCabやKyTeaのような形態素解析器を使う。scikit-learnには日本語の形態素解析器は入っていないので、この処理が別に必要になる。
- counting: 個々のドキュメントごとに、それぞれの語の出現頻度を数える。
- normalizing and weighting: 正規化と重みづけ。語の出現頻度とドキュメント内の語の数とドキュメント数でtf-idfを計算して、さらにそれを使いやすい値に変換する。

scikit-learnでは以上3つの手順をまとめて **vectorization** つまり「ベクトル化」と呼んでいる。後で登場するTfidfVectorizerは3つの手順すべてを行える。すでに途中まで手順を終えているなら、途中からの計算もできるし、途中までの計算もできる。

ちなみにscikit-learnはbag-of-wordsだけでなく、2つ以上の語の連続に着目したn-gramでのtfidf計算もできるが今回はやらない。

### CountVectorizer
sklearn.feature_extraction.textにいるCountVectorizerは、tokenizingとcountingができる。Countingの結果はベクトルで表現されているのでVectorizer。

[公式ドキュメントではこの箇所で説明されている。](http://scikit-learn.org/stable/modules/feature_extraction.html#common-vectorizer-usage)

### TfidfTransformer
同じくsklearn.feature_extraction.textにあるTfidfTransformerはnormalizingを受け持つ。fit_transformメソッドで、ただの「ドキュメントごとの語の出現頻度」をもとにtfidfを計算して、さらに正規化までしてくれる。[公式ドキュメントではここ。](http://scikit-learn.org/stable/modules/feature_extraction.html#tfidf-term-weighting)

### TfidfVectorizer
CountVectorizerとTfidfTransformerの機能を併せ持つ存在。まさに三位一体、トリニティフォーム。生テキストから素性抽出するときはこれを使うと便利。


## 実際に使ってみた

### tfidfの高い語を見てみる
ここからが本題。8つのクエリで取得した400のWebページ内の語、36934種類。これらのなかから、「出現したドキュメントでtfidfが0.1より大きな語」をprintする。

まずtfidfの計算はかなり高コストなので、tfidfを計算したあと、結果をpickle化しておこう。

```python:set_tfidf_with_sklearn_to_fetched_pages.py
import utils
import constants
import pickle
import os
from sklearn.feature_extraction.text import TfidfVectorizer


def is_bigger_than_min_tfidf(term, terms, tfidfs):
    '''
    [term for term in terms if is_bigger_than_min_tfidf(term, terms, tfidfs)]で使う
    list化した、語たちのtfidfの値のなかから、順番に当てる関数。
    tfidfの値がMIN_TFIDFよりも大きければTrueを返す
    '''
    if tfidfs[terms.index(term)] > constants.MIN_TFIDF:
        return True
    return False


def tfidf(pages):
    # analyzerは文字列を入れると文字列のlistが返る関数
    vectorizer = TfidfVectorizer(analyzer=utils.stems, min_df=1, max_df=50)
    corpus = [page.text for page in pages]

    x = vectorizer.fit_transform(corpus)

    #  ここから下は返す値と関係ない。tfidfの高い語がどんなものか見てみたかっただけ
    terms = vectorizer.get_feature_names()
    tfidfs = x.toarray()[constants.DOC_NUM]
    print([term for term in terms if is_bigger_than_min_tfidf(term, terms, tfidfs)])

    print('合計%i種類の単語が%iページから見つかりました。' % (len(terms), len(pages)))

    return x, vectorizer  # xはtfidf_resultとしてmainで受け取る

if __name__ == '__main__':
    utils.go_to_fetched_pages_dir()
    pages = utils.load_all_html_files()  # pagesはhtmlをフェッチしてtextにセットずみ
    tfidf_result, vectorizer = tfidf(pages)  # tfidf_resultはtfidf関数のx

    pkl_tfidf_result_path = os.path.join('..', constants.TFIDF_RESULT_PKL_FILENAME)
    pkl_tfidf_vectorizer_path = os.path.join('..', constants.TFIDF_VECTORIZER_PKL_FILENAME)

    with open(pkl_tfidf_result_path, 'wb') as f:
        pickle.dump(tfidf_result, f)
    with open(pkl_tfidf_vectorizer_path, 'wb') as f:
        pickle.dump(vectorizer, f)
```

tfidf関数のなかで

```
vectorizer = TfidfVectorizer(analyzer=utils.stems, min_df=1, max_df=50)
```

としている。analyzerは文字列を入れると文字列のlistを返す関数を入れる。デフォルトではホワイトスペースで分割して1文字の記号を除去するだけだが、日本語で行うときは形態素解析器を利用した関数を自分で作って設定してやる必要がある。utils.stems関数はMeCabで形態素解析をして語幹に変換してlistにして返す関数で、後述するutils.py内に書いた。

tfidf関数の中でprintしているのは、「胃もたれ」で検索した結果ページの1つの中から発見できる単語のなかで、tfidfの値が0.1以上のものである。これの結果は後述する。

コード中に出てくるutilsは以下のようなもので、多様な場面で使う便利な関数を集めている。

```python:utils.py
import MeCab
import constants
import os
import pdb
from web_page import WebPage

def _split_to_words(text, to_stem=False):
    """
    入力: 'すべて自分のほうへ'
    出力: tuple(['すべて', '自分', 'の', 'ほう', 'へ'])
    """
    tagger = MeCab.Tagger('mecabrc')  # 別のTaggerを使ってもいい
    mecab_result = tagger.parse(text)
    info_of_words = mecab_result.split('\n')
    words = []
    for info in info_of_words:
        # macabで分けると、文の最後に’’が、その手前に'EOS'が来る
        if info == 'EOS' or info == '':
            break
            # info => 'な\t助詞,終助詞,*,*,*,*,な,ナ,ナ'
        info_elems = info.split(',')
        # 6番目に、無活用系の単語が入る。もし6番目が'*'だったら0番目を入れる
        if info_elems[6] == '*':
            # info_elems[0] => 'ヴァンロッサム\t名詞'
            words.append(info_elems[0][:-3])
            continue
        if to_stem:
            # 語幹に変換
            words.append(info_elems[6])
            continue
        # 語をそのまま
        words.append(info_elems[0][:-3])
    return words


def words(text):
    words = _split_to_words(text=text, to_stem=False)
    return words


def stems(text):
    stems = _split_to_words(text=text, to_stem=True)
    return stems


def load_all_html_files():
    pages = []
    for query in constants.QUERIES:
        pages.extend(load_html_files_with_query(query))
    return pages


def load_html_files_with_query(query):
    pages = []
    for i in range(constants.NUM_OF_FETCHED_PAGES):
        with open('%s_%s.html' % (query, str(i)), 'r') as f:
            page = WebPage()
            page.html_body = f.read()
        page.remove_html_tags()
        pages.append(page)
    return pages

def load_html_files():
    """
    HTMLファイルがあるディレクトリにいる前提で使う
    """
    pages = load_html_files_with_query(constants.QUERY)
    return pages


def go_to_fetched_pages_dir():
    if not os.path.exists(constants.FETCHED_PAGES_DIR_NAME):
        os.mkdir(constants.FETCHED_PAGES_DIR_NAME)
    os.chdir(constants.FETCHED_PAGES_DIR_NAME)

```

そしてconstantsは以下の通り。

```python:constants.py
FETCHED_PAGES_DIR_NAME = 'fetched_pages'
QUERIES = '胃もたれ 虫歯 花粉症対策 鬱 機械 骨折 肩こり 書類'.split(' ')
NUM_OF_FETCHED_PAGES = 50
NB_PKL_FILENAME = 'naive_bayes_classifier.pkl'
DOC_NUM = 0
MIN_TFIDF = 0.1
TFIDF_RESULT_PKL_FILENAME = 'tfidf_result.pkl'
TFIDF_VECTORIZER_PKL_FILENAME = 'tfidf_vectorizer.pkl'
```

QUERIESの順番を見てもらえば、「胃もたれ」カテゴリーが最初に来ることがわかるだろう。DOC_NUM定数は今回の実験のために作ったもので、「胃もたれ」カテゴリーの0番目のファイル、つまり「胃もたれ_0.html」という名前のファイルを指定するために使った。

さて。このコードを実行しよう。

```
$ python set_tfidf_with_sklearn_to_fetched_pages.py
```

scikit-learnを使っていてもtfidfの計算には時間がかかる。僕の環境では25.81秒かかった。結果。

```
['gaJsHost', 'https', 'たれる', 'やけ', '空気嚥下症', '胃酸過多症', '胸', '調理', '食材', '食道裂孔ヘルニア']
合計36934種類の単語が400ページから見つかりました。
```

胃もたれっぽい語だ。胃もたれ_0.html内の語のなかでtfidfが0.1を超えているのは以上10種類の語ということがわかった。

gaJsHostとhttpsはJavaScriptのコードの一部と思われる。うぬー。こういうノイズは全部削除したいのだが、うまい方法が思いつかない。いっそのことアルファベットだけの語は排除したほうがいいかもしれない。

ちなみに「食道裂孔ヘルニア」のような語はMeCabのIPADIC（IPADICの由来については[この記事](http://parame.mwj.jp/blog/0209)が詳しい）には入っていないので、Wikipediaやはてなキーワードの語を辞書に入れるなどして強化する必要がある。やりかたはググってください。


### 語とtfidfの値のマッピングを確認

公式ページを読んだのだが、tfidfを計算した結果はscipyのcsr_matrixという型で出力される。これはスパースな（0が多い）行列で、個々のドキュメント内の語のtf-idfを0から1までの小数で表現している。

```
(Pdb) type(x)
<class 'scipy.sparse.csr.csr_matrix'>
```

そのtfidfの値の集合と、語とのマッピングがどうなっているかわからなかった（あとでわかった）ので、pdb.set_trace()を使って簡単な実験を行ってみた。

使うメソッドはTfidfVectorizerが持つ

- get_feature_names
- inverse_transform

そしてscipy.sparse.csr_matrixが持つ

- toarray

である。



まず、文書番号0のWebPageを調べたところ、[胃もたれ.com](http://atdesk.jp/)というページだった。このページに出現する語がどのように表現されているか、調べてみる。

tfidfの計算結果をpickle化したあと、以下のコードを実行した。

```python:play_with_tfidf.py
# -*- coding: utf-8 -*-
import pickle
import constants
import pdb

def is_bigger_than_min_tfidf(term, terms, tfidfs):
    '''
    [term for term in terms if is_bigger_than_min_tfidf(term, terms, tfidfs)]で使う
    list化した、語たちのtfidfの値のなかから、順番に当てる関数。
    tfidfの値がMIN_TFIDFよりも大きければTrueを返す
    '''
    if tfidfs[terms.index(term)] > constants.MIN_TFIDF:
        return True
    return False

if __name__ == '__main__':
    with open(constants.TFIDF_VECTORIZER_PKL_FILENAME, 'rb') as f:
        vectorizer = pickle.load(f)
    with open(constants.TFIDF_RESULT_PKL_FILENAME, 'rb') as f:
        x = pickle.load(f)

    pdb.set_trace()

    terms = vectorizer.get_feature_names()
    for i in range(3):
        tfidfs = x.toarray()[i]
        print([term for term in terms if is_bigger_than_min_tfidf(term, terms, tfidfs)])
```

pdb.set_traceでブレイクポイントとなり、そこからは対話環境で値を出力できるので、色々な確認作業ができる。

```
(Pdb) vectorizer.inverse_transform(x)[0]
> array(['食道裂孔ヘルニア', '食材', '食事療法', '運営', '逆流性食道炎', '調理', '胸', '胃酸過多症', '胃痛',
       '胃潰瘍', '胃下垂', '胃がん', '空気嚥下症', '漢方薬', '構造', '慢性胃炎', '十二指腸潰瘍', '医療保険',
       '免責', '企業情報', 'ポリープ', 'ツボ', 'ケア', 'アメリカンファミリー生命保険会社', 'アフラック', 'ゆく',
       'やけ', 'に関して', 'たれる', 'unescape', 'try', 'ssl', 'protocol',
       'javascript', 'inquiry', 'https', 'gaJsHost', 'ga', 'err',
       'comCopyright', 'analytics', 'Inc', 'Cscript', 'CROSSFINITY',
       '=\'"', "='", ':"', '.")', '."', ')\u3000', '(("', '("%', "'%",
       '"))'],
      dtype='<U26')
```

「食道裂孔ヘルニア」という語は珍しく、他のページにはめったに出現しないと思われるので、この語をマーカーとして使うことに決めた。

```
(Pdb) vectorizer.get_feature_names().index('食道裂孔ヘルニア')
36097
```

で36097番目の語だとわかった。では、0番目のドキュメント（つまり胃もたれ.com）における36097番目の語のtfidfの値は？

```
(Pdb) x.toarray()[0][36097]
0.10163697033184078
```

かなり高い。文書番号0において単語番号36097の語はtfidfが0.10163697033184078であることがわかった。これだけ高い（まずなにより0でない）tfidfの値が、偶然単語番号36097で出現するとは思えない。x.toarray()はとても疎な行列であり、ほとんどの要素が0のはずだ。よって、vectorizer.get_feature_names()で取れる語リストの順番とx.toarray()で取れるtfidfがセットされた語の順番は同じだと考えていい。

こうして、単語一覧は同じ順序が保たれていることが確認できた。「単語の順番は保たれる」と、公式ドキュメントのどこかに書いてあると思う。

このあと、pdb.set_trace()を削除して、もういちどplay_with_tfidf.pyを実行した。

```
['gaJsHost', 'https', 'たれる', 'やけ', '空気嚥下症', '胃酸過多症', '胸', '調理', '食材', '食道裂孔ヘルニア']
['たれる', 'むかつき', 'やけ', '胃痛', '胸', '過ぎる']
['TVCM', 'ぐする', 'たれる', 'のむ', 'もたれる', 'やけ', 'やける', 'り', 'アクション', 'サイエンス', 'サクロン', 'セルベール', 'トリプル', 'ベール', '二日酔い', '弱る', '整える', '粘液', '胃痛', '胃薬', '胸', '膨満']
```

これらの語はtfidfが高く（いかにも高そう）、文書と胃もたれカテゴリーとの類似度を計算する際の素性として有用だと考えられる。

## まとめ

scikit-learn便利。

コードはGithubに上げました。

https://github.com/katryo/tfidf_with_sklearn

## 次回予告
tfidfの計算機能を実装して、scikit-learnとの比較をしたい。