找回密码
 立即注册
首页 业界区 业界 手撕 Transformer (3):编码器的实现

手撕 Transformer (3):编码器的实现

搜娲瘠 4 小时前
我们在结构拆解那篇文章中讲过,Transformer 可分为四个部分:输入、输出、编码器、解码器。上篇文章介绍了输入部分的代码实现和原理讲解。
本文介绍编码器部分的代码实现和原理讲解。回顾一下,我们之前介绍过 Transformer 的编码器。它由 N 个编码器层堆叠而成;每个编码器层由 2 个子层组成;第一个子层由多头自注意力(Multi-Head Self-Attention,下图中的 Multi-Head Attention)和层归一化(Layer Normalization,下图中的 Norm),以及残差连接组成。第二个子层由前馈层和层归一化,以及残差连接组成。
1.png

本文将围绕多头自注意力、前馈层、层归一化进行介绍。
1 注意力机制

在介绍多头自注意力前,我们需要先掌握注意力机制(Attention),在之前的文章 《理解『注意力机制』的本质》 中,我们对注意力机制进行了详细介绍,如果对注意力机制理解得不好的小伙伴,可以去看一下这篇文章。注意力机制的核心公式为缩放点积注意力(Scaled Dot-Product Attention),用于计算模型对输入数据不同部分的关注程度,公式如下::

\[\text{Attention}(Q,K,V)=\text{Softmax}(\frac{QK^T}{\sqrt{d_k}})V\]
其中,\(Q \in \mathbb{R}^{n_q \times d_k}\) 为一组查询向量 \(q_i\) 打包的查询矩阵(Query),\(K \in \mathbb{R}^{n_k \times d_k}\) 为一组键向量 \(k_i\) 打包的键矩阵(Key),\(V\) 为一组值向量 \(v_i\) 打包的值矩阵(Value),\(d_k\) 为键矩阵维度。“打包”的目的是为了并行计算。在实际的代码实现中,q, k, v 都是高维张量。\(QK^T\) 是在通过点积计算相似度,即注意力分数。除以键矩阵维度的平方根 \(\sqrt{d_k}\) 是为了防止梯度消失,稳定训练。使用 Softmax 函数是为了将分数转换为概率分布(权重),确保所有权重之和为 1。用归一化后的权重对 \(V\) 进行加权求和,得到最终输出。
1.1 注意力机制的代码实现
  1. def attention(query, key, value, mask=None, dropout=None):
  2.     # 实现缩放点积注意力机制
  3.     d_k = query.size(-1)    # k和q的维度
  4.     # 计算注意力分数
  5.     scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
  6.     # 应用掩码
  7.     if mask is not None:
  8.         # 找到掩码中值为0的位置(需要屏蔽的位置)
  9.         # 将这些位置的注意力分数设置为负无穷,这样在 softmax 后这些位置的注意力权重会接近0
  10.         scores = scores.masked_fill(mask == 0, -1e9)
  11.     # 对最后一个维度(序列长度维度)进行 softmax 操作,得到注意力概率分布
  12.     p_attn = scores.softmax(dim=-1)
  13.     # 应用Dropout
  14.     if dropout is not None:
  15.         p_attn = dropout(p_attn)
  16.     # 返回注意力输出和注意力权重
  17.     return torch.matmul(p_attn, value), p_attn
复制代码
代码中的掩码张量是在解码器中使用的。主要是防止模型在训练时“偷看”未来的答案,确保自注意力机制只能关注已生成的部分,从而符合序列生成的因果约束。掩码张量中一般只有两种元素 0 和 1,代表该位置被遮挡或不被遮挡。至于 0 位置是被遮挡还是不被遮挡,这个可以自己定义。对于掩码张量不熟悉的小伙伴可以去看这篇文章:掩码张量
有没有人有这个疑问:为什么在代码里只转置 key 的后两维,而不是整个转置呢?这跟公式里的不一样啊。
公式里 \(K^T \in \mathbb{R}^{d_k \times n_k}\) ,计算矩阵乘法时是两个形状为 \((n_q \times d_k)\) 和 \((d_k \times n_k)\) 的矩阵相乘,即 \(QK^T \in \mathbb{R}^{n_q \times n_k}\)。表示每个 query 与所有 key 的相似度。在论文里,这些都是二维矩阵,所以写成 \(K^T\)。
代码里的张量其实是四维的。在 PyTorch 实现中,通常是:
  1. query.shape = (batch, heads, seq_len_q, d_k)
  2. key.shape   = (batch, heads, seq_len_k, d_k)
  3. value.shape = (batch, heads, seq_len_v, d_v)
