一、总体分析
感觉很多chatbot的博文都是直接拿seq2seq开刀,上来就堆了一堆RNN(或者LSTM,Attention)模型的原理和公式。本篇从初学者的角度出发更想将机器学习基础(目标函数,优化方法,正则化等思想)贯穿始终。并结合Tensorboard可视化tensorflow中相关的模型算法。
在Machine Learning by Mitchell(1997)中,给出了机器学习的一个简洁定义:“对于某类任务
T
T
和性能度量 ,一个计算机程序被认为可以从经验
E
E
中学习是指,通过经验 改进后,它在任务
T
T
上由性能度量 衡量的性能有所提升 ”。所以对于我们的非任务导向型的对话系统(chatbot)而言,也可以从这三维度展开讨论。
1.1 任务 T T
机器学习任务定义为机器学习系统应该如何处理样本。样本是指我们从某些希望机器学习系统处理的对象或事件中收集到的已经量化的特征的集合。在上一篇博文中预处理成问答对并用word2vec模型训练后的词向量即是我们chatbot系统的样本数据。机器学习的学习任务有很多种,如分类,回归,转录,机器翻译,异常值检测,密度估计(学习样本采样空间的概率密度函数)等。我们这里的任务大概可以描述为给指定问句生成对答语句。
1.2 性能度量
对于诸如分类,转录之类的任务,通常度量模型的准确率(即模型输出正确结果的样本比例)。回归之类的任务通常用模型输出和样本实际目标值
y
y
之间的均方误差 来衡量。对于密度估计类任务,更通常的是评估模型生成的概率密度函数(模型分布
pmodel
p
m
o
d
e
l
)和样本实际概率密度函数(经验分布
p̂data
p
^
d
a
t
a
)之间的相似性,通常用KL散度来衡量。
其中 logp̂data(x) log p ^ d a t a ( x ) 仅涉及数据生成过程,和模型无关。这意味着最小化KL散度时,只需要最大化 Ex∼p̂data[logpmodel(x)] E x ∼ p ^ d a t a [ log p m o d e l ( x ) ] ,这也即是最大似然估计。
1.3 经验 E E
根据学习过程中的不同经验,机器学习算法大致分为无监督学习算法和监督学习算法。通常的无监督学习任务除了聚类之外还有一些需要学习生成数据集的整个概率分布,显示地比如密度估计,或是隐式地比如合成或去噪等。监督学习算法训练含有很多特征的数据集,不过数据集中的样本都有一个目标值。就我们当前的任务和数据集来说,显然是监督学习过程,每个问答对(question-answer)中的answer部分就是模型要匹配的目标值。
总结以上三点,对我们要构建的机器学习系统就是针对为给指定问句生成对答语句这个任务,使用监督学习算法构建模型,针对每个question用模型的输出去拟合实际的answer。并可以使用最小化均方误差作为性能度量标准来不断优化模型参数。
二、模型算法
第一小节从机器学习整体过程入手给出了Chatbot的大致框架,接下来就需要针对任务的具体特性来分析。根据question生成answer总体上来说是一个Seq2Seq模型(也称为Encoder-Decoder模型),在上一篇博文中对question和answer经过中文分词并生成词向量之后,其实question和answer就分别是问题词向量序列和回答词向量序列了。这种结构最重要的地方在于输入序列和输出序列的长度是可变的(参考The Unreasonable Effectiveness of Recurrent Neural Networks)
![RNN](https://i-blog.csdnimg.cn/blog_migrate/6312a30d4d927f17db3a85485b5c58c2.jpeg)
论文Sequence to Sequence Learning with Neural Networks中给出了直观的Seq2Seq模型
![Seq2Seq](https://i-blog.csdnimg.cn/blog_migrate/783b3e40b061b16cf88c386c30b9858b.png)
Fig 2-2 经典Seq2Seq模型
其中ABC是输入语句,WXYZ是输出语句,EOS是标识一句话结束,图中的训练单元是lstm,其核心在于 cell 和 结构图上面的那条横穿的水平线。cell 状态的传输就像一条传送带,向量从整个 cell 中穿过,只是做了少量的线性操作。这种结构能够很轻松地实现信息从整个 cell 中穿过而不做改变。从而可以保持长时期记忆。更多基础原理和算法设计可参考经典文献Understanding LSTM Networks。,所以能够根据输入的多个字来确定后面的多个字。显示融合了LSTM模型的并区分出Encoder和Decoder的示意图如下:
![这里写图片描述](https://i-blog.csdnimg.cn/blog_migrate/bc5cd27c320596e67910b0a7aae024c4.png)
图中的 Encoder 和 Decoder 都只展示了一层的普通的 LSTMCell。从上面的结构中,我们可以看到,整个模型结构还是非常简单的。 EncoderCell 最后一个时刻的状态就是整个句子的语义向量,它将作为 DecoderCell 的初始状态。然后在 DecoderCell 中,每个时刻的输出将会作为下一个时刻的输入。以此类推,直到 DecoderCell 某个时刻预测输出特殊符号 <END> < E N D > <script type="math/tex" id="MathJax-Element-20"> </script>结束。
三、基于Tensorflow的Chatbot实例
tensorflow自带的seq2seq模型基于one-hot的词嵌入向量(把每个单词按顺序编号,每个词就是一个很长的向量,向量的长度等于词表的大小,只有对应位置上的数字编号为1,其余位置为0.在实际应用中一般采用稀疏矩阵的表示方式)。这种表示方法不足以表示词与词之间的关系(因为任何两个词向量之间的
L2
L
2
距离都是
2‾√
2
,体现不出相似词)。因此我们之前预处理是采用基于word2vec的多维词向量。这里给出一个基于one-hot的词嵌入向量的英文对话系统的开源项目供参考,后面都将围绕word2vec多维词向量展开。
我们是使用的看了很多经典论文中阐释的Seq2Seq模型,Decoder中每个LSTMCell的输入都是上一个LSTMCell的输出(第一个除外,Decoder中第一个LSTM的输入是特殊的起始向量 如
Start
S
t
a
r
t
)。但是在实际训练中,Decoder输出是一个词一个词依次输出的,我们没法保证上一个Decoder LSTMCell的输出就是实际answer中的正确输出,因而这样的输出提供给下一个Decoder LSTMCell的输入,只会让误差越来越大,或者说算法需要更长的训练时间。因此我们直接用answer中
T
T
时刻的实际值作为第
T+1
T
+
1
个Decoder LSTMCell的输入。也即对于 Fig 2-2 所示的question=”ABC”,answer=”WXYZ”的情况下。我们模型实际输入为
InputX
I
n
p
u
t
X
=”ABCSWXY”(其中
InputXEncoder
I
n
p
u
t
X
E
n
c
o
d
e
r
=”ABC”,
InputXDecoder
I
n
p
u
t
X
D
e
c
o
d
e
r
=”SWXY”,S表示Decoder的起始输入Start),模型要拟合的输出为
TargetY
T
a
r
g
e
t
Y
={WXYZ}。参考文献【6】里给了一张较为清晰的全流程手绘图,如下:
![这里写图片描述](https://i-blog.csdnimg.cn/blog_migrate/84266a33d3b1092c0cf22e830b4fc8e5.jpeg)
Fig 3-1 全流程手绘图
基于Tensorflow的Chatbot实例核心代码如下:
class Seq2Seq(object):
def __init__(self, word_vector_model_path, seq2seq_model_path, input_file, word_vec_dim=200, max_seq_len=16):
"""
:param word_vector_model_path: 已经训练好的word2vec词向量模型路径
:param seq2seq_model_path: seq2seq模型路径
:param input_file: 已经处理好的问答对源文件
:param word_vec_dim: 词向量长度
:param max_seq_len: 最大序列长度
"""
self.word_vector_model_path = word_vector_model_path
self.input_file = input_file
self.word_vec_dim = word_vec_dim
self.max_seq_len = max_seq_len
self.seq2seq_model_path = seq2seq_model_path
self.question_seqs = [] # 问题序列集
self.answer_seqs = [] # 回答序列集
self.word_vector_dict = {}
def load_word_vector_dict(self):
model = word2vec.Word2Vec.load(self.word_vector_model_path)
vocab = model.wv.vocab
word_vector = {}
for word in vocab:
self.word_vector_dict[word] = model[word]
def init_seq(self):
"""
初始化问答词向量序列
"""
if not self.word_vector_dict:
self.load_word_vector_dict()
file_object = open(self.input_file, 'r', encoding='utf-8')
while True:
line = file_object.readline()
if line:
line_pair = line.split("|")
line_question_words = line_pair[0].split(" ")
line_answer_words = line_pair[1].split(" ")
question_seq = []
answer_seq = []
for word in line_question_words:
if word in self.word_vector_dict:
question_seq.append(self.word_vector_dict[word])
for word in line_answer_words:
if word in self.word_vector_dict:
answer_seq.append(self.word_vector_dict[word])
self.question_seqs.append(question_seq)
self.answer_seqs.append(answer_seq)
else:
break
file_object.close()
def generate_trainig_data(self):
if not self.question_seqs:
self.init_seq()
# xy_data = []
# y_data = []
train_XY=np.empty(shape=[0,32,200])
train_Y=np.empty(shape=[0,17,200])
print(len(self.question_seqs))
for i in range(len(self.question_seqs)):
question_seq = self.question_seqs[i]
answer_seq = self.answer_seqs[i]
# 输入序列长度补齐为max_seq_len
if len(question_seq) < self.max_seq_len and len(answer_seq) < self.max_seq_len:
seq_xy = [np.zeros(self.word_vec_dim)] * (self.max_seq_len - len(question_seq)) + list(reversed(question_seq))
seq_y = answer_seq + [np.zeros(self.word_vec_dim)] * (self.max_seq_len - len(answer_seq))
seq_xy = seq_xy + seq_y
seq_y = [np.ones(self.word_vec_dim)] + seq_y
# xy_data.append(seq_xy)
# y_data.append(seq_y)
train_XY = np.append(train_XY,[seq_xy],axis=0)
train_Y = np.append(train_Y,[seq_y],axis=0)
return train_XY,train_Y
# test_xy = np.array(train_XY)
# test_y = np.array(train_Y)
# print(test_xy.shape)
# print(test_y.shape)
# return np.array(xy_data), np.array(y_data)
def seq2seq_model(self):
# 为输入的样本数据申请变量空间,每个样本最多包含max_seq_len*2个词(包含qustion和answer),每个词用word_vec_dim维浮点数表示
input_data = tflearn.input_data(shape=[None, self.max_seq_len * 2, self.word_vec_dim], name="XY")
# 从输入的所有样本数据的词序列中切出前max_seq_len个,也就是question句子部分的词向量作为编码器的输入
encoder_inputs = tf.slice(input_data, [0, 0, 0], [-1, self.max_seq_len, self.word_vec_dim], name="enc_in")
# 再取出后max_seq_len-1个,也就是answer句子部分的词向量作为解码器的输入,这里只取了max_seq_len-1个,因为要在前面拼上一组
# GO标识来告诉解码器要开始解码了
decoder_inputs_tmp = tf.slice(input_data, [0, self.max_seq_len, 0], [-1, self.max_seq_len - 1, self.word_vec_dim],
name="dec_in_tmp")
go_inputs = tf.ones_like(decoder_inputs_tmp)
go_inputs = tf.slice(go_inputs, [0, 0, 0], [-1, 1, self.word_vec_dim])
# 插入GO标识作为解码器的第一个输入
decoder_inputs = tf.concat([go_inputs, decoder_inputs_tmp], 1, name="dec_in")
# 开始编码过程,返回的encoder_output_tensor展开成tflearn.regression回归可以识别的形如(?,1,200)的向量
(encoder_output_tensor, states) = tflearn.lstm(encoder_inputs, self.word_vec_dim, return_state=True,
scope="encoder_lstm")
encoder_output_sequence = tf.stack([encoder_output_tensor], axis=1)
# 获取decoder的第一个字符,即GO标识
first_dec_input = tf.slice(decoder_inputs, [0, 0, 0], [-1, 1, self.word_vec_dim])
# 将GO标识输入到解码器中,解码器的state初始化为编码器生成的states,这里的scope='decoder_lstm'是为了下面重用同一个解码器
decoder_output_tensor = tflearn.lstm(first_dec_input, self.word_vec_dim, initial_state=states, return_state=False,
reuse=False, scope="decoder_lstm")
# 暂时先将解码器的第一个输出存到decoder_output_sequence_list中供最后一起输出
decoder_output_sequence_single = tf.stack([decoder_output_tensor], axis=1)
decoder_output_sequence_list = [decoder_output_tensor]
# 接下来我们循环max_seq_len-1次,不断取decoder_inputs的一个个词向量作为下一轮解码器输入,并将结果添加到
# decoder_output_sequence_list中,这里面的reuse=True,scope="decoder_lstm"说明和上面第一次解码用的是同一个lstm层
for i in range(self.max_seq_len - 1):
next_dec_input = tf.slice(decoder_inputs, [0, i, 0], [-1, 1, self.word_vec_dim])
decoder_output_tensor = tflearn.lstm(next_dec_input, self.word_vec_dim, return_seq=False, reuse=True,
scope="decoder_lstm")
decoder_output_sequence_single = tf.stack([decoder_output_tensor], axis=1)
decoder_output_sequence_list.append(decoder_output_tensor)
# 下面我们把编码器第一个输出和解码器所有输出拼接起来,作为tflearn.regression回归的输入
decode_output_sequence = tf.stack(decoder_output_sequence_list, axis=1)
real_output_sequence = tf.concat([encoder_output_sequence, decode_output_sequence], axis=1)
net = tflearn.regression(real_output_sequence, optimizer='sgd', learning_rate=0.1, loss='mean_square')
model = tflearn.DNN(net, tensorboard_verbose=3, tensorboard_dir="D:\\Code\\DeepLearning\\chatbot\\seq2seq_debug_log")
return model
def train_model(self):
train_xy, train_y = self.generate_trainig_data()
model = self.seq2seq_model()
model.fit(train_xy, train_y, n_epoch=1000, snapshot_epoch=False, batch_size=1)
model.save(self.seq2seq_model_path)
return model
def load_model(self):
model = self.seq2seq_model().load(self.seq2seq_model_path)
return model
在主体的seq2seq_model()方法中我们定义了整个模型。除了使用tflearn封装的lstm作为Encoder和Decoder之外。还可以看到整个神经网络使用tflearn.regression来将Encoder-Decoder模型的输出和实际的targetY进行回归拟合。其中回归的损失函数用的是均方误差(loss=’mean_square’),模型参数迭代优化方法用的是随机梯度下降算法SGD( optimizer=’sgd’),关于这些细节部分将在下一篇结合tensorboard可视化整个chatbot模型进行详细分析。调用train_model()方法创建和训练好模型之后,模型训练日志保存在了tensorboard_dir目录中。
参考文献
【1】The Unreasonable Effectiveness of Recurrent Neural Networks
【2】Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation
【3】Sequence to Sequence Learning with Neural Networks
【4】Understanding LSTM Networks
【5】200 lines implementation of Twitter/Cornell-Movie Chatbot
【6】用 TensorFlow 做个聊天机器人