LoginSignup
12
21

More than 3 years have passed since last update.

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

Last updated at Posted at 2018-12-15

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

1. はじめに

 前回の投稿で、Twitterからの会話データ収集ツールを準備しましたので、収集したデータからエンコーダ/デコーダ入力、およびラベルデータを生成して、以前チャットボット用に作成したKerasベースのAttention付きSeq2seqモデルを訓練し、応答文生成の精度が向上するかどうか、見てみます。

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

2. 本稿のゴール

 以下の通りです。

  • 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マシンか、クラウドサービスの利用を推奨します。

3. 訓練データ生成

3-1. Twitter会話データの収集

 こちらの記事の内容にしたがって、Twitterから会話データを収集します。実行したいフォルダに実行ファイルconvercation_py3.pyを置き、ツイートデータの格納用フォルダtweetを作成したうえで、以下のように実行してください。

$ python conversation_py3.py です

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

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

3-2. 品詞分解

 京大黒橋・河原研究室のJUMAN++を使用します。

 名大会話コーパス等から訓練データを生成した時には、V1.02を使用しましたが、この版は動作が遅く、20万会話対を収納したファイルの品詞分解が、24時間では終了しませんでした。

 そこで、こちらの記事を参考に、最新版を導入します。2018年11月時点の最新版は2.0.0-rc2で、まだ「開発版」の扱いですが、背に腹は代えられません。

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

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

3-3. 単語リスト作成

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

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

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

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

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

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フォルダを準備したうえで、以下のようにコマンド起動します。

$ python generate_word_list.py

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

3-4. 辞書および単語インデックス配列作成

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

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

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

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

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')                                   #0パディング対策。インデックス0用キャラクタを追加
    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節と同じフォルダ上で、以下のようにコマンド起動します。

$ python generate_index_matrix.py

3-5. 訓練データ生成

 前節で作成した単語インデックス配列から、Seq2Seqモデル用のエンコーダインプット、デコーダインプット、およびラベルを生成します。これらがニューラルネットワークでどのように使われるのかについては、こちらをご覧ください。

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

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

tw_fig1.png

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

tw_fig2.png

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

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

    #入力、ラベルテンソルの初期値定義(0値マトリックス)
    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を指定してあります。

$ python generate_data.py 20 20

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

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

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

4. 訓練

 準備したデータを使って、ニューラルネットワークを訓練します。対象となるニューラルネットワークは、こちらの記事のものです。

 ただし、そのままだとメモリが足りなくてResourceExhaustedErrorが発生するので、3-2-4節のバッチサイズbatch_size と隠れ層の次元n_hiddenを、以下のように変更しました。

batch_size = 80
n_hidden = int(vec_dim*1.5 ) #隠れ層の次元

 次元を変更しましたので、以前作成したパラメータファイルがあると、次元不一致でエラーになります。以前のファイルはリネームするか、削除してください。

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

5. 学習結果

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

 応答文生成処理を実行するにあたっては、訓練データの文の切れ目を表すデリミタを、3-5節で変更しましたので、処理を記述するこちらのコード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時間以上かかりました。

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

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

6. おわりに

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

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

 引き続き訓練を継続して、さらなる精度改善を図るとともに、訓練時間を短縮する方法が無いか、検討してみます。

変更履歴

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
21