本文分析了一个 Tensorflow 实现的开源聊天机器人项目 deepQA,首先从数据集上和一些重要代码上进行了说明和阐述,最后针对于测试的情况,在 deepQA 项目上实现了 Beam Search 的方法,让模型输出的句子更加准确。扩展的版本在这里
DeepQA 是一个 Tensorflow 实现的开源的 seq2seq 模型的聊天机器人 (传送们), 出自谷歌的一篇关于对话模型的论文 A Neural Conversational Model,训练的语料库包含电影台词的对话(Cornell 和扩展版本的 Cornell),Scotus 对话库,以及 Ubantu 的对话。这些数据都能在项目的 data 里找到,但是目前好像只能针对某一个对话数据库进行训练,还没有支持混合对话的训练。目前实现的模型使用的是基础 RNN 中的 seq2seq 模型,主要针对的是比较短的对话。
DeepQA 默认的是 Cornell 对话数据,一共两个文件:人物对话信息 movie_conversations.txt 和具体对话内容 movie_lines.txt,
为分隔符,movie_conversations.txt 里每一行的第一个数据代表对话人物 1 的 ID,第二个数据代表对话人物 2 的 ID,第三个数据代表电影 ID,后面是对话的 ID,而 movie_lines.txt 里每一行的第一个数据代表对话 ID,第二个数据表示说话的人物 ID,第三个数据电影 ID,第四个是此人物的名字,最后是这句话的具体内容。
- +++$+++
- #movie_conversations.txt u0+++$+++u2+++$+++m0+++$+++['L194', 'L195', 'L196', 'L197'] u0+++$+++u2+++$+++m0+++$+++['L198', 'L199'] u0+++$+++u2+++$+++m0+++$+++['L200', 'L201', 'L202', 'L203'] u0+++$+++u2+++$+++m0+++$+++['L204', 'L205', 'L206'] u0+++$+++u2+++$+++m0+++$+++['L207', 'L208'] u0+++$+++u2+++$+++m0+++$+++['L271', 'L272', 'L273', 'L274', 'L275'] u0+++$+++u2+++$+++m0+++$+++['L276', 'L277']
- #movie_lines.txt L1045+++$+++u0+++$+++m0+++$+++BIANCA+++$+++They do not ! L1044+++$+++u2+++$+++m0+++$+++CAMERON+++$+++They do to ! L985+++$+++u0+++$+++m0+++$+++BIANCA+++$+++I hope so.L984+++$+++u2+++$+++m0+++$+++CAMERON+++$+++She okay ? L925+++$+++u0+++$+++m0+++$+++BIANCA+++$+++Let 's go.
- L924 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ Wow
- L872 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ Okay -- you're gonna need to learn how to lie.L871+++$+++u2+++$+++m0+++$+++CAMERON+++$+++No L870+++$+++u0+++$+++m0+++$+++BIANCA+++$+++I 'm kidding. You know how sometimes you just become this "persona"? And you don't know how to quit ? L869+++$+++u0+++$+++m0+++$+++BIANCA+++$+++Like my fear of wearing pastels ? L868+++$+++u2+++$+++m0+++$+++CAMERON+++$+++The "real you".
通过解析这个两个文件,可以在 data/samples 路径生成 dataset-cornell.pkl 数据集以及 dataset-cornel-length10-filter1-vocabSize40000.pkl 词汇表,默认对话的长度一般最大为 10,过滤词频小于 1 的词并用 unk 代替,词汇表最大 40000;这些参数可以自行修改,生成过程具体由 textdata.py 实现
直接看一下构建模型的代码,首先定义单个 LSTM cell,然后用 Dropout 包裹,最后用参数 numLayers 决定多少层 Stack 结构的 RNN:
- def create_rnn_cell() : encoDecoCell = tf.contrib.rnn.BasicLSTMCell(#Or GRUCell, LSTMCell(args.hiddenSize) self.args.hiddenSize, ) if not self.args.test: #TODO: Should use a placeholder instead encoDecoCell = tf.contrib.rnn.DropoutWrapper(encoDecoCell, input_keep_prob = 1.0, output_keep_prob = self.args.dropout) return encoDecoCell
- encoDecoCell = tf.contrib.rnn.MultiRNNCell([create_rnn_cell() for _ in range(self.args.numLayers)], )
接着定义网络的输入值,根据标准的 seq2seq 模型,一共四个:
1. encorder 的输入:人物 1 说的一句话 A,最大长度 10
2. decoder 的输入:人物 2 回复的对话 B,因为前后分别加上了 go 开始符和 end 结束符,最大长度为 12
3. decoder 的 target 输入:decoder 输入的目标输出,与 decoder 的输入一样但只有 end 标示符号,可以理解为 decoder 的输入在时序上的结果,比如说完这个词后的下个词的结果。
4. decoder 的 weight 输入:用来标记 target 中的非 padding 的位置,即实际句子的长度,因为不是所有的句子的长度都一样,在实际输入的过程中,各个句子的长度都会被用统一的标示符来填充(padding)至最大长度,weight 用来标记实际词汇的位置,代表这个位置将会有梯度值回传。
- with tf.name_scope('placeholder_encoder') : self.encoderInputs = [tf.placeholder(tf.int32, [None, ]) for _ in range(self.args.maxLengthEnco)]#Batch size * sequence length * input dim
- with tf.name_scope('placeholder_decoder') : self.decoderInputs = [tf.placeholder(tf.int32, [None, ], name = 'inputs') for _ in range(self.args.maxLengthDeco)]#Same sentence length
- for input and output(Right ? ) self.decoderTargets = [tf.placeholder(tf.int32, [None, ], name = 'targets') for _ in range(self.args.maxLengthDeco)] self.decoderWeights = [tf.placeholder(tf.float32, [None, ], name = 'weights') for _ in range(self.args.maxLengthDeco)]
其实,数据获取后的 Batch 的过程可以由 Tensorflow 标准的 batch 方法来实现,而这个项目自己 Batch 各个输入值,因此对于初学者来说,可以观察输入值的构造,对入门 RNN 还是很有帮助的
Tensorflow 把常用的 seq2seq 模型都封装好了,比如 embedding_rnn_seq2seq,这是 seq2seq 一个最简单的模型,一般的文本任务都会加上 attention 机制,但这里都用的短句子,所以 attention 并考虑,若想加上 attention 也很简单,直接修改模型名字为 embedding_attention_seq2seq 即可,这种封装虽然使用起来很方便,但是对于用户来说就是个黑匣子,想要自己去实现一些功能,还得去看代码。
- decoderOutputs,
- states = tf.contrib.legacy_seq2seq.embedding_rnn_seq2seq(self.encoderInputs, #List < [batch = ?, inputDim = 1] > , list of size args.maxLength self.decoderInputs, #For training, we force the correct output(feed_previous = False) encoDecoCell, self.textData.getVocabularySize(), self.textData.getVocabularySize(), #Both encoder and decoder have the same number of class embedding_size = self.args.embeddingSize, #Dimension of each word output_projection = outputProjection.getWeights() if outputProjection
- else None, feed_previous = True
- if bool(self.args.test) and not bool(self.args.beam_search)
- else False
- #When we test(self.args.test), we use previous output as next input(feed_previous))
RNN 输出一个句子的过程,其实是对句子里的每一个词来做整个词汇表的 softmax 分类,取概率最大的词作为当前位置的输出词,但是若词汇表很大,计算量会很大,那么通常的解决方法是在词汇表里做一个下采样,采样的个数通常小于词汇表,例如词汇表有 50000 个,经过采样后得到 4096 个样本集,样本集里包含 1 个正样本(正确分类)和 4095 个负样本,然后对这 4096 个样本进行 softmax 计算作为原来词汇表的一种样本估计,这样计算量会小不少。
在这里,具体的操作是定义一个全映射 outputProjection 对象,把隐藏层的输出映射到整个词汇表,这种映射需要参数 w 和 b,也就是 out=w*h+b,h 是隐藏层的输出,out 是整个词汇表的输出,可以理解为一个普通的全连接层。假设隐藏层的输出是 512,那么 w 的 shape 就为 50000*512,我们采样词汇表的操作可以看作是对 w 和 b 参数的采样,也就是采样出来的 w 为 4096*512,用这个 w 带入上式计算,能得出 4096 个输出,然后计算 softmax loss,这个 sampled softmax loss 是原词汇表 softmax loss 的一种近似。outputProjection 的定义请看 model.py。
- outputProjection = ProjectionOp((self.textData.getVocabularySize(), self.args.hiddenSize), scope = 'softmax_projection', dtype = self.dtype)
- def sampledSoftmax(labels, inputs) : labels = tf.reshape(labels, [ - 1, 1])#Add one dimension(nb of true classes, here 1)
- #We need to compute the sampled_softmax_loss using 32bit floats to#avoid numerical instabilities.localWt = tf.cast(outputProjection.W_t, tf.float32) localB = tf.cast(outputProjection.b, tf.float32) localInputs = tf.cast(inputs, tf.float32)
- return tf.cast(tf.nn.sampled_softmax_loss(localWt, #Should have shape[num_classes, dim] localB, labels, localInputs, self.args.softmaxSamples, #The number of classes to randomly sample per batch self.textData.getVocabularySize()), #The number of classes self.dtype)
下面定义 seq2seq 模型的损失函数 sequence_loss,其中 sequence_loss 需要 softmax_loss_function 参数,这个参数若不指定,那么就是默认对整个词汇表的做 softmax loss,若需要采样来加速计算,则要传入上面定义的 sampledSoftmax 方法,这个方法的返回值是 TF 定义的 sampled_softmax_loss。更新方法采用默认参数的 Adam。
- #
- Finally,
- we define the loss
- function self.lossFct = tf.contrib.legacy_seq2seq.sequence_loss(decoderOutputs, self.decoderTargets, self.decoderWeights, self.textData.getVocabularySize(), softmax_loss_function = sampledSoftmax
- if outputProjection
- else None#If None, use
- default SoftMax) tf.summary.scalar('loss', self.lossFct)#Keep track of the cost
- #Initialize the optimizer opt = tf.train.AdamOptimizer(learning_rate = self.args.learningRate, beta1 = 0.9, beta2 = 0.999, epsilon = 1e-08) self.optOp = opt.minimize(self.lossFct)
在测试模型的时候,比如我输入问一句话 "How are you?" 以及一个 go 开始符,那么模型就开始输出第一个词的候选集,这个候选集里的每一个词都有一个概率,一般采用贪婪的思想,就取概率最大的那个词作为当前输出,然后把这个词作为预测第二个词的输入再 feed 进网络,如此循环,直到模型输出 end 结束符,那么这句话就输出完毕。
这种把上一个时刻的输出当作下一个时刻的输入的过程在 TF 模型中由 feed_previous 参数决定:在训练模型的时候,我们是知道每一时刻的正确输入和输出的,并不需要这个过程,因此
,而只有在测试的时候,才会需要这种过程
- feed_previous=False
- feed_previous=True
而 Beam Search 是这种贪婪的思想的扩展,前面是选择最大的 Top 1 概率的词作为当前输出,而 Beam Search 是选择当前 Top k 得分的词,当然这个得分也就是概率,那么采用这种思想,对一个问题,模型最后的输出就应该有好几种回答,这些回答根据得分排序,最终选择得分最高的句子作为最终输出。相对于前面贪婪的回答,这种搜索机制能让机器人选择更好的回答。
那么在 DeepQA 的基础上,我们来自己实现一下 Beam Search(目前 DeepQA 并不支持),网上有很多实现的方法,大都是自己编写 decoder,比较复杂。那么本文就采用一种非常直接的方法,依照 Beam Search 的思想:feed in 上一时刻产生的 Top k 答案来产生本时刻的候选答案集,然后排序本时刻的候选答案集再选择 Tok k 作为本时刻的最终答案,并作为下一时刻输入,如此循环。每一时刻需要记录当前选择词的 id,从第一个词到最后一个词,这些词的 id 构成一种选择路径。最后根据参数 k,得出 k 条路径,每条路径有一个概率得分。这种情况下,我们是手动 feed in 各个候选答案,因此
- feed_previous=False
- def beamSearchPredict(self, question, questionSeq = None) :
- #question为输入的句子,这里先把每个词转为id,再加上padding和go标示符构成一个batch,这个batch就包含一个句子batch = self.textData.sentence2enco(question)
- if not batch: return None
- if questionSeq is not None: #If the caller want to have the real input questionSeq.extend(batch.encoderSeqs)
- #feedDict为TF的placeholder变量以及对应的数据#ops为TF的需要计算的网络图ops,
- feedDict = self.model.step(batch)
- #定义softmax操作def softmax(x) : return np.exp(x) / np.sum(np.exp(x), axis = 0)
- #path储存搜索的路径,probs里储存每个路径对应的得分(log概率)#beam_size对应路径的个数,储存的位置越靠后,得分越高beam_size = self.args.beam_size path = [[]
- for _ in range(beam_size)] probs = [[]
- for _ in range(beam_size)]
- #计算第一个词的output output = self.sess.run(ops[0][0], feedDict) for k in range(len(path)) : #计算输出的softmax概率分布prob = softmax(output[ - 1].reshape( - 1, ))#用对数表示这些概率分布log_probs = np.log(np.clip(a = prob, a_min = 1e-5, a_max = 1))
- #根据概率大小排序,取前Top-beam_size的词,记录log概率和词的id top_k_indexs = np.argsort(log_probs)[ - beam_size: ] path[k].extend([top_k_indexs[k]]) probs[k].extend([log_probs[top_k_indexs[k]]])
- #计算第二个词到最后一个词
- for i in range(2, self.args.maxLengthDeco) : tmp = []
- for k in range(len(path)) : for j in range(len(path[k])) : #feedDict种加入前面的词的id数据feedDict[self.model.decoderInputs[j + 1]] = [path[k][j]]#输入feedDict至网络图,计算当前句子的output output = self.sess.run(ops[0][0 : i], feedDict)#取最后一个output(当前词的输出)做softmax和log prob = softmax(output[ - 1].reshape( - 1, )) log_probs = np.log(np.clip(a = prob, a_min = 1e-5, a_max = 1))#计算概率得分:P(a, b) = P(a | b) * P(b)#a为当前要选择的词,b为上一个已选择的词#Log表示:log P(a, b) = log P(a | b) + log P(b) tmp.extend(list(log_probs + probs[k][ - 1]))
- #假设上一个词的候选集有三个元素a,
- b,
- c#那么分别把这个三个元素作为输入feed进网络里,会得出三个output的结果#将这些output的概率串联到一个tmp里排序,依然取出前Top - beam_size的词作为当前的词的候选集top_k_indexs = np.argsort(tmp)[ - beam_size: ] indexs = top_k_indexs % self.textData.getVocabularySize()
- #记录当前选择词的id和log概率得分
- for k in range(len(path)) : probs[k].extend([tmp[top_k_indexs[k]]]) path[k].extend([indexs[k]]) return path
还有部分细节没有展出,详细请看 chatbot.py
deepQA 是基于 python 3.5 的项目,用 python 2.7 也可以跑,但是需要稍微修改一些地方,另外项目还提供了训练好的参数,不想训练的可以直接导入训练好的模型。但是那些模型都是用 python 3.5 训练的,如果用 python 2.7 会导入不了这些预训练的模型。我用的是 python 2.7,因此只能自己训练,由于显存的限制,训练的参数很小,效果不太好,这里给出官方的对话例子:
- Q: Hi A: Hi.
- Q: What is your name ? A: Laura.
- Q: What does that mean ? A: I dunno.
- Q: How old are you ? A: thirty - five.
- Q: Will Google hire me ? A: No.
- Q: Tell me the alphabet A: Fuck you.
- Q: That 's not nice
- A: Yeah.'
下面给出部分我用 Beam Search 做出来结果,beam_size=3,因此每个问题有三个答案,得分从高到低:
- Q: Hi A: Hi,
- A: Hey,
- man,
- A: Hello...alright ! what suicide ! . ?
- Q: Luke,
- I am your father ! A: What ? A: Who ? A: We 're!?!
- Q: Are you ready ?
- A: I'm A: What ? A: Who ? ready ?
- Q: When are you ready ? A: Tomorrow.A: Thursday.A: I,
- uh.,
- is
- Q: How old are you ? A: Eighteen.A: Twenty.A: I 'm thirty-four.
- Q: How is Laura ?
- A: Fine.
- A: Tolerable well
- A: Good...'
通过 Beam search,有的问题能够举例多种回答,但是有的问题的后面的回答直接就失败了。有以下方面可以还可以提高:
来源: http://blog.csdn.net/ppp8300885/article/details/74905828