复制代码
其中,batch表示batch size,heads表示注意力头数,seq_len表示token 数,d_k表示向量维度。
key.transpose(-2, -1)的意思是把key的形状从 (batch, heads, seq_len_k, d_k) 变成 (batch, heads, d_k, seq_len_k) 这样就可以进行矩阵乘法 query @ key^T 了。
从形状上看:
  1. (batch, heads, seq_len_q, d_k)
  2. @
  3. (batch, heads, d_k, seq_len_k)
  4. =
  5. (batch, heads, seq_len_q, seq_len_k)
复制代码
这正好就是,每个 query token 对所有 key token 的注意力分数。交换最后两个维度,相当于对每个 batch、每个 head 单独做 \(K^T\).
如果把整个矩阵转置会发生什么? 那么 PyTorch 会把所有维度反转,(batch, heads, seq_len, d_k)→(d_k, seq_len, heads, batch),无法进行 batch matmul。
1.2 注意力权重和注意力输出的区别


  • 注意力权重 p_attn 是经过 softmax 归一化后的注意力概率分布,其形状与注意力分数 scores 形状相同,表示序列中每个位置对其他位置的关注程度,可用于可视化注意力模式
  • 注意力输出 torch.matmul(p_attn, value) 是通过注意力权重对 value 向量进行加权求和的结果,其形状与输入的 value 形状相同,包含了序列中每个位置对其他位置的加权信息,是注意力机制的主要输出
2 多头注意力机制

多头注意力允许模型在不同位置,同时关注来自不同表示子空间的信息。而单头注意力(Single-Head Attention)会通过平均操作抑制这种多维度信息的联合捕捉能力。下图展示了多头注意力机制的结构图。
多头注意力的计算公式如下:

\[\mathrm{MultiHead}(Q, K, V) = \mathrm{Concat}(\mathrm{head_1}, ..., \mathrm{head_h})W^O\]
其中,\(\mathrm{head_i} = \mathrm{Attention}(QW^Q_i, KW^K_i, VW^V_i)\)
公式中,投影矩阵均为可训练参数:\(W^Q_i \in \mathbb{R}^{d_{\text{model}} \times d_k}\)、\(W^K_i \in \mathbb{R}^{d_{\text{model}} \times d_k}\)、\(W^V_i \in \mathbb{R}^{d_{\text{model}} \times d_v}\)、\(W^O \in \mathbb{R}^{hd_v \times d_{\text{model}}}\)。
在Transformer原论文中,设置 多头数 \(h=8\)。每个头均采用 \(d_k=d_v=d_{\text{model}}/h=64\) 的维度配置。由于每个头的维度显著降低,多头注意力的总计算成本与单头全维度注意力的计算成本基本相当。
2.png

多头注意力机制的作用:这种结构能让每个注意力机制去学习每个词汇的不同特征部分,从而均衡单一注意力机制可能产生的偏差,让词汇的含义有更多元的表达,实验表明多头注意力机制可以提升模型表现。
在用代码实现多头注意力机制之前,我们要先定义一个克隆函数用于深拷贝网络层。因为在多头注意力中有多个结构相同的线性层。需要几个线性层呢?
多头注意力机制一共需要 4 个线性层。Q, K, V 的投影各需要一个线性层,多头结果拼接后的最终投影也需要一个线性层。
注意,不是每个头单独配线性层,而是所有头共享这 4 个线性层,通过维度拆分实现多头并行计算。
  1. import copy
  2. def clones(module, N):
  3.     """
  4.     用于生成相同网络层的克隆函数,它的参数module表示要克隆的目标网络层,N代表需要克隆的数量
  5.     """
  6.     # for 循环:对 module 进行 N 次深度拷贝,使其每个 module 成为独立的层
  7.     # 然后将其放在 nn.ModuleList 类型的列表中存放
  8.     return nn.ModuleList([copu.deepcopy(module) for _ in range(N)])
