一、总体介绍
论文名:Attention is all you need
针对问题:RNN等序列模型不能并行运行,利用完全基于自注意力机制的自编码器去训练
论文创新点包括:
- 利用layer-normal:助于避免训练过程中的梯度消失问题,提高模型的稳定性。
- Scaled Dot-Product Attention:对自注意力机制中 除以QKT除以dk ,以防止乘机过大,如下。
注意力机制公式
- 位置编码:由于 Transformer 不使用递归或卷积,它通过位置编码来加入序列中元素的位置信息。
- 自注意力机制:它允许模型在处理序列的每个元素时同时考虑序列中的所有其他元素,从而捕捉元素之间的关系。
- 多头注意力:Transformer 通过并行的多头注意力机制来捕获序列中不同位置的信息,增强模型的学习能力。
- 代码能够并行处理。
模型框架:
Transformer总体框架
Transformer 是深度学习领域的阶段性成果或者说是具有里程碑意义的成果,最初用于处理自然语言处理任务,由 Vaswani 等人在 2017 年提出。目前在Bert和ChatGPT上几乎都有它的影子。它的核心特征是自注意力机制,这使得模型能够有效地处理序列数据中的长距离依赖问题。由于其出色的并行处理能力和优异的性能,Transformer 已成为当今自然语言处理领域的基石技术。
我们将分模块来详细介绍下Transformer的处理流程。
二、Encoder部分
Encoder部分主要包括:
input Embedding (输入嵌入) / Positional Encoding(位置编码) / Multi-Head Attention 多头注意力机制 / Feed Forward 前馈网络
2.1 input Embedding 层
input Embedding (输入嵌入层): 将输入的单词或者符号转换成固定维度的向量表示,使其能够被模型处理。(因为计算机本身并不能处理文字等信息,需要将其转为向量来处理。)
input Embedding 层
例如:输入句子是 "Hi how are you", input Embedding 层主要是将每个单词映射到长度相等的向量(这里的向量长度是等于3,但实际中一般较大,可以是225、1024等等,也可以自己设定)。
如下,这里我们就将单词"Hi"映射为长度为3的向量[0.1, 0.54, 0.29],[0.1, 0.54, 0.29]被称为嵌入向量。
input Embedding 示例
2.2 Positional Encoding(位置编码)
由于Transformer模型本身不具备处理序列顺序的能力,但因为在文本信息的处理时,当前单词是跟前后单词有关联系的,因此需要在输入嵌入层( input Embedding)后加入位置编码(position encoding),以提供位置信息;方便后面的模型训练能够获取文本的位置信息,从而更精准地来进行模型训练。
位置编码通常是一组与嵌入向量维度相同的向量,它们通过特定的数学函数生成,并与嵌入向量相加,具体下面会说明。
位置编码的维度要和嵌入向量的维度一样的原因是因为位置编码是要和嵌入向量的维度相加,因此维度需要一致。
(这里的位置编码内容描述的比较多,如果已经掌握的可以直接跳到下一小节。)
首先,我们先看原文中的位置编码函数是如下定义的:
PosEnc(pos,2i)=sin(pos100002idmodel)
PosEnc(pos,2i+1)=cos(pos100002idmodel)
其中, dmodel 是 input Embedding嵌入向量的维度,pos 是单词在序列中的位置,i 是嵌入向量中的维度索引。
举个栗子。
假设输入数据为[Hi, how, are, you],通过 input Embedding 后将每个单词映射为3为的嵌入向量,如下:
[[0.1, 0.54, 0.29], [2.1, 3.8, 0.36], [1.5, 0.18, 2.2]]
接着,我们需要计算位置编码;那么对于单词 "Hi"(位置为0), "how"(位置为1),"are"(位置为2),"you"(位置为3)的位置编码分别是:
对于位置0的编码:
PosEnc(0,0)=sin(01000003)=0
PosEnc(0,1)=cos(01000013)=1
PosEnc(0,2)=sin(01000023)=0
对于位置1的编码:
PosEnc(1,0)=sin(11000003)≈0.8415
PosEnc(1,1)=cos(11000013)≈0.00464
PosEnc(1,2)=sin(11000023)≈0.00464
(PS:这里的约等于的值可能算的不是很对,大家不要介意,只要看大概是什么样的流程即可)
简单地说,当位置编码为偶数时使用sin函数,当位置编码为奇数时使用cos函数;对于位置2、3的编码以此类推。
从上可以得到:
- 第一个单词 "Hi", 它的位置编码 -> [0, 1, 0]
- 第二个单词 "how", 它的位置编码 -> [0.8415, 0.00464, 0.00464]
- 第三个单词.......
然后,我们将位置编码向量加到嵌入向量上,得到位置感知的嵌入:
- "Hi" 的最终嵌入 -> [0.1 + 0, 0.54 + 1, 0.29 + 0]
- "how" 的最终嵌入 -> [2.1 + 0.8415, 3.8 + 0.00464, 0.36 + 0.00464]
- ......
Q:可能有人会问:为什么这里的位置编码要这么复杂,用sin和cons函数;为什么不直接使用one-hot编码?
A:原因有三:第一, One-hot编码的维度与序列的长度直接相关。在处理 长序列时,如果序列长度较长(比如100),这将导致维度非常高,从而增加了计算复杂性和模型参数。相比之下,Transformer中的位置编码使用正弦和 余弦函数,其维度仅与嵌入维度$$d_{model}$$相关,与序列长度无关,因此更加高效。
第二,用正弦和余弦函数作为位置编码允许模型更好地泛化到不同长度的序列。One-hot编码难以适应序列长度的变化,因为每个位置都有一个固定的、唯一的表示,这在处理比训练时更长的序列时会遇到问题。而基于正弦和余弦的位置编码通过数学函数生成,可以应对任意长度的序列。
第三,正弦和余弦位置编码通过其波形的性质可以让模型捕捉到单词之间的相对位置信息。举个例子,假设我们有一个很简短的句子:"Hello World",我们将其输入到 Transformer模型中。为简化起见,我们假设模型的嵌入维度是2,这意味着每个单词的位置编码将是一个2维向量。假设位置0的位置编码是 [0, 1],位置1的位置编码是 [0.0001, 1];,模型可能会发现当两个词的位置编码在某一维度上的差异是特定模式时(如在我们的例子中,第一个维度的微小变化),这两个词在句子中可能具有特定的相对关系。这种机制使得Transformer能够处理序列数据,理解词语之间的关系,即使它的自注意力机制本身不直接处理序列顺序。
2.3 Multi-Head Attention (多头注意力机制)
对于多头注意力机制的理解,可以看下这篇博客。
可乐不加糖:注意力机制综述(图解完整版附代码)854 赞同 · 89 评论文章编辑
2.4 Norm (层归一化)
Transformer中主要利用layer-normal层归一化,而并不是批归一化,这有助于避免训练过程中的梯度消失问题,提高模型的稳定性。
使用层归一化的原因在于:在处理自然语言任务时,序列的长度通常是变化的。层归一化因为是对单个样本中的所有特征进行归一化,所以能够更好地处理这种可变长度的情况。而批归一化则依赖于整个批次的数据统计,这在处理小批次或可变长度的序列时可能不太有效。此外,Transformer模型特别依赖于捕捉长期依赖关系(即序列中相隔较远的元素之间的关系),而层归一化有助于缓解训练过程中可能出现的梯度消失问题,从而更有效地学习这些长期依赖关系。
什么时候使用:在残差连接(Residual Connection)之后,Transformer模型中的每个子层都伴随一个残差连接,然后紧接着一个层归一化操作。具体来说,对于每个子层(例如自注意力或前馈网络),输入首先通过子层自身,然后将子层的输出与输入进行相加(残差连接),最后对这个相加的结果进行层归一化。
举个栗子。
假设我们有一个神经网络,正在处理以下4个样本的小批次,每个样本有2个特征:
批次数据:
样本1: [1, 3]
样本2: [2, 4]
样本3: [3, 2]
样本4: [4, 1]
- 批归一化操作
在批归一化中,我们对每个特征在整个批次中进行归一化。以第一个特征为例,其均值和标准差分别为:
均值 = (1 + 2 + 3 + 4) / 4 = 2.5
标准差 = sqrt(((1-2.5)² + (2-2.5)² + (3-2.5)² + (4-2.5)²) / 4) ≈ 1.29
然后,对每个样本的这个特征进行归一化:
归一化后的特征1:
样本1: (1 - 2.5) / 1.29 ≈ -1.16
样本2: (2 - 2.5) / 1.29 ≈ -0.39
样本3: (3 - 2.5) / 1.29 ≈ 0.39
样本4: (4 - 2.5) / 1.29 ≈ 1.16
- 层归一化操作
对于同样的数据,如果我们使用层归一化,那么归一化是在每个样本内部进行的。
以样本1为例:
样本1: [1, 3]
对于样本1,我们计算其所有特征的均值和标准差:
均值 = (1 + 3) / 2 = 2
标准差 = sqrt(((1-2)² + (3-2)²) / 2) ≈ 1.41
然后对样本1的每个特征进行归一化:
归一化后的样本1:
特征1: (1 - 2) / 1.41 ≈ -0.71
特征2: (3 - 2) / 1.41 ≈ 0.71
对于其他样本,我们也会重复这一过程。
简单地说:
- 批归一化是跨样本对每个特征分别归一化,每个特征的归一化基于整个批次的统计数据。
- 层归一化是在每个样本内部进行的,每个样本的所有特征都根据该样本的统计数据进行归一化。
2.5 残差连接
在下面红色框里的残差连接,主要是将多头注意力机制的输出向量加到原始输入向量(添加过位置编码后的Embedding向量)上,在经过层归一化。
残差连接
2.6 Feed Forward (前馈网络)
其实在Transformer原文中 Feed Forward 的全称是 Position-wise Feed-Forward Networks (点对点前馈神经网络,简称FFN)。
其实他就是两个全连接层:第一个全连接层将输入的维度扩展(例如,从512维扩展到2048维),接着是一个激活函数(通常是ReLU或GELU),然后是第二个全连接层,将维度从扩展的维度缩减回原始维度(例如,从2048维缩减回512维)。
但有一点需要注意的是:
- 在Transformer中FFN是网络对输入序列中的每个元素(或“点”)独立地进行处理;对序列中的每个位置独立应用相同的操作。
- 而在全连接层中,通常处理整个输入作为一个整体,而不是独立地处理输入中的每个元素。
举个例子。
假设我们有一个输入向量 [x1, x2],并且有一个全连接层,这个层有两个神经元。每个神经元都有自己的权重和偏置。
- 神经元1 的权重为
[w11, w12]
,偏置为b1
。 - 神经元2 的权重为
[w21, w22]
,偏置为b2
。
全连接层的做法:全连接层的输出是每个神经元的加权和加上偏置。因此,对于输入向量 [x1, x2]
,全连接层的输出向量 [y1, y2]
计算如下:
y1 = w11*x1 + w12*x2 + b1
y2 = w21*x1 + w22*x2 + b2
可以看到,全连接层中的每个输出元素(y1和y2)都是通过整合输入向量的所有元素计算得到的,而不是单独对每个输入元素进行相同的操作。
Transformer中的点对点前馈网络的做法:我们对每个元素独立应用相同的线性变换,假设这个变换是乘以权重w
加上偏置b
,计算如下:
y1 = w*x1 + b
y2 = w*x2 + b
在这里,每个输入元素被单独处理,每个输出元素只依赖于对应的输入元素,且所有输入元素都应用了相同的变换。这就是所谓的点对点操作。
简单地说,在全连接层的例子中,输出的每个元素是输入向量所有元素的综合结果。而在Transformer的FFN中,输出的每个元素只依赖于输入向量中相对应的元素,且每个元素都经历了相同的变换。
Q:为什么使用点对点?而不是直接使用普遍的全连接层?
A:1. 保留位置信息:Transformer处理的是序列数据,其中每个元素的位置非常重要。点对点前馈网络(FFN)确保在处理过程中保持每个元素的位置信息。在FFN中,每个序列位置的元素都独立地进行相同的处理,这样可以确保输出序列中每个位置的信息仅由输入序列中相应位置的元素决定。
2. 并行处理:点对点操作允许模型在处理序列数据时实现更高的 并行性。由于每个位置的计算是独立的,它们可以同时进行,这对于提高计算效率和加速训练和推断过程非常有帮助。
3. 配合自注意力机制:在Transformer中,自注意力机制已经负责捕捉序列中不同位置之间的依赖关系。因此,FFN不需要再次处理这些关系。通过使用点对点操作,FFN可以专注于对每个位置的表示进行更丰富的、非线性的处理,而不是重新考虑位置之间的相互作用。
前馈网络处理完后,先对其进行一个残差连接,再进行层归一化处理。
以上就是编码器部分的所有组件,编码器的作用主要是为了将输入编码为连续表示,并带有注意力信息;有助于帮助解码器在解码过程中关注输入中的重要词汇。当然,需要注意的是,可以将编码器堆叠 N 次,以进一步编码信息,其中每一层都有机会学习不同的注意力表示,从而提高Transformer的预测能力;如下图所示。
三、Decoder部分
写在前面。
Decoderde的任务是生成文本序列,需要注意的是解码器是自回归的,Decoder部分主要包括:Masked Multi-Head Attention 具有掩码的多头注意力机制 / Multi-Head Attention 多头注意力机制 / Feed Forward 前馈网络 / 分类器。
Q:什么是自回归?
A:在Transformer模型中,自回归任务指的是一种序列生成任务,其中模型在生成每个 新元素时都依赖于之前已生成的序列。简言之,就是模型在预测下一个输出时,会使用到目前为止已经生成的所有输出作为上下文信息。这也是为什么ChatGPT的回答是一个字一个字往外蹦的原因。
Q:在详细介绍之前,我们需要先厘清Decoder部分的输入数据由哪些?看到图中Decoder部分的输入有个Outputs,难免会有些疑惑。
A:在Transformer模型中,解码器(Decoder)部分的输入通常包括两个主要部分:
1. 编码器的输出:图中可以看到编码器(Encoder)和解码器(Decoder)部分有一个连线,编码器(Encoder)的输出是解码器的一部分输入,这个应该也是最容易理解的来。在编码器-解码器架构中,编码器首先处理源序列(例如英文句子),产生一个包含序列信息的表示,然后传递给解码器。解码器利用这些信息来帮助生成目标序列(例如法文句子)。
2. 目标序列的前缀:这部分也就是大家在图中看到Decoder部分最下方的Outputs;这个前缀是目标序列当前已知的部分,解码器的任务是基于这个前缀生成序列的下一部分。例如,在机器翻译任务中,如果源语言句子是英文,目标语言句子是法文,那么解码器的输入就是目前已经生成的法文句子部分。当然,在Transformer模型的解码器刚开始工作时,并没有现成的序列前缀,通常会使用一个特殊的起始符号(例如"<sos>",代表“start of sequence”,也有的用的是"<bos>")作为序列的初始输入。这个起始符号的作用是表示序列的开始,为模型提供一个明确的起点。
例如,假设目标序列是“Hello World”,那么解码器的输入序列将是“<sos> Hello World”,其中“<sos>”是起始符号。
3.1 Output Embedding层 和 Positional Encoding(位置编码)
在Decoder部分,也可以看到有Embedding 和 Positional Encoding层,如下图所示。
在训练阶段,目标序列是已知的,整个已知的目标序列(包括起始符号,通常是“<sos>”)一次性转换为索引,然后通过嵌入层转换为密集的向量表示。然后,这些嵌入向量会被加上位置编码,以引入序列中单词的位置信息。加上位置编码的嵌入向量作为解码器的输入,用于模型的训练过程。
这里的目标序列可以理解为输入的序列,就说输入的文本数据。
3.2 Masked Multi-Head Attention(具有掩码的多头注意力机制)
由于在Decoder中的Embedding层 和 Positional Encoding层是对目标序列所有单词都进行了嵌入,但我们利用Transformer模型在生成序列时,是一个个单词往外蹦的;那么,确保在预测序列的特定位置时,模型只能使用到该位置之前的信息,其后面的信息就不能被注意力机制看到(也就是保证模型在训练的时候只能看到当前单词之前的单词,不能看到之后的),从而防止信息泄露。就需要想办法去遮挡后面的信息。
具有掩码的多头注意力机制
例如,我们在使用自注意力机制的时候,会计算当前单词 a2 和其他单词( a1,a3,a4 )的关系,如下图所示。
但是呢,当我们在Transformer中的Decoder中开始需要生成序列时,当前位置需要预测的单词,只能允许和前面的信息有关,就比如我们要预测 a2 是什么单词,那我们只能使用 和a1和a2 的信息,并不能用到 和a3和a4 的信息,如下图所示。
那如何在Transformer网络中去遮挡后面的信息呢?
主要是通过在自注意力机制中应用一个掩码(Mask)来实现的。准确地说,还是通过矩阵来进行操作。
例如,现在有段话:“i am fine”, 我们提前计算好了他们之间的注意力得分,如下图所示。(这里的<start>和之前的<sos>是一样的)
但当在计算单词 “am” 的注意力得分时,只能访问它自己和它之前的单词,就不能获得 “am”之后的单词信息,比如 “fine”,因为 “fine” 是之后才会生成的单词;如下图所示。
我们可以先解释下这张图:
- 当我们访问到<start>位置时,只能获取<start>和它自己的注意力得分,其他的不能获取
- 当我们访问到 I 位置时,只能获取 I 和 <start>, 以及 I 和 它自己的注意力得分,其他的不能获取
- 当我们访问到 am 位置时,只能获取 am 和 <start>, am 和 I, 以及 am 和 它自己的注意力得分,其他的不能获取
- 以此类推......
为了防止解码器看到未来的信息,就需要在已得到的注意力分数矩阵式加上一个mask机制(或者叫mask矩阵),如下图所示。
其中,-inf表示负无穷大。
当以上得到的橙色矩阵再经过sigmoid函数时,相对“当前单词”的“未来单词”的注意力得分就会变为0,这样就不会访问到未来信息。
3.3 Multi-Head Attention(多头注意力机制)
这里的多头注意力机制的原理是和Encoder部分一样的,具体计算过程可以参考Encoder部分。
Decoder中的多头注意力机制
但需要注意的是,这一部分主要是将解码器当前生成的序列与原始输入序列(经过编码器处理的)联系起来,用于生成下一个目标单词。这部分的注意力机制作用主要有两个:
- 连接源序列和目标序列:通过关注编码器的输出,解码器可以根据需要从源序列中提取相关的上下文信息;
- 动态关注不同部分:同时,在解码过程中,模型可能会选择关注输入序列的不同部分。
举个例子:
假设我们正在进行英译法的机器翻译任务。给定一个英文句子(比如 "The cat sits on the mat"),经过编码器处理后,解码器开始逐步生成法文翻译。
- 当解码器准备生成下一个法文词时,不带掩码的编码器-解码器注意力机制会参考整个英文句子的编码(Encoder的结果)表示。
- 这个机制允许解码器在生成每个法文词时都能够根据需要关注英文句子中的相应部分,比如在生成“le chat”(猫)时,可能会特别关注“the cat”的编码表示。
3.4 Feed Forward(前馈网络)
参考Encoder部分的 Feed Forward 。
3.5 分类器
最后,由一个线性层和一个softmax来得到单词概率。
分类器
3.6 生成序列停止
不知道大家有没有注意一个问题:Transformer模型并没有定义结果的输出长度,那整个序列就会一直生成下去。那程序什么时候停止呢?
就是在模型输出"<eos>"(代表“end of sequence”)时停止。
四、总结
这一部分,我对Transformer中的每个部分的作用做了一个总结,仅供参考。
Transformer中各组件的作用
五、代码
代码建议大家直接放在google colab上,可以直接运行,不需要搭建环境。
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
def make_batch(sentences):
input_batch = [[src_vocab[n] for n in sentences[0].split()]]
output_batch = [[tgt_vocab[n] for n in sentences[1].split()]]
target_batch = [[tgt_vocab[n] for n in sentences[2].split()]]
return torch.LongTensor(input_batch), torch.LongTensor(output_batch), torch.LongTensor(target_batch)
def get_sinusoid_encoding_table(n_position, d_model):
def cal_angle(position, hid_idx):
return position / np.power(10000, 2 * (hid_idx // 2) / d_model)
def get_posi_angle_vec(position):
return [cal_angle(position, hid_j) for hid_j in range(d_model)]
sinusoid_table = np.array([get_posi_angle_vec(pos_i) for pos_i in range(n_position)])
sinusoid_table[:, 0::2] = np.sin(sinusoid_table[:, 0::2]) # dim 2i
sinusoid_table[:, 1::2] = np.cos(sinusoid_table[:, 1::2]) # dim 2i+1
return torch.FloatTensor(sinusoid_table)
def get_attn_pad_mask(seq_q, seq_k):
batch_size, len_q = seq_q.size()
batch_size, len_k = seq_k.size()
# eq(zero) is PAD token
pad_attn_mask = seq_k.data.eq(0).unsqueeze(1) # batch_size x 1 x len_k(=len_q), one is masking
return pad_attn_mask.expand(batch_size, len_q, len_k) # batch_size x len_q x len_k
def get_attn_subsequent_mask(seq):
attn_shape = [seq.size(0), seq.size(1), seq.size(1)]
subsequent_mask = np.triu(np.ones(attn_shape), k=1)
subsequent_mask = torch.from_numpy(subsequent_mask).byte()
return subsequent_mask
class ScaledDotProductAttention(nn.Module):
def __init__(self):
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask):
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k) # scores : [batch_size x n_heads x len_q(=len_k) x len_k(=len_q)]
scores.masked_fill_(attn_mask, -1e9) # Fills elements of self tensor with value where mask is one.
attn = nn.Softmax(dim=-1)(scores)
context = torch.matmul(attn, V)
return context, attn
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
self.W_Q = nn.Linear(d_model, d_k * n_heads)
self.W_K = nn.Linear(d_model, d_k * n_heads)
self.W_V = nn.Linear(d_model, d_v * n_heads)
self.linear = nn.Linear(n_heads * d_v, d_model)
self.layer_norm = nn.LayerNorm(d_model)
def forward(self, Q, K, V, attn_mask):
# q: [batch_size x len_q x d_model], k: [batch_size x len_k x d_model], v: [batch_size x len_k x d_model]
residual, batch_size = Q, Q.size(0)
# (B, S, D) -proj-> (B, S, D) -split-> (B, S, H, W) -trans-> (B, H, S, W)
q_s = self.W_Q(Q).view(batch_size, -1, n_heads, d_k).transpose(1,2) # q_s: [batch_size x n_heads x len_q x d_k]
k_s = self.W_K(K).view(batch_size, -1, n_heads, d_k).transpose(1,2) # k_s: [batch_size x n_heads x len_k x d_k]
v_s = self.W_V(V).view(batch_size, -1, n_heads, d_v).transpose(1,2) # v_s: [batch_size x n_heads x len_k x d_v]
attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1) # attn_mask : [batch_size x n_heads x len_q x len_k]
# context: [batch_size x n_heads x len_q x d_v], attn: [batch_size x n_heads x len_q(=len_k) x len_k(=len_q)]
context, attn = ScaledDotProductAttention()(q_s, k_s, v_s, attn_mask)
context = context.transpose(1, 2).contiguous().view(batch_size, -1, n_heads * d_v) # context: [batch_size x len_q x n_heads * d_v]
output = self.linear(context)
return self.layer_norm(output + residual), attn # output: [batch_size x len_q x d_model]
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
super(PoswiseFeedForwardNet, self).__init__()
self.conv1 = nn.Conv1d(in_channels=d_model, out_channels=d_ff, kernel_size=1)
self.conv2 = nn.Conv1d(in_channels=d_ff, out_channels=d_model, kernel_size=1)
self.layer_norm = nn.LayerNorm(d_model)
def forward(self, inputs):
residual = inputs # inputs : [batch_size, len_q, d_model]
output = nn.ReLU()(self.conv1(inputs.transpose(1, 2)))
output = self.conv2(output).transpose(1, 2)
return self.layer_norm(output + residual)
class EncoderLayer(nn.Module):
def __init__(self):
super(EncoderLayer, self).__init__()
self.enc_self_attn = MultiHeadAttention()
self.pos_ffn = PoswiseFeedForwardNet()
def forward(self, enc_inputs, enc_self_attn_mask):
enc_outputs, attn = self.enc_self_attn(enc_inputs, enc_inputs, enc_inputs, enc_self_attn_mask) # enc_inputs to same Q,K,V
enc_outputs = self.pos_ffn(enc_outputs) # enc_outputs: [batch_size x len_q x d_model]
return enc_outputs, attn
class DecoderLayer(nn.Module):
def __init__(self):
super(DecoderLayer, self).__init__()
self.dec_self_attn = MultiHeadAttention()
self.dec_enc_attn = MultiHeadAttention()
self.pos_ffn = PoswiseFeedForwardNet()
def forward(self, dec_inputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask):
dec_outputs, dec_self_attn = self.dec_self_attn(dec_inputs, dec_inputs, dec_inputs, dec_self_attn_mask)
dec_outputs, dec_enc_attn = self.dec_enc_attn(dec_outputs, enc_outputs, enc_outputs, dec_enc_attn_mask)
dec_outputs = self.pos_ffn(dec_outputs)
return dec_outputs, dec_self_attn, dec_enc_attn
"""
编码器
"""
class Encoder(nn.Module):
def __init__(self):
super(Encoder, self).__init__()
# 将输入单词进行Embedding
self.src_emb = nn.Embedding(src_vocab_size, d_model) # src_vocab_size:词表大小;d_model:嵌入维度
# 添加位置编码
self.pos_emb = nn.Embedding.from_pretrained(get_sinusoid_encoding_table(src_len+1, d_model),freeze=True)
# 前馈神经网络
self.layers = nn.ModuleList([EncoderLayer() for _ in range(n_layers)])
def forward(self, enc_inputs): # enc_inputs : [batch_size x source_len]
# 词向量 和 位置编码进行相加
enc_outputs = self.src_emb(enc_inputs) + self.pos_emb(torch.LongTensor([[1,2,3,4,0]]))
#
enc_self_attn_mask = get_attn_pad_mask(enc_inputs, enc_inputs)
enc_self_attns = []
for layer in self.layers:
enc_outputs, enc_self_attn = layer(enc_outputs, enc_self_attn_mask)
enc_self_attns.append(enc_self_attn)
return enc_outputs, enc_self_attns
class Decoder(nn.Module):
def __init__(self):
super(Decoder, self).__init__()
self.tgt_emb = nn.Embedding(tgt_vocab_size, d_model)
self.pos_emb = nn.Embedding.from_pretrained(get_sinusoid_encoding_table(tgt_len+1, d_model),freeze=True)
self.layers = nn.ModuleList([DecoderLayer() for _ in range(n_layers)])
def forward(self, dec_inputs, enc_inputs, enc_outputs): # dec_inputs : [batch_size x target_len]
dec_outputs = self.tgt_emb(dec_inputs) + self.pos_emb(torch.LongTensor([[5,1,2,3,4]]))
dec_self_attn_pad_mask = get_attn_pad_mask(dec_inputs, dec_inputs)
dec_self_attn_subsequent_mask = get_attn_subsequent_mask(dec_inputs)
dec_self_attn_mask = torch.gt((dec_self_attn_pad_mask + dec_self_attn_subsequent_mask), 0)
dec_enc_attn_mask = get_attn_pad_mask(dec_inputs, enc_inputs)
dec_self_attns, dec_enc_attns = [], []
for layer in self.layers:
dec_outputs, dec_self_attn, dec_enc_attn = layer(dec_outputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask)
dec_self_attns.append(dec_self_attn)
dec_enc_attns.append(dec_enc_attn)
return dec_outputs, dec_self_attns, dec_enc_attns
class Transformer(nn.Module):
def __init__(self):
super(Transformer, self).__init__()
# 编码器
self.encoder = Encoder()
# 解码器
self.decoder = Decoder()
# 解码器最后的分类器,分类器的输入d_model是解码层每个token的输出维度大小,需要将其转为词表大小,再计算softmax;计算哪个词出现的概率最大
self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False)
def forward(self, enc_inputs, dec_inputs):
# Transformer的两个输入,一个是编码器的输入(源序列),一个是解码器的输入(目标序列)
# 其中,enc_inputs的大小应该是 [batch_size, src_len] ; dec_inputs的大小应该是 [batch_size, dec_inputs]
"""
源数据输入到encoder之后得到 enc_outputs, enc_self_attns;
enc_outputs是需要传给decoder的矩阵,表示源数据的表示特征
enc_self_attns表示单词之间的相关性矩阵
"""
enc_outputs, enc_self_attns = self.encoder(enc_inputs)
"""
decoder的输入数据包括三部分:
1. encoder得到的表示特征enc_outputs、
2. 解码器的输入dec_inputs(目标序列)、
3. 以及enc_inputs
"""
dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
"""
将decoder的输出映射到词表大小,最后进行softmax输出即可
"""
dec_logits = self.projection(dec_outputs) # dec_logits : [batch_size x src_vocab_size x tgt_vocab_size]
return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns
def showgraph(attn):
attn = attn[-1].squeeze(0)[0]
attn = attn.squeeze(0).data.numpy()
fig = plt.figure(figsize=(n_heads, n_heads)) # [n_heads, n_heads]
ax = fig.add_subplot(1, 1, 1)
ax.matshow(attn, cmap='viridis')
ax.set_xticklabels(['']+sentences[0].split(), fontdict={'fontsize': 14}, rotation=90)
ax.set_yticklabels(['']+sentences[2].split(), fontdict={'fontsize': 14})
plt.show()
if __name__ == '__main__':
# 句子的输入部分
"""
第一个句子 是 编码器的输入
第二个句子 是 解码器的输入
第三个句子 是 标签
P 可以理解为 编码器输入结束的字符(Padding填充字符)
S 可以理解为 Start
E 可以理解为 End
此外,需要注意的是,由于文本内容长度往往会不一致,因此在代码实现过程中,我们往往会设置一个最大长度max_length,
- 大于max_length的句子,多余的部分将会被裁剪
- 小于max_length的句子,缺少的部分将会被填充
"""
sentences = ['ich mochte ein bier P', 'S i want a beer', 'i want a beer E']
# Transformer Parameters
# Padding Should be Zero
src_vocab = {'P': 0, 'ich': 1, 'mochte': 2, 'ein': 3, 'bier': 4}
src_vocab_size = len(src_vocab)
tgt_vocab = {'P': 0, 'i': 1, 'want': 2, 'a': 3, 'beer': 4, 'S': 5, 'E': 6}
number_dict = {i: w for i, w in enumerate(tgt_vocab)}
tgt_vocab_size = len(tgt_vocab)
src_len = 5 # length of source 输入长度
tgt_len = 5 # length of target 解码端的输入长度
d_model = 512 # Embedding Size Embedding后的长度
d_ff = 2048 # FeedForward dimension 前馈神经网络的中间维度
d_k = d_v = 64 # dimension of K(=Q), V
n_layers = 6 # number of Encoder of Decoder Layer Encoder和Decoder N的个数
n_heads = 8 # number of heads in Multi-Head Attention 多头注意力机制分为几个头
model = Transformer()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
enc_inputs, dec_inputs, target_batch = make_batch(sentences)
for epoch in range(20):
optimizer.zero_grad()
outputs, enc_self_attns, dec_self_attns, dec_enc_attns = model(enc_inputs, dec_inputs)
loss = criterion(outputs, target_batch.contiguous().view(-1))
print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss))
loss.backward()
optimizer.step()
# Test
predict, _, _, _ = model(enc_inputs, dec_inputs)
predict = predict.data.max(1, keepdim=True)[1]
print(sentences[0], '->', [number_dict[n.item()] for n in predict.squeeze()])
print('first head of last state enc_self_attns')
showgraph(enc_self_attns)
print('first head of last state dec_self_attns')
showgraph(dec_self_attns)
print('first head of last state dec_enc_attns')
showgraph(dec_enc_attns)
运行结果如下: