查看原文
其他

【NLP实战】如何基于Tensorflow搭建一个聊天机器人

小Dream哥 有三AI 2020-09-08

实战是学习一门技术最好的方式,也是深入了解一门技术唯一的方式。因此,NLP专栏计划推出一个实战专栏,让有兴趣的同学在看文章之余也可以自动动手试一试。


本篇介绍如何基于tensorflow快速搭建一个基于seq2seq框架的聊天机器人。


作者&编辑 | 小Dream哥

1 语料准备 

用于聊天机器人训练的语料应该是一系列的问答对,即大量的如下的形式问答对:

Q:“今天天气怎么?”

A:“天气预报说今天会下大暴雨的”

此外,为了减少数据处理时的padding量,对训练语料进行了分桶的处理,如下图所示:

所谓分桶,就是按照Q和A的长度进行重新的组织,例如上例Q的长度为7,A的长度为13,则这条语料会分在“bucket_5_15.db”文件中。

2 模型搭建

这里介绍的是基于seq2seq框架的聊天机器人,关于seq2seq框架的相关的理论内容,可以看一下笔者这一篇文章:

【NLP-ChatBot】能闲聊的端到端生成型聊天机器人背后都有哪些技术?


下面我们看看如何基于tensorflow,搭建一个seq2seq+attention的聊天机器人。


(1) 构建seq2seq编解码器的特征抽取器

这里采用LSTM作为encoder和decoder的特征抽取器:

# LSTM cells
cell = tf.contrib.rnn.BasicLSTMCell(size)
cell = tf.contrib.rnn.DropoutWrapper(cell, output_keep_prob=dropout)
cell = tf.contrib.rnn.MultiRNNCell([cell] * num_layers)

(2) 处理输入数据

采用tensorflow的placeholder模块,先定义输入数据的shape。这里定义了encoder_inputs,decoder_inputs,decoder_weights,targets三个列表。

# inputs
self.encoder_inputs = []
self.decoder_inputs = []
self.decoder_weights = []


#encoder_inputs表示解码器的输入,这个列表对象中的每一个元素表示一个占位符,其名字分别为encoder0, encoder1,…,encoder39

#encoder{i}的几何意义是编码器在时刻i的输入。


# 这里取的是最长的那个分桶的长度

for i in range(buckets[-1][0]):
   self.encoder_inputs.append(tf.placeholder(
       tf.int32,
       shape=[None],
       name='encoder_input_{}'.format(i)
   ))


# 输出比输入大 1,这是为了保证下面的targets可以向左shift 1位

for i in range(buckets[-1][1] + 1):
   self.decoder_inputs.append(tf.placeholder(
       tf.int32,
       shape=[None],
       name='decoder_input_{}'.format(i)
   ))
   self.decoder_weights.append(tf.placeholder(
       dtype,
       shape=[None],
       name='decoder_weight_{}'.format(i)
   ))


 #target_weights 是一个与 decoder_outputs 大小一样的 0-1 矩阵。该矩阵将目标序列长度以外的其他位置填充为标量值 0。
targets = [
   self.decoder_inputs[i + 1] for i in range(buckets[-1][1])
]

(3) 搭建引入attention机制的seq2seq模型


encoder先将cell进行deepcopy,因为seq2seq模型是两个相同的特征抽取模型,但是模型参数不共享,所以encoder和decoder要使用两个不同的LSTMCell。


然后,直接调用系统函数embedding_attention_seq2seq()搭建引入attention机制的seq2seq模型。这里介绍下该函数的各个输入。


encoder_inputs:编码器的输入;

decoder_inputs:解码器的输入;

num_encoder_symbols:source的vocab_size大小,用于embedding矩阵定义;

num_decoder_symbols:target的vocab_size大小,用于embedding矩阵定义;

embedding_size:embedding向量的维度;

num_heads:Attention头的个数,就是使用多少种attention的加权方式,用更多的参数来求出几种attention向量;

output_projection: 输出的映射层,因为decoder输出的维度是output_size,所以想要得到num_decoder_symbols对应的词还需要增加一个映射层;

feed_previous:是否将上一时刻输出作为下一时刻输入,一般测试的时候置为True,此时decoder_inputs除了第一个元素之外其他元素都不会使用;

initial_state_attention:默认为False, 初始的attention是零;若为True,将从initial state和attention states开始。