复制代码
2.1 多头注意力机制代码实现
  1. class MultiHeadedAttention(nn.Module):
  2.     def __init__(self, h, d_model, dropout=0.1):
  3.         # h:注意力头的数量,决定了模型并行关注不同子空间的能力
  4.         # d_model:模型的总维度,需要能被 h 整除
  5.         # dropout:Dropout概率,默认为0.1
  6.         super(MultiHeadedAttention, self).__init__()
  7.         assert d_model % h == 0
  8.         # We assume d_v always equals d_k
  9.         self.d_k = d_model // h     # 每个注意力头的维度
  10.         self.h = h
  11.         # self.linears:包含4个线性层的列表,用于不同的投影操作
  12.         # 输入输出都是 d_model 内部变换矩阵就是 d_model × d_model
  13.         self.linears = clones(nn.Linear(d_model, d_model), 4)
  14.         # 存储注意力权重的变量,可用于后续分析
  15.         self.attn = None
  16.         self.dropout = nn.Dropout(p=dropout)    # 正则化,防止模型过拟合,提高泛化能力
  17.     def forward(self, query, key, value, mask=None):
  18.         # 掩码处理
  19.         if mask is not None:
  20.             # 如果提供了掩码,在第1维(注意力头维度)插入一个维度,确保掩码能同时应用到所有注意力头
  21.             mask = mask.unsqueeze(1)
  22.         # 获取批次大小
  23.         nbatches = query.size(0)
  24.         # 1) 线性投影与维度变换
  25.         # 将输入的query、key、value通过线性层投影到多个子空间
  26.         # 并调整张量维度以适应多头注意力计算
  27.         query, key, value = [
  28.             # 对每个输入向量应用对应的线性层
  29.             # lin(x): 将输入从d_model维度投影到d_model维度(实际上是h*d_k)
  30.             # view(nbatches, -1, self.h, self.d_k): 重塑为[批次大小, 序列长度, 注意力头数, 每个头的维度]
  31.             # transpose(1, 2): 交换序列长度和注意力头维度,得到[批次大小, 注意力头数, 序列长度, 每个头的维度]
  32.             lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
  33.             # 遍历线性层列表和输入向量元组
  34.             for lin, x in zip(self.linears, (query, key, value))
  35.         ]
  36.         # 2) 应用注意力机制
  37.         # 计算注意力输出和注意力权重
  38.         x, self.attn = attention(
  39.             query, key, value, mask=mask, dropout=self.dropout
  40.         )
  41.         # 3) 拼接多头结果
  42.         x = (
  43.             # 交换注意力头和序列长度维度,
  44.             # 形状从[batch_size, num_heads, seq_len, d_k]变为[batch_size, seq_len, num_heads, d_k]
  45.             x.transpose(1, 2)
  46.             # 确保张量在内存中是连续的,为后续的view操作做准备
  47.             .contiguous()  # 语法规定:可以先 view 然后 transpose ,但是不能先 transpose 然后 view
  48.             # 将num_heads和d_k维度合并,形状变为[batch_size, seq_len, d_model]
  49.             # 变为和输入形状相同
  50.             .view(nbatches, -1, self.h * self.d_k)
  51.         )
  52.         # 清理临时变量并应用最终线性层
  53.         del query
  54.         del key
  55.         del value
  56.         # 第四个线性层
  57.         return self.linears[-1](x)
复制代码
线性变换与维度变换部分的代码
  1.         query, key, value = [
  2.             lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
  3.             for lin, x in zip(self.linears, (query, key, value))
  4.         ]
复制代码
这部分的设计意图是:
(1) 将输入分散到多个子空间,每个注意力头专注于不同的特征
(2) 调整维度顺序,使注意力计算可以在批量和多头维度上并行执行
(3) 为后续的 attention 函数调用做准备,确保输入形状符合要求
拼接多头结果部分的代码
  1.         x = (x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k))
复制代码
这部分的设计意图是:
(1) 将多个注意力头的输出重新组合成与输入维度相同的张量
(2) 确保多头注意力的输出可以与模型的其他部分无缝集成
(3) 为后续的线性投影做准备
代码验证一下多头注意力机制
  1. query = key = value = pe_result     # torch.Size([2, 4, 512])
  2. attn, p_attn = attention(query, key, value)
  3. print('attn: ', attn)
  4. print('attn shape: ', attn.shape)
  5. print('p_attn: ', p_attn)
  6. # 实例化参数
  7. head = 8
  8. embedding_dim = 512
  9. dropout = 0.2
  10. # 输入参数
  11. query = key = value = pe_result
  12. mask = Variable(torch.zeros(2, 4, 4))
  13. mha = MultiHeadedAttention(head, embedding_dim, dropout)
  14. mha_result = mha(query, key, value, mask)
  15. print(mha_result)
  16. print(mha_result.shape)
复制代码
结果
  1. attn:  tensor([[[-28.5405,   8.5180,  49.6881,  ..., -18.6899,  19.7789,  25.0898],
  2.          [  4.2671, -30.6077, -15.6015,  ...,   0.0000,   0.0000,  -0.7959],
  3.          [-27.3173,  15.3063,  -3.8314,  ...,  59.0806, -20.0541,   2.6914],
  4.          [ 15.5324,   0.0000,   5.3321,  ..., -37.6823,  -9.8541,   1.2505]],
  5.         [[-18.3518, -38.6171,   0.0000,  ...,  -8.2401,  15.4565, -46.3310],
  6.          [ -3.5752, -20.1649,  59.3744,  ...,  -9.8403, -21.6180,  23.2361],
  7.          [-42.6460,   0.0000,  52.3963,  ..., -16.5290,   0.0000,  17.9956],
  8.          [-12.7819, -43.9217,  -2.9674,  ...,   0.0000,  16.9880,   0.0000]]],
  9.        grad_fn=<UnsafeViewBackward0>)
  10. attn shape:  torch.Size([2, 4, 512])
  11. p_attn:  tensor([[[1., 0., 0., 0.],
  12.          [0., 1., 0., 0.],
  13.          [0., 0., 1., 0.],
  14.          [0., 0., 0., 1.]],
  15.         [[1., 0., 0., 0.],
  16.          [0., 1., 0., 0.],
  17.          [0., 0., 1., 0.],
  18.          [0., 0., 0., 1.]]], grad_fn=<SoftmaxBackward0>)
  19. tensor([[[-3.9070e+00, -3.3605e+00,  3.2105e-03,  ..., -5.9947e+00,
  20.           -4.9712e+00,  2.2472e-01],
  21.          [-7.5267e+00, -4.0815e+00, -2.0464e+00,  ..., -7.9205e+00,
  22.           -7.4661e+00, -2.8792e+00],
  23.          [-8.9720e+00, -4.2863e+00, -4.4051e+00,  ..., -4.5087e+00,
  24.           -9.8329e+00,  2.2278e-01],
  25.          [-9.2407e+00, -4.7355e-01, -1.8737e+00,  ..., -5.2340e+00,
  26.           -5.5457e+00, -1.2230e+00]],
  27.         [[-2.4451e+00,  2.0092e+00, -3.2150e+00,  ..., -1.3062e+01,
  28.            2.9305e-02,  5.5562e+00],
  29.          [-4.8202e+00,  2.5720e+00, -8.5146e+00,  ..., -4.1689e+00,
  30.           -3.5412e-01,  7.3528e+00],
  31.          [-2.8532e+00,  2.1834e+00, -5.0711e+00,  ..., -6.0639e+00,
  32.           -3.5013e-01,  2.6073e+00],
  33.          [-4.7823e+00,  1.7275e+00, -4.4381e+00,  ..., -5.6696e+00,
  34.            7.2888e-01,  1.3089e+00]]], grad_fn=<ViewBackward0>)
  35. torch.Size([2, 4, 512])
复制代码
3 前馈全连接层

在 Transformer 中,前馈全连接层是具有两层线性层(nn.Linear)的全连接网络。它的全称是逐位置前馈网络(Position-wise Feed-Forward Network, PW-FFN)。
3.1 为什么需要前馈全连接层?

Transformer 的多头自注意力层本质是线性操作,它包含矩阵乘法 + 加权求和 + 线性投影,全程没有任何非线性激活。线性模型只能拟合线性关系,完全无法处理语言、图像这种复杂的非线性语义数据。而前馈全连接层通过插入激活函数(ReLU),为整个模型引入了非线性,让 Transformer 满足万能近似定理,具备拟合任意复杂函数的能力。
3.2 前馈全连接层的代码实现
  1. class PositionwiseFeedForward(nn.Module):
  2.     """
  3.     前馈全连接层
  4.     """
  5.     def __init__(self, d_model, d_ff, dropout=0.1):
  6.         """
  7.         d_model: 第一个线性层的输入维度、第二个线性层的输出维度
  8.         d_ff: 第一个线性层的输出维度、第二个线性层的输入维度
  9.         """
  10.         super(PositionwiseFeedForward, self).__init__()
  11.         # 实例化两个线性层对象
  12.         self.w_1 = nn.Linear(d_model, d_ff)
  13.         self.w_2 = nn.Linear(d_ff, d_model)
  14.         # 实例化 dropout 对象
  15.         self.dropout = nn.Dropout(dropout)
  16.     def forward(self, x):
  17.         """
  18.         x: 上一层的输出
  19.         """
  20.         # 先经过第一个线性层,然后经过relu,然后dropout,最后第二个线性层
  21.         return self.w_2(self.dropout(self.w_1(x).relu()))
复制代码
代码验证一下:
  1. x = mha_result
  2. ff = PositionwiseFeedForward(d_model, d_ff, dropout)
  3. ff_result = ff(x)
  4. print(ff_result)
  5. print(ff_result.shape)
复制代码
运行结果
  1. tensor([[[ 0.2707, -1.3878,  0.3316,  ...,  0.4291,  1.0571, -1.4091],
  2.          [-0.0900,  0.3818, -0.9083,  ..., -0.7012,  0.8274, -2.2341],
  3.          [ 0.1352,  0.9792, -1.0576,  ..., -2.3358, -1.0655, -0.9393],
  4.          [-1.2512, -1.6793,  0.5821,  ..., -0.6112,  0.6517, -0.6183]],
  5.         [[-0.2039,  0.2232, -0.5123,  ..., -1.4035, -1.5380, -1.6538],
  6.          [ 1.2860, -0.9415, -0.1841,  ..., -0.9235, -0.0309, -0.0950],
  7.          [-0.0801,  0.1572, -0.6025,  ..., -0.4801, -1.4604, -2.4773],
  8.          [ 0.7402, -0.3408,  0.2080,  ..., -0.5617, -1.1475,  0.6073]]],
  9.        grad_fn=<ViewBackward0>)
  10. torch.Size([2, 4, 512])
复制代码
可以看到,经过前馈层之后,张量形状没有变化,还是 torch.Size([2, 4, 512])
4 层归一化

层归一化(Layer Normalization)是 Transformer 等深层网络的关键层,因为随着网络加深,每层的特征激活值会发生分布漂移,导致梯度异常、收敛缓慢。因此 Transformer 的每个模块都会接入层归一化,将特征分布稳定在合理范围,从而稳定训练、加速收敛。
与批归一化(BatchNorm)的区别:BatchNorm是对一个批次(Batch)内所有样本的同一特征通道进行归一化,适用于卷积神经网络(CNN)。而LayerNorm是针对单个样本的所有特征通道进行归一化,更适用于序列数据或Transformer架构,因为它不依赖于批次大小,对变长序列更友好。
层归一化的公式是:

\[\text{LayerNorm}(x) = \alpha \cdot \frac{x - \mu}{\sigma + \epsilon} + \beta \]
其中:

  • \(\mu\) 是输入的均值
  • \(\sigma\) 是输入的标准差
  • \(\epsilon\) 是防止分母为零的小值
  • \(\alpha\) 和 \(\beta\) 是可学习的参数(对应下面代码中的 a_2 和 b_2)
4.1 层归一化的代码实现
  1. class LayerNorm(nn.Module):
  2.     # 层归一化
  3.     def __init__(self, features, eps=1e-6):
  4.         """
  5.         features: 词嵌入的维度
  6.         eps: 防止分母为 0 的一个很小的数
  7.         """
  8.         super(LayerNorm, self).__init__()
  9.         # 根据features的形状初始化两个张量,一个是全1张量a_2,一个是全0张量b_2
  10.         self.a_2 = nn.Parameter(torch.ones(features))
  11.         self.b_2 = nn.Parameter(torch.zeros(features))
  12.         self.eps = eps
  13.     def forward(self, x):
  14.         # 计算输入 x 在最后一个维度上的均值和标准差
  15.         mean = x.mean(-1, keepdim=True)  # keepdim=True是保持输入输出维度一致
  16.         std = x.std(-1, keepdim=True)
  17.         return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
复制代码
self.a_2 是一个全1初始化的可学习参数,用于缩放归一化后的值;self.b_2 是一个全0初始化的可学习参数,用于平移归一化后的值。
这两个参数有很多用处:
(1)打破对称性:

  • 如果没有这些参数,所有特征在归一化后都会被限制在相似的范围内
  • 可学习参数允许模型为不同特征学习不同的缩放和平移,打破这种对称性
(2)保持表达能力:

  • 标准归一化会将数据限制在均值为0、标准差为1的分布
  • 但神经网络可能需要不同的分布来更好地表达信息
  • 可学习参数允许模型学习最适合当前任务的分布
(3)灵活性

  • 不同层可能需要不同的归一化策略
  • 可学习参数让模型能够根据具体情况调整归一化行为
(4)训练稳定性

  • 初始化时使用全1和全0,相当于初始时不改变归一化结果
  • 随着训练的进行,模型会逐渐调整这些参数以获得更好的性能
代码验证一下
  1. # 实例化参数
  2. features = d_model = 512
  3. eps = 1e-6
  4. x = ff_result
  5. ln = LayerNorm(features, eps)
  6. ln_result = ln(x)
  7. print(ln_result)
  8. print(ln_result.shape)
复制代码
运行结果
  1. tensor([[[-1.0068, -0.9608,  0.6201,  ..., -1.4909, -1.3558,  0.6573],
  2.          [-0.7536, -1.0619,  0.2659,  ..., -1.6619, -2.2498,  0.5365],
  3.          [-0.9633, -0.8850,  0.1006,  ..., -1.2924, -1.7498,  0.8749],
  4.          [-0.7381, -1.5674,  0.3517,  ..., -1.3092, -1.3130,  0.4619]],
  5.         [[-0.2242,  1.2858,  0.2319,  ..., -0.8165, -0.3229,  1.8841],
  6.          [ 0.7869,  0.0377, -0.7480,  ...,  0.1628, -1.6486,  1.8530],
  7.          [ 1.3031,  0.7250, -1.5757,  ..., -0.2893, -1.4918,  1.0906],
  8.          [ 0.6810,  1.1282, -1.6565,  ..., -0.1728, -1.5334,  2.4894]]],
  9.        grad_fn=)
  10. torch.Size([2, 4, 512])
复制代码
可以看到输出张量的形状还是[2, 4, 512]
5 子层连接结构

如下图所示,每个子层均搭配残差连接与层归一化,这一整体结构被称为子层连接结构(Sublayer Connection)。每个编码器层包含两个子层,对应形成两组子层连接结构。
3.png

4.png

在 Transformer 论文中,每个子层连接的计算逻辑是 \(\text{LayerNorm}(x + \text{Sublayer}(x))\),但在实现时,常写成 \(x + \text{Sublayer}(\text{LayerNorm}(x))\),即 Pre-Norm 版本。这样设计有解耦结构、代码复用等好处。
5.1 子层连接结构的代码实现
  1. class SublayerConnection(nn.Module):
  2.     def __init__(self, size, dropout):
  3.         super(SublayerConnection, self).__init__()
  4.         # 实例化层归一化对象 self.norm
  5.         self.norm = LayerNorm(size)
  6.         # 实例化dropout
  7.         self.dropout = nn.Dropout(dropout)
  8.     def forward(self, x, sublayer):
  9.         """
  10.         (1) 层归一化
  11.         (2) 传给子层处理
  12.         (3) dropout
  13.         (4) 残差连接
  14.         """
  15.         return x + self.dropout(sublayer(self.norm(x)))
复制代码
验证一下
  1. # 实例化参数
  2. size = 512
  3. dropout = 0.2
  4. head = 8
  5. d_model = 512
  6. x = pe_result   # 令 x 为位置编码的输出
  7. mask = Variable(torch.zeros(2, 4, 4))
  8. # 假设子层中装的是多头注意力层,实例化这个类
  9. self_attn = MultiHeadedAttention(head, d_model)
  10. # lambda 函数捕获了外部变量 self_attn 和 mask,将多参数函数转换为单参数函数
  11. sublayer = lambda x: self_attn(x, x, x, mask)
  12. sc = SublayerConnection(size, dropout)
  13. sc_result = sc(x, sublayer)
  14. print(sc_result)
  15. print(sc_result.shape)
复制代码
运行结果
  1. tensor([[[-6.0652e+01,  0.0000e+00, -2.7157e+01,  ..., -1.5954e-01,
  2.           -3.2806e+01,  1.6951e+01],
  3.          [ 3.0377e+01,  7.0292e+00, -1.1884e-01,  ..., -7.3743e-02,
  4.           -1.0353e+01,  7.5711e+00],
  5.          [ 5.9228e+00, -2.6340e+01,  1.9062e-01,  ..., -4.0861e+01,
  6.            2.1353e+01, -4.2987e+00],
  7.          [ 8.8359e+00,  5.9631e+00,  5.7076e+00,  ...,  1.0041e+01,
  8.            1.2221e-01,  3.5923e+01]],
  9.         [[ 8.4341e+00, -5.5592e+00,  1.0057e+00,  ..., -1.0425e+01,
  10.           -5.0544e+00,  1.3094e+01],
  11.          [ 2.6334e+01,  3.5420e-01,  1.2274e+01,  ...,  1.6654e+01,
  12.           -2.2240e+01,  1.2143e+01],
  13.          [ 2.7146e+01, -1.0327e+01,  3.0792e+01,  ...,  7.0511e+00,
  14.           -5.8975e-03, -1.3620e+01],
  15.          [-1.3136e+00,  4.0415e+01, -1.4205e+01,  ...,  1.6174e+01,
  16.            5.4314e+01, -2.7685e+01]]], grad_fn=)
  17. torch.Size([2, 4, 512])
复制代码
sublayer = lambda x: self_attn(x, x, x, mask) 这行代码是在干什么?
它等价于定义了一个函数:
  1. def sublayer(x):
  2.     return self_attn(x, x, x, mask)
复制代码
把一个多参数函数包装成单参数函数。
6 编码器层

编码器层是编码器的组成单元,每个编码器层的功能都是提取特征。
5.png

