Doc2Vecを使って、文章認識モデルを構築してみた！

## はじめに
こんにちは。
先日、Nishikaのトレーニングコンペの芥川龍之介の文章を見分けられるかにチャレンジしましたので、
その技術要素について、実際のコードを用いて、簡単に紹介したいと思います。

## 文章判別までのざっくりとした流れ
まず一つ一つの技術要素を紹介する前に、全体の流れを紹介したいと思います。
全体の流れは以下の通りです。
①文章データの準備
②文章データを数値ベクトルに変換
③識別機に②のデータを与えて、学習させる
④③の識別機を使って、未知の数値ベクトルに変換した文章データを与えて、判断させる。
※今回はコンペに存在するデータを使っているので、実質①の工程は無視できます。
それでは、以降のセクションで上記の②～④の流れについて紹介していきたいと思います。

## 文章データを数値ベクトルに変換
前提として、識別機は文字を扱う事できないので、どうにかして文字を数値に置き換えないといけません。
ここでは、文字を数値ベクトルに変換する考え方と、
その拡張版である文章を数値ベクトルに変換する考え方を紹介したいと思います。

突然ですが、我々はどのようにして、文字の意味を理解しているのでしょうか。
例えば、「say」の意味は知らないという前提として、以下の文章があったとしましょう。
**`I say Hello you.`**
「say」は知らないですが、他の単語の意味は知ってるので、なんとなくでも「話す」の意味があるのでは？と予測できるかと思います。
これは前後の単語から「say」の意味を予測した事になります。
この考えを使ったモデルがCBowというモデルになります。
CBowのイメージは以下のようなイメージです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1616757/fd24b63d-ddbf-5425-cf28-15f2c3880b93.png)
上記図のように、まず単語をone-hot-vector表現に変換します。
（今回の文章の場合は「.」も含めて5つあるので、5次元ベクトルになります。）
one-hot-vector表現に変換後に、Affine変換を行います。（情報をコンパクトにする為）
（中間層のAffine変換の写像後の次元は任意。）
Affine変換後に、元の単語ベクトルの次元に戻してあげる必要があるので、
元の次元に戻すために再度Affine変換を行い、Softmaxにより確率分布を算出します。
算出後、最も高い確率の単語を出力結果とします。
この出力結果の精度が良くなるようにCBowモデルを学習していき、Wのパラメータを調整させていきます。
(つまり、周辺単語から対象の単語を予測できるモデルを構築していく)
すると、学習できたモデルでは、単語に対応する重み行列（W1,W2...を列にもつ行列）が
単語を上手くエンコードしている数値ベクトルを列にもつ行列であると解釈する事ができます。
上記例でいくと、Iの単語の場合は、W1の数値ベクトルがIの単語を上手くエンコードした数値ベクトルになります。

この考えを拡張して、文章にも適用させたのが、dmpvというモデルになります。
dmpvでは、文章を扱うので、上記の図で言うと、文章中の単語以外に、文章ID（任意で決定できるID）という要素を加えて学習を行います。
CBowと同じで、文章IDに対応する重み行列の列ベクトルが文章をエンコードした数値ベクトルになります。
ここまでが、文章を数値ベクトル表現にする方法になります。

先の例では、英語の場合を考えました。
英語の文章の場合は、半角スペースを元手に単語を分割する事ができましたが、
今回のデータの芥川龍之介の文章は日本語なので、そうはいきません。
日本語の場合は、英語のような単語毎に区切り文字がないので、単純に単語分割はできないからです。
そこで、mecabという形態素解析エンジンを使って、日本語を単語毎に区切る分かち書きを実施します。
これによって、日本語の文章を単語毎に区切る事ができます。

