大模型基石——Transformer架构深度解构

2017年的《Attention Is All You Need》开辟了大语言模型时代。本文将从原理部分出发,剖析模型的设计思想,并且使用PyTorch搭建Transformer模型,训练一个机器翻译模型,以更深入理解Transformer结构。

1 词嵌入

将token转换为计算机能够理解的语言,这叫做词嵌入(Word Embedding)。即把一个token映射到词向量空间中,用一个高维度的向量来表示。在统计机器学习时代,词向量使用独热编码、词袋等模型来表示,但这些模型并不能反映词语和词语之间语义的关联。

之后Word2Vec模型提出将token映射为静态的向量,不需要再考虑其在句子中的位置、上下文之间的关系。比如说将token映射到3维空间中,那么语义相近的词向量,方向也应该是相近的。如图所示以中文token为例,token“善”和“好”都是正面的词汇,映射在第一卦限中;“恶”是负面的词汇,和前面两个token的语义完全相反,距离更远,因此其方向是相反的,比如在第八卦限中。

一个词也不仅仅有语义之间的差别,还有词性,如动词、名词之间的差别。因此,低维度的向量无法充分表示token之间各种各样的关系,词嵌入一般将一个token映射到一个高维度空间中,向量之间的距离代表token之间语义、词性等的关系。

Word2Vec通过大量的语料来单独训练出一个词嵌入模型,将token转换为静态的高维度的向量。而Transformer的做法是将词嵌入模型和语言模型放在一起训练,模型参数更新的同时也调整词嵌入向量的方向,让模型在语料中自行学习token之间的关系,并适配所训练的模型。

将token映射到维度大小为\(d_{model}\)的向量空间中,基于此将一个长度为\(S\)seq_len = S)的序列映射为一个矩阵,记作\(\boldsymbol E_{token}\in\mathbb{R}^{S\times d_{model}}\)。注意\(\boldsymbol E_{token}\)是可学习的参数。

位置编码 Positional Encoding

尽管后续的大模型并未采用Transformer的静态正余弦位置编码(采用的是旋转位置编码),但位置编码仍为后续模型的设计提供了思路。Transformer的正余弦静态编码的思路就是对每个词的位置向量,奇数位采用余弦值生成,偶数位采用正弦值生成,位置向量的维度和词嵌入的向量维度一样,皆是\(\mathbb{R}^{d_{model}}\)

\[\begin{align*} PE(\text{pos}, 2i)&=\sin(\text{pos}/10000^{2i/d_{model}}),\\ PE(\text{pos}, 2i+1)&=\cos(\text{pos}/10000^{2i/d_{model}}). \end{align*} \]

式中\(\text{pos}\)表示这个token在原序列中的位置,下标从\(0\)开始算。记位置编码矩阵为\(\boldsymbol E_{pos}\),该矩阵的值是静态不变的。

综上,Transformer的输入是\(\boldsymbol X=\boldsymbol E_{pos}+{\color{red} \boldsymbol E_{token}}\)​,标红部分的值是随着模型的训练而动态调整的。

2 注意力机制

回顾Seq2Seq

回忆一下Seq2Seq模型结构,比如使用Seq2Seq完成一个机器翻译的模型训练任务,Encoder负责理解和总结待翻译的序列(上文),经过RNN的最后一个时间步的隐藏层输出,作为上下文向量(Context Vector)\(\boldsymbol C\)输入给Decoder。如图所示,比如我要将“你好呀”翻译为“Nice to meet you”,首先我要将两个序列进行分词得到一个一个的token,然后再将其映射到高维的词向量空间中。

中文语句分词为['<bos>', '你', '好', '呀', '<eos>'],这里为了画图方便,省略了句号,句号也应该作为一个单独的token。此外,序列还加上了两个特殊的token,用于表示句子的开头和结尾。Encoder将分词后的序列中的语义提取出来,归纳总结为一个向量,然后输入给Decoder作为上文的参考,Decoder根据上文的语义信息来进行推理预测:一切句子的开头都是<bos>这个特殊的token,经过上文的提示,第一个时间步预测出“Nice”,并将“Nice”的词向量和当前时间步的隐藏层输出作为下一个时间步的输入。直到输出预测的序列长度达到设定的最大值或预测输出句子的结尾token<eos>

注意力思想

在Encoder中,无论输入序列有多长,终究都要被压缩为一个固定维度的向量,这个过程不可避免地导致信息的损失。Decoder序列过长的时候,网络会遗忘上文信息,因此在生成下一个token的时候,要时不时回头看一下原序列的相关内容。就好比做阅读理解,针对一个选择题,需要在原文的信息强相关的段落去找答案,而不是把整篇文章读一遍再去选择答案,这样会造成注意力涣散,难以做出正确的选择,即我需要将注意力放在解决当前问题的上文段落中。因此在每个时间步的输入,需要添加一个关于上文的注意力向量。对于时间步\(j\)来说,可以想到使用加权的方法得到注意力向量:

\[\boldsymbol a_j=\sum_{i=1}^S\alpha_{ij}\boldsymbol h_i, \]

