深度學習對話系統實戰篇--新版本chatbot代碼實現

上篇文章我們使用tf.contrib.legacy_seq2seq下的API構建了一個簡單的chatbot對話系統,但是我們已經說過,這部分代碼是1.0版本之前所提供的API,將來會被棄用,而且API介面並不靈活,在實際使用過程中還會存在版本不同導致的各種個樣的錯誤。所以我們有必要學習一下新版本的API,這裡先來說一下二者的不同:

  • 新版本都是用dynamic_rnn來構造RNN模型,這樣就避免了數據長度不同所帶來的困擾,不需要再使用model_with_buckets這種方法來構建模型,使得我們數據處理和模型代碼都簡潔很多
  • 新版本將Attention、Decoder等幾個主要的功能都分別進行封裝,直接調用相應的Wapper函數進行封裝即可,調用起來更加靈活方便,而且只需要寫幾個簡單的函數既可以自定義的各個模塊以滿足我們個性化的需求。
  • 實現了beam_search功能,可直接調用

這次我們先來看如何直接使用新版本API構造對話系統,然後等下一篇文章在分析一些主要文件和函數的源碼實現。本文代碼可以再我的github中找到:seq2seq_chatbot_new。歡迎fork和star~~

數據處理

仍然沿用之前的代碼,不過createBatch函數可以變得簡單而又整潔,原因是新版本API我們在定義輸入的placeholder是不需要在定義為seq_len*batch_size這樣的列表,直接定義一個batch_size*seq_len的tensor即可。所以數據處理部分的代碼也可以簡化為得到一個嵌套列表的形式即可。這裡我們重新定義Batch類,使其包含四個元素分別為encoder_inputs、encoder_inputs_length、decoder_targets、decoder_targets_length,前兩項是PAD之後的源序列及每個序列的長度,後兩項為PAD之後的目的序列和每個序列的長度。這裡encoder_inputs_length和decoder_targets_length是為了動態編解碼時表示序列長度的作用,下面給出修改了的Batch類和createBatch函數,其他函數都沒有發生變化。

class Batch: def __init__(self): self.encoder_inputs = [] #嵌套列表,每個元素都是一個句子中每個單詞都id self.encoder_inputs_length = [] #一維列表,每個元素對應上面每個句子的長度 self.decoder_targets = [] self.decoder_targets_length = [] def createBatch(samples): 根據給出的samples(就是一個batch的數據),進行padding並構造成placeholder所需要的數據形式 :param samples: 一個batch的樣本數據,列表,每個元素都是[question, answer]的形式,id :return: 處理完之後可以直接傳入feed_dict的數據格式 batch = Batch() #獲取每個樣本的長度,並保存在source_length和target_length中 batch.encoder_inputs_length = [len(sample[0]) for sample in samples] batch.decoder_targets_length = [len(sample[1]) for sample in samples] #獲得一個batch樣本中最大的序列長度 max_source_length = max(batch.encoder_inputs_length) max_target_length = max(batch.decoder_targets_length) #將每個樣本進行PAD至最大長度 for sample in samples: #將source進行反序並PAD值本batch的最大長度 source = list(reversed(sample[0])) pad = [padToken] * (max_source_length - len(source)) batch.encoder_inputs.append(pad + source) #將target進行PAD,並添加END符號 target = sample[1] pad = [padToken] * (max_target_length - len(target)) batch.decoder_targets.append(target + pad) return batch

模型構建

這一部分代碼主要是從tensorflow官網給出的nmt例子的代碼簡化而來,實現了最基本的attention和beam_search等功能,同時有將nmt代碼中繁雜的代碼邏輯進行簡化,將不必要的代碼都清除,是的代碼的易讀性提高。這裡參考nmt中所提到的構建train、eval、inference三個圖進行模型構建,好處在於(下面部分翻譯自nmt官方文檔):

  • inference圖往往與train和eval結構存在較大差異(沒有decoder輸入和目標,需要使用貪婪或者beam_search進行decode,batch_size也不同等等),所以往往需要單獨進行構建
  • eval圖也會得到簡化,因為其不需要進行反向傳播,只需要得到一個loss和acc值
  • 數據可以分別進行feed,簡化數據操作
  • 變數重用變得簡單,因為train、eval存在一些公用變數和代碼塊,就不需要我們重複定義,使代碼簡化
  • 只需要在train時不斷保存模型參數,然後在eval和infer的時候restore參數即可

以上,所以我們構建了train、eval、infer三個函數來實現上面的功能。在看代碼之前我們先來簡單說一下新版API幾個主要的模塊以及相互之間的調用關係。tf.contrib.seq2seq文件夾下面主要有下面6個文件,除了loss文件和之前的sequence_loss函數沒有很大區別,這裡不介紹之外,其他幾個文件都會簡單的說一下,這裡主要介紹函數和類的功能,源碼會放在下篇文章中介紹。

  • decoder
  • basic_decoder
  • helper
  • attention_wrapper
  • beam_search_decoder
  • loss

BasicDecoder類和dynamic_decode

decoder和basic_decoder文件可以放在一起看,decoder文件中定義了Decoder抽象類和dynamic_decode函數,dynamic_decode可以視為整個解碼過程的入口,需要傳入的參數就是Decoder的一個實例,他會動態的調用Decoder的step函數按步執行decode,可以理解為Decoder類定義了單步解碼(根據輸入求出輸出,並將該輸出當做下一時刻輸入),而dynamic_decode則會調用control_flow_ops.while_loop這個函數來循環執行直到輸出<eos>結束編碼過程。而basic_decoder文件定義了一個基本的Decoder類實例BasicDecoder,看一下其初始化函數:

def __init__(self, cell, helper, initial_state, output_layer=None):

需要傳入的參數就是cell類型、helper類型、初始化狀態(encoder的最後一個隱層狀態)、輸出層(輸出映射層,將rnn_size轉化為vocab_size維),需要注意的就是前面兩個,下面分別介紹:

cell類型(Attention類型)

cell類型就是RNNCell,也就是decode階段的神經元,可以使簡單的RNN、GRU、LSTM(也可以加上dropout、並使用MultiRNNCell進行堆疊成多層),也可以是加上了Attention功能之後的RNNcell。這就引入了attention_wrapper文件中定義的幾種attention機制(BahdanauAttention、 LuongAttention、 BahdanauMonotonicAttention、 LuongMonotonicAttention)和將attention機制封裝到RNNCell上面的方法AttentionWrapper。其實很簡單,就跟dropoutwrapper、outputwrapper一樣,我們只需要在原本RNNCell的基礎上在封裝一層attention即可。代碼如下所示:

# 分為三步,第一步是定義attention機制,第二步是定義要是用的基礎的RNNCell,第三步是使用AttentionWrapper進行封裝 #定義要使用的attention機制。 attention_mechanism = tf.contrib.seq2seq.BahdanauAttention(num_units=self.rnn_size, memory=encoder_outputs, memory_sequence_length=encoder_inputs_length) #attention_mechanism = tf.contrib.seq2seq.LuongAttention(num_units=self.rnn_size, memory=encoder_outputs, memory_sequence_length=encoder_inputs_length) # 定義decoder階段要是用的LSTMCell,然後為其封裝attention wrapper decoder_cell = self._create_rnn_cell() decoder_cell = tf.contrib.seq2seq.AttentionWrapper(cell=decoder_cell, attention_mechanism=attention_mechanism, attention_layer_size=self.rnn_size, name=Attention_Wrapper)

helper類型

helper其實就是decode階段如何根據預測結果得到下一時刻的輸入,比如訓練過程中應該直接使用上一時刻的真實值作為下一時刻輸入,預測過程中可以使用貪婪的方法選擇概率最大的那個值作為下一時刻等等。所以Helper也就可以大致分為訓練時helper和預測時helper兩種。官網給出了下面幾種Helper類:

  • "Helper":最基本的抽象類
  • "TrainingHelper":訓練過程中最常使用的Helper,下一時刻輸入就是上一時刻target的真實值
  • "GreedyEmbeddingHelper":預測階段最常使用的Helper,下一時刻輸入是上一時刻概率最大的單詞通過embedding之後的向量
  • "SampleEmbeddingHelper":預測時helper,繼承自GreedyEmbeddingHelper,下一時刻輸入是上一時刻通過某種概率分布採樣而來在經過embedding之後的向量
  • "CustomHelper":最簡單的helper,一般用戶自定義helper時會基於此,需要用戶自己定義如何根據輸出得到下一時刻輸入
  • "ScheduledEmbeddingTrainingHelper":訓練時Helper,繼承自TrainingHelper,添加了廣義伯努利分布,對id的embedding向量進行sampling
  • "ScheduledOutputTrainingHelper":訓練時Helper,繼承自TrainingHelper,直接對輸出進行採樣
  • "InferenceHelper":CustomHelper的特例,只用於預測的helper,也需要用戶自定義如何得到下一時刻輸入