それでは、実際に文章データを数値ベクトルに変換するコードの紹介です。
```python
import MeCab
import pandas as pd
import numpy as np
from gensim.models.doc2vec import Doc2Vec,TaggedDocument
import lightgbm as lgb
import os
import pickle
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score,confusion_matrix,ConfusionMatrixDisplay
from tqdm import tqdm
from imblearn.under_sampling import RandomUnderSampler
import matplotlib.pyplot as plt

mecab = MeCab.Tagger("-Ochasen")
df = pd.read_csv('./data/train.csv')
df = df.drop(columns='writing_id', axis=1)
texts = df['body'].values

# 文章を分かち書きにしてリスト形式で返却する
def apply_wakati(doc):
    result= []
    for d in mecab.parse(doc).splitlines():
        if len(d.split()) > 1:
            result.append(d.split()[0])
    return result

# 各文章からDoc2Vecの学習データを取得する
def get_doc2Vec_train(documentList):
    cnt = 0
    result = []
    for document in documentList:
        result.append(TaggedDocument(words=document,tags=[cnt])) #1つの文章に対して、与えた文章を表現する任意のタグを付与する。
        cnt += 1
    return result

document_list = []
for text in texts:
    document_list.append(apply_wakati(text))

if os.path.exists('doc2vec.model'):
    model = Doc2Vec.load('doc2vec.model')
else:
    # Doc2Vec用の学習データを取得
    doc_train = get_doc2Vec_train(document_list)

    # ハイパーパラメータの設定
    # https://deepage.net/machine_learning/2017/01/08/doc2vec.html のパラメータを参考にした
    dm = 1 #dm=0でDBOW dm=1 dmpvのアルゴリズムを使用する
    vector_size = 300
    window  = 5
    negative =5
    min_count = 1
    epochs = 1000
    model = Doc2Vec(doc_train,dm=dm, vector_size=vector_size, min_count=min_count, window =window ,epochs=epochs,negative=negative) 
    model.save('doc2vec.model')

# モデルから文章データを与えて、文章ベクトルを取得する
file = 'document_train_save.npy'
document_vecs=[]
if not RETRAIN_VEC_FLAG and os.path.exists(file):
    document_vecs=np.load(file)
else:
    for document in tqdm(document_list):
        model.random.seed(0)
        document_vecs.append(model.infer_vector(document))
    document_vecs=np.array(document_vecs)
    np.save(file, document_vecs)
```
コードでは実行する度に、再度、学習・数値ベクトルの作成しないように、
学習したモデル・変換した数値ベクトルを保存するようにしています。
以上が、文章を数値ベクトルに変換する方法でした。

※今回紹介したのは、周辺単語から間の単語を予測させる事で、単語に対応する数値ベクトルを獲得する方法を紹介しましたが、
　他にも、１つだけ単語を入力させて、周辺単語を予測させる事で、単語に対応する数値ベクトルを獲得する方法もあります。

## 識別機の学習
それでは、識別機の学習について紹介したいと思います。
今回しようしたモデルは、lightGBMというモデルを使用しました。
このモデルは、勾配ブースティング決定木の一つのモデルです。
（XGBoostより軽量で高速なのが特徴のモデルです。）

以下がコードになります。
```python
def lgbm_train(X_train, y_train):
   model = lgb.LGBMClassifier(
       objective='binary',
       metric='auc',
       boosting_type='gbdt',
       num_leaves=30,
       learning_rate=0.01,
       feature_fraction=0.9,
       subsample=0.8,
       max_depth=12,
       min_data_in_leaf=12)
   model.fit(X_train, y_train)
   return model

def bagging(seed, X_train, y_train):
   # アンダーサンプリング
   sampler = RandomUnderSampler(random_state=seed, replacement=True)
   X_resampled, y_resampled = sampler.fit_resample(X_train, y_train)
   model_bagging = lgbm_train(X_resampled,y_resampled)
   return model_bagging

# LGBの学習データの用意
X = document_vecs
y = df['author'].to_numpy()
_,X_valid,_,y_valid = train_test_split(X,y,test_size=0.20,shuffle=True, random_state=2)
unsambles = 10 #アンサンブルの数
models = []
for i in range(unsambles):
    file = './models/trained_model_' + str(i) + '.pkl'
    # ロード
    if os.path.exists(file):
        models.append(pickle.load(open(file, 'rb')))
    else:
        if False == os.path.isdir('models'):
            os.mkdir('models')
        model = bagging(i, X, y)
        pickle.dump(model, open(file, 'wb'))
        models.append(model)

```
今回のデータは不均衡データと言って、
芥川龍之介と芥川龍之介でない文章データのバランスが取れていないデータになっています。
（芥川龍之介でないデータの方が圧倒的に多い。）
その為、そのまま学習すると、「芥川龍之介でない」と判断しがちのモデルになってしまうので、
アンダーサンプリングという手法を使って、芥川龍之介でないデータの数を、芥川龍之介であるデータの数に合わせるようにしています。
但し、モデルに与えるデータの数を減らした事で、未知のデータに対しての予測値の分散が大きくなってしまいます。
（データが少ないので、モデルがデータに完全にフィッティングできてしまう為。）
そこで、予測の分散値を減少させる為に、アンサンブル法の一つである、バギングという手法を使って、
オーバーフィッティングしているモデルを複数個用意するようにしています。
（後の予測の際に使用する為）
以上が、モデルの学習方法でした。