其中,\(S\)表示Encoder输入序列的长度,表示时间步\(j\)的输出和第\(i\)个token的相关性,且\(\displaystyle \sum_{i=1}^S\alpha_{ij}=1\)。相关性越大,表示Deocder时间步\(j\)的输出答案应该去Encoder的第\(i\)个时间步的隐藏层输出(提取了第\(i\)个输入token和其上文的语义信息)的信息里面找,即\(\alpha_{ij}\)越大。那么如何知道上文中哪些信息很相关呢?上面提到的词嵌入给出了答案。token的语义、词性等属性越相近,其词向量在向量空间中就越接近,那么它们的点积就越大。因此对于Encoder输入序列\(\boldsymbol X=(\boldsymbol x_1^T,\cdots, \boldsymbol x_S^T)^T\),Decoder的第\(j\)个时间步的token输入\(\boldsymbol y_j\)来说,加权系数(这里也可以叫做注意力分数)计算可以是

\[e_{ij}=\boldsymbol x\boldsymbol y^T, \]

其中,词向量采用计算机中的标准来表示,即行向量是一维的,列向量是特殊的二维矩阵。一般来说,Encoder和Decoder的词嵌入向量维度是一样的,若不一样,则需要经过线性映射再进行点积:\(e_{ij}=\boldsymbol x\boldsymbol W\boldsymbol y^T\)。之后再进行归一化,一般采用Softmax方法:

\[\alpha_{ij}=\frac{e^{e_{ij}}}{\sum_{k=1}^S e_{kj}}. \]

这就是2015年《Effective Approaches to Attention-based Neural Machine Translation》提出的Luong Attention(也叫做Multiplicative Attention)。你可以通过向量拼接的方式将注意力向量输入到Decoder中:\((\boldsymbol y_j, \boldsymbol a_j)\)。维度翻倍,这时候只需要将RNN中的线性变换矩阵的某一维度也翻倍即可。怎样输入注意力矩阵并不重要,重要的是知道注意力的目的和计算方式。

自注意力机制 Self-Attention

通过Luong Attention的学习,我们知道一个token想要注意到上文信息对自己重要的部分,需要有3种向量参与运算:上文序列的词向量\(\boldsymbol x_i\ (1\le i\le S)\),第\(i\)个时间步经过RNN上文语义信息提取的隐藏层输出\(\boldsymbol h_i\),以及Decoder当前时间步\(j\)的输入token词向量\(\boldsymbol y_j\)。由于RNN的第\(t\)个时间步需要等待上一个时间步\(t-1\)的输出,导致模型的训练和推理是串行执行的,速度非常慢。因此Transformer提出Self-Attention,使模型的训练和推理能够并行执行,下一个token的输出不再依赖于上一个token的输入。

具体的做法就是将输入token \(\boldsymbol x_i\ (\in\mathbb{R}^{d_{model}})\)映射为3个向量:\(\boldsymbol q_i, \boldsymbol k_i, \boldsymbol v_i\ (\in\mathbb{R}^{d_{model}})\)。先解释一下这三个向量的含义:

  • \(\boldsymbol q_i\)表示query,询问上文中哪些语义、文本信息是值得注意的,相当于上面提到的\(\boldsymbol y_j\)
  • \(\boldsymbol k_i\)表示key,相当于上一段提到的\(\boldsymbol x_i\)(不是本段的),是上文序列的词向量;
  • \(\boldsymbol v_i\)表示value,相当于提取上文序列词向量\(\boldsymbol k_i\)信息的隐藏层\(\boldsymbol h_i\)

这三个向量的来源是一致的,即\(\boldsymbol x_i\),只不过线性变换不同:

\[\begin{align*} \boldsymbol q_i=&\boldsymbol x_i\boldsymbol W^Q, \\ \boldsymbol k_i=&\boldsymbol x_i\boldsymbol W^K, \\ \boldsymbol v_i=&\boldsymbol x_i\boldsymbol W^V. \end{align*} \]

token和下标\(i\)一一对应。那么\(\boldsymbol q_i, \boldsymbol k_i, \boldsymbol v_i\)凭什么能够对应上计算注意力的3个向量呢?实际上,\(\boldsymbol q_i, \boldsymbol k_i, \boldsymbol v_i\)是线性映射得到的,映射空间\(\boldsymbol W^Q, \boldsymbol W^K, \boldsymbol W^V\ (\in\mathbb{R}^{d_{model}\times d_{model}})\)需要模型自己去学习,让模型在训练过程中理解这种注意力的思想。因此需要构造出注意力计算的流程,才能让模型学习到。将向量组装成矩阵来进行计算,如下图所示,让token的注意力放在自身上下文当中,这就是Self的由来。

经过点积计算,可以得到每个token在自身上下文中的注意力分数矩阵\(\boldsymbol Q\boldsymbol K^T\)。由于模型对参数的大小非常敏感,论文首先采用缩放点积注意力(Scaled Dot-Product Attention)的方式对注意力分数进行缩放,然后再进行Softmax,最后和语义信息矩阵\(\boldsymbol V\)相乘,得到注意力矩阵:

\[\text{Attention}(\boldsymbol Q, \boldsymbol K, \boldsymbol V)=\text{Softmax}(\frac{\boldsymbol Q\boldsymbol K^T}{\sqrt{d_{k}}})\boldsymbol V. \]

在这里\(d_k=d_{model}\)\(\boldsymbol Q, \boldsymbol K, \boldsymbol V\in\mathbb{R}^{S\times d_{model}}\)。注意力矩阵\(\text{Attention}(\boldsymbol Q, \boldsymbol K, \boldsymbol V)\in\mathbb{R}^{S\times d_{model}}\),意味着第\(i\)行的向量是第\(i\)个token的注意力向量,因为乘\(\boldsymbol V\)是加权求和的过程(将\(\boldsymbol V\)的所有行向量加权求和)。

掩码机制 Masked Self-Attention

在Decoder阶段,需要使用序列['<bos>', 'Nice', 'to', 'meet', 'you', '.']预测输出['Nice', 'to', 'meet', 'you', '.', '<eos>']。即通过上文来预测下文,这并不是做一个“完形填空”的NLP任务,因此一个token就不能注意到它所有的上下文了。具体来讲,to只能注意到Nice<bos>,而不能注意到meetyou,否则模型在训练的过程中会发现,将注意力放在后文能够更快地降低损失,导致过拟合。这就相当于在考试的过程中给模型提供答案。因此,我们希望token对于后文的注意力分数为0。如图所示,灰色代表值为0(或者极小),黄色代表值非0,在Query矩阵中,to对应的query向量不能注意到meet,而you可以,在对应的注意力分数矩阵中的值分别为0和非0。依此类推,所有的token只能注意到其自身上文的token,最终得到的注意力分数矩阵的上三角(不含对角线)应该均为0。可以用一个掩码矩阵对\(\boldsymbol Q\boldsymbol K^T\)按位相乘来实现掩码。

多头注意力机制 Multi-Head Attention

经过自注意力机制,输入输出的并行已经提高了训练和推理的速度。然而,这还不够快。基于数学原理,在\(d_{model}\)维度上做切分。举一个例子,\(d_{model}=6\),将这个维度切分成3个部分,\(\boldsymbol Q, \boldsymbol K, \boldsymbol V\)都是同样的操作。得到如图所示的蓝、绿、红三个部分,相同颜色的矩阵做乘法,分别得到注意力分数矩阵的一部分,这些零散的部分相加就是完整的注意力分数矩阵。上述的这些矩阵乘法操作在GPU上可以并行执行。因此,蓝、绿、红是3个不同的头(Head),这几个头并行做相同的操作。

一般而言,\(d_{model}\)能够被头的个数\(h\)整除,\(\boldsymbol Q, \boldsymbol K, \boldsymbol V\)的维度则变为了\(\mathbb{R}^{S\times h\times d_k}\),其中\(d_k=d_{model} / h\)。这几个头能够并行执行,因此将头这个维度放在最外层,也就是\(\mathbb{R}^{h\times S\times d_k}\),即\(h\)个大小为\(\mathbb{R}^{S\times d_k}\)的矩阵同时做相同的操作(矩阵乘、掩码)。由数学推导,要得到和没有进行多头切分的计算结果,\(\boldsymbol V\)不应该进行多头处理,那么为什么这里计算注意力矩阵的每个头是

\[\text{head}_k=\text{Sofrmax}(\frac{\boldsymbol q_k\boldsymbol k_k^T}{\sqrt{d_k}})\boldsymbol v_k,\ 1\le k\le h \]

而不是\(\displaystyle \text{head}_k=\text{Sofrmax}(\frac{\boldsymbol q_k\boldsymbol k_k^T}{\sqrt{d_k}})\boldsymbol V\)?实际上,多头注意力不仅仅实现了并行计算注意力,而且通过将输入映射到多个不同的子空间,增强模型捕捉不同语义关系的能力,比如说第一个头的子空间(\(\in\mathbb{R}^{d_k}\))代表词性,第二个头的子空间代表token含义......这些子空间的含义在训练过程中让模型自己学习。

由于\(\boldsymbol q_k\boldsymbol k_k^T\)的大小不变,因此做掩码的时候,几个头是并行mask的。在并行计算完成以后,需要将维度还原为\(\mathbb{R}^{S\times d_{model}}\),即完成头的拼接:\(\text{Concat}(head_1,\cdots,head_h)\)。在此之后还需要做一个线性变换(线性变换的参数\(\boldsymbol W^O\)是可学习的),得到最终的多头注意力矩阵:

\[\text{MHA}=\text{Concat}(head_1,\cdots,head_h)\boldsymbol W^O. \]

3 层归一化 Layer Normalization

简单回顾一下在CNN卷积神经网络中的批归一化(Batch Normalization)。如图所示,以一个Batch中的每个特征图的同一个通道为单位进行归一化,即计算图中红色部分的所有数据的均值和方差。

2016年《Layer Normalization》提出层归一化,即以一张特征图(通道数×H×W)为单位进行归一化,和Batch没关系了。Transformer将这个思想引入到序列数据中:将通道Channel看作是一个token的特征维度\(d_{model}\)\(H\times W\)这一张特征图相当于token词向量中的一个特征,一共有\(d_{model}\)个这样的特征,因此Transformer中的Layer Norm是以一个token的特征向量(词向量)为单位进行层归一化。

归一化(Layer或Batch)的具体流程是计算一个单位\(\boldsymbol x=(x_1,\cdots,x_{d_{model}})\)中的均值\(E(\boldsymbol x)\)和方差\(D(\boldsymbol x)\),然后再对每个值进行线性变换(缩放和平移),得到每个数值的归一化

\[y_i=\frac{x_i-E(\boldsymbol x)}{\sqrt{D(\boldsymbol x)}+\epsilon}\cdot\gamma_i+\beta_i,\ 1\le i\le d_{model}. \]

其中线性变换的参数\(\gamma_i\)\(\beta_i\)可学习的参数,随着模型的训练一起调整。有时候分母还写作\(\sqrt{D(\boldsymbol x)+\epsilon}\),这个并没有关系。

Layer Normalization的线性变换参数\(\boldsymbol\gamma,\boldsymbol\beta\)和序列长度、批次大小都没有关系,因此保证了训练和推理时的计算行为一致。

4 用PyTorch组装起来

Transformer主要的思想和重要的结构已经讲解完毕,接下来我们使用PyTorch搭建一个Transformer网络。首先查看论文中所给出的模型结构:

训练的过程中一般都是以批次为单位,批次大小为batch_size = B,这就意味着\(B\)个大小相同的序列\(S\times d_{model}\)做相同的操作,因此图中Encoder Block的输入输出大小总是\(B\times S\times d_{model}\)。原论文图中的\(N\)表示Encoder Block的个数。Encoder模块最后一层的输出作为所有Decoder Block的交叉注意力输入,输入到MHA的value和key的位置,这和Seq2Seq的含义相同,编码器将源序列(Source Sequence)的信息压缩到大小为\(S\times d_{model}\)的矩阵中,作为目标序列(Target Sequence)的“查询”对象。

与此同时,Decoder的输入和输出的大小一样,与Encoder类似,只不过序列长度记作\(T\)但是实际上\(S=T\),如果序列长度不一样的话,矩阵乘法就不能对应上了。所以需要将源序列和目标序列的长度填充至最大值

1. MHA

图中有3个注意力模块(橘黄色方块),每个模块的操作是一样的,只不过在输入和输出、是否添加掩码这几个方面不同。将该操作总结到一个网络模块中:

import math
import torch
import torch.optim as optim

class MHABlock(nn.Module):
    def __init__(self, d_model: int, h: int, dropout: float):
        super().__init__()
        self.d_model = d_model
        self.h = h
        assert d_model % h == 0, "h 无法整除 d_model"

        self.d_k = d_model // h
        self.WQ = nn.Linear(d_model, d_model, bias=False)
        self.WK = nn.Linear(d_model, d_model, bias=False)
        self.WV = nn.Linear(d_model, d_model, bias=False)
        self.WO = nn.Linear(d_model, d_model, bias=False)
        self.dropout = nn.Dropout(dropout)

    @staticmethod
    def attention(Q: torch.tensor, K: torch.tensor, V: torch.tensor, mask, dropout: nn.Dropout):
        # Q, K, V: (B, h, S, d_k)
        d_k = Q.shape[-1]
        attn_scores = (Q @ K.transpose(-2, -1)) / math.sqrt(d_k)
        if mask is not None:
            attn_scores.masked_fill_(mask == 0, -1e9)
        attn_scores = attn_scores.softmax(dim=-1)
        if dropout is not None:
            attn_scores = dropout(attn_scores)
        return attn_scores @ V

    def forward(self, XQ: torch.tensor, XK: torch.tensor, XV: torch.tensor, mask):
        # 多头拆分 (B, S, d_model) ==> (B, S, h, d_k) ==> (B, h, S, d_k)
        Q = self.WQ(XQ)
        K = self.WK(XK)
        V = self.WV(XV)

        Q = Q.view(Q.shape[0], Q.shape[1], self.h, self.d_k).transpose(1, 2)
        K = K.view(K.shape[0], K.shape[1], self.h, self.d_k).transpose(1, 2)
        V = V.view(V.shape[0], V.shape[1], self.h, self.d_k).transpose(1, 2)

        attn = MHABlock.attention(Q, K, V, mask, self.dropout)
        # 多头合并 contiguous()感觉是深拷贝的意思
        attn = attn.transpose(1, 2).contiguous().view(attn.shape[0], -1, self.d_model)
        return self.WO(attn)
  • 在多头注意力机制中,大小为\(B\times h\times S\times d_k\)的张量计算表示\(B\times h\)\(S\times d_k\)矩阵并行计算
  • 如果没有传入mask矩阵,即mask = None,则表示不进行掩码操作。

2. Layer Norm

gammabeta定义为可学习的参数,保留最后一个维度,即keepdim=True,方便PyTorch广播。

class LayerNorm(nn.Module):
    def __init__(self, features: int, eps: float=1e-6):
        super().__init__()
        self.eps = eps
        self.gamma = nn.Parameter(torch.ones(features))
        self.beta = nn.Parameter(torch.zeros(features))

    def forward(self, x):
        # x (B, S, d_model)
        # 在代码表示中 d_model = hidden_size = features
        mean = x.mean(dim=-1, keepdim=True)
        std = x.std(dim=-1, keepdim=True)    # (B, S, 1)
        return self.gamma * (x - mean) / (std + self.eps) + self.beta

3. Positional Encoding

class PositionalEncoding(nn.Module):
    def __init__(self, d_model: int, seq_len: int, dropout: float):
        super().__init__()
        self.d_model = d_model
        self.seq_len = seq_len
        self.dropout = nn.Dropout(dropout)

        pe = torch.zeros(seq_len, d_model)
        # 大小为d_model的行向量, 再添加一个维度使之变成列向量
        position = torch.arange(0, seq_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.pow(10000.0, -torch.arange(0, d_model, 2, dtype=torch.float) / d_model)
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0) # 在 batch_size 维度上广播
        # 注册为一个buffer, 不会参与训练, 但会随模型一起被保存到GPU中
        self.register_buffer('pe', pe)

    def forward(self, x):
        x = x + (self.pe[:, :x.shape[1], :]).requires_grad_(False)
        return self.dropout(x)

4. Feed Forward

前向传播模块很简单,先升维,将\(d_{model}\)映射到\(d_{ff}\)空间中,然后再降维回\(d_{model}\),类似于计算机视觉中上采样和下采样的过程。

class FFNBlock(nn.Module):
    def __init__(self, d_model: int, d_ff: int, dropout: float):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(d_model, d_ff), nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(d_ff, d_model)
        )

    def forward(self, x):
        return self.net(x)

5. 组装Encoder*

class EncoderBlock(nn.Module):
    def __init__(self, features: int, num_heads: int, d_ff: int, dropout: float):
        super().__init__()
        self.mha_block = MHABlock(d_model=features, h=num_heads, dropout=dropout)
        self.ffn_block = FFNBlock(d_model=features, d_ff=d_ff, dropout=dropout)
        self.norm1 = LayerNorm(features)
        self.norm2 = LayerNorm(features)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, src_mask):
        # Pre-Norm
        _x = self.norm1(x)
        x = x + self.dropout(self.mha_block(_x, _x, _x, src_mask))

        _x = self.norm2(x)
        x = x + self.dropout(self.ffn_block(_x))
        return x

class Encoder(nn.Module):
    def __init__(self, num_layers: int, features: int, num_heads: int, d_ff: int, dropout: float):
        super().__init__()
        self.layers = nn.ModuleList([EncoderBlock(features, num_heads, d_ff, dropout) for _ in range(num_layers)])

    def forward(self, x, src_mask):
        for layer in self.layers:
            x = layer(x, src_mask)
        return x

Pre-Norm和Post-Norm是两种不同的Layer Normalization和Residual Connections组合方式。它们在Transformer模型中有着不同的应用和效果。

  • 原始的Transformer论文中使用的是Post-Norm结构:

    y = Norm(x + attn(x))
    x_next = Norm(y + FFN(y))
    

    在训练深层网络时,存在梯度容易爆炸、学习率敏感、初始化权重敏感等问题,需要大量调参和Warm up

  • 而Pre-Norm:

    y = x + attn(Norm(x))
    x_next = y + FFN(Norm(y))
    

    这样训练更加稳定,收敛性更好,因此在大模型时代被广泛采用。但是也存在潜在的表示塌陷问题,即靠近输出位置的层会变得非常相似,从而对模型的贡献变小。

6. 组装Decoder

和Encoder结构类似

class DecoderBlock(nn.Module):
    def __init__(self, features: int, num_heads: int, d_ff: int, dropout: float):
        super().__init__()
        self.mha_block = MHABlock(d_model=features, h=num_heads, dropout=dropout)
        self.masked_mha_block = MHABlock(d_model=features, h=num_heads, dropout=dropout)
        self.ffn_block = FFNBlock(d_model=features, d_ff=d_ff, dropout=dropout)
        self.norm1 = LayerNorm(features)
        self.norm2 = LayerNorm(features)
        self.norm3 = LayerNorm(features)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, encoder_output, src_mask, tgt_mask):
        # Pre-Norm
        _x = self.norm1(x)
        x = x + self.dropout(self.masked_mha_block(_x, _x, _x, tgt_mask))

        _x = self.norm2(x)
        x = x + self.dropout(self.mha_block(_x, encoder_output, encoder_output, src_mask))

        _x = self.norm3(x)
        x = x + self.dropout(self.ffn_block(_x))
        return x

class Decoder(nn.Module):
    def __init__(self, num_layers: int, features: int, num_heads: int, d_ff: int, dropout: float):
        super().__init__()
        self.layers = nn.ModuleList([DecoderBlock(features, num_heads, d_ff, dropout) for _ in range(num_layers)])

    def forward(self, x, encoder_output, src_mask, tgt_mask):
        for layer in self.layers:
            x = layer(x, encoder_output, src_mask, tgt_mask)
        return x

7. 搭建完整的模型

在此只是简单的实现,torch.nn中的实现只有Deocder和Encoder结构,不包含词嵌入和最终预测输出的部分。还有一些参数已经在前面的模块中写死了,比如Q、K、V的计算是否需要bias。

class Transformer(nn.Module):
    def __init__(
            self,
            src_vocab_size: int,
            tgt_vocab_size: int,
            d_model: int=512,
            nhead: int=8,
            num_encoder_layers: int=6,
            num_decoder_layers: int=6,
            dim_feedforward: int=2048,
            dropout: float=0.1,
            src_seq_len: int=512,
            tgt_seq_len: int=512
    ):
        super().__init__()
        self.src_embedding = nn.Embedding(src_vocab_size, d_model)
        self.tgt_embedding = nn.Embedding(tgt_vocab_size, d_model)
        self.input_pe = PositionalEncoding(d_model, src_seq_len, dropout)
        self.output_pe = PositionalEncoding(d_model, tgt_seq_len, dropout)

        self.encoder = Encoder(num_encoder_layers, d_model, nhead, dim_feedforward, dropout)
        self.decoder = Decoder(num_decoder_layers, d_model, nhead, dim_feedforward, dropout)

        self.fc_out = nn.Linear(d_model, tgt_vocab_size)
        self.dropout = nn.Dropout(dropout)
        self.d_model = d_model

    def forward(self, src, tgt, src_mask, tgt_mask):
        src = self.input_pe(self.src_embedding(src) * math.sqrt(self.d_model))
        tgt = self.output_pe(self.tgt_embedding(tgt) * math.sqrt(self.d_model))

        encoder_output = self.encoder(src, src_mask)
        decoder_output = self.decoder(tgt, encoder_output, src_mask, tgt_mask)

        output = self.fc_out(decoder_output)
        return output

为什么这里的位置编码需要乘\(\sqrt{d_{model}}\)​?

词嵌入层通常使用Xavier或Kaiming初始化,其权重方差设计为\(1/d_{model}\),若未进行缩放,则嵌入输出的方差为1,但点积后方差仍会膨胀至\(d_k\),因此输入Embedding应该先乘\(\sqrt{d_{model}}\),使嵌入输出的方差为\(d_{model}\),点积后再除\(\sqrt{d_k}\),将方差拉回1。

5 训练一个机器翻译模型

在写代码之前,必须搞清楚应当如何处理一个序列。数据处理的流程如下图所示。假设现在有两句话“衬衫的价格是9磅15便士。”以及“李华正在写作文。”,将这两句话作为一个批次输出,那么批次大小\(B = 2\)。对批次中的每一句话进行分词,图中以BERT中文分词器为例。接下来添加首位特殊token,用于判别句子的开始和结尾,并且以一个批次中的最大长度为准,将该batch中的所有句子填充到最大长度,便于构造出一个三个维度的张量用于计算。然后通过查询词汇表,将token转换成token id。最后进行词嵌入,每个id对应一个大小为$$d_{model}的向量,那么每个句子就用一张\(S\times d_{model}\)大小的矩阵表示(图中红色方框表示),一个批次中有\(B\)个这样的矩阵(图中有2个),于是得到Decoder或Encoder的一次输入\(\boldsymbol X\ (\in\mathbb{R}^{B\times S\times d_{model}})\)

数据处理

首先准备数据集,中英文翻译的数据集比较难找,这里推荐translation2019zh数据集,在kaggle上就能搜索到。该数据集包含train和valid两个部分,train的大小大概是1.2G,其JSON数据包含格式为:{"english": "xxx", "chinese": "xxx"},有多个这样的JSON行。它并不是一个标准的JSON文件,而是每一行符合JSON格式。因此采用以下方式进行数据的处理:

from torch.utils.data import Dataset, DataLoader
import json

class TranslationDataset(Dataset):
    def __init__(self, data_path, src_tokenizer, tgt_tokenizer, max_len=128):
        self.data = self.load_data(data_path)
        self.src_tokenizer = src_tokenizer
        self.tgt_tokenizer = tgt_tokenizer
        self.max_len = max_len

    def load_data(self, path):
        data = []
        if not os.path.exists(path):
            print(f"ERROR:文件 {path} 不存在")
            return None

        with open(path, 'r', encoding='utf-8') as f:
            content = f.read().strip()
            lines = content.split('\n')
            for line in lines:
                data.append(json.loads(line))
        return data

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        item = self.data[idx]
        src_text = item.get('chinese', '')
        tgt_text = item.get('english', '')

        # BertTokenizer 会自动添加[CLS]和[SEP]来表示句子的起始和中止,在这里手动添加

        src_encoding = self.src_tokenizer(src_text, truncation=True, max_length=self.max_len, add_special_tokens=False)
        tgt_encoding = self.tgt_tokenizer(tgt_text, truncation=True, max_length=self.max_len, add_special_tokens=False)

        # 获取 ID 列表
        src_ids = src_encoding['input_ids']
        tgt_ids = tgt_encoding['input_ids']

        # 添加特殊 token (借用BERT的[CLS]作为SOS, [SEP]作为EOS)
        sos_id = self.src_tokenizer.cls_token_id
        eos_id = self.src_tokenizer.sep_token_id

        return torch.tensor([sos_id] + src_ids + [eos_id], dtype=torch.long), \
               torch.tensor([sos_id] + tgt_ids + [eos_id], dtype=torch.long)

本文的重点不在分词上,因此借用BERT分词器实现。在这里添加特殊的token<bos>(代码中写的是sos,begin和start都是一个意思)和<eos>是为了适用于其他分词器,比如自己训练的,手动添加特殊token。

接下来自定义Batch处理函数,在DataLoader中使用(collate_fn这个变量)。下面函数的目的就是将每个Batch中的源序列和目标序列填充到一样的长度,使用特殊token<pad>来填充。每个batch中填充后的序列长度可能是不一样的。

from torch.nn.utils.rnn import pad_sequence

def collate_fn(batch):
    src_batch, tgt_batch = zip(*batch)
    # 0是BERT tokenizer默认的pad_token_id
    pad_idx = 0 
    # 填充序列 (Padding)
    src_padded = pad_sequence(src_batch, batch_first=True, padding_value=pad_idx)
    tgt_padded = pad_sequence(tgt_batch, batch_first=True, padding_value=pad_idx)
    return src_padded, tgt_padded

掩码生成

掩码有两种:

  • src_mask\(B\times 1\times 1\times S\),中间两个维度是为了在\(h\)和列方向上进行广播(下图最右图所示,当\(1\times d_{model}\)的矩阵和\(k\times d_{model}\)的矩阵做加法运算时,\(1\times d_{model}\)会将第一个维度的\(1\)扩展到\(k\)大小,即复制\(k\)行的向量)。所有的token都不能查询padding,也就是对padding形成注意力,所以\(\boldsymbol K^T\)的最后被padding的几列(图中灰色的部分)和\(\boldsymbol Q\)的所有行相乘得到的注意力分数为0(代码中是\inf),而<pad>可以对其他token形成注意力。所以只需要生成大小为\(d_{model}\times d_{model}\)、最后被padding的几列均为-inf(图中是最后两列)的掩码矩阵即可。
  • tgt_mask\(B\times 1\times S\times S\),不仅需要上三角掩码,还需要对padding进行掩码。
def create_masks(src, tgt, pad_idx=0):
    src_seq_len = src.shape[1]
    tgt_seq_len = tgt.shape[1]

    # (src != pad_idx) -> (B, S) -> unsqueeze -> (B, 1, 1, S)
    src_mask = (src != pad_idx).unsqueeze(1).unsqueeze(2)

    # 屏蔽pad: (B, 1, 1, T)
    tgt_pad_mask = (tgt != pad_idx).unsqueeze(1).unsqueeze(2)
    # 屏蔽未来 (Look-ahead): (1, 1, T, T)
    # 下三角矩阵为1,其余为0
    tgt_sub_mask = torch.tril(torch.ones((tgt_seq_len, tgt_seq_len), device=src.device)).bool()
    # 两个矩阵结合
    tgt_mask = tgt_pad_mask & tgt_sub_mask.unsqueeze(0).unsqueeze(0)

    return src_mask, tgt_mask

训练

显卡:Kaggle上的P40有16G大小的显存。如果没有更好的计算资源,可以使用Kaggle免费提供的GPU。

首先配置参数:注意,如果在自己的系统上使用BERT分词器,需要安装Hugging Face维护的transformers

class Config:
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    train_data_path = "/translation2019zh/translation2019zh_train.json"
    valid_data_path = "/translation2019zh/translation2019zh_valid.json"

    # 训练超参数
    batch_size = 32
    num_epochs = 1
    learning_rate = 0.0001
    max_len = 128  # 限制最大句子长度

    # 模型参数
    d_model = 512
    nhead = 8
    num_encoder_layers = 3
    num_decoder_layers = 3
    dim_feedforward = 2048
    dropout = 0.1

    # 使用Hugging Face的BERT分词器
    src_tokenizer_name = 'bert-base-chinese'
    tgt_tokenizer_name = 'bert-base-uncased'

包装训练和评估模块

from tqdm import tqdm

def train(model, iterator, optimizer, criterion, clip, device):
    model.train()
    epoch_loss = 0
    proc_bar = tqdm(iterator, desc="Training")
    for i, (src, tgt) in enumerate(proc_bar):
        src = src.to(device)
        tgt = tgt.to(device)

        tgt_input = tgt[:, :-1]		# tgt输入: 去掉最后一个 token <eos>
        tgt_output = tgt[:, 1:]		# tgt输出(标签): 去掉第一个 token <sos>

        src_mask, tgt_mask = create_masks(src, tgt_input, pad_idx=0)

        optimizer.zero_grad()
        output = model(src, tgt_input, src_mask, tgt_mask)	# (B, T, tgt_vocab_size)
        output_dim = output.shape[-1]

        # 拉平
        output = output.contiguous().view(-1, output_dim)
        tgt_output = tgt_output.contiguous().view(-1)

        loss = criterion(output, tgt_output)

        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
        optimizer.step()

        epoch_loss += loss.item()
        proc_bar.set_postfix(loss=loss.item())

    return epoch_loss / len(iterator)

def evaluate(model, iterator, criterion, device):
    model.eval()
    epoch_loss = 0

    with torch.no_grad():
        for i, (src, tgt) in enumerate(iterator):
            src = src.to(device)
            tgt = tgt.to(device)

            tgt_input = tgt[:, :-1]
            tgt_output = tgt[:, 1:]

            src_mask, tgt_mask = create_masks(src, tgt_input, pad_idx=0)

            output = model(src, tgt_input, src_mask, tgt_mask)

            output_dim = output.shape[-1]
            output = output.contiguous().view(-1, output_dim)
            tgt_output = tgt_output.contiguous().view(-1)

            loss = criterion(output, tgt_output)
            epoch_loss += loss.item()

    return epoch_loss / len(iterator)

开始训练

import torch.optim as optim
import os
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
os.environ["HF_HOME"] = "./model/"
from transformers import AutoTokenizer  # 缓存和下载地址的配置都必须在导入这个包之前完成

src_tokenizer = AutoTokenizer.from_pretrained(Config.src_tokenizer_name)
tgt_tokenizer = AutoTokenizer.from_pretrained(Config.tgt_tokenizer_name)

train_dataset = TranslationDataset(Config.train_data_path, src_tokenizer, tgt_tokenizer, max_len=Config.max_len)
val_dataset = TranslationDataset(Config.valid_data_path, src_tokenizer, tgt_tokenizer, max_len=Config.max_len)
# 传入数据的同时填充序列到相同长度(按照批次中最长的那个序列)
train_loader = DataLoader(train_dataset, batch_size=Config.batch_size, shuffle=True, collate_fn=collate_fn)
val_loader = DataLoader(val_dataset, batch_size=Config.batch_size, shuffle=False, collate_fn=collate_fn)

model = Transformer(
    src_vocab_size=src_tokenizer.vocab_size,
    tgt_vocab_size=tgt_tokenizer.vocab_size,
    d_model=Config.d_model,
    nhead=Config.nhead,
    num_encoder_layers=Config.num_encoder_layers,
    num_decoder_layers=Config.num_decoder_layers,
    dim_feedforward=Config.dim_feedforward,
    dropout=Config.dropout,
    src_seq_len=Config.max_len + 5,
    tgt_seq_len=Config.max_len + 5
).to(Config.device)

# 初始化权重Xavier init通常对Transformer有效
def initialize_weights(m):
    if hasattr(m, 'weight') and m.weight.dim() > 1:
        nn.init.xavier_uniform_(m.weight.data)
model.apply(initialize_weights)

optimizer = optim.Adam(model.parameters(), lr=Config.learning_rate)
# 忽略<pad>的loss
criterion = nn.CrossEntropyLoss(ignore_index=0)
best_valid_loss = float('inf')
start_epoch = 0

for epoch in range(start_epoch, Config.num_epochs):
    train_loss = train(model, train_loader, optimizer, criterion, 1.0, Config.device)
    valid_loss = evaluate(model, val_loader, criterion, Config.device)
    # 保存最佳模型
    if valid_loss < best_valid_loss:
        best_valid_loss = valid_loss
        torch.save(model.state_dict(), 'transformer_model.pt')

经过5个半小时才训练完一轮,loss在训练完30%的数据时才下降到3.0左右,训练完剩下的数据,loss下降到2.5左右。上述所有的代码可以直接按顺序复制到jupytor或者python文件中运行,建议在jupytor notebook中运行,因为它可以保存已经执行的状态。另外需要注意自己的GPU显存大小,对于16G的显存大小,batch size设置为32比较好,设置为64或者更大的时候可能会出现错误:CUDA out of memory。

Xavier初始化(又称Glorot初始化)是一种深度神经网络权重初始化方法,由Xavier Glorot和Yoshua Bengio在2010年提出,其核心目标是解决深度网络训练初期梯度消失或爆炸的问题,通过控制权重初始化的范围,确保信号在前向传播和反向传播过程中的方差稳定性。(后续完善)

推理

保证网络结构、参数配置和训练时的一样

def translate_sentence(sentence, src_tokenizer, tgt_tokenizer, model, device, max_len=128):
    model.eval()

    # 手动添加特殊token,因此add_special_tokens=False
    tokens = src_tokenizer(sentence, truncation=True, max_length=max_len, add_special_tokens=False)['input_ids']

    sos_idx = src_tokenizer.cls_token_id
    eos_idx = src_tokenizer.sep_token_id

    src_indices = [sos_idx] + tokens + [eos_idx]
    src_tensor = torch.LongTensor(src_indices).unsqueeze(0).to(device) # (1, src_len)

    # 屏蔽<pad>,这里batch=1且无pad,但为了兼容性还是加上。你也可以直接传入None
    src_mask = (src_tensor != 0).unsqueeze(1).unsqueeze(2) # (1, 1, 1, src_len)

    with torch.no_grad():
        # 对应Transformer.forward中的: src = self.input_pe(self.src_embedding(src) * math.sqrt(self.d_model))
        src_emb = model.src_embedding(src_tensor) * math.sqrt(model.d_model)
        src_emb = model.input_pe(src_emb)
        encoder_output = model.encoder(src_emb, src_mask)

    # 自回归生成
    tgt_indices = [sos_idx] # 初始输入只有<sos>

    for i in range(max_len):
        tgt_tensor = torch.LongTensor(tgt_indices).unsqueeze(0).to(device) # (1, current_tgt_len)

        # Look-ahead mask (注意, 在推理的过程中实际上不需要掩码了, 因为Decoder的过程本来就是预测未来)
        # tgt_len = tgt_tensor.shape[1]
        # tgt_sub_mask = torch.tril(torch.ones((tgt_len, tgt_len), device=device)).bool() # (T, T)
        # tgt_mask = tgt_sub_mask.unsqueeze(0).unsqueeze(0) # (1, 1, T, T)

        with torch.no_grad():
            # 对应Transformer.forward中的: tgt = self.output_pe(self.tgt_embedding(tgt) * math.sqrt(self.d_model))
            tgt_emb = model.tgt_embedding(tgt_tensor) * math.sqrt(model.d_model)
            tgt_emb = model.output_pe(tgt_emb)
            output = model.decoder(tgt_emb, encoder_output, src_mask, tgt_mask)
            # 通过全连接层映射到词表
            output = model.fc_out(output)

        # 获取最后一个时间步的预测结果 output shape: (1, T, tgt_vocab_size)
        pred_token = output.argmax(2)[:, -1].item()
        tgt_indices.append(pred_token)

        # 预测出结束符<eos>则停止生成
        if pred_token == eos_idx:
            break
    # ID转换回token, skip_special_tokens=True 会自动去掉特殊token
    translated_sentence = tgt_tokenizer.decode(tgt_indices, skip_special_tokens=True)
    return translated_sentence

自回归Auto-Regression:Decoder首先输入<sos>,模型根据从Encoder中理解到的信息生成下一个token,比如it,将该token加入到Decoder输入中,得到下一个输入序列[<sos>, it],将这个序列作为Decoder的输入,预测得到下一个token's,再将该token加入到输入序列中得到[<sos>, it, 's]......如此循环,直到预测的下一个token是句子的截止符<eos>或者达到设定的序列最大长度。

展示:

src_tokenizer = AutoTokenizer.from_pretrained(Config.src_tokenizer_name)
tgt_tokenizer = AutoTokenizer.from_pretrained(Config.tgt_tokenizer_name)

# model = Transformer(...)
model_path = 'transformer_model.pt'
model.load_state_dict(torch.load(model_path, map_location=Config.device))

print("-" * 50)
print("Enter a Chinese sentence to translate (type 'q' to quit):")
print("-" * 50)

while True:
    sentence = input("Chinese: ")
    if sentence.lower() == 'q':
        break

    translation = translate_sentence(sentence, src_tokenizer, tgt_tokenizer, model, Config.device)
    print(f"Chinese input: {sentence} ==> English: {translation}")
    print("-" * 50)

可以发现,仅仅训练了一轮,就能达到不错的翻译效果。若想要量化语言模型的效果,可以选择BLEU等评价指标,在这里就不赘述了。但是最直观的判断模型效果的方法就是人的感受。

posted @ 2026-01-01 10:30  skeinz  阅读(207)  评论(0)    收藏  举报