Kerasで実装するSeq2Seq −その2 単層LSTM

 本稿では、KerasベースのSeq2Seq(Sequence to Sequence)モデルによるチャットボットを、LSTM(Long short-term memory)単層のアーキテクチャで作成してみます。


1. はじめに

 本稿はSeq2SeqをKerasで構築し、チャットボットの作成を目指す投稿の2回目です。前回の投稿で、訓練データを準備しましたので、今回は単層LSTMのSeq2Seqニューラルネットワークを構築して、訓練と応答文生成を行います。


2. 本稿のゴール

 以下のとおりです。


  • ニューラルネットワーク(単層LSTM)の構築と、訓練

  • 応答文生成

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


  • 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


3. ニューラルネットワーク構築


3−1. ソースコード

Keras : Ex-Tutorials : Seq2Seq 学習へのイントロを参考に、コードを組んでみました。

 コードはJupyter Notebook上で、コードブロックごとに実行していくことを前提としています。


3-1-1. Import宣言

 まず、import宣言です。

import tensorflow as tf

import numpy as np
import csv
import random
import numpy.random as nr
import sys
import math
import time
import pickle
import gc
import os

from __future__ import print_function
from keras.layers.core import Dense
from keras.layers.core import Masking
from keras.layers import Input
from keras.models import Model
from keras.layers.recurrent import SimpleRNN
from keras.layers.recurrent import LSTM
from keras.layers.embeddings import Embedding
from keras.layers.normalization import BatchNormalization
from keras.initializers import glorot_uniform
from keras.initializers import uniform
from keras.initializers import orthogonal
from keras.initializers import TruncatedNormal
from keras import regularizers
from keras import backend as K
from keras.utils import np_utils
from keras.utils import plot_model

from pyknp import Jumanpp
import codecs

 最後から2行目のpyknpのインストールについては、5-2節を参照してください。


3-1-2. 訓練データロード

 次に、訓練データ等のロードです。

#単語ファイルロード

with open('words.pickle', 'rb') as ff :
words=pickle.load(ff)

#Encoder Inputデータをロード
with open('e.pickle', 'rb') as f :
e = pickle.load(f)

#Decoder Inputデータをロード
with open('d.pickle', 'rb') as g :
d = pickle.load(g)

#ラベルデータをロード
with open('t.pickle', 'rb') as h :
t = pickle.load(h)

#maxlenロード
with open('maxlen.pickle', 'rb') as maxlen :
[maxlen_e, maxlen_d] = pickle.load(maxlen)

n_split=int(e.shape[0]*0.95) #訓練データとテストデータを95:5に分割
e_train,e_test=np.vsplit(e,[n_split]) #エンコーダインプットデータを訓練用とテスト用に分割
d_train,d_test=np.vsplit(d,[n_split]) #デコーダインプットデータを訓練用とテスト用に分割
t_train,t_test=np.vsplit(t,[n_split]) #ラベルデータを訓練用とテスト用に分割


3-1-3. ニューラルネットワーク定義

ニューラルネットワークの定義です。

class Dialog :

def __init__(self,maxlen_e,maxlen_d,n_hidden,input_dim,vec_dim,output_dim):
self.maxlen_e=maxlen_e
self.maxlen_d=maxlen_d
self.n_hidden=n_hidden
self.input_dim=input_dim
self.vec_dim=vec_dim
self.output_dim=output_dim

def create_model(self):
print('#2')
#
#エンコーダー
#
encoder_input = Input(shape=(self.maxlen_e,), dtype='int16', name='encoder_input')
e_i = Embedding(output_dim=self.vec_dim, input_dim=self.input_dim, #input_length=self.maxlen_e,
mask_zero=True,
embeddings_initializer=uniform(seed=20170719))(encoder_input)
e_i=BatchNormalization(axis=-1)(e_i)
e_i=Masking(mask_value=0.0)(e_i)
encoder_outputs, state_h, state_c =LSTM(self.n_hidden, name='encoder_LSTM',
return_state=True,
kernel_initializer=glorot_uniform(seed=20170719),
recurrent_initializer=orthogonal(gain=1.0, seed=20170719),
dropout=0.5, recurrent_dropout=0.5
)(e_i)
# `encoder_outputs` は使わない。statesのみ使用する
encoder_states = [state_h, state_c]

