0%

Attention Is All You Need (Transformer) 是当今深度学习初学者必读的一篇论文。但是,这篇工作当时主要是用于解决机器翻译问题,有一定的写作背景,对没有相关背景知识的初学者来说十分难读懂。在这篇文章里,我将先补充背景知识,再清晰地解读一下这篇论文,保证让大多数对深度学习仅有少量基础的读者也能彻底读懂这篇论文。

知识准备

机器翻译,就是将某种语言的一段文字翻译成另一段文字。

由于翻译没有唯一的正确答案,用准确率来衡量一个机器翻译算法并不合适。因此,机器翻译的数据集通常会为每一条输入准备若干个参考输出。统计算法输出和参考输出之间的重复程度,就能评价算法输出的好坏了。这种评价指标叫做BLEU Score。这一指标越高越好。

在深度学习时代早期,人们使用RNN(循环神经网络)来处理机器翻译任务。一段输入先是会被预处理成一个token序列。RNN会对每个token逐个做计算,并维护一个表示整段文字整体信息的状态。根据当前时刻的状态,RNN可以输出当前时刻的一个token。

所谓token,既可以是一个单词、一个汉字,也可能是一个表示空白字符、未知字符、句首字符的特殊字符。

具体来说,在第$t$轮计算中,输入是上一轮的状态$a^{< t - 1 >}$以及这一轮的输入token $x ^{< t >}$,输出这一轮的状态$a^{< t >}$以及这一轮的输出token $y ^{< t >}$。

这种简单的RNN架构仅适用于输入和输出等长的任务。然而,大多数情况下,机器翻译的输出和输入都不是等长的。因此,人们使用了一种新的架构。前半部分的RNN只有输入,后半部分的RNN只有输出(上一轮的输出会当作下一轮的输入以补充信息)。两个部分通过一个状态$a^{< T_x >}$来传递信息。把该状态看成输入信息的一种编码的话,前半部分可以叫做“编码器”,后半部分可以叫做“解码器”。这种架构因而被称为“编码器-解码器”架构。

这种架构存在不足:编码器和解码器之间只通过一个隐状态来传递信息。在处理较长的文章时,这种架构的表现不够理想。为此,有人提出了基于注意力的架构。这种架构依然使用了编码器和解码器,只不过解码器的输入是编码器的状态的加权和,而不再是一个简单的中间状态。每一个输出对每一个输入的权重叫做注意力,注意力的大小取决于输出和输入的相关关系。这种架构优化了编码器和解码器之间的信息交流方式,在处理长文章时更加有效。

尽管注意力模型的表现已经足够优秀,但所有基于RNN的模型都面临着同样一个问题:RNN本轮的输入状态取决于上一轮的输出状态,这使RNN的计算必须串行执行。因此,RNN的训练通常比较缓慢。

在这一背景下,抛弃RNN,只使用注意力机制的Transformer横空出世了。

摘要与引言

补充完了背景知识,文章就读起来比较轻松了。

摘要传递的信息非常简练:

  • 当前最好的架构是基于注意力的”encoder-decoder”架构。这些架构都使用了CNN或RNN。这篇文章提出的Transformer架构仅使用了注意力机制,而无需使用CNN和RNN。

  • 两项机器翻译的实验表明,这种架构不仅精度高,而且训练时间大幅缩短。

摘要并没有解释Transformer的设计动机。让我们在引言中一探究竟。

引言的第一段回顾了RNN架构。以LSTM和GRU为代表的RNN在多项序列任务中取得顶尖的成果。许多研究仍在拓宽循环语言模型和”encoder-decoder”架构的能力边界。

第二段就开始讲RNN的不足了。RNN要维护一个隐状态,该隐状态取决于上一时刻的隐状态。这种内在的串行计算特质阻碍了训练时的并行计算(特别是训练序列较长时,每一个句子占用的存储更多,batch size变小,并行度降低)。有许多研究都在尝试解决这一问题,但是,串行计算的本质是无法改变的。

上一段暗示了Transformer的第一个设计动机:提升训练的并行度。第三段讲了Transformer的另一个设计动机:注意力机制。注意力机制是当时最顶尖的模型中不可或缺的组件。这一机制可以让每对输入输出关联起来,而不用像早期使用一个隐状态传递信息的”encoder-decoder”模型一样,受到序列距离的限制。然而,几乎所有的注意力机制都用在RNN上的。

既然注意力机制能够无视序列的先后顺序,捕捉序列间的关系,为什么不只用这种机制来构造一个适用于并行计算的模型呢?因此,在这篇文章中,作者提出了Transformer架构。这一架构规避了RNN的使用,完全使用注意力机制来捕捉输入输出序列之间的依赖关系。这种架构不仅训练得更快了,表现还更强了。

通过阅读摘要和引言,我们基本理解了Transformer架构的设计动机。作者想克服RNN不能并行的缺点,又想充分利用没有串行限制的注意力机制,于是就提出了一个只有注意力机制的模型。模型训练出来了,结果出乎预料地好,不仅训练速度大幅加快,模型的表现也超过了当时所有其他模型。

接下来,我们可以直接跳到第三章学习Tranformer的结构。

注意力机制

文章在介绍Transformer的架构时,是自顶向下介绍的。但是,一开始我们并不了解Transformer的各个模块,理解整体框架时会有不少的阻碍。因此,我们可以自底向上地来学习Transformer架构。

首先,跳到3.2节,这一节介绍了Transformer里最核心的机制——注意力。在阅读这部分的文字之前,我们先抽象地理解一下注意力机制究竟是在做什么。

注意力计算的一个例子

其实,“注意力”这个名字取得非常不易于理解。这个机制应该叫做“全局信息查询”。做一次“注意力”计算,其实就跟去数据库了做了一次查询一样。假设,我们现在有这样一个以人名为key(键),以年龄为value(值)的数据库:

text
1
2
3
4
5
6
{
张三: 18,
张三: 20,
李四: 22,
张伟: 19
}

现在,我们有一个query(查询),问所有叫“张三”的人的年龄平均值是多少。让我们写程序的话,我们会把字符串“张三”和所有key做比较,找出所有“张三”的value,把这些年龄值相加,取一个平均数。这个平均数是(18+20)/2=19。

但是,很多时候,我们的查询并不是那么明确。比如,我们可能想查询一下所有姓张的人的年龄平均值。这次,我们不是去比较key == 张三,而是比较key[0] == 张。这个平均数应该是(18+20+19)/3=19。

或许,我们的查询会更模糊一点,模糊到无法用简单的判断语句来完成。因此,最通用的方法是,把query和key各建模成一个向量。之后,对query和key之间算一个相似度(比如向量内积),以这个相似度为权重,算value的加权和。这样,不管多么抽象的查询,我们都可以把query, key建模成向量,用向量相似度代替查询的判断语句,用加权和代替直接取值再求平均值。“注意力”,其实指的就是这里的权重。

把这种新方法套入刚刚那个例子里。我们先把所有key建模成向量,可能可以得到这样的一个新数据库:

text
1
2
3
4
5
6
{
[1, 2, 0]: 18, # 张三
[1, 2, 0]: 20, # 张三
[0, 0, 2]: 22, # 李四
[1, 4, 0]: 19 # 张伟
}

假设key[0]==1表示姓张。我们的查询“所有姓张的人的年龄平均值”就可以表示成向量[1, 0, 0]。用这个query和所有key算出的权重是:

1
2
3
4
dot([1, 0, 0], [1, 2, 0]) = 1
dot([1, 0, 0], [1, 2, 0]) = 1
dot([1, 0, 0], [0, 0, 2]) = 0
dot([1, 0, 0], [1, 4, 0]) = 1

之后,我们该用这些权重算平均值了。注意,算平均值时,权重的和应该是1。因此,我们可以用softmax把这些权重归一化一下,再算value的加权和。

text
1
2
softmax([1, 1, 0, 1]) = [1/3, 1/3, 0, 1/3]
dot([1/3, 1/3, 0, 1/3], [18, 20, 22, 19]) = 19

这样,我们就用向量运算代替了判断语句,完成了数据库的全局信息查询。那三个1/3,就是query对每个key的注意力。

Scaled Dot-Product Attention (3.2.1节)

我们刚刚完成的计算差不多就是Transformer里的注意力,这种计算在论文里叫做放缩点乘注意力(Scaled Dot-Product Attention)。它的公式是:

我们先来看看$Q, K, V$在刚刚那个例子里究竟是什么。$K$比较好理解,$K$其实就是key向量的数组,也就是

text
1
K = [[1, 2, 0], [1, 2, 0], [0, 0, 2], [1, 4, 0]] 

同样,$V$就是value向量的数组。而在我们刚刚那个例子里,value都是实数。实数其实也就是可以看成长度为1的向量。因此,那个例子的$V$应该是

text
1
V = [[18], [20], [22], [19]]

在刚刚那个例子里,我们只做了一次查询。因此,准确来说,我们的操作应该写成。

其中,query $q$就是[1, 0, 0]了。

实际上,我们可以一次做多组query。把所有$q$打包成矩阵$Q$,就得到了公式

等等,这个$d_k$是什么意思?$d_k$就是query和key向量的长度。由于query和key要做点乘,这两种向量的长度必须一致。value向量的长度倒是可以不一致,论文里把value向量的长度叫做$d_v$。在我们这个例子里,$d_k=3, d_v=1$。

为什么要用一个和$d_k$成比例的项来放缩$QK^T$呢?这是因为,softmax在绝对值较大的区域梯度较小,梯度下降的速度比较慢。因此,我们要让被softmax的点乘数值尽可能小。而一般在$d_k$较大时,也就是向量较长时,点乘的数值会比较大。除以一个和$d_k$相关的量能够防止点乘的值过大。

刚才也提到,$QK^T$其实是在算query和key的相似度。而算相似度并不只有求点乘这一种方式。另一种常用的注意力函数叫做加性注意力,它用一个单层神经网络来计算两个向量的相似度。相比之下,点乘注意力算起来快一些。出于性能上的考量,论文使用了点乘注意力。

自注意力

自注意力是3.2.3节里提及的内容。我认为,学完注意力的原理后,立刻去学自注意力能够更快地理解注意力机制。当然,论文里并没有对自注意力进行过多的引入,初学者学起来会非常困难。因此,这里我参考《深度学习专项》里的介绍方式,用一个更具体的例子介绍了自注意力。

大致明白了注意力机制其实就是“全局信息查询”,并掌握了注意力的公式后,我们来以Transformer的自注意力为例,进一步理解注意力的意义。

自注意力模块的目的是为每一个输入token生成一个向量表示,该表示不仅能反映token本身的性质,还能反映token在句子里特有的性质。比如翻译“简访问非洲”这句话时,第三个字“问”在中文里有很多个意思,比如询问、慰问等。我们想为它生成一个表示,知道它在句子中的具体意思。而在例句中,“问”字组词组成了“访问”,所以它应该取“询问”这个意思,而不是“慰问”。“询问”就是“问”字在这句话里的表示。

让我们看看自注意力模块具体是怎么生成这种表示的。自注意力模块的输入是3个矩阵$Q, K, V$。准确来说,这些矩阵是向量的数组,也就是每一个token的query, key, value向量构成的数组。自注意力模块会为每一个token输出一个向量表示$A$。$A^{< t >}$是第$t$个token在这句话里的向量表示。

我们先别管token的query, key, value究竟是什么算出来的,后文会对此做解释。

让我们还是以刚刚那个句子“简访问非洲”为例,看一下自注意力是怎么计算的。现在,我们想计算$A^{< 3 >}$。$A^{< 3 >}$表示的是“问”字在句子里的确切含义。为了获取$A^{< 3 >}$,我们可以问这样一个可以用数学表达的问题:“和‘问’字组词的字的词嵌入是什么?”。这个问题就是第三个token的query向量$q^{< 3 >}$。

和“问”字组词的字,很可能是一个动词。恰好,每一个token的key $k^{< t >}$就表示这个token的词性;每一个token的value $v^{< t >}$,就是这个token的嵌入。

这样,我们就可以根据每个字的词性(key),尽量去找动词(和query比较相似的key),求出权重(query和key做点乘再做softmax),对所有value求一个加权平均,就差不多能回答问题$q^{< 3 >}$了。

经计算,$q^{< 3 >}, k^{< 2 >}$可能会比较相关,即这两个向量的内积比较大。因此,最终算出来的$A^{< 3 >}$应该约等于$v^{< 2 >}$,即问题“哪个字和‘问’字组词了?”的答案是第二个字“访”。

这是$A^{< 3 >}$的计算过程。准确来说,$A^{< 3 >}=A(q^{< 3 >}, K, V)$。类似地,$A^{< 1 >}到A^{< 5 >}$都是用这个公式来计算。把所有$A$的计算合起来,把$q$合起来,得到的公式就是注意力的公式。

从上一节中,我们知道了注意力其实就是全局信息查询。而在这一节,我们知道了注意力的一种应用:通过让一句话中的每个单词去向其他单词查询信息,我们能为每一个单词生成一个更有意义的向量表示。

可是,我们还留了一个问题没有解决:每个单词的query, key, value是怎么得来的?这就要看Transformer里的另一种机制了——多头注意力。

多头注意力 (3.2.2节)

在自注意力中,每一个单词的query, key, value应该只和该单词本身有关。因此,这三个向量都应该由单词的词嵌入得到。另外,每个单词的query, key, value不应该是人工指定的,而应该是可学习的。因此,我们可以用可学习的参数来描述从词嵌入到query, key, value的变换过程。综上,自注意力的输入$Q, K, V$应该用下面这个公式计算:

其中,$E$是词嵌入矩阵,也就是每个单词的词嵌入的数组;$W^Q, W^K, W^V$是可学习的参数矩阵。在Transformer中,大部分中间向量的长度都用$d{model}$表示,词嵌入的长度也是$d{model}$。因此,设输入的句子长度为$n$,则$E$的形状是$n \times d{model}$,$W^Q, W^K$的形状是$d{model} \times dk$,$W^V$的形状是$d{model} \times d_v$。

就像卷积层能够用多个卷积核生成多个通道的特征一样,我们也用多组$W^Q, W^K, W^V$生成多组自注意力结果。这样,每个单词的自注意力表示会更丰富一点。这种机制就叫做多头注意力。把多头注意力用在自注意力上的公式为:

Transformer似乎默认所有向量都是行向量,参数矩阵都写成了右乘而不是常见的左乘。

其中,$h$是多头自注意力的“头”数,$W^O$是另一个参数矩阵。多头注意力模块的输入输出向量的长度都是$d{model}$。因此,$W^O$的形状是$hd_v \times d{model}$(自注意力的输出长度是$d_v$,有$h$个输出)。在论文中,Transfomer的默认参数配置如下:

  • $d_{model} = 512$
  • $h = 8$
  • $dk = d_v = d{model}/h = 64$

实际上,多头注意力机制不仅仅可以用在计算自注意力上。推广一下,如果把多头自注意力的输入$E$拆成三个矩阵$Q, K, V$,则多头注意力的公式为:

Transformer 模型架构

看懂了注意力机制,可以回过头阅读3.1节学习Transformer的整体架构了。

论文里的图1是Transformer的架构图。然而,由于我们没读后面的章节,有一些模块还没有见过。因此,我们这轮阅读的时候可以只关注模型主干,搞懂encoder和decoder之间是怎么组织起来的。

我们现在仅知道多头注意力模块的原理,对模型主干中的三个模块还有疑问:

  1. Add & Norm
  2. Feed Forward
  3. 为什么一个多头注意力前面加了Masked

我们来依次看懂这三个模块。

残差连接(3.1节)

Transformer使用了和ResNet类似的残差连接,即设模块本身的映射为$F(x)$,则模块输出为$Normalization(F(x)+x)$。和ResNet不同,Transformer使用的归一化方法是LayerNorm。

另外要注意的是,残差连接有一个要求:输入$x$和输出$F(x)+x$的维度必须等长。在Transformer中,包括所有词嵌入在内的向量长度都是$d_{model}=512$。

前馈网络

架构图中的前馈网络(Feed Forward)其实就是一个全连接网络。具体来说,这个子网络由两个线性层组成,中间用ReLU作为激活函数。

中间的隐藏层的维度数记作$d{ff}$。默认$d{ff}=2048$。

整体架构与掩码多头注意力

现在,我们基本能看懂模型的整体架构了。只有读懂了整个模型的运行原理,我们才能搞懂多头注意力前面的masked是哪来的。

论文第3章开头介绍了模型的运行原理。和多数强力的序列转换模型一样,Transformer使用了encoder-decoder的架构。早期基于RNN的序列转换模型在生成序列时一般会输入前$i$个单词,输出第$i+1$个单词。

而Transformer不同。对于输入序列$(x1, …, x_s)$,它会被编码器编码成中间表示 $\mathbf{z} = (z_1, …, z_s)$。给定$\mathbf{z}$的前提下,解码器输入$(y_1, …, y_t)$,输出$(y_2, …, y{t+1})$的预测。

Transformer 默认会并行地输出结果。而在推理时,序列必须得串行生成。直接调用Transformer的并行输出逻辑会产生非常多的冗余运算量。推理的代码实现可以进行优化。

具体来说,输入序列$x$会经过$N=6$个结构相同的层。每层由多个子层组成。第一个子层是多头注意力层,准确来说,是多头自注意力。这一层可以为每一个输入单词提取出更有意义的表示。之后数据会经过前馈网络子层。最终,输出编码结果$\mathbf{z}$。

得到了$\mathbf{z}$后,要用解码器输出结果了。解码器的输入是当前已经生成的序列,该序列会经过一个掩码(masked)多头自注意力子层。我们先不管这个掩码是什么意思,暂且把它当成普通的多头自注意力层。它的作用和编码器中的一样,用于提取出更有意义的表示。

接下来,数据还会经过一个多头注意力层。这个层比较特别,它的K,V来自$\mathbf{z}$,Q来自上一层的输出。为什么会有这样的设计呢?这种设计来自于早期的注意力模型。如下图所示,在早期的注意力模型中,每一个输出单词都会与每一个输入单词求一个注意力,以找到每一个输出单词最相关的某几个输入单词。用注意力公式来表达的话,Q就是输出单词,K, V就是输入单词。

经过第二个多头注意力层后,和编码器一样,数据会经过一个前馈网络。最终,网络并行输出各个时刻的下一个单词。

这种并行计算有一个要注意的地方。在输出第$t+1$个单词时,模型不应该提前知道$t+1$时刻之后的信息。因此,应该只保留$t$时刻之前的信息,遮住后面的输入。这可以通过添加掩码实现。添加掩码的一个不严谨的示例如下表所示:

输入 输出
(y1, y2, y3, y4) y2
(y1, y2, y3, y4) y3
(y1, y2, y3, y4) y4

这就是为什么解码器的多头自注意力层前面有一个masked。在论文中,mask是通过令注意力公式的softmax的输入为$- \infty$来实现的(softmax的输入为$- \infty$,注意力权重就几乎为0,被遮住的输出也几乎全部为0)。每个mask都是一个上三角矩阵。

嵌入层

看完了Transformer的主干结构,再来看看输入输出做了哪些前后处理。

和其他大多数序列转换任务一样,Transformer主干结构的输入输出都是词嵌入序列。词嵌入,其实就是一个把one-hot向量转换成有意义的向量的转换矩阵。在Transformer中,解码器的嵌入层和输出线性层是共享权重的——输出线性层表示的线性变换是嵌入层的逆变换,其目的是把网络输出的嵌入再转换回one-hot向量。如果某任务的输入和输出是同一种语言,那么编码器的嵌入层和解码器的嵌入层也可以共享权重。

