/ NLP  

NLP系列

基于内容检索式的聊天机器人

提示:如果大家觉得计算资源有限,欢迎大家在”科学上网“后免费试用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则表示不是。下面是数据示例:

img

数据集的生成使用了NLTK工具,包括分词、stemmed、lemmatized等文本预处理步骤;同时还使用了NER技术,将文本中的实体,如姓名、地点、组织、URL等替换成特殊字符。这些文本预处理并不是必须的,但是能够提升一些模型的性能。据统计,query的平均长度为86个word,而response的平均长度为17个word,更多的数据统计信息见Jupyter notebook

数据集也包括了测试和验证集,但这两部分的数据和训练数据在格式上不太一样。在测试集和验证集中,对于每一条实例,有一个正例和九个负例数据(也称为干扰数据)。模型的目标在于给正例的得分尽可能的高,而给负例的得分尽可能的低。下面是数据示例:

img

模型的评测方式有很多种。其中最常用到的是recall@k,即经模型对候选的response排序后,前k个候选中存在正例数据(正确的那个)的占比;显然k值越大,该指标会越高,因为这对模型性能的要求越松。

在Ubuntu数据集中,负例数据都是随机生成的;然而在现实中,想要从全部的数据中随机生成负例是不可能的。谷歌的Smart Reply则使用了聚类技术,然后将每个类的中取一些作为负例,这样生成负例的方式显得更加合理(考虑了负例数据的多样性,同时减少时间开销)。

BASELINE

在使用NN模型之前,先设立一些简单的baseline模型,以方便后续的效果对比。使用如下的函数来计算recall@k:

1
2
3
4
5
6
7
def evaluate_recall(y, y_test, k=1):
num_examples = float(len(y))
num_correct = 0
for predictions, label in zip(y, y_test):
if label in predictions[:k]:
num_correct += 1
return num_correct/num_examples

其中,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
2
3
4
5
6
7
8
9
# Random Predictor
def predict_random(context, utterances):
return np.random.choice(len(utterances), 10, replace=False)

# Evaluate Random predictor
y_random = [predict_random(test_df.Context[x], test_df.iloc[x,1:].values) for x in range(len(test_df))]
y_test = np.zeros(len(y_random))
for n in [1, 2, 5, 10]:
print("Recall @ ({}, 10): {:g}".format(n, evaluate_recall(y_random, y_test, n)))

实际的模型结果如下:

1
2
3
4
Recall @ (1, 10): 0.0937632
Recall @ (2, 10): 0.194503
Recall @ (5, 10): 0.49297
Recall @ (10, 10): 1

这与理论预期相符,但这不是我们所追求的结果。