encoder_model = Model(inputs=encoder_input,
outputs=[encoder_outputs,state_h,state_c]) #エンコーダモデル
print('#3')
#
# デコーダー(学習用)
#

#レイヤ定義
decoder_LSTM = LSTM(self.n_hidden, name='decoder_LSTM',
return_sequences=True, return_state=True,
kernel_initializer=glorot_uniform(seed=20170719),
recurrent_initializer=orthogonal(gain=1.0, seed=20170719),
dropout=0.5, recurrent_dropout=0.5
)
decoder_Dense = Dense(self.output_dim,activation='softmax', name='decoder_Dense',
kernel_initializer=glorot_uniform(seed=20170719))
#入力
decoder_inputs = Input(shape=(self.maxlen_d,), dtype='int16', name='decoder_inputs')
d_i = Embedding(output_dim=self.vec_dim, input_dim=self.input_dim, #input_length=self.maxlen_d,
mask_zero=True,
embeddings_initializer=uniform(seed=20170719))(decoder_inputs)
d_i=BatchNormalization(axis=-1)(d_i)
d_i=Masking(mask_value=0.0)(d_i)
d_input = d_i
#LSTM
d_outputs, _, _ =decoder_LSTM(d_i,initial_state=encoder_states)
print('#4')
decoder_outputs = decoder_Dense(d_outputs)
model = Model(inputs=[encoder_input, decoder_inputs], outputs=decoder_outputs)
model.compile(loss="categorical_crossentropy",optimizer="Adam", metrics=['categorical_accuracy'])
#
#デコーダー(応答文生成)
#
print('#5')
#入力定義
decoder_state_input_h = Input(shape=(self.n_hidden,),name='input_h')
decoder_state_input_c = Input(shape=(self.n_hidden,),name='input_c')
decoder_states_inputs = [decoder_state_input_h, decoder_state_input_c]
#LSTM
decoder_lstm,state_h, state_c =decoder_LSTM(d_input,
initial_state=decoder_states_inputs) #initial_stateが学習の時と違う
decoder_states = [state_h,state_c]
decoder_res = decoder_Dense(decoder_lstm)
decoder_model = Model(
[decoder_inputs] + decoder_states_inputs,
[decoder_res] + decoder_states)

return model ,encoder_model ,decoder_model

#
#評価
#
def eval_perplexity(self,model,e_test,d_test,t_test,batch_size) :
row=e_test.shape[0]
list_loss=[]

s_time = time.time()
n_batch = math.ceil(row/batch_size)

n_loss=0
sum_loss=0.
for i in range(0,n_batch) :

s = i*batch_size
e = min([(i+1) * batch_size,row])
e_on_batch = e_test[s:e,:]
d_on_batch = d_test[s:e,:]
t_on_batch = t_test[s:e,:]
t_on_batch = np_utils.to_categorical(t_on_batch,self.output_dim)
#mask行列作成
mask1 = np.zeros((e-s,self.maxlen_d,self.output_dim),dtype=np.float32)
for j in range(0,e-s) :
n_dim = maxlen_d-list(d_on_batch[j,:]).count(0.)
mask1[j,0:n_dim,:]=1
n_loss += n_dim

mask2 = mask1.reshape(1,(e-s)*self.maxlen_d*self.output_dim)
#予測
y_predict1 = model.predict_on_batch([e_on_batch, d_on_batch])
y_predict2 = np.maximum(y_predict1,1e-7)
y_predict2 = -np.log(y_predict2)
y_predict3 = y_predict2.reshape(1,(e-s)*self.maxlen_d*self.output_dim)