def seq2seq_f(encoder_inputs, decoder_inputs, do_decode):


   tmp_cell = copy.deepcopy(cell)


   return tf.contrib.legacy_seq2seq.embedding_attention_seq2seq(
       encoder_inputs,
       decoder_inputs,
       tmp_cell,#自定义的cell,可以是GRU/LSTM, 设置multilayer等
       num_encoder_symbols=source_vocab_size,
       num_decoder_symbols=target_vocab_size,
       embedding_size=size,# embedding 维度
       output_projection=output_projection,
       feed_previous=do_decode,
       dtype=dtype
   )

(4)构建损失计算层


因为采用的是sampled_loss,在解码器和loss层要加一个projection层来做适配。

# 如果vocabulary太大,我们还是按照vocabulary来sample的话,内存会爆
if num_samples > 0 and num_samples < self.target_vocab_size:
   w_t = tf.get_variable(
       "proj_w",
       [self.target_vocab_size, size],
       dtype=dtype
   )
   w = tf.transpose(w_t)
   b = tf.get_variable(
       "proj_b",
       [self.target_vocab_size],
       dtype=dtype
   )
   output_projection = (w, b)

构建sampled_loss层,也是直接调用sampled_softmax_loss函数,关于sampled_loss相关的理论问题,我们再找个机会单独讨论吧。

def sampled_loss(labels, logits):


   labels = tf.reshape(labels, [-1, 1])
   local_w_t = tf.cast(w_t, tf.float32)
   local_b = tf.cast(b, tf.float32)
   local_inputs = tf.cast(logits, tf.float32)
   return tf.cast(
       tf.nn.sampled_softmax_loss(
           weights=local_w_t,
           biases=local_b,
           labels=labels,
           inputs=local_inputs,
           num_sampled=num_samples,
           num_classes=self.target_vocab_size
       ),
       dtype
   )

(5) 计算损失和logits


这里调用了系统函数model_with_buckets,这样对每个bucket都构造一个模型,然后训练时取相应长度的序列,不同长度的模型参数共享。

self.outputs, self.losses = tf.contrib.legacy_seq2seq.model_with_buckets(
   self.encoder_inputs,
   self.decoder_inputs,
   targets,
   self.decoder_weights,
   buckets,
   lambda x, y: seq2seq_f(x, y, False),
   softmax_loss_function=softmax_loss_function
)

(6) 构建优化器和保存训练模型

params = tf.trainable_variables()
opt = tf.train.AdamOptimizer(
   learning_rate=learning_rate
)

if not forward_only:# 只有训练阶段才需要计算梯度和参数更新
   self.gradient_norms = []
   self.updates = []
   for output, loss in zip(self.outputs, self.losses):

    # 用梯度下降法优化
       gradients = tf.gradients(loss, params)
       clipped_gradients, norm = tf.clip_by_global_norm(
           gradients,
           max_gradient_norm
       )
       self.gradient_norms.append(norm)
       self.updates.append(opt.apply_gradients(
           zip(clipped_gradients, params)
       ))
# 模型参数保存
self.saver = tf.train.Saver(
   tf.all_variables(),
   write_version=tf.train.SaverDef.V2
)

(7) 开始训练

params = tf.trainable_variables()
opt = tf.train.AdamOptimizer(
   learning_rate=learning_rate
)

if not forward_only:# 只有训练阶段才需要计算梯度和参数更新
   self.gradient_norms = []
   self.updates = []
   for output, loss in zip(self.outputs, self.losses):

    # 用梯度下降法优化
       gradients = tf.gradients(loss, params)
       clipped_gradients, norm = tf.clip_by_global_norm(
           gradients,
           max_gradient_norm
       )
       self.gradient_norms.append(norm)
       self.updates.append(opt.apply_gradients(
           zip(clipped_gradients, params)
       ))
# 模型参数保存
self.saver = tf.train.Saver(
   tf.all_variables(),
   write_version=tf.train.SaverDef.V2
)

模型搭建好之后,就可以给模型为数据,开始训练了。

def step(
   self,
   session,
   encoder_inputs,
   decoder_inputs,
   decoder_weights,
   bucket_id,
   forward_only
):
   encoder_size, decoder_size = self.buckets[bucket_id]
   if len(encoder_inputs) != encoder_size:
       raise ValueError(
           "Encoder length must be equal to the one in bucket,"
           " %d != %d." % (len(encoder_inputs), encoder_size)
       )
   if len(decoder_inputs) != decoder_size:
       raise ValueError(
           "Decoder length must be equal to the one in bucket,"
           " %d != %d." % (len(decoder_inputs), decoder_size)
       )
   if len(decoder_weights) != decoder_size:
       raise ValueError(
           "Weights length must be equal to the one in bucket,"
           " %d != %d." % (len(decoder_weights), decoder_size)
       )

   input_feed = {}

#1.将语料放到之前定义好的列表中,待模型读取
 for i in range(encoder_size):
       input_feed[self.encoder_inputs[i].name] = encoder_inputs[i]
   for i in range(decoder_size):
       input_feed[self.decoder_inputs[i].name] = decoder_inputs[i]
       input_feed[self.decoder_weights[i].name] = decoder_weights[i]

   last_target = self.decoder_inputs[decoder_size].name
   input_feed[last_target] = np.zeros([self.batch_size], dtype=np.int32)

   if not forward_only:
       output_feed = [
           self.updates[bucket_id],
           self.gradient_norms[bucket_id],
           self.losses[bucket_id]
       ]
       output_feed.append(self.outputs[bucket_id][i])
   else:
       output_feed = [self.losses[bucket_id]]
       for i in range(decoder_size):
           output_feed.append(self.outputs[bucket_id][i])
 #2.开始训练
   outputs = session.run(output_feed, input_feed)
   if not forward_only:
       return outputs[1], outputs[2], outputs[3:]
   else:
       return None, outputs[0], outputs[1:]

3 如何进行训练和测试

(1) 进行训练

在s2s.py中,做如下的设置,并设置好学习率,batch_size等其他超参数,执行s2s.py进行训练。

tf.app.flags.DEFINE_boolean(
   'test',
   False,
   '是否在测试'
)

开始训练后,会打印出来当前的loss值,如下图所示:

模型文件会生成在model文件夹中。

(2) 进行测试

在s2s.py中,做如下的设置:

tf.app.flags.DEFINE_boolean(
   'test',
    True,
   '是否在测试'
)

然后,可以输入问句,看机器人怎么回答了。因为我训练的不充分,所以机器人的回答会出现重复的。另一方面,这种生成式的机器人,可控性不强,目前基本处于研究阶段。

4 如何获取代码与交流

至此,介绍了如何利用tensorflow平台自己搭建一个基于seq2seq框架的聊天机器,代码在我们有三AI的github可以下载:https://github.com/longpeng2008/yousan.ai/tree/master/natural_language_processing

找到seq2seqChatbot文件夹,执行python3 s2s.py就可以进行训练或者测试了。


代码来源于github,也可参考这个github:

https://github.com/qhduan/Seq2Seq_Chatbot_QA

总结


生成式的聊天机器人技术框架非常简洁,在构建过程是端到端(End-to-End)的,实现简单。因此,我见过很多简历上写的聊天机器人项目是基于此框架的,大多雷同,建议读者在简历上写这个项目时要慎重,非要写的话,务必要突出差异。


读者们可以留言,或者加入我们的NLP群进行讨论。感兴趣的同学可以微信搜索jen104,备注"加入有三AI NLP群"


下期预告:基于BERT的文本分类模型

知识星球推荐

扫描上面的二维码,就可以加入我们的星球,助你成长为一名合格的自然语言处理算法工程师。


知识星球主要有以下内容:


(1) 聊天机器人。考虑到聊天机器人是一个非常复杂的NLP应用场景,几乎涵盖了所有的NLP任务及应用。所以小Dream哥计划以聊天机器人作为切入点,通过介绍聊天机器人的原理和实践,逐步系统的更新到大部分NLP的知识,会包括语义匹配,文本分类,意图识别,语义匹配命名实体识别、对话管理以及分词等。


(2) 知识图谱。知识图谱对于NLP各项任务效果好坏的重要性,就好比基础知识对于一个学生成绩好坏的重要性。他是NLP最重要的基础设施,目前各大公司都在着力打造知识图谱,作为一个NLP工程师,必须要熟悉和了解他。


(3) NLP预训练模型。基于海量数据,进行超大规模网络的无监督预训练。具体的任务再通过少量的样本进行Fine-Tune。这样模式是目前NLP领域最火热的模式,很有可能引领NLP进入一个全新发展高度。你怎么不深入的了解?


转载文章请后台联系

侵权必究

往期精选


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存