我们在结构拆解那篇文章中讲过,Transformer 可分为四个部分:输入、输出、编码器、解码器。上篇文章介绍了输入部分的代码实现和原理讲解。
本文介绍编码器部分的代码实现和原理讲解。回顾一下,我们之前介绍过 Transformer 的编码器。它由 N 个编码器层堆叠而成;每个编码器层由 2 个子层组成;第一个子层由多头自注意力(Multi-Head Self-Attention,下图中的 Multi-Head Attention)和层归一化(Layer Normalization,下图中的 Norm),以及残差连接组成。第二个子层由前馈层和层归一化,以及残差连接组成。
本文将围绕多头自注意力、前馈层、层归一化进行介绍。
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 注意力机制的代码实现
- def attention(query, key, value, mask=None, dropout=None):
- # 实现缩放点积注意力机制
- d_k = query.size(-1) # k和q的维度
- # 计算注意力分数
- scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
- # 应用掩码
- if mask is not None:
- # 找到掩码中值为0的位置(需要屏蔽的位置)
- # 将这些位置的注意力分数设置为负无穷,这样在 softmax 后这些位置的注意力权重会接近0
- scores = scores.masked_fill(mask == 0, -1e9)
- # 对最后一个维度(序列长度维度)进行 softmax 操作,得到注意力概率分布
- p_attn = scores.softmax(dim=-1)
- # 应用Dropout
- if dropout is not None:
- p_attn = dropout(p_attn)
- # 返回注意力输出和注意力权重
- 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 实现中,通常是:- query.shape = (batch, heads, seq_len_q, d_k)
- key.shape = (batch, heads, seq_len_k, d_k)
- 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 了。
从形状上看:- (batch, heads, seq_len_q, d_k)
- @
- (batch, heads, d_k, seq_len_k)
- =
- (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\) 的维度配置。由于每个头的维度显著降低,多头注意力的总计算成本与单头全维度注意力的计算成本基本相当。
多头注意力机制的作用:这种结构能让每个注意力机制去学习每个词汇的不同特征部分,从而均衡单一注意力机制可能产生的偏差,让词汇的含义有更多元的表达,实验表明多头注意力机制可以提升模型表现。
在用代码实现多头注意力机制之前,我们要先定义一个克隆函数用于深拷贝网络层。因为在多头注意力中有多个结构相同的线性层。需要几个线性层呢?
多头注意力机制一共需要 4 个线性层。Q, K, V 的投影各需要一个线性层,多头结果拼接后的最终投影也需要一个线性层。
注意,不是每个头单独配线性层,而是所有头共享这 4 个线性层,通过维度拆分实现多头并行计算。- import copy
- def clones(module, N):
- """
- 用于生成相同网络层的克隆函数,它的参数module表示要克隆的目标网络层,N代表需要克隆的数量
- """
- # for 循环:对 module 进行 N 次深度拷贝,使其每个 module 成为独立的层
- # 然后将其放在 nn.ModuleList 类型的列表中存放
- return nn.ModuleList([copu.deepcopy(module) for _ in range(N)])
复制代码 2.1 多头注意力机制代码实现
- class MultiHeadedAttention(nn.Module):
- def __init__(self, h, d_model, dropout=0.1):
- # h:注意力头的数量,决定了模型并行关注不同子空间的能力
- # d_model:模型的总维度,需要能被 h 整除
- # dropout:Dropout概率,默认为0.1
- super(MultiHeadedAttention, self).__init__()
- assert d_model % h == 0
- # We assume d_v always equals d_k
- self.d_k = d_model // h # 每个注意力头的维度
- self.h = h
- # self.linears:包含4个线性层的列表,用于不同的投影操作
- # 输入输出都是 d_model 内部变换矩阵就是 d_model × d_model
- self.linears = clones(nn.Linear(d_model, d_model), 4)
- # 存储注意力权重的变量,可用于后续分析
- self.attn = None
- self.dropout = nn.Dropout(p=dropout) # 正则化,防止模型过拟合,提高泛化能力
- def forward(self, query, key, value, mask=None):
- # 掩码处理
- if mask is not None:
- # 如果提供了掩码,在第1维(注意力头维度)插入一个维度,确保掩码能同时应用到所有注意力头
- mask = mask.unsqueeze(1)
- # 获取批次大小
- nbatches = query.size(0)
- # 1) 线性投影与维度变换
- # 将输入的query、key、value通过线性层投影到多个子空间
- # 并调整张量维度以适应多头注意力计算
- query, key, value = [
- # 对每个输入向量应用对应的线性层
- # lin(x): 将输入从d_model维度投影到d_model维度(实际上是h*d_k)
- # view(nbatches, -1, self.h, self.d_k): 重塑为[批次大小, 序列长度, 注意力头数, 每个头的维度]
- # transpose(1, 2): 交换序列长度和注意力头维度,得到[批次大小, 注意力头数, 序列长度, 每个头的维度]
- lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
- # 遍历线性层列表和输入向量元组
- for lin, x in zip(self.linears, (query, key, value))
- ]
- # 2) 应用注意力机制
- # 计算注意力输出和注意力权重
- x, self.attn = attention(
- query, key, value, mask=mask, dropout=self.dropout
- )
- # 3) 拼接多头结果
- x = (
- # 交换注意力头和序列长度维度,
- # 形状从[batch_size, num_heads, seq_len, d_k]变为[batch_size, seq_len, num_heads, d_k]
- x.transpose(1, 2)
- # 确保张量在内存中是连续的,为后续的view操作做准备
- .contiguous() # 语法规定:可以先 view 然后 transpose ,但是不能先 transpose 然后 view
- # 将num_heads和d_k维度合并,形状变为[batch_size, seq_len, d_model]
- # 变为和输入形状相同
- .view(nbatches, -1, self.h * self.d_k)
- )
- # 清理临时变量并应用最终线性层
- del query
- del key
- del value
- # 第四个线性层
- return self.linears[-1](x)
复制代码 线性变换与维度变换部分的代码- query, key, value = [
- lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
- for lin, x in zip(self.linears, (query, key, value))
- ]
复制代码 这部分的设计意图是:
(1) 将输入分散到多个子空间,每个注意力头专注于不同的特征
(2) 调整维度顺序,使注意力计算可以在批量和多头维度上并行执行
(3) 为后续的 attention 函数调用做准备,确保输入形状符合要求
拼接多头结果部分的代码- x = (x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k))
复制代码 这部分的设计意图是:
(1) 将多个注意力头的输出重新组合成与输入维度相同的张量
(2) 确保多头注意力的输出可以与模型的其他部分无缝集成
(3) 为后续的线性投影做准备
代码验证一下多头注意力机制- query = key = value = pe_result # torch.Size([2, 4, 512])
- attn, p_attn = attention(query, key, value)
- print('attn: ', attn)
- print('attn shape: ', attn.shape)
- print('p_attn: ', p_attn)
- # 实例化参数
- head = 8
- embedding_dim = 512
- dropout = 0.2
- # 输入参数
- query = key = value = pe_result
- mask = Variable(torch.zeros(2, 4, 4))
- mha = MultiHeadedAttention(head, embedding_dim, dropout)
- mha_result = mha(query, key, value, mask)
- print(mha_result)
- print(mha_result.shape)
复制代码 结果- attn: tensor([[[-28.5405, 8.5180, 49.6881, ..., -18.6899, 19.7789, 25.0898],
- [ 4.2671, -30.6077, -15.6015, ..., 0.0000, 0.0000, -0.7959],
- [-27.3173, 15.3063, -3.8314, ..., 59.0806, -20.0541, 2.6914],
- [ 15.5324, 0.0000, 5.3321, ..., -37.6823, -9.8541, 1.2505]],
- [[-18.3518, -38.6171, 0.0000, ..., -8.2401, 15.4565, -46.3310],
- [ -3.5752, -20.1649, 59.3744, ..., -9.8403, -21.6180, 23.2361],
- [-42.6460, 0.0000, 52.3963, ..., -16.5290, 0.0000, 17.9956],
- [-12.7819, -43.9217, -2.9674, ..., 0.0000, 16.9880, 0.0000]]],
- grad_fn=<UnsafeViewBackward0>)
- attn shape: torch.Size([2, 4, 512])
- p_attn: tensor([[[1., 0., 0., 0.],
- [0., 1., 0., 0.],
- [0., 0., 1., 0.],
- [0., 0., 0., 1.]],
- [[1., 0., 0., 0.],
- [0., 1., 0., 0.],
- [0., 0., 1., 0.],
- [0., 0., 0., 1.]]], grad_fn=<SoftmaxBackward0>)
- tensor([[[-3.9070e+00, -3.3605e+00, 3.2105e-03, ..., -5.9947e+00,
- -4.9712e+00, 2.2472e-01],
- [-7.5267e+00, -4.0815e+00, -2.0464e+00, ..., -7.9205e+00,
- -7.4661e+00, -2.8792e+00],
- [-8.9720e+00, -4.2863e+00, -4.4051e+00, ..., -4.5087e+00,
- -9.8329e+00, 2.2278e-01],
- [-9.2407e+00, -4.7355e-01, -1.8737e+00, ..., -5.2340e+00,
- -5.5457e+00, -1.2230e+00]],
- [[-2.4451e+00, 2.0092e+00, -3.2150e+00, ..., -1.3062e+01,
- 2.9305e-02, 5.5562e+00],
- [-4.8202e+00, 2.5720e+00, -8.5146e+00, ..., -4.1689e+00,
- -3.5412e-01, 7.3528e+00],
- [-2.8532e+00, 2.1834e+00, -5.0711e+00, ..., -6.0639e+00,
- -3.5013e-01, 2.6073e+00],
- [-4.7823e+00, 1.7275e+00, -4.4381e+00, ..., -5.6696e+00,
- 7.2888e-01, 1.3089e+00]]], grad_fn=<ViewBackward0>)
- torch.Size([2, 4, 512])
复制代码 3 前馈全连接层
在 Transformer 中,前馈全连接层是具有两层线性层(nn.Linear)的全连接网络。它的全称是逐位置前馈网络(Position-wise Feed-Forward Network, PW-FFN)。
3.1 为什么需要前馈全连接层?
Transformer 的多头自注意力层本质是线性操作,它包含矩阵乘法 + 加权求和 + 线性投影,全程没有任何非线性激活。线性模型只能拟合线性关系,完全无法处理语言、图像这种复杂的非线性语义数据。而前馈全连接层通过插入激活函数(ReLU),为整个模型引入了非线性,让 Transformer 满足万能近似定理,具备拟合任意复杂函数的能力。
3.2 前馈全连接层的代码实现
- class PositionwiseFeedForward(nn.Module):
- """
- 前馈全连接层
- """
- def __init__(self, d_model, d_ff, dropout=0.1):
- """
- d_model: 第一个线性层的输入维度、第二个线性层的输出维度
- d_ff: 第一个线性层的输出维度、第二个线性层的输入维度
- """
- super(PositionwiseFeedForward, self).__init__()
- # 实例化两个线性层对象
- self.w_1 = nn.Linear(d_model, d_ff)
- self.w_2 = nn.Linear(d_ff, d_model)
- # 实例化 dropout 对象
- self.dropout = nn.Dropout(dropout)
- def forward(self, x):
- """
- x: 上一层的输出
- """
- # 先经过第一个线性层,然后经过relu,然后dropout,最后第二个线性层
- return self.w_2(self.dropout(self.w_1(x).relu()))
复制代码 代码验证一下:- x = mha_result
- ff = PositionwiseFeedForward(d_model, d_ff, dropout)
- ff_result = ff(x)
- print(ff_result)
- print(ff_result.shape)
复制代码 运行结果- tensor([[[ 0.2707, -1.3878, 0.3316, ..., 0.4291, 1.0571, -1.4091],
- [-0.0900, 0.3818, -0.9083, ..., -0.7012, 0.8274, -2.2341],
- [ 0.1352, 0.9792, -1.0576, ..., -2.3358, -1.0655, -0.9393],
- [-1.2512, -1.6793, 0.5821, ..., -0.6112, 0.6517, -0.6183]],
- [[-0.2039, 0.2232, -0.5123, ..., -1.4035, -1.5380, -1.6538],
- [ 1.2860, -0.9415, -0.1841, ..., -0.9235, -0.0309, -0.0950],
- [-0.0801, 0.1572, -0.6025, ..., -0.4801, -1.4604, -2.4773],
- [ 0.7402, -0.3408, 0.2080, ..., -0.5617, -1.1475, 0.6073]]],
- grad_fn=<ViewBackward0>)
- 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 层归一化的代码实现
- class LayerNorm(nn.Module):
- # 层归一化
- def __init__(self, features, eps=1e-6):
- """
- features: 词嵌入的维度
- eps: 防止分母为 0 的一个很小的数
- """
- super(LayerNorm, self).__init__()
- # 根据features的形状初始化两个张量,一个是全1张量a_2,一个是全0张量b_2
- self.a_2 = nn.Parameter(torch.ones(features))
- self.b_2 = nn.Parameter(torch.zeros(features))
- self.eps = eps
- def forward(self, x):
- # 计算输入 x 在最后一个维度上的均值和标准差
- mean = x.mean(-1, keepdim=True) # keepdim=True是保持输入输出维度一致
- std = x.std(-1, keepdim=True)
- 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,相当于初始时不改变归一化结果
- 随着训练的进行,模型会逐渐调整这些参数以获得更好的性能
代码验证一下- # 实例化参数
- features = d_model = 512
- eps = 1e-6
- x = ff_result
- ln = LayerNorm(features, eps)
- ln_result = ln(x)
- print(ln_result)
- print(ln_result.shape)
复制代码 运行结果- tensor([[[-1.0068, -0.9608, 0.6201, ..., -1.4909, -1.3558, 0.6573],
- [-0.7536, -1.0619, 0.2659, ..., -1.6619, -2.2498, 0.5365],
- [-0.9633, -0.8850, 0.1006, ..., -1.2924, -1.7498, 0.8749],
- [-0.7381, -1.5674, 0.3517, ..., -1.3092, -1.3130, 0.4619]],
- [[-0.2242, 1.2858, 0.2319, ..., -0.8165, -0.3229, 1.8841],
- [ 0.7869, 0.0377, -0.7480, ..., 0.1628, -1.6486, 1.8530],
- [ 1.3031, 0.7250, -1.5757, ..., -0.2893, -1.4918, 1.0906],
- [ 0.6810, 1.1282, -1.6565, ..., -0.1728, -1.5334, 2.4894]]],
- grad_fn=)
- torch.Size([2, 4, 512])
复制代码 可以看到输出张量的形状还是[2, 4, 512]
5 子层连接结构
如下图所示,每个子层均搭配残差连接与层归一化,这一整体结构被称为子层连接结构(Sublayer Connection)。每个编码器层包含两个子层,对应形成两组子层连接结构。
在 Transformer 论文中,每个子层连接的计算逻辑是 \(\text{LayerNorm}(x + \text{Sublayer}(x))\),但在实现时,常写成 \(x + \text{Sublayer}(\text{LayerNorm}(x))\),即 Pre-Norm 版本。这样设计有解耦结构、代码复用等好处。
5.1 子层连接结构的代码实现
- class SublayerConnection(nn.Module):
- def __init__(self, size, dropout):
- super(SublayerConnection, self).__init__()
- # 实例化层归一化对象 self.norm
- self.norm = LayerNorm(size)
- # 实例化dropout
- self.dropout = nn.Dropout(dropout)
- def forward(self, x, sublayer):
- """
- (1) 层归一化
- (2) 传给子层处理
- (3) dropout
- (4) 残差连接
- """
- return x + self.dropout(sublayer(self.norm(x)))
复制代码 验证一下- # 实例化参数
- size = 512
- dropout = 0.2
- head = 8
- d_model = 512
- x = pe_result # 令 x 为位置编码的输出
- mask = Variable(torch.zeros(2, 4, 4))
- # 假设子层中装的是多头注意力层,实例化这个类
- self_attn = MultiHeadedAttention(head, d_model)
- # lambda 函数捕获了外部变量 self_attn 和 mask,将多参数函数转换为单参数函数
- sublayer = lambda x: self_attn(x, x, x, mask)
- sc = SublayerConnection(size, dropout)
- sc_result = sc(x, sublayer)
- print(sc_result)
- print(sc_result.shape)
复制代码 运行结果- tensor([[[-6.0652e+01, 0.0000e+00, -2.7157e+01, ..., -1.5954e-01,
- -3.2806e+01, 1.6951e+01],
- [ 3.0377e+01, 7.0292e+00, -1.1884e-01, ..., -7.3743e-02,
- -1.0353e+01, 7.5711e+00],
- [ 5.9228e+00, -2.6340e+01, 1.9062e-01, ..., -4.0861e+01,
- 2.1353e+01, -4.2987e+00],
- [ 8.8359e+00, 5.9631e+00, 5.7076e+00, ..., 1.0041e+01,
- 1.2221e-01, 3.5923e+01]],
- [[ 8.4341e+00, -5.5592e+00, 1.0057e+00, ..., -1.0425e+01,
- -5.0544e+00, 1.3094e+01],
- [ 2.6334e+01, 3.5420e-01, 1.2274e+01, ..., 1.6654e+01,
- -2.2240e+01, 1.2143e+01],
- [ 2.7146e+01, -1.0327e+01, 3.0792e+01, ..., 7.0511e+00,
- -5.8975e-03, -1.3620e+01],
- [-1.3136e+00, 4.0415e+01, -1.4205e+01, ..., 1.6174e+01,
- 5.4314e+01, -2.7685e+01]]], grad_fn=)
- torch.Size([2, 4, 512])
复制代码 sublayer = lambda x: self_attn(x, x, x, mask) 这行代码是在干什么?
它等价于定义了一个函数:- def sublayer(x):
- return self_attn(x, x, x, mask)
复制代码 把一个多参数函数包装成单参数函数。
6 编码器层
编码器层是编码器的组成单元,每个编码器层的功能都是提取特征。
6.1 编码器层的代码实现
- class EncoderLayer(nn.Module):
- "Encoder is made up of self-attn and feed forward (defined below)"
- def __init__(self, size, self_attn, feed_forward, dropout):
- """
- size: 词嵌入维度的大小
- self_attn: 多头注意力子层的实例化对象
- feed_forward: 前馈全连接层的实例化对象
- dropout: dropout置零的概率
- """
- super(EncoderLayer, self).__init__()
- self.self_attn = self_attn
- self.feed_forward = feed_forward
- # 克隆 2 个子层
- self.sublayer = clones(SublayerConnection(size, dropout), 2)
- self.size = size
- def forward(self, x, mask):
- """
- 第一层:多头自注意力机制
- (1) 通过 lambda 函数将输入 x 同时作为 query、key、value 传入 self_attn
- (2) 输入 x 先经过层归一化
- (3) 然后通过多头自注意力计算
- (4) 应用 dropout 正则化
- (5) 与原始输入进行残差连接
- """
- x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
- """
- 第二层:前馈层
- (1) 上一层的输出 x 先经过层归一化
- (2) 然后通过前馈神经网络处理
- (3) 应用 dropout 正则化
- (4) 最后与原始输入进行残差连接并返回结果
- """
- return self.sublayer[1](x, self.feed_forward)
复制代码 验证一下- # 实例化参数
- size = 512
- head = 8
- d_model = 512 # 前馈层的输入维度
- d_ff = 64 # 前馈层的输出维度
- x = pe_result # 位置编码的输出作为编码器层的输入
- dropout = 0.2
- self_attn = MultiHeadedAttention(head, d_model)
- ff = PositionwiseFeedForward(d_model, d_ff, dropout)
- mask = Variable(torch.zeros(2, 4, 4))
- el = EncoderLayer(size, self_attn, ff, dropout)
- el_result = el(x, mask)
- print(el_result)
- print(el_result.shape)
复制代码 运行结果- tensor([[[-14.3124, 7.1696, 24.1787, ..., 17.6387, -0.7578, 8.7262],
- [ -8.3042, -27.5725, 21.0715, ..., 0.5590, 16.9088, 24.2006],
- [ -0.4118, -26.7662, -19.0783, ..., 23.8286, 21.8681, 33.2096],
- [ 15.2147, -34.4229, -21.7659, ..., 3.5362, -34.4189, 43.5179]],
- [[-12.5658, 1.5620, -11.3925, ..., -32.0729, 0.6346, 21.0039],
- [ 17.2242, 9.0129, 25.7235, ..., -46.8909, -26.5053, 23.1490],
- [ -2.7985, 0.0665, -3.9887, ..., 2.8081, 49.2965, 30.5333],
- [ -7.2096, 10.4575, -8.5856, ..., 28.0996, 53.2981, 7.2780]]],
- grad_fn=)
- torch.Size([2, 4, 512])
复制代码 7 编码器
如下图所示,Transformer的编码器由N个编码器层堆叠而成,其作用是特征提取。
7.1 编码器层的代码实现
- class Encoder(nn.Module):
- def __init__(self, layer, N):
- super(Encoder, self).__init__()
- """
- layer: 编码器层
- N: 编码器层的个数
- """
- # 克隆N个编码器层,放在self.layers列表当中
- self.layers = clones(layer, N)
- # 初始化层归一化
- self.norm = LayerNorm(layer.size)
- def forward(self, x, mask):
- """
- x: 上一层的输出
- mask: 掩码张量
- """
- # 遍历 N 个编码器层,每次遍历都会得到一个新的x
- for layer in self.layers:
- x = layer(x, mask)
- # 层归一化
- return self.norm(x)
复制代码 验证一下- size = 512
- head = 8
- d_model = 512
- d_ff = 64
- c = copy.deepcopy
- dropout = 0.2
- attn = MultiHeadedAttention(head, d_model)
- ff = PositionwiseFeedForward(d_model, d_ff, dropout)
- layer = EncoderLayer(size, c(attn), c(ff), dropout)
- N = 8
- mask = Variable(torch.zeros(2, 4, 4))
- en = Encoder(layer, N)
- en_result = en(x, mask)
- print(en_result)
- print(en_result.shape)
复制代码 layer = EncoderLayer(size, c(attn), c(ff), dropout) 注意这里也用了深拷贝。
运行结果- tensor([[[-0.0998, -1.5993, -0.7237, ..., 0.0082, -0.8688, 0.1374],
- [-1.5608, -0.1286, -0.1014, ..., -0.4247, -0.7740, 1.5801],
- [-2.5692, -2.7113, -1.0077, ..., 0.8781, -0.6017, 2.4760],
- [ 0.2211, 2.1159, -0.8062, ..., 0.4103, 0.0268, 1.2779]],
- [[ 0.8414, 0.2135, 0.9392, ..., 0.8944, -1.1631, 0.4807],
- [-0.4408, 0.6319, 0.4742, ..., -0.1946, 0.6842, -0.1318],
- [ 0.0145, 0.7471, 0.7337, ..., 2.2360, -1.2449, -1.7164],
- [ 1.0887, -0.1907, -0.9139, ..., 0.1577, 0.1085, 0.8614]]],
- grad_fn=)
- torch.Size([2, 4, 512])
复制代码 附:一些函数的演示
附1:tensor.view() 演示
- x = torch.randn(4, 4)
- print(x.size())
- y = x.view(16)
- print("x 经过 view(16): ", y.size())
- z = x.view(-1, 8)
- print("x 经过 view(-1, 8): ", z.size())
复制代码 运行结果:- torch.Size([4, 4])
- x 经过 view(16): torch.Size([16])
- x 经过 view(-1, 8): torch.Size([2, 8])
复制代码 附2:transpose() 和 view() 的区别
- a = torch.randn(1, 2, 3, 4)
- print("a的原始尺寸", a.size())
- b = a.transpose(1, 2)
- print("a经过transpose(1, 2)后的尺寸:", b.size())
- c = a.view(1, 3, 2, 4)
- print("a经过view(1, 3, 2, 4)后的尺寸:", c.size())
- print("经过transpose(1, 2)后和经过view(1, 3, 2, 4)后的张量是否相等:", torch.equal(b, c))
复制代码 运行结果:- a的原始尺寸 torch.Size([1, 2, 3, 4])
- a经过transpose(1, 2)后的尺寸: torch.Size([1, 3, 2, 4])
- a经过view(1, 3, 2, 4)后的尺寸: torch.Size([1, 3, 2, 4])
- 经过transpose(1, 2)后和经过view(1, 3, 2, 4)后的张量是否相等: False
复制代码 transpose 改变的是数据的排列方式(stride),而 view 只是重新解释内存,不会重新排列数据。view 必须作用于连续张量。
代码演示:- # 构造固定值的张量,方便观察
- a = torch.arange(24).reshape(1, 2, 3, 4)
- print("a的原始值:\n", a)
- print("a的原始尺寸:", a.size())
- # 交换维度1和2
- b = a.transpose(1, 2)
- print("\nb = a.transpose(1, 2) 的值:\n", b)
- print("b的尺寸:", b.size())
- # 直接修改维度形状
- c = a.view(1, 3, 2, 4)
- print("\nc = a.view(1, 3, 2, 4) 的值:\n", c)
- print("c的尺寸:", c.size())
- # 验证是否相等
- print("\nb和c是否相等:", torch.equal(b, c))
复制代码 运行结果:- a的原始值:
- tensor([[[[ 0, 1, 2, 3],
- [ 4, 5, 6, 7],
- [ 8, 9, 10, 11]],
- [[12, 13, 14, 15],
- [16, 17, 18, 19],
- [20, 21, 22, 23]]]])
- a的原始尺寸: torch.Size([1, 2, 3, 4])
- b = a.transpose(1, 2) 的值:
- tensor([[[[ 0, 1, 2, 3],
- [12, 13, 14, 15]],
- [[ 4, 5, 6, 7],
- [16, 17, 18, 19]],
- [[ 8, 9, 10, 11],
- [20, 21, 22, 23]]]])
- b的尺寸: torch.Size([1, 3, 2, 4])
- c = a.view(1, 3, 2, 4) 的值:
- tensor([[[[ 0, 1, 2, 3],
- [ 4, 5, 6, 7]],
- [[ 8, 9, 10, 11],
- [12, 13, 14, 15]],
- [[16, 17, 18, 19],
- [20, 21, 22, 23]]]])
- c的尺寸: torch.Size([1, 3, 2, 4])
- b和c是否相等: False
复制代码 来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |