用深度学习网络搭建一个聊天机器人(下篇):实现基于检索模型的机器
谁/为什么关注基于检索模型的机器人?
在本系列的上一篇博客中说到,基于检索模型的机器人有一个的回答集(repository),包含了预先定义的若干回答。与该模型相对应的产生式模型则是在不借助任何回答集的情况下产生一个全新的回答。
让我们更正式地定义基于检索模型的机器人:模型的输入为上下文(context)和回答(response)。模型会根据上下文为回答评分,评分最高的回答将被选择作为模型的输出。
你一定想问,在能够搭建一个产生式模型的情况下,为什么我们更想搭建一个基于检索的模型?诚然,产生式模型看起来更灵活,并且不需要预先定义的回答集。原因很简单:当下的产生式模型在实践中表现不佳。因为他们太灵活了,以至于他们非常容易犯语法错误、产生和问题不相关的回答、产生万金油式的回答或是与前文不一致的回答(我们在(一)中对这些问题有过简略讨论)。此外,产生式模型需要大量的训练数据。现今,工业界主流的系统大多仍是基于检索模型的,或是两种模型的结合。产生式模型是一个活跃的研究领域,但是我们才刚刚起步。如果现在的你想搭建一个聊天机器人,基于检索的模型应该能让你更有成就感 :)
UBUNTU对话语料库
在这篇博客中,我们将使用UBUNTU对话语料库(Ubuntu Dialog Corpus)(paper, code)。UBUNTU对话语料库(UDC)是基于Ubuntu频道的对话日志,是最大的公开的对话数据集之一。
这篇文章已经深入介绍了这个数据集是如何创建的,因此本文不再加以赘述。不过我们可以大致了解一下数据集的结构,方便我们在模型中使用。
训练数据集包括了100万个样本,正负样本各占一半。每个样本由上下文(context)和回答(utterance)构成。上下文指的是从对话开始,截止到当前的内容,回答指的是对这段内容的回应。换而言之,上下文可以是若干句对话,而回答则是对这若干句对话的回应。正样本指的是该样本的上下文和回答是匹配的,对应地,负样本指的是二者是不匹配的——回答是从语料库的某个地方随机抽取的。下图是训练数据集的部分展示:
实现一个基于检索模型的机器人!你会发现,这些样本看起来有点奇怪。事实上,是因为产生数据集的脚本使用NLTK为我们做了一系列的数据预处理工作——分词(tokenized)、英文单词取词根(stemmed)、英文单词变形的归类(lemmatized)(例如单复数归类)等。此外,例如人名、地名、组织名、URL链接、系统路径等专有名词,NLTK也做了替代。这些预处理工作也不是非做不可,不过它们似乎让结果变好了:) 经过统计,上下文的平均长度大概是450个字符,回答的平均长度大概是80个字符。
产生数据集的脚本也能够生成测试数据集(见下图)。在测试数据集中,每一条记录(record)包括:1个上下文;1个真实回答;9个错误回答。本模型的目标就是让真实回答的评分最高,错误回答的评分低(这样模型就能选出正确回答了!)
介绍完数据集,大致说一下评测模型好坏的方法。有许多评测方法可以使用,而最常用的方法称为 。什么意思呢?模型会按照评分的从高到低,挑选K个回答。如果正确的回答在这K个当中,我们就认为这条测试样本预测正确。显然,K越大,事情越简单。对于刚才介绍的测试集,如果令 ,则分类准确率为100%,因为所有的回答都被选进来了,正确的回答一定在其中!对应的,如果令 ,则模型只有一次选择的机会,这对模型的精准程度要求很高。
在这里,我想提一下该数据集的特殊性以及和真实数据的区别。对于该数据集,机器人模型每次都对不同的回答打分,在训练阶段,有些回答机器人可能只见过一次。这意味着机器人的泛化能力要好,才能在面对测试集中许多从未见过的回答时表现良好。然而,在许多的现实系统中,机器人只需要处理数量有限的回答,即训练集中,每一种可能的回答,都会有若干条样本与此对应。因此,机器人不会被要求给从没见过的回答打分。这样事情就简单多了!因此现实中的基于检索模型的机器人,应该会比本模型的效果要好。
一些简单的基准线(baseline)
在介绍更复杂的深度学习的模型之前,先让我们再次明确一下我们的任务,并且搭建几个简单的baseline模型。这有助于了解我们能够对我们的模型抱有多大的期待 :)
我们将使用下面的函数来评估我们的指标:
# Evaluation
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
在这里,是排序过后的预测值的list,是真实的标签(label)。举例来说,有一个是这样的:,代表编号为0的回答得到了最高分,编号为9的回答最低分。因为我们的测试集的样本有10个回答,因此编号为0~9。如果,即正确的回答为编号为3的回答,并且评测标准为,那么这条测试样本将被标注为错误;反之,如果是,那么这条样本则为正确。
直观来说,一个完全随机的预测模型,在时,正确率应该为10%,在时,正确率应为20%,以此类推。让我们写一个小程序验证一下:
# 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))]
for n in [1, 2, 5, 10]:
print("Recall @ ({}, 10): {:g}".format(n, evaluate_recall(y_random, y_test, n)))
Recall @ (1, 10): 0.0937632
Recall @ (2, 10): 0.194503
Recall @ (5, 10): 0.49297
Recall @ (10, 10): 1
非常好!结果和我们预想的一样。当然了,我们并不满足于一个随机的预测模型。在那篇文章(见上)中,还讨论了另一个baseline,叫做tf-idf预测模型。tf-idf指的是词频-逆向文件频率(term frequency – inverse document frequency),衡量的是一个单词在一个语料库中的重要程度。更多关于tf-idf的细节我们将不再赘述(网上有许多相关资料),一言蔽之,相似内容的文档具有相似的tf-idf向量。直观来说,如果一个上下文和回答具有相似的词汇,那么它们更有可能是一对匹配的组合。至少这种估计会比随机的要靠谱。
当下,许多库(例如scikit-learn)都有tf-idf的内置函数,因此,使用起来并不困难。让我们搭建一个tf-idf的预测模型,看看它表现如何:
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)))
Recall @ (1, 10): 0.495032
Recall @ (2, 10): 0.596882
Recall @ (5, 10): 0.766121
Recall @ (10, 10): 1
可以看到,tf-idf模型比随机模型表现好多了,但是这远远不够。事实上,我们刚才的假设是有问题的:第一,合适的回答没必要和上下文词汇相似;第二,tf-idf忽略了词汇的顺序,而这点很关键。使用基于神经网络的模型,我们应该会得到更好的结果。
对偶编码LSTM模型(DUAL ENCODER LSTM)
在本小节中,我们将搭建一个对偶编码的LSTM深度学习模型,也叫作连体网络(Siamese network)。这种类型的网络只是解决此类问题的选择之一,也许不是最好的。大家当然可以发挥想象力,搭建各种各样的深度学习框架来尝试——这也是目前的研究热点。那么,为什么我们选择了对偶编码的模型呢?因为根据这个实验的结果,该模型表现良好。并且,由于我们已经有可以参照的基准程序(benchmark)了,我们就可以对重现该模型有一个合理的预估。当然,使用别的模型(例如attention-based的RNN模型)也会是个有趣的研究点。 我们搭建的对偶编码RNN模型结构如下(paper):
它的工作原理大致如下:
-
上下文(context)和回答(response)按照单词分割,然后每个单词用词向量代替。我们使用的词向量是Stanford的GloVe,它们在网络训练过程中也会被调优(图中没有展示词向量代替)
-
上下文和回答会以单词为粒度,送入RNN中(图中和可以认为是一个单词的词向量)。接着RNN会产生一个向量,可以认为其大致代表上下文和回答的“含义”(图中的和)我们可以指定这个向量的维度,假设现在我们指定为256维。
-
我们将和一个矩阵相乘,“预测”一个回答。如果是一个256维的向量,那么设定为256*256维的矩阵,这样乘出来的结果就是另一个256维的向量。可以认为是上下文经过网络后,产生的回答。矩阵在训练过程中会被学习。
-
我们衡量产生的回答和真实回答之间的相似度。使用的方法为对二者进行点积(dot product)操作。点积结果越大,说明二者越相似,那么当前的回答就会获得越高分。然后,我们会使用sigmoid函数,将点积结果转化为概率值。图中右侧的结合了步骤3和步骤4。
为了训练这个网络,我们需要定义一个损失函数(loss function)。我们将使用在分类问题中常用的二元交叉熵(binary cross-entropy)。我们用代表“上下文-回答”的pair的真实标注(true label),要么是1(真实回答),要么是0(错误回答);用代表预测出来的概率值,。那么,交叉熵损失值的计算方式如下:。这个公式的直观理解非常简单。如果,则,那么,如果离1很远,L的值就会很大,作为惩罚。如果,则,此时则是惩罚离0很远的情况。
我们的模型实现使用了numpy, pandas, Tensorflow和TF Learn(也是Tensorflow的一部分,提供很多便于使用的函数)
在模型搭建之前,我们需要定义一些超参数(hyper-parameters):
# The maximum number of words to consider for the contexts
MAX_CONTEXT_LENGTH = 80
# The maximum number of words to consider for the utterances
MAX_UTTERANCE_LENGTH = 30
# Word embedding dimensionality
EMBEDDING_SIZE = 300
# LSTM Cell dimensionality
LSTM_CELL_SIZE = 256
限制上下文和回答句子的长度是为了使得模型训练得更快。根据之前介绍的对数据集的统计,80个单词大概能够截取到上下文的大部分内容,相应地,回答使用40个单词大概足够。我们让词向量的维数为300,因为预先训练(pre-trained)好的无论是word2vec还是GloVe都是300维的,这样设定方便我们直接使用它们。
接着,我们利用TF Learn的库函数,对数据做预处理。包括构建单词-索引表(vocab_processor)、将数据集从单词转换为索引(index);此外,我们还载入GloVe的词向量,并初始化单词索引-词向量表(initial_embeddings):将数据集中,在GloVe存在的单词替换为GloVe词向量,不存在的单词则初始化为之间的均匀分布。
# Preprocessing
# ==================================================
# Create vocabulary mapping
all_sentences = np.append(train_df.Context, train_df.Utterance)
vocab_processor = skflow.preprocessing.VocabularyProcessor(MAX_CONTEXT_LENGTH, min_frequency=5)
vocab_processor.fit(all_sentences)
# Transform contexts and utterances
X_train_context = np.array(list(vocab_processor.transform(train_df.Context)))
X_train_utterance = np.array(list(vocab_processor.transform(train_df.Utterance)))
# Generate training tensor
X_train = np.stack([X_train_context, X_train_utterance], axis=1)
y_train = train_df.Label
n_words = len(vocab_processor.vocabulary_)
print("Total words: {}".format(n_words))
# Load glove vectors
# ==================================================
vocab_set = set(vocab_processor.vocabulary_._mapping.keys())
glove_vectors, glove_dict = load_glove_vectors(os.path.join(FLAGS.data_dir, "glove.840B.300d.txt"), vocab_set)
# Build initial word embeddings
# ==================================================
initial_embeddings = np.random.uniform(-0.25, 0.25, (n_words, EMBEDDING_DIM)).astype("float32")
for word, glove_word_idx in glove_dict.items():
word_idx = vocab_processor.vocabulary_.get(word)
initial_embeddings[word_idx, :] = glove_vectors[glove_word_idx]
在搭建模型之前,我们需要先引入一个扩展内容。由于在实际测试时,对数据集进行“多截取,少补零”的规整化方式,可能会让我们损失一些精度。设想,如果一句话只有5个单词,而被补全到了80个单词,或是一句话有150个单词,却被截取到了80个单词,无论如何,都是不够好的。但是,在上文也提过,对数据进行切取,是为了加速训练过程。因此,我们需要一个trade-off。对于截取,我们仍然维持原状;对于补全,我们在送入RNN网络之前,会先计算出数据的原始长度(即最后一个非零index)。值得注意的是,之所以可以这么做,是因为tensorflow的RNN模块是支持变长输入数据的训练的。获取不大于最大长度的数据的实际长度的函数定义如下:
def get_sequence_length(input_tensor, max_length):
"""
If a sentence is padded, returns the index of the first 0 (the padding symbol).
If the sentence has no padding, returns the max length.
"""
zero_tensor = np.zeros_like(input_tensor)
comparsion = tf.equal(input_tensor, zero_tensor)
zero_positions = tf.argmax(tf.to_int32(comparsion), 1)
position_mask = tf.to_int64(tf.equal(zero_positions, 0))
sequence_lengths = zero_positions + (position_mask * max_length)
return sequence_lengths
接下来,我们可以开始搭建模型了!以下的操作都是以batch为单位进行的。基本步骤如下:
-
调用get_sequence_length函数,分别获取上下文和回答的实际长度;
-
使用先前构建的单词索引-词向量表,将上下文和回答替换为词向量;
-
将上下文和回答分别送入同一个RNN网络中训练,取RNN网络的最后一个state作为上下文和回答的encoding;
-
预测、计算概率值和loss。
这些步骤和之前的图解是一一对应的,代码如下:
def rnn_encoder_model(X, y):
# Split input tensor into separare context and utterance tensor
context, utterance = tf.split(1, 2, X, name='split')
context = tf.squeeze(context, [1])
utterance = tf.squeeze(utterance, [1])
utterance_truncated = tf.slice(utterance, [0, 0], [-1, MAX_UTTERANCE_LENGTH])
# Calculate the sequence length for RNN calculation
context_seq_length = get_sequence_length(context, MAX_CONTEXT_LENGTH)
utterance_seq_length = get_sequence_length(utterance, MAX_UTTERANCE_LENGTH)
# Embed context and utterance into the same space
with tf.variable_scope("shared_embeddings") as vs, tf.device('/cpu:0'):
embedding_tensor = tf.convert_to_tensor(initial_embeddings)
embeddings = tf.get_variable("word_embeddings", initializer=embedding_tensor)
# Embed the context
word_vectors_context = skflow.ops.embedding_lookup(embeddings, context)
word_list_context = skflow.ops.split_squeeze(1, MAX_CONTEXT_LENGTH, word_vectors_context)
# Embed the utterance
word_vectors_utterance = skflow.ops.embedding_lookup(embeddings, utterance_truncated)
word_list_utterance = skflow.ops.split_squeeze(1, MAX_UTTERANCE_LENGTH, word_vectors_utterance)
# Run context and utterance through the same RNN
with tf.variable_scope("shared_rnn_params") as vs:
#lsy modified the forget_bias = 2.0
cell = tf.nn.rnn_cell.LSTMCell(RNN_DIM, forget_bias=2.0)
cell = tf.nn.rnn_cell.DropoutWrapper(cell,output_keep_prob=0.5)
context_outputs, context_state = tf.nn.rnn(
cell, word_list_context, dtype=dtypes.float32, sequence_length=context_seq_length)
encoding_context = tf.slice(context_state, [0, cell.output_size], [-1, -1])
vs.reuse_variables()
utterance_outputs, utterance_state = tf.nn.rnn(
cell, word_list_utterance, dtype=dtypes.float32, sequence_length=utterance_seq_length)
encoding_utterance = tf.slice(utterance_state, [0, cell.output_size], [-1, -1])
with tf.variable_scope("prediction") as vs:
W = tf.get_variable("W",
shape=[encoding_context.get_shape()[1], encoding_utterance.get_shape()[1]],
initializer=tf.random_normal_initializer())
b = tf.get_variable("b", [1])
# We can interpret this is a "Generated context"
generated_context = tf.matmul(encoding_utterance, W)
# Batch multiply contexts and utterances (batch_matmul only works with 3-d tensors)
generated_context = tf.expand_dims(generated_context, 2)
encoding_context = tf.expand_dims(encoding_context, 2)
scores = tf.batch_matmul(generated_context, encoding_context, True) + b
# Go from [15,1,1] to [15,1]: We want a vector of 15 scores
scores = tf.squeeze(scores, [2])
# Convert scores into probabilities
probs = tf.sigmoid(scores)
# Calculate loss
loss = tf.contrib.losses.logistic(scores, tf.expand_dims(y, 1))
tf.scalar_summary("mean_loss", tf.reduce_mean(loss))
return [probs, loss]
在定义好模型函数之后,我们调用TF Learn的方法,将模型函数包装起来,还可以设定一些诸如优化方法(optimazer)、学习速率(learning rate)、学习速率衰减函数(learning rate decay function)等参数。然后,令分类器启动,就可以开始训练了!
def evaluate_rnn_predictor(df):
y_test = np.zeros(len(df))
y = predict_rnn_batch(df.Context, df.iloc[:, 1:].values)
for n in [1, 2, 5, 10]:
print("Recall @ ({}, 10): {:g}".format(n, evaluate_recall(y, y_test, n)))
class ValidationMonitor(tf.contrib.learn.monitors.BaseMonitor):
def __init__(self, print_steps=100, early_stopping_rounds=None, verbose=1, val_steps=1000):
super(ValidationMonitor, self).__init__(
print_steps=print_steps,
early_stopping_rounds=early_stopping_rounds,
verbose=verbose)
self.val_steps = val_steps
def _modify_summary_string(self):
if self.steps % self.val_steps == 0:
evaluate_rnn_predictor(validation_df)
def learning_rate_decay_func(global_step):
return tf.train.exponential_decay(
FLAGS.learning_rate,
global_step,
decay_steps=FLAGS.learning_rate_decay_every,
decay_rate=FLAGS.learning_rate_decay_rate,
staircase=True)
classifier = tf.contrib.learn.TensorFlowEstimator(
model_fn=rnn_encoder_model,
n_classes=1,
continue_training=True,
steps=FLAGS.num_steps,
learning_rate=learning_rate_decay_func,
optimizer=FLAGS.optimizer,
batch_size=FLAGS.batch_size)
monitor = ValidationMonitor(print_steps=100, val_steps=1000)
classifier.fit(X_train, y_train, logdir='./tmp/tf/dual_lstm_chatbot/', monitor=monitor)
关于对测试集的检验函数,只需要调用我们定义好的classifier的predict_proba函数,就能便捷地做预测了。当然,在此之前,还需要将数据从单词转换为索引。事实上,测试和训练几乎可以共享之前定义的model,除此之外的一些区别,classifier会帮我们处理。
def predict_rnn_batch(contexts, utterances, n=1):
num_contexts = len(contexts)
num_records = np.multiply(*utterances.shape)
input_vectors = []
for context, utterance_list in zip(contexts, utterances):
cvec = np.array(list(vocab_processor.transform([context])))
for u in utterance_list:
uvec = np.array(list(vocab_processor.transform([u])))
stacked = np.stack([cvec, uvec], axis=1)
input_vectors.append(stacked)
batch = np.vstack(input_vectors)
result = classifier.predict_proba(batch)[:, 0]
result = np.split(result, num_contexts)
return np.argsort(result, axis=1)[:, ::-1]
代码框架大致介绍到这,如果你对这个基于检索的对话系统模型感兴趣,希望自己试验一下,可以访问原作者的github得到更多资料。