论文中写道:“输入输出的嵌入层和softmax前的线性层共享权重”。这个描述不够清楚。如果输入和输出的不是同一种语言,比如输入中文输出英文,那么共享一个词嵌入是没有意义的。

嵌入矩阵的权重乘了一个$\sqrt{d_{model}}$。

由于模型要预测一个单词,输出的线性层后面还有一个常规的softmax操作。

位置编码

现在,Transformer的结构图还剩一个模块没有读——位置编码。无论是RNN还是CNN,都能自然地利用到序列的先后顺序这一信息。然而,Transformer的主干网络并不能利用到序列顺序信息。因此,Transformer使用了一种叫做“位置编码”的机制,对编码器和解码器的嵌入输入做了一些修改,以向模型提供序列顺序信息。

嵌入层的输出是一个向量数组,即词嵌入向量的序列。设数组的位置叫$pos$,向量的某一维叫$i$。我们为每一个向量里的每一个数添加一个实数编码,这种编码方式要满足以下性质:

  1. 对于同一个$pos$不同的$i$,即对于一个词嵌入向量的不同元素,它们的编码要各不相同。
  2. 对于向量的同一个维度处,不同$pos$的编码不同。且$pos$间要满足相对关系,即$f(pos+1) - f(pos) = f(pos) - f(pos - 1)$。

要满足这两种性质的话,我们可以轻松地设计一种编码函数:

即对于每一个位置$i$,用小数点后的3个十进制数位来表示不同的$pos$。$pos$之间也满足相对关系。

但是,这种编码不利于网络的学习。我们更希望所有编码都差不多大小,且都位于0~1之间。为此,Transformer使用了三角函数作为编码函数。这种位置编码(Positional Encoding, PE)的公式如下。

$i$不同,则三角函数的周期不同。同$pos$不同周期的三角函数值不重复。这满足上面的性质1。另外,根据三角函数的和角公式:

$f(pos + k)$ 是 $f(pos)$ 的一个线性函数,即不同的pos之间有相对关系。这满足性质2。

本文作者也尝试了用可学习的函数作为位置编码函数。实验表明,二者的表现相当。作者还是使用了三角函数作为最终的编码函数,这是因为三角函数能够外推到任意长度的输入序列,而可学习的位置编码只能适应训练时的序列长度。

为什么用自注意力

在论文的第四章,作者用自注意力层对比了循环层和卷积层,探讨了自注意力的一些优点。

自注意力层是一种和循环层和卷积层等效的计算单元。它们的目的都是把一个向量序列映射成另一个向量序列,比如说编码器把$x$映射成中间表示$z$。论文比较了三个指标:每一层的计算复杂度、串行操作的复杂度、最大路径长度。

前两个指标很容易懂,第三个指标最大路径长度需要解释一下。最大路径长度表示数据从某个位置传递到另一个位置的最大长度。比如对边长为n的图像做普通卷积操作,卷积核大小3x3,要做$n/3$次卷积才能把信息从左上角的像素传播到右下角的像素。设卷积核边长为$k$,则最大路径长度$O(n/k)$。如果是空洞卷积的话,像素第一次卷积的感受野是3x3,第二次是5x5,第三次是9x9,以此类推,感受野会指数级增长。这种卷积的最大路径长度是$O(log_k(n))$。

我们可以从这三个指标分别探讨自注意力的好处。首先看序列操作的复杂度。如引言所写,循环层最大的问题是不能并行训练,序列计算复杂度是$O(n)$。而自注意力层和卷积一样可以完全并行。

再看每一层的复杂度。设$n$是序列长度,$d$是词嵌入向量长度。其他架构的复杂度有$d^2$,而自注意力是$d$。一般模型的$d$会大于$n$,自注意力的计算复杂度也会低一些。

最后是最大路径长度。注意力本来就是全局查询操作,可以在$O(1)$的时间里完成所有元素间信息的传递。它的信息传递速度远胜卷积层和循环层。

为了降低每层的计算复杂度,可以改进自注意力层的查询方式,让每个元素查询最近的$r$个元素。本文仅提出了这一想法,并没有做相关实验。

实验与结果

本工作测试了“英语-德语”和“英语-法语”两项翻译任务。使用论文的默认模型配置,在8张P100上只需12小时就能把模型训练完。本工作使用了Adam优化器,并对学习率调度有一定的优化。模型有两种正则化方式:1)每个子层后面有Dropout,丢弃概率0.1;2)标签平滑(Label Smoothing)。Transformer在翻译任务上胜过了所有其他模型,且训练时间大幅缩短。

论文同样展示了不同配置下Transformer的消融实验结果。

实验A表明,计算量不变的前提下,需要谨慎地调节$h$和$d_k, d_v$的比例,太大太小都不好。这些实验也说明,多头注意力比单头是要好的。

实验B表明,$d_k$增加可以提升模型性能。作者认为,这说明计算key, value相关性是比较困难的,如果用更精巧的计算方式来代替点乘,可能可以提升性能。

实验C, D表明,大模型是更优的,且dropout是必要的。

如正文所写,实验E探究了可学习的位置编码。可学习的位置编码的效果和三角函数几乎一致。

总结

为了改进RNN不可并行的问题,这篇工作提出了Transformer这一仅由注意力机制构成的模型。Transformer的效果非常出色,不仅训练速度快了,还在两项翻译任务上胜过其他模型。

作者也很期待Transformer在其他任务上的应用。对于序列长度比较大的任务,如图像、音频、视频,可能要使用文中提到的只关注局部的注意力机制。由于序列输出时仍然避免不了串行,作者也在探究如何减少序列输出的串行度。

现在来看,Transformer是近年来最有影响力的深度学习模型之一。它先是在NLP中发扬光大,再逐渐扩散到了CV等领域。文中的一些预测也成为了现实,现在很多论文都在讨论如何在图像中使用注意力,以及如何使用带限制的注意力以降低长序列导致的计算性能问题。

我认为,对于深度学习的初学者,不管是研究什么领域,都应该仔细学习Transformer。在学Transformer之前,最好先了解一下RNN和经典的encoder-decoder架构,再学习注意力模型。有了这些基础,读Transformer论文就会顺利很多。读论文时,最重要的是看懂注意力公式的原理,再看懂自注意力和多头注意力,最后看一看位置编码。其他一些和机器翻译任务相关的设计可以不用那么关注。

总算,我们把《深度学习专项》的最后一门课《序列模型》学完了。这门课主要讨论了和序列数据相关的深度学习方法,介绍了以RNN为代表的经典序列模型和Transformer自注意力模型这两大类模型。同时,这门课还介绍了一些常见自然语言处理问题的基础知识和建模方法。让我们回顾一下RNN、Transformer、NLP这三个方面的知识。

所有知识按技能树的形式组织,学完了上一层的才能学下一层的。部分知识是派生出来的,学会与否不影响后续知识的学习,这些知识的前置条件会特别标出。

RNN

0. RNN 处理的数据种类:序列数据

常见的序列数据有:语音、文字、DNA、视频。

1.1 RNN 的结构

使用这种结构的原因:如果用全连接网络,既无法处理任意长度的句子,又不能共享每一处数据的知识。而 RNN 既能用同样的方法处理每一处数据,又能用一个隐变量表示上下文信息以描述序列关系。

1.2 RNN 的梯度问题

RNN 的梯度可能爆炸也可能消失。梯度爆炸可以通过设置一个梯度最大值来解决。而梯度消失要靠结构改进解决。

下面是 PyTorch 的梯度裁剪函数的使用方法:

1
2
3
4
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

1.3 多层 RNN

竖着堆叠 RNN。由于 RNN 计算较慢,一般叠 3 层就算很多了。

想要进一步提高网络的拟合能力,可以在输出模块里多加一些非时序的神经网络。

GRU (需学习 RNN)

序列一长,靠前的数据的梯度就容易消失。GRU 解决了这一问题。

GRU 改进了普通的 RNN,每次更新隐变量时会使用一个0~1之间的更新比例,防止之前的信息被过快地忘记。

LSTM (需学习 RNN, GRU)

LSTM 的作用和 GRU 类似,效果更好,但更费时。

双向 RNN (需学习 RNN)

等价于正着跑一遍 RNN,倒着跑一遍 RNN,输出结果由前向和后向的两个隐变量决定。

碰到一个新的序列任务时,双向 LSTM 依然是首选。

2. 序列问题的 RNN 建模方法

RNN 只提供了N对N的序列问题的解决方法。对于其他输入输出格式的问题,需要进行巧妙的建模,才能使用 RNN 来解决问题。

其中,最重要的是不等长多对多数据的建模方法。前半段只输入不输出的部分叫做编码器,后半段只输出不输入的部分叫做解码器。这种“编码器-解码器”架构是一种经典的 RNN 架构。

RNN 可以轻松地完成序列生成任务:输出下一个单词的 softmax 的预测结果,挑选概率较高的一个作为下一次的输入,循环执行。找到总体较好的一个输出的算法叫做 Beam Search。

3. 注意力模型

一种加强版 RNN 架构。它依然是一种“编码器-解码器”架构。编码器是双向 RNN,解码器是单向 RNN。每个输出的 RNN 对每个输入都有一个权重,该权重叫做注意力。每个输出从每个输入接收的数据是一个加权和。

Transformer

Transformer 是一类重要的模型。学习此模型之前最好学完 RNN。

Transformer 从多个角度改进了原序列模型。学习 Transformer 时,主要关注以下几方面的知识:

  1. 注意力和多头注意力
  2. 输出是怎样并行计算的
  3. 位置编码

1. 注意力

注意力的本质是全局信息查询。给出问题 Q,去现有的数据库 (K, V) 里查出一个 V 的加权和,权重由 Q, K 的相似度决定。

2.1 自注意力

自注意力是注意力的一个应用。当 Q, K, V 都是同一个张量时,注意力计算起到是特征提取的作用。相对于 CNN,自注意力更能提取全局信息。

2.2 输出对输入的注意力

注意力机制在早期的注意力模型中是用来计算每个输出对每个输入的权重的。Q 来自输出,K, V 来自输入。

2.3 多头注意力

多头注意力是普通注意力计算的一个加强版。首先,它引入了可学习参数,能够让注意力模块学习要提取哪些特征;其次,它可以像卷积层一样提取多个特征。

3. 并行解码

对于输入序列$(x1, …, x_s)$,它会被编码器编码成中间表示 $\mathbf{z} = (z_1, …, z_s)$。给定$\mathbf{z}$的前提下,解码器输入$(y_1, …, y_t)$,输出$(y_2, …, y{t+1})$的预测。

为了模拟解码器的串行执行,其第一个多头注意力有掩码机制,盖住了此时刻之后的数据。

4. 位置编码

由于 Transformer 没有利用到数据的先后顺序这一信息,包含位置信息的位置编码会作为额外输入加到输入的张量上。

NLP 知识

为了更好地介绍 RNN,《深度学习专项》补充了很多 NLP知识。

词汇表

为了表示单词,要实现准备一个包含所有要用到的单词的词汇表。每个单词用一个 one-hot 向量表示。若一个单词在词汇表里的序号是 i,则其 one-hot 向量的第 i 个元素为1。

词嵌入 (需学习 RNN 结构、RNN 建模方法)

one-hot 编码是没有任何含义的。词嵌入是每个单词的一种有意义的表示,向量间的差能够表示语义上的差距。

词嵌入要在某些任务中学习得到。词嵌入的学习方法有:语言模型、Word2Vec、负采样、GloVe。

学习好了词嵌入后,词嵌入应该放在其他任务的神经网络的第一层,作为输入数据的一种预处理方法。

BLEU Score

BLEU Score 是为了评价机器翻译而提出的指标。如果模型输出的翻译和参考译文有越多相同的单词、连续2个相同单词、连续3个相同单词……,则得分越高。

总结

《深度学习专项》的第五门课主要介绍了这三个方面的知识。如果你以后不从事 NLP 的研究,可以略过 NLP 的知识。而 RNN 和 Transformer 的知识是必须要学的。学 RNN 时,主要关注各个问题是怎么用 RNN 建模的;而学 Transformer 时,要更加关注模型的结构和原理。

深度学习的技术日新月异。上完了课后,最好去深入阅读一下经典论文,再去跟进最新的论文,尤其是 Transformer 这种较新的领域。

在看这篇总结时,如果你看到了不懂的地方,欢迎回顾我之前的笔记。

《卷积神经网络》总结

前几周,我们学完了《深度学习专项》第四门课《卷积神经网络》。这门课的知识点太多了,想详细地整理出来并不容易。我打算较为宏观地概括一下这门课的主要内容。

《卷积神经网络》先从细节入手,介绍了卷积神经网络(CNN)的基础构件与运算方式。之后,这门课展示了一些图像分类中的经典网络,通过示例介绍了构筑CNN的一般方法。最后,这门课拓展介绍了CNN在目标检测、图像分割、人类识别、风格迁移等具体任务中的应用。在学习时,我们应该着重关注前两部分的内容,应用部分以了解为主。

卷积层

  • 相比全连接层的优势:参数量变少、平移不变性
  • 卷积的运算方法(灰度图、多通道图)
  • 卷积的参数(填充、步幅)
  • 卷积层的运算方法(如何加上bias并送入激活函数)

CNN

  • 三大构件:卷积层、池化层、全连接层
  • 构建方式:卷积接池化,最后全连接
  • 图像尺寸变小,通道数变大

经典网络(LeNet, AlexNet, VGG)

  • 卷积接池化,最后全连接;图像尺寸变小,通道数变大
  • 平均池化进化成最大池化
  • tanh进化到ReLU
  • 逐渐使用更简单的3x3卷积,2x2池化

ResNet

  • 待解决的问题:网络深度增加而性能退化
  • 解决问题的方法:学习一个靠近全等映射的残差
  • 2层残差块的结构、计算公式
  • 常用的ResNet的结构(ResNet-18, ResNet-50)

一般的网络只要学习其有关知识就行了。而ResNet不同,它已经被使用得太广泛了,必须要认真学习一下它的结构。

Inception

  • 1x1卷积的用法
  • 如果利用1x1卷积降低运算量(bottleneck)
  • Inception模块

MobileNet

  • 逐深度可分卷积
  • MobileNetv2降低运算量的bottleneck结构

CNN实现使用技巧

  • 使用开源代码
  • 利用迁移学习(获取预训练模型,在数据集上微调)
  • 数据增强
  • 打比赛时,用多个模型的平均输出提高精度

目标检测

  • 任务定义:从目标定位到目标检测
  • 从滑动窗口算法到基于卷积的滑动窗口算法
  • YOLO
  • NMS
  • 区域提案(region proposal)

语义分割

  • 任务定义
  • 反卷积
  • U-Net架构

人脸识别

  • 任务定义:人脸验证与人脸识别
  • 单样本学习
  • 孪生网络
  • 三元组误差

神经网络风格迁移

  • CNN的“可视化”
  • 任务定义
  • 内容误差与风格误差

入门基于深度学习的计算机视觉相关研究,一定要把图像分类的经典网络学扎实。这些网络是其他任务的基石,英文用backbone来称呼它们。学会了通用的知识后,再去学习某任务特有的知识。

《序列模型》预览

深度学习的第五门课——也是最后一门课——的标题是《序列模型》。更确切地来说,这门课主要围绕人工智能中另一大领域——自然语言处理(NLP),介绍如何用RNN, Transformer等模型处理以序列形式输入输出的数据。

这门课的主要内容有:

RNN

  • 序列任务的输入输出形式
  • RNN的计算方式
  • GRU
  • LSTM
  • 双向RNN
  • 深层RNN

词嵌入

  • 问题定义
  • Word2Vec
  • 负采样
  • GloVe

RNN的应用

  • 语言模型
  • 机器翻译
  • Beam Search
  • Bleu Score

注意力

  • 注意力模型
  • 自注意力
  • Transformer

不同于CNN,序列模型能够用另一种方式对问题建模,尤其擅长于处理和序列数据相关的问题。如果你的研究方向是CV,也不要错过这一门课的学习。很多序列模型都在CV中得到了应用。比如光流模型RAFT就使用到了GRU,很多主流CV backbone都使用到了transformer。

未来的几周我也会继续分享这方面的笔记。不过,我的研究方向不是NLP,代码项目会稍微潦草一些。我打算只用PyTorch复现课堂上讲过的一些简单项目。

大家或许在课本或故事里接触过“熵”这个概念:“熵表示混乱程度。熵只会越变越多,熵增会让宇宙最终走向灭亡”。“熵”也常常出现在机器学习的概念中,比如分类任务会使用到一种叫做“交叉熵”的公式。

那么,“熵”究竟是什么呢?熵的数学单位和意义并没有生活中的其他事物那么直观。我们可以说一元一元的钱、一吨一吨的铁、一升一升的水,却很难说出一单位的熵表示什么。在这篇文章里,我会从非常易懂的例子中引入这个概念,并介绍和熵相关的交叉熵相对熵(KL散度) 等概念,使大家一次性理解熵的原理。

熵的提出——尽可能节省电报费

假设现在是一个世纪以前,人们用电报来传输信息。为了刊登天气预报,报社每天会从气象中心接收电报,获知第二天的天气。这样的电报应该怎么写呢?

我们先假设明天的天气只有“好”和“坏”两种情况。好天气,就是晴天或多云;坏天气,就是刮风或下雨。这样,电报就可以写成“明天天气很好”或者“明天天气不好”。

这种电报的描述确实十分清楚。但是,电报是要收费的——电报里的字越多,花费的钱越多。为了节约成本,我们可以只在电报里写一个字。天气好,就发一个“好”;天气差,就发一个“坏”。由于报社和气象中心已经商量好了,这一条电报线只用来发送明天的天气这一种信息,所以“好”和“坏”只是指代明天的天气,不会有任何歧义。通过这种方式,发电报的成本大大降低了。

但是,这种方式还不是最省钱的。通讯其实使用的是二进制编码。比如如果用两个二进制位,可以表示00, 01, 10, 11这$2^2$种不同的信息。而汉字有上万个,为了区分每一个汉字,一般会使用16个二进制位,以表示$2^{16}=65536$个不同的汉字。要传输一个汉字,就要传输16个0或1的数字。而要表示明天的天气,最节省的情况下,只要一个二进制位就好了——0表示天气不好,1表示天气很好。

这样,我们得出了结论:最节省的情况下,用1位二进制数表示明天的天气就行了。1位二进制数有单位,叫做比特(bit)。因此,我们还可以说:明天天气的熵是1比特。没错,的意思就是最少花几比特的二进制数去传递一种信息。发明出“熵”的人,很可能正在思考怎么用更短的编码去节省一点电报费。

熵与平均编码长度

明天的天气只有两种可能:好、坏。这一情况太简单了,以至于我们可以脱口而出:这个问题的熵是1。

可当问题更复杂、更贴近实际时,熵的计算就没那么简单了。明天的天气,可能有4种情况:

这个问题的熵是多少呢?

刚刚我们也看到了,1个二进制数表示2种情况,2个二进制数表示4种情况。现在有4种情况,只要两位编码就行了:

因此,这个问题的熵是2。

但有人可能对这种编码方式感到不满:“这种编码太乱了,每个二进制位都没有意义。我有一种更好的编码方式。”