所以了解cell和helper類之後我們就可以很輕鬆的構建decode階段的模型,以train階段為例:

#分為四步,第一步是定義cell類型,第二步是定義helper類型,第三步是定義BasicDecoder類實例,第四步是調用dynamic_decode函數進行解碼 cell = ***(上面的代碼) training_helper = tf.contrib.seq2seq.TrainingHelper(inputs=decoder_inputs_embedded, sequence_length=self.decoder_targets_length, time_major=False, name=training_helper) training_decoder = tf.contrib.seq2seq.BasicDecoder(cell=decoder_cell, helper=training_helper, initial_state=decoder_initial_state, output_layer=output_layer) decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=training_decoder, impute_finished=True, maximum_iterations=self.max_target_sequence_length)

beam search decoder類

到這,基本上就可以構建一個完整的seq2seq模型了,但是上面的文件中還有beam_search_decoder.py文件沒有介紹,也就是我們常用的beam_search方法,下面也簡單說一下。該文件定義了BeamSearchDecoder類,其實是一個Decoder的實例,跟BasicDecoder在一個等級上,但是二者又存在著不同,因為BasicDecoder需要指定helper參數,也就是指定decode階段如何根據上一時刻輸出獲得下一時刻輸入。但是BeamSearchDecoder不需要,因為其在內部實現了beam_search的功能,也就包含了helper的效果,不需要再額外定義。所以BeamSearchDecoder的調用方法如下所示:

#分為三步,第一步是定義cell,第二步是定義BeamSearchDecoder,第三步是調用dynamic_decode函數進行解碼 cell = ***(上面代碼) inference_decoder = tf.contrib.seq2seq.BeamSearchDecoder(cell=decoder_cell, embedding=embedding, start_tokens=start_tokens, end_token=end_token, initial_state=decoder_initial_state, beam_width=self.beam_size, output_layer=output_layer) decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=inference_decoder, maximum_iterations=self.max_target_sequence_length)

OK,接下來切入正題,看一下model部分代碼:

