書籍「15Stepで踏破 自然言語処理アプリケーション開発入門」をやってみる - 3章Step12メモ「Convolutional Neural Networks」

# 内容
[15stepで踏破 自然言語処理アプリケーション入門](https://www.amazon.co.jp/dp/4865941320) を読み進めていくにあたっての自分用のメモです。
今回は3章Step12で、自分なりのポイントをメモります。
CNN自体は大体勉強したことがあるので、内容はざっくりと。

# 準備
- 個人用MacPC：MacOS Mojave バージョン10.14.6
- docker version：Client, Server共にバージョン19.03.2

# 章の概要
前章ではword embeddingsを導入することにより、単語の分散表現を特徴量として扱えるようになれた。
ただ、文レベルの特徴量に仕立てるには分散表現の合計や平均を取る必要があり、それだけだと予測がBoW系に劣ってしまう。
この章では単語の分散表現を文に対応する形で並べた列を入力とした畳み込みニューラルネットワーク（CNN）を構築する。
馴染みのある画像解析に用いるCNNは二次元なので、自然言語処理（テキスト分類など）で扱う際のCNNは一次元であることに注意する。

# 12.1 ~ 12.4

|CNNの層 |内容 |
|--- |--- |
|Convolutional layer |・入力<br>　・word embeddingsによって得られた単語の分散表現列<br>　・CNN層を重ねる場合、前層のPooling layerの出力列<br><br>・文を構成する各単語の分散表現列の長さを揃える<br>　・超えた長さは無視する<br>　・足りない分はゼロベクトルで埋める<br><br>・文構成の方向に対してkernel_sizeごとに、重みと掛け合わせてバイアスを加えて出力の一つとする<br>　・文構成の方向に対してstrideごとに同操作を繰り返すが、重みは前層と同じ重みを用いる：**weight sharing**|
|Pooling layer |・入力<br>　・Convolutional layerの出力列<br><br>・Max poolingやAverage poolingがあるが、非線形処理であるMax poolingの方が高性能<br><br>・文構成の方向に対してstrideごとに同操作を繰り返すことができるが、strideを設定せずに一括で処理する方法もある；**global max pooling, global average pooling**|
|fully-connected layer<br>(densely-connected layer;全結合層) |・多クラス分類するために多層パーセプトロンに入力したい<br>・pooling layerの出力は2次元配列なので、多層パーセプトロンに入力できる1次元配列に変換する|

# 12.5 KerasによるCNNの実装
## word embeddingの分散表現の平均を特徴量とする(識別器：SVC)
前章では分散表現を合計していたので、平均して実行してみた。

```python
import numpy as np
from gensim.models import Word2Vec
from sklearn.svm import SVC
from tokenizer import tokenize
from sklearn.pipeline import Pipeline

class DialogueAgent:
    def __init__(self):
        self.model = Word2Vec.load(
            './latest-ja-word2vec-gensim-model/word2vec.gensim.model')  # <1>

    def train(self, texts, labels):
        pipeline = Pipeline([
            ('classifier', SVC()),
        ])
        pipeline.fit(texts, labels)
        self.pipeline = pipeline

    def predict(self, texts):
        return self.pipeline.predict(texts)

    # 内容はStep11のものとほぼ同じ
    def calc_text_feature(self, text):
~~
#        return np.sum(word_vectors, axis=0)
        return np.average(word_vectors, axis=0)
```

```python:evaluate_dialogue_agent.py
from os.path import dirname, join, normpath

import pandas as pd
import numpy as np
from sklearn.metrics import accuracy_score

from <実装したモジュール名> import DialogueAgent

if __name__ == '__main__':
    BASE_DIR = normpath(dirname(__file__))

    # Training
    training_data = pd.read_csv(join(BASE_DIR, './training_data.csv'))

    dialogue_agent = DialogueAgent()
    X_train = np.array([dialogue_agent.calc_text_feature(text) for text in training_data['text']])
    y_train = np.array(training_data['label'])
    dialogue_agent.train(X_train, y_train)

    # Evaluation
    test_data = pd.read_csv(join(BASE_DIR, './test_data.csv'))
    X_test = np.array([dialogue_agent.calc_text_feature(text) for text in test_data['text']])
    y_test = np.array(test_data['label'])

    y_pred = dialogue_agent.predict(X_test)

    print(accuracy_score(y_test, y_pred))
```

## word embeddingの分散表現の合計/平均を特徴量とする(識別器：NN)
上記では識別器がSVCだったので、これをNNに変更してみる。
各単語の分散表現を平均しているので、分散表現の次元`texts.shape[1]`がKerasClassifierのinput_dimとして設定する。

```python
~~
    def _build_mlp(self, input_dim, hidden_units, output_dim):
        mlp = Sequential()
        mlp.add(Dense(units=hidden_units,
                      input_dim=input_dim,
                      activation='relu'))
        mlp.add(Dense(units=output_dim, activation='softmax'))
        mlp.compile(loss='categorical_crossentropy',
                    optimizer='adam')

        return mlp

    def train(self, texts, labels, hidden_units = 32, classifier__epochs = 100):
        feature_dim = texts.shape[1]
        print(feature_dim)
        n_labels = max(labels) + 1

        classifier = KerasClassifier(build_fn=self._build_mlp,
                                     input_dim=feature_dim,
                                     hidden_units=hidden_units,
                                     output_dim=n_labels)

        pipeline = Pipeline([
            ('classifier', classifier),
        ])

        pipeline.fit(texts, labels, classifier__epochs=classifier__epochs)

        self.pipeline = pipeline

    def predict(self, texts):
        return self.pipeline.predict(texts)
~~
```

## word embeddingの分散表現をそのまま特徴量とする（入力層：Embedding -> Flatten -> Dense）
word embeddingの分散表現は二次元配列なので、Flatten層を入れた後に、Dense層に入力する。

```python
    # モデル構築
    model = Sequential()
    model.add(get_keras_embedding(we_model.wv,
                                  input_shape=(MAX_SEQUENCE_LENGTH, ),
                                  trainable=False))

    model.add(Flatten())
    model.add(Dense(units=256, activation='relu'))
    model.add(Dense(units=128, activation='relu'))
    model.add(Dense(units=n_classes, activation='softmax'))
    model.compile(loss='categorical_crossentropy',
                  optimizer='rmsprop',
                  metrics=['accuracy'])
```

## word embeddingの分散表現をそのまま特徴量とする（入力層：Embedding -> CNN(Dense)）
word embeddingの分散表現をCNNのConvolutional layerに入力するが、kernel_sizeを分散表現の次元`x_train.shape[1]`にして実質Dense層と同じ構成にする。

```python
    # モデル構築
    model = Sequential()
    model.add(get_keras_embedding(we_model.wv,
                                  input_shape=(MAX_SEQUENCE_LENGTH, ),
                                  trainable=False))  # <6>

    # 1D Convolution
    model.add(Conv1D(filters=256, kernel_size=x_train.shape[1], strides=1, activation='relu'))
    # Global max pooling
    model.add(MaxPooling1D(pool_size=int(model.output.shape[1])))
    model.add(Flatten())
    model.add(Dense(units=128, activation='relu'))
    model.add(Dense(units=n_classes, activation='softmax'))
    model.compile(loss='categorical_crossentropy',
                  optimizer='rmsprop',
                  metrics=['accuracy'])
```

## word embeddingの分散表現をそのまま特徴量とする（入力層：Embedding -> CNN）
word embeddingの分散表現をCNNのConvolutional layerに入力する。
書籍通りなので詳細は省略。

```python:Embedding層
    Embedding(input_dim=word_num + 1,
             output_dim=embedding_dim,
             weights=[weights_with_zero],
             *args, **kwargs)
↓
# *args, **kwargsは実際はこんな感じ
    Embedding(input_dim=word_num + 1,
             output_dim=embedding_dim,
             weights=[weights_with_zero],
             input_shape=(MAX_SEQUENCE_LENGTH, ),
             trainable=False)
```

- trainable：学習時に重みを更新しない（すでに学習済みの重みを用いてEmbeddingするので学習による重み更新は不要）
- input_shape：Kerasで層をaddで追加する際、一番はじめの入力層に指定
- input_dim/output_dim：Embedding層の重みのin/outのdim。Embedding層の出力次元はoutput_dimと等しくなる

## 実行結果
|word embeddingsの分散表現 |識別器 |実行結果 |
|--- |--- |--- |
|合計 |SVC |0.40425531914893614 |
|平均 |SVC |0.425531914893617 |
|合計/平均 |NN |0.5638297872340425 /<br>0.5531914893617021 |
|行列のまま |Embedding -> Flatten -> Dense |0.5319148936170213 |
|行列のまま |Embedding -> CNN(Dense) |0.5 |
|行列のまま |Embedding -> CNN |0.6808510638297872 |

- 通常実装(Step01)：37.2%
- 前処理追加(Step02)：43.6%
- 前処理＋特徴抽出変更(Step04)：58.5%
- 前処理＋特徴抽出変更＋識別器変更RandomForest(Step06)：61.7%
- 前処理＋特徴抽出変更＋識別器変更NN(Step09)：66.0%
- 前処理＋特徴抽出変更(Step11)：40.4%
- 前処理＋特徴抽出変更＋識別器変更CNN(Step12)：68.1%