character-level CNNでクリスマスを生き抜く

この記事は [Retty Advent Calendar](http://qiita.com/advent-calendar/2016/retty) 18日目です。
昨日は@YutaSakataの[クリスマスプレゼントにはKotlin1.1が欲しいです](http://qiita.com/YutaSakata/items/e7d309a2ddf6c0e2072e)でした。

さて、もうすぐクリスマスですが、皆さん一緒に過ごすお相手はおられますか？
私？私はもちろんいます。この子が。
![mycat.jpg](https://qiita-image-store.s3.amazonaws.com/0/88387/dbc1ce1c-e194-5580-7d94-ece3b115997d.jpeg)


独りだと酒でも飲みに行きたくなりますよね？ちょっと奮発していい店でしっとり飲むのもいいものです。
ですが、そんなつもりで入った店がリア充どもの巣窟だったらどうでしょう？
せっかくの孤独のグルメタイムが台無しです。

そんな危険な店を事前に避けるため、Deep Learningの力をかりましょう。

# 用意するもの

- [keras](https://keras.io/ja/)
- お店の口コミ

[keras](https://keras.io/ja/)は[tensorflow](https://www.tensorflow.org/)か[theano](http://deeplearning.net/software/theano/)をバックエンドにして動くDeep Learning用のライブラリです。複雑なことをしようとすると結構面倒ですが、大抵のモデルについてはかなり簡単に書けます。今回はこれを使ってみます。

(2017/3/1 追記) 私はバックエンドにtensorflowを使っています。theanoだとCNNでのchannelの扱いの違いで下記のコードは動きません。ちょっとした修正で直ります。詳しくはコメント欄参照。

お店の口コミはRettyの口コミを使います。クローリングとかしなくてもいいのは中の人の特権ですね。

# やること

お店をリア充どもの巣窟とそうでないところに分類したい、ということでDeep Learningで分類器を作りましょう。
流れとしては、

1. Rettyのデート目的に設定されている店の口コミをデート目的店口コミとする
2. 上記以外の店の口コミをデート以外目的店口コミとする
3. 上記二つの口コミを教師として口コミ分類器を作る。
4. お店の口コミ全てを分類器にかけて、デート目的店口コミ率が高いところをリア充窟と認定する。

という感じです。

分類器の作り方は色々ありますが、今回は**character-level CNN**を利用します。

# character-level CNN

自然言語処理にDeep Learningを使うって話になるとLSTMとかがよく出てきますが、今回は使いません。使うのはCNNです。
character-level CNNにはとてもいい特徴があります。それは**分かち書きが要らない**ってことです。
character-level CNNは単語単位ではなく文字単位で処理を行うので、文を単語に分ける必要がないのです。
やり方の概要は以下のような感じです。

1. 文章を文字の配列に分解
2. 各文字をUNICODE値に変換
3. 固定長配列にする。（長い場合は打ち切り、短い場合は0 padding)
4. keras.layers.embeddings.EmbeddingでUNICODEの配列をベクトルの配列にする
5. ベクトル列をCNNにかける
6. 全結合層通して分類結果を返す

# 実装

ここからは具体的な実装を紹介します。

## モデル作り

まずはcharacter-level CNNのモデルから。超簡単です。

1. shapeが(batchサイズ, 最大文字列長)なInputを受け取る
2. Embeddingで各文字をembed_size次元に変換。ここでembのshapeは(batchサイズ, 最大文字列長, embed_size)。
3. Convolution2Dが受け取ってくれるshapeは(batchサイズ, 最大文字列長, embed_size, チャネル数)。チャネルは1でいいので、ここではReshapeで軸を1つ追加する。
4. **(ここ重要)** 同一の入力に対して複数のカーネルサイズで畳み込みをかけて、結果を結合する。
5. 結合したものを平坦にならして全結合層にかけ、最終的に1次元にする(0がデート目的店以外の口コミ、1がデート目的店口コミとなる）。

```py
def create_model(embed_size=128, max_length=300, filter_sizes=(2, 3, 4, 5), filter_num=64):
    inp = Input(shape=(max_length,))
    emb = Embedding(0xffff, embed_size)(inp)
    emb_ex = Reshape((max_length, embed_size, 1))(emb)
    convs = []
    # Convolution2Dを複数通りかける
    for filter_size in filter_sizes:
        conv = Convolution2D(filter_num, filter_size, embed_size, activation="relu")(emb_ex)
        pool = MaxPooling2D(pool_size=(max_length - filter_size + 1, 1))(conv)
        convs.append(pool)
    convs_merged = merge(convs, mode='concat')
    reshape = Reshape((filter_num * len(filter_sizes),))(convs_merged)
    fc1 = Dense(64, activation="relu")(reshape)
    bn1 = BatchNormalization()(fc1)
    do1 = Dropout(0.5)(bn1)
    fc2 = Dense(1, activation='sigmoid')(do1)
    model = Model(input=inp, output=fc2)
    return model
```

4についてもう少し詳しく書いておきます。
Convolution2Dの引数の仕様は以下のようになってます。

```py
keras.layers.convolutional.Convolution2D(nb_filter, nb_row, nb_col, init='glorot_uniform', activation='linear', weights=None, border_mode='valid', subsample=(1, 1), dim_ordering='default', W_regularizer=None, b_regularizer=None, activity_regularizer=None, W_constraint=None, b_constraint=None, bias=True)
```

ここでnb_rowに2,3,4,5、nb_colにembed_sizeを指定しています。これは要は2,3,4,5文字分のサイズのカーネルを適用しているのです。これは2-gram, 3-gram, 4-gram, 5-gramを模倣している感じです。これらの結果を一つに繋げることで、複数のn-gramの結果をまとめて利用したようになります。

## データの読み込み

データの読み込み部分はジェネレータ使ってメモリに優しい作りもできますが、画像じゃないしメモリそこまで食わないのでもう全部メモリに乗っけちゃいましょう。

```py
def load_data(filepath, targets, max_length=300, min_length=10):
    comments = []
    tmp_comments = []
    with open(filepath) as f:
        for l in f:
            # 一行毎にtab区切りで店舗ID,口コミが書かれてる前提
            restaurant_id, comment = l.split("\t", 1)
            restaurant_id = int(restaurant_id)
            # 文字毎にUNICODEに変換
            comment = [ord(x) for x in comment.strip().decode("utf-8")]
            # 長い部分は打ち切り
            comment = comment[:max_length]
            comment_len = len(comment)
            if comment_len < min_length:
                # 短すぎる口コミは対象外
                continue
            if comment_len < max_length:
                # 固定長にするために足りない部分を0で埋める
                comment += ([0] * (max_length - comment_len))
            if restaurant_id not in targets:
                tmp_comments.append((0, comment))
            else:
                comments.append((1, comment))
    # 学習のためにはデート目的店口コミとそれ以外が同数であった方がいい
    random.shuffle(tmp_comments)
    comments.extend(tmp_comments[:len(comments)])
    random.shuffle(comments)
    return comments
```

## 学習

では学習させましょう。

```py
def train(inputs, targets, batch_size=100, epoch_count=100, max_length=300, model_filepath="model.h5", learning_rate=0.001):

    # 学習率を少しずつ下げるようにする
    start = learning_rate
    stop = learning_rate * 0.01
    learning_rates = np.linspace(start, stop, epoch_count)

    # モデル作成
    model = create_model(max_length=max_length)
    optimizer = Adam(lr=learning_rate)
    model.compile(loss='binary_crossentropy',
                  optimizer=optimizer,
                  metrics=['accuracy'])

    # 学習
    model.fit(inputs, targets,
              nb_epoch=epoch_count,
              batch_size=batch_size,
              verbose=1,
              validation_split=0.1,
              shuffle=True,
              callbacks=[
                  LearningRateScheduler(lambda epoch: learning_rates[epoch]),
              ])

    # モデルの保存
    model.save(model_filepath)


if __name__ == "__main__":
    comments = load_data(..., ...)

    input_values = []
    target_values = []
    for target_value, input_value in comments:
        input_values.append(input_value)
        target_values.append(target_value)
    input_values = np.array(input_values)
    target_values = np.array(target_values)
    train(input_values, target_values, epoch_count=50)
```

私の方で試したところ、学習データに対しては99%越え、テストデータに対しては80%弱くらいのaccuracyになりました。

# 判別

さあ、ここまできたらもう口コミを判別できます。

```py
# -*- coding:utf-8 -*-

import numpy as np
from keras.models import load_model

def predict(comments, model_filepath="model.h5"):
    model = load_model(model_filepath)
    ret = model.predict(comments)
    return ret

if __name__ == "__main__":
    raw_comment = "デートには最高やで！"
    comment = [ord(x) for x in raw_comment.strip().decode("utf-8")]
    comment = comment[:300]
    if len(comment) < 10:
        exit("too short!!")
    if len(comment) < 300:
        comment += ([0] * (300 - len(comment)))
    ret = predict(np.array([comment]))
    predict_result = ret[0][0]
    print "リア充度: {}%".format(predict_result * 100)
```

>武蔵小山の焼き鳥ワインのお店。ここは全般美味かった!値段は決して安くは無いですが、ワインも全てBioワインとの事。焼き鳥はおまかせコースがお勧めとの事でしたので、そちらで。店員さんの接客も最高で是非行ってみて下さい。

上記で試したら99.9996066093%でした。
焼き鳥といえどこの漂うリア充臭は隠せませんね。
ちなみにこの口コミは我らがRetty創業者、武田さんのものです。こんなキラキラしてそうなところにまさか1人で行ったということはないでしょう。誰と行ったんでしょうね。

> 駅直結!!という立地の良さから選びました✨焼き鳥、水炊きなどなど頼みましたが、鳥がぷるぷるで美味しかったですよ!!仕事終わりの方たちがサクッと一杯...というイメージですかね。でも値段もリーズナブルでなかなか良かったです♫

上記では2.91604362879e-07%でした。
同じ焼き鳥でも花金な感じだとここまでリア充度が下がります。心が穏やかになってきますね。
この口コミは某Retty社員ですが、誰なのかはここではやめときましょう。

ここまでできたらあとはお店の全口コミを突っ込んで口コミのリア充度の平均でも取ればそこがリア充どもの巣窟かどうかわかります。

# まとめ

Deep Learningは心の平穏を守るための技術としても優秀ですね。character-level CNNは今年の頭くらいに出てきたものですが、最近出てきた[QRNN](http://metamind.io/research/new-neural-network-building-block-allows-faster-and-more-accurate-text-understanding/)とやらも似たようなことできそうなので試してみたいです。

ではよいクリスマスを。