class Seq2SeqModel(): def __init__(self, rnn_size, num_layers, embedding_size, learning_rate, word_to_idx, mode, use_attention, beam_search, beam_size, max_gradient_norm=5.0): self.learing_rate = learning_rate self.embedding_size = embedding_size self.rnn_size = rnn_size self.num_layers = num_layers self.word_to_idx = word_to_idx self.vocab_size = len(self.word_to_idx) self.mode = mode self.use_attention = use_attention self.beam_search = beam_search self.beam_size = beam_size self.max_gradient_norm = max_gradient_norm #執行模型構建部分的代碼 self.build_model() def _create_rnn_cell(self, single=False): def single_rnn_cell(): # 創建單個cell,這裡需要注意的是一定要使用一個single_rnn_cell的函數,不然直接把cell放在MultiRNNCell # 的列表中最終模型會發生錯誤 single_cell = tf.contrib.rnn.LSTMCell(self.rnn_size) #添加dropout cell = tf.contrib.rnn.DropoutWrapper(single_cell, output_keep_prob=self.keep_prob_placeholder) return cell #列表中每個元素都是調用single_rnn_cell函數 cell = tf.contrib.rnn.MultiRNNCell([single_rnn_cell() for _ in range(self.num_layers)]) return cell def build_model(self): print(building model... ...) #=================================1, 定義模型的placeholder self.encoder_inputs = tf.placeholder(tf.int32, [None, None], name=encoder_inputs) self.encoder_inputs_length = tf.placeholder(tf.int32, [None], name=encoder_inputs_length) self.batch_size = tf.placeholder(tf.int32, [], name=batch_size) self.keep_prob_placeholder = tf.placeholder(tf.float32, name=keep_prob_placeholder) self.decoder_targets = tf.placeholder(tf.int32, [None, None], name=decoder_targets) self.decoder_targets_length = tf.placeholder(tf.int32, [None], name=decoder_targets_length) # 根據目標序列長度,選出其中最大值,然後使用該值構建序列長度的mask標誌。用一個sequence_mask的例子來說明起作用 # tf.sequence_mask([1, 3, 2], 5) # [[True, False, False, False, False], # [True, True, True, False, False], # [True, True, False, False, False]] self.max_target_sequence_length = tf.reduce_max(self.decoder_targets_length, name=max_target_len) self.mask = tf.sequence_mask(self.decoder_targets_length, self.max_target_sequence_length, dtype=tf.float32, name=masks) #=================================2, 定義模型的encoder部分 with tf.variable_scope(encoder): #創建LSTMCell,兩層+dropout encoder_cell = self._create_rnn_cell() #構建embedding矩陣,encoder和decoder公用該詞向量矩陣 embedding = tf.get_variable(embedding, [self.vocab_size, self.embedding_size]) encoder_inputs_embedded = tf.nn.embedding_lookup(embedding, self.encoder_inputs) # 使用dynamic_rnn構建LSTM模型,將輸入編碼成隱層向量。 # encoder_outputs用於attention,batch_size*encoder_inputs_length*rnn_size, # encoder_state用於decoder的初始化狀態,batch_size*rnn_szie encoder_outputs, encoder_state = tf.nn.dynamic_rnn(encoder_cell, encoder_inputs_embedded, sequence_length=self.encoder_inputs_length, dtype=tf.float32) # =================================3, 定義模型的decoder部分 with tf.variable_scope(decoder): encoder_inputs_length = self.encoder_inputs_length if self.beam_search: # 如果使用beam_search,則需要將encoder的輸出進行tile_batch,其實就是複製beam_size份。 print("use beamsearch decoding..") encoder_outputs = tf.contrib.seq2seq.tile_batch(encoder_outputs, multiplier=self.beam_size) encoder_state = nest.map_structure(lambda s: tf.contrib.seq2seq.tile_batch(s, self.beam_size), encoder_state) encoder_inputs_length = tf.contrib.seq2seq.tile_batch(self.encoder_inputs_length, multiplier=self.beam_size) #定義要使用的attention機制。 attention_mechanism = tf.contrib.seq2seq.BahdanauAttention(num_units=self.rnn_size, memory=encoder_outputs, memory_sequence_length=encoder_inputs_length) #attention_mechanism = tf.contrib.seq2seq.LuongAttention(num_units=self.rnn_size, memory=encoder_outputs, memory_sequence_length=encoder_inputs_length) # 定義decoder階段要是用的LSTMCell,然後為其封裝attention wrapper decoder_cell = self._create_rnn_cell() decoder_cell = tf.contrib.seq2seq.AttentionWrapper(cell=decoder_cell, attention_mechanism=attention_mechanism, attention_layer_size=self.rnn_size, name=Attention_Wrapper) #如果使用beam_seach則batch_size = self.batch_size * self.beam_size。因為之前已經複製過一次 batch_size = self.batch_size if not self.beam_search else self.batch_size * self.beam_size #定義decoder階段的初始化狀態,直接使用encoder階段的最後一個隱層狀態進行賦值 decoder_initial_state = decoder_cell.zero_state(batch_size=batch_size, dtype=tf.float32).clone(cell_state=encoder_state) output_layer = tf.layers.Dense(self.vocab_size, kernel_initializer=tf.truncated_normal_initializer(mean=0.0, stddev=0.1)) if self.mode == train: # 定義decoder階段的輸入,其實就是在decoder的target開始處添加一個<go>,並刪除結尾處的<end>,並進行embedding。 # decoder_inputs_embedded的shape為[batch_size, decoder_targets_length, embedding_size] ending = tf.strided_slice(self.decoder_targets, [0, 0], [self.batch_size, -1], [1, 1]) decoder_input = tf.concat([tf.fill([self.batch_size, 1], self.word_to_idx[<go>]), ending], 1) decoder_inputs_embedded = tf.nn.embedding_lookup(embedding, decoder_input) #訓練階段,使用TrainingHelper+BasicDecoder的組合,這一般是固定的,當然也可以自己定義Helper類,實現自己的功能 training_helper = tf.contrib.seq2seq.TrainingHelper(inputs=decoder_inputs_embedded, sequence_length=self.decoder_targets_length, time_major=False, name=training_helper) training_decoder = tf.contrib.seq2seq.BasicDecoder(cell=decoder_cell, helper=training_helper, initial_state=decoder_initial_state, output_layer=output_layer) #調用dynamic_decode進行解碼,decoder_outputs是一個namedtuple,裡面包含兩項(rnn_outputs, sample_id) # rnn_output: [batch_size, decoder_targets_length, vocab_size],保存decode每個時刻每個單詞的概率,可以用來計算loss # sample_id: [batch_size], tf.int32,保存最終的編碼結果。可以表示最後的答案 decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=training_decoder, impute_finished=True, maximum_iterations=self.max_target_sequence_length) # 根據輸出計算loss和梯度,並定義進行更新的AdamOptimizer和train_op self.decoder_logits_train = tf.identity(decoder_outputs.rnn_output) self.decoder_predict_train = tf.argmax(self.decoder_logits_train, axis=-1, name=decoder_pred_train) # 使用sequence_loss計算loss,這裡需要傳入之前定義的mask標誌 self.loss = tf.contrib.seq2seq.sequence_loss(logits=self.decoder_logits_train, targets=self.decoder_targets, weights=self.mask) # Training summary for the current batch_loss tf.summary.scalar(loss, self.loss) self.summary_op = tf.summary.merge_all() optimizer = tf.train.AdamOptimizer(self.learing_rate) trainable_params = tf.trainable_variables() gradients = tf.gradients(self.loss, trainable_params) clip_gradients, _ = tf.clip_by_global_norm(gradients, self.max_gradient_norm) self.train_op = optimizer.apply_gradients(zip(clip_gradients, trainable_params)) elif self.mode == decode: start_tokens = tf.ones([self.batch_size, ], tf.int32) * self.word_to_idx[<go>] end_token = self.word_to_idx[<eos>] # decoder階段根據是否使用beam_search決定不同的組合, # 如果使用則直接調用BeamSearchDecoder(裡面已經實現了helper類) # 如果不使用則調用GreedyEmbeddingHelper+BasicDecoder的組合進行貪婪式解碼 if self.beam_search: inference_decoder = tf.contrib.seq2seq.BeamSearchDecoder(cell=decoder_cell, embedding=embedding, start_tokens=start_tokens, end_token=end_token, initial_state=decoder_initial_state, beam_width=self.beam_size, output_layer=output_layer) else: decoding_helper = tf.contrib.seq2seq.GreedyEmbeddingHelper(embedding=embedding, start_tokens=start_tokens, end_token=end_token) inference_decoder = tf.contrib.seq2seq.BasicDecoder(cell=decoder_cell, helper=decoding_helper, initial_state=decoder_initial_state, output_layer=output_layer) decoder_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder=inference_decoder, maximum_iterations=10) # 調用dynamic_decode進行解碼,decoder_outputs是一個namedtuple, # 對於不使用beam_search的時候,它裡面包含兩項(rnn_outputs, sample_id) # rnn_output: [batch_size, decoder_targets_length, vocab_size] # sample_id: [batch_size, decoder_targets_length], tf.int32 # 對於使用beam_search的時候,它裡面包含兩項(predicted_ids, beam_search_decoder_output) # predicted_ids: [batch_size, decoder_targets_length, beam_size],保存輸出結果 # beam_search_decoder_output: BeamSearchDecoderOutput instance namedtuple(scores, predicted_ids, parent_ids) # 所以對應只需要返回predicted_ids或者sample_id即可翻譯成最終的結果 if self.beam_search: self.decoder_predict_decode = decoder_outputs.predicted_ids else: self.decoder_predict_decode = tf.expand_dims(decoder_outputs.sample_id, -1) # =================================4, 保存模型 self.saver = tf.train.Saver(tf.all_variables()) def train(self, sess, batch): #對於訓練階段,需要執行self.train_op, self.loss, self.summary_op三個op,並傳入相應的數據 feed_dict = {self.encoder_inputs: batch.encoder_inputs, self.encoder_inputs_length: batch.encoder_inputs_length, self.decoder_targets: batch.decoder_targets, self.decoder_targets_length: batch.decoder_targets_length, self.keep_prob_placeholder: 0.5, self.batch_size: len(batch.encoder_inputs)} _, loss, summary = sess.run([self.train_op, self.loss, self.summary_op], feed_dict=feed_dict) return loss, summary def eval(self, sess, batch): # 對於eval階段,不需要反向傳播,所以只執行self.loss, self.summary_op兩個op,並傳入相應的數據 feed_dict = {self.encoder_inputs: batch.encoder_inputs, self.encoder_inputs_length: batch.encoder_inputs_length, self.decoder_targets: batch.decoder_targets, self.decoder_targets_length: batch.decoder_targets_length, self.keep_prob_placeholder: 1.0, self.batch_size: len(batch.encoder_inputs)} loss, summary = sess.run([self.loss, self.summary_op], feed_dict=feed_dict) return loss, summary def infer(self, sess, batch): #infer階段只需要運行最後的結果,不需要計算loss,所以feed_dict只需要傳入encoder_input相應的數據即可 feed_dict = {self.encoder_inputs: batch.encoder_inputs, self.encoder_inputs_length: batch.encoder_inputs_length, self.keep_prob_placeholder: 1.0, self.batch_size: len(batch.encoder_inputs)} predict = sess.run([self.decoder_predict_decode], feed_dict=feed_dict) return predict

