fastTextとDoc2Vecのモデルを作成してニュース記事の多クラス分類の精度を比較する

# 概要

自然言語処理で文書をベクトル化する手法として、fastText と Doc2vec のどちらが良いのかと思い、試してみることにしました、という趣旨の記事です。

## ソースコード

今回の実験のために実装したコードはこちらに上げてあります。
https://github.com/kazuki-hayakawa/fasttext_vs_doc2vec

また、記事本文中ではライブラリのimport文などは省略している箇所もありますので、それらに関しては上記ソースコードをご覧ください。

### ディレクトリ構成

以下のようなディレクトリ構成です。２つのモデルを作成して、最終的に `classifier.py` で文書の分類を実行します。

```
fasttext_vs_doc2vec
├ dataset
│ ├ news_text (ニュース記事本文格納ディレクトリ)
│ └ make_dataset.py 
├ lib
│ └ mecab_split.py 
├ model_doc2vec
│ └ make_d2v_model.py 
├ model_fasttext
│ ├ fthandler.py 
│ └ make_ft_model.py 
└ classifier.py 
```

## ニュース記事データセット

日本語のニュース記事データセットはlivedoorニュースコーパスを利用しました。
https://www.rondhuit.com/download.html

ニュース記事は、 `dokujo-tsushin, it-life-hack, kaden-channel, livedoor-homme, movie-enter, peachy, smax, sports-watch, topic-news` の9種類の分類として与えられています。


# 自然言語モデルの作成

## 前処理など準備

### データセットの成形

日本語記事を今回の実験用に成形します。記事本文は `/dataset/news_text/` ディレクトリ配下に置いておきます。なお、 `LICENSE.txt` など利用しないテキストファイルはあらかじめ削除しておきます。

今回はモデルの作成、教師データとしてテキストの8割を、未知のテキスト、バリデーション用データとして2割を使用します。

それぞれ分割し、別のcsvファイルとして作成しておきます。
ちなみに、トレーニング用データは5,894個、バリデーション用データは1,473個の文書があります。

```make_dataset.py
# -*- coding: utf-8 -*-
import pandas as pd
import glob
import os

classes = ["dokujo-tsushin","it-life-hack","kaden-channel","livedoor-homme",
           "movie-enter","peachy","smax","sports-watch","topic-news"]

df_texts = pd.DataFrame(columns=['class','body'])

if __name__ == '__main__':

    # テキストの抽出
    for c in classes:
        filepath = './news_text/' + c + '/*.txt'
        files = glob.glob(filepath)

        for i in files:
            f = open(i)
            text = f.read()
            text = text.replace("\u3000","")
            f.close()
            row = pd.Series([c, "".join(text.split("\n")[3:])], index=df_texts.columns)
            df_texts = df_texts.append(row, ignore_index=True)


    # トレーニング、バリデーションで 8:2 に分割
    df_train = pd.DataFrame(columns=['class','body'])
    df_validation = pd.DataFrame(columns=['class','body'])

    for c in classes:
        df_text_c = df_texts[df_texts['class'] == c]

        df_text_c_train = df_text_c.head(round(len(df_text_c)*0.8))
        df_train = df_train.append(df_text_c_train, ignore_index=True)

        df_text_c_validation = df_text_c.tail(round(len(df_text_c)*0.2))
        df_validation = df_validation.append(df_text_c_validation, ignore_index=True)

    # テキストの保存
    df_train.to_csv('dataset_train.csv')
    df_validation.to_csv('dataset_validation.csv')
```

### 単語の分かち書き方法

文章を単語に分かち書きするにはMeCabを使います。以下のクラスを作成し用いています。
今回は特徴を拾いやすくするために名詞だけ抽出してみることにしました。係り受けなどは考慮しないので、形容詞、副詞、動詞などは今回は含めていません。辞書は neologd を使っています。

```mecab_split.py
import sys
import MeCab
import unicodedata

class mecab_split():
    """ mecabで分かち書きなどを行う処理まとめ """
    def __init__(self):
        pass

    @staticmethod
    def split(text):
        #文字コード変換処理。変換しないと濁点と半濁点が分離する。
        text = unicodedata.normalize('NFC', text)

        result = []
        tagger = MeCab.Tagger('-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd')
        tagger.parse('') #parseToNode前に一度parseしておくとsurfaceの読み取りエラーを回避できる

        nodes = tagger.parseToNode(text)
        while nodes:
            if nodes.feature.split(',')[0] in ['名詞']:
                word = nodes.surface
                result.append(word)
            nodes = nodes.next
        return ' '.join(result)
```

