自然言語処理
Keras
LSTM
seq2seq
チャットボット

Kerasで実装するSeq2Seq −その3 多層LSTMとBidirectional

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


1. はじめに

  本稿はSeq2SeqをKerasで構築し、チャットボットの作成を目指す投稿の3回目です。前回の投稿では、単層LSTMのSeq2Seqニューラルネットワークを構築しましたが、今回は、これをBidirectionalの多層LSTMに拡張します。


2. 本稿のゴール

 以下のとおりです。


  • ニューラルネットワーク(Bidirectional多層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

 また、投稿第1回の内容に従って、訓練データが準備されているものとします。


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


3−1. Bidirectional多層LSTMの構成

 実装に当たっては、「深層学習による自然言語処理 」(講談社)を参考にしました。

 エンコーダー、デコーダーとも、LSTMレイヤを2段にします。また、エンコーダーについては、入力系列を逆方向から処理するレイヤを設け、出力されるstatesを順方向のものと足し合わせて、デコーダーの入力にします。以下にレイヤ構成を図示します。

fig5.png

 逆方向処理の実装方式ですが、KerasにはBidirectional用のレイヤラッパーが用意されています。これは層毎にBidirectional処理を行いますが、今回は多層LSTM処理の後でBidirecitonal処理を行いたかったので、LSTMのgo_backwardsオプションを使用して実装しました。


3−2. ソースコード


3−2−1. Import宣言

 import宣言のコードです。前回投稿とほとんど同じですが、kerasのimportが追加されています。

import tensorflow as tf

import numpy as np
import csv
import random
import numpy.random as nr
import keras
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


3−2−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−2−3. ニューラルネットワーク定義

 

 ニューラルネットワークの定義です。前回との違いは、LSTMが2層になっていることと、逆方向のLSTMがあることです。エンコーダーのLSTMは4層、デコーダーのLSTMは2層あります。

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)

e_i_fw1, state_h_fw1, state_c_fw1 =LSTM(self.n_hidden, name='encoder_LSTM_fw1', #前向き1段目
return_sequences=True,return_state=True,
kernel_initializer=glorot_uniform(seed=20170719),
recurrent_initializer=orthogonal(gain=1.0, seed=20170719),
)(e_i)
e_i_fw2, state_h_fw2, state_c_fw2 =LSTM(self.n_hidden, name='encoder_LSTM_fw2', #前向き2段目
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_fw1)

e_i_bw0=e_i
e_i_bw1, state_h_bw1, state_c_bw1 =LSTM(self.n_hidden, name='encoder_LSTM_bw1', #後ろ向き1段目
return_sequences=True,return_state=True,go_backwards=True,
kernel_initializer=glorot_uniform(seed=20170719),
recurrent_initializer=orthogonal(gain=1.0, seed=20170719),
)(e_i_bw0)
e_i_bw2, state_h_bw2, state_c_bw2 =LSTM(self.n_hidden, name='encoder_LSTM_bw2', #後ろ向き2段目
return_state=True,go_backwards=True,
kernel_initializer=glorot_uniform(seed=20170719),
recurrent_initializer=orthogonal(gain=1.0, seed=20170719),
dropout=0.5, recurrent_dropout=0.5
)(e_i_bw1)

encoder_outputs = keras.layers.add([e_i_fw2,e_i_bw2],name='encoder_outputs')
# `encoder_outputs` は使わない。statesのみ使用する
state_h_1=keras.layers.add([state_h_fw1,state_h_bw1],name='state_h_1')
state_c_1=keras.layers.add([state_c_fw1,state_c_bw1],name='state_c_1')
state_h_2=keras.layers.add([state_h_fw2,state_h_bw2],name='state_h_2')
state_c_2=keras.layers.add([state_c_fw2,state_c_bw2],name='state_c_2')
encoder_states1 = [state_h_1,state_c_1]
encoder_states2 = [state_h_2,state_c_2]

encoder_model = Model(inputs=encoder_input,
outputs=[encoder_outputs,state_h_1,state_c_1,state_h_2,state_c_2]) #エンコーダモデル

print('#3')
#デコーダー(学習用)
# デコーダを、完全な出力シークエンスを返し、内部状態もまた返すように設定します。
# 訓練モデルではreturn_sequencesを使用しませんが、推論では使用します。

#レイヤ定義
decoder_LSTM1 = LSTM(self.n_hidden, name='decode_LSTM1',
return_sequences=True, return_state=True,
kernel_initializer=glorot_uniform(seed=20170719),
recurrent_initializer=orthogonal(gain=1.0, seed=20170719),
)
decoder_LSTM2 =LSTM(self.n_hidden, name='decode_LSTM2',
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_i_1, h1, c1 = decoder_LSTM1(d_i,initial_state=encoder_states1)
d_i_2, h2, c2 = decoder_LSTM2(d_i_1,initial_state=encoder_states2)
#出力
decoder_outputs = decoder_Dense(d_i_2)
print('#4')
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_1 = Input(shape=(self.n_hidden,),name='input_h1')
decoder_state_input_c_1 = Input(shape=(self.n_hidden,),name='input_c1')
decoder_state_input_h_2 = Input(shape=(self.n_hidden,),name='input_h2')
decoder_state_input_c_2 = Input(shape=(self.n_hidden,),name='input_c2')
decoder_states_inputs_1 = [decoder_state_input_h_1, decoder_state_input_c_1]
decoder_states_inputs_2 = [decoder_state_input_h_2, decoder_state_input_c_2]
decoder_states_inputs = [decoder_state_input_h_1, decoder_state_input_c_1,
decoder_state_input_h_2, decoder_state_input_c_2]

#LSTM1段目
d_i_1_2,state_h_1, state_c_1 =decoder_LSTM1(d_input,
initial_state=decoder_states_inputs_1) #initial_stateが学習の時と違う
#LSTM2段目
d_i_2_2, state_h_2, state_c_2 =decoder_LSTM2(d_i_1_2,
initial_state=decoder_states_inputs_2)
decoder_states=[state_h_1,state_c_1,state_h_2, state_c_2]
#出力
decoder_outputs = decoder_Dense(d_i_2_2)

decoder_model = Model(
[decoder_inputs] + decoder_states_inputs,
[decoder_outputs] + 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])
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_1,state_c_1,state_h_2,state_c_2 = encoder_model.predict(e_input)
states_value=[state_h_1,state_c_1,state_h_2,state_c_2]

# 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, h1, c1, h2, c2 = 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 = [h1, c1, h2, c2]

return decoded_sentence


3−2−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_seq2seq021.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='seq2seq021.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−3. ネットワーク図

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

seq2seq021.png

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

seq2seq021_encoder.png

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

seq2seq021_decoder.png


4. 訓練

 3-2節のコードを順次実行すると、訓練が始まります。Early stoppingで訓練が停止した時のperplexityは約64で、前回からわずかに改善しました。


5. 応答文生成

 3-2-1項、3-2-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='seq2seq021_encoder.png')
plot_model(decoder_model, show_shapes=True,to_file='seq2seq021_decoder.png')
emb_param='param_seq2seq021.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)

 実行した結果は、以下のとおりです。前回から、あまり変わり映えはしません。

> おはよう!

……
>> 今何してる?
うん。
>> ご飯食べた?
うん。
>> こんにちは。
うん。
>> それでは御免蒙りまするでござります。
ヘエ、お目出とう存じます。


6. おわりに

 以上、Bidirectional多層LSTMによるseq2seqを実装してみました。次回は、Attentionの実装にチャレンジします。