訓練 && 預測

模型構建好之後,剩下的工作就很簡單了,訓練的話其實就是一個簡單的循環,每個epoch都重新shuffle數據併產生batches的數據並傳入模型調用train函數進行訓練。時不時列印結果並保存模型參數,這裡如果大家有eval數據集可以添加上相應的代碼,比如每個100步評價一次等~~

預測的話跟訓練步驟是一樣的,先倒入模型參數,再將輸入的句子轉化成batch,接下來調用infer函數即可。這裡主要說一下如何從infer函數返回結果predicted_ids得到我們想要的字元串結果。首先來講predicted_ids是一個batch_size*decode_length*beam_size維度的列表,我這裡每次只預測一個結果所以batch_size為1。我們最終想要beam_szie個長度為decode_length的字元串(如果字元串中有<eos>,則長度會變短),所以需要對predicted_ids進行轉化並將id轉換為其對應的word。使用下面這個函數即可:

def predict_ids_to_seq(predict_ids, id2word, beam_szie): 將beam_search返回的結果轉化為字元串 :param predict_ids: 列表,長度為batch_size,每個元素都是decode_len*beam_size的數組 :param id2word: vocab字典 :return: for single_predict in predict_ids: for i in range(beam_szie): predict_list = np.ndarray.tolist(single_predict[:, :, i]) predict_seq = [] for idx in predict_list[0]: predict_seq.append(id2word[idx]) print(predict_seq)

