scikit-learnとgensimでニュース記事を分類する


こんにちは、初心者です。
適当なニュース記事があったとして、ニュースのカテゴリを推測するみたいな、よくあるやつをやってみました。Python3.3を使いました。

## 何をやるの？

データセットはlivedoorニュースコーパスを使いました。
http://www.rondhuit.com/download.html#ldcc

クリエイティブ・コモンズライセンスが適用されるニュース記事だけを集めてるそうです。

トピックニュース、Sports Watch、ITライフハック、家電チャンネル 、MOVIE ENTER、独女通信、エスマックス、livedoor HOMME、Peachy というクラスがあります。

データは、1記事1テキストファイルの形式で、クラス別のディレクトリにいっぱい入っています。
これを学習して、未知の文章に対して、お前は独女通信っぽい、お前は家電チャンネルっぽい、みたいに、分類ができればOKとします。

## どうやるの？

文書のベクトル表現方法はいろいろあるみたいだけど、簡単そうなBoW（Bag of words)を使います。係り受けの情報を捨てて、単語だけをバッグにぽいぽい入れちゃったイメージだそうです。誰のバッグだよ。僕のバッグそんなことない。すっごい、ポケットいっぱいあるし。

で、文章の特徴を表しそうな単語（特徴語）を何個か（何千個？）決めて、それの頻度をカウントすれば、文書をベクトルで表現できるということだそうです。

ということは、特徴語を決定する必要がある。このあたりは、Gensimというライブラリが良いそうなので、脳停止でそれ使います。

あと、日本語文章の特徴語を抽出するということは、その前に形態素解析をする必要がある。これはMeCabを使えば良いでしょう。

手順をまとめると、
1. 記事からMeCabで単語だけ切り出して記事を単語リストに変換
2. 単語リスト群から、Gensimで特徴語の辞書を定義
3. BoWの要領で各文章に特徴語が何個あるかカウントして特徴ベクトル作る
4. この特徴ベクトルで学習。
5. 未知の文章も、3の方法で特徴ベクトルを作れば、分類器にかけてカテゴリを当てられるはず

という感じだと思います。


## 各種インストール
ちまたにはPython2でのやり方が多かったのですが、Python3でやりました。多少ハマりましたがPython3で大丈夫です。Python3使おう！

### scikit-learnインストール
前にやったので入れ方忘れました。
http://breakbee.hatenablog.jp/entry/2013/12/02/020834
このあたりで良いんじゃないでしょうか。Macなので、できるだけbrewで入れてます。


### MeCabとmecab-pythonインストール
これに助けられました。
http://qiita.com/namoshika/items/37e1351f7ffefd505eec
昔brewで入れたmecabは、pythonから呼ぶと落ちたので消しました。

### Gensimインストール
pip gensim でサクッと入れたんですけど、Python3では動きませんでした。調べたら、GensimPy3というのが別であった。うかつだった。
http://qiita.com/katryo/items/e40da7dc3cb666391e28

以上です。

## 1.MeCabで単語切り出し

PythonからMeCab呼べてすごく便利
http://stmind.hatenablog.com/entry/2013/11/04/164608
ここを丸パク……マネして、名詞だけ取り出しています。

簡単のために、7000記事のうち2記事、しかも一部だけ取り出してやってます。


```mecabtest.py
# -*- coding: utf-8 -*-
import MeCab
mecab = MeCab.Tagger('mecabrc')


def tokenize(text):
    '''
    とりあえず形態素解析して名詞だけ取り出す感じにしてる
    '''
    node = mecab.parseToNode(text)
    while node:
        if node.feature.split(',')[0] == '名詞':
            yield node.surface.lower()
        node = node.next


def get_words(contents):
    '''
    記事群のdictについて、形態素解析してリストにして返す
    '''
    ret = []
    for k, content in contents.items():
        ret.append(get_words_main(content))
    return ret


def get_words_main(content):
    '''
    一つの記事を形態素解析して返す
    '''
    return [token for token in tokenize(content)]


# 2記事の一部だけ取り出しました
# 1つめがITライフハック、2つめが独女通信の記事です。
if __name__ == '__main__':
    words = get_words({'it-life-hack-001.txt': 'アナタはまだブラウザのブックマーク？　ブックマーク管理はライフリストがオススメ 最近ネットサーフィンをする際にもっぱら利用しているのが「ライフリスト」というサイトだ。この「ライフリスト」は、ひとことで言うと自分専用のブックマークサイトである。というよりブラウザのスタートページにするとブラウザのブックマーク管理が不要になる便利なサイトなのである。', 'dokujo-tsushin-001.txt': 'たとえば、馴れ馴れしく近づいてくるチャラ男、クールを装って迫ってくるエロエロ既婚男性etc…に対し「下心、見え見え〜」と思ったことはないだろうか？ “下心”と一言で言うと、特に男性が女性のからだを目的に執拗に口説くなど、イヤらしい言葉に聞こえてしまう。実際、辞書で「下心」の意味を調べてみると、心の底で考えていること。かねて心に期すること、かねてのたくらみ。特に、わるだくみ。（広辞苑より）という意味があるのだから仕方がないのかもしれない。'})
    print(words)
```

