RNN聊天机器人与Beam Search [Tensorflow Seq2Seq]

来源:互联网 发布:java pfx证书读取 编辑:程序博客网 时间:2024/05/22 06:34

本博客分析了一个Tensorflow实现的开源聊天机器人项目deepQA,首先从数据集上和一些重要代码上进行了说明和阐述,最后针对于测试的情况,在deepQA项目上实现了Beam Search的方法,让模型输出的句子更加准确。

DeepQA

DeepQA是一个Tensorflow实现的开源的seq2seq模型的聊天机器人( 传送们),出自谷歌的一篇关于对话模型的论文A Neural Conversational Model,训练的语料库包含电影台词的对话(Cornell和扩展版本的Cornell),Scotus对话库,以及Ubantu的对话。这些数据都能在项目的data里找到,但是目前好像只能针对某一个对话数据库进行训练,还没有支持混合对话的训练。目前实现的模型使用的是基础RNN中的seq2seq模型,主要针对的是比较短的对话。

1. Cornell数据集

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.txtu0 +++$+++ 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.txtL1045 +++$+++ 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 +++$+++ WowL872 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ Okay -- you're gonna need to learn how to lie.L871 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ NoL870 +++$+++ 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实现

2. 重要代码分析

2.1 构建基础RNN网络结构

直接看一下构建模型的代码,首先定义单个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 encoDecoCellencoDecoCell = tf.contrib.rnn.MultiRNNCell(    [create_rnn_cell() for _ in range(self.args.numLayers)],)

2.2 定义输入值

接着定义网络的输入值,根据标准的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 dimwith 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还是很有帮助的

2.3 封装Embedding seq2seq模型

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)

2.4 定义损失函数和更新方法

下面定义seq2seq模型的损失函数sequence_loss,其中sequence_loss需要softmax_loss_function参数,这个参数若不指定,那么就是默认对整个词汇表的做softmax loss,若需要采样来加速计算,则要传入上面定义的sampledSoftmax方法,这个方法的返回值是TF定义的sampled_softmax_loss。更新方法采用默认参数的Adam。

# Finally, we define the loss functionself.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 optimizeropt = 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

4.运行

使用python main来训练模型,使用python main --test interactive --beam-search --beam-size 3来实时测试对话,下面是loss的图,混乱度为以loss为自变量的e的指数函数

这里写图片描述

使用python main --test来生成对话,在save/model/model_predictions.txt中查看结果

5. 结果

deepQA是基于python 3.5的项目,用python 2.7也可以跑,但是需要稍微修改一些地方,另外项目还提供了训练好的参数,不想训练的可以直接导入训练好的模型。但是那些模型都是用python 3.5训练的,如果用python 2.7会导入不了这些预训练的模型。我用的是python 2.7,因此只能自己训练,由于显存的限制,训练的参数很小,效果不太好,这里给出官方的对话例子:

Q: HiA: 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 alphabetA: Fuck you.Q: That's not niceA: Yeah.

下面给出部分我用Beam Search做出来结果,beam_size=3,因此每个问题有三个答案,得分从高到低:

Q: HiA: 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'mA: What?A: Who? ready?Q: When are you ready ?A: Tomorrow.A: Thursday.A: I, uh., isQ: How old are you ?A: Eighteen.A: Twenty.A: I'm thirty-four.Q: How is Laura ?A: Fine.A: Tolerable wellA: Good...

6. 分析

通过Beam search,有的问题能够举例多种回答,但是有的问题的后面的回答直接就失败了。有以下方面可以还可以提高:

  1. 更大的训练集:训练效果强烈依赖于训练集的质量,甚至可以把几个数据集在同一个RNN网络上训练,猜想可能效果会更好
  2. 一些参数的调整:如词频过滤的大小,词汇表大小,word embedding的维度大小,RNN的stack层数,输入输出句子最大长度,softmax的采样的大小,以及导入预训练的word2vec来加速训练过程等
  3. 增加attention机制,对于长句子
  4. 输入多个问题,只给出一个答案:这样能让网络稍微记住当前答案与前几个问题有关,目前是当前答案只与当前问题有关,问题与问题之间相互独立
  5. 更高级一点,增加记忆网络,显性将特征记忆到外部储存器,然后在记忆中搜索答案返回,这样可以使记忆长期保留,是目前比较火的研究方向
  6. 再高级就是增加知识图谱和一些启发函数,让模型自己去外部获取信息处理,然后返回答案,类似你问一个问题,即使机器人不知道,但是它可以去上网查资料然后返回你答案,这种level是最高级的。
原创粉丝点击