确实如此。要表示四种情况,不是非得用2位的编码,而可以用1-3位的编码分别表示不同的信息。可是,这两种方案哪个更节省一些呢?我们不能轻易做出判断。假如大部分时候都是晴天,那么用第二种方案的话很多时候只需要1位编码就行了;而假如大部分时候都是下雨或下雪,则用第一种方案会更好一些。为了判断哪种方案更好,我们还缺了一个重要的信息——每种天气的出现概率。

假如我们知道了每种天气的出现概率,就可以算出某种编码的平均编码长度,进而选择一种更优的编码。让我们看一个例子。假设四种天气的出现概率是80%/10%/5%/5%,则两种编码的平均长度为:

用每种天气的出现概率,乘上每种天气的编码的长度,求和,就可以算出该编码下表示一种天气的平均长度。从结果来看,第二种编码更好一些。事实上,第二种编码是这种概率分布下最短的编码。

只有四种天气,我们还可以通过尝试与猜测,找出最好的编码。可是,如果天气再多一点,编码方式就更复杂了。想找出最好的编码就很困难了。有没有一种能够快速知道最优平均编码长度的方式呢?

让我们从简单的例子出发,一点一点找出规律。假如四种天气的出现概率都是25%,那问题就很简单了,直接令每种天气的编码都是2位就行了。由于每种天气的出现概率都相等,让哪种天气的编码长一点短一点都是不合理的。类似地,假如有八种天气,每种天气的概率都是12.5%,那么应该让每种天气都用3位的编码。

这里的“2位”、“3位”是怎么算出来的呢?很简单,对总的天气数取对数即可。有4种天气,就是$log_24=2$;有8种天气,就是$log_28=3$。

现在,让问题再稍微复杂一点。假如还是晴天、刮风、下雨、下雪这四种天气,每种天气的概率都是25%。现在,报社觉得区分下雨和下雪太麻烦了,想把下雨或下雪当成一种出现概率为50%的天气来看待。这种情况下的最优编码是什么呢?

原来下雨的编码是10,下雪的编码是11。既然它们合并了,把编码也合并一下就行。10和11,共同点是第一位都是1。因此,合并后的编码就是1。这样,我们可以得到新问题下的编码方案。刚刚的四种天气均分的编码方案是最优的,那么这种把两种编码合并后的方案也是最优的。

晴天和刮风的编码长度是2。这个很好理解。25%是4种天气平均分的概率,因此编码长度是$log_24=2$。下雨/下雪的编码长度是1,这个又是怎么得到的呢?50%,可以认为是2种天气平均分的概率,这时的编码长度是$log_22=1$。

由于一般我们只知道每种天气的概率,而不太好算出每种天气被平均分成了几份。因此,我们可以把上面两个对数运算用概率来表达。

更一般地,假设某种天气的出现概率是$P$。我们可以根据规律猜测,当这种天气的编码长度是$-log_2P$时,整个编码方案是最优的。事实上,这个猜测是正确的。

我们来算一下最优编码方案下的平均编码长度,看看每次接收天气预报的电报平均下来要花几个比特。假设第$i$种天气的出现概率是$P(i)$,那么最优平均编码长度就是:

这就是我们经常见到的熵的计算公式了。没错,上一节我们对于熵的定义并不是那么准确。熵其实表示的是理想情况下,某个信息系统的最短平均编码长度

在这个公式里,由于被累加的情况是离散的,我们使用了求和符号。对于连续的情况,要把求和变成求积分。

这个定义有几个要注意的地方。首先,熵是针对某个信息系统的平均情况而言的。这说明,用熵来描述的信息必须要能够分成许多种,就像很多种天气一样。同时,每种具体的信息都得有一个出现概率。我们不是希望让某一次传输信息的编码长度最短,而是希望在大量实验的情况下,令平均编码长度最短。

另外,为什么要说“理想情况下”呢?试想一下,假如有6种天气,每种天气的出现概率相等。这样,每种天气都应该用$log_26$位编码来表示。可是,$log_26$并不是一个整数。实际情况中,我们只能用2~3位编码来表示6种天气。由于编码长度必须是整数,这种最优的编码长度只是说理论上成立,可能实际上并不能实现。

求解最优整数编码的算法叫做“哈夫曼编码”,这是一个经典的算法题。感兴趣的话可以查阅有关资料。

推而广之,熵除了指编码长度这种比较具体的事物以外,还可以表示一些其他的量。这个时候,“编码长度”就不一定是一个整数了,算出来的熵也就更加有实际意义。因此,与其用“无序度”、“信息量”这些笼统的词汇来概括熵,我们可以用一些更具体的话来描述熵:

对于一个信息系统,如果它的熵很低,就说明奇奇怪怪的信息较少,用少量词汇就可以概括;如果它的熵很高,就说它包括了各式各样的信息,需要用更精确的词汇来表达。

最后,再对熵的公式做一个补充。一般情况下,算熵时,对数的底不是2,而是自然常数e。由换底公式

可知,对数用不同的底只是差了一个常数系数而已,使用什么数为底并不影响其相对大小。在使用熵时,我们也只关心多个熵的相对大小,而不怎么会关注绝对数值。

以2为底时,熵的单位是比特。以e为底时,熵的单位是奈特(nat)。

用交叉熵算其他方案的平均编码长度

理解了熵的概念后,像交叉熵这种衍生概念就非常好理解了。在学习所有和熵有关的概念时,都可以把问题转换成“怎么用最节省的编码方式来描述天气”。

假设电报员正在和气象中心商量天气的编码方式。可是,这个电报员刚来这个城市不久,不知道这里是晴天比较多,还是下雨比较多。于是,对于晴天、刮风、下雨、下雪这四种天气,他采用了最朴素的平均法,让每种天气的编码都占2位。

大概100天后,电报员统计出了每种天气的出现频率。他猛然发现,这个城市大部分时间都在下雨。如果让下雨的编码只占1位,会节省得多。于是,他连忙修改了编码的方式。

这时,他开始后悔了:“要是早点用新的编码就好了。两种方案的平均编码长度差了多少呢?”假设各种天气的出现概率如下,我们可以计算出新旧方案的平均编码长度。

就像计算熵一样,我们来用公式表示一下这个不那么好的平均编码长度。之前,电报员为什么会都用2位来表示每一种天气?这是因为,他估计每种天气的出现概率都是25%。也就是说,在算某一个实际概率分布为$P$的信息系统时,我们或许用了概率分布$Q$去估计$P$。在刚刚那个问题里,$Q$就表示所有天气的出现概率相等,$P$就是雨天较多的那个实际概率分布。这样,较差的平均编码长度计算如下:

这是不是和熵的公式

很像?没错,这就是交叉熵的公式。交叉熵表示当你不知道某个系统的概率分布时,用一个估计的概率分布去编码得到的平均编码长度。$-log_2Q(i)$表示的是每一条信息的编码长度,由于编码长度是由我们自己决定的,只能用估计的分布来算,所以它里面用的是$Q$这个分布。而算期望时,得用真实的概率分布$P$。因此,这个式子外面乘上的是$P(i)$。

交叉熵有一个很重要的性质:交叉熵一定不大于熵。熵是最优的编码长度,你估计出来的编码方案,一定不会比最优的更好。所有交叉熵的应用基本上都是利用了这一性质。

在机器学习中,我们会为分类任务使用交叉熵作为损失函数。这正是利用了交叉熵不比熵更大这一性质。让我们以猫狗分类为例看看这是怎么回事。

在猫狗分类中,我们会给每张图片标一个one-hot编码。如果图片里是猫,编码就是[1, 0];如果是狗,编码就是[0, 1]。one-hot编码,其实就是一个概率分布,只不过某种事件的出现概率是100%而已。可以轻松地算出,one-hot编码的熵是0。

机器学习模型做分类任务,其实就是在估计的one-hot编码表示的概率分布。模型输出的概率分布就是交叉熵公式里的$Q$,实际的概率分布,也就是one-hot编码,就是交叉熵公式里的$P$。由于熵是0,交叉熵的值一定大于等于0。因此,交叉熵的值可以表示它和熵——最优的概率分布的信息量——之间的差距。

用KL散度算方案亏了多少

用交叉熵算出旧方案的平均编码长度后,电报员打算统计一下旧编码浪费了多少编码量。既然熵是最优编码的编码长度,那么交叉熵减去熵就能反映出旧编码平均浪费了多少编码长度。这个计算过程如下:

可以总结出,如果我们还是用分布$Q$去估计分布的$P$的话,则这其中损失的编码量可以用下面的公式直接计算。

这个公式描述的量叫做相对熵,又叫做KL散度。KL散度的定义非常易懂,它只不过是交叉熵和熵的差而已,反映了一个分布与另一个分布的差异程度。最理想情况下,$P=Q$,则KL散度为0。

当然,KL散度不是一个距离指标。从公式中能够看出,$D{KL}(P||Q)\neq D{KL}(Q||P)$,这个指标并不满足交换律。

KL散度常用来描述正态分布之间的差异。比如VAE(变分自编码器)中,就用了KL散度来比较估计的分布和标准正态分布。计算正态分布间的KL散度时,我们不用从头推导,可以直接去套一个公式。

设一维正态分布$P, Q$的公式如下:

则KL散度为:

在看计算机方向的论文时,如果碰到了KL散度,我们不需要深究其背后的数学公式,只需要理解KL散度的原理,知道它是用来做什么的即可。

总结

在各个学科中,都能见到“熵”的身影。其实,大部分和熵有关的量都是在最基本的熵公式之上的拓展。只要理解了熵最核心的意义,理解其他和熵相关的量会非常轻松。希望大家能够通过这篇文章里的例子,直观地理解熵的意义。

参考资料

我的这篇文章基本照抄了三篇介绍熵的文章。这三篇文章写得非常易懂,强烈推荐阅读。

Entropy Demystified

Cross-Entropy Demystified

KL Divergence Demystified

有关KL散度的推导可以参见这篇博文:

KL散度(Kullback-Leibler Divergence)介绍及详细公式推导

我在今年6月15日开通了美股账户。而到了9月21日,我的总收益率达到了100.27%。

第一个月的时候,我还是以投资为主,小赚。第二个月,我开始做短线投机,第一周的亏损超过了30%。但当我冷静下来,“开启认真模式”之后,我在整整两个月的时间里获得了超过150%的收益。

看到这个收益率,不同的人可能会有不同的评论。

大部分人会说:银行定期存款的年化收益也就3-5%,你的收益率这么高,太厉害了。

有些对金融新闻比较感兴趣的人会说:你这很一般啊。我见过炒期货的人,几万做到了几千万啊。还有炒币的,几天就翻几十倍。你这也就是普通水平。

金融知识扎实的人会说:你的收益曲线看上去很好看,但是你的回撤太大,收益不稳定。

具有怀疑精神的人会说:你这个从来没有学过金融的人,刚入市没多久,凭什么能赚这么多钱?是不是在P图?

不管别人怎么说,我来客观评价一下自己的战绩:我的收益率已经足够可观了。如果这个收益率真的能保持下去,几年后我就是世界首富了。当然,我现在使用了低杠杆,风险适中,回撤偶尔会比较大。如果我真的更激进一点,像炒币的那些人一样追求短期翻几十倍,那么也很容易在一天内资金归零。没有金融知识,没学多久就开始稳定赚钱,是因为在炒股经验的积累上,我一天抵别人一百天。为什么这么说呢?因为我天赋异禀。

不管怎么说,我认为我已经掌握了一些投机交易的技巧。在赚到一些钱后,我才发现,市面上大多数介绍投资、交易的文章都是在胡说,甚至是别有用心地在损人利己。我很看不起这些人。在这篇文章里,我想分享一下自己对于投机交易本质的思考,而不是信誓旦旦地说“我这样做一定能赚钱”、“你要跟着我这样做,你要买我的产品”、“价值投资没用/短线投机没用”。

当然,我写这篇文章最主要的目的还是炫耀一下自己的成就。有了成就不去炫耀,那可太亏了,哈哈哈。

投机——期望为正的赌博

在正式介绍交易赚钱的原理之前,我先规范地描述一下交易的游戏规则。为了方便描述,所有的交易产品都将以股票为例。当然,期货、外汇、加密货币等其他金融衍生品的原理是一模一样的。

投机,即利用股票不同时期的差价来赚钱。既可以买入股票,在股价上涨后卖出获利,也可以向他人借取股票,提前卖出,等股价下跌后买回更便宜的股票获利。前者称为“做多”,后者称为“做空”。也就是说,不管股票是涨是跌,只要有价格波动,就有赚钱的空间。

很多炒过股的人会说,炒股就是赌博。在我看来,这句话说不对也不对,说对也对。

有人把炒股称为赌博,是因为他们觉得炒股虽然有几率赚大钱,但是这个几率很低。长期来看,收益的期望一定是负数。这些特征与赌博相符。这个看法肯定不是完全正确的。相比胜负完全随机的赌博,股票的价格变化显然有一定的规律。

说炒股是赌博,其实是从游戏规则的角度来看的。赌博是一局一局进行的,有明确的开始与结束。而股票交易不同,买入股票后,你可以一直持有。只要你不卖出,不管股票跌了多少,都是浮亏,你都可以说自己没有真正亏钱。当然,抱有这种心态的话,是很难挣钱的。人们反倒更希望把股票交易转换成一局一局的赌博游戏。这样,就可以用数学工具分析每局赌博的收益期望,实现稳定的盈利。

这样来想的话,我们可以把投机转换成以下这种可以重复进行的赌博游戏:根据某些利多或者利空信号,在某一时刻开始对股票做多或者做空,满足结束条件后立刻平仓离场,结算盈亏。这个定义有两大重点:

  1. 信号,指的是你发现股价大概率会往某个方向走;或者股价一旦往某个方向走,会出现剧烈的变化。信号的存在,保证收益的期望是正数。
  2. 结束条件的存在,意味着交易可以被分成相互独立的一局一局赌博。

再具体来看一下结束条件的概念。最常见的结束条件是时限。比如说,昨天股市大跌。根据这一信号,我猜测今天股市也要跌下去。所以,我在今天开盘的时候做空,接近收盘的时候平仓。

当然,只有时限是不太充分的。可能这一天确实会出现下跌,但尾盘的时候价格又抬了上去。所以,我可以多加一些结束条件:如果这个股票今天跌了2%,我就平仓止盈。这样,不管后面再发生什么变化,我都稳稳地赚到了钱。

这样做还是有一点不充分:万一今天股市大涨了怎么办?为了避免亏损,我可以再设置一个结束条件:如果这个股票涨了2%,我就承认我猜错了,立刻止损。

这样下来,这局交易就可以被描述成这样的赌博游戏:今天开盘时做空某只股票,在下跌2%或者上涨2%的时候平仓,或者接近收盘的时候平仓。如果我们的信号是正确的,从统计上来看股票在这种情况下大概率会下跌,那么我们持续地做这种交易,一定可以赚到钱。

很多人在“投资”的时候——不管是持有股票还是基金——都会亏钱,大概率是因为没有建立这种“赌博”的意识。他们不知道为什么要买这个股票,也不知道什么时候该离场了。他们买入股票的信号,仅仅是因为别人的建议:“我看好这个股票”、“我这样做挣钱了”、“长期投资总没错”。但是,别人不会告诉你他这么做的原因。如果不明确自己为何而建仓,为何而平仓,那么你的收益只能由市场被动地决定,而无法用自己的主观判断提升收益。对于有主观判断的人来说,所有的亏损都是自己的信号看错了,而市场永远不会有错。

投机的时效性与周期

上一节讲到了投机的基本模型。为了在这个模型下尽可能盈利,有一个概念是必须要了解的——信号的时效性。

我们在上一节定义过一局交易,它有三个结束条件:

  1. 当天之内必须平仓。
  2. 赚2%止盈。
  3. 亏2%止损。

然而,这些条件都不是绝对正确的。万一今天没赚多少,明天的走势却能让你赚大钱呢?万一今天股价往你的方向变动了不止2%,而是变动了10%呢?万一今天股市先让你亏了2%,后来又能让你赚2%呢?

因此,所有结束条件都不是绝对正确的,都有一定概率会降低你的收益。我认为,只有唯一一个结束条件是绝对正确的:

当你觉得自己的信号失效后,结束交易。

仔细想一下,你为什么会做这一轮交易?不就是你找到了股价会往某个方向运动的原因吗?在这个原因消失前,不管是赚是亏,你都不应该平仓;在这个原因消失时,不管是赚是亏,你都应该立刻平仓。

所有常见的结束条件,都只是这一原则的延申。比如说止损,止损的本质原因是股价的运动和你期望的大相径庭,你认为自己的信号看错了,所以止损;再比如止盈,止盈是因为按照你的信号,股价就应该变动这么多,之后是不是会继续变动,你判断不了。

从这个角度来考虑,做投机的人不应该关注自己是赚钱还是亏钱,而应该只关注自己得出来的信号。一方面,要提高信号的准确度;另一方面,要时刻监控自己的信号,在发现信号失效的瞬间停止交易。做到前者并不难,对股价变动稍有观察的人都能总结出一些信号。难的是后者,如何公正而勇敢地宣布自己的信号失效。

因此,在投机时,最重要的判断信号何时结束。说是要“监控”信号,其实并不是说真的去监控,去时时刻刻看着股价的变化。事实上,每个信号都有时效性。在一轮交易开始前,你是可以大致判断出这个信号的有效期的。在有效期尚未结束时,不用过多地关注股价变动。我们来看几个例子。

这一天,纳斯达克指数一直在下跌,已经持续几个小时了。中途股价虽然有几波反弹,但依然没有改变下跌的趋势。突然,股价猛地反弹,下跌趋势也稳住了。

这个时候,可以猜测股价不会再跌下去,而是会有所反弹。如果股价再继续跌下去,就说明猜错了,信号失效;如果股价有所反弹,就说明猜对了,赚到了钱,信号也失效了。

这轮交易的逻辑非常简单:股价不可能一直跌下去,总会发生一点变动。判断信号失效的方法也很简单,只要价格变动过大,就能说明判断是对还是错,信号也随之失效了。

这一轮,交易的原因是价格在短期内不会一直往一个方向变动。这个“短期”,指的是几个小时。那么,价格会出现反弹的时间,也差不多就在一两个小时以内。过了这段时间,这个信号一定就失效了。这是一个时效性较短的信号。

再来看一个时效性中等的信号。今年,由于美国改变了货币政策,美股一直在下跌。如果你交易的依据是货币政策会压低股价,那么你在这段时间里随时都可以去做空纳斯达克指数。只要美国的货币政策不变,不管中途出现了几波反弹,不管是赚是亏,都不应该结束交易。

最后,从长期来看,美股是上涨的。如果你觉得美股具有投资价值,最终一定会涨起来,那么你随时都可以去做多纳斯达克指数。只要美国的经济地位不变,你就可以放心地把钱投资进去。

从这些例子可以看出,在同一个时间点,你不管是做多美股还是做空美股,都可以是有理由的。做多和做空,赚钱和亏钱,其实都没有对错。有对错的,只有自己的信号。问题的关键,在于如何选择正确率更高的信号,以及注意信号的时效性。

上面三个例子其实也对应了常见的三种交易周期,它们所需的信号各不相同。

在周期为几分钟、几小时的短线交易中,股票的基本价值是不会发生变化的。这时,股价总会发生有规律的变动。所有信号都是由过往的价格变化趋势产生的。