另外一个baseline的模型为tfidf predictor。tfidf表示词频(term frequency)和逆文档词频(inverse document frequency),它衡量了一个词在一篇文档中的重要程度(基于整个语料库)。直观上,两篇文档对应的tfidf向量越接近,两篇文章的内容也越相似。同样的,对于一个QR pair,它们语义上接近的词共现的越多,也将越可能是一个正确的QR pair(这句话存疑,原因在于QR之间也有可能不存在语义上的相似,一个Q对应的R是多样的。)。tfidf predictor对应的代码如下(利用scikit-learn工具能够轻易实现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class TFIDFPredictor:
def __init__(self):
self.vectorizer = TfidfVectorizer()

def train(self, data):
self.vectorizer.fit(np.append(data.Context.values,data.Utterance.values))

def predict(self, context, utterances):
# Convert context and utterances into tfidf vector
vector_context = self.vectorizer.transform([context])
vector_doc = self.vectorizer.transform(utterances)
# The dot product measures the similarity of the resulting vectors
result = np.dot(vector_doc, vector_context.T).todense()
result = np.asarray(result).flatten()
# Sort by top results and return the indices in descending order
return np.argsort(result, axis=0)[::-1]


# Evaluate TFIDF predictor
pred = TFIDFPredictor()
pred.train(train_df)
y = [pred.predict(test_df.Context[x], test_df.iloc[x,1:].values) for x in range(len(test_df))]
for n in [1, 2, 5, 10]:
print("Recall @ ({}, 10): {:g}".format(n, evaluate_recall(y, y_test, n)))

模型结果如下:

1
2
3
4
Recall @ (1, 10): 0.495032
Recall @ (2, 10): 0.596882
Recall @ (5, 10): 0.766121
Recall @ (10, 10): 1

显然这比Random的模型要好得多,但这还不够。之前的假设并不完美,首先query和response之间并不一定要是语义上的相近;其次tfidf模型忽略了词序这一重要的信息。使用NN模型我们能做得更好一些。

LSTM

这篇博文将建立的NN模型为两层Encoder的LSTM模型(Dual Encoder LSTM Network),这种形式的网络被广泛应用在chatbot中(尽管可能效果并不是最佳的那个,你可以尽可能地尝试其他的NN模型)。seq2seq模型常用于机器翻译领域,并取得了较大的效果。使用Dual LSTM模型的原因在于这个模型被证明在这个数据集有较好的效果(详情见这里),这可以作为我们后续模型效果的验证。

两层Encoder的LSTM模型的结构图如下(论文来源):

img

大致的流程如下:

(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使得损失函数的值越小;反之亦然。

实现过程中使用了numpypandasTensorFlowTF 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
2
3
def input_fn():
# TODO Load and preprocess data here
return batched_features, labels

因为我们需要在模型训练和评测过程中使用不同的输入函数,为了防止重复书写代码,我们创建一个包装器(wrapper),名称为create_input_fn,针对不同的mode使用相应的code,如下:

1
2
3
4
5
def create_input_fn(mode, input_files, batch_size, num_epochs=None):
def input_fn():
# TODO Load and preprocess data here
return batched_features, labels
return input_fn

完整的code见udc_inputs.py。整体上,这个函数做了如下的事情:

(1) 定义了示例文件中的feature字段;
(2) 使用tf.TFRecordReader来读取input_files中的数据;
(3) 根据feature字段的定义对数据进行解析;
(4) 提取训练数据的标签;
(5) 产生批量化的训练数据;
(6) 返回批量的特征数据及对应标签;

定义评测指标

之前已经提到用recall@k这个指标来评测模型,TensorFlow中已经实现了许多标准指标(包括recall@k)。为了使用这些指标,需要创建一个字典,key为指标名称,value为对应的计算函数。如下:

1
2
3
4
5
6
7
def create_evaluation_metrics():
eval_metrics = {}
for k in [1, 2, 5, 10]:
eval_metrics["recall_at_%d" % k] = functools.partial(
tf.contrib.metrics.streaming_sparse_recall_at_k,
k=k)
return eval_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 featureslabelmode(train/evaluation),函数的输出为预测值。程序样例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
estimator = tf.contrib.learn.Estimator(
model_fn=model_fn,
model_dir=MODEL_DIR,
config=tf.contrib.learn.RunConfig())

input_fn_train = udc_inputs.create_input_fn(
mode=tf.contrib.learn.ModeKeys.TRAIN,
input_files=[TRAIN_FILE],
batch_size=hparams.batch_size)

input_fn_eval = udc_inputs.create_input_fn(
mode=tf.contrib.learn.ModeKeys.EVAL,
input_files=[VALIDATION_FILE],
batch_size=hparams.eval_batch_size,
num_epochs=1)

eval_metrics = udc_metrics.create_evaluation_metrics()

# We need to subclass theis manually for now. The next TF version will
# have support ValidationMonitors with metrics built-in.
# It's already on the master branch.
class EvaluationMonitor(tf.contrib.learn.monitors.EveryN):
def every_n_step_end(self, step, outputs):
self._estimator.evaluate(
input_fn=input_fn_eval,
metrics=eval_metrics,
steps=None)

eval_monitor = EvaluationMonitor(every_n_steps=FLAGS.eval_every)
estimator.fit(input_fn=input_fn_train, steps=None, monitors=[eval_monitor])

这里创建了一个model_fnestimator(评估函数);两个输入函数,input_fn_traininput_fn_eval,以及计算评测指标的函数;

创建模型

到目前为止,我们创建了模型的输入、解析、评测和训练的样例程序。现在我们来写LSTM的程序,create_model_fn函数用以处理不同格式的训练和测试数据;它的输入参数为model_impl,这个函数表示实际作出预测的模型,这里就是用的LSTM,当然你可以替换成任意的其他模型。程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def dual_encoder_model(
hparams,
mode,
context,
context_len,
utterance,
utterance_len,
targets):

# Initialize embedidngs randomly or with pre-trained vectors if available
embeddings_W = get_embeddings(hparams)

# Embed the context and the utterance
context_embedded = tf.nn.embedding_lookup(
embeddings_W, context, name="embed_context")
utterance_embedded = tf.nn.embedding_lookup(
embeddings_W, utterance, name="embed_utterance")


# Build the RNN
with tf.variable_scope("rnn") as vs:
# We use an LSTM Cell
cell = tf.nn.rnn_cell.LSTMCell(
hparams.rnn_dim,
forget_bias=2.0,
use_peepholes=True,
state_is_tuple=True)

# Run the utterance and context through the RNN
rnn_outputs, rnn_states = tf.nn.dynamic_rnn(
cell,
tf.concat(0, [context_embedded, utterance_embedded]),
sequence_length=tf.concat(0, [context_len, utterance_len]),
dtype=tf.float32)
encoding_context, encoding_utterance = tf.split(0, 2, rnn_states.h)

with tf.variable_scope("prediction") as vs:
M = tf.get_variable("M",
shape=[hparams.rnn_dim, hparams.rnn_dim],
initializer=tf.truncated_normal_initializer())

# "Predict" a response: c * M
generated_response = tf.matmul(encoding_context, M)
generated_response = tf.expand_dims(generated_response, 2)
encoding_utterance = tf.expand_dims(encoding_utterance, 2)

# Dot product between generated response and actual response
# (c * M) * r
logits = tf.batch_matmul(generated_response, encoding_utterance, True)
logits = tf.squeeze(logits, [2])

# Apply sigmoid to convert logits to probabilities
probs = tf.sigmoid(logits)

# Calculate the binary cross-entropy loss
losses = tf.nn.sigmoid_cross_entropy_with_logits(logits, tf.to_float(targets))

# Mean loss across the batch of examples
mean_loss = tf.reduce_mean(losses, name="mean_loss")
return probs, mean_loss

完整的程序见dual_encoder.py。基于这个,我们能够实例化model函数在我们之前定义的udc_train.py,如下:

1
2
3
model_fn = udc_model.create_model_fn(
hparams=hparams,
model_impl=dual_encoder_model)

这样我们就可以直接运行udc_train.py文件,来开始模型的训练和评测了,你可以设定--eval_every参数来控制模型在验证集上的评测频率。更多的命令行参数信息可见tf.flagshparams,你也可以运行python udc_train.py --help来查看。

运行程序的效果如下:

1
2
3
4
5
6
7
INFO:tensorflow:training step 20200, loss = 0.36895 (0.330 sec/batch).
INFO:tensorflow:Step 20201: mean_loss:0 = 0.385877
INFO:tensorflow:training step 20300, loss = 0.25251 (0.338 sec/batch).
INFO:tensorflow:Step 20301: mean_loss:0 = 0.405653
...
INFO:tensorflow:Results after 270 steps (0.248 sec/batch): recall_at_1 = 0.507581018519, recall_at_2 = 0.689699074074, recall_at_5 = 0.913020833333, recall_at_10 = 1.0, loss = 0.5383
...

模型的评测

在训练完模型后,你可以将其应用在测试集上,使用:

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
2
3
recall_at_1 = 0.507581018519
recall_at_2 = 0.689699074074
recall_at_5 = 0.913020833333

其中,recall@1的值与tfidf模型的差不多,但是recall@2recall@5的值则比tfidf模型的结果好太多。原论文中的结果依次是0.55,0.72和0.92,可能通过模型调参或者预处理能够达到这个结果。

使用模型进行预测

对于新的数据,你可以使用udc_predict.py来进行预测;例如:

1
python udc_predict.py --model_dir=./runs/1467576365/

结果如下:

1
2
3
Context: Example context
Response 1: 0.44806
Response 2: 0.481638

你可以从候选的回复中,选择预测分值最高的那个作为回复。

总结

以上,我们实现了一个基于检索的NN模型,它能够对候选的回复进行预测和打分,通过输出分值最高(或者满足一定阈值)的候选回复已完成聊天的过程。后续可以尝试其他更好的模型,或者通过调参来取得更好的实验结果。