記事を、単語の羅列に変換しました。

```結果
[['アナタ', 'ブラウザ', 'ブック', 'マーク', 'ブック', 'マーク', '管理', 'ライフ', 'リスト', 'オススメ', '最近', 'ネット', 'サーフィン', '際', '利用', 'の', 'ライフ', 'リスト', 'サイト', 'ライフ', 'リスト', 'ひとこと', '自分', '専用', 'ブックマークサイト', 'ブラウザ', 'スタート', 'ページ', 'ブラウザ', 'ブック', 'マーク', '管理', '不要', '便利', 'サイト', 'の'],['チャラ', '男', 'クール', 'エロ', 'エロ', '既婚', '男性', 'etc', '下心', '見え', '見え', 'こと', '下心', '一言', '男性', '女性', 'からだ', '目的', '執拗', 'イヤ', '言葉', '辞書', '下心', '意味', '心', '底', 'こと', '心', 'こと', 'わる', 'くみ', '広辞苑', '意味', 'の', '仕方', 'の']]
```
各記事が、名詞の配列になりました。なんとなーく、文章の感じは残ってる。
単語に重複がありますが、これは、あとで頻度を調べるのでもちろんそのままです。2個目のようにエロが2個続いてれば、それだけエロい文章なんでしょう。

それよりも、「の」とか要らない単語があります。「の」の数が文章の特徴に寄与しないのは明らか。捨てたい。

これをどうするかですが、

・ストップワードを定義して消す
・高頻度すぎる単語、低頻度すぎる単語を消す

とかがあるようです。
前者はこの時点でやってしまってもいいようです。ストップワードは、例えばI, my, his とか、どんな文章にも出てくるくだらない単語とかです。僕は、ここで数字だけのワードを消すようにしました。上のテストでは出ていないですが、全記事で試すと、意味の無い数字だけの単語が結構増えます。

後者は、次の工程でGensimを使って簡単にできるようです。ストップワードのいくらかは、こちらの作業でも消える気がします。


## 2.Gensimで特徴語辞書を作る

さっきの単語のリストから辞書を作るよ
これを参考にしました
http://sucrose.hatenablog.com/entry/2013/10/29/001041


```gensimtest.py
from gensim import corpora

# words はさっきの単語リスト
dictionary = corpora.Dictionary(words)
print(dictionary.token2id)
```

結果

```
{'オススメ': 28, '最近': 44, 'ブックマークサイト': 34, '便利': 41, 'サイト': 29, '言葉': 24, 'チャラ': 9, '男性': 21, '自分': 46, 'わる': 5, '底': 16, '仕方': 12, 'ひとこと': 26, 'イヤ': 6, 'からだ': 1, 'ブラウザ': 35, 'くみ': 2, 'ネット': 32, '専用': 43, 'ページ': 36, '下心': 11, 'スタート': 31, 'サーフィン': 30, 'リスト': 39, '一言': 10, '見え': 23, 'マーク': 37, 'クール': 8, '心': 17, '利用': 42, 'エロ': 7, 'ライフ': 38, '女性': 14, 'etc': 0, '既婚': 19, '管理': 45, 'アナタ': 27, 'の': 4, '意味': 18, '不要': 40, '広辞苑': 15, '男': 20, 'ブック': 33, '目的': 22, '執拗': 13, 'こと': 3, '際': 47, '辞書': 25}
```

すべての記事で使われてる単語の重複無しセットになりました。単語になんかIDふってくれた。

`の:4`とか要らねー、みたいなやつは、高頻度すぎるものと低頻度すぎるものを消す方法があるらしい。

```gensimtest.py
dictionary.filter_extremes(no_below=20, no_above=0.3)
# no_berow: 使われてる文章がno_berow個以下の単語無視
# no_above: 使われてる文章の割合がno_above以上の場合無視
```
今はテストで2記事しか解析してないのでこれ通すと全単語消えちゃいますけど、あとで全記事で学習するときはバリバリ使ってます。ここはチューニングしがいのあるところかも。

ここまでで、特徴を計測するための辞書が出来ました。

ところで、辞書を作る作業は記事数が多くなってくると時間がかかるし、何度もやる必要は無いはずなので、辞書をファイルに保存します。

```gensimtest.py
dictionary.save_as_text('livedoordic.txt')
```

作った辞書ファイルをロードして辞書オブジェクト作るときはこうみたいです。

```
dictionary = corpora.Dictionary.load_from_text('livedoordic.txt')
```




## 3.BoWの要領で各文章の特徴語をカウントして特徴ベクトル作る

さっきの記事に、辞書の特徴語が何個あるかカウント。GensimにはBoWというそのままの関数がある！ ヤベー！
ということで、ITライフハックのとある1記事について特徴ベクトルを作ってみる。

参考：http://sucrose.hatenablog.com/entry/2013/10/29/001041


``` bowtest.py

# dictionary は既に作成済みとして……

# さっきITライフハックの1記事を形態素解析かけて名詞取り出したやつ
words = ['アナタ', 'ブラウザ', 'ブック', 'マーク', 'ブック', 'マーク', '管理', 'ライフ', 'リスト', 'オススメ', '最近', 'ネット', 'サーフィン', '際', '利用', 'の', 'ライフ', 'リスト', 'サイト', 'ライフ', 'リスト', 'ひとこと', '自分', '専用', 'ブックマークサイト', 'ブラウザ', 'スタート', 'ページ', 'ブラウザ', 'ブック', 'マーク', '管理', '不要', '便利', 'サイト', 'の']

# BoW
vec = dictionary.doc2bow(words)
print(vec)
```


```結果
[(4, 2), (26, 1), (27, 1), (28, 1), (29, 2), (30, 1), (31, 1), (32, 1), (33, 3), (34, 1), (35, 3), (36, 1), (37, 3), (38, 3), (39, 3), (40, 1), (41, 1), (42, 1), (43, 1), (44, 1), (45, 2), (46, 1), (47, 1)]
```

辞書中の単語IDと、頻度のタプルになった。例えば `(35, 3)` は、辞書ID35の単語「ブラウザ」が、3回出てくるという意味。ITライフハックの記事っぽい特徴が出てる。

これをさらに、特徴ベクトルに変換するには、こうすれば良いようです。

```bowtest.py
from gensim import corpora, matutils

# dictionary は既に作成済みとして……

# ITライフハックの1記事を形態素解析かけて名詞取り出したやつ
words = ['アナタ', 'ブラウザ', 'ブック', 'マーク', 'ブック', 'マーク', '管理', 'ライフ', 'リスト', 'オススメ', '最近', 'ネット', 'サーフィン', '際', '利用', 'の', 'ライフ', 'リスト', 'サイト', 'ライフ', 'リスト', 'ひとこと', '自分', '専用', 'ブックマークサイト', 'ブラウザ', 'スタート', 'ページ', 'ブラウザ', 'ブック', 'マーク', '管理', '不要', '便利', 'サイト', 'の']

tmp = dictionary.doc2bow(words)
dense = list(matutils.corpus2dense([tmp], num_terms=len(dictionary)).T[0])
print(dense)
```

```結果
[0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0, 1.0, 3.0, 1.0, 3.0, 1.0, 3.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0]
```

辞書中のすべてのIDと、頻度のリストが得られた。これは、文章の特徴ベクトルとして扱える。


これを全記事に対してやれば良さそう。

あと、このテストでは23次元なので別にいいですけど、全記事でやったときには、6000ワード、6000次元とかになってやばい。
こういうときは、次元削減とかやれば良いようです。
今回はこのまま突っ走ります。


## 4. 機械学習

複数の文章に対して、特徴ベクトルと、正解クラスIDのリストがあれば、機械学習できます。

クラスIDを以下のように定義しました。

```
0: 'dokujo-tsushin'
1: 'it-life-hack'
2: 'kaden-channel'
3: 'livedoor-homme'
4: 'movie-enter'
5: 'peachy'
6: 'smax'
7: 'sports-watch'
8: 'topic-news'
```

そうすると、機械学習させるのはこんな感じになりました。
多クラス分類ということで、ランダムフォレスト使っています。

``` estimationtest.py
from sklearn.ensemble import RandomForestClassifier

# 1個目がITライフハックのある記事、 2個目が独女通信のある記事の特徴ベクトル
data_train = [[0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0, 1.0, 3.0, 1.0, 3.0, 1.0, 3.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0],[2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 3.0, 1.0, 1.0, 2.0, 1.0, 1.0, 1.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 1.0, 2.0, 1.0, 1.0]]

# 正解のラベル
label_train = [1,0]  # 1: ITライフハック、 0: 独女通信


estimator = RandomForestClassifier()

# 学習させる
estimator.fit(data_train, label_train)

```

学習おしまい


##5.未知のデータのクラスを当てる

未知のデータを予測するにはこんな感じ。
今、未知のデータないので、試しにdata_trainをもういっかいぶっこんでみます。


``` estimationtest.py
# 予測
label_predict = estimator.predict(data_train)
print(label_predict)
```

```結果
[1 0] 
```
1個目がITライフハック、2個目が独女通信なので、あってる！
とりあえず動作確認できた。

## 全部の記事でやってみる
ということで、これをもとに、7000記事全部で学習するプログラムを書いてみました。

全体はこっちに置きました。
https://github.com/yasunori/Random-Forest-Example

やってることは変わってないですが、学習してないデータで試したかったので、 `train_test_split` を使って、学習用のデータと試験用のデータ（未知というていで）に分けてやっています。

```estimation.py
# 6割を学習用、 4割を試験用にする
data_train_s, data_test_s, label_train_s, label_test_s = train_test_split(data_train, label_train, test_size=0.4)

# 学習用に切り出したやつだけで学習
estimator.fit(data_train_s, label_train_s)

# 予測。正解が分かってる場合は、predict関数じゃなくてこうやると、正解率出してくれる
print(estimator.score(data_test_s, label_test_s))
```

```結果
0.857491856678
```

86%の正解率。まあまあ。大体なんとなーく分類できるってことか。
ちなみに、`train_test_split` がランダムなので毎回結果変わります。


## チューニングする
ところで、scikit-learnのランダムフォレストにはこれだけのパラメタがある。
http://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html


例えばこのうち、`n_estimators`。デフォルトは10なんだけど、木の数は、特徴量をNとしたとき N^(1/2) が良いんですって。なるほど。理由が全然分からん。
http://d.hatena.ne.jp/shakezo/20121221/1356089207

全然分からんけど、今回特徴ベクトルの次元は6000くらいなので、80とかなのかな……

良いパラメタを見つけるのに、グリッドサーチという方法があるんだそうです。scikit-learnでグリッドサーチは簡単にできました。

超絶参考にしたページ
http://qiita.com/sotetsuk/items/16ffd76978085bfd7628

`GridSearchCV` に分類器と変化させたいパラメタのdictを渡してあげればいいだけっぽい。

こんな感じでやってみました。

```estimation.py

# この掛け合わせを試す
tuned_parameters = [{'n_estimators': [10, 30, 50, 70, 90, 110, 130, 150], 'max_features': ['auto', 'sqrt', 'log2', None]}]

clf = GridSearchCV(RandomForestClassifier(), tuned_parameters, cv=2, scoring='accuracy', n_jobs=-1)
clf.fit(data_train_s, label_train_s)

print("ベストパラメタを表示")
print(clf.best_estimator_)

print("トレーニングデータでCVした時の平均スコア")
for params, mean_score, all_scores in clf.grid_scores_:
    print("{:.3f} (+/- {:.3f}) for {}".format(mean_score, all_scores.std() / 2, params))

y_true, y_pred = label_test_s, clf.predict(data_test_s)
print(classification_report(y_true, y_pred))


```
ちなみにこれ僕のMacBookAirだと2時間くらい4コアフル回転でした。