在周期为几天、几周的中线交易中,股票的基本价值会随着新闻、经济政策而发生改变,只看过往的价格变化是不够的。这时,可以多关注新闻、经济评论,捕捉投资者的情绪,并结合近期的价格变化做出预测。

而在周期更长的长线交易中,任何短期的波动都显得微不足道了。这个时候,公司的本身价值才能决定股票的长期走势。买入某公司股票的信号,可能仅仅是你在某一天发现了这个股票很有投资价值而已,而与其近期走势毫无关系。

总结一下,每轮交易的信号是有时效性的。信号的依据不同,其有效周期也不同。仅在信号失效时结束交易。

散户战胜富豪的秘诀:高流动性、短周期

知道了交易的信号存在时效性还不够,如何选择周期恰当的信号依然是一个难题。在介绍如何选择与发现信号之前,我先来讲另一件事——为什么很多散户的收益率能超过巴菲特?

秉承价格投资理念的人,会对短期内获取暴利的技术分析者大加批判:“巴菲特的年化收益才是20%,你凭什么能赚那么多钱?你这些靠技术分析得来的钱迟早要亏掉的。”

这一判断在逻辑上是有问题的,因为它暗含了一个错误的前提:散户和巴菲特只有交易水平上的差距,其他条件一概相同。

的确,个人交易者与专业的投资机构在实力上有着不可逾越的鸿沟。在机构里,有经验丰富的经理,有无数金融高材生提供市场分析,有无数数学系和计算机系的博士提供数学模型。你一个人,凭什么比他们这个团队还要厉害?

但是,仅从短期收益率这个角度来看,确实有散户能战胜专业机构。根据田忌赛马的原理,整体实力落后的人获胜,说明实力落后的人在某些方面超过了实力较强的人。而在投机中,散户相较机构有着一个巨大的优势:资金量较少。这一个优势就足以覆盖其他所有的劣势。

资金量大起来之后,很多短线的交易方式就失效了。比如你觉得这个股票最近跌了很多,明天很可能会反弹。于是,你用你的大资金去买这个股票。结果,恭喜你,股价确实反弹了一阵子。只不过,股价是被你的买入带起来的。你的持有成本特别高,根本没有买在低位。过了一会儿,股价又跌回去了。

因为这些原因,大资金只能去做长线交易。他们无法应对短期内的价格波动,甚至难以应对很明确的会令股价下跌的经济政策。比长期投资,个人是绝对比不过机构的;但是,短期内的价格波动,正是散户收割富豪财富的良机。

回到之前那个问题,如何选择周期恰当的交易。我的回答很明确:避开长期交易,利用流动性,做周期较短的交易。长线交易是用来让大资金稳定获利的,它的难度更高,且收益率较低,市场不好的时候不可避免地要面对收益回撤。

当然,周期特别短也是不行的。一分钟以内基本是量化交易的天下。许多毫秒级的信号是十分明确的,这时要比拼的是反应速度。显然,人是比不过低延迟的电脑命令的。

我认为,一分钟到两周的周期都是可行的,哪怕缺乏金融知识的人也能通过不断学习在这些周期的交易中获利。注意,越是短期的交易,越依赖短期的价格信号,也越容易碰到毫无理由的价格波动。超短线潜在的利润多,交易操作难度也大。很多人为了放大超短线的收益,会加大杠杆。但正是超短线交易中不可避免的价格波动,会让人瞬间坠入深渊。

对于初学者,我建议去做一日到一周的交易。这种交易周期参考的信号既包括短期的价格趋势,又包含新闻、政策。当所有信号都指向同一个方向时,就可以大胆出手交易了,基本就可以稳定地赚到一小笔钱。

来看一个例子。9月13号这一天,美股已经陷入熊市大半年了,三周前又一次进入了下跌趋势,前三个交易日超涨了,盘前出现了大利空新闻。在所有这些信号的加持下,根本找不出股市不大跌的理由。只要在开盘时做空纳斯达克指数,尾盘时平仓,就能稳定赚一笔钱。

到目前为止,我已经揭示了没有金融基础的散户在市场投机赚钱的本质方法。其流程总结如下:

原理:把投机交易当成一局一局赌博。根据成功率高的信号入场,在信号失效时立场。尽可能去选择周期较短而稳定的信号。

操作方法:1) 观察股票价格的变化,总结规律,尝试根据过往价格变化和新闻预测未来的价格变化,得出自己能把握的信号。2)严格执行交易纪律,仅在信号失效时离场(包括设置止损止盈降低风险)。不优化收益率,只优化信号的准确性。

赚钱的秘诀,其实就这么简单,几句话就可以概括了。很多文章会讲一些更具体的操作策略,比如看到什么指标超过什么值就买入之类的。这些策略肯定有用,但它们其实属于信号的一种,不能保证一定赚钱。归根结底,学具体的策略,不如学会最本质的思考方法。如果搞懂了投机的原理,你就能自己不断地发现信号,不断优化自己的策略,而不仅仅是人云亦云,把成败归咎于外界。

新手入市指南

讲完了原理,我来具体讲一下新手应该如何从零开始学习交易。

目标

不是每一个人都适合通过交易赚钱。我不建议大家以立刻赚到一笔钱为交易的目标,而是以学习,以拓宽视野为目标。等过了一两个月,你可以去审视一下自己,看看自己是不是适合交易。如果你交易时患得患失,倍感压力,那就不用做下去了。如果你不管赚了还是亏了都很开心,你感觉自己总能学到东西,你更在乎判断的准确性而不在乎收益,那就可以做下去。

资金

建议把两到三个月的工资投入交易。这笔钱必须是生活用不到的闲钱。只要你把钱放进去了,就当作是花光了这笔钱,哪怕亏完了也不要有压力。

选择两三个月的工资是有原因的。如果亏了钱,你可以说:“反正过一个月就赚回来了”;如果赚了钱,你可以说:“我赚到的钱能丰富一下月收入了。”人改变不了随机事件的结果,但能够改变自己对事件结果的看法。如果能永远保持乐观的心态,不管你怎么亏钱,你都是不亏的。

选择交易品种

建议选择一个T+0交易、交易量大、能够做多做空的品种。比如期货、外汇、美股指数。

T+0和做多做空是交易的标准配置。没了它们,操作空间会小很多。

我其实不太懂股票交易,也不懂国内A股的规律。A股不能做空,操作空间太小,我不太愿意去学习。我在A股亏掉的钱估计一辈子也赚不回来了。

交易量大,其实意味着交易品不容易受到小部分人的操控,价格更容易按照规律运动。

我做的是纳斯达克指数,我认为它是最简单的交易品了。首先,纳斯达克指数是世界上交易最活跃的品种之一,难以受到少数人的干扰。其次,指数比股票更容易掌控,因为股票不仅受股市整体影响,还要受股票自身影响,要考虑的因素更多。而纳斯达克指数只需要关注美国经济新闻即可,其他的品种除了要关注其本身的新闻外,也需要关注美国经济新闻。最后,很多专业的经济学家会对美国股市的走势做出预测,你哪怕什么都不懂,跟着多数经济学家预测的去做就行了。

另外,学习使用低倍杠杆是有必要的。比如美股允许券商提供至多2倍杠杆,这个幅度绝对不会让你瞬间爆仓,还能有效放大收益。

可以在网上多搜索一点信息,以确认交易品种。开户的流程也可以在网上搜到。

学习与交易

开户后,就可以交易了。按照前文的描述,学会观测信号,正确地执行交易即可。一开始最好是做模拟交易,或者自己看几天盘,尝试预测价格变化。当然,做模拟盘和做实盘还是有区别的。不是实实在在的赚钱和亏钱,你的操作方法肯定会更乱来。

交易结束后,一定要复盘。复盘主要是针对亏损的交易,分析错误的原因,要知道错误是信号看错了,还是交易执行得不好。交易中,有非常多要注意的细节。如果你的复盘是有效的,你每犯一次错,就能学到一项细节,从而不会再次犯错。

在交易中,你的资产会不断地发生变化。而资产是和生活挂钩的,所以资产的变动会对你的心态产生很大的影响。心态好,并不能帮你挣钱,而只能保证你按照计划赚到该赚的钱;而心态不好,则会让你亏掉超出计划外的钱。交易不能像其他事一样马马虎虎,要完全地用理性的眼光去看待。任何不理性的想法都会招致亏损。学习交易时,学习控制自己的心态也是很重要的。

结语

在这篇文章中,我分享了自己在这个几个月交易中领悟到的精华思想。我相信,根据这篇文章的指引,用正确的态度去接触交易,一定能够从中有所收获。金钱上的收获倒另说,从中学到的道理是能够受用终生的。希望大家能够开辟出除了加班工作外另一种人生的可能性。

谨记,赚钱绝对不是唯一的目的。如果想着暴富,想着要定目标赚多少钱,大亏后非得立刻赚回来,那肯定赚不到钱。如果把投机当成电子游戏,每次去最优化自己的操作,去学习,去享受快乐,而不只是在乎金钱,那反而能赚到钱。这些话看上去很矛盾,却富有哲理。如果你某一天凭借着自己的经历得出了和我一样的结论,你就知道我说的都是对的了。

我留了不少复盘记录。之后我会不定期发布自己做每笔交易的心路历程,详细描述我是怎样一步一步学习的。

如果你觉得这篇文章讲得很有道理,欢迎转发,把好的想法传播给更多的人。

在最后一课中,我们要学习Transformer模型。Transformer是深度学习发展史上的一次重大突破,它在多个领域中取得了傲人的成绩。Transformer最早用于解决NLP任务,它在CV任务上的潜力也在近几年里被挖掘出来。

RNN有一个致命的缺陷:计算必须按照时序执行,无法并行。为了改进这一点,Transformer借用了CNN的想法,并行地用注意力机制处理所有输入,抛弃了经典RNN的组件。

为了理解Transformer,我们将主要学习两个概念:自注意力和多头注意力。

  • 自注意力:Transformer会为序列里的每一个元素用注意力生成一个新的表示,就和CNN里卷积层能为每个像素生成高维特征向量一样。这个表示和词嵌入不同,词嵌入只能表示一个单词本身的意义,而「自注意力」生成的表示是和句子里其他单词相关的。
  • 多头注意力:「多头注意力」表示多次利用自注意力机制,生成多个表示,就和CNN里N个卷积核能生成N个特征一样。

这节课是《深度学习专项》新增的课程,内容比较简短。估计是因为Transformer太火了,不得不在教材里加上这些新内容。这节课讲得并不是很清楚,我会用更易懂的逻辑把这节课讲一遍。

Transformer

自注意力

词嵌入只能反映一个词本身的意思,而不能反映一个词在句子中的意思。比如我们要把”简访问非洲“翻译成英文,其中第三个字“问”有很多意思,比如询问、慰问等。自注意力的目的就是为每个词生成一个新的表示$A$,反映它在句子中的意思,比如我们为“问”字生成的表示$A^{< 3 >}$是“访问”这个具体的意思。

自注意力,顾名思义,就是对句子自己使用注意力机制。我们先回忆一下最初的注意力机制。

在翻译句子时,我们要算每个输出单词对每个输入单词的注意力$\alpha$,以这个注意力为权重,我们可以算所有输入状态$a$的加权平均数$c$(公式1)。这个$c$用于输出每一个单词。

注意力$\alpha$是权重$e$的归一化结果(如公式2所示,因为是求平均数,得保证权重$\alpha$的和为1),这里的$e^{< t, t’ >}$表示第$t’$个输入和第$t$个输出的相关量。由于$e^{< t, t’ >}$是用于计算$s^{< t >}$的,$s^{< t >}$还获取不了,我们只能用$s^{< t - 1 >}$来表示第$t$个输出。另外,我们用$a^{< t’ >}$表示第$t’$个输入。这样,$e^{< t, t’ >}$就应该由$s^{< t - 1 >}$和$a^{< t’ >}$决定。

Transformer用一种通用的公式表示了这种注意力的计算。

我们先不管这里的$q, K, V$是什么意思,暂且把它们当成标记。用它来表示最初的注意力机制的话,$q$就相当于输出状态$s$,$k$就相当于输入状态$a$,二者的相关关系$e$就是两个向量的内积$qk$。$v$也是输入状态$a$。

现在,我们来看看如何用这个公式计算自注意力。我们将以第三个输入$x ^ {< 3 >}$的注意力表示$A^{< 3 >}$为例。

假设我们为每个输入单词都已经维护好了3个变量$q^{< t >}, k^{< t >}, v^{< t >}$。q, k, v是英文query, key, value的缩写,这一概念来自于数据库。假如数据库里存了学生的年龄,第一条记录的key-value是("张三", 18),第二条记录的key-value是("李四", 19)。现在,有一条query,询问"张三"的年龄。我们把这一条query和所有key比对,发现第一条记录是我们需要的。因此,我们取出第一个value,即18

每个单词的$q, k, v$也可以有类似的解释。比如第三个字“问”的query $q^{< 3 >}$是:哪个字和“问”字组词了?当然,在这个句子里,我们人类可以很轻松地知道答案,“问”和“访”组成了“访问”这个词。每个字的key可以认为是字的固有属性,比如是名词还是动词。每个字的value就可以认为是这个字的词嵌入。

让计算机去查询$q^{< 3 >}$的话,我们要用这个query去句子的其他字里查出我们要的答案。和数据库的查询一样,我们也要把这个query和所有字的key进行比对。根据注意力的公式,我们用$q^{< 3 >}$和所有$k$相乘再做softmax,得到注意力权重。再用这个权重乘上每个$v$,加起来,得到所有$v$的加权平均数。既然是查询“问”字和谁组词了,那么这个要找的字肯定是一个动词。因此,计算机会发现$q^{< 3 >}, k^{< 2 >}$比较相关,即这两个向量的内积比较大。一切顺利的话,这个$A^{< 3 >}$应该和$v^{< 2 >}$很接近,即问题“哪个字和‘问’字组词了?”的答案是第二个字“访”。

这是$A^{< 3 >}$的计算过程。准确来说,$A^{< 3 >}=A(q^{< 3 >}, K, V)$。类似地,$A^{< 1 >}-A^{< 5 >}$都是用这个公式来计算。把所有$A$的计算合起来,把$q$合起来,得到的就是下面这个公式。

其中,$d_k$是一个常量,$\sqrt{d_k}$这一项是用来防止点乘结果的数值过大的。它属于实现细节,对整个式子的意义不影响。抛去这一项的话,上式不过是之前那个公式的矩阵版本。这个公式在原论文里叫做Scaled Dot-Product Attention。

现在,我们已经理解了注意力的公式。自注意力,其实就是令$Q=K=V=E$,对$E$这个向量自己做注意力。但是,我们还有一个重要的问题没有解决:$Q, K, V$在Transformer里是怎么获得的?别急,后几节我们会把所有的概念串起来,学习Transformer的结构。现在,让我们再看一看Transformer里的另一个概念——多头注意力。

多头注意力

在CNN中,我们会在一个卷积层里使用多个卷积核,以提取出不同的特征。比如第一个卷积核提取出图像水平方向的边缘,第二个卷积核提取出图像垂直方向的边缘。类似地,一次自注意力也只能得到部分的信息,我们可以多次使用自注意力得到多个信息。

上一节的$A^{< 3 >}$是问题“哪个字和‘问’字组词了?”的答案。我们可以多问几个问题,得到有关“问”字的更多信息,比如“‘问’的主语是谁?”、“‘问’的宾语是谁?”。为了描述这些不同的问题,我们要准备一些可学习的矩阵$W^Q_i, W^K_i, W^V_i$。$Attention(W^Q_1Q, W^K_1K, W^V_1V)$就是第一组注意力结果,$Attention(W^Q_2Q, W^K_2K, W^V_2V)$就是第二组注意力结果,以此类推。这种机制叫做多头注意力。

每次Attention的结果是一个向量,所有Attention的结果concat起来就是多头注意力的结果。

Transformer 网络结构

搞懂了自注意力、多头注意力这两个核心机制后,我们就能够看懂Transformer的整体结构了。

让我们看看Transformer是怎么翻译一句话的。给定一个单词序列(为了方便,我们认为输入句子已经被token化,且输入的是每个token的嵌入),我们要先用encoder提取特征,再用decoder逐个输出翻译出来的token。

在encoder中,输入会经过一个多头注意力层。这一层的Q, K, V都是输入的词嵌入。多头注意力层会接到一个前馈神经网络上,这个网络就是一个简单的全连接网络。

别忘了,多头注意力层的可学习参数是$W^Q_i, W^K_i, W^V_i$,Q, K, V可以是预训练好的词嵌入。

再看decoder。先看一下decoder的输出流程。第一轮,decoder的输入是<SOS>,输出Jane;第二轮,decoder的输入是<SOS> Jane,输出是visits。也就是说,decoder的输入是现有的翻译序列,输出是下一个翻译出来的单词。

在decoder中,输入同样要经过一个多头注意力层。这一层的Q, K, V全是输入的词嵌入。之后,数据会经过另一个多头注意力层,它的Q是上一层的输出,K, V来自于Encoder。为什么这一层有这样的设计呢?大家不妨翻回到前几节,回顾一下经典注意力模型中注意力是怎么计算的。其实,经典注意力模型就是以decoder的隐状态为Q,以encoder的隐状态为K, V的注意力。Transformer不过是用一套全新的公式把之前的机制搬过来了而已。做完这次多头注意力后,数据也是会经过一个全连接网络,输出结果。

encoder和decoder的大模块一共重复了N次,原论文中N=6。

总结一下注意力在Transformer里的使用。多头注意力其实有两个版本:第一个版本是自注意力,用于进一步提取输入的特征;第二个版本和经典注意力模型一样,把输出序列和输入序列关联了起来。

刚才我们学习的是Transformer的主要结构。实际上,Transformer的结构里还有很多细节。

  1. Positional Encoding (位置编码): 和RNN不同,Transformer的多头注意力无法区分每一个输入的顺序。为了告诉模型每一个token的先后顺序,词嵌入在输入模型之前还要加上一个Positional Encoding。这个编码会给每一个词嵌入的每一维加上一个三角函数值。三角函数之间的差具有周期性,模型能够从这个值里认识到输入序列的顺序信息。

我会在之后的文章详细介绍位置编码,这里只需要明白位置编码的意义即可。

  1. Add & Norm 层:参考深度CNN的结构,Transformer给每个模块的输出都做了一次归一化,并且使用了残差连接。

  2. 输出前的线性层和Softmax:为了输出一个单词,我们肯定要做一个softmax。这些层是输出单词的常规操作,和RNN结构里的相同。

最后,在训练Tranformer时,还有一个重要的模块:Masked Multi-head Attention。刚刚我们学到,在输出翻译的句子时,我们要输出一个单词,更新一次decoder的输入;输出一个单词,更新一次decoder的输入。然而,在训练时,我们实际上已经有了正确的输出。因此,我们可以同时给decoder看前1个单词,看前2个单词……,并行地训练所有的这些情况。为了只让decoder看到前几个单词,我们可以给输入加一个mask,把后面那些不应该看到的单词“蒙上”。

Masked Multi-head Attention 其实只是一个实现上的小技巧,不能算作一个新模块。具体的实现方式其实有很多,我们只要重点理解这一设计的原因即可。总之,这种设计让Transformer能够并行地训练翻译进度不同的句子。因此,Transfomer的训练效率会比RNN快很多。

总结

这节课我们主要学习了Transformer模型。在学习时,我们主要应该关注两个核心机制:自注意力、多头注意力。搞懂Transformer的注意力机制后,我们基本就理解了Transformer的原理了。剩下的一些细节知识可以去看原论文,这些细节不干扰论文主体思想的理解。

