Twitterデータを用いたチャットボットの訓練

　本稿では、KerasベースのAttention付きSeq2seqモデルによって構築したチャットボットを、Twitterから取得した大量の会話データを用いて訓練し、応答文生成の精度向上を図ります。

# １．　はじめに

　[前回の投稿](https://qiita.com/gacky01/items/89c6c626848417391438 "TwitterAPIを用いた会話データ収集")で、Twitterからの会話データ収集ツールを準備しましたので、収集したデータからエンコーダ／デコーダ入力、およびラベルデータを生成して、以前チャットボット用に作成した[KerasベースのAttention付きSeq2seqモデル](https://qiita.com/gacky01/items/5cc14af9f27ce38b94a8 "Kerasで実装するSeq2Seq　−その4　Attention")を訓練し、応答文生成の精度が向上するかどうか、見てみます。

　前回は[名大会話コーパス](https://mmsrv.ninjal.ac.jp/nucc/ "名大会話コーパス")などを訓練データに使用しましたが、応答文の精度は今一つでした。その原因は訓練データのボリューム不足にあると考え、今回はTwitterから会話データを大量に入手することでボリューム面の課題解決を図ります。

# ２．　本稿のゴール

　以下の通りです。

+ Twitterから取得した会話データから、訓練用のエンコーダ／デコーダ入力、およびラベルデータを生成します
+ 作成した訓練データを使って、チャットボットを訓練します

　また、、本稿の前提となるソフトウェア環境は、以下の通りです。

+ Ubuntu 16.04 LTS
+ Python 3.6.4
+ Anaconda　5.1.0
+ TensorFlow-gpu 1.3.0
+ Keras 2.1.4
+ Jupyter 4.4.0

　なお、後述の訓練処理が非常に重いので、ハードウェア環境としては、GPUマシンか、クラウドサービスの利用を推奨します。


# ３．　訓練データ生成

## ３－１．　Twitter会話データの収集

　[こちらの記事](https://qiita.com/gacky01/items/89c6c626848417391438 "TwitterAPIを用いた会話データ収集")の内容にしたがって、Twitterから会話データを収集します。実行したいフォルダに実行ファイルconvercation_py3.pyを置き、ツイートデータの格納用フォルダtweetを作成したうえで、以下のように実行してください。

```terminal
$ python conversation_py3.py です
```

「です」というのは引数です。何か適当な検索ワードを指定してください。

　十分なデータが収集できるまで、動作させたままにしておきます。指定する検索ワードにも依りますが、1日当たり、20万～30万対の会話データを収集できます。
　
## ３－２．　品詞分解

　京大黒橋・河原研究室の[JUMAN++](http://nlp.ist.i.kyoto-u.ac.jp/index.php?JUMAN++ "日本語形態素解析システム JUMAN++")を使用します。

　[名大会話コーパス等から訓練データを生成した](https://qiita.com/gacky01/items/26cd642731e3eddde60d "Kerasで実装するSeq2Seq　－その１　日本語訓練データの準備")時には、V1.02を使用しましたが、この版は動作が遅く、20万会話対を収納したファイルの品詞分解が、24時間では終了しませんでした。

　そこで、[こちらの記事](https://qiita.com/spiderx_jp/items/f0466705d1dda1a8c396 "Juman++インストール[Ubuntu 16.04 LTS]")を参考に、最新版を導入します。2018年11月時点の最新版は2.0.0-rc2で、まだ「開発版」の扱いですが、背に腹は代えられません。

　ちょっと手間はかかりますが、無事インストールが済んだら、実行します。実行手順は前の版と同じです。すると、驚くようなスピードで、それこそあっという間に処理が終了しました。

　出来上がった品詞分解結果ファイルは、「juman」という名前のフォルダを作成して、そこに格納しておきます。

## ３－３．　単語リスト作成

　JUMAN++の出力結果は、デリミタが半角スペースのCSVファイルの形態をとっています。これに対し、

+ １列目を取り出す
+ 「@」で始まる行（同音異義語）や「EOS」の行を削除する
+ 「REQ」+「:」、「RES」+「:」を、それぞれ「REQREQ」、「RESRES」に置き換える

　という加工をします。3つ目の処理は、ツイート収集時に文の切れ目の目印として付与した「REQ:」「RES:」がjuman++によって分解されてしまったため、入れた処理です。

　これらの処理によって得た単語を、出現順にリストにします。

　コードは以下の通りです。

```python:generate_word_list.py
# coding: utf-8

#**********************************************************************************
#                                                                                 *
#  juman++の品詞分解結果をリストに書き出し                                        *
#                                                                                 *
#**********************************************************************************
def genarate_npy(source_csv ,list_corpus) :
    with open(source_csv, 'r') as f :

        df2 = csv.reader(f,delimiter=' ')

        mat = [ v for v in df2]
        print(len(mat))
        j=0
        #補正
        for i in range(0,len(mat)):
            if len(mat[i]) !=  0 :
            
                if mat[i][0] != '' :
            
                    if mat[i][0] != '@' and mat[i][0] != 'EOS' and mat[i][0] != ':' and mat[i][0][0] != '\\' :
                        if mat[i][0] == 'REQ' and mat[i+1][0] == ':' : #デリミタ「REQ:」対応
                            list_corpus.append('REQREQ')
                        elif mat[i][0] == 'RES' and mat[i+1][0] == ':' : #デリミタ「RES:」対応
                            list_corpus.append('RESRES')
                        else :
                            list_corpus.append(mat[i][0])
                        if i % 1000000 == 0 :
                            print(i,list_corpus[j])
                        j += 1
                       
    print(len(list_corpus))
    del mat
    return 

#**********************************************************************************
#                                                                                 *
#  メイン処理                                                                     *
#                                                                                 *
#**********************************************************************************
if __name__ == '__main__':
    import numpy as np
    import csv
    import glob
    import re
    import pickle

    file_list = glob.glob('juman/*')
    print(len(file_list))
    
    n_words = 0
    for j in range(0,len(file_list)) :
        
        print(file_list[j])
        generated_list=[]
        genarate_npy(file_list[j],generated_list)
        #コーパスリストセーブ
        with open('list_corpus/list_corpus'+str(j)+'.pickle', 'wb') as g :
            pickle.dump(generated_list , g)
        n_words += len(generated_list)
        print(n_words)
        del generated_list
  
```

　スクリプトを実行するフォルダ配下に、Juman++の品詞分解結果が入ったjumanフォルダと、単語リストファイルを格納するlist_corpusフォルダを準備したうえで、以下のようにコマンド起動します。

```terminal
$ python generate_word_list.py
```

　入力の品詞分解結果ファイルの単位に処理を行い、単語リストファイルもその単位で作成します。最初は単一の単語リストにしようとしたのですが、リストのサイズが大きくなりすぎたのか、途中で処理が進まなくなったので、現行の処理方式としています。

## ３－４．　辞書および単語インデックス配列作成

　前節で作成した単語リストを入力に、単語にインデックスを付与して、両引きができる辞書と、単語リストの単語の並び順にインデックスを配した1次元配列を生成します。今回は、全データを単一の1次元配列にまとめます。

　インデックス0は、ニューラルネットワークのMasking用に予約したいので、これに単語がアサインされないよう、辞書ファイルに、単語ソート時に必ず先頭に来る「\t」(タブ)を追加します。

　単語数は、チャットボットを構成するニューラルネットワークの入出力次元になります。これが大きいと、学習コストの割に予測精度が上がらないので、出現頻度の低い単語は思い切って、十把一絡げに不明単語を表す文字列「UNK」に置き換えます。

　コードは以下の通りです。以下の例では、「UNK」置き換えのしきい値を10にしてあります。これにより、今回の場合の単語数は10万程度になりました。

```python:generate_index_matrix.py
# coding: utf-8

def generate_mat() :
   
    file_list = glob.glob('list_corpus/*')
    print('ファイル数 =',len(file_list))
    mat=[]
    for i in range (0,len(file_list)) :
        with open(file_list[i],'rb') as f :
            generated_list=pickle.load(f)         #生成リストロード
            mat.extend(generated_list)
            print(i)
            del generated_list
    

    mat.append('REQREQ')

    print(len(mat))
    words = sorted(list(set(mat)))
    cnt = np.zeros(len(words))

    print('total words:', len(words))
    word_indices = dict((w, i) for i, w in enumerate(words))    #単語をキーにインデックス検索
    indices_word = dict((i, w) for i, w in enumerate(words))    #インデックスをキーに単語を検索

    #単語の出現数をカウント
    for j in range (0,len(mat)):
        cnt[word_indices[mat[j]]] += 1

    #出現頻度の少ない単語を「UNK」で置き換え
    words_unk = []                                #未知語一覧

    for k in range(0,len(words)):
        if cnt[k] <= 10 :
            words_unk.append(words[k])
            words[k] = 'UNK'

    print('words_unk:',len(words_unk))                   # words_unkはunkに変換された単語のリスト

    #低頻度単語をUNKに置き換えたので、辞書作り直し
    words = list(set(words))
    words.append('\t')                                   #０パディング対策。インデックス０用キャラクタを追加
    words = sorted(words)
    print('new total words:', len(words))
    word_indices = dict((w, i) for i, w in enumerate(words))    #単語をキーにインデックス検索
    indices_word = dict((i, w) for i, w in enumerate(words))    #インデックスをキーに単語を検索

    #単語インデックス配列作成
    mat_urtext = np.zeros((len(mat),1),dtype=int)
    for i in range(0,len(mat)):
        if mat[i] in word_indices :           #出現頻度の低い単語のインデックスをunkのそれに置き換え
            mat_urtext[i,0] = word_indices[mat[i]]
        else:
            mat_urtext[i,0] = word_indices['UNK']

    print(mat_urtext.shape)

    #作成した辞書をセーブ
    with open('word_indices.pickle', 'wb') as f :
        pickle.dump(word_indices , f)

    with open('indices_word.pickle', 'wb') as g :
        pickle.dump(indices_word , g)

    #単語ファイルセーブ
    with open('words.pickle', 'wb') as h :
        pickle.dump(words , h)

    #コーパスセーブ
    with open('mat_urtext.pickle', 'wb') as ff :
        pickle.dump(mat_urtext , ff)    
    
if __name__ == '__main__':
    import numpy as np
    import pickle
    import glob
    
    generate_mat()


```

　コードの実行は、3-3節と同じフォルダ上で、以下のようにコマンド起動します。

```terminal
$ python generate_index_matrix.py
```

## ３－５．　訓練データ生成

　前節で作成した単語インデックス配列から、Seq2Seqモデル用のエンコーダインプット、デコーダインプット、およびラベルを生成します。これらがニューラルネットワークでどのように使われるのかについては、[こちら](https://qiita.com/gacky01/items/26cd642731e3eddde60d#%EF%BC%95%E8%A8%93%E7%B7%B4%E3%83%87%E3%83%BC%E3%82%BF%E4%BD%9C%E6%88%90 "Kerasで実装するSeq2Seq　－その１　日本語訓練データの準備")をご覧ください。

　エンコーダインプットは、単語インデックス配列の、デリミタ「REQREQ」から「RESRES」までを取り出して生成します。また、デコーダインプットは、これと対になる「RESRES」から「REQREQ」までを取り出して生成します。

　ラベルデータは、応答文から生成します。どちらも同じものから生成しますが、デコーダーは入力単語の１つ先の単語を予測するように訓練しますので、この２つは１単語分ずれています。

![tw_fig1.png](https://qiita-image-store.s3.amazonaws.com/0/289661/ed6b84e9-43a7-49e2-0fff-08c6bcc39bb8.png)

　発話文と応答文の対が同一の行になるように、エンコーダインプット、デコーダインプット、およびラベルにデータを詰め込んでいきます。



![tw_fig2.png](https://qiita-image-store.s3.amazonaws.com/0/289661/01188879-0847-044a-0acf-0a990e9af7d1.png)


　以下にコードを示します。発話文と応答文は必ず対になるように、ツイート取得処理を作ってありますが、まれに発話－応答の組が崩れていることがあるので、そのような場合のスキップ処理を入れてあります。





```python:generate_data.py
# coding: utf-8

#*******************************************************************************
#                                                                              *
# 訓練データ／ラベルテンソル作成処理                                           *
#                                                                              *
#*******************************************************************************    
def generate_tensors(maxlen_e,maxlen_d) :
    #--------------------------------------------------------------------------*
    #                                                                          *
    # 単語配列、コーパス配列、辞書のロード                                     *
    #                                                                          *
    #--------------------------------------------------------------------------*    

    #単語ファイルロード
    with open('words.pickle', 'rb') as f :
        words=pickle.load(f)    

    #作成した辞書をロード
    with open('word_indices.pickle', 'rb') as f :
        word_indices=pickle.load(f)

    with open('indices_word.pickle', 'rb') as g :
        indices_word = pickle.load(g)
    
    #コーパスロード
    with open('mat_urtext.pickle', 'rb') as ff :
        mat_urtext = pickle.load(ff)  

    #--------------------------------------------------------------------------*
    #                                                                          *
    # コーパスをエンコーダ入力、デコーダ入力応答文のテンソルに変換             *
    #                                                                          *
    #--------------------------------------------------------------------------*
    req = word_indices['REQREQ']
    res = word_indices['RESRES']

    delimiters  = []

    #コーパス上のデリミタの位置を特定する
    for i in range(0,mat_urtext.shape[0]) :
        if mat_urtext[i,0] == req or mat_urtext[i,0] == res:
            delimiters.append(i) 
        if i % 10000000 == 0 :
            print(i)

    print(len(delimiters))
    n=len(delimiters) // 2

    #入力、ラベルテンソルの初期値定義（０値マトリックス）
    enc_input = np.zeros((n,maxlen_e))
    dec_input = np.zeros((n,maxlen_d))
    target = np.zeros((n,maxlen_d))
    
    #デリミタを目印に、コーパスから文章データを切り出して入力／ラベルマトリックスにコピー
    j = 0
    err_cnt = 0
    for i in range(0,n) :
        index1=2*i+err_cnt         # 「REQREQ」のインデックス
        index2=2*i+1+err_cnt       # 「RESRES」のインデックス
        index3=2*i+2+err_cnt       # 次の「REQREQ」のインデックス
        if index3 >= n :
            break
        #発話／応答の組が崩れていた時はスキップする
        if mat_urtext[delimiters[index1],0] != req or mat_urtext[delimiters[index2],0] != res :
            print('シーケンスエラー ',i)
            print(index1,index2)
            print(delimiters[index1],delimiters[index2])
            print(mat_urtext[delimiters[index1] ,0], mat_urtext[delimiters[index2],0])
            print(mat_urtext[delimiters[index1]:delimiters[index3],0])
            err_cnt += 1
            continue
        len_e = delimiters[index2] - delimiters[index1] - 1
        len_d = delimiters[index3] - delimiters[index2]
        #系列長より短い会話のみ、入力／ラベルマトリックスに書き込む
        if len_e <= maxlen_e and len_d <= maxlen_d  :
            enc_input[j,0:len_e] = mat_urtext[delimiters[index1]+1:delimiters[index2],0].T
            dec_input[j,0:len_d] = mat_urtext[delimiters[index2]:delimiters[index3],0].T
            target[j,0:len_d] = mat_urtext[delimiters[index2]+1:delimiters[index3]+1,0].T
            j += 1
        if i % 1000000 == 0 :
            print(i)
        
    #会話文データを書き込んだ分だけ切り出す
    e = enc_input[0:j,:].reshape(j,maxlen_e,1)
    d = dec_input[0:j,:].reshape(j,maxlen_d,1)
    t = target[0:j,:].reshape(j,maxlen_d,1)

    print(e.shape,d.shape,t.shape)

    # シャッフル処理
    z = list(zip(e, d, t))
    nr.seed(12345)
    nr.shuffle(z)                               #シャッフル
    e,d,t=zip(*z)
    nr.seed()

    e = np.array(e).reshape(j,maxlen_e,1)
    d = np.array(d).reshape(j,maxlen_d,1)
    t = np.array(t).reshape(j,maxlen_d,1)

    print(e.shape,d.shape,t.shape)

    #--------------------------------------------------------------------------*
    #                                                                          *
    # 作成したエンコーダ／デコーダ入力テンソル、ラベルテンソルをセーブ         *
    #                                                                          *
    #--------------------------------------------------------------------------* 
    #Encoder Inputデータをセーブ
    with open('e.pickle', 'wb') as f :
        pickle.dump(e , f)

    #Decoder Inputデータをセーブ
    with open('d.pickle', 'wb') as g :
        pickle.dump(d , g)

    #ラベルデータをセーブ
    with open('t.pickle', 'wb') as h :
        pickle.dump(t , h)
    
    #maxlenセーブ
    with open('maxlen.pickle', 'wb') as maxlen :
        pickle.dump([maxlen_e, maxlen_d] , maxlen)

#*******************************************************************************
#                                                                              *
# メイン処理                                                                   *
#                                                                              *
#*******************************************************************************    
if __name__ == '__main__':  
    
    import numpy.random as nr
    import pickle
    import numpy as np
    import sys
    
    args = sys.argv
    
    #args[1] = 50                                              # jupyter上で実行するとき用
    #args[2] = 50                                              # jupyter上で実行するとき用
    
    generate_tensors(int(args[1]) ,int(args[2]))
```

　コードの実行は、3-3節、3-4節と同じフォルダ上で、以下のようにコマンド起動します。

　今回は引数があります。エンコーダ系列長、デコーダ系列長を指定するようになっていますので、適当な値を指定してください。以下の例では、双方とも20を指定してあります。

```terminal
$ python generate_data.py 20 20
```

　系列長というのは、入力文章長の最大値です。この値以下のデータを、エンコーダインプット等に右詰めで設定します。

　系列長を絞り込んだため、訓練データサイズは小さくなります。今回の場合、もともとのデータ量は100万以上の会話対がありましたが、出来上がった訓練データは約30万対です。

　ここまでの処理で、ニューラルネットワーク訓練のためのデータが、すべてそろいました。

# ４．　訓練

　準備したデータを使って、ニューラルネットワークを訓練します。対象となるニューラルネットワークは、[こちらの記事](https://qiita.com/gacky01/items/5cc14af9f27ce38b94a8 "Kerasで実装するSeq2Seq　−その4　Attention")のものです。

　ただし、そのままだとメモリが足りなくてResourceExhaustedErrorが発生するので、[3-2-4節](https://qiita.com/gacky01/items/5cc14af9f27ce38b94a8#%EF%BC%93%EF%BC%92%EF%BC%94%E8%A8%93%E7%B7%B4%E5%AE%9F%E8%A1%8C%E5%87%A6%E7%90%86)のバッチサイズ`batch_size` と隠れ層の次元`n_hidden`を、以下のように変更しました。

```python
batch_size = 80
n_hidden = int(vec_dim*1.5 ) #隠れ層の次元
```
　次元を変更しましたので、以前作成したパラメータファイルがあると、次元不一致でエラーになります。以前のファイルはリネームするか、削除してください。

　今回のケースでは、出力次元が10万を超え、また、訓練データ量も多いので、1epochあたりの処理時間がだいぶ大きくなっています。筆者の場合、1epochに5000秒ほどかかりました。

# ５．　学習結果

　上記のとおり、系列データ長20、埋め込みベクトル次元400、隠れ層次元600という条件のもとで、訓練を実施しました。

　応答文生成処理を実行するにあたっては、訓練データの文の切れ目を表すデリミタを、3-5節で変更しましたので、処理を記述する[こちらのコード](https://qiita.com/gacky01/items/5cc14af9f27ce38b94a8#%EF%BC%93%EF%BC%92%EF%BC%93%E3%83%8B%E3%83%A5%E3%83%BC%E3%83%A9%E3%83%AB%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%E5%AE%9A%E7%BE%A9)の```def response(self,e_input,length) :```の中にある以下の行を

```
target_seq[0,  0] = word_indices['SSSS']
```

以下のように修正してください。

```
target_seq[0,  0] = word_indices['RESRES']
```

　また、その少し先にある以下の行を、

```
if sampled_char == 'SSSS' :
```

以下のように修正してください。

```
if sampled_char == 'REQREQ' :
```

　以下は28epoch訓練後の結果です。延べ36時間以上かかりました。

```
>> おはよう！
おはようサーンです！
>> こんにちは。
こんにちは。️。️。️。️。️。️。️
>> ご飯食べた？
私もご飯食べたい！
>> 今何してる？
私もキャッチャーしました！
>> それでは御免蒙りまするでござります。
私も混ぜてください。
```

　前よりマシになったように見えます。

# ６．　おわりに

　以上、訓練データの量的拡大による応答文生成精度改善の試みについて、記述しました。訓練データ量を大きくすることにより、応答文作成精度は向上するように見えますが、以下のファクターにより、より多くの訓練時間を必要とするようになりました。

+ １エポックあたりのデータ量が多い
+ 単語数が多くなるため、入出力次元が大きくなる
+ メモリ制約から、バッチサイズを小さくする必要がある

　引き続き訓練を継続して、さらなる精度改善を図るとともに、[訓練時間を短縮する方法が無いか、検討](https://qiita.com/gacky01/items/12e3c5fc04dd4d74b1bb "Twitterデータを用いたチャットボットの訓練　－その2　処理性能とメモリ使用量改善")してみます。

# 変更履歴

| 項番  | 日付  | 変更箇所  | 内容 |
|--:|:--|:--|:--|
| 1  | 2018/12/15  | -  | 初版 |
|2 |2019/8/27 |５章|デリミタ変更に伴う、応答文生成処理の修正について追記|