## 未知データに対しての予測
先ほど作成した、オーバーフィッティングしているモデル群を使って、
各モデルに予測値を出力させて、多数決により最終的な予測としていきたいと思います。
それでは、コードになります。
```python
#評価
# 指定されたリストの要素をboolean型に変換する
def to_boolean(li):
    return [bool(i) for i in li]

predicts=np.array([])
for m in models:
    # 初回は1回目のデータをコピーする
    if 0 == len(predicts):
        predicts=np.copy(m.predict(X_valid))
    else:
        #行方向に結合する
        predicts = np.vstack((predicts,m.predict(X_valid)))
# 多数決で予測結果を決める(アンサンブル数の半数より大きいかどうかで決定する)
predict = np.sum(predicts,axis=0) > (unsambles/2) 
cmx = confusion_matrix(y_valid, predict)
cmd = ConfusionMatrixDisplay(cmx)
cmd.plot(cmap=plt.cm.Blues)
plt.show()

y_valid = to_boolean(y_valid)
f1 = f1_score(y_valid, predict)
print(f1)
```
モデルの評価にはF値を使って評価しています。
F値とは、再現率と適合率の調和平均値の事です。
適合率とは、モデルが芥川龍之介と判断した数の中で、実際に芥川龍之介であった比率の事を言います。
再現率とは、実際に芥川龍之介であった文章の中で、モデルが芥川龍之介と判断した比率の事を言います。
調和平均は、比率の平均を求める時に使用するもので、今回でいくと適合率と再現率の平均の良さを表現する事になります。
今回作成したモデルのF値は、0.8という値となりました。（F値は0~1.0を取り、1.0に近いほど良いです。）
0.8なので良い評価となりそうですが、念のため、混合行列を確認してみます。

![アンサンブル結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1616757/608bf5ac-8073-18d1-5b56-f2cf8670b6d1.png)

混合行列を確認すると、
このモデルは「芥川龍之介でない」と確実にわかった時しか、「芥川龍之介ではない」と判断せず、
逆に少しでも芥川龍之介の要素があれば、「芥川龍之介である」と判断するモデルという事がわかります。
この世の中、本を書いてる人・書いた人は何万人もいると思いますが、
その人たち全員が芥川龍之介のような書き方をしない・しなかったとは限りません。
なので、少しでも芥川龍之介の文章に似た要素があれば、
「芥川龍之介である」と判断するのは少し無理やりな気がします。。。
この辺りは、学習方法をチューニングするか、
この記事を読んでいる読者の方で、芥川龍之介の本だけに現れる独特の表現を知っていれば、
その要素をモデルに与える事で、今回作成したモデルより、マシなモデルが作成できると思います！

それでは今回作成したモデルで、予測した結果をコンペで提出した結果です。
![コンペ結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1616757/a8a7074d-3dd0-c0b7-0195-703bf96d5aef.png)


以上で、今回の紹介は終わりたいと思います。最後まで読んでいただいてありがとうございました。