《深度学习专项》的课就到此结束了。不过,之后我还会围绕这门课发两篇文章。第一篇文章会详细地解读“Attention Is All You Need”这篇论文,第二篇文章会详细介绍Transformer的复现过程,作为这节课的大作业。

在前两周的课中,我们分别学习了RNN的基础结构、词嵌入的知识。学完这些东西后,我们已经能够开发大多数NLP应用了。

正如我们第一周所学,序列任务的本质区别是输入和输出的映射关系不同。我们曾经把任务按映射关系分成一对一、一对多、多对一、等长多对多、不等长多对多。其中,不等长的多对多任务是最常见、最通用的。这周,我们就要以机器翻译任务为例,学习解决多对多问题的更多技巧。同时,我们也会学习机器翻译任务中比较出名的一种模型架构——注意力模型。

学完了机器翻译任务的一些常见架构后,我们还会把这些方法拓展到语音识别问题上,了解一些语音识别中的常见任务。

序列到序列模型

基础模型

有一些问题,它们的输入和输出都是一个序列。比如说机器翻译,输入是某种语言的一句话,输出是另一种语言的一句话。这种序列到序列的问题可以简单地套用RNN解决。

如我们第一周所学,不等长的序列到序列问题可以用如下的RNN模型解决。前面只有输入的部分叫做编码器(encoder),后面输出的部分叫做解码器(decoder)。

再举另一个任务——看图说话的例子。看图说话,就是输入一幅图片,输出该图片的文字描述。比如给定下图的图片,我们可以说“小猫在椅子上”。这个任务的输入看上去并不是一个序列,但我们可以用某种CNN架构把图片转换成一个向量。这个向量就可以看成图片的编码,和刚刚那个RNN编码器的输出一样。利用这个编码,我们可以用RNN解码器生成一句话。

挑选最好的句子

但是,这种基础的架构类似于我们第一周学的语言模型。它可能生成多个新句子,而不能保证生成最好的句子。为了按要求生成最好的句子,我们要使用一些其他的方法。

比如把“Jane九月要访问非洲”翻译成英文,可以翻译成”Jane is visiting Africa in September.”,也可以翻译成”Jane is going to be visiting Africa in September.”。上节提到的那个类似于语言模型的RNN架构可以生成出这两个句子中的任何一个。

从语言的角度,”Jane is visiting Africa in September.”这个翻译比”Jane is going to be visiting Africa in September.”更好。或者说,前面那个句子的概率更高一点。用数学公式来表达,给定被翻译的句子$x$, 我们希望求一个使$P(y^{< 1 >}, y^{< 2 >}, …y^{< T_y >},|x)$最大的序列$y$。

求解这个最优化问题时,我们可能会想到贪心算法。由于RNN解码器每步的softmax可以输出下一个词的概率(还是和语言模型一样的道理),我们可以求出$argmax{y^{< 1 >}}P(y^{< 1 >}|x)$, $argmax{y^{< 2 >}}P(y^{< 2 >}|y^{< 1 >}, x)$, …,即用贪心算法每次求出一个概率最大的单词。

然而,每次选一个概率最大的单词,不能保证整句话概率最大。比如,模型可能有”Jane is visiting Africa in September.”和”Jane is going to be visiting Africa in September.”这两个潜在的候选翻译结果。选第三个单词时,”going”的概率可能比”visiting”要高,按贪心算法,我们最后会生成出第二个句子。可是,从翻译质量来看,第一个句子显然更好,它的概率更高。

为了求出概率最大的输出序列,我们要使用一种较好的搜索算法。

贪心算法每次只求出能令当前句子概率最大的下一个单词。这种算法太容易遗漏更优的输出了。而如果真的想求出最优的句子,即求出$argmax_{y}P(y|x)$,需要遍历所有可能的$y$。假如每个单词有$N$种选择,句子长度$T_y$,则搜索算法的复杂度是$O(N^{T_y})$。这个指数增长的复杂度是不能接受的。

Beam Search是这样一种折中的启发式搜算算法。它不能保证求出最优解,却能比贪心算法找出更多更优的解。

Beam Search的核心思想可以用一句话概括:相比于只维护一个概率最优句子的贪心算法,Beam Search每次维护$B$个概率最优的句子。

还是拿开始那句话的翻译为例,并假设$B=3$,词汇表大小为10000。生成第一个单词时,概率最高的三个单词可能是in, Jane, September。生成第二个单词时,我们要遍历第一个单词是in, Jane, September时的所有30000种两个单词组合的可能。最终,我们可能发现in September, Jane is, Jane visits这三个句子的概率最高。依次类推,我们继续遍历下去,直到生成句子里的所有单词。

这个算法用伪代码表示如下:

这份伪代码不是用来是解读的,而是用来对齐想法的。看懂了算法的流程后,读者应该能够用自己的伪代码把这个算法写出来。如果读者自己能写出伪代码,就也能顺着思路读懂我的这份伪代码。

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
Input x, B

# encoder
a = 0
for i in range(tx):
a, _ = RNN(embed(x[i]), a)
# decoder
# step 1
a, p = RNN(0, a)
a_arr = [a] * B
words, prob = get_max_words_from_softmax(p, B)
sentences = copy(words)
# step 2 - ty
for i in range(ty - 1):
all_words = []
all_prob = []
all_a = []
for j in range(B):
new_a, p = RNN(embed(words[j]), a_arr[j])
tmp_words, tmp_prob = get_max_words_from_softmax(p, B)
tmp_a = [new_a] * B
# Accmulative multiply the probablity
for k in range(B):
tmp_prob[k] *= prob[j]
all_words += tmp_words
all_parob += tmp_prob
all_a += tmp_a
words, prob, a_arr = get_max_B(all_words, all_prob, all_a)

# Cancatenate output
for j in range(B):
sentences[j].append(words[B])

y = get_max_sentences(sentences[j], prob)
Output y

Beam Search 还有一些优化的手段。

在Beam Search中,我们用了语言模型中求句子的概率的方法,即:

这个概率是我们的优化目标。但是,使用累乘会碰到计算机硬件精度不够的问题。为此,我们可以把优化目标由累乘取log变成累加:

此外,大多情况下,输出的句子越短,句子可能越不准确。因此,我们可以给优化目标添加一个和长度有关的归一化项:

这里的$\alpha(\alpha< 1)$是一个超参数,它用于让长度惩罚更平滑一点,即模型更容易生成长句子。一般$\alpha=0.7$。

为了生成不同长度的句子,在让decoder输出句子时,我们可以记录下$T_y=1, 2…$时概率最大的句子。得到了这些不同长度的候选句子后,把它们的概率乘上归一化项,找出整体概率最大的句子。

Beam Search是一种启发式算法,它的效果取决于超参数B。一般情况下,为了保证速度,B取10就挺不错了。只有在某些不考虑速度的应用或研究中才会令B=100或更高。

加入Beam Search会让我们调试机器翻译算法时更加困难。还是对于开始那个翻译示例,假如人类给出了翻译$y^{\ast}$:Jane visits Africa in September,算法给出了翻译$\hat{y}$:Jane visited Africa last September。这个算法的翻译不够好,我们想利用第三门课学的错误分析方法来分析错误的来源。这究竟是Beam Search出了问题,还是RNN神经网络出了问题呢?

对此,我们可以把训练好的RNN当成一个语言模型,输入$y^{\ast}$和$\hat{y}$,求出这两个句子的概率。如果$y^{\ast} \gt \hat{y}$,那说明RNN的判断是准确的,是Beam Search漏搜了;如果$y^{\ast} \leq \hat{y}$,那说明RNN判断得不准,是RNN出了问题。

Bleu Score

在图像分类中,我们可以用识别准确率来轻松地评价一个模型。但是,在机器翻译任务中,最优的翻译可能不只一个。比如把“小猫在垫子上”翻译成英文,既可以说”The cat is on the mat”,也可以说”There is a cat on the mat”。我们不太好去评价每句话的翻译质量。Bleu Score就是一种衡量翻译质量的指标。

为了评价一句话的翻译质量,我们还是需要专业翻译者给出的参考翻译。比如把刚刚那两句英文翻译作为参考译句:

  • The cat is on the mat
  • There is a cat on the mat

我们可以把机器的翻译结果和这两句参考结果做对比。对比的第一想法是看看机器翻译的句子里的单词有多少个在参考句子里出现过。但是,这种比较方法有问题。假如机器输出了”the the the the the the the”,the在参考句子里出现过,输出的7个单词全部都出现过。因此,翻译准确率是$\frac{7}{7}$。这显然不是一个好的评价指标。

一种更公平的比较方法是,每个单词重复计分的次数是原句中该单词出现的最大次数。比如,the在第一个参考句子里出现2次,在第二个参考句子里出现1次。因此,我们认为the最多计分两次。这样,这句话的翻译准确率就是$\frac{2}{7}$。这个评价结果好多了。

我们不仅可以对单个单词计分,还可以对相邻两个单词构成的单词对计分。比如机器翻译输出了句子The cat the cat on the cat。我们统计每一个词对在输出里出现的次数和计分的次数。

count score
the cat 2 1
cat the 1 0
cat on 1 1
on the 1 1
the mat 1 1

这样,这个句子的准确率是$\frac{4}{6}$。

这种打分方式就叫做bleu score。刚刚我们只讨论了考虑一个单词、两个单词时的打分结果。实际上,我们可以用$p_n$表示连续考虑$n$个单词的bleu score。最终评价一个翻译出来的句子时,我们会考虑到$n$取不同值的情况,比如考虑$n=1, 2, 3, 4$的情况。最终使用的这种指标叫做组合bleu score,它的计算公式为:

其中,BP的全称是brevity penalty(简短惩罚)。这是一个系数,用于防止输出的句子过短。它的计算公式是:

其中,$L_o$是输出句子的长度,$L_r$是参考句子的长度。

bleu score是一个能十分合理地评价机器翻译句子的指标。在评估机器翻译模型时,我们只要使用这一种指标就行了,这符合我们在第三门课中学习的单一优化目标原则。bleu score最早是在机器翻译任务中提出的,后续很多和句子生成相关的任务都使用了此评估指标。

注意力模型

我们刚刚学习的这种“编码器-解码器”架构的RNN确实能在机器翻译上取得不错的效果。但是,这种架构存在一定的限制:模型的编码(输入)和解码(输出)这两步都是一步完成的,模型一次性输入所有的句子,一次性输出所有的句子。这种做法在句子较短的时候还比较可行,但输入句子较长时,模型就“记不住”之前的信息了。而我们这一节学习的注意力模型能够很好地处理任意长度的句子。

让我们看看人类在翻译长句的时候是怎么做的。比如把“简访问非洲”翻译成”Jane visits Africa”时,我们一般不会把整句话一次性翻译,而是会对单词(或文字)逐个翻译。我们会把“简”翻译成”Jane”,“访问”翻译成”visits”,“非洲”翻译成”Africa”。在输出每一个单词时,我们往往只需要关心输入里的某几个单词就行了,而不需要关注所有单词。

注意力模型就使用了类似的原理。在注意力模型中,我们先把输入喂给一个BRNN(双向RNN)。这个BRNN不用来输出句子,而是用于提取每一个输入单词的特征。我们会用另一个单向RNN来输出句子。每一个输出单词的RNN会去查看输入特征,看看它需要“关注”哪些输入。比如,”Jane”的RNN会关注“简”的特征,”visits”的RNN会关注“访”和“问”的特征。这一过程如下图所示(线条表示输出对输入的关注,线条越粗关注度越高)。

这样,不管输入的序列有多长,每一个输出都能找到它需要关注的部分单词,仅根据这些输入来完成翻译,就和我们人类的做法一样。这就是注意力模型的思想。

让我们看一下具体的计算过程。为了区分上下两个RNN,我们用$a$表示编码RNN的状态,$t’$表示输入序号;$s$表示解码RNN的状态,$t$表示输出序号。刚才提到的那种关注每个输入单词的注意力机制会给每个输出一个上下文向量$c^{< t >}$。这个向量和上一轮输出$\hat{y}^{< t-1 >}$拼在一起作为这轮解码RNN的输入。

注意,从逻辑上来讲,解码RNN有两个输入。第一个输入和我们之前见过的解码RNN一样,是上一轮的输出$\hat{y}^{< t-1 >}$。第二个输入是注意力上下文$c^{< t >}$。这两个输入通过拼接(concatenate)的方式一起输入解码RNN。我在学到这里的时候一直很疑惑,两个输入该怎么输入进RNN。原视频并没有强调两个输入是拼接在一起的。

如果输出的单词不是很依赖于上一个单词,解码RNN也可以不输入上一个单词,只输入注意力上下文。这门课的编程作业就采用了这种只有一个输入的更为简单的结构。

接下来,我们来详细看看注意力机制是怎么工作的。如前文所述,注意力机制就是要算一个对每个输入的关注度,根据这个关注度以不同的权重去输入里取值。这个过程的表示如下。

设输入状态$a^{< t’ >}=(\overrightarrow{a}^{< t’ >}, \overleftarrow{a}^{< t’ >})$(把BRNN前后的状态拼接到一起)。假如我们得到了权重$\alpha^{< t, t’ >}$,它表示第$t$个输出对第$t’$个输入的关注度,则注意力上下文$c^{< t >}$的计算方法为:

对于每一个$t$, 所有$t’$的$\alpha^{< t, t’ >}$和为1。也就是说,上式其实就是一个加权平均数,其中$\alpha^{< t, t’ >}$是权重。“注意力”这个名词看上去很高端,其实就是一个中学生都会的概念而已。

现在,我们还不知道关注度$\alpha^{< t, t’ >}$是怎么算的。让我们思考一下,第$t$个输出单词和第$t’$个输入单词的关注度取决于谁的信息呢?答案很简单,取决于第$t$个输出单词的信息和第$t’$个输入单词的信息。第$t$个输出单词的信息,可以用其上一层的状态$s^{< t-1 >}$表达;第$t’$个输入单词的信息,可以用$a^{< t’ >}$表达。怎么用它们算一个关注度出来呢?谁也给不出一个具体的公式,干脆就用一个神经网络来拟合就好了。

具体的计算过程如下图所示:

我们用一个小全连接网络算出一个输出$e^{< t, t’ >}$。由于$\alpha^{< t, t’ >}$最后的和要为1,我们用对$e^{< t, t’ >}$做一个softmax,得到归一化的$\alpha^{< t, t’ >}$。

整理一下,注意力模型的计算步骤如下:

  1. 用一个编码RNN(比如Bi-LSTM)算出输入的特征$a^{< t’ >}$。
  2. 用一个解码RNN(比如LSTM)的状态$s^{< t-1 >}$和所有$a^{< t’ >}$算一个对每个输入的关注度$\alpha^{< t, t’ >}$ 。
  3. 以关注度为权重,以$a^{< t’ >}$为值,算一个加权平均数$c^{< t >}$作为第$t$个输出的注意力上下文。
  4. 以上一轮输出$y^{< t-1 >}$和$c^{< t >}$为输入,以$s^{< t-1 >}$为上一轮状态,计算解码RNN这一轮的输出$y^{< t >}$。

注意力模型的效果不错,但它的计算复杂度是平方级别的(输入长度乘输出长度)。不过,机器翻译任务的输入输出都不会太大,这一性能弊端没有那么明显。

语音任务

语音识别

到目前为止,我们主要用序列模型完成NLP任务。其实序列模型也很适合用在语音数据上。让我们来快速认识一下语音识别任务的解决方法。

语音数据是一维数据,表示每一时刻的声音强度。而我们人脑在在接受声音时,会自动对声音处理,感知到声音的音调(频率)和响度。

语音识别任务的输入是语音数据,输出是一个句子。我们可以直接用注意力模型解决这个问题(令输出元素为字母而不是单词),也可以用一种叫做CTC(connectionist temporal classification)的算法解决。

CTC算法用于把语音识别的输入输出长度对齐。这样,我们用一个简单的等长多对多RNN就可以了。在语音识别中,输入的长度远大于输出的长度,我们可以想办法扩大输出长度。这种扩充方式如下:

比如,对于句子”the quick brown fox”,我们可以把”the q”它扩充成ttt_h_eee____< space >____qqq__。这个和输入等长的序列表示每一个时刻发音者正在说哪个字母。序列中有一些特殊标记,下划线表示没有识别出任何东西,空格< space >表示英语里的空格。

通过这种方法,我们可以把所有训练数据的标签预处理成和输入等长的序列,进而用普通RNN解决语音识别问题。

触发词检测

触发词检测也是一类常见的语音问题,我们可以用序列模型轻松解决它。

触发词检测在生活中比较常见,比如苹果设备的”Hey Siri”就可以唤醒苹果语音助手。我们的任务,就是给定一段语音序列,输出何时有人说出了某个触发词。

我们可以用一张图快速地学会如何解决这个问题。如下图所示,对于每一个输入,我们可以构造一个等长的输出,表示每一时刻是否说完了触发词。每当一个触发词说完,我们就往它后面几个时刻标上1。用一个普通RNN就可以解决这个问题了。

从这两个示例中,我们能看出,学会了序列模型后,我们掌握了很多武器。而在解决实际问题时,关键在于如何建模,把问题的输入输出对应到RNN的输入输出上,把我们的武器用出去。

总结

这堂课以机器翻译任务为例,介绍了序列到序列问题的一些解决方法。特别地,这堂课介绍了注意力模型。注意力模型在序列到序列问题上有着极佳的表现,并催生了后续纯粹由注意力机制构成的更加强大的Transformer模型。利用这些学到的知识,我们可以轻松地解决大多数序列到序列问题,比如和语音相关的语音识别与触发词检测问题。

这堂课的知识点有:

  • Beam Search
    • 序列到序列问题的建模方法:解码器与生成器,生成一个概率尽可能大的输出序列
    • 为什么需要搜索算法,为什么贪心算法不好
    • Beam Search 的过程
  • Bleu Score 的计算思想:公平地根据参考序列评价生成序列的质量
  • 注意力模型
    • 新的编码器-解码器架构
    • 注意力机制的动机
    • 解码器怎么利用注意力权重
    • 注意力权重怎么生成
  • 语音问题
    • 建模思想:对齐输入输出序列,用简单的RNN解决问题
    • 语音识别CTC算法:输出每一时刻正在发音的字符
    • 触发词检测:用01表示是否有触发词

本来,深度学习专项的课程就到此为止了。但是,后来Transformer太火了,课程专门加了一节介绍Transformer的内容。让我们在下周详细学习一下Transformer这一功能强大的模型架构。

Transformer中的“注意力”最早来自于NLP里的注意力模型。通过动手实现一遍注意力模型,我们能够更深刻地理解注意力的原理,以便于学习Transformer等后续那些基于注意力的模型。在这篇文章中,我将分享如何用PyTorch的基本API实现注意力模型,完成一个简单的机器翻译项目——把各种格式的日期“翻译”成统一格式的日期。

有关机器翻译、注意力模型相关知识请参考我之前的文章。如序列模型与注意力机制

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/attention

知识背景

注意力模型发源自机器翻译任务。最早,基于RNN的机器翻译模型都采用如下的架构:

前半部分的RNN只有输入,后半部分的RNN只有输出。两个部分通过一个简单的隐状态来传递信息。把隐状态看成输入信息的一种编码的话,前半部分可以叫做“编码器”,后半部分可以叫做“解码器”。这种架构因而被称为“编码器-解码器”架构。