```結果
ベストパラメタを表示
RandomForestClassifier(bootstrap=True, compute_importances=None,
            criterion=gini, max_depth=None, max_features=auto,
            min_density=None, min_samples_leaf=1, min_samples_split=2,
            n_estimators=90, n_jobs=1, oob_score=False, random_state=None,
            verbose=0)
トレーニングデータでCVした時の平均スコア
0.847 (+/- 0.000) for {'n_estimators': 10, 'max_features': 'auto'}
0.880 (+/- 0.002) for {'n_estimators': 30, 'max_features': 'auto'}
0.883 (+/- 0.002) for {'n_estimators': 50, 'max_features': 'auto'}
0.887 (+/- 0.001) for {'n_estimators': 70, 'max_features': 'auto'}
0.893 (+/- 0.001) for {'n_estimators': 90, 'max_features': 'auto'}
0.892 (+/- 0.003) for {'n_estimators': 110, 'max_features': 'auto'}
0.887 (+/- 0.002) for {'n_estimators': 130, 'max_features': 'auto'}
0.885 (+/- 0.000) for {'n_estimators': 150, 'max_features': 'auto'}
0.827 (+/- 0.000) for {'n_estimators': 10, 'max_features': 'sqrt'}
0.874 (+/- 0.002) for {'n_estimators': 30, 'max_features': 'sqrt'}
0.878 (+/- 0.003) for {'n_estimators': 50, 'max_features': 'sqrt'}
0.886 (+/- 0.002) for {'n_estimators': 70, 'max_features': 'sqrt'}
0.885 (+/- 0.003) for {'n_estimators': 90, 'max_features': 'sqrt'}
0.885 (+/- 0.002) for {'n_estimators': 110, 'max_features': 'sqrt'}
0.888 (+/- 0.003) for {'n_estimators': 130, 'max_features': 'sqrt'}
0.892 (+/- 0.001) for {'n_estimators': 150, 'max_features': 'sqrt'}
0.773 (+/- 0.000) for {'n_estimators': 10, 'max_features': 'log2'}
0.848 (+/- 0.001) for {'n_estimators': 30, 'max_features': 'log2'}
0.862 (+/- 0.003) for {'n_estimators': 50, 'max_features': 'log2'}
0.872 (+/- 0.002) for {'n_estimators': 70, 'max_features': 'log2'}
0.870 (+/- 0.003) for {'n_estimators': 90, 'max_features': 'log2'}
0.876 (+/- 0.003) for {'n_estimators': 110, 'max_features': 'log2'}
0.878 (+/- 0.002) for {'n_estimators': 130, 'max_features': 'log2'}
0.877 (+/- 0.003) for {'n_estimators': 150, 'max_features': 'log2'}
0.845 (+/- 0.005) for {'n_estimators': 10, 'max_features': None}
0.857 (+/- 0.004) for {'n_estimators': 30, 'max_features': None}
0.860 (+/- 0.002) for {'n_estimators': 50, 'max_features': None}
0.864 (+/- 0.002) for {'n_estimators': 70, 'max_features': None}
0.862 (+/- 0.004) for {'n_estimators': 90, 'max_features': None}
0.862 (+/- 0.004) for {'n_estimators': 110, 'max_features': None}
0.860 (+/- 0.003) for {'n_estimators': 130, 'max_features': None}
0.859 (+/- 0.003) for {'n_estimators': 150, 'max_features': None}
             precision    recall  f1-score   support

          0       0.89      0.90      0.89       417
          1       0.92      0.91      0.91       444
          2       0.94      0.94      0.94       445
          3       0.94      0.57      0.71       226
          4       0.88      0.99      0.93       447
          5       0.80      0.85      0.83       412
          6       1.00      1.00      1.00       452
          7       0.90      0.98      0.94       447
          8       0.95      0.89      0.92       394

avg / total       0.91      0.91      0.91      3684
```

`n_estimators=90`, `max_features=auto` が一番良い値だったようです。
そのときの正解率は0.893。 ヤクザ。さっきより上がりました！ だいたい9割で当たる。ヤッター。

というわけで、なんとなく分類とチューニングをやってみました。
特徴ベクトル6000次元ってのが多すぎる？ このあたりを減らすともっと上がるのかもしれない、これは次回やります。

おしまい。