TensorFlow でポケモンの名前から種族値とタイプを予測させる遊び

先日 [TensorFlow 研究会](http://d-cube.connpass.com/event/23094/)に発表者として行ってきました.
周りの人に勉強会の内容何か書かないのかと言われたのですが, 人数にビビって[誰も喜ばなさそうな発表](http://www.slideshare.net/s_fujiwara/tensorflow-56050548)をしてしまったので, 代わりにここでは元々使う予定だった没ネタを消費しておきます.

# 目標
やりたいことはタイトルの通りとても単純です.
ポケモンの名前を入力したら種族値とタイプっぽいものが出てきて欲しいです.
Twitter の診断メーカーとかでありがちなやつを, もうちょい真面目にやってみる感じですね.

# モデルの設計

## 入力の詳細
ポケモンの名前を 1 文字ごとに分解して, 各文字の出現回数と 2-gram を特徴量として使用しました.
例えばデデンネの場合は以下のようになります.

```json
{
  デ: 2, ン: 1, ネ: 1,
  デデ: 1, デン: 1, ンネ: 1
}
```
n-gram の特徴量を作るのは自力でやると面倒なのですが scikit-learn の [Vectorizer](http://scikit-learn.org/stable/modules/feature_extraction.html) を使用すると 2, 3 行で細かい設定もしつつ n-gram 特徴量ができるわ必要な情報が一通り格納された vectorizer を簡単に保存できるわで, ウルトラスーパーミラクルおすすめです.
来世に備えて徳を積まなければならないとか, 特殊な事情が無い限りは絶対使った方が良いです.

## 出力の詳細
出力はちょっと面倒で, 大きく分けて以下の 3 つを出力しなければなりません.

* 種族値
  - HP, 攻撃, 防御, 特攻, 特防, 素早さ, の 6 つの連続値 (回帰問題)
* タイプ 1
  - 18 種類のカテゴリ (分類問題)
* タイプ 2
  - 「無し」も含めた 19 種類のカテゴリ (分類問題)

それぞれ別々にモデルを立てて予測しても良いのですが, 何でもまとめて詰め込める柔軟性がニューラルネットの強み (だと個人的には思っている) ので, 全部を 1 つのネットワークに突っ込んでみました.
つまりこういうことです.

![graph.png](https://qiita-image-store.s3.amazonaws.com/0/70763/56b2038f-849f-890f-19f6-a8a64476d226.png)

最後の層のユニット数は 6 + 18 + 19 個です.
種族値に相当する部分はそれをそのまま出力とし, 二乗誤差で損失を定義しました.
タイプに相当する部分は 18 or 19 個ずつでそれぞれ softmax に突っ込んだものを出力とし, 損失はそれぞれの cross entropy で定義しました.
最終的な目的関数はこれらの損失の重み付き足し合わせです. 

# データ収集
~~別に極秘データではないので, 頑張れば集まります. 頑張ってください.~~
使った分のデータだけ，コードと一緒に [GitHub に上げておいた](https://github.com/sfujiwara/pn2bs)．

# 出来上がったコード
```py
# -*- coding: utf-8 -*-

import numpy as np
import pandas as pd
import tensorflow as tf
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.externals import joblib
from sklearn.feature_extraction import DictVectorizer


def inference(x_placeholder, n_in, n_hidden1, n_hidden2):
    """
    Description
    -----------
    Forward step which build graph.

    Parameters
    ----------
    x_placeholder: Placeholder for feature vectors
    n_in: Number of units in input layer which is dimension of feature
    n_hidden1: Number of units in hidden layer 1
    n_hidden2: Number of units in hidden layer 2

    Returns
    -------
    y_bs: Output tensor of predicted values for base stats
    y_type1: Output tensor of predicted values for type 1
    y_type2: Output tensor of predicted values for type 2
    """
    # Hidden1
    with tf.name_scope('hidden1') as scope:
        weights = tf.Variable(
            tf.truncated_normal([n_in, n_hidden1]),
            name='weights'
        )
        biases = tf.Variable(tf.zeros([n_hidden1]))
        hidden1 = tf.nn.sigmoid(tf.matmul(x_placeholder, weights) + biases)

    # Hidden2
    with tf.name_scope('hidden2') as scope:
        weights = tf.Variable(
            tf.truncated_normal([n_hidden1, n_hidden2]),
            name='weights'
        )
        biases = tf.Variable(tf.zeros([n_hidden2]))
        hidden2 = tf.nn.sigmoid(tf.matmul(hidden1, weights) + biases)

    # Output layer for base stats
    with tf.name_scope('output_base_stats') as scope:
        weights = tf.Variable(
            tf.truncated_normal([n_hidden2, 6]),
            name='weights'
        )
        biases = tf.Variable(tf.zeros([6]))
        y_bs = tf.matmul(hidden2, weights) + biases

    # Output layer for type1
    with tf.name_scope('output_type1') as scope:
        weights = tf.Variable(
            tf.truncated_normal([n_hidden2, 18]),
            name='weights'
        )
        biases = tf.Variable(tf.zeros([18]))
        # y_type1 = tf.nn.softmax(tf.matmul(hidden2, weights) + biases)
        y_type1 = tf.matmul(hidden2, weights) + biases

    # Output layer for type2
    with tf.name_scope('output_type2') as scope:
        weights = tf.Variable(
            tf.truncated_normal([n_hidden2, 19]),
            name='weights'
        )
        biases = tf.Variable(tf.zeros([19]))
        y_type2 = tf.matmul(hidden2, weights) + biases
        # y_type2 = tf.nn.softmax(tf.matmul(hidden2, weights) + biases)

    return [y_bs, y_type1, y_type2]


def build_loss_bs(y_bs, t_ph_bs):
    """
    Parameters
    ----------
    y_bs: Output tensor of predicted values for base stats
    t_ph_bs: Placeholder for base stats

    Returns
    -------
    Loss tensor which includes placeholder of features and labels
    """
    loss_bs = tf.reduce_mean(tf.nn.l2_loss(t_ph_bs - y_bs), name='LossBaseStats')
    return loss_bs


def build_loss_type1(y_type1, t_ph_type1):
    """
    Parameters
    ----------
    y_type1: Output tensor of predicted values for base stats
    t_ph_type1: Placeholder for base stats

    Returns
    -------
    Loss tensor which includes placeholder of features and labels
    """
    loss_type1 = tf.reduce_mean(
        tf.nn.softmax_cross_entropy_with_logits(y_type1, t_ph_type1),
        name='LossType1'
    )
    return loss_type1


def build_loss_type2(y_type2, t_ph_type2):
    """
    Parameters
    ----------
    y_type2: Output tensor of predicted values for base stats
    t_ph_type2: Placeholder for base stats

    Returns
    -------
    Loss tensor which includes placeholder of features and labels
    """
    loss_type2 = tf.reduce_mean(
        tf.nn.softmax_cross_entropy_with_logits(y_type2, t_ph_type2),
        name='LossType2'
    )
    return loss_type2


def build_optimizer(loss, step_size):
    """
    Parameters
    ----------
    loss: Tensor of objective value to be minimized
    step_size: Step size for gradient descent

    Returns
    -------
    Operation of optimization
    """
    optimizer = tf.train.GradientDescentOptimizer(step_size)
    global_step = tf.Variable(0, name='global_step', trainable=False)
    train_op = optimizer.minimize(loss, global_step=global_step)
    return train_op


if __name__ == '__main__':
    # Set seed
    tf.set_random_seed(0)

    # Load data set and extract features
    df = pd.read_csv('data/poke_selected.csv')

    # Fill nulls in type2
    df.loc[df.type2.isnull(), 'type2'] = '無'

    # Vectorize pokemon name
    pokename_vectorizer = CountVectorizer(analyzer='char', min_df=1, ngram_range=(1, 2))
    x = pokename_vectorizer.fit_transform(list(df['name_jp'])).toarray()
    t_bs = np.array(df[['hp', 'attack', 'block', 'contact', 'defense', 'speed']])

    # Vectorize pokemon type1
    poketype1_vectorizer = DictVectorizer(sparse=False)
    d = df[['type1']].to_dict('record')
    t_type1 = poketype1_vectorizer.fit_transform(d)

    # Vectorize pokemon type2
    poketype2_vectorizer = DictVectorizer(sparse=False)
    d = df[['type2']].to_dict('record')
    t_type2 = poketype2_vectorizer.fit_transform(d)

    # Placeholders
    x_ph = tf.placeholder(dtype=tf.float32)
    t_ph_bs = tf.placeholder(dtype=tf.float32)
    t_ph_type1 = tf.placeholder(dtype=tf.float32)
    t_ph_type2 = tf.placeholder(dtype=tf.float32)

    # build graph, loss, and optimizer
    y_bs, y_type1, y_type2 = inference(x_ph, n_in=1403, n_hidden1=512, n_hidden2=256)
    loss_bs = build_loss_bs(y_bs, t_ph_bs)
    loss_type1 = build_loss_type1(y_type1, t_ph_type1)
    loss_type2 = build_loss_type2(y_type2, t_ph_type2)
    loss = tf.add_n([1e-4 * loss_bs, loss_type1, loss_type2], name='ObjectiveFunction')
    optim = build_optimizer(loss, 1e-1)

    # Create session
    sess = tf.Session()

    # Initialize variables
    init = tf.initialize_all_variables()
    sess.run(init)

    # Create summary writer and saver
    summary_writer = tf.train.SummaryWriter('log', graph_def=sess.graph_def)
    tf.scalar_summary(loss.op.name, loss)
    tf.scalar_summary(loss_bs.op.name, loss_bs)
    tf.scalar_summary(loss_type1.op.name, loss_type1)
    tf.scalar_summary(loss_type2.op.name, loss_type2)
    summary_op = tf.merge_all_summaries()
    saver = tf.train.Saver()

    # Run optimization
    for i in range(1500):
        # Choose indices for mini batch update
        ind = np.random.choice(802, 802)
        batch_xs = x[ind]
        batch_ts_bs = t_bs[ind]
        batch_ts_type1 = t_type1[ind]
        batch_ts_type2 = t_type2[ind]
        # Create feed dict
        fd = {
            x_ph: batch_xs,
            t_ph_bs: batch_ts_bs,
            t_ph_type1: batch_ts_type1,
            t_ph_type2: batch_ts_type2
        }
        # Run optimizer and update variables
        sess.run(optim, feed_dict=fd)
        # Show information and write summary in every n steps
        if i % 100 == 99:
            # Show num of epoch
            print 'Epoch:', i + 1, 'Mini-Batch Loss:', sess.run(loss, feed_dict=fd)
            # Write summary and save checkpoint
            summary_str = sess.run(summary_op, feed_dict=fd)
            summary_writer.add_summary(summary_str, i)
            name_model_file = 'model_lmd1e-4_epoch_' + str(i+1) + '.ckpt'
            save_path = saver.save(sess, 'model/tensorflow/'+name_model_file)
    else:
        name_model_file = 'model_lmd1e-4_epoch_' + str(i+1) + '.ckpt'
        save_path = saver.save(sess, 'model/tensorflow/'+name_model_file)

    # Show example
    poke_name = 'サンダー'
    v = pokename_vectorizer.transform([poke_name]).toarray()
    pred_bs = sess.run(y_bs, feed_dict={x_ph: v})
    pred_type1 = np.argmax(sess.run(y_type1, feed_dict={x_ph: v}))
    pred_type2 = np.argmax(sess.run(y_type2, feed_dict={x_ph: v}))
    print poke_name
    print pred_bs
    print pred_type1, pred_type2
    print poketype1_vectorizer.get_feature_names()[pred_type1]
    print poketype2_vectorizer.get_feature_names()[pred_type2]

    # Save vectorizer of scikit-learn
    joblib.dump(pokename_vectorizer, 'model/sklearn/pokemon-name-vectorizer')
    joblib.dump(poketype1_vectorizer, 'model/sklearn/pokemon-type1-vectorizer')
    joblib.dump(poketype2_vectorizer, 'model/sklearn/pokemon-type2-vectorizer')

```

# 学習させてみる
ネタがネタなだけに真面目にチューニングしたらどうなるというわけでもないので, 最低限タイプに関する損失と種族値に関する損失がバランス良く減っているかどうか確認するために TensorBoard を眺めていただけです.

# 遊んでみる
こんな感じでモデルを読み込んで遊んでみます.

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

import numpy as np
import pandas as pd
import tensorflow as tf
from sklearn.externals import joblib
import pn2bs


# Placeholder
x_ph = tf.placeholder(dtype=tf.float32)
t_ph = tf.placeholder(dtype=tf.float32)

y_bs, y_type1, y_type2 = pn2bs.inference(x_ph, n_in=1403, n_hidden1=512, n_hidden2=256)

# Create session
sess = tf.Session()

# Load TensorFlow model
saver = tf.train.Saver()
saver.restore(sess, "model/tensorflow/model_lmd1e-4_epoch_1500.ckpt")

# Load vectorizer of scikit-learn
pokename_vectorizer = joblib.load("model/sklearn/pokemon-name-vectorizer")
poketype1_vectorizer = joblib.load("model/sklearn/pokemon-type1-vectorizer")
poketype2_vectorizer = joblib.load("model/sklearn/pokemon-type2-vectorizer")

poke_name = 'ゴンザレス'
v = pokename_vectorizer.transform([poke_name]).toarray()
pred_bs = sess.run(y_bs, feed_dict={x_ph: v})
pred_type1 = np.argmax(sess.run(y_type1, feed_dict={x_ph: v}))
pred_type2 = np.argmax(sess.run(y_type2, feed_dict={x_ph: v}))
result = {
    'name'   : poke_name,
    'hp'     : pred_bs[0][0],
    'attack' : pred_bs[0][1],
    'block'  : pred_bs[0][2],
    'contact': pred_bs[0][3],
    'defense': pred_bs[0][4],
    'speed'  : pred_bs[0][5],
    'type1'  : poketype1_vectorizer.get_feature_names()[pred_type1],
    'type2'  : poketype2_vectorizer.get_feature_names()[pred_type2],
}
print result['name']
print result['hp']
print result['attack']
print result['block']
print result['contact']
print result['defense']
print result['speed']
print result['type1']
print result['type2']
```

結果の例です.
特に言うことはありませんが, 各々何かを察してください.

| Name | H | A | B | C | D | S | Type 1 | Type 2 |
|:----:|:-:|:-:|:-:|:-:|:-:|:-:|:------:|:------:|
| テンソルフロー | 92 | 102 | 84 | 85 | 65 | 73 | フェアリー | 悪 |
| メガピカチュウ | 74 | 80 | 50 | 97 | 85 | 80 | 電気 | 無 |
| ゴンザレス | 81 | 103 | 107 | 86 | 103 | 65 | ドラゴン | 電気 |
| メガゴンザレス | 100 | 137 | 131 | 118 | 117 | 103 | ドラゴン | 悪 |


# 今後の課題 (取り組むとは言っていない)
* 今の設定だとタイプ 1: 水, タイプ 2: 水, などの意味不明なポケモンが誕生する可能性がある
* 2-gram をカウントするとき BOS, EOS も 1 文字と見做して使った方が良いかも
* Dropout の存在忘れてた

# まとめ
ニューラルネットは凸性とか関数の良い性質を全力でドブに投げ捨てた分, モデリングの柔軟性がすごいなーと思いました.
例えば SVM で同じことをやろうとすると, まず種族値のような多次元の出力を求められた時点でどうしようかなと立ち止まることになるだろうし, ましてやついでにタイプに関する分類問題も一緒に解いてくれなどと言われた日には, 寝言は寝て言えよという気持ちになります.
とは言えニューラルネットに 9 回裏 2 アウトの打席を任せられるかというと, うーんという感じ.
そんな感じ.

# 追記

## 2016-01-26

今でも時々ストックしてくれている人がいるのにまともに試せる状態で公開していないのは少し申し訳ない気がしてきたので、一応動くものを [GitHub に上げておいた](https://github.com/sfujiwara/pn2bs)．
入力された名前に応じて Google Charts か何かでレーダーチャートとか出したら格好良いと思ったけど，bot に叩かせて遊びたかったので Web API にした．

使ってみると多分バレると思うから先に白状しておくと，既存のポケモンを入れたときに実際と全然違うステータスになると萎えるので，わざと過学習気味のモデルを採用している．