这种架构在翻译短句子时确实有效,但面对长文章时就捉襟见肘了。使用“编码器-解码器”架构时,无论输入有多长,输入都会被压缩成一个简短的编码。也就是说,模型要一次性阅读完所有输入,再一次性输出所有翻译。这显然不是一种好的方法。联想一下,我们人类在翻译时,一般会读一句话,翻译一句话,读一句话,翻译一句话。基于这种思想,有人提出了注意力模型。注意力模型能够有效地翻译长文章。

在注意力模型中,编码器和解码器以另一种方式连接在一起。在完成编码后,解码器会以不同的权重去各个编码输出中取出相关信息,也就是以不同的“注意力”去关注输入信息。

具体来说,注意力模型的结构如下。

对于每一轮的输出$\hat{y}^{< t >}$,它的解码RNN的输入由上一轮输出$\hat{y}^{< t - 1>}$和注意力上下文$c^{< t >}$拼接而成。注意力上下文$c^{< t >}$,就是所有输入的编码RNN的隐变量$a^{< t >}$的一个加权平均数。这里加权平均数的权重$\alpha$就是该输出对每一个输入的注意力。每一个$\alpha$由编码RNN本轮状态$a^{< t’ >}$和解码RNN上一轮状态$s^{< t - 1 >}$决定。这两个输入会被送入一个简单的全连接网络,输出权重$e$(一个实数)。所有输入元素的$e$经过一个softmax输出$\alpha$。

日期翻译任务及其数据集

为了简化项目的实现,我们来完成一个简单的日期翻译任务。在这个任务中,输入是各式各样的日期,输出是某一个标准格式的日期。比如:

input output
Nov 23, 1999 1999-11-23
3 April 2005 2005-04-03
14/01/1989 1989-01-14
Thursday, February 7, 1985 1985-02-07

我们可以自己动手用Python生成数据集。在生成数据集时,我们要用到随机生成日期的faker库和格式化日期的babel库。

1
pip install faker babel

运行下面这段代码,我们可以生成不同格式的日期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import random

from babel.dates import format_date
from faker import Faker

faker = Faker()
format_list = [
'short', 'medium', 'long', 'full', 'd MMM YYY', 'd MMMM YYY', 'dd/MM/YYY',
'dd-MM-YYY', 'EE d, MMM YYY', 'EEEE d, MMMM YYY'
]

if __name__ == '__main__':
for format in format_list:
date_obj = faker.date_object()
print(f'{format}:', date_obj,
format_date(date_obj, format=format, locale='en'))
text
1
2
3
4
5
6
7
8
9
10
11
Possible output:
short: 1986-02-25 2/25/86
medium: 1979-08-05 Aug 5, 1979
long: 1971-12-15 December 15, 1971
full: 2017-02-14 Tuesday, February 14, 2017
d MMM YYY: 1984-02-21 21 Feb 1984
d MMMM YYY: 2011-06-22 22 June 2011
dd/MM/YYY: 1991-08-02 02/08/1991
dd-MM-YYY: 1987-06-12 12-06-1987
EE d, MMM YYY: 1986-11-02 Sun 2, Nov 1986
EEEE d, MMMM YYY: 1996-01-26 Friday 26, January 1996

Faker()是生成随机数据的代理类,用它的date_object()方法可以随机生成一个日期字符串date_obj。这个日期就是我们期望的标准格式。而通过使用format_date函数,我们可以通过改变该函数的format参数来得到格式不一样的日期字符串。各种格式的日期示例可以参考上面的输出。

利用这些工具函数,我们可以编写下面这些生成、读取数据集的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def generate_date():
format = random.choice(format_list)
date_obj = faker.date_object()
formated_date = format_date(date_obj, format=format, locale='en')
return formated_date, date_obj


def generate_date_data(count, filename):
with open(filename, 'w') as fp:
for _ in range(count):
formated_date, date_obj = generate_date()
fp.write(f'{formated_date}\t{date_obj}\n')


def load_date_data(filename):
with open(filename, 'r') as fp:
lines = fp.readlines()
return [line.strip('\n').split('\t') for line in lines]


generate_date_data(50000, 'dldemos/attention/train.txt')
generate_date_data(10000, 'dldemos/attention/test.txt')

注意力模型

在这个项目中,最难的部分是注意力模型的实现,即如何把上一节那个结构图用PyTorch描述出来。所有模型实现的代码如下:

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
61
62
63
64
65
66
67
68
69
70
import torch
import torch.nn as nn
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader, Dataset

from dldemos.attention.dataset import generate_date, load_date_data


EMBEDDING_LENGTH = 128
OUTPUT_LENGTH = 10

class AttentionModel(nn.Module):
def __init__(self,
embeding_dim=32,
encoder_dim=32,
decoder_dim=32,
dropout_rate=0.5):
super().__init__()
self.drop = nn.Dropout(dropout_rate)
self.embedding = nn.Embedding(EMBEDDING_LENGTH, embeding_dim)
self.attention_linear = nn.Linear(2 * encoder_dim + decoder_dim, 1)
self.softmax = nn.Softmax(-1)
self.encoder = nn.LSTM(embeding_dim,
encoder_dim,
1,
batch_first=True,
bidirectional=True)
self.decoder = nn.LSTM(EMBEDDING_LENGTH + 2 * encoder_dim,
decoder_dim,
1,
batch_first=True)
self.output_linear = nn.Linear(decoder_dim, EMBEDDING_LENGTH)
self.decoder_dim = decoder_dim

def forward(self, x: torch.Tensor, n_output: int = OUTPUT_LENGTH):
# x: [batch, n_sequence, EMBEDDING_LENGTH]
batch, n_squence = x.shape[0:2]

# x: [batch, n_sequence, embeding_dim]
x = self.drop(self.embedding(x))

# a: [batch, n_sequence, hidden]
a, _ = self.encoder(x)

# prev_s: [batch, n_squence=1, hidden]
# prev_y: [batch, n_squence=1, EMBEDDING_LENGTH]
# y: [batch, n_output, EMBEDDING_LENGTH]
prev_s = x.new_zeros(batch, 1, self.decoder_dim)
prev_y = x.new_zeros(batch, 1, EMBEDDING_LENGTH)
y = x.new_empty(batch, n_output, EMBEDDING_LENGTH)
tmp_states = None
for i_output in range(n_output):
# repeat_s: [batch, n_squence, hidden]
repeat_s = prev_s.repeat(1, n_squence, 1)
# attention_input: [batch * n_sequence, hidden_s + hidden_a]
attention_input = torch.cat((repeat_s, a),
2).reshape(batch * n_squence, -1)
alpha = self.softmax(self.attention_linear(attention_input))
c = torch.sum(a * alpha.reshape(batch, n_squence, 1), 1)
c = c.unsqueeze(1)
decoder_input = torch.cat((prev_y, c), 2)

if tmp_states is None:
prev_s, tmp_states = self.decoder(decoder_input)
else:
prev_s, tmp_states = self.decoder(decoder_input, tmp_states)

prev_y = self.output_linear(prev_s)
y[:, i_output] = prev_y.squeeze(1)
return y

让我们把这份实现一点一点过一遍。

在实现前,我们要准备一些常量。我们首先要决定“词汇表”的大小。在日期翻译任务中,输入和输出应当看成是字符序列。字符最多有128个,因此我们可以令“词汇表”大小为128。

1
EMBEDDING_LENGTH = 128

在我们这个任务中,输出序列的长度是固定的。对于yyyy-mm-dd这个日期字符串,其长度为10。我们要把这个常量也准备好。

1
OUTPUT_LENGTH = 10

接下来是模型的实现。先看__init__里的结构定义。一开始,按照RNN模型的惯例,我们要让输入过Dropout和嵌入层。对于单词序列,使用预训练的单词嵌入会好一点。然而,我们这个项目用的是字符序列,直接定义一个可学习的嵌入层即可。

1
2
self.drop = nn.Dropout(dropout_rate)
self.embedding = nn.Embedding(EMBEDDING_LENGTH, embeding_dim)

接下来是编码器和解码器。在注意力模型中,编码器和解码器是两个不同的RNN。为了充分利用输入信息,可以把双向RNN当作编码器。而由于机器翻译是一个生成答案的任务,每轮生成元素时需要用到上一轮生成出来的元素,解码器必须是一个单向RNN。在本项目中,我使用的RNN是LSTM。模块定义代码如下:

1
2
3
4
5
6
7
8
9
self.encoder = nn.LSTM(embeding_dim,
encoder_dim,
1,
batch_first=True,
bidirectional=True)
self.decoder = nn.LSTM(EMBEDDING_LENGTH + 2 * encoder_dim,
decoder_dim,
1,
batch_first=True)

这里要注意一下这两个模块的输入通道数。encoder的输入来自嵌入层,因此是embeding_dim,这个很好理解。decoder的输入通道则需要计算一番了。decoder的输入由模型上一轮的输出和注意力输出拼接而成。模型每轮会输出一个字符,字符的通道数是“词汇表”大小,即EMBEDDING_LENGTH。注意力的输出是encoder的隐变量的加权和,因此其通道数和encoder的隐变量一致。encoder是双向RNN,其隐变量的通道数是2 * encoder_dim。最终,decoder的输入通道数应是EMBEDDING_LENGTH + 2 * encoder_dim

在注意力模块中,解码RNN对各编码RNN的注意力由一个线性层计算而得。该线性层的输入由解码RNN和编码RNN的隐变量拼接而成,因此其通道数为2 * encoder_dim + decoder_dim;该线性层的输出是注意力权重——一个实数。

1
self.attention_linear = nn.Linear(2 * encoder_dim + decoder_dim, 1)

解码结束后,还需要经过一个线性层才能输出结果。

1
self.output_linear = nn.Linear(decoder_dim, EMBEDDING_LENGTH)

看完了__init__,来看看forward里各模块是怎么连接起来的。

机器翻译其实是一个生成序列的任务。一般情况下,生成序列的长度是不确定的,需要用一些额外的技巧来选择最佳的输出序列。为了简化实现,在这个项目中,我们生成一个固定长度的输出序列。该长度应该在forward的参数里指定。因此,forward的参数如下:

1
def forward(self, x: torch.Tensor, n_output: int = OUTPUT_LENGTH):

一开始,先获取一些形状信息。

1
2
# x: [batch, n_sequence, EMBEDDING_LENGTH]
batch, n_squence = x.shape[0:2]

输入通过嵌入层和dropout层。

1
2
# x: [batch, n_sequence, embeding_dim]
x = self.drop(self.embedding(x))

再通过编码器,得到编码隐状态a

1
2
# a: [batch, n_sequence, hidden]
a, _ = self.encoder(x)

接下来,要用for循环输出每一轮的结果了。在此之前,我们要准备一些中间变量:用于计算注意力的解码器上一轮状态prev_s,用于解码器输入的上一轮输出prev_y,输出张量y。另外,由于我们要在循环中手动调用decoder完成每一轮的计算,还需要保存decoder的所有中间变量tmp_states

1
2
3
4
5
6
7
# prev_s: [batch, n_squence=1, hidden]
# prev_y: [batch, n_squence=1, EMBEDDING_LENGTH]
# y: [batch, n_output, EMBEDDING_LENGTH]
prev_s = x.new_zeros(batch, 1, self.decoder_dim)
prev_y = x.new_zeros(batch, 1, EMBEDDING_LENGTH)
y = x.new_empty(batch, n_output, EMBEDDING_LENGTH)
tmp_states = None

在每一轮输出中,我们首先要获得当前的解码器对于每一个输入的注意力alpha。每一个alpha由解码器上一轮状态prev_s和编码器本轮状态决定(一个全连接层+softmax)。为了充分利用并行计算,我们可以把所有alpha的计算打包成batch,一步做完。

注意,这里的全连接层+softmax和普通的全连接网络不太一样。这里全连接层的输出通道数是1,会对n组输入做n次计算,得到n个结果,再对n个结果做softmax。我们之所以能一次得到n个结果,是巧妙地把n放到了batch那一维。

1
2
3
4
5
6
7
8
9
10
11
for i_output in range(n_output):
# repeat_s: [batch, n_squence, hidden]
repeat_s = prev_s.repeat(1, n_squence, 1)
# attention_input: [batch * n_sequence, hidden_s + hidden_a]
attention_input = torch.cat((repeat_s, a),
2).reshape(batch * n_squence, -1)
# x: [batch * n_sequence, 1]
x = self.attention_linear(attention_input)
# x: [batch, n_sequence]
x = x.reshape(batch, n_squence)
alpha = self.softmax(x)

求出了注意力alpha后,就可以用它来算出注意力上下文c了。

1
c = torch.sum(a * alpha.reshape(batch, n_squence, 1), 1)

之后,我们把c和上一轮输出prev_y拼一下,作为解码器的输出。

1
2
c = c.unsqueeze(1)
decoder_input = torch.cat((prev_y, c), 2)

再调用解码器即可。这里我利用PyTorch的机制偷了个懒。理论上解码器第一轮的状态应该是全零张量,我们应该初始化两个全零张量作为LSTM的初始状态。但是,在PyTorch里,如果调用RNN时不传入状态,就默认会使用全零状态。因此,在第一轮调用时,我们可以不去传状态参数。

1
2
3
4
if tmp_states is None:
prev_s, tmp_states = self.decoder(decoder_input)
else:
prev_s, tmp_states = self.decoder(decoder_input, tmp_states)

最后,用线性层算出这轮的输出,维护输出变量y。循环结束后,返回y

1
2
3
    prev_y = self.output_linear(prev_s)
y[:, i_output] = prev_y.squeeze(1)
return y

训练、测试、推理

写完了最核心的注意力模型,剩下的代码就比较简单了。

首先,我们要准备一个Dataset类。这个类可以读取输入、输出字符串,并把它们转换成整形数组。字符和整形数字间的映射非常暴力,一个字符的序号就是该字符的ASCII码。这样写比较简洁,但由于很多字符是用不到的,会浪费一些计算性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def stoi(str):
return torch.LongTensor([ord(char) for char in str])


def itos(arr):
return ''.join([chr(x) for x in arr])


class DateDataset(Dataset):
def __init__(self, lines):
self.lines = lines

def __len__(self):
return len(self.lines)

def __getitem__(self, index):
line = self.lines[index]

return stoi(line[0]), stoi(line[1])

准备好DataSet后,就可以生成DataLoader了。在序列任务中,各个样本的序列长度可能是不一致的。我们可以用PyTorch的pad_sequence对长度不足的样本进行0填充,使得一个batch里的所有样本都有着同样的序列长度。

1
2
3
4
5
6
7
8
9
10
11
def get_dataloader(filename):

def collate_fn(batch):
x, y = zip(*batch)
x_pad = pad_sequence(x, batch_first=True)
y_pad = pad_sequence(y, batch_first=True)
return x_pad, y_pad

lines = load_date_data(filename)
dataset = DateDataset(lines)
return DataLoader(dataset, 32, collate_fn=collate_fn)

这里要稍微注意一下,pad_sequence默认会做0填充,0填充在我们的项目里是合理的。在我们定义的“词汇表”里,0对应的是ASCII里的0号字符,这个字符不会和其他字符起冲突。

做好一切准备工作后,可以开始训练模型了。训练模型的代码非常常规,定义好Adam优化器、交叉熵误差,跑完模型后reshape一下算出loss再反向传播即可。

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
def main():
device = 'cuda:0'
train_dataloader = get_dataloader('dldemos/attention/train.txt')
test_dataloader = get_dataloader('dldemos/attention/test.txt')

model = AttentionModel().to(device)

# train

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
citerion = torch.nn.CrossEntropyLoss()
for epoch in range(20):

loss_sum = 0
dataset_len = len(train_dataloader.dataset)

for x, y in train_dataloader:
x = x.to(device)
y = y.to(device)
hat_y = model(x)
n, Tx, _ = hat_y.shape
hat_y = torch.reshape(hat_y, (n * Tx, -1))
label_y = torch.reshape(y, (n * Tx, ))
loss = citerion(hat_y, label_y)

optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

loss_sum += loss * n

print(f'Epoch {epoch}. loss: {loss_sum / dataset_len}')

torch.save(model.state_dict(), 'dldemos/attention/model.pth')

训练完模型后,我们可以测试一下模型在测试集上的正确率。在日期翻译任务中,我们可以把“正确”定义为输出和真值一模一样。比如一条日期的真值是”2000-01-01”,模型的输出必须也是”2000-01-01”才能说这个输出是正确的。编写并行化计算正确率的代码稍有难度。

模型的输出hat_y表示各个字符的出现概率。我们先用prediction = torch.argmax(hat_y, 2)把序列里每个概率最大的字符作为模型预测的字符。现在,我们要用并行化编程判断每对序列(整形标签数组)predition[i]y[i]是否相等(注意,preditiony是带了batch那个维度的)。这里,我们可以让predition[i]y[i]做减法再求和。仅当这个和为0时,我们才能说predition[i]y[i]完全相等。通过这样一种曲折的实现方法,我们可以并行地算出正确率。

也许有更方便的API可以完成这个逻辑判断,但去网上搜索这么复杂的一个需求太麻烦了,我偷了个懒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# test
model.load_state_dict(torch.load('dldemos/attention/model.pth'))

accuracy = 0
dataset_len = len(test_dataloader.dataset)

for x, y in test_dataloader:
x = x.to(device)
y = y.to(device)
hat_y = model(x)
prediction = torch.argmax(hat_y, 2)
score = torch.where(torch.sum(prediction - y, -1) == 0, 1, 0)
accuracy += torch.sum(score)

print(f'Accuracy: {accuracy / dataset_len}')

最后,我们也可以临时生成几个测试用例,输出模型的预测结果。

1
2
3
4
5
6
7
8
9
# inference
for _ in range(5):
x, y = generate_date()
origin_x = x
x = stoi(x).unsqueeze(0).to(device)
hat_y = model(x)
hat_y = hat_y.squeeze(0).argmax(1)
hat_y = itos(hat_y)
print(f'input: {origin_x}, prediction: {hat_y}, gt: {y}')

训练20-30个epoch后,模型差不多就收敛了。我训练的模型在测试集上的正确率约有98%。下面是随机测试用例的推理结果,可以看出模型的判断确实很准确。

text
1
2
3
4
5
input: 4 November 1988, prediction: 1988-11-04, gt: 1988-11-04
input: Friday 26, March 2021, prediction: 2021-03-26, gt: 2021-03-26
input: Saturday 2, December 1989, prediction: 1989-12-02, gt: 1989-12-02
input: 15/10/1971, prediction: 1971-10-15, gt: 1971-10-15
input: Mon 9, Oct 1989, prediction: 1989-10-09, gt: 1989-10-09

总结

在这篇文章中,我展示了一个用PyTorch编写的注意力模型,它用于完成日期翻译任务。在这个项目中,最重要的是注意力模型的编写。如今,注意力模型已经不是功能最强大的模型架构了。不过,通过动手实现这个模型,我们可以对注意力机制有着更深刻的认识,有助于理解那些更先进的模型。

词嵌入简介

基础概念

之前,我们是用one-hot编码来表示单词的:假设一个单词在词汇表里的序号是$t$,词汇表大小是$T$,则这个单词的编码是一个长度为$T$的向量,向量只有第$t$维是1,其他维都是0。我们用$O_t$来表示这个单词的one-hot编码。如下图所示。