target=t_on_batch.reshape(1,(e-s)*self.maxlen_d*self.output_dim)
#マスキング
target1=target*mask2
#category_crossentropy計算
loss=np.dot(y_predict3,target.T)
sum_loss += loss[0,0]
#perplexity計算
perplexity=pow(math.e, sum_loss/n_loss)
elapsed_time = time.time() - s_time
sys.stdout.write("\r"+str(e)+"/"+str(row)+" "+str(int(elapsed_time))+"s "+"\t"+
"{0:.4f}".format(perplexity)+" ")
sys.stdout.flush()
del e_on_batch,d_on_batch,t_on_batch
del mask1,mask2
del y_predict1, y_predict2, y_predict3
del target
gc.collect()

print()

return perplexity

#
#train_on_batchメイン処理
#
def on_batch(self,model,j,e_train,d_train,t_train,e_val,d_val,t_val,batch_size) :
#損失関数、評価関数の平均計算用リスト
list_loss =[]
list_accuracy=[]

s_time = time.time()
row=e_train.shape[0]
n_batch = math.ceil(row/batch_size)
for i in range(0,n_batch) :
s = i*batch_size
e = min([(i+1) * batch_size,row])
e_on_batch = e_train[s:e,:]
d_on_batch = d_train[s:e,:]
t_on_batch = t_train[s:e,:]
t_on_batch = np_utils.to_categorical(t_on_batch,self.output_dim)
result = model.train_on_batch([e_on_batch, d_on_batch],t_on_batch)
list_loss.append(result[0])
list_accuracy.append(result[1])
#perplexity=pow(math.e, np.average(list_loss))
elapsed_time = time.time() - s_time
sys.stdout.write("\r"+str(e)+"/"+str(row)+" "+str(int(elapsed_time))+"s "+"\t"+
"{0:.4f}".format(np.average(list_loss))+"\t"+
"{0:.4f}".format(np.average(list_accuracy)))
sys.stdout.flush()
del e_on_batch,d_on_batch,t_on_batch

#perplexity評価
print()
val_perplexity = self.eval_perplexity(model,e_val,d_val,t_val,batch_size)

del list_loss, list_accuracy

return val_perplexity

#
#学習
#
def train(self, e_input, d_input,target,batch_size,epochs, emb_param) :

print ('#1',target.shape)
model, _, _ = self.create_model()
if os.path.isfile(emb_param) :
model.load_weights(emb_param) #埋め込みパラメータセット
print ('#6')

e_i = e_input
d_i = d_input
t_l = target
z = list(zip(e_i,d_i,t_l))
nr.shuffle(z) #シャッフル
e_i,d_i,t_l = zip(*z)

e_i = np.array(e_i).reshape(len(e_i),self.maxlen_e)
d_i = np.array(d_i).reshape(len(d_i),self.maxlen_d)
t_l = np.array(t_l).reshape(len(t_l),self.maxlen_d)

n_split = int(e_i.shape[0]*0.9) #訓練データとテストデータを9:1に分割
e_train,e_val=np.vsplit(e_i,[n_split]) #エンコーダインプットデータを訓練用と評価用に分割
d_train,d_val=np.vsplit(d_i,[n_split]) #デコーダインプットデータを訓練用と評価用に分割
t_train,t_val=np.vsplit(t_l,[n_split]) #ラベルデータを訓練用と評価用に分割

row = e_input.shape[0]
loss_bk = 10000
for j in range(0,epochs) :
print("Epoch ",j+1,"/",epochs)
val_perplexity = self.on_batch(model,j,e_train,d_train,t_train,e_val,d_val,t_val,batch_size)

#EarlyStopping
if j == 0 or val_perplexity <= loss_bk:
loss_bk = val_perplexity
else :
print('EarlyStopping')
break

return model

#
#応答文生成
#
def response(self,e_input,length) :
# Encode the input as state vectors.
encoder_outputs, state_h, state_c = encoder_model.predict(e_input)
states_value=[state_h, state_c]

# Generate empty target sequence of length 1.
target_seq = np.zeros((1,1))
# Populate the first character of target sequence with the start character.
target_seq[0, 0] = word_indices['SSSS']