結果

訓練過程中發現,相比老版本的API而言,訓練速度變慢了很多,大概降低了4,5倍左右,具體的原因還沒有搞清楚,不知道大家有沒有遇到這種情況,求指教~~導致我的模型現在還沒訓練結束==不過也有一些簡單的結果可以看看:

先看看模型的結構圖:

decoder的內部細節:

訓練的loss曲線:

回答,現在模型訓練的時間還太短,貌似還沒有學習到什麼有營養的回答,機器太慢也是沒有辦法,這裡beam_size取5,如下圖所示,等過幾天模型訓練好了再來更新吧:

參考

tf官網nmt文檔和代碼

Higepon』s blog

Tensorflow新版Seq2Seq介面使用 - CSDN博客

JayParks/tf-seq2seq

seq2seq模型簡單實現 - CSDN博客


推薦閱讀:

TensorFlow-dev-summit:那些TensorFlow上好玩的和黑科技
tensorflow是如何求導的?
用Tensorflow自動化構建海洋生物系統,利用上萬的圖片訓練,找到瀕臨物種「海牛」是什麼原理?
【博客存檔】TensoFlow之深入理解GoogLeNet
學習筆記TF003:數據流圖定義、執行、可視化

TAG:深度学习DeepLearning | 自然语言处理 | TensorFlow |