这种表示法能区分每个词,但是,它有一个缺陷:one-hot向量两两之间的乘积为0,不能通过向量的相似度推理出单词的相似度。因此,在NLP中,一个重要的任务就是找到一种合理的词表示方法,使得我们能够利用向量的某些性质来表示单词之间的某些性质。

我们来看一种新的词表示方法。假设有Man Woman King Queen Apple Orange这6个单词,我们从性别(Gender),皇家的(Royal),年龄(Age),是否是食物(Food)这几个角度来描述这几个单词,可以填写出这样一份表格:

在这份表格里,每一列的数字可以看成属于某一单词的向量,这个向量就是一种词表示方法。我们用$et$表示词汇表里序号$t$的这种有意义的向量。比如$e{5391}$就是Man的向量。观察每列的向量,我们能发现Woman和Man很相似,King Queen很相似,Apple和Orange很相似。这样,使用这种词表示,单词的相似度由向量的相似度表示了出来,符合我们对词表示的期望。

当然,使用算法生成的词表示中,向量的每一维不可能像这样可解释性这么强。

这种用向量描述单词的方法称为词嵌入(word embedding)。使用高维向量描述单词,就好像是把一个抽象的概念嵌进了一个向量一样。

词嵌入使用示例

看完了词嵌入的基础概念,我们来看看使用词嵌入有什么好处。

还是以命名实体识别任务为例。假设有这么一句话”Sally Johnson is an orange farmer”,我们能够推断出”Sally Johnson”是一个人名,这是因为我们看到了后面的”orange farmer”。橙子农民很可能与人名对应。

假设从刚刚那句话中,模型已经学会了橙子农民与人名之间的关系。现在,又有了一条新的训练样本”Robert Lin is an apple farmer”。使用了词嵌入的话,模型虽然不知道“苹果农民”是什么,但它知道”apple”和”orange”是很相似的东西,能够很快学会这句话的”Robert Lin”也是一个人名。

也就是说,通过使用词嵌入,模型能够利用单词之间的关系更快地完成学习。实际上,不仅是训练,使用了词嵌入后,哪怕出现了训练集中没出现过的单词,模型也能根据单词间的关系做出正确的推理。我们再来看一个例子。

假设又有一条测试样本”Robert Lin is a durian cultivator”。模型可能从来在训练集里没有见过”durian”和”cultivator”这两个单词。但是,通过词嵌入,模型知道”durian(榴莲)”是一种和”apple”相近的东西,”cultivator(培育者)”是一种和”farmer”类似的东西。通过这层关系,模型还是能够推理出”Robert Lin”是一个人名。

这就是词嵌入之所以那么重要的原因。词嵌入本质上是一种迁移学习,它隐含了在其他数据中学习到的单词之间的关系。利用这些知识,另一种任务能够在词嵌入的帮助下更快地完成学习。一般使用词嵌入的步骤如下:

  1. 用大量数据(千亿级)训练出词嵌入。
  2. 把词嵌入迁移到新任务上,使用一个较小的数据集(比如,万级)。
  3. 可选:继续finetune词嵌入(仅当新任务的数据量足够多时)。

除了加速训练外,词嵌入还有一个好处:词嵌入的向量长度往往较少。比如一个长度为10000的字典训练出来的词嵌入可能长度只有300。

结束这节前,顺便提一下词嵌入与上门课讲到的人脸识别中的编码(encoding)之间的关系。不管是嵌入还是编码,其实都是指向量,两种描述不少时候可以通用。但是,人脸识别中的编码主要指对任何一张数据算出一个编码,而嵌入指把一个已知的单词集合的每一个单词嵌进一个向量空间里。二者的主要区别在于输入集合是否固定。

词嵌入的性质

掌握了词嵌入的基本应用,让我们在另一种任务里进一步认识词嵌入的性质。

NLP里有一种任务叫做类比推理。比如,Man和Woman的关系,相当于King和谁的关系?有了词嵌入,这一问题就很好回答了。

还是以刚刚那张人工构造出来的词嵌入表为例。这里我们用$e_{man}$来表示Man的词嵌入,以此类推。

单词间的关系不好描述,而向量间的关系却很容易算。我们可以用向量的差来表示向量直接的关系。计算一下,$e{man}-e{woman} \approx [2, 0, 0, 0]^T$, $e{king}-e{queen} \approx [2, 0, 0, 0]^T$。通过猜测,我们发现King和Queen的关系与Man和Woman的关系类似。进而可以得出,Man之于Woman,相当于King之于Queen。

准确来说,刚刚这个问题相当于求解一个单词$w$,其嵌入$e_w$满足

,移项,$w$的计算方法就是:

,其中$sim$表示某种相似度,比如cosine相似度。只要我们计算出了右边的$e{king}-e{man}+e_{woman}$,再用某种算法就可以求出最合适的$e_w$了。

通过这些观察,我们可以发现,词嵌入蕴含了语义信息。词嵌入间的差别很有可能就是语义上的差别。

词嵌入矩阵

假设词嵌入向量的长度是300,有10000个单词。词嵌入的过程,其实就算把一个长度为10000的向量映射成长度为300的向量的过程。这个过程可以用一个$300 \times 10000$的矩阵$E$表示,$E$就是词嵌入向量的数组。每一个词嵌入列向量$e_t$可以由one-hot编码$o_t$和$E$计算得到:$e_t=Eo_t$。

词嵌入的学习

认识了词嵌入的基本概念后,我们来看看如何用学习算法得到一个词嵌入矩阵。

学习词嵌入和学习神经网络的参数是一样的。只要我们是在根据词嵌入算一个损失函数,就可以使用梯度下降法优化词嵌入。因此,问题的关键在于如何建模一个使用到词嵌入的优化任务。

语言模型

早期的词嵌入是通过语言模型任务学习到的。回想上周课的内容,语言模型就是预测一句话在这种语言中出现的概率。比如 “I want a glass of orange _ .”,我们会自然地觉得空格里的单词是”juice”,这是因为填juice后整句话的出现概率比填其他单词更高一点。

暂时抛开上周讲的RNN,我们可以用一个使用词嵌入的神经网络来学习语言模型,如下图所示:

这个任务的输入是一句话中连续的6个单词,输出是第7个单词的预测。在用神经网络建模时,要先根据各个单词的one-hot编码$o_t$从词嵌入矩阵$E$中选出其嵌入$e_t$,再把各个单词的嵌入堆叠成一个向量,输入进标准神经网络里,最后用一个softmax预测下一个单词。

在这个模型中,可见的单词数是固定的。假设词嵌入的维度是300,那么根据6个单词进行预测的神经网络的输入向量长度就必须是1800。为了遍历整句话,可以拿一个长度为7(算上预测词)的滑动窗口在整句话上滑一遍。

可见前6个单词,其实算是网络的一个超参数。除了选取前6个单词外,还有其他的选取上下文的方式。常见的上下文选取方式如下:

  • 前4个单词
  • 前4个单词和后4个单词
  • 前1个单词
  • 往前数的第2个单词

经研究,如果只是要学习一个语言模型,使用前4个单词可能更好。而如果要学词嵌入的话,后几个方式也不错。

Word2Vec

Word2Vec是一种比语言模型更高效的词嵌入学习算法。与语言模型任务的思想类似,Word2Vec也要完成一个单词预测任务:给定一个上下文(context)单词,要求模型预测一个目标(target)单词。但是,这个目标单词不只是上下文单词的后一个单词,而是上下文单词前后10个单词中任意一个单词。比如在句子”I want a glass of orange juice to go along with my cereal”中,对于上下文单词glass,目标单词可以是juice, glass, my。

具体来说,每一条训练样本是一个上下文单词和目标单词的词对。比如(orange, juice), (orange, glass)。为了生成这些训练数据,我们要从语料库里每一个句子里采样出训练词对。在采样时,要先对上下文单词采样,再对目标单词采样。

假设有了上下文单词,对目标单词采样很简单,只需要从上下文单词的前后10个单词中均匀采样单词即可。而采样上下文单词就需要一些设计了。在英文中,大部分单词都是a, the, of这些没什么含义的词,如果在句子里均匀采样的话,大部分时候得到的都是这些词。因此,在Word2Vec论文中,有一些对上下文单词采样的设计,各单词的出现概率会更平均一点。

看完了训练数据的采样,再看一看Word2Vec的模型。Word2Vec模型非常简单,它是只有一个softmax层的神经网络,我们可以直接写出这个模型的公式:

$p(t|c)$即目标单词$t$在上下文单词$c$前后的出现概率。$e_c$是$c$的嵌入。$\theta$是softmax层的线性计算的参数,这里我们省略掉了bias。求和里的10000是整个词汇表的大小,也就是softmax输出向量的大小。

和其他多分类任务一样,这个任务的损失函数也是交叉熵函数。

Word2Vec的模型结构十分简单,因此,整个模型的计算量全部落在了softmax的分母求和上。假设词汇表有n个单词,整个模型的时间复杂度就是$O(n)$。在词汇表很大时,求和的开销也是很大的。

为了优化这个求和,Word2Vec使用了H-Softmax(Hierachical Softmax)这种优化方式。一个多分类任务,其实可以拆成多个二分类任务。比如有“猫、狗、树、草”这四种类别,我们可以先做是动物还是植物的二分类,再做一次更具体的二分类,最后把两次次二分类的概率乘起来。H-Softmax就是用这种思想优化了softmax的求和。

使用H-Softmax前,要先对所有单词建立一颗二叉树,比如对A, B, C, D四个单词,可以这样建树:”ABCD-(AB,CD)”, “AB-(A,B)”, “CD-(C,D)”。这样,把一个多分类问题拆成多个二分类问题,就等价于从树的根部开始,经过多个节点,达到单词所在的叶节点。使用H-Softmax时,只要把访问该单词的路径上所有节点的概率乘起来就行了。比如要求单词$x$是$C$的概率,可以先算$x$属于$CD$的概率$P(x \in CD)$,再算已知$x$属于$CD$时$x$是C的概率$P(x \in C | x \in CD)$,二者一乘就是我们要的$P(x \in C)$。

二分类的复杂度是$O(1)$,要做$O(logn)$次二分类。因此,经优化后,H-Softmax的复杂度$O(logn)$。实际上,这个算法还有一些优化空间。词汇表里的词汇是固定的,我们可以巧妙地修改建立二叉树的方法,进一步减少运算量。“给定各元素的访问概率(在这个问题里是单词在语言里的出现概率),对所有元素建立一颗二叉树,以最小化访问叶节点的路径长度的期望”是一个经典的问题,这个问题的解法叫做哈夫曼编码。这是离散数学的知识,和本课的关系就不大了。

H-Softmax的核心思想是把多分类拆成二分类,搞懂这个就行了。至于使用二叉树,怎么建立更好的二叉树,这是一个独立的子问题,理解它和理解H-Softmax无关。在学NLP时,可以先把这个子问题放一放,理解H-Softmax的用意就行。

Word2Vec的目标任务还有其他的形式。除了找上下文单词前后10个单词中的某个目标单词外,还可以用前后的1个单词预测中间的目标单词,这种方法叫做CBow。两种方法各有千秋。Word2Vec的主要思想是那个单层softmax模型,具体的任务倒不是最重要的。

负采样

通过前面几个小节的学习,我们能够总结词嵌入学习的一些经验:词嵌入的根本目的是学习词嵌入矩阵,使用词嵌入的任务倒没有那么重要。因此,我们可以放心大胆地去简化每轮任务的计算量,加快词嵌入的学习效率。

基于这种思想,我们可以进一步去优化Word2Vec里的多分类任务。实际上,一个N分类任务,可以“复杂化”成N个二分类任务——逐个判断输入是否是N个类别中的一种。顺着这个思路,我们不用去求给定上下文单词时目标单词的概率分布,只需要判断给定上下文单词和目标单词,判断二者是否相关即可。

这样,在每一轮任务中,我们不用去计算多分类的softmax,只要计算一个二分类的sigmoid就行了。这样一种算法叫做负采样(Negative Sampling)。

负采样使用的模型和Word2Vec一样简单:输入一个上下文单词的嵌入,经过一个sigmoid层,输出那个上下文单词和某个目标单词是否相关。

负采样算法中真正的难点是训练数据的生成。在看数据生成算法之前,我们先看一下训练样本的格式。负采样的每一条样本是一个三元组(context, word, target),分别表示上下文单词、目标单词、用01表示的两个单词是否相关。比如,我们可能会得到这样的正负样本:

context word target
orange juice 1
orange king 0

接下来,我们来看如何生成这些样本。使用Word2Vec的采样方法,我们会对语料库里的每一句话采样出一些词对。这样,每一个词对能构成一个正样本,它的target值为1。

正样本很好生成,可负样本就不是很好采样了。负采样算法使用了一种巧妙的采样方法(这也是其名称的由来):在生成一个正样本的同时,算法还会对同一个上下文单词context生成$k$个target为0的负样本。这些样本里的目标单词word是随机挑选的。

举个例子,设$k=4$,在”I want a glass of orange juice to go along with my cereal”这句话中,假如我们采样到了(orange, juice)这个词对,我们可能会随机选4个单词,得到下面这些训练样本:

context word target
orange juice 1
orange king 0
orange book 0
orange the 0
orange of 0

注意,每$k$个负样本是针对一条正样本而言的。尽管orange, of都出现在了这句话里,但我们在考虑(orange, juice)这个正样本词对时,会把其他所有词对都当做负样本。

刚刚讲到,负样本里的word是随机挑选的。其实,这种“随机”有一些讲究。如果对所有单词均匀采样,那么不常用的词会被过度学习;如果按照单词的出现频率采样,of, the这些助词又会被过度学习。因此,在采样负样本时,这个负采样算法的论文使用了这样一种折中的方法:

这里$p(w_i)$表示第$i$个单词$w_i$被采样到的概率,$f(w_i)$是单词在这个语言中的出现频率。公式里的$\frac{3}{4}$是根据经验试出来的。

GloVe

GloVe(global vectors for word representation)是一种更加简单的求词嵌入的算法。刚才学习的几种方法都需要进行复杂的采样,而GloVe使用了一种更简洁的学习目标$x_{ij}$,以代替多分类任务或者二分类任务。

$x{ij}$为给定上下文单词$i$时单词$j$出现的次数。和前面一样,这里的“上下文”可以有多种定义。比如,如果上下文的定义是“前后5个单词”,那么这就是一个对称的上下文定义,$x{ij}=x{ji}$;如果上下文的定义是“后1个单词”,则$x{ij}\neq x{ji}$。我们可以简单地把$x{ij}$理解成$j$出现在$i$附近的次数。

比如我们把上下文定义为前后2个单词。在句子”a b c b d e”中,$x{ca}=1, x{cb}=2, x{cd}=1, x{ce}=0$。

有了$x_{ij}$,我们就能直接知道给定上下文$i$时各个单词$j$的出现频率,而不需要再构建一个分类任务去学习单词$j$出现的条件概率。这样一个新的误差函数是:

视频里的公式把i, j写反了。

和之前几个任务一样,$ei$是上下文单词的词嵌入,$\theta$是线性计算的参数。$\theta^{T}_je_i$其实再就是拟合某单词$j$和上下文单词$i$的相关程度。而$logx{ij}$恰好能反映某单词$j$和上下文单词$i$的相关程度。

刚刚那个误差函数有几个需要改进的地方:

  • log 里面可能出现0。对于$x{ij}=0$的地方,我们要想办法让$logx{ij}=0$。
  • 不同单词的出现频率不同。对于出现频率较少的单词,我们可以限制它对优化目标的影响。
  • 可以像普通的线性层一样,加入偏差项bias。

因此,最终的损失函数为(假设词汇表大小10000):

其中,$f$是权重项,既用于防止$x_{ij}=0$($f(0)=0$),也用于调节低频率单词的影响。$b_i, b’_j$分别是上下文单词和目标单词的偏差项。

有趣的是,当$x_{ij}$是对称矩阵的时候,$\theta, e$也是对称的,它们在式子里的作用是等价的。我们可以让最终的词嵌入为$\theta, e$的平均值。

在结束词嵌入的学习前,我们还要补充学习一下词嵌入的一些性质。

上图是我们在这节课的开头学习到的“人造词嵌入”。在这个词嵌入中,向量的每一个维度都有一个意义。而在实际情况中,算法学习出来的词嵌入不能保证每个维度都只有一个意义。根据线性代数的知识,要表示同一个空间,有无数组选择坐标轴的方法。很可能0.3x+0.7y这个方向表示一个意思,0.4y+0.6z这个方向又表示一个意思,而不是每个坐标轴的方向恰好表示一个意思。当然,不管怎么选取坐标轴,两个向量的相对关系不会变,对词嵌入做减法以判断两个单词的关系的做法依然适用。

词嵌入的应用

情感分析

词嵌入可以应用于情感分析(Sentiment Classification)任务。在情感分析任务中,算法的输入是一段文字(比如影评),输出是用户表达出来的喜恶程度(比如1-5星)。

有了词嵌入,我们可以轻松地构筑一个简单的模型。

如上图所示,只要简单地对所有输入单词的词嵌入取平均值,放入softmax即可。

这种算法确实能够生效。但是,它只考虑了每个单词的含义,而忽略了整体的意思。如果句子里有”not”这种否定词,这个模型就不太有效了。为此,我们可以构建更精巧的RNN模型。

如第一周所学,RNN是一个“多对一”任务。我们可以让RNN最后一轮输出一个分类结果。只不过,这次输入RNN的不是one-hot向量,而是更有意义的词嵌入。

消除歧视

词嵌入会自动从大量的本文中学习知识。但是,数据中的知识可能本身带有偏见。比如,在自动学到的词嵌入看来,男人之于程序员,就像女人之于家庭主妇;父亲之于医生,就像母亲之于护士。类似的歧视不仅存在于性别这一维度,还存在于种族、年龄、贫富等维度。我们希望消除词嵌入里面的这些歧视。

本节仅对消除歧视的方法做一个简介,很多实现细节都被省略了。详情请见原论文。

词嵌入本身是向量,歧视其实就是某些本应该对称的向量不太对称了。我们的目的就是在带有偏见的维度上令向量对称。

第一步,我们要找到带有偏见的维度。比如,对于性别维度,我们可以算$e{he}-e{she}, e{male}-e{female}$,对这些表示同一意义的方向取一个平均向量,得到偏见的方向。得到方向后,我们可以用一个平面图来可视化和偏见相关的向量。假设词嵌入的长度是300,那么x轴表示带有偏见的那个维度,y轴表示剩余的299个维度。

所有的单词可以分成两类:和性别相关的明确(definitional)单词和剩余不明确的单词(明确单词需要手动找出来)。第二步,我们要让所有不明确单词都恰好回到y轴上。这样,任何其他单词都不会偏向某一性别了。

最后,有些和性别相关还不够对称。我们要想办法让每对和性别相关的词恰好按y轴对称。

总结

在这堂课中,我们系统地学习了词嵌入这个概念,并大致了解了如何在NLP任务中使用词嵌入。相关的知识有:

  • 词嵌入简介
    • 从one-hot到词嵌入
    • 词嵌入向量的意义
  • 词嵌入学习算法
    • 语音模型
    • Word2Vec
    • 负采样
    • GloVe
  • 如何应用词嵌入

词嵌入是专属于NLP的概念,且是NLP任务的基石。如果要开展NLP相关研究,词嵌入是一个绕不过去的知识;反过来说,如果不搞NLP,只是想广泛地学习深度学习,那么词嵌入本身可能不是那么重要,对词嵌入问题的建模方法会更重要一点。

