TensorFlowのseq2seqでチャットボットが作りたい



パソコンを新調したので、この機会にQiitaデビューしました。
手始めに以前から作りたいと思っていたニューラルチャットボット(の基本の基本)を実装したので、アウトプットの練習に綴っていこうと思います。

TensorFlowによる[seq2seq modelのチュートリアル](https://www.tensorflow.org/tutorials/)を基に実装してあります。
実装手順についてはほぼ[こちら（TensorFlowのSeq2Seqモデルでチャットボットっぽいものを作ってみた）](https://qiita.com/san_/items/128bf1b5a898ad5c18f1)を参考にさせていただきました。感謝です!!

~~近日中にGithubにソースコードを公開できればと思っています。~~
Githubに公開しました。([こちら](https://github.com/Pu-of-Parari/Japanese-Neural-Conversational-Model))

この記事の続き
[TensorFlowのseq2seqでチャットボットが作りたい (Slack Bot化編)](https://qiita.com/Pu-of-Parari/items/6ebf22a2edcefc013eff)

# 今回やったこと
- TensorFlowのチュートリアルにあるseq2seqモデルを利用して日本語雑談コーパスを学習
- 学習したモデルと雑談

#実装環境
- OS:Ubuntu 18.04 LTS
- メモリ：16GB
- CPU：Intel® Core™ i7-7500U CPU @ 2.70GHz × 4
 <br>(身近に手軽に使えるGPUマシンがないため今回はCPUでノロノロ回してます。。)
<br>
- python：2.7.15
 - tensorflow==0.12.0
 - mecab-python==0.996
<br>(anacondaを利用して仮想環境を作成して作業しました。mecabの辞書はipadic-8を使っています。)

#データの準備
モデル学習のためのデータセットには[名大会話コーパス（旧日本語自然会話書き起こしコーパス）](https://mmsrv.ninjal.ac.jp/nucc/)を利用しました。

[こちらのツール](https://github.com/knok/make-meidai-dialogue)を使わせていただき、txt形式の生データ`sequence.txt`を用意しました。
このデータをモデル用に行対応のある`input.txt`,`output.txt`に整形します。
整形のために以下のコードを作成しました。

```python:data_prepro.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import MeCab
import re

def wakati(input_str):
        '''分かち書き用関数
        引数 input_str : 入力テキスト
        返値 m.parse(wakatext) : 分かち済みテキスト'''
        wakatext = input_str
        m = MeCab.Tagger('-Owakati')
        #print(m.parse(wakatext))
        return m.parse(wakatext)


with open("sequence.txt") as seqtex, open("./input.txt",'w') as infile,\
 open("./output.txt",'w') as outfile:
    print("data writing...")
    for line in seqtex:
        if line.find("input:") != -1:   #input文の処理
            line = re.sub('input: ', '', line)
            wakati_in = wakati(line)
            infile.write(wakati_in)

        else:  #output文の処理
            line = re.sub('output: ', '', line)
            wakati_out = wakati(line)
            outfile.write(wakati_out)

print("finished.")
```

```
$ python data_prepro.py
```

あとから気づいたのですが、正規化や不要な記号の除去などを行わずに使用していたため、これらを行うことでもう少し改善の余地はありそうです。

整形後のデータ数は以下のようになりました。

```
$ wc -l input.txt output.txt
33361 input.txt
33361 output.txt
66722 合計
```

## データの中身
`data_prepro.py`を実行して得た`input.txt`, `output.txt`の中身はこのようになっています。

```input.txt
うん 、 かわいい 、 これ すごく 。 
でも 結婚式 、 お金 かかる でしょ う ？ 
すげ え 調子 悪い ん です けど 。
ベビー スター は 昔 ３ ０ 円 だっ た 気 が する ん だ けど 。 
```

```output.txt
でしょ 。 
そんなに かけ ない わ 。 
手 が 震え て ん だ もん 。
えっ 、 今 は ？ 
```

こんな感じで`input.txt`と`output.txt`が行ごとに対応しています。
実際の会話を文字に起こしたコーパスのため、「あー」とか「うん」などが多い印象です。



# モデルのトレーニング

## データの配置
整形処理したデータを学習用に30000,評価用に3361に分割して訓練を行いました。

リポジトリは以下のような構成です。

    chatbot
    ├── data
    │   ├── test_data_ids_{in, out}.txt
    │   ├── test_data_{in, out}.txt
    │   ├── train_data_ids_{in,out}.txt
    │   ├── train_data_{in, out}.txt
    │   └── vocab_{in, out}.txt
    │
    ├── predata
    │   └── data_prepro.py
    │
    ├── data_utils.py
    ├── seq2seq_model.py
    └── translate.py

`/datas`内の`{test, train}_data_{in, out}.txt`の計4つのファイルに準備したテキストデータを配置し、残りは空のファイルを準備しておきます。

## トレーニング実行
モデルのトレーニングを行います。
以下のコードを使用しました。オリジナルをベースにハイパーパラメータ他、コードの一部を自分の環境用に変更しています。
レイヤー数2, ユニット数256, 語彙数12500で実行しました。

```python:translate.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import MeCab

import math
import os
import random
import sys
import time

import tensorflow.python.platform

import numpy as np
from six.moves import xrange
import tensorflow as tf

import data_utils
from tensorflow.models.rnn.translate import seq2seq_model
from tensorflow.python.platform import gfile


tf.app.flags.DEFINE_float("learning_rate", 0.5, "Learning rate.")
tf.app.flags.DEFINE_float("learning_rate_decay_factor", 0.99,
                          "Learning rate decays by this much.")
tf.app.flags.DEFINE_float("max_gradient_norm", 5.0,
                          "Clip gradients to this norm.")
tf.app.flags.DEFINE_integer("batch_size", 4,
                            "Batch size to use during training.")
tf.app.flags.DEFINE_integer("size", 256, "Size of each model layer.")
tf.app.flags.DEFINE_integer("num_layers", 2, "Number of layers in the model.")
tf.app.flags.DEFINE_integer("in_vocab_size", 12500, "input vocabulary size.")
tf.app.flags.DEFINE_integer("out_vocab_size", 12500, "output vocabulary size.")
tf.app.flags.DEFINE_string("data_dir", "./datas", "Data directory")
tf.app.flags.DEFINE_string("train_dir", "./datas", "Training directory.")
tf.app.flags.DEFINE_integer("max_train_data_size", 0,
                            "Limit on the size of training data (0: no limit).")
tf.app.flags.DEFINE_integer("steps_per_checkpoint", 100,
                            "How many training steps to do per checkpoint.")
tf.app.flags.DEFINE_boolean("decode", False,
                            "Set to True for interactive decoding.")
tf.app.flags.DEFINE_boolean("self_test", False,
                            "Run a self-test if this is set to True.")

FLAGS = tf.app.flags.FLAGS

_buckets = [(5, 10), (10, 15), (20, 25), (40, 50)]


def read_data(source_path, target_path, max_size=None):
  data_set = [[] for _ in _buckets]
  source_file = open(source_path,"r")
  target_file = open(target_path,"r")

  source, target = source_file.readline(), target_file.readline()
  counter = 0
  while source and target and (not max_size or counter < max_size):
    counter += 1
    if counter % 50 == 0:
      print("  reading data line %d" % counter)
      sys.stdout.flush()

    source_ids = [int(x) for x in source.split()]
    target_ids = [int(x) for x in target.split()]
    target_ids.append(data_utils.EOS_ID)
    for bucket_id, (source_size, target_size) in enumerate(_buckets):
      if len(source_ids) < source_size and len(target_ids) < target_size:
        data_set[bucket_id].append([source_ids, target_ids])
        break
    source, target = source_file.readline(), target_file.readline()
  return data_set

def create_model(session, forward_only):
  model = seq2seq_model.Seq2SeqModel(
      FLAGS.in_vocab_size, FLAGS.out_vocab_size, _buckets,
      FLAGS.size, FLAGS.num_layers, FLAGS.max_gradient_norm, FLAGS.batch_size,
      FLAGS.learning_rate, FLAGS.learning_rate_decay_factor,
      forward_only=forward_only)

  ckpt = tf.train.get_checkpoint_state(FLAGS.train_dir)
  #if ckpt and gfile.Exists(ckpt.model_checkpoint_path):
    #add
  if not os.path.isabs(ckpt.model_checkpoint_path):
    ckpt.model_checkpoint_path= os.path.abspath(os.path.join(os.getcwd(), ckpt.model_checkpoint_path))
    #so far
    print("Reading model parameters from %s" % ckpt.model_checkpoint_path)
    model.saver.restore(session, ckpt.model_checkpoint_path)
  else:
    print("Created model with fresh parameters.")
    session.run(tf.initialize_all_variables())
  return model


def train():

  print("Preparing data in %s" % FLAGS.data_dir)
  in_train, out_train, in_dev, out_dev, _, _ = data_utils.prepare_wmt_data(
      FLAGS.data_dir, FLAGS.in_vocab_size, FLAGS.out_vocab_size)


  with tf.Session() as sess:


    print("Creating %d layers of %d units." % (FLAGS.num_layers, FLAGS.size))
    model = create_model(sess, False)

    print ("Reading development and training data (limit: %d)."
           % FLAGS.max_train_data_size)
    dev_set = read_data(in_dev, out_dev)
    train_set = read_data(in_train, out_train, FLAGS.max_train_data_size)

    train_bucket_sizes = [len(train_set[b]) for b in xrange(len(_buckets))]
    train_total_size = float(sum(train_bucket_sizes))


    train_buckets_scale = [sum(train_bucket_sizes[:i + 1]) / train_total_size
                           for i in xrange(len(train_bucket_sizes))]

    step_time, loss = 0.0, 0.0
    current_step = 0
    previous_losses = []
    while True:

      random_number_01 = np.random.random_sample()
      bucket_id = min([i for i in xrange(len(train_buckets_scale))
                       if train_buckets_scale[i] > random_number_01])

      start_time = time.time()
      encoder_inputs, decoder_inputs, target_weights = model.get_batch(
          train_set, bucket_id)

      _, step_loss, _ = model.step(sess, encoder_inputs, decoder_inputs,
                                   target_weights, bucket_id, False)
      step_time += (time.time() - start_time) / FLAGS.steps_per_checkpoint
      loss += step_loss / FLAGS.steps_per_checkpoint
      current_step += 1


      if current_step % FLAGS.steps_per_checkpoint == 0:

        perplexity = math.exp(loss) if loss < 300 else float('inf')
        print ("global step %d learning rate %.4f step-time %.2f perplexity "
               "%.2f" % (model.global_step.eval(), model.learning_rate.eval(),
                         step_time, perplexity))


        if len(previous_losses) > 2 and loss > max(previous_losses[-3:]):
          sess.run(model.learning_rate_decay_op)
        previous_losses.append(loss)

        checkpoint_path = os.path.join(FLAGS.train_dir, "translate.ckpt")
        model.saver.save(sess, checkpoint_path, global_step=model.global_step)
        step_time, loss = 0.0, 0.0

        for bucket_id in xrange(len(_buckets)):
          encoder_inputs, decoder_inputs, target_weights = model.get_batch(
              dev_set, bucket_id)
          _, eval_loss, _ = model.step(sess, encoder_inputs, decoder_inputs,
                                       target_weights, bucket_id, True)
          eval_ppx = math.exp(eval_loss) if eval_loss < 300 else float('inf')
          print("  eval: bucket %d perplexity %.2f" % (bucket_id, eval_ppx))
        sys.stdout.flush()


def decode():
  with tf.Session() as sess:
    model = create_model(sess, True)
    model.batch_size = 1

    in_vocab_path = os.path.join(FLAGS.data_dir,
                                 "vocab_in.txt")
    out_vocab_path = os.path.join(FLAGS.data_dir,
                                 "vocab_out.txt" )

    in_vocab, _ = data_utils.initialize_vocabulary(in_vocab_path)
    _, rev_out_vocab = data_utils.initialize_vocabulary(out_vocab_path)

    print ("Hello!!")
    sys.stdout.write("> ")
    sys.stdout.flush()
    sentence = sys.stdin.readline()
    while sentence:
      sentence = wakati(sentence)
      token_ids = data_utils.sentence_to_token_ids(sentence, in_vocab)

      bucket_id = min([b for b in xrange(len(_buckets))
                       if _buckets[b][0] > len(token_ids)])

      encoder_inputs, decoder_inputs, target_weights = model.get_batch(
          {bucket_id: [(token_ids, [])]}, bucket_id)

      _, _, output_logits = model.step(sess, encoder_inputs, decoder_inputs,
                                       target_weights, bucket_id, True)

      outputs = [int(np.argmax(logit, axis=1)) for logit in output_logits]

      if data_utils.EOS_ID in outputs:
        outputs = outputs[:outputs.index(data_utils.EOS_ID)]

      print("".join([rev_out_vocab[output] for output in outputs]))
      print("\n> ", end="")
      sys.stdout.flush()
      sentence = sys.stdin.readline()


def self_test():

  with tf.Session() as sess:
    print("Self-test for neural translation model.")

    model = seq2seq_model.Seq2SeqModel(10, 10, [(3, 3), (6, 6)], 32, 2,
                                       5.0, 32, 0.3, 0.99, num_samples=8)
    sess.run(tf.initialize_all_variables())


    data_set = ([([1, 1], [2, 2]), ([3, 3], [4]), ([5], [6])],
                [([1, 1, 1, 1, 1], [2, 2, 2, 2, 2]), ([3, 3, 3], [5, 6])])
    for _ in xrange(5):
      bucket_id = random.choice([0, 1])
      encoder_inputs, decoder_inputs, target_weights = model.get_batch(
          data_set, bucket_id)
      model.step(sess, encoder_inputs, decoder_inputs, target_weights,
                 bucket_id, False)

def wakati(input_str):
        '''分かち書き用関数
        引数 input_str : 入力テキスト
        返値 m.parse(wakatext) : 分かち済みテキスト'''
        wakatext = input_str
        m = MeCab.Tagger('-Owakati')
        #print(m.parse(wakatext))
        return m.parse(wakatext)

def main(_):
  if FLAGS.self_test:
    self_test()
  elif FLAGS.decode:
    decode()
  else:
    train()

if __name__ == "__main__":
  tf.app.run()


```

```
$ python translate.py
```
で辞書生成および、モデルのトレーニングが始まります。


```:実行途中
global step 82700 learning rate 0.0844 step-time 0.19 perplexity 2.46
  eval: bucket 0 perplexity 122.06
  eval: bucket 1 perplexity 242.77
  eval: bucket 2 perplexity 108.74
  eval: bucket 3 perplexity 126.07
global step 82800 learning rate 0.0836 step-time 0.17 perplexity 2.07
  eval: bucket 0 perplexity 21.42
  eval: bucket 1 perplexity 501.38
  eval: bucket 2 perplexity 145.67
  eval: bucket 3 perplexity 210.59
global step 82900 learning rate 0.0836 step-time 0.19 perplexity 2.49
  eval: bucket 0 perplexity 67.73
  eval: bucket 1 perplexity 66.10
  eval: bucket 2 perplexity 183.61
  eval: bucket 3 perplexity 26.17
global step 83000 learning rate 0.0827 step-time 0.18 perplexity 2.46
  eval: bucket 0 perplexity 100.90
  eval: bucket 1 perplexity 22.86
  eval: bucket 2 perplexity 57.99
  eval: bucket 3 perplexity 108.47

```
このように学習が進んでいきます。
空いてる時間を見つけてコツコツ学習を続け、おおよそ30時間くらい回しましたが、cpuを使っているためperplexityがなかなか下がってくれません。

100stepごとにチェックポイントを生成しているため、いったん`ctrl+C`で終了してももう一度実行し直すことで、途中から学習を再開してくれます。

#対話モード
ある程度トレーニングを行った後、実際にモデルと対話してみます。
オリジナルのコードは英仏翻訳のため、日本語チャット用にユーザ入力をわかち処理してからモデルに渡すようにコードを修正しています。

`translate.py`に引数`--decode`をつけると対話モードが起動します。

```
$ python translate.py --decode
```

```:実行結果
> 名前は？
カーミちゃん。

> 何歳なの？
知らない。

> 元気にしてた？
してないね。

> 怒ってる？
ううん。

> カルシウム足りてないんじゃない？？
うん、たぶんね。

> 好きな食べ物って何かある？
おいしい、テレビで。
```

">"から始まるのがユーザ入力です。素朴な返答ながらちゃんと返してくれてます!（内容は別として、、）
名前はカーミちゃんらしいです。自分をちゃん付けしちゃう子に育ってしまいました。

使用したデータが日常会話の口語コーパスなだけに、フランクな会話のほうがいい感じの返答をしてくれるようです。

逆に少し丁寧な口調で話しかけると...

```:実行結果
> こんにちは、はじめまして
でも、

> 今日もいい天気ですね！
うーん、どうするんだよねー。

> 名前はなんですか？
英語は、英語は、上も元気。

> 何歳ですか？
１２日。

> 元気ですか？
英語でこう。
```

このようにちんちくりんな返答になってしまいました。

もともと、フランクな会話ができるチャットボットを作りたいというモチベーションであるため、ここはそんなに問題視しないことにします。

# まとめと今後
今回はattention機構や単語の分散表現を一切使用しないシンプルなモデルで学習を行いましたが、思った以上にいい感じになりました。

今後やりたいこととして、

- データの前処理をちゃんとする
- attentionや分散表現の導入
- データセットの増強
- Webアプリ上のボットとしての実装

などなど盛りだくさんなので、やれることから少しずつ改良していけたらと思っています。

初心者でまだまだ至らないところばかりですのでご意見、アドバイス等いただければ幸いです。


この記事の続き
[TensorFlowのseq2seqでチャットボットが作りたい (Slack Bot化編)](https://qiita.com/Pu-of-Parari/items/6ebf22a2edcefc013eff)

# 参考
- [TensorFlow 公式チュートリアル](https://www.tensorflow.org/tutorials/)
- [TensorFlowのSeq2Seqモデルでチャットボットっぽいものを作ってみた](https://qiita.com/san_/items/128bf1b5a898ad5c18f1)
- [名大会話コーパス](https://mmsrv.ninjal.ac.jp/nucc/)
- [make-meidai-dialogue](https://github.com/knok/make-meidai-dialogue)

使用したデータセットのライセンスはオリジナルに従います。