6.1 编码器层的代码实现
  1. class EncoderLayer(nn.Module):
  2.     "Encoder is made up of self-attn and feed forward (defined below)"
  3.     def __init__(self, size, self_attn, feed_forward, dropout):
  4.         """
  5.         size: 词嵌入维度的大小
  6.         self_attn: 多头注意力子层的实例化对象
  7.         feed_forward: 前馈全连接层的实例化对象
  8.         dropout: dropout置零的概率
  9.         """
  10.         super(EncoderLayer, self).__init__()
  11.         self.self_attn = self_attn
  12.         self.feed_forward = feed_forward
  13.         # 克隆 2 个子层
  14.         self.sublayer = clones(SublayerConnection(size, dropout), 2)
  15.         self.size = size
  16.     def forward(self, x, mask):
  17.         """
  18.         第一层:多头自注意力机制
  19.         (1) 通过 lambda 函数将输入 x 同时作为 query、key、value 传入 self_attn
  20.         (2) 输入 x 先经过层归一化
  21.         (3) 然后通过多头自注意力计算
  22.         (4) 应用 dropout 正则化
  23.         (5) 与原始输入进行残差连接
  24.         """
  25.         x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
  26.         """
  27.         第二层:前馈层
  28.         (1) 上一层的输出 x 先经过层归一化
  29.         (2) 然后通过前馈神经网络处理
  30.         (3) 应用 dropout 正则化
  31.         (4) 最后与原始输入进行残差连接并返回结果
  32.         """
  33.         return self.sublayer[1](x, self.feed_forward)
复制代码
验证一下
  1. # 实例化参数
  2. size = 512
  3. head = 8
  4. d_model = 512   # 前馈层的输入维度
  5. d_ff = 64       # 前馈层的输出维度
  6. x = pe_result   # 位置编码的输出作为编码器层的输入
  7. dropout = 0.2
  8. self_attn = MultiHeadedAttention(head, d_model)
  9. ff = PositionwiseFeedForward(d_model, d_ff, dropout)
  10. mask = Variable(torch.zeros(2, 4, 4))
  11. el = EncoderLayer(size, self_attn, ff, dropout)
  12. el_result = el(x, mask)
  13. print(el_result)
  14. print(el_result.shape)
复制代码
运行结果
  1. tensor([[[-14.3124,   7.1696,  24.1787,  ...,  17.6387,  -0.7578,   8.7262],
  2.          [ -8.3042, -27.5725,  21.0715,  ...,   0.5590,  16.9088,  24.2006],
  3.          [ -0.4118, -26.7662, -19.0783,  ...,  23.8286,  21.8681,  33.2096],
  4.          [ 15.2147, -34.4229, -21.7659,  ...,   3.5362, -34.4189,  43.5179]],
  5.         [[-12.5658,   1.5620, -11.3925,  ..., -32.0729,   0.6346,  21.0039],
  6.          [ 17.2242,   9.0129,  25.7235,  ..., -46.8909, -26.5053,  23.1490],
  7.          [ -2.7985,   0.0665,  -3.9887,  ...,   2.8081,  49.2965,  30.5333],
  8.          [ -7.2096,  10.4575,  -8.5856,  ...,  28.0996,  53.2981,   7.2780]]],
  9.        grad_fn=)
  10. torch.Size([2, 4, 512])
复制代码
7 编码器

如下图所示,Transformer的编码器由N个编码器层堆叠而成,其作用是特征提取。
6.png

7.1 编码器层的代码实现
  1. class Encoder(nn.Module):
  2.     def __init__(self, layer, N):
  3.         super(Encoder, self).__init__()
  4.         """
  5.         layer: 编码器层
  6.         N: 编码器层的个数
  7.         """
  8.         # 克隆N个编码器层,放在self.layers列表当中
  9.         self.layers = clones(layer, N)
  10.         # 初始化层归一化
  11.         self.norm = LayerNorm(layer.size)
  12.     def forward(self, x, mask):
  13.         """
  14.         x: 上一层的输出
  15.         mask: 掩码张量
  16.         """
  17.         # 遍历 N 个编码器层,每次遍历都会得到一个新的x
  18.         for layer in self.layers:
  19.             x = layer(x, mask)
  20.         # 层归一化
  21.         return self.norm(x)
复制代码
验证一下
  1. size = 512
  2. head = 8
  3. d_model = 512
  4. d_ff = 64
  5. c = copy.deepcopy
  6. dropout = 0.2
  7. attn = MultiHeadedAttention(head, d_model)
  8. ff = PositionwiseFeedForward(d_model, d_ff, dropout)
  9. layer = EncoderLayer(size, c(attn), c(ff), dropout)
  10. N = 8
  11. mask = Variable(torch.zeros(2, 4, 4))
  12. en = Encoder(layer, N)
  13. en_result = en(x, mask)
  14. print(en_result)
  15. print(en_result.shape)
复制代码
layer = EncoderLayer(size, c(attn), c(ff), dropout) 注意这里也用了深拷贝。
运行结果
  1. tensor([[[-0.0998, -1.5993, -0.7237,  ...,  0.0082, -0.8688,  0.1374],
  2.          [-1.5608, -0.1286, -0.1014,  ..., -0.4247, -0.7740,  1.5801],
  3.          [-2.5692, -2.7113, -1.0077,  ...,  0.8781, -0.6017,  2.4760],
  4.          [ 0.2211,  2.1159, -0.8062,  ...,  0.4103,  0.0268,  1.2779]],
  5.         [[ 0.8414,  0.2135,  0.9392,  ...,  0.8944, -1.1631,  0.4807],
  6.          [-0.4408,  0.6319,  0.4742,  ..., -0.1946,  0.6842, -0.1318],
  7.          [ 0.0145,  0.7471,  0.7337,  ...,  2.2360, -1.2449, -1.7164],
  8.          [ 1.0887, -0.1907, -0.9139,  ...,  0.1577,  0.1085,  0.8614]]],
  9.        grad_fn=)
  10. torch.Size([2, 4, 512])
复制代码
附:一些函数的演示

附1:tensor.view() 演示
  1. x = torch.randn(4, 4)
  2. print(x.size())
  3. y = x.view(16)
  4. print("x 经过 view(16): ", y.size())
  5. z = x.view(-1, 8)
  6. print("x 经过 view(-1, 8): ", z.size())
复制代码
运行结果:
  1. torch.Size([4, 4])
  2. x 经过 view(16):  torch.Size([16])
  3. x 经过 view(-1, 8):  torch.Size([2, 8])
复制代码
附2:transpose() 和 view() 的区别
  1. a = torch.randn(1, 2, 3, 4)
  2. print("a的原始尺寸", a.size())
  3. b = a.transpose(1, 2)
  4. print("a经过transpose(1, 2)后的尺寸:", b.size())
  5. c = a.view(1, 3, 2, 4)
  6. print("a经过view(1, 3, 2, 4)后的尺寸:", c.size())
  7. print("经过transpose(1, 2)后和经过view(1, 3, 2, 4)后的张量是否相等:", torch.equal(b, c))
复制代码
运行结果:
  1. a的原始尺寸 torch.Size([1, 2, 3, 4])
  2. a经过transpose(1, 2)后的尺寸: torch.Size([1, 3, 2, 4])
  3. a经过view(1, 3, 2, 4)后的尺寸: torch.Size([1, 3, 2, 4])
  4. 经过transpose(1, 2)后和经过view(1, 3, 2, 4)后的张量是否相等: False
复制代码
transpose 改变的是数据的排列方式(stride),而 view 只是重新解释内存,不会重新排列数据。view 必须作用于连续张量。
代码演示:
  1. # 构造固定值的张量,方便观察
  2. a = torch.arange(24).reshape(1, 2, 3, 4)
  3. print("a的原始值:\n", a)
  4. print("a的原始尺寸:", a.size())
  5. # 交换维度1和2
  6. b = a.transpose(1, 2)
  7. print("\nb = a.transpose(1, 2) 的值:\n", b)
  8. print("b的尺寸:", b.size())
  9. # 直接修改维度形状
  10. c = a.view(1, 3, 2, 4)
  11. print("\nc = a.view(1, 3, 2, 4) 的值:\n", c)
  12. print("c的尺寸:", c.size())
  13. # 验证是否相等
  14. print("\nb和c是否相等:", torch.equal(b, c))
复制代码
运行结果:
  1. a的原始值:
  2. tensor([[[[ 0,  1,  2,  3],
  3.           [ 4,  5,  6,  7],
  4.           [ 8,  9, 10, 11]],
  5.          [[12, 13, 14, 15],
  6.           [16, 17, 18, 19],
  7.           [20, 21, 22, 23]]]])
  8. a的原始尺寸: torch.Size([1, 2, 3, 4])
  9. b = a.transpose(1, 2) 的值:
  10. tensor([[[[ 0,  1,  2,  3],
  11.           [12, 13, 14, 15]],
  12.          [[ 4,  5,  6,  7],
  13.           [16, 17, 18, 19]],
  14.          [[ 8,  9, 10, 11],
  15.           [20, 21, 22, 23]]]])
  16. b的尺寸: torch.Size([1, 3, 2, 4])
  17. c = a.view(1, 3, 2, 4) 的值:
  18. tensor([[[[ 0,  1,  2,  3],
  19.           [ 4,  5,  6,  7]],
  20.          [[ 8,  9, 10, 11],
  21.           [12, 13, 14, 15]],
  22.          [[16, 17, 18, 19],
  23.           [20, 21, 22, 23]]]])
  24. c的尺寸: torch.Size([1, 3, 2, 4])
  25. b和c是否相等: False
复制代码
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册