# Sampling loop for a batch of sequences
# (to simplify, here we assume a batch of size 1).
#stop_condition = False
decoded_sentence = ''
for i in range(0,length) :
output_tokens, h, c = decoder_model.predict(
[target_seq]+ states_value)

# Sample a token
sampled_token_index = np.argmax(output_tokens[0, 0, :])
sampled_char = indices_word[sampled_token_index]

# Exit condition: find stop character.
if sampled_char == 'SSSS' :
break
decoded_sentence += sampled_char
# Update the target sequence (of length 1).
if i==length-1:
break
target_seq[0,0] = sampled_token_index
# Update states
states_value = [h, c]

return decoded_sentence


3-1-4. 訓練実行処理

 訓練実行処理です。

vec_dim = 400

epochs = 10
batch_size = 100
input_dim = len(words)
output_dim = input_dim
n_hidden = int(vec_dim*2 ) #隠れ層の次元

prediction = Dialog(maxlen_e,maxlen_d,n_hidden,input_dim,vec_dim,output_dim)
emb_param = 'param_seq2seq01.hdf5'
row = e_train.shape[0]
e_train = e_train.reshape(row,maxlen_e)
d_train = d_train.reshape(row,maxlen_d)
t_train = t_train.reshape(row,maxlen_d)
model = prediction.train(e_train, d_train,t_train,batch_size,epochs,emb_param)
plot_model(model, show_shapes=True,to_file='seq2seq01.png') #ネットワーク図出力
model.save_weights(emb_param) #学習済みパラメータセーブ

row2 = e_test.shape[0]
e_test = e_test.reshape(row2,maxlen_e)
d_test = d_test.reshape(row2,maxlen_d)
#t_test=t_test.reshape(row2,maxlen_d)
print()
perplexity = prediction.eval_perplexity(model,e_test,d_test,t_test,batch_size)
print('Perplexity=',perplexity)


3−2. ネットワーク図

 訓練用ニューラルネットワークです。

seq2seq01.png

 応答文生成用エンコーダーです。

seq2seq01_encoder.png

 応答文生成用デコーダーです。

seq2seq01_decoder.png


3−3. ソースコードの説明

 3-1-3項のニューラルネットワーク定義のコードについて、以下に説明を加えます。


3-3-1. 重みの共有

 Seq2Seqは訓練用ニューラルネットワークと、推論用(応答文生成用)ニューラルネットワークが別物なので、何らかの方法で訓練結果の重みを応答文生成用ニューラルネットワークに反映させる必要が有ります。

 今回の実装では、レイヤーの定義をぞれぞれのニューラルネットワークが共有することで、訓練用の重みをロードしたら、それが自動的に応答文生成用ニューラルネットワークに反映されるようにしました。

 レイヤー定義です。「create_model」内の、デコーダー(訓練用)の直前に有ります。

#レイヤ定義

decoder_LSTM = LSTM(self.n_hidden, name='decoder_LSTM',
return_sequences=True, return_state=True,
kernel_initializer=glorot_uniform(seed=20170719),
recurrent_initializer=orthogonal(gain=1.0, seed=20170719),
dropout=0.5, recurrent_dropout=0.5
)
decoder_Dense = Dense(self.output_dim,activation='softmax', name='decoder_Dense',
kernel_initializer=glorot_uniform(seed=20170719))

 これを、デコーダー(訓練用)およびデコーダー(応答文生成用)が共有します。

 デコーダー(訓練用)の記述です。

d_outputs, _, _  =decoder_LSTM(d_i,initial_state=encoder_states) 

 デコーダー(応答文生成用)の記述です。

decoder_lstm,state_h, state_c  =decoder_LSTM(d_input,

initial_state=decoder_states_inputs) #initial_stateが学習の時と違う

 なお、レイヤー定義でseed指定しているのは、実行時のランダム要素をなるべく排除して、ソースを修正、再実行したときの効果を確認しやすくするためです。


3-3-2. Train on Batch

 普通にfitを使って学習を進めようとしましたが、メモリ不足に陥ってしまったので、train_on_batchを使用することにしました。train_on_batchを使って、Kerasの外でミニバッチ制御を行うことによって、メモリが節約できます。

 以下の通りです。「on_batch」関数の中にあります。

for i in range(0,n_batch) :

s = i*batch_size
e = min([(i+1) * batch_size,row])
e_on_batch = e_train[s:e,:]
d_on_batch = d_train[s:e,:]
t_on_batch = t_train[s:e,:]
t_on_batch = np_utils.to_categorical(t_on_batch,self.output_dim)
result = model.train_on_batch([e_on_batch, d_on_batch],t_on_batch)


3-3-3. Perplexity

 Seq2Seqでは、損失関数としてperplexityというものを使用するようですが、Kerasにはそれが実装されていないので、自分で準備することにしました。

 perplexityはよく、損失関数$loss$を用いて、

e^{loss}

と定義されていますが、$loss$がcossentropyの損失関数の場合、cossentropyの定義から、上記は予測確率の逆数になります。これは取り得る選択肢の数を意味していて、これが小さいほど、選択候補が絞りこまれている、すなわち予測精度が高いということになります。

 cossentropyの損失関数はKerasでも標準でサポートしているので、最初はこれを使ってperplexityを計算しようとしましたが、妙に良い値が出るのと、系列長(入出力の単語数)を大きくするだけで値が改善するところから、もしかしてMask値0が計算に使われているのかもしれない、と考えて、自作することにしました。

 Kerasでは損失関数のカスタムメイドが可能ですが、マスク情報の渡し方がわからなかったので、Kerasの外側で実装しました。

 以下の通りです。「eval_perplexity」の中に有ります。

s = i*batch_size

e = min([(i+1) * batch_size,row])
e_on_batch = e_test[s:e,:]
d_on_batch = d_test[s:e,:]
t_on_batch = t_test[s:e,:]
t_on_batch = np_utils.to_categorical(t_on_batch,self.output_dim)
#mask行列作成
mask1 = np.zeros((e-s,self.maxlen_d,self.output_dim),dtype=np.float32)
for j in range(0,e-s) :
n_dim = maxlen_d-list(d_on_batch[j,:]).count(0.)
mask1[j,0:n_dim,:]=1
n_loss += n_dim

mask2 = mask1.reshape(1,(e-s)*self.maxlen_d*self.output_dim)
#予測
y_predict1 = model.predict_on_batch([e_on_batch, d_on_batch])
y_predict2 = np.maximum(y_predict1,1e-7)
y_predict2 = -np.log(y_predict2)
y_predict3 = y_predict2.reshape(1,(e-s)*self.maxlen_d*self.output_dim)

target=t_on_batch.reshape(1,(e-s)*self.maxlen_d*self.output_dim)
#マスキング
target1 = target*mask2
#category_crossentropy計算
loss = np.dot(y_predict3,target.T)
sum_loss += loss[0,0]
#perplexity計算
perplexity = pow(math.e, sum_loss/n_loss)

 入力テンソルの0の数を数えてマスク行列を作成し、ラベルテンソルをマスクします。予測テンソルの対数をとって符号を反転させ、ラベルテンソルとのベクトル積を計算すればcrossentropy損失関数が算出されますが、予測テンソルの各要素値を1e-7で下方から足切りしているのは、0や小さい値を対数計算すると、以下のエラーが発生するので、これを防ぐためです。

~/anaconda3/lib/python3.6/site-packages/ipykernel_launcher.py:108: RuntimeWarning: divide by zero encountered in log


3-3-4. Early Stopping

 Early stoppingも自作します。データを訓練データ、評価データ、テストデータの3つに分け、訓練は訓練データを使用して行いますが、epoch毎に評価データを使ってperplexityを計算し、値の減少が止まったところで訓練を打ち切ります。最後に、テストデータを使ってperplexityを計算します。

 以下の通りです。「train」の中にあります。

loss_bk = 10000

for j in range(0,epochs) :
print("Epoch ",j+1,"/",epochs)
val_perplexity = self.on_batch(model,j,e_train,d_train,t_train,e_val,d_val,t_val,batch_size)

#EarlyStopping
if j == 0 or val_perplexity <= loss_bk:
loss_bk = val_perplexity
else :
print('EarlyStopping')
break


3-3-5. 応答文生成

 コードは「resonse」の部分です。応答文生成はKerasレイヤーおよびニューラルネットワークの外側で1単語単位にループを回します。出力された単語およびstatesを、次回ループの入力にします。ループはデリミタ'SSSS'が出力されるか、規定回数に達するまで実行します。

fig4.png


4. 訓練

3-1節のコードを順次実行すると、訓練が始まります。訓練データサイズは約9万件、バッチサイズは100です。5epochほどでearly stoppingがかかりました。このときのperplexityは約65です。


5. 応答文生成

 いよいよ発話に対する応答文を生成してみます。


5-1. pythonラッパー「pyknp」のインストール

 発話文の入力の延長でJUMAN++の品詞分解を実行するため、pythonラッパー「pyknp」をインストールします。

 JUMAN++のマニュアルにインストール方法が載っていますが、Anaconda環境の場合は、以下のコマンドでインストールします。

pip install ./pyknp-0.3(setup.pyがあるフォルダ)


5-2. 応答文生成処理実行

 3-1-1項のimport宣言と、3-1-3項のニューラルネットワーク定義のコードブロックを実行した後で、以下のコードを実行します。

#辞書をロード

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('words.pickle', 'rb') as ff :
words=pickle.load(ff)

#maxlenロード
with open('maxlen.pickle', 'rb') as maxlen :
[maxlen_e, maxlen_d] = pickle.load(maxlen)

vec_dim = 400
input_dim = len(words)
output_dim = input_dim
n_hidden = int(vec_dim*2 ) #隠れ層の次元

dialog = Dialog(maxlen_e, 1, n_hidden, input_dim, vec_dim, output_dim)
model,encoder_model,decoder_model=dialog.create_model()

plot_model(encoder_model, show_shapes=True,to_file='seq2seq01_encoder.png')
plot_model(decoder_model, show_shapes=True,to_file='seq2seq01_decoder.png')
emb_param='param_seq2seq01.hdf5'
model.load_weights(emb_param) #パラメータセット
sys.stdin = codecs.getreader('utf_8')(sys.stdin)

# Use Juman++ in subprocess mode
jumanpp = Jumanpp()

while True:
cns_input = input(">> ")
if cns_input == "q":
print("終了")
break

result = jumanpp.analysis(cns_input)
input_text=[]
for mrph in result.mrph_list():
input_text.append(mrph.midasi)

mat_input=np.array(input_text)

#入力データe_inputに入力文の単語インデックスを設定
e_input=np.zeros((1,maxlen_e))
for i in range(0,len(mat_input)) :
if mat_input[i] in words :
e_input[0,i] = word_indices[mat_input[i]]
else :
e_input[0,i] = word_indices['UNK']

input_sentence=''
for i in range(0,maxlen_e) :
j=e_input[0,i]
if j!=0 :
input_sentence +=indices_word[j]
else :
break

#応答文組み立て
response = dialog.response(e_input,maxlen_d)

print(response)

 ダイアログボックスが開きますので、発話文を入力すると、応答文が表示されます。初回のみ、ちょっと時間がかかります。

 終了するときは、半角「q」を入力します。


5-3. 実行結果

 以下の通りです。まあ、そんなものかな、といった感じです。

>> おはよう!

……
>> 今何してる?
うん。
>> ご飯食べた?
うん。
>> 何食べた?
うん。
>> こんにちは。
うん。
>> それでは御免蒙りまするでござります
何を言ってやがるんだ。


6. おわりに

 以上、単層LSTMによるseq2seqを実装し、チャットボットが動作するところまで実現できました。今後の予定としては、多層LSTMAttentionによって、どれくらい精度が向上するか、確認していきます。