只从实用的角度来看的话,这堂课介绍的知识并没有那么重要,网上能够轻松找到别人预训练好的词嵌入权重。真正重要的是词嵌入在框架中的使用方法,以及如何在一般任务中使用词嵌入。在这周的代码实战中,我会分享一下如何用预训练的词嵌入完成某些NLP任务。

词嵌入能够用更加有意义的向量表示单词。在NLP任务中使用预训练的词嵌入,往往能极大地加快训练效率。在这篇文章中,我将面向NLP初学者,分享一下如何在PyTorch中使用预训练的GloVe词嵌入,并借助它完成一个简单的NLP任务——情感分析。

相关的背景知识可以参考我之前有关词嵌入的文章:词嵌入 (Word2Vec, GloVe)

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/SentimentAnalysis

GloVe 词嵌入

GloVe是一种学习词嵌入的方法,它希望拟合给定上下文单词$i$时单词$j$出现的次数$x_{ij}$。使用的误差函数为:

其中,$N$是词汇表大小,$\theta, b$是线性层参数,$e_i$是词嵌入。$f(x)$是权重项,用于平衡不同频率的单词对误差的影响,并消除$log0$时式子不成立的情况。

GloVe作者提供了官方的预训练词嵌入(https://nlp.stanford.edu/projects/glove/ )。预训练的GloVe有好几个版本,按数据来源,可以分成:

  • 维基百科+gigaword(6B)
  • 爬虫(42B)
  • 爬虫(840B)
  • 推特(27B)

其中,括号里的数字表示数据集的token数。

按照词嵌入向量的大小分,又可以分成50维、100维、200维等不同维度。

预训练GloVe的文件格式非常简明。一行表示一个单词向量,每行先是一个单词,再是若干个浮点数,表示该单词向量的每一个元素。

当然,在使用PyTorch时,我们不必自己去下载解析GloVe,而是可以直接调用PyTorch的库自动下载解析GloVe。首先,我们要安装PyTorch的NLP库——torchtext。

conda可以用下面的命令安装:

1
conda install -c pytorch torchtext

pip可以直接安装:

1
pip install torchtext

之后,在Python里运行下面的代码,就可以获取GloVe的类了。

1
2
3
4
import torch
from torchtext.vocab import GloVe

glove = GloVe(name='6B', dim=100)

如前文所述,GloVe的版本可以由其数据来源和向量维数确定。在构建GloVe类时,要提供这两个参数。最好是去GloVe的官网查好一个确定的版本,用该版本的参数构建这个GloVe类。我在这个项目中使用的是6B token,维度数100的GloVe。

调用glove.get_vecs_by_tokens,我们能够把token转换成GloVe里的向量。

1
2
3
# Get vectors
tensor = glove.get_vecs_by_tokens(['', '1998', '199999998', ',', 'cat'], True)
print(tensor)

PyTorch提供的这个函数非常方便。如果token不在GloVe里的话,该函数会返回一个全0向量。如果你运行上面的代码,可以观察到一些有趣的事:空字符串和199999998这样的不常见数字不在词汇表里,而1998这种常见的数字以及标点符号都在词汇表里。

GloVe类内部维护了一个矩阵,即每个单词向量的数组。因此,GloVe需要一个映射表来把单词映射成向量数组的下标。glove.itosglove.stoi完成了下标与单词字符串的相互映射。比如用下面的代码,我们可以知道词汇表的大小,并访问词汇表的前几个单词:

1
2
3
myvocab = glove.itos
print(len(myvocab))
print(myvocab[0], myvocab[1], myvocab[2], myvocab[3])

最后,我们来通过一个实际的例子认识一下词嵌入的意义。词嵌入就是向量,向量的关系常常与语义关系对应。利用词嵌入的相对关系,我们能够回答“x1之于y1,相当于x2之于谁?”这种问题。比如,男人之于女人,相当于国王之于王后。设我们要找的向量为y2,我们想让x1-y1=x2-y2,即找出一个和x2-(x1-y1)最相近的向量y2出来。这一过程可以用如下的代码描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def get_counterpart(x1, y1, x2):
"""Find y2 that makes x1-y1=x2-y2"""
x1_id = glove.stoi[x1]
y1_id = glove.stoi[y1]
x2_id = glove.stoi[x2]
x1, y1, x2 = glove.get_vecs_by_tokens([x1, y1, x2], True)
target = x2 - x1 + y1
max_sim = 0
max_id = -1
for i in range(len(myvocab)):
vector = glove.get_vecs_by_tokens([myvocab[i]], True)[0]
cossim = torch.dot(target, vector)
if cossim > max_sim and i not in {x1_id, y1_id, x2_id}:
max_sim = cossim
max_id = i
return myvocab[max_id]


print(get_counterpart('man', 'woman', 'king'))
print(get_counterpart('more', 'less', 'long'))
print(get_counterpart('apple', 'red', 'banana'))

在函数get_counterpart中,我们遍历所有向量,根据cosine相似度,找一个和x2-x1+y1最相近的向量(除三个输入向量之外)。使用这个函数,我们可以回答以下三组问题:

  • man-woman, king-queen
  • more-less, long-short
  • apple-red, banana-yellow

词嵌入确实非常神奇,连反义词、水果的颜色这种抽象关系都能记录。当然,这里我只挑选了几组成功的例子。这种算法并不能认出单词的比较级(good-better, bad-worse)等更抽象的关系。

通过这一节的实践,我们认识了GloVe的基本用法。接下来,我们来看看怎么用词嵌入完成情感分析任务。

基于GloVe的情感分析

情感分析任务与数据集

和猫狗分类类似,情感分析任务是一种比较简单的二分类NLP任务:给定一段话,输出这段话的情感是积极的还是消极的。

比如下面这段话:

I went and saw this movie last night after being coaxed to by a few friends of mine. I’ll admit that I was reluctant to see it because from what I knew of Ashton Kutcher he was only able to do comedy. I was wrong. Kutcher played the character of Jake Fischer very well, and Kevin Costner played Ben Randall with such professionalism. ……

这是一段影评,大意说,这个观众本来不太想去看电影,因为他认为演员Kutcher只能演好喜剧。但是,看完后,他发现他错了,所有演员都演得非常好。这是一段积极的评论。

再比如这段话:

This is a pale imitation of ‘Officer and a Gentleman.’ There is NO chemistry between Kutcher and the unknown woman who plays his love interest. The dialog is wooden, the situations hackneyed.

这段影评说,这部剧是对《军官与绅士》的一个拙劣的模仿。Kutcher和那个成为他心上人的路人女性之间没有产生任何“化学反应”。对话太死板,场景太陈腐了。这是一段消极的评论。

这些评论都选自斯坦福大学的大型电影数据集。它收录了IMDb上的电影评论,正面评论和负面评论各25000条。这个数据集是情感分析中最为常用的数据集,多数新手在学习NLP时都会用它训练一个情感分析模型。我们这个项目也会使用这个数据集。

这个数据集的文件结构大致如下:

text
1
2
3
4
5
6
7
8
9
10
├─test
│ ├─neg
│ │ ├ 0_2.txt
│ │ ├ 1_3.txt
│ │ └ ...
│ └─pos
├─train
│ ├─neg
│ └─pos
└─imdb.vocab

其中,imdb.vocab记录了数据集中的所有单词,一行一个。testtrain是测试集和训练集,它们的negpos子文件夹分别记录了负面评论和正面评论。每一条评论都是一句话,存在一个txt文件里。

使用下面这个函数,我们就可以读取一个子文件夹里的所有评论:

1
2
3
4
5
6
7
8
9
10
11
import os

def read_imdb(dir='data/aclImdb', split='pos', is_train=True):
subdir = 'train' if is_train else 'test'
dir = os.path.join(dir, subdir, split)
lines = []
for file in os.listdir(dir):
with open(os.path.join(dir, file), 'rb') as f:
line = f.read().decode('utf-8')
lines.append(line)
return lines

这里,顺便介绍一下torchtext提供的分词工具。在NLP中,我们在得到一段文本时,一般需要对文本做一步预处理操作,把一段话变成“单词”的数组。这里的“单词”即可以是英文单词,也可以是数字序列、标点符号。在NLP中,这步预处理操作称为分词,“单词”叫做token(中文直译是“符号,记号”)。

使用torchtext把一段话转换成token数组的方式如下:

1
2
3
4
5
from torchtext.data import get_tokenizer

tokenizer = get_tokenizer('basic_english')
print(tokenizer('a, b'))
# >> ['a', ',', 'b']

有了它,我们可以验证读取IMDb数据集和分词的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
from torchtext.data import get_tokenizer

def main():
lines = read_imdb()
print('Length of the file:', len(lines))
print('lines[0]:', lines[0])
tokenizer = get_tokenizer('basic_english')
tokens = tokenizer(lines[0])
print('lines[0] tokens:', tokens)


if __name__ == '__main__':
main()

输出:

text
1
2
3
Length of the file: 12500
lines[0]: This is a very light headed comedy about a wonderful ...
lines[0] tokens: ['this', 'is', 'a', 'very', 'light', 'headed', 'comedy', 'about', 'a', 'wonderful', ...

获取经GloVe预处理的数据

在这个项目中,我们的模型结构十分简单:输入序列经过词嵌入,送入单层RNN,之后输出结果。整个项目最难的部分是如何把token转换成GloVe词嵌入。在这一节里,我将介绍一种非常简单的实现方法。

torchtext其实还提供了一些更方便的NLP工具类(Field, Vectors等),用于管理词向量。但是,这些工具需要一定的学习成本。由于本文的主旨是介绍深度学习技术而非PyTorch使用技巧,本项目不会用到这些更高级的类。如果你以后要用PyTorch完成NLP任务,建议看完本文后参考相关文章进一步学习torchtext的用法。

PyTorch通常用nn.Embedding来表示词嵌入层。nn.Embedding其实就是一个矩阵,每一行都是一个词嵌入。每一个token都是整型索引,表示该token在词汇表里的序号。有了索引,有了矩阵,就可以得到token的词嵌入了。

但是,有些token在词汇表中并不存在。我们得对输入做处理,把词汇表里没有的token转换成<unk>这个表示未知字符的特殊token。同时,为了对齐序列的长度,我们还得添加<pad>这个特殊字符。而用GloVe直接生成的nn.Embedding里没有<unk><pad>字符。如果使用nn.Embedding的话,我们要编写非常复杂的预处理逻辑。

为此,我们可以用GloVe类的get_vecs_by_tokens直接获取token的词嵌入,以代替nn.Embedding。回忆一下前文提到的get_vecs_by_tokens的使用结果,所有没有出现的token都会被转换成零向量。这样,我们就不必操心数据预处理的事情了。

get_vecs_by_tokens应该发生在数据读取之后,它可以直接被写在Dataset的读取逻辑里。我为此项目编写的Dataset如下:

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
from torch.utils.data import DataLoader, Dataset
from torchtext.data import get_tokenizer
from torchtext.vocab import GloVe

from dldemos.SentimentAnalysis.read_imdb import read_imdb

GLOVE_DIM = 100
GLOVE = GloVe(name='6B', dim=GLOVE_DIM)


class IMDBDataset(Dataset):
def __init__(self, is_train=True, dir='data/aclImdb'):
super().__init__()
self.tokenizer = get_tokenizer('basic_english')
pos_lines = read_imdb(dir, 'pos', is_train)
neg_lines = read_imdb(dir, 'neg', is_train)
self.lines = pos_lines + neg_lines
self.pos_length = len(pos_lines)
self.neg_length = len(neg_lines)

def __len__(self):
return self.pos_length + self.neg_length

def __getitem__(self, index):
sentence = self.tokenizer(self.lines[index])
x = GLOVE.get_vecs_by_tokens(sentence)
label = 1 if index < self.pos_length else 0
return x, label

数据预处理的逻辑都在__getitem__里。每一段字符串会先被token化,之后由GLOVE.get_vecs_by_tokens得到词嵌入数组。

对齐输入

使用一个batch的序列数据时常常会碰到序列不等长的问题。在我的上篇RNN代码实战文章中,我曾计算了序列的最大长度,并手动为每个序列都创建了一个最大长度的向量。实际上,利用PyTorch DataLoadercollate_fn机制,还有一些更简洁的实现方法。

在这个项目中,我们可以这样创建DataLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
from torch.nn.utils.rnn import pad_sequence

def get_dataloader(dir='data/aclImdb'):
def collate_fn(batch):
x, y = zip(*batch)
x_pad = pad_sequence(x, batch_first=True)
y = torch.Tensor(y)
return x_pad, y

train_dataloader = DataLoader(IMDBDataset(True, dir),
batch_size=32,
shuffle=True,
collate_fn=collate_fn)
test_dataloader = DataLoader(IMDBDataset(False, dir),
batch_size=32,
shuffle=True,
collate_fn=collate_fn)
return train_dataloader, test_dataloader

PyTorch DataLoader在获取Dataset的一个batch的数据时,实际上会先调用Dataset.__getitem__,获取若干个样本,再把所有样本拼接成一个batch。比如用__getitem__获取4个[3, 10, 10]的图片张量,再拼接成[4, 3, 10, 10]这一个batch。可是,序列数据通常长度不等,__getitem__可能会获得[10, 100], [15, 100]这样不等长的词嵌入数组。

为了解决这个问题,我们要手动编写把所有张量拼成一个batch的函数。这个函数就是DataLoadercollate_fn函数。我们的collate_fn应该这样编写:

1
2
3
4
5
def collate_fn(batch):
x, y = zip(*batch)
x_pad = pad_sequence(x, batch_first=True)
y = torch.Tensor(y)
return x_pad, y

collate_fn的输入batch是每次__getitem__的结果的数组。比如在我们这个项目中,第一次获取了一个长度为10的积极的句子,__getitem__返回(Tensor[10, 100], 1);第二次获取了一个长度为15的消极的句子,__getitem__返回(Tensor[15, 100], 0)。那么,输入batch的内容就是:

1
[(Tensor[10, 100], 1), (Tensor[15, 100], 0)]

我们可以用x, y = zip(*batch)把它巧妙地转换成两个元组:

1
2
x = (Tensor[10, 100], Tensor[15, 100])
y = (1, 0)

之后,PyTorch的pad_sequence可以把不等长序列的数组按最大长度填充成一整个batch张量。也就是说,经过这个函数后,x_pad变成了:

1
x_pad = Tensor[2, 15, 100]

pad_sequencebatch_first决定了batch是否在第一维。如果它为False,则结果张量的形状是[15, 2, 100]

pad_sequence还可以决定填充内容,默认填充0。在我们这个项目中,被填充的序列已经是词嵌入了,直接用全零向量表示<pad>没问题。

有了collate_fn,构建DataLoader就很轻松了:

1
2
3
4
DataLoader(IMDBDataset(True, dir),
batch_size=32,
shuffle=True,
collate_fn=collate_fn)

注意,使用shuffle=True可以令DataLoader随机取数据构成batch。由于我们的Dataset十分工整,前一半的标签是1,后一半是0,必须得用随机的方式去取数据以提高训练效率。

模型

模型非常简单,就是单层RNN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class RNN(torch.nn.Module):
def __init__(self, hidden_units=64, dropout_rate=0.5):
super().__init__()
self.drop = nn.Dropout(dropout_rate)
self.rnn = nn.GRU(GLOVE_DIM, hidden_units, 1, batch_first=True)
self.linear = nn.Linear(hidden_units, 1)
self.sigmoid = nn.Sigmoid()

def forward(self, x: torch.Tensor):
# x shape: [batch, max_word_length, embedding_length]
emb = self.drop(x)
output, _ = self.rnn(emb)
output = output[:, -1]
output = self.linear(output)
output = self.sigmoid(output)

return output

这里要注意一下,PyTorch的RNN会返回整个序列的输出。而在预测分类概率时,我们只需要用到最后一轮RNN计算的输出。因此,要用output[:, -1]取最后一次的输出。

训练、测试、推理

项目的其他地方都比较简单,我把剩下的所有逻辑都写到main函数里了。

先准备好模型。

1
2
3
4
def main():
device = 'cuda:0'
train_dataloader, test_dataloader = get_dataloader()
model = RNN().to(device)

第一步是训练。训练照着普通RNN的训练模板写就行,没什么特别的。注意,在PyTorch中,使用二分类误差时,要在模型里用nn.Sigmoid,并使用nn.BCELoss作为误差函数。算误差前,得把序列长度那一维去掉。

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
# train

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
citerion = torch.nn.BCELoss()
for epoch in range(100):

loss_sum = 0
dataset_len = len(train_dataloader.dataset)

for x, y in train_dataloader:
batchsize = y.shape[0]
x = x.to(device)
y = y.to(device)
hat_y = model(x)
hat_y = hat_y.squeeze(-1)
loss = citerion(hat_y, y)

optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

loss_sum += loss * batchsize

print(f'Epoch {epoch}. loss: {loss_sum / dataset_len}')

torch.save(model.state_dict(), 'dldemos/SentimentAnalysis/rnn.pth')

训练几十个epoch,模型就差不多收敛了。词嵌入对于训练还是有很大帮助的。

训练完了,接下来要测试精度。这些代码也很简单,跑完了模型和0.5比较得到预测结果,再和正确标签比较算一个准确度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# test

# model.load_state_dict(
# torch.load('dldemos/SentimentAnalysis/rnn.pth', 'cuda:0'))

accuracy = 0
dataset_len = len(test_dataloader.dataset)
model.eval()
for x, y in test_dataloader:
x = x.to(device)
y = y.to(device)
with torch.no_grad():
hat_y = model(x)
hat_y.squeeze_(1)
predictions = torch.where(hat_y > 0.5, 1, 0)
score = torch.sum(torch.where(predictions == y, 1, 0))
accuracy += score.item()
accuracy /= dataset_len

print(f'Accuracy: {accuracy}')

我的精度达到了90%多。考虑到模型并不复杂,且并没有用验证集进行调参,这个精度已经非常棒了。

训练完了模型,我们来看看模型能不能在实际应用中排上用场。我去最近的财经新闻里摘抄了几句对美股的评论:

U.S. stock indexes fell Tuesday, driven by expectations for tighter Federal Reserve policy and an energy crisis in Europe. Stocks around the globe have come under pressure in recent weeks as worries about tighter monetary policy in the U.S. and a darkening economic outlook in Europe have led investors to sell riskier assets.

1
2
3
4
5
6
7
8
9
10
# Inference
tokenizer = get_tokenizer('basic_english')
article = ...

x = GLOVE.get_vecs_by_tokens(tokenizer(article)).unsqueeze(0).to(device)
with torch.no_grad():
hat_y = model(x)
hat_y = hat_y.squeeze_().item()
result = 'positive' if hat_y > 0.5 else 'negative'
print(result)

评论说,受到联邦政府更紧缩的保守经济政策和欧洲能源危机的影响,美国股市指数在周二下跌。近几周,全球股市都笼罩在对美国更紧缩的经济政策的担忧压力之下,欧洲灰暗的经济前景令投资者选择抛售高风险的资产。这显然是一段消极的评论。我的模型也很明智地输出了”negative”。看来,情感分析模型还是能在实际应用中发挥用场的。

总结

在这篇文章中,我介绍了GloVe词嵌入在PyTorch的一个应用。如果你只是想学习深度学习,建议多关注一下词嵌入的意义,不需要学习过多的API。如果你正在入门NLP,建议从这个简单的项目入手,体会一下词嵌入的作用。