学习目标
🍀 了解编码器中各个组成部分的作用.
🍀 掌握编码器中各个组成部分的实现过程.
🍔 编码器介绍
编码器部分: * 由N个编码器层堆叠而成 * 每个编码器层由两个子层连接结构组成 * 第一个子层连接结构包括一个多头自注意力子层和规范化层以及一个残差连接 * 第二个子层连接结构包括一个前馈全连接子层和规范化层以及一个残差连接
🍔 掩码张量
2.1 掩码张量介绍
- 掩代表遮掩,码就是我们张量中的数值,它的尺寸不定,里面一般只有1和0的元素,代表位置被遮掩或者不被遮掩,至于是0位置被遮掩还是1位置被遮掩可以自定义,因此它的作用就是让另外一个张量中的一些数值被遮掩,也可以说被替换, 它的表现形式是一个张量.
2.2 掩码张量的作用
- 在transformer中, 掩码张量的主要作用在应用attention(将在下一小节讲解)时,有一些生成的attention张量中的值计算有可能已知了未来信息而得到的,未来信息被看到是因为训练时会把整个输出结果都一次性进行Embedding,但是理论上解码器的的输出却不是一次就能产生最终结果的,而是一次次通过上一次结果综合得出的,因此,未来的信息可能被提前利用. 所以,我们会进行遮掩. 关于解码器的有关知识将在后面的章节中讲解.
2.3 生成掩码张量的代码分析
def subsequent_mask(size): """生成向后遮掩的掩码张量, 参数size是掩码张量最后两个维度的大小, 它的最后两维形成一个方阵""" # 在函数中, 首先定义掩码张量的形状 attn_shape = (1, size, size) # 然后使用np.ones方法向这个形状中添加1元素,形成上三角阵, 最后为了节约空间, # 再使其中的数据类型变为无符号8位整形unit8 subsequent_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8') # 最后将numpy类型转化为torch中的tensor, 内部做一个1 - 的操作, # 在这个其实是做了一个三角阵的反转, subsequent_mask中的每个元素都会被1减, # 如果是0, subsequent_mask中的该位置由0变成1 # 如果是1, subsequent_mask中的该位置由1变成0 return torch.from_numpy(1 - subsequent_mask)
- np.triu演示:
>>> np.triu([[1,2,3],[4,5,6],[7,8,9],[10,11,12]], k=-1) array([[ 1, 2, 3], [ 4, 5, 6], [ 0, 8, 9], [ 0, 0, 12]]) >>> np.triu([[1,2,3],[4,5,6],[7,8,9],[10,11,12]], k=0) array([[ 1, 2, 3], [ 0, 5, 6], [ 0, 0, 9], [ 0, 0, 0]]) >>> np.triu([[1,2,3],[4,5,6],[7,8,9],[10,11,12]], k=1) array([[ 0, 2, 3], [ 0, 0, 6], [ 0, 0, 0], [ 0, 0, 0]])
- 输入实例:
# 生成的掩码张量的最后两维的大小 size = 5
- 调用:
sm = subsequent_mask(size) print("sm:", sm)
- 输出效果:
# 最后两维形成一个下三角阵 sm: (0 ,.,.) = 1 0 0 0 0 1 1 0 0 0 1 1 1 0 0 1 1 1 1 0 1 1 1 1 1 [torch.ByteTensor of size 1x5x5]
2.4 掩码张量的可视化
plt.figure(figsize=(5,5)) plt.imshow(subsequent_mask(20)[0])
- 输出效果:
- 效果分析:
- 通过观察可视化方阵, 黄色是1的部分, 这里代表被遮掩, 紫色代表没有被遮掩的信息, 横坐标代表目标词汇的位置, 纵坐标代表可查看的位置;
- 我们看到, 在0的位置我们一看望过去都是黄色的, 都被遮住了,1的位置一眼望过去还是黄色, 说明第一次词还没有产生, 从第二个位置看过去, 就能看到位置1的词, 其他位置看不到, 以此类推.
2.5 掩码张量总结
- 学习了什么是掩码张量:
- 掩代表遮掩,码就是我们张量中的数值,它的尺寸不定,里面一般只有1和0的元素,代表位置被遮掩或者不被遮掩,至于是0位置被遮掩还是1位置被遮掩可以自定义,因此它的作用就是让另外一个张量中的一些数值被遮掩, 也可以说被替换, 它的表现形式是一个张量.
- 学习了掩码张量的作用:
- 在transformer中, 掩码张量的主要作用在应用attention(将在下一小节讲解)时,有一些生成的attetion张量中的值计算有可能已知量未来信息而得到的,未来信息被看到是因为训练时会把整个输出结果都一次性进行Embedding,但是理论上解码器的的输出却不是一次就能产生最终结果的,而是一次次通过上一次结果综合得出的,因此,未来的信息可能被提前利用. 所以,我们会进行遮掩. 关于解码器的有关知识将在后面的章节中讲解.
- 学习并实现了生成向后遮掩的掩码张量函数: subsequent_mask
- 它的输入是size, 代表掩码张量的大小.
- 它的输出是一个最后两维形成1方阵的下三角阵.
- 最后对生成的掩码张量进行了可视化分析, 更深一步理解了它的用途.
🍔 注意力机制
我们这里使用的注意力的计算规则:
3.1 注意力计算规则的代码分析
import torch.nn.functional as F def attention(query, key, value, mask=None, dropout=None): """注意力机制的实现, 输入分别是query, key, value, mask: 掩码张量, dropout是nn.Dropout层的实例化对象, 默认为None""" # 在函数中, 首先取query的最后一维的大小, 一般情况下就等同于我们的词嵌入维度, 命名为d_k d_k = query.size(-1) # 按照注意力公式, 将query与key的转置相乘, 这里面key是将最后两个维度进行转置, 再除以缩放系数根号下d_k, 这种计算方法也称为缩放点积注意力计算. # 得到注意力得分张量scores scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k) # 接着判断是否使用掩码张量 if mask is not None: # 使用tensor的masked_fill方法, 将掩码张量和scores张量每个位置一一比较, 如果掩码张量处为0 # 则对应的scores张量用-1e9这个值来替换, 如下演示 scores = scores.masked_fill(mask == 0, -1e9) # 对scores的最后一维进行softmax操作, 使用F.softmax方法, 第一个参数是softmax对象, 第二个是目标维度. # 这样获得最终的注意力张量 p_attn = F.softmax(scores, dim = -1) # 之后判断是否使用dropout进行随机置0 if dropout is not None: # 将p_attn传入dropout对象中进行'丢弃'处理 p_attn = dropout(p_attn) # 最后, 根据公式将p_attn与value张量相乘获得最终的query注意力表示, 同时返回注意力张量 return torch.matmul(p_attn, value), p_attn
- tensor.masked_fill演示:
>>> input = Variable(torch.randn(5, 5)) >>> input Variable containing: 2.0344 -0.5450 0.3365 -0.1888 -2.1803 1.5221 -0.3823 0.8414 0.7836 -0.8481 -0.0345 -0.8643 0.6476 -0.2713 1.5645 0.8788 -2.2142 0.4022 0.1997 0.1474 2.9109 0.6006 -0.6745 -1.7262 0.6977 [torch.FloatTensor of size 5x5] >>> mask = Variable(torch.zeros(5, 5)) >>> mask Variable containing: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 [torch.FloatTensor of size 5x5] >>> input.masked_fill(mask == 0, -1e9) Variable containing: -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 -1.0000e+09 [torch.FloatTensor of size 5x5]
- 输入参数:
# 我们令输入的query, key, value都相同, 位置编码的输出 query = key = value = pe_result Variable containing: ( 0 ,.,.) = 46.5196 16.2057 -41.5581 ... -16.0242 -17.8929 -43.0405 -32.6040 16.1096 -29.5228 ... 4.2721 20.6034 -1.2747 -18.6235 14.5076 -2.0105 ... 15.6462 -24.6081 -30.3391 0.0000 -66.1486 -11.5123 ... 20.1519 -4.6823 0.4916 ( 1 ,.,.) = -24.8681 7.5495 -5.0765 ... -7.5992 -26.6630 40.9517 13.1581 -3.1918 -30.9001 ... 25.1187 -26.4621 2.9542 -49.7690 -42.5019 8.0198 ... -5.4809 25.9403 -27.4931 -52.2775 10.4006 0.0000 ... -1.9985 7.0106 -0.5189 [torch.FloatTensor of size 2x4x512]
- 调用:
attn, p_attn = attention(query, key, value) print("attn:", attn) print("p_attn:", p_attn)
- 输出效果:
# 将得到两个结果 # query的注意力表示: attn: Variable containing: ( 0 ,.,.) = 12.8269 7.7403 41.2225 ... 1.4603 27.8559 -12.2600 12.4904 0.0000 24.1575 ... 0.0000 2.5838 18.0647 -32.5959 -4.6252 -29.1050 ... 0.0000 -22.6409 -11.8341 8.9921 -33.0114 -0.7393 ... 4.7871 -5.7735 8.3374 ( 1 ,.,.) = -25.6705 -4.0860 -36.8226 ... 37.2346 -27.3576 2.5497 -16.6674 73.9788 -33.3296 ... 28.5028 -5.5488 -13.7564 0.0000 -29.9039 -3.0405 ... 0.0000 14.4408 14.8579 30.7819 0.0000 21.3908 ... -29.0746 0.0000 -5.8475 [torch.FloatTensor of size 2x4x512] # 注意力张量: p_attn: Variable containing: (0 ,.,.) = 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 (1 ,.,.) = 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 [torch.FloatTensor of size 2x4x4]
3.2 带有mask的输入参数:
query = key = value = pe_result # 令mask为一个2x4x4的零张量 mask = Variable(torch.zeros(2, 4, 4))
- 调用:
attn, p_attn = attention(query, key, value, mask=mask) print("attn:", attn) print("p_attn:", p_attn)
- 带有mask的输出效果:
# query的注意力表示: attn: Variable containing: ( 0 ,.,.) = 0.4284 -7.4741 8.8839 ... 1.5618 0.5063 0.5770 0.4284 -7.4741 8.8839 ... 1.5618 0.5063 0.5770 0.4284 -7.4741 8.8839 ... 1.5618 0.5063 0.5770 0.4284 -7.4741 8.8839 ... 1.5618 0.5063 0.5770 ( 1 ,.,.) = -2.8890 9.9972 -12.9505 ... 9.1657 -4.6164 -0.5491 -2.8890 9.9972 -12.9505 ... 9.1657 -4.6164 -0.5491 -2.8890 9.9972 -12.9505 ... 9.1657 -4.6164 -0.5491 -2.8890 9.9972 -12.9505 ... 9.1657 -4.6164 -0.5491 [torch.FloatTensor of size 2x4x512] # 注意力张量: p_attn: Variable containing: (0 ,.,.) = 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 (1 ,.,.) = 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 0.2500 [torch.FloatTensor of size 2x4x4]
3.3 注意力机制总结
- 学习并实现了注意力计算规则的函数: attention
- 它的输入就是Q,K,V以及mask和dropout, mask用于掩码, dropout用于随机置0.
- 它的输出有两个, query的注意力表示以及注意力张量.
🍔 多头注意力机制
4.1 多头注意力机制概念
- 从多头注意力的结构图中,貌似这个所谓的多个头就是指多组线性变换层,其实并不是,我只有使用了一组线性变化层,即三个变换张量对Q,K,V分别进行线性变换,这些变换不会改变原有张量的尺寸,因此每个变换矩阵都是方阵,得到输出结果后,多头的作用才开始显现,每个头开始从词义层面分割输出的张量,也就是每个头都想获得一组Q,K,V进行注意力机制的计算,但是句子中的每个词的表示只获得一部分,也就是只分割了最后一维的词嵌入向量. 这就是所谓的多头,将每个头的获得的输入送到注意力机制中, 就形成多头注意力机制.
4.2 多头注意力机制结构图
4.3 多头注意力机制的作用
- 这种结构设计能让每个注意力机制去优化每个词汇的不同特征部分,从而均衡同一种注意力机制可能产生的偏差,让词义拥有来自更多元的表达,实验表明可以从而提升模型效果.
4.4 多头注意力机制的代码实现
# 用于深度拷贝的copy工具包 import copy # 首先需要定义克隆函数, 因为在多头注意力机制的实现中, 用到多个结构相同的线性层. # 我们将使用clone函数将他们一同初始化在一个网络层列表对象中. 之后的结构中也会用到该函数. def clones(module, N): """用于生成相同网络层的克隆函数, 它的参数module表示要克隆的目标网络层, N代表需要克隆的数量""" # 在函数中, 我们通过for循环对module进行N次深度拷贝, 使其每个module成为独立的层, # 然后将其放在nn.ModuleList类型的列表中存放. return nn.ModuleList([copy.deepcopy(module) for _ in range(N)]) # 我们使用一个类来实现多头注意力机制的处理 class MultiHeadedAttention(nn.Module): def __init__(self, head, embedding_dim, dropout=0.1): """在类的初始化时, 会传入三个参数,head代表头数,embedding_dim代表词嵌入的维度, dropout代表进行dropout操作时置0比率,默认是0.1.""" super(MultiHeadedAttention, self).__init__() # 在函数中,首先使用了一个测试中常用的assert语句,判断h是否能被d_model整除, # 这是因为我们之后要给每个头分配等量的词特征.也就是embedding_dim/head个. assert embedding_dim % head == 0 # 得到每个头获得的分割词向量维度d_k self.d_k = embedding_dim // head # 传入头数h self.head = head # 然后获得线性层对象,通过nn的Linear实例化,它的内部变换矩阵是embedding_dim x embedding_dim,然后使用clones函数克隆四个, # 为什么是四个呢,这是因为在多头注意力中,Q,K,V各需要一个,最后拼接的矩阵还需要一个,因此一共是四个. self.linears = clones(nn.Linear(embedding_dim, embedding_dim), 4) # self.attn为None,它代表最后得到的注意力张量,现在还没有结果所以为None. self.attn = None # 最后就是一个self.dropout对象,它通过nn中的Dropout实例化而来,置0比率为传进来的参数dropout. self.dropout = nn.Dropout(p=dropout) def forward(self, query, key, value, mask=None): """前向逻辑函数, 它的输入参数有四个,前三个就是注意力机制需要的Q, K, V, 最后一个是注意力机制中可能需要的mask掩码张量,默认是None. """ # 如果存在掩码张量mask if mask is not None: # 使用unsqueeze拓展维度 mask = mask.unsqueeze(0) # 接着,我们获得一个batch_size的变量,他是query尺寸的第1个数字,代表有多少条样本. batch_size = query.size(0) # 之后就进入多头处理环节 # 首先利用zip将输入QKV与三个线性层组到一起,然后使用for循环,将输入QKV分别传到线性层中, # 做完线性变换后,开始为每个头分割输入,这里使用view方法对线性变换的结果进行维度重塑,多加了一个维度h,代表头数, # 这样就意味着每个头可以获得一部分词特征组成的句子,其中的-1代表自适应维度, # 计算机会根据这种变换自动计算这里的值.然后对第二维和第三维进行转置操作, # 为了让代表句子长度维度和词向量维度能够相邻,这样注意力机制才能找到词义与句子位置的关系, # 从attention函数中可以看到,利用的是原始输入的倒数第一和第二维.这样我们就得到了每个头的输入. query, key, value = \ [model(x).view(batch_size, -1, self.head, self.d_k).transpose(1, 2) for model, x in zip(self.linears, (query, key, value))] # 得到每个头的输入后,接下来就是将他们传入到attention中, # 这里直接调用我们之前实现的attention函数.同时也将mask和dropout传入其中. x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout) # 通过多头注意力计算后,我们就得到了每个头计算结果组成的4维张量,我们需要将其转换为输入的形状以方便后续的计算, # 因此这里开始进行第一步处理环节的逆操作,先对第二和第三维进行转置,然后使用contiguous方法, # 这个方法的作用就是能够让转置后的张量应用view方法,否则将无法直接使用, # 所以,下一步就是使用view重塑形状,变成和输入形状相同. x = x.transpose(1, 2).contiguous().view(batch_size, -1, self.head * self.d_k) # 最后使用线性层列表中的最后一个线性层对输入进行线性变换得到最终的多头注意力结构的输出. return self.linears[-1](x)
- tensor.view演示:
>>> x = torch.randn(4, 4) >>> x.size() torch.Size([4, 4]) >>> y = x.view(16) >>> y.size() torch.Size([16]) >>> z = x.view(-1, 8) # the size -1 is inferred from other dimensions >>> z.size() torch.Size([2, 8]) >>> a = torch.randn(1, 2, 3, 4) >>> a.size() torch.Size([1, 2, 3, 4]) >>> b = a.transpose(1, 2) # Swaps 2nd and 3rd dimension >>> b.size() torch.Size([1, 3, 2, 4]) >>> c = a.view(1, 3, 2, 4) # Does not change tensor layout in memory >>> c.size() torch.Size([1, 3, 2, 4]) >>> torch.equal(b, c) False
- torch.transpose演示:
>>> x = torch.randn(2, 3) >>> x tensor([[ 1.0028, -0.9893, 0.5809], [-0.1669, 0.7299, 0.4942]]) >>> torch.transpose(x, 0, 1) tensor([[ 1.0028, -0.1669], [-0.9893, 0.7299], [ 0.5809, 0.4942]])
- 实例化参数:
# 头数head head = 8 # 词嵌入维度embedding_dim embedding_dim = 512 # 置零比率dropout dropout = 0.2
- 输入参数:
# 假设输入的Q,K,V仍然相等 query = value = key = pe_result # 输入的掩码张量mask mask = Variable(torch.zeros(8, 4, 4))
- 调用:
mha = MultiHeadedAttention(head, embedding_dim, dropout) mha_result = mha(query, key, value, mask) print(mha_result)
- 输出效果:
tensor([[[-0.3075, 1.5687, -2.5693, ..., -1.1098, 0.0878, -3.3609], [ 3.8065, -2.4538, -0.3708, ..., -1.5205, -1.1488, -1.3984], [ 2.4190, 0.5376, -2.8475, ..., 1.4218, -0.4488, -0.2984], [ 2.9356, 0.3620, -3.8722, ..., -0.7996, 0.1468, 1.0345]], [[ 1.1423, 0.6038, 0.0954, ..., 2.2679, -5.7749, 1.4132], [ 2.4066, -0.2777, 2.8102, ..., 0.1137, -3.9517, -2.9246], [ 5.8201, 1.1534, -1.9191, ..., 0.1410, -7.6110, 1.0046], [ 3.1209, 1.0008, -0.5317, ..., 2.8619, -6.3204, -1.3435]]], grad_fn=<AddBackward0>) torch.Size([2, 4, 512])
4.5 多头注意力机制总结
- 学习了什么是多头注意力机制:
- 每个头开始从词义层面分割输出的张量,也就是每个头都想获得一组Q,K,V进行注意力机制的计算,但是句子中的每个词的表示只获得一部分,也就是只分割了最后一维的词嵌入向量. 这就是所谓的多头.将每个头的获得的输入送到注意力机制中, 就形成了多头注意力机制.
- 学习了多头注意力机制的作用:
- 这种结构设计能让每个注意力机制去优化每个词汇的不同特征部分,从而均衡同一种注意力机制可能产生的偏差,让词义拥有来自更多元的表达,实验表明可以从而提升模型效果.
- 学习并实现了多头注意力机制的类: MultiHeadedAttention
- 因为多头注意力机制中需要使用多个相同的线性层, 首先实现了克隆函数clones.
- clones函数的输入是module,N,分别代表克隆的目标层,和克隆个数.
- clones函数的输出是装有N个克隆层的Module列表.
- 接着实现MultiHeadedAttention类, 它的初始化函数输入是h, d_model, dropout分别代表头数,词嵌入维度和置零比率.
- 它的实例化对象输入是Q, K, V以及掩码张量mask.
- 它的实例化对象输出是通过多头注意力机制处理的Q的注意力表示.