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

我们在结构拆解那篇文章中讲过,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 注意力机制的代码实现

python 复制代码
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 个线性层,通过维度拆分实现多头并行计算。

python 复制代码
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 多头注意力机制代码实现

python 复制代码
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)

线性变换与维度变换部分的代码

python 复制代码
        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 函数调用做准备,确保输入形状符合要求

拼接多头结果部分的代码

python 复制代码
        x = (x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k))

这部分的设计意图是:

(1) 将多个注意力头的输出重新组合成与输入维度相同的张量

(2) 确保多头注意力的输出可以与模型的其他部分无缝集成

(3) 为后续的线性投影做准备

代码验证一下多头注意力机制

python 复制代码
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)

结果

plaintext 复制代码
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 前馈全连接层的代码实现

python 复制代码
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()))

代码验证一下:

python 复制代码
x = mha_result
ff = PositionwiseFeedForward(d_model, d_ff, dropout)
ff_result = ff(x)
print(ff_result)
print(ff_result.shape)

运行结果

plaintext 复制代码
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 层归一化的代码实现

python 复制代码
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,相当于初始时不改变归一化结果
  • 随着训练的进行,模型会逐渐调整这些参数以获得更好的性能

代码验证一下

python 复制代码
# 实例化参数
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)

运行结果

plaintext 复制代码
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=<AddBackward0>)
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 子层连接结构的代码实现

python 复制代码
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)))

验证一下

python 复制代码
# 实例化参数
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)

运行结果

plaintext 复制代码
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=<AddBackward0>)
torch.Size([2, 4, 512])

sublayer = lambda x: self_attn(x, x, x, mask) 这行代码是在干什么?

它等价于定义了一个函数:

python 复制代码
def sublayer(x):
    return self_attn(x, x, x, mask)

把一个多参数函数包装成单参数函数。

6 编码器层

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

6.1 编码器层的代码实现

python 复制代码
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)

验证一下

python 复制代码
# 实例化参数
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)

运行结果

plaintext 复制代码
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=<AddBackward0>)
torch.Size([2, 4, 512])

7 编码器

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

7.1 编码器层的代码实现

python 复制代码
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)

验证一下

python 复制代码
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) 注意这里也用了深拷贝。

运行结果

plaintext 复制代码
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=<AddBackward0>)
torch.Size([2, 4, 512])

附:一些函数的演示

附1:tensor.view() 演示

python 复制代码
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())

运行结果:

plaintext 复制代码
torch.Size([4, 4])
x 经过 view(16):  torch.Size([16])
x 经过 view(-1, 8):  torch.Size([2, 8])

附2:transpose()view() 的区别

python 复制代码
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))

运行结果:

plaintext 复制代码
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 必须作用于连续张量。

代码演示:

python 复制代码
# 构造固定值的张量,方便观察
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))

运行结果:

plaintext 复制代码
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
相关推荐
盼小辉丶6 小时前
Transformer实战——视觉提示模型
深度学习·计算机视觉·transformer
机器学习之心6 小时前
CEEMDAN-VMD-Transformer-LSTM双重分解+编码器+长短期记忆神经网络多元时间序列预测
神经网络·lstm·transformer
Flying pigs~~7 小时前
从“计数”到“对话”:一文读懂语言模型与大语言模型 (LLM)
人工智能·语言模型·自然语言处理·大模型·llm·transformer
renhongxia17 小时前
基于角色的大型语言模型框架,用于从健康食品政策中提取结构化信息
人工智能·深度学习·游戏·microsoft·语言模型·自然语言处理·transformer
老刘说AI20 小时前
浅谈多模态领域的Transformer
人工智能·深度学习·神经网络·机器学习·语言模型·transformer
夜幕下的ACM之路21 小时前
一、基础知识学习(Transformer + 上下文窗口 + Token 计算 + Embedding 向量)
人工智能·学习·transformer·embedding
前端摸鱼匠21 小时前
【AI大模型春招面试题13】残差连接(Residual Connection)与层归一化(Layer Norm)在Transformer中的作用?
人工智能·深度学习·语言模型·面试·transformer·求职招聘
小超同学你好1 天前
Transformer 19. Qwen 2 架构介绍:相对 Qwen 1 / Qwen 1.5 的演进与 MoE 扩展
深度学习·架构·transformer
高洁011 天前
大模型Prompt实战:精准生成专业技术文档
人工智能·python·数据挖掘·transformer·知识图谱