## 自然言語モデルの作成

### Doc2Vec でモデル作成

`gensim` というライブラリに Doc2Vec が実装されているのでそれを使います。手法は dmpv という手法を用います。

この手法で学習させる際には文書idをタグとして持つので、以下のように書きます。

```python
# coding: utf-8
import sys
import os
import pandas as pd
from gensim.models.doc2vec import Doc2Vec
from gensim.models.doc2vec import TaggedDocument

sys.path.append(os.pardir)
from lib.mecab_split import mecab_split as ms

dataset_file = '../dataset/dataset_train.csv'

df_text = pd.read_csv(dataset_file)
trainings = [TaggedDocument(words = ms.split(body), tags = [i])
             for i, body in enumerate(df_text['body'])]
```

モデルを学習させます。dmpv を使うときは `dm=1` を明示します。ハイパーパラメータは『[Doc2Vecの仕組みとgensimを使った文書類似度算出チュートリアル - DeepAge](https://deepage.net/machine_learning/2017/01/08/doc2vec.html)』を参考にさせていただきました。dmpv についても詳しく書かれているので、理論的背景を知るにもおすすめです。

```python
# モデルの学習, dmpvで学習させる
model = Doc2Vec(documents=trainings, dm=1, size=300, window=5, min_count=5)

# モデルの保存
model.save('doc2vec.model')
```

これで Doc2Vec のモデルの作成は完了です。

### fastText でモデル作成

fastText では skip-gram という手法を用いて単語ベクトルの学習をします。fastText と skip-gram については『[Facebookが公開した10億語を数分で学習するfastTextで一体何ができるのか - DeepAge](https://deepage.net/bigdata/machine_learning/2016/08/28/fast_text_facebook.html)』に書かれているので参照してみてください。

fastText を使うための関数を集めたクラスを作成しました。以下を使っていきます。

```fthandler.py
# coding: utf-8
from __future__ import print_function
import os
import sys
import re
import fasttext as ft
import numpy as np
import pandas as pd

sys.path.append(os.pardir)
from lib.mecab_split import mecab_split as ms

class FastTextHandler(object):

    def __init__(self, dim=200, epoch=5, window_size=5):
        super(FastTextHandler, self).__init__()
        self.__params_fasttext = {
            'dim': dim,
            'epoch': epoch,
            'ws': window_size
        }
        self.model = None
        self.df_word = pd.DataFrame()

    def generate_model(self, file_name='doc.txt', model_name='model',
                       load=True):
        u"""document名・model名を指定してmodelを生成する。

        Args:
            file_name : String,  形態素解析したドキュメントデータ
            model_name: String,  skip-gramの結果生成されるファイルの名前
            load      : Boolean, model生成後、そのmodelをloadするか否か
        """
        ft.skipgram(file_name, model_name, **self.__params_fasttext)
        if load:
            self.model = ft.load_model(model_name + '.bin')
            self.df_word = self.model2df()

    def load(self, model_name='model'):
        u"""model名を指定してloadする。"""
        self.model = ft.load_model(model_name + '.bin')
        self.df_word = self.model2df()

    def model2df(self):
        u"""fasttext.modelをpd.DataFrameに変換する。"""
        return pd.DataFrame([[i + 1, v, self.model[v]] for i, v in
                             enumerate(self.model.words)],
                            columns=['id', 'content', 'vector'])

    def set_df_text(self, dataframe):
        """ テキストのデータフレームを整形してベクトル追加 """
        if 'body' not in dataframe.columns:
            print(u'文書がありません。body の名前でカラムを作成してください。')
            return

        else:
            df_text = dataframe.copy()
            if 'vector' not in df_text.columns:
                df_text['vector'] = None


            df_text['body'] = df_text['body'].str.replace(r'<[^>]*>|\r|\n',' ')
            df_text['body'] = df_text['body'].str.replace(r'https?://[\w/:%#\$&\?\(\)~\.=\+\-…]+',' ')
            df_text['vector'] = [
                self.text2vec(body, consider_length=True) for body
                in df_text['body'].astype('str').values]

            return df_text

    def combine_word_vectors(self, words, is_mean=True):
        u"""指定されたワードを組み合わせて新たなベクトルを生成。

        Args:
            words  : Array,   modelに含まれているワード
            is_mean: Boolean, np.meanかnp.sumを使うかのフラグ

        Returns:
            Array, self.dimと同じ次元のベクトル
            np.array([0.123828, -0.232955, ..., 2.987532])
        """
        # modelをloadしていない場合は警告
        if self.model is None:
            print('Model is not set.')
            print('Try `cls.load` or `cls.generate_model`.')
            return

        # modelに含まれているワードかどうかチェック
        words = list(filter(lambda x: x in self.model.words, words))
        if not words:
            print('No words are in this model.')
            return list(np.zeros(self.__params_fasttext['dim']))

        # 指定された関数を適用してベクトルを生成
        apply_func = np.mean if is_mean else np.sum
        return apply_func(list(map(lambda x: self.model[x], words)), axis=0)

    def text2vec(self, text, consider_length=True):
        u"""文章を形態素解析してベクトルの和に変換する。

        Args:
            text           : String , ベクトル化する文章
            consider_length: Boolean, 文章の長さを考慮するか否かのフラグ
        """
        return list(self.combine_word_vectors(
            ms.split(text), is_mean=consider_length))

```

また、fastText での学習に合わせた形でテキストを成形し直します。

```python
def make_wordsfile():
    dataset_file = '../dataset/dataset_train.csv'
    words_file = 'newswords.txt'

    # ニュースのテキストデータを取り出し分かち書きを行う
    df_text = pd.read_csv(dataset_file)
    wordlist = [ms.split(body) for body in df_text['body'].astype('str').values]
    words = ' '.join(wordlist)

    with open(words_file, 'w') as file:
        file.write(words)
```

これらを利用して fastText での学習を実行します。

```python
# coding: utf-8
import sys
import os
import pandas as pd

sys.path.append(os.pardir)
from fthandler import FastTextHandler
from lib.mecab_split import mecab_split as ms

model_name = 'ft_model'
epoch = 10
window_size = 10
dim = 200

def main():
    # ニュースのテキストから fasttext 用に分かち書きした単語テキストファイル作成
    make_wordsfile()

    # fasttext のインスタンス作成
    fth = FastTextHandler(epoch=epoch, window_size=window_size, dim=dim)

    # fasttext の skip-gram で学習モデル作成
    fth.generate_model(file_name=words_file, model_name=model_name)

if __name__ == '__main__':
    main()
```

これを実行すると学習モデルが生成されて `model_name` で指定したファイル名で保存されます。


# 文書の多クラス分類

今回の文書は複数のカテゴリの分類になるので、 Random Forest を用いての多クラス分類を行います。 `dataset_train.csv` が訓練用データ、 `dataset_validation.csv` をバリデーションデータとして用います。
データはそれぞれ以下のように前処理して DataFrame型にしておきます。

```python
# クラス名をクラスIDに変換するための辞書
classToClassid = {
    'dokujo-tsushin' : 0,
    'it-life-hack' : 1,
    'kaden-channel' : 2,
    'livedoor-homme' : 3,
    'movie-enter' : 4,
    'peachy' : 5,
    'smax' : 6,
    'sports-watch' : 7,
    'topic-news' : 8
}

def preprocessing(filepath):
    """ データセットの読み込み、前処理 """
    dataset = pd.read_csv(filepath)
    dataset['class_id'] = [classToClassid[c] for c in dataset['class']]

    return dataset

dataset_train_path = './dataset/dataset_train.csv'
dataset_validation_path = './dataset/dataset_validation.csv'

# トレーニング、バリデーション用データ作成
df_train = preprocessing(dataset_train_path)
df_validation = preprocessing(dataset_validation_path)
```

## 各モデルを利用し文書をベクトル化する

日本語の文章を計算可能なようにベクトルに変換します。Doc2vec と fastText で文書ベクトルの精製方法が異なるので、それぞれ説明します。

### Doc2Vec で文書ベクトルを作成する

またも gensim を利用します。実装は以下のとおりです。
( `dimension_reduction` 関数については後述します。)

```python 
import pandas as pd
from lib.mecab_split import mecab_split as ms

def set_d2v_vector(dataframe, d2v_instance, dim=50):
    """ doc2vec モデルを使って文章をベクトル化し、カラムに加える

    Parameters
    ----------
        dataframe : DataFrame
            body カラムを持つデータフレーム
        d2v_instance : class instance
            モデルロード済み Doc2Vec クラスのインスタンス
        dim : int, default 50
            圧縮ベクトルの次元数

    Returns
    -------
        df_vecadd : DataFrame
            次元圧縮済みベクトルのカラムを追加したデータフレーム

    """
    df_tmp = dataframe.copy()

    # doc2vec でベクトル化するには文書を単語のリストとして保つ必要があるので、変形する
    df_tmp['doc_words'] = [ms.split(body).split(' ') for body in df_tmp['body']]

    # 文書ベクトル作成
    df_tmp['vector'] = [d2v_instance.infer_vector(doc_words) for doc_words in df_tmp['doc_words']]

    # ベクトルの次元を圧縮
    df_tmp = dimension_reduction(df_tmp, dim)

    # 不要なカラムを削除
    del df_tmp['body']
    del df_tmp['class_id']

    df_vecadd = pd.merge(dataframe, df_tmp, how='left',
                        left_index=True, right_index=True)

    return df_vecadd
```

これを用いて、以下のように文書をベクトル化します。

```python 
from gensim.models.doc2vec import Doc2Vec

# モデルのロード
d2v_model_path = './model_doc2vec/doc2vec.model'
d2v = Doc2Vec.load(d2v_model_path)

# 圧縮後の文書ベクトルの次元数
vector_dim = 20

# ベクトルデータ作成
train_data = set_d2v_vector(train_data, d2v, dim=vector_dim)
validation_data = set_d2v_vector(validation_data, d2v, dim=vector_dim)
```

### fastText で文書ベクトルを作成する

fastText のモデルだと単語ベクトルしか作れません。この単語ベクトルを重ね合わせることで、文書ベクトルを作成します。具体的には、文章に含まれるすべての単語（今回は全ての名詞）の単語ベクトルの各成分の平均値を文書ベクトルとしています。数式で書くと、

```math
V_{sentence} = \frac{\sum V_{word}}{N}
```
です。これは先程紹介した、 `fthandler.py` のクラス内に実装されています。

これを使い、文書ベクトルを以下のように作成します。
( `dimension_reduction` 関数については後述します。)

```python
def set_ft_vector(dataframe, fth_instance, dim=50):
    """ fasttext モデルを使って文章をベクトル化し、カラムに加える

    Parameters
    ----------
        dataframe : DataFrame
            body カラムを持つデータフレーム
        fth_instance : class instance
            モデルロード済み FastTextHandler クラスのインスタンス
        dim : int, default 50
            圧縮ベクトルの次元数

    Returns
    -------
        df_vecadd : DataFrame
            次元圧縮済みベクトルのカラムを追加したデータフレーム

    """
    # テキストのベクトル化
    df_tmp = fth_instance.set_df_text(dataframe)

    # ベクトルの次元を圧縮
    df_tmp = dimension_reduction(df_tmp, dim)

    # 不要なカラムを削除
    del df_tmp['body']
    del df_tmp['class_id']

    df_vecadd = pd.merge(dataframe, df_tmp, how='left',
                        left_index=True, right_index=True)

    return df_vecadd
```

これを用いて、以下のように文書をベクトル化します。

```python 
from model_fasttext.fthandler import FastTextHandler

# モデルのロード
ft_model_path = './model_fasttext/ft_model'
fth = FastTextHandler()
fth.load(model_name=ft_model_path)

# 圧縮後の文書ベクトルの次元数
vector_dim = 20

# ベクトルデータ作成
train_data = set_ft_vector(train_data, fth, dim=vector_dim)
validation_data = set_ft_vector(validation_data, fth, dim=vector_dim)
```

## ベクトルを主成分分析により次元を圧縮する

Doc2Vec で作成された文書ベクトルは300次元、fastText により作成された文書ベクトルは200次元あり、次元数が大きいので、主成分分析により次元数を圧縮することで各ベクトルの成分の特徴がはっきりするので分類の精度が向上すると考えました。 `scikit-learn` ライブラリに実装されている `PCA` を用いて次元削減関数を作成します。
これがテキストのベクトル化を行う関数内で書かれていた `dimension_reduction` 関数となります。

```python 
from sklearn.decomposition import PCA

def dimension_reduction(data, pca_dimension):
    """ dataframeの vector カラムのベクトルを任意の次元に圧縮する

    Parameters
    ----------
        data : DataFrame
            vector カラムを持つデータフレーム
        pca_dimension : int
            PCAで圧縮したい次元数

    Returns
    -------
        pca_data : DataFrame
            vector カラムを次元圧縮したデータフレーム
    """

    # 文章ベクトルの次元圧縮
    pca_data = data.copy()
    pca = PCA(n_components=pca_dimension)
    vector = np.array([np.array(v) for v in pca_data['vector']])
    pca_vector = pca.fit_transform(vector)
    pca_data['pca_vector'] = [v for v in pca_vector]
    del pca_data['vector']
    pca_data.rename(columns={'pca_vector':'vector'}, inplace=True)

    return pca_data
```

## Random Forest による多クラス分類

文章をテキスト形式から圧縮したベクトル形式に表現することができたので、これを用いて文書のクラス分類を実施します。多クラス分類ははじめに書いたとおり Random Forest で実施します。 Random Forest のハイパーパラメータはグリッドサーチで決定します。
Random Forest での多クラス分類を行い、正解率を算出する関数は以下のとおりです。

```python 
from sklearn.ensemble import RandomForestClassifier
from sklearn.grid_search import GridSearchCV
from sklearn.metrics import accuracy_score

# RandomForest のグリッドサーチで探索するパラメータ
params = {
    'min_samples_leaf' : [i+1 for i in range(10)],
    'max_depth' : [i+1 for i in range(5)]
}

def accuracy_randomforest_calassifier(train_data, validation_data):
    """ ランダムフォレストでクラス分類を行い、正解率を算出
    Parameters
    ----------
        train_data : DataFrame
        validation_data : DataFrame
            いずれも vector, class_id のカラムを持つ

    Returns
    -------
        acc : float
    """

    X_train = np.array([np.array(v) for v in train_data['vector']])
    X_validation = np.array([np.array(v) for v in validation_data['vector']])
    y_train = np.array([i for i in train_data['class_id']])
    y_validation = np.array([i for i in validation_data['class_id']])

    # RandomForest モデル学習
    mod = RandomForestClassifier()
    clf = GridSearchCV(mod, params)
    clf.fit(X_train, y_train)

    # 予測, 正解率算出
    y_pred = clf.predict(X_validation)
    acc = accuracy_score(y_validation, y_pred)

    return acc
```

# 分類してみた結果

実際にニュースの文書を分類してみた結果がこちら。

```
================================
accuracy: fasttext model
0.346

================================
accuracy: doc2vec model
0.289

================================
```

頑張って実装した割に正解率が30%前後というなんとも微妙な結果に…
言語モデルのトレーニングに用いた文章の数が約6,000個程度しかなく、おそらく学習不足なのでしょう。うまく文章ベクトルが作れていないと思われます。もっと巨大なデータセットを用いて学習したかったのですが、~~データ収集がめんどくさい~~ 学習時間がかかってしまうのと、ちゃちゃっと確かめたかったのでそこまでの精度を要求していなかったというのが理由で今回はこのニュース記事のみで試してます。

ともあれ、簡単な検証でしたが、今回は6%ほど fastText のほうが Doc2Vec よりも精度が高いという結果になりました。

他にもよいモデル構築手法がある、別の学習データセットならもっと精度上げられるなどありましたらご教授いただけますと幸いです。

大変長文の記事でしたが、ここまでお読み下さりありがとうございました！


# 参考文献

・[livedoorニュースコーパス](https://www.rondhuit.com/download.html)
→ ニュース記事を利用しました。
・[[gensim]Doc2Vecの使い方 - Qiita](https://qiita.com/asian373asian/items/1be1bec7f2297b8326cf)
→ Doc2Vecは初めて使ったのでこちらを参考にさせていただきました。
・[gensim: models.doc2vec – Deep learning with paragraph2vec](https://radimrehurek.com/gensim/models/doc2vec.html)
→ gensim提供のDoc2Vec公式ドキュメントです。
・[Doc2Vecの仕組みとgensimを使った文書類似度算出チュートリアル - DeepAge](https://deepage.net/machine_learning/2017/01/08/doc2vec.html)
→ Doc2vecのモデル学習の際のハイパーパラメータなど参考にさせていただきました。
・[Facebookが公開した10億語を数分で学習するfastTextで一体何ができるのか - DeepAge](https://deepage.net/bigdata/machine_learning/2016/08/28/fast_text_facebook.html)
→ fastText における skip-gram 手法について勉強させていただきました。