简单易懂使用PyTorch实现Cha

本系列文章通过通俗易懂的方式介绍强化学习的基本概念,虽然语言通俗,但是内容依旧非常严谨性。文中用很多的公式,对数学公式头疼的读者可能会被吓住,但是如果读者一步一步follow下来,就会发现公式的推导非常自然,对于透彻的理解这些基本概念非常有帮助。除了理论之外,文章还会介绍每种算法的实现代码,深入解答每一行关键代码。让读者不但理解理论和算法,同时还能知道怎么用代码来实现。通过理论与实际的结合,更加深入的理解学过的概念。读者只需要基本的Python编程知识,文中每一个算法都有对应的JupyterNotebook代码。(文章来源,李理的Github博客)

本教程会介绍使用seq2seq模型实现一个chatbot,训练数据来自Cornell电影对话语料库。对话系统是目前的研究热点,它在客服、可穿戴设备和智能家居等场景有广泛应用。

传统的对话系统要么基于检索的方法——提前准备一个问答库,根据用户的输入寻找类似的问题和答案。这更像一个问答系统,它很难进行多轮的交互,而且答案是固定不变的。要么基于预先设置的对话流程,这主要用于slot-filling(Task-Oriented)的任务,比如查询机票需要用户提供日期,达到城市等信息。这种方法的缺点是比较死板,如果用户的意图在设计的流程之外,那么就无法处理,而且对话的流程也一般比较固定,要支持用户随意的话题内跳转和话题间切换比较困难。

因此目前学术界的研究热点是根据大量的对话数据,自动的End-to-End的使用Seq2Seq模型学习对话模型。它的好处是不需要人来设计这个对话流程,完全是数据驱动的方法。它的缺点是流程不受人(开发者)控制,在严肃的场景(比如客服)下使用会有比较大的风险,而且需要大量的对话数据,这在很多实际应用中是很难得到的。因此目前seq2seq模型的对话系统更多的是用于类似小冰的闲聊机器人上,最近也有不少论文研究把这种方法用于task-oriented的任务,但还不是太成熟,在业界还很少被使用。

效果

本文使用的Cornell电影对话语料库就是偏向于闲聊的语料库。

本教程的主要内容参考了PyTorch官方教程。读者可以从这里获取完整代码。下面是这个教程实现的对话效果示例:

准备

首先我们通过下载链接下载训练语料库,这是一个zip文件,把它下载后解压到项目目录的子目录data下。接下来我们导入需要用到的模块,这主要是PyTorch的模块:

加载和预处理数据

接下来我们需要对原始数据进行变换然后用合适的数据结构加载到内存里。

Cornell电影对话语料库是电影人物的对话数据,它包括:

10,对电影人物(一部电影有多个人物,他们两两之间可能存在对话)的,个对话部电影的9,个人物总共,个utterance(utterance是对话中的语音片段,不一定是完整的句子)这个数据集是比较大并且多样的(diverse),语言形式、时代和情感都有很多样。这样的数据可以使得我们的chatbot对于不同的输入更加鲁棒(robust)。

首先我们来看一下原始数据长什么样:

解压后的目录有很多文件,我们会用到的文件包括movie_lines.txt。上面的代码输出这个文件的前10行,结果如下:

注意:上面的move_lines.txt每行都是一个utterance,但是这个文件看不出哪些utterance是组成一段对话的,这需要movie_conversations.txt文件:

每一行用”+++$+++”分割成4列,第一列表示第一个人物的ID,第二列表示第二个人物的ID,第三列表示电影的ID,第四列表示这两个人物在这部电影中的一段对话,比如第一行的表示人物u0和u2在电影m0中的一段对话包含ID为L、L、L和L的4个utterance。注意:两个人物在一部电影中会有多段对话,中间可能穿插其他人之间的对话,而且即使中间没有其他人说话,这两个人物对话的内容从语义上也可能是属于不同的对话(话题)。所以我们看到第二行还是u0和u2在电影m0中的对话,它包含L和L两个utterance,L是紧接着L之后的,但是它们属于两个对话(话题)。

数据处理

为了使用方便,我们会把原始数据处理成一个新的文件,这个新文件的每一行都是用TAB分割问题(query)和答案(response)对。为了实现这个目的,我们首先定义一些用于parsing原始文件movie_lines.txt的辅助函数。

loadLines把movie_lines.txt文件切分成(lineID,characterID,movieID,character,text)loadConversations把上面的行group成一个个多轮的对话extractSentencePairs从上面的每个对话中抽取句对

接下来我们利用上面的3个函数对原始数据进行处理,最终得到formatted_movie_lines.txt。

上面的代码会生成一个新的文件formatted_movie_lines.txt,这文件每一行包含一对句对,用tab分割。下面是前十行:

创建词典

接下来我们需要构建词典然后把问答句对加载到内存里。

我们的输入是一个句对,每个句子都是词的序列,但是机器学习只能处理数值,因此我们需要建立词到数字ID的映射。

为此,我们会定义一个Voc类,它会保存词到ID的映射,同时也保存反向的从ID到词的映射。除此之外,它还记录每个词出现的次数,以及总共出现的词的个数。这个类提供addWord方法来增加一个词,addSentence方法来增加句子,也提供方法trim来去除低频的词。

有了上面的Voc类我们就可以通过问答句对来构建词典了。但是在构建之前我们需要进行一些预处理。

首先我们需要使用函数unicodeToAscii来把unicode字符变成ascii,比如把à变成a。注意,这里的代码只是用于处理西方文字,如果是中文,这个函数直接会丢弃掉。接下来把所有字母变成小写同时丢弃掉字母和常见标点(.!?)之外的所有字符。最后为了训练收敛,我们会用函数filterPairs去掉长度超过MAX_LENGTH的句子(句对)。

上面的代码的输出为:

我们可以看到,原来共有个句对,经过处理后我们只保留了个句对。

另外为了收敛更快,我们可以去除掉一些低频词。这可以分为两步:

1)使用voc.trim函数去掉频次低于MIN_COUNT的词。2)去掉包含低频词的句子(只保留这样的句子——每一个词都是高频的,也就是在voc中出现的)

代码的输出为:

个词之中,频次大于等于3的只有43%,去掉低频的57%的词之后,保留的句子为,占比为82%。

为模型准备数据

前面我们构建了词典,并且对训练数据进行预处理并且滤掉一些句对,但是模型最终用到的是Tensor。最简单的办法是一次处理一个句对,那么上面得到的句对直接就可以使用。但是为了加快训练速度,尤其是重复利用GPU的并行能力,我们需要一次处理一个batch的数据。

对于某些问题,比如图像来说,输入可能是固定大小的(或者通过预处理缩放成固定大小),但是对于文本来说,我们很难把一个二十个词的句子”缩放”成十个词同时还保持语义不变。但是为了充分利用GPU等计算自由,我们又必须变成固定大小的Tensor,因此我们通常会使用Padding的技巧,把短的句子补充上零使得输入大小是(batch,max_length),这样通过一次就能实现一个batch数据的forward或者backward计算。当然padding的部分的结果是没有意义的,比如某个句子实际长度是5,而max_length是10,那么最终forward的输出应该是第5个时刻的输出,后面5个时刻计算是无用功。方向计算梯度的时候也是类似的,我们需要从第5个时刻开始反向计算梯度。为了提高效率,我们通常把长度接近的训练数据放到一个batch里面,这样无用的计算是最少的。因此我们通常把全部训练数据根据长度划分成一些组,比如长度小于4的一组,长度4到8的一组,长度8到12的一组,…。然后每次随机的选择一个组,再随机的从一组里选择batch个数据。不过本教程并没有这么做,而是每次随机的从所有pair里随机选择batch个数据。

原始的输入通常是batch个list,表示batch个句子,因此自然的表示方法为(batch,max_length),这种表示方法第一维是batch,每移动一个下标得到的是一个样本的max_length个词(包括padding)。因为RNN的依赖关系,我们在计算t+1时刻必须知道t时刻的结果,因此我们无法用多个核同时计算一个样本的forward。但是不同样本之间是没有依赖关系的,因此我们可以在根据t时刻batch样本的当前状态计算batch个样本的输出和新状态,然后再计算t+2时刻,…。为了便于GPU一次取出t时刻的batch个数据,我们通常把输入从(batch,max_length)变成(max_length,batch),这样使得t时刻的batch个数据在内存(显存)中是连续的,从而读取效率更高。这个过程如下图所示,原始输入的大小是(batch=6,max_length=4),转置之后变成(4,6)。这样某个时刻的6个样本数据在内存中是连续的。

因此我们会用一些工具函数来实现上述处理。

inputVar函数把batch个句子padding后变成一个LongTensor,大小是(max_length,batch),同时会返回一个大小是batch的listlengths,说明每个句子的实际长度,这个参数后面会传给PyTorch,从而在forward和backward计算的时候使用实际的长度。

outputVar函数和inputVar类似,但是它输出的第二个参数不是lengths,而是一个大小为(max_length,batch)的mask矩阵(tensor),某位是0表示这个位置是padding,1表示不是padding,这样做的目的是后面计算方便。当然这两种表示是等价的,只不过lengths表示更加紧凑,但是计算起来不同方便,而mask矩阵和outputVar直接相乘就可以把padding的位置给mask(变成0)掉,这在计算loss时会非常方便。

batch2TrainData则利用上面的两个函数把一个batch的句对处理成合适的输入和输出Tensor。

示例的输出为:

我们可以看到input_variable的每一列表示一个样本,而每一行表示batch(5)个样本在这个时刻的值。而lengths表示真实的长度。类似的target_variable也是每一列表示一个样本,而mask的shape和target_variable一样,如果某个位置是0,则表示padding。

定义模型

Seq2Seq模型

我们这个chatbot的核心是一个sequence-to-sequence(seq2seq)模型。seq2seq模型的输入是一个变长的序列,而输出也是一个变长的序列。而且这两个序列的长度并不相同。一般我们使用RNN来处理变长的序列,Sutskever等人的论文发现通过使用两个RNN可以解决这类问题。这类问题的输入和输出都是变长的而且长度不一样,包括问答系统、机器翻译、自动摘要等等都可以使用seq2seq模型来解决。其中一个RNN叫做Encoder,它把变长的输入序列编码成一个固定长度的context向量,我们一般可以认为这个向量包含了输入句子的语义。而第二个RNN叫做Decoder,初始隐状态是Encoder的输出context向量,输入是(表示句子开始的特殊Token),然后用RNN计算第一个时刻的输出,接着用第一个时刻的输出和隐状态计算第二个时刻的输出和新的隐状态,...,直到某个时刻输出特殊的(表示句子结束的特殊Token)或者长度超过一个阈值。Seq2Seq模型如下图所示。

Encoder

Encoder是个RNN,它会遍历输入的每一个Token(词),每个时刻的输入是上一个时刻的隐状态和输入,然后会有一个输出和新的隐状态。这个新的隐状态会作为下一个时刻的输入隐状态。每个时刻都有一个输出,对于seq2seq模型来说,我们通常只保留最后一个时刻的隐状态,认为它编码了整个句子的语义,但是后面我们会用到Attention机制,它还会用到Encoder每个时刻的输出。Encoder处理结束后会把最后一个时刻的隐状态作为Decoder的初始隐状态。

实际我们通常使用多层的GatedRecurrentUnit(GRU)或者LSTM来作为Encoder,这里使用GRU,读者可以参考Cho等人年的[论文]。

此外我们会使用双向的RNN,如下图所示。

注意在接入RNN之前会有一个embedding层,用来把每一个词(ID或者one-hot向量)映射成一个连续的稠密的向量,我们可以认为这个向量编码了一个词的语义。在我们的模型里,我们把它的大小定义成和RNN的隐状态大小一样(但是并不是一定要一样)。有了Embedding之后,模型会把相似的词编码成相似的向量(距离比较近)。

最后,为了把padding的batch数据传给RNN,我们需要使用下面的两个函数来进行pack和unpack,后面我们会详细介绍它们。这两个函数是:

torch.nn.utils.rnn.pack_padded_sequencetorch.nn.utils.rnn.pad_packed_sequence计算图:

1)把词的ID通过Embedding层变成向量。2)把padding后的数据进行pack。3)传入GRU进行Forward计算。4)Unpack计算结果5)把双向GRU的结果向量加起来。6)返回(所有时刻的)输出和最后时刻的隐状态。

输入:

input_seq:一个batch的输入句子,shape是(max_length,batch_size)

input_lengths:一个长度为batch的list,表示句子的实际长度。

hidden:初始化隐状态(通常是零),shape是(n_layersxnum_directions,batch_size,hidden_size)

输出:

outputs:最后一层GRU的输出向量(双向的向量加在了一起),shape(max_length,batch_size,hidden_size)

hidden:最后一个时刻的隐状态,shape是(n_layersxnum_directions,batch_size,hidden_size)

EncoderRNN代码如下,请读者详细阅读注释。

Decoder

Decoder也是一个RNN,它每个时刻输出一个词。每个时刻的输入是上一个时刻的隐状态和上一个时刻的输出。一开始的隐状态是Encoder最后时刻的隐状态,输入是特殊的。然后使用RNN计算新的隐状态和输出第一个词,接着用新的隐状态和第一个词计算第二个词,...,直到遇到,结束输出。普通的RNNDecoder的问题是它只依赖与Encoder最后一个时刻的隐状态,虽然理论上这个隐状态(context向量)可以编码输入句子的语义,但是实际会比较困难。因此当输入句子很长的时候,效果会很长。

为了解决这个问题,Bahdanau等人在论文里提出了注意力机制(attentionmechanism),在Decoder进行t时刻计算的时候,除了t-1时刻的隐状态,当前时刻的输入,注意力机制还可以参考Encoder所有时刻的输入。拿机器翻译来说,我们在翻译以句子的第t个词的时候会把注意力机制在某个词上。当然常见的注意力是一种soft的注意力,假设输入有5个词,注意力可能是一个概率,比如(0.6,0.1,0.1,0.1,0.1),表示当前最


转载请注明:http://www.92nongye.com/txjg/txjg/204626938.html

  • 上一篇文章:
  •   
  • 下一篇文章: 没有了