基于内容检索式的聊天机器人
提示:如果大家觉得计算资源有限,欢迎大家在”科学上网“后免费试用google的colab,有免费的K80 GPU供大家使用,大家只需要把课程的notebook上传即可运行
以下内容会介绍到基于检索的聊天机器人原理,并实现一个基于检索的模型,使用了双层Decoder的LSTM模型,通过这个模型可以实现聊天机器人。
本部分英文原文见deep-learning-for-chatbots-2-retrieval-based-model-tensorflow,本文涉及到的数据和代码见Github仓库地址。
基于检索模型的聊天机器人
本文我们将介绍和实现一个基于检索模型的聊天机器人。检索模型所使用的回复数据通常是预先存储且知道(或定义)的数据,而不像生成式模型那样可以创造出崭新的、未知的回复内容(模型没有见过)。准确来讲,检索式模型的输入是一段上下文内容 C (会话到目前未知的内容信息) 和一个可能作为回复的候选答案;模型的输出是对这个候选答案的打分。寻找最合适的回复内容的过程是:先对一堆候选答案进行打分及排序,最后选出分值最高的那个最为回复。
也许你会质疑为什么不直接使用生成式模型,生成式模型不需要预先存储且定义好的数据,比起检索模型更加的灵活多变。原因在于目前生成式模型的效果并不佳,由于生成式模型的约束条件少,过于多变的模型导致生成的response中出现一些语法错误和语义无关的内容。生成式模型需要海量的训练数据,且难以优化。目前工业界常用的模型还是基于检索的模型,或者以生成式模型作为补充的两者结合,谷歌的Smart Reply就是一个例子。尽管目前生成式模型是学术界的研究热点,但在实践中使用检索式模型是更加合适的选择。
Ubuntu对话数据集
这篇博客我们将使用Ubuntu对话数据集(论文来源 github地址)。这个数据集(Ubuntu Dialog Corpus, UDC)是目前最大的公开对话数据集之一,它是来自Ubuntu的IRC网络上的对话日志。这篇论文介绍了该数据集生成的具体细节。下面简单介绍一下数据的格式。
训练数据有1,000,000条实例,其中一半是正例(label为1),一半是负例(label为0,负例为随机生成)。每条实例包括一段上下文信息(context),即Query;和一段可能的回复内容,即Response;Label为1表示该Response确实是Query的回复,Label为0则表示不是。下面是数据示例:
数据集的生成使用了NLTK工具,包括分词、stemmed、lemmatized等文本预处理步骤;同时还使用了NER技术,将文本中的实体,如姓名、地点、组织、URL等替换成特殊字符。这些文本预处理并不是必须的,但是能够提升一些模型的性能。据统计,query的平均长度为86个word,而response的平均长度为17个word,更多的数据统计信息见Jupyter notebook。
数据集也包括了测试和验证集,但这两部分的数据和训练数据在格式上不太一样。在测试集和验证集中,对于每一条实例,有一个正例和九个负例数据(也称为干扰数据)。模型的目标在于给正例的得分尽可能的高,而给负例的得分尽可能的低。下面是数据示例:
模型的评测方式有很多种。其中最常用到的是recall@k,即经模型对候选的response排序后,前k个候选中存在正例数据(正确的那个)的占比;显然k值越大,该指标会越高,因为这对模型性能的要求越松。
在Ubuntu数据集中,负例数据都是随机生成的;然而在现实中,想要从全部的数据中随机生成负例是不可能的。谷歌的Smart Reply则使用了聚类技术,然后将每个类的中取一些作为负例,这样生成负例的方式显得更加合理(考虑了负例数据的多样性,同时减少时间开销)。
BASELINE
在使用NN模型之前,先设立一些简单的baseline模型,以方便后续的效果对比。使用如下的函数来计算recall@k:
1 | def evaluate_recall(y, y_test, k=1): |
其中,y
是所预测的以降序排列的模型预测分值,y_test
是实际的label值。举个例子,假设y
的值为[0,3,1,2,5,6,4,7,8,9],这说明第0号的候选的预测分值最高、作为回复的可能性最高,而9号则最低。这里的第0号同时也是正确的那个,即正例数据,标号为1-9的为随机生成的负例数据。
理论上,最base的随机模型(Random Predictor)的recall@1的值为10%,recall@2的值为20%。相应的代码如下:
1 | # Random Predictor |
实际的模型结果如下:
1 | Recall @ (1, 10): 0.0937632 |
这与理论预期相符,但这不是我们所追求的结果。
另外一个baseline的模型为tfidf predictor。tfidf表示词频(term frequency)和逆文档词频(inverse document frequency),它衡量了一个词在一篇文档中的重要程度(基于整个语料库)。直观上,两篇文档对应的tfidf向量越接近,两篇文章的内容也越相似。同样的,对于一个QR pair,它们语义上接近的词共现的越多,也将越可能是一个正确的QR pair(这句话存疑,原因在于QR之间也有可能不存在语义上的相似,一个Q对应的R是多样的。)。tfidf predictor对应的代码如下(利用scikit-learn工具能够轻易实现):
1 | class TFIDFPredictor: |
模型结果如下:
1 | Recall @ (1, 10): 0.495032 |
显然这比Random的模型要好得多,但这还不够。之前的假设并不完美,首先query和response之间并不一定要是语义上的相近;其次tfidf模型忽略了词序这一重要的信息。使用NN模型我们能做得更好一些。
LSTM
这篇博文将建立的NN模型为两层Encoder的LSTM模型(Dual Encoder LSTM Network),这种形式的网络被广泛应用在chatbot中(尽管可能效果并不是最佳的那个,你可以尽可能地尝试其他的NN模型)。seq2seq模型常用于机器翻译领域,并取得了较大的效果。使用Dual LSTM模型的原因在于这个模型被证明在这个数据集有较好的效果(详情见这里),这可以作为我们后续模型效果的验证。
两层Encoder的LSTM模型的结构图如下(论文来源):
大致的流程如下:
(1) Query和Response都是经过分词的,分词后每个词embedded为向量形式。初始的词向量使用GloVe vectors,之后词向量随着模型的训练会进行fine-tuned(实验发现,初始的词向量使用GloVe并没有在性能上带来显著的提升)。
(2) 分词且向量化的Query和Response经过相同的RNN(word by word)。RNN最终生成一个向量表示,捕捉了Query和Response之间的[语义联系](图中的c和r);这个向量的维度是可以指定的,这里指定为256维。
(3) 将向量c与一个矩阵M相乘,来预测一个可能的回复r’。如果c为一个256维的向量,M维256*256的矩阵,两者相乘的结果为另一个256维的向量,我们可以将其解释为[一个生成式的回复向量]。矩阵M是需要训练的参数。
(4) 通过点乘的方式来预测生成的回复r’和候选的回复r之间的相似程度,点乘结果越大表示候选回复作为回复的可信度越高;之后通过sigmoid函数归一化,转成概率形式。图中把第(3)步和第(4)步结合在一起了。
为了训练模型,我们还需要一个损失函数(loss function)。这里使用二元的交叉熵(binary cross-entropy)作为损失函数。我们已知实例的真实label y
,值为0或1;通过上面的第(4)步可以得到一个概率值 y'
;因此,交叉熵损失值为L = -y * ln(y') - (1 - y) * ln(1 - y')
。这个公式的意义是直观的,即当y=1
时,L = -ln(y')
,我们期望y'
尽量地接近1使得损失函数的值越小;反之亦然。
实现过程中使用了numpy、pandas、TensorFlow和TF Learn等工具。
数据预处理
数据集的原始格式为csv格式,我们需要先将其转为TensorFlow专有的格式,这种格式的好处在于能够直接从输入文件中load tensors,并让TensorFlow来处理洗牌(shuffling)、批量(batching)和队列化(queuing)等操作。预处理中还包括创建一个字典库,将词进行标号,TFRecord文件将直接存储这些词的标号。
每个实例包括如下几个字段:
- Query:表示为一串词标号的序列,如[231, 2190, 737, 0, 912];
- Query的长度;
- Response:同样是一串词标号的序列;
- Response的长度;
- Label;
- Distractor_[N]:表示负例干扰数据,仅在验证集和测试集中有,N的取值为0-8;
- Distractor_[N]的长度;
数据预处理的Python脚本见这里,生成了3个文件:train.tfrecords, validation.tfrecords 和 test.tfrecords。你可以尝试自己运行程序,或者直接下载和使用预处理后的数据。
创建输入函数
为了使用TensoFlow内置的训练和评测模块,我们需要创建一个输入函数:这个函数返回输入数据的batch。因为训练数据和测试数据的格式不同,我们需要创建不同的输入函数。输入函数需要返回批量(batch)的特征和标签值(如果有的话)。类似于如下:
1 | def input_fn(): |
因为我们需要在模型训练和评测过程中使用不同的输入函数,为了防止重复书写代码,我们创建一个包装器(wrapper),名称为create_input_fn
,针对不同的mode使用相应的code,如下:
1 | def create_input_fn(mode, input_files, batch_size, num_epochs=None): |
完整的code见udc_inputs.py。整体上,这个函数做了如下的事情:
(1) 定义了示例文件中的feature字段;
(2) 使用tf.TFRecordReader
来读取input_files
中的数据;
(3) 根据feature字段的定义对数据进行解析;
(4) 提取训练数据的标签;
(5) 产生批量化的训练数据;
(6) 返回批量的特征数据及对应标签;
定义评测指标
之前已经提到用recall@k
这个指标来评测模型,TensorFlow中已经实现了许多标准指标(包括recall@k
)。为了使用这些指标,需要创建一个字典,key为指标名称,value为对应的计算函数。如下:
1 | def create_evaluation_metrics(): |
如上,我们使用了functools.partial函数,这个函数的输入参数有两个。不要被streaming_sparse_recall_at_k
所困惑,其中的streaming
的含义是表示指标的计算是增量式的。
训练和测试所使用的评测方式是不一样的,训练过程中我们对每个case可能作为正确回复的概率进行预测,而测试过程中我们对每组数据(包含10个case,其中1个是正确的,另外9个是生成的负例/噪音数据)中的case进行逐条概率预测,得到例如[0.34, 0.11, 0.22, 0.45, 0.01, 0.02, 0.03, 0.08, 0.33, 0.11]
这样格式的输出,这些输出值的和并不要求为1(因为是逐条预测的,有单独的预测概率值,在0到1之间);而对于这组数据而言,因为数据index=0
对应的为正确答案,这里recall@1
为0,因为0.34
是其中第二大的值,所以recall@2
是1(表示这组数据中预测概率值在前二的中有一个是正确的)。
训练程序样例
首先,给一个模型训练和测试的程序样例,这之后你可以参照程序中所用到的标准函数,来快速切换和使用其他的网络模型。假设我们有一个函数model_fn
,函数的输入参数有batched features
,label
和mode(train/evaluation)
,函数的输出为预测值。程序样例如下:
1 | estimator = tf.contrib.learn.Estimator( |
这里创建了一个model_fn
的estimator
(评估函数);两个输入函数,input_fn_train
和input_fn_eval
,以及计算评测指标的函数;
创建模型
到目前为止,我们创建了模型的输入、解析、评测和训练的样例程序。现在我们来写LSTM的程序,create_model_fn函数用以处理不同格式的训练和测试数据;它的输入参数为model_impl
,这个函数表示实际作出预测的模型,这里就是用的LSTM,当然你可以替换成任意的其他模型。程序如下:
1 | def dual_encoder_model( |
完整的程序见dual_encoder.py。基于这个,我们能够实例化model函数在我们之前定义的udc_train.py,如下:
1 | model_fn = udc_model.create_model_fn( |
这样我们就可以直接运行udc_train.py
文件,来开始模型的训练和评测了,你可以设定--eval_every
参数来控制模型在验证集上的评测频率。更多的命令行参数信息可见tf.flags
和hparams
,你也可以运行python udc_train.py --help
来查看。
运行程序的效果如下:
1 | INFO:tensorflow:training step 20200, loss = 0.36895 (0.330 sec/batch). |
模型的评测
在训练完模型后,你可以将其应用在测试集上,使用:
1 | python udc_test.py --model_dir=$MODEL_DIR_FROM_TRAINING |
例如:
1 | python udc_test.py --model_dir=~/github/chatbot-retrieval/runs/1467389151 |
这将得到模型在测试集上的recall@k
的结果,注意在使用udc_test.py
文件时,需要使用与训练时相同的参数。
在训练模型的次数大约2w次时(在GPU上大约花费1小时),模型在测试集上得到如下的结果:
1 | recall_at_1 = 0.507581018519 |
其中,recall@1
的值与tfidf模型的差不多,但是recall@2
和recall@5
的值则比tfidf模型的结果好太多。原论文中的结果依次是0.55,0.72和0.92,可能通过模型调参或者预处理能够达到这个结果。
使用模型进行预测
对于新的数据,你可以使用udc_predict.py来进行预测;例如:
1 | python udc_predict.py --model_dir=./runs/1467576365/ |
结果如下:
1 | Context: Example context |
你可以从候选的回复中,选择预测分值最高的那个作为回复。
总结
以上,我们实现了一个基于检索的NN模型,它能够对候选的回复进行预测和打分,通过输出分值最高(或者满足一定阈值)的候选回复已完成聊天的过程。后续可以尝试其他更好的模型,或者通过调参来取得更好的实验结果。