Encoder-Decoder
Seq2Seq模型
Seq2Seq 模型输入的是一个自然语言序列input=(x1,x2,x3...xn) ,输出的是一个可能不等长的自然语言序列output=(y1,y2,y3...ym), Transformer 是一个典型的 Seq2Seq 模型,序列进入 Encoder 进行编码,到 Encoder Layer 的最顶层再将编码结果输出给 Decoder Layer 的每一层,通过 Decoder 解码后就可以得到输出目标序列了。
编码:将输入的自然语言序列通过隐藏层编码成复杂的词向量表示。
解码:对输入的自然语言序列编码得到的向量通过隐藏层输出,再解码成对应的自然语言目标序列。
几乎所有的 NLP 任务都可以视为 Seq2Seq 任务。例如文本分类任务,可以视为输出长度为 1 的目标序列(如在上式中m = 1);词性标注任务,可以视为输出与输入序列等长的目标序列(如在上式中m =n )。

前馈神经网络(Feed Forward Neural Network,FFN)
**定义:**每一层的神经元都和上下两层的神经元完全连接的网络结构。
每一个 Encoder Layer 都包含一个注意力机制和一个前馈神经网络。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import torch import torch.nn as nn import torch.nn.functional as F
class MLP(nn.Module): def __init__(self, dim: int, hidden_dim:int, dropout: float): super().__init__() self.fc1 = nn.Linear(dim, hidden_dim, bias = False) self.fc2 = nn.Linear(dim, hidden_dim, bias = False) self.dropout = nn.Dropout(dropout) def foward(self, x): return self.dropout(self.fc2(F.relu(self.fc1(x))))
|
注: Transformer 的 FFN 由两个线性层中间加一个 RELU 激活函数组成,引入 Dropout 防止过拟合。
Dropout 层仅在训练时开启,推理/测试阶段关闭,所以大多 Transformer 结构示意图不会画出该层
层归一化(Layer Norm)
由于深度神经网络中每一层的输入都是上一层的输出,随着神经网络参数的更新,各层的输出分布是不相同的,且差异会随着网络深度的增大而增大。
但需要预测的条件分布始终是相同的,也就造成了预测的误差。
归一化的作用: 将每一层的输入归一化成标准正态分布,从而使不同层输入的取值范围或者分布一致;
神经网络主流的归一化一般有两种:批归一化(Batch Norm)和层归一化(Layer Norm)。
批归一化: 在一个 mini-batch 上进行归一化(batch 是样本拆分出来的一部分)
首先计算 mini-batch 样本的均值:$ \mu_j = \frac{1}{m}\sum^{m}_{i=1}Z_j^{i}$ , 其中,Zji 是样本 i 在第 j 个维度上的值,m 就是 mini-batch 的大小。
然后计算 mini-batch 样本的方差:$\sigma^2 = \frac{1}{m}\sum^{m}_{i=1}(Z_j^i - \mu_j)^2 $
最后对每个样本的值减去均值再除以标准差来将这一个 mini-batch 的样本的分布转化为标准正态分布:Zj=σ2+ϵZj−μj ,此处加上ϵ 这一极小量是为了避免分母为0。
局限:
- 当显存有限,mini-batch 较小时,Batch Norm 取的样本的均值和方差不能反映全局的统计分布信息,从而导致效果变差;
- 对于在时间维度展开的 RNN,不同句子的同一分布大概率不同,所以 Batch Norm 的归一化会失去意义;
- 在训练时,Batch Norm 需要保存每个 step 的统计信息(均值和方差)。在测试时,由于变长句子的特性,测试集可能出现比训练集更长的句子,所以对于后面位置的 step,是没有训练的统计量使用的;
- 应用 Batch Norm,每个 step 都需要去保存和计算 batch 统计量,耗时又耗力
层归一化: 相较于 Batch Norm 在每一层统计所有样本的均值和方差,Layer Norm 在每个样本上计算其所有层的均值和方差,从而使每个样本的分布达到稳定。
- BN 和 LN 公式两者公式一样,只是计算方式不一样
Var[x]+ϵx−E[x]∗γ+β
γ 和 β 是可学习参数,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import torch import torch.nn as nn class LayerNorm(nn.Module): def __init__(self, features, eps = 1e-6): super.__init__() self.a_2 = nn.Parameter(torch.ones(features)) self.b_2 = nn.Parameter(torch.zeros(features)) self.eps = eps
def forward(self,x): mean = x.mean(-1,keepdim = True) std = x.std(-1,keepdim = True)
return self.a_2 * (x-mean) / (std + self.eps) + self.b_2
|
残差连接
由于 Transformer 模型结构较复杂、层数较深,为了避免模型退化,Transformer 采用残差连接来连接每一个子层。
定义: 下一层的输入不仅是上一层的输出,还包括上一层的输入。残差连接允许最底层信息直接传到最高层,让高层专注于残差的学习;
在 Encoder 的第一个子层中,输入会先进行归一化(Layer Norm),然后输入多头自注意力层,其输出会与原输入相加,后续的子层采用同样操作,即:
x=x+MultiHeadSelfAttention(LayerNorm(x))
output=x+FNN(LayerNorm(x))
在代码实现中,通过在层的 forward 计算中加上原输入值即可实现残差连接:
1 2 3 4
| x = x + self.attention.forward(self.attention_norm(x))
out = h + self.feed_froward.forward(self.fnn_norm(x))
|
注:self.attention_norm 和 self.fnn_norm 都是 LayerNorm 层,self.attntion 是注意力层,而 self.feed_forward 是 前馈神经网络。
Encoder
在实现上述基础组建后,可以搭建 Transformer 的 Encoder。
Encoder 由 N 个 Encoder Layer 组成,每个 Encoder Layer 包括一个注意力层和一个前向神经网络。
首先,我们先实现 Encoder Layer 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class EncoderLayer(nn.Module): def __init__(self, args): super().__init__() self.attention_norm = LayerNorm(args.n_embd) self.attention = MultiHeadAttention(args, is_causal = False) self.ffn_norm = LayerNorm(args.n_embd) self.ffn = MLP(args.dim, args.hidden_dim, args.dropout) def forward(self, x): norm_x = self.attention_norm(x) h = h + self.attention.forward(norm_x,norm_x,norm_x) norm_h =self.ffn_norm(h) out = h + self.ffn.forward(norm_h) return out
|
之后我们可以搭建 Encoder,其由 N 个 Encoder Layer 组成,在最后会加上一个 LayerNorm 进行归一化
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Encoder(nn.Module): def __init__(self, args): super(Encoder, self).__init__() self.layers = nn.MoudleList([EncoderLayer(args) for _ in range(args.n_layer)]) self.norm = LayerNorm(args.embd) def forward(self, x): for layer in self.layers: x = layer(x) return self.norm(x)
|
Decoder
类似的,我们也可以先搭建 Decoder Layer,再将 N 个 Decoder Layer 构建为 Decoder;
但和 Encoder 不同的是,Decoder 由两个注意力层和一个前馈神经网络组成。
- 第一个注意力层是一个掩码自注意力层,即使用 Mask 的注意力计算,保证每一个 token 只能使用该 token 之前的注意力分数;
- 第二个注意力层是一个多头注意力层,使用第一个注意力层的输出作为 query,使用 Encoder 的输出作为 key 和 value,计算注意力分数。
- 最后,再经过前馈神经网络;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class DecoderLayer(nn.Module): def __init__(self, args): super().__init__() self.atten_norm_1 = LayerNorm(args.n_embd) self.mask_attention = MultiHeadAttention(args, is_causal=True) self.atten_norm_2 = LayerNorm(args.n_embd) self.attention = MultiHeadAttention(args, is_causal = False) self.ffn_norm = LayerNorm(args.n_embd) self.ffn = MLP(args.dim, args.hidden_dim, args.dropout) def forward(self, x, encoder_out): norm_x = self.atten_norm_1(x) x = x + self.mask_attention.forward(norm_x, norm_x, norm_x) norm_x = self.atten_norm_2(x) h = x + self.attention.forward(norm_x, encoder_out, encoder_out) out = h + self.ffn.forward(self.ffn_norm(h)) return out
|
同样的,我们搭建一个 Decoder 块:
1 2 3 4 5 6 7 8 9 10 11
| class Decoder(nn.Module): def __init__(self,args): super(Decoder, self).__init__() self.layers = nn.ModuleList([DecoderLayer(args) for _ in range(args.n_layers)]) self.norm = LayerNorm(args.n_embd) def forward(self, x, encoder_out): for layer in self.layers: x = layer(x, encoder_out) return self.norm(x)
|
完成上述 Encoder、Decoder 的搭建,就完成了 Transformer 的核心部分,接下来将 Encoder、Decoder 拼接起来再加入 Embedding 层就可以搭建出完整的 Transformer 模型。
Embedding 层
在 NLP 任务中,往往需要将自然语言的输入转化为机器可以处理的向量。在深度学习中,承担这个任务的组件就是 Embedding 层。
Embedding 层其实是一个存储固定大小的词典的嵌入向量查找表。
在输入神经网络之前,往往会先让自然语言输入通过分词器 tokenizer,分词器的作用是把自然语言输入切分成 token 并转化成一个固定的 index。
例如,如果我们将词表大小设为 4,输入“我喜欢你”,那么,分词器可以将输入转化成:
1 2 3 4 5 6 7 8
| input: 我 output: 0
input: 喜欢 output: 1
input: 你 output: 2
|
因此,Embedding 层的输入往往是一个形状为 (batch_size,seq_len,1)的矩阵:
- 第一个维度是一次批处理的数量;
- 第二个维度是自然语言序列的长度;
- 第三个维度则是 token 经过 tokenizer 转化成的 index 值。
例如,对上述输入,Embedding 层的输入会是:
Embedding 内部是一个可训练的(Vocab_size,embedding_dim)的权重矩阵,词表里的每一个值,都对应一行维度为 embedding_dim 的向量, 然后拼接成(batch_size,seq_len,embedding_dim)的矩阵输出。
1
| self.token_embeddings = nn.Embedding(args.vocab_size, args.dim)
|
位置编码
在 RNN、LSTM 中,输入序列会沿着语句本身的顺序被依次递归处理,因此输入序列的顺序提供了极其重要的信息,这也和自然语言的本身特性非常吻合。
注意力机制可以实现并行计算,但在计算过程中,对于序列中的每一个 token,其他各个位置对其来说都是平等的,即“我喜欢你”和“你喜欢我”在注意力机制看来是完全相同的,从而会导致序列中相对位置的丢失 。
因此,为了保留序列中的相对位置信息,Transformer 采用了位置编码机制,该机制也在之后被多种模型沿用。
位置编码: 即根据序列中 token 的相对位置对其进行编码,再将位置编码加入词向量编码中。
位置编码的方式有很多,Transformer 使用了正余弦函数来进行位置编码(绝对位置编码Sinusoidal),其编码方式为:
PE(pos,2i)=sin(pos/100002i/dmodel)PE(pos,2i+1)=cos(pos/100002i/dmodel)
pos 为 token 在句子中的位置,2i 和 2i+1 指示了位置编码向量的维度索引是奇数还是偶数,Transformer 对奇数维度和偶数维度采用了不同的函数进行编码。
优势:
- PE 能够适应比训练集里面更长的句子,假设训练集里面最长的句子为 20 个单词,突然来了一个长度为 21 的句子,则使用公式计算的方法可以计算出第 21 位的 Embedding。
- 可以让模型容易地计算出相对位置,对于固定长度的间距 k,PE(pos+k) 可以用 PE(pos) 计算得到。因为 Sin(A+B) = Sin(A)Cos(B) + Cos(A)Sin(B), Cos(A+B) = Cos(A)Cos(B) - Sin(A)Sin(B)。
以一个简单的例子来说明位置编码的计算过程:
假如我们输入的是一个长度为 4 的句子"I like to code",可以得到下面的词向量矩阵x ,其中每一行代表的就是一个词向量,x0=[0.1,0.2,0.3,0.4] 对应的就是“I”的词向量,它的pos就是为0,以此类推,第二行代表的是“like”的词向量,它的pos就是1:
x=0.10.20.30.40.20.30.40.50.30.40.50.60.40.50.60.7
则经过位置编码后的词向量为:
xPE=0.10.20.30.40.20.30.40.50.30.40.50.60.40.50.60.7+sin(1000000)sin(1000001)sin(1000002)sin(1000003)cos(1000000)cos(1000001)cos(1000002)cos(1000003)sin(100002/40)sin(100002/41)sin(100002/42)sin(100002/43)cos(100002/40)cos(100002/41)cos(100002/42)cos(100002/43)=0.11.0411.2090.5411.20.84−0.016−0.4890.30.410.520.8951.41.491.591.655
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class PositionalEncoding(nn.Module): def __init__(self,args): super(PositionalEncoding,self).__init__() pe = torch.zeros(args.max_len, args.n_embd) position = torch.arrange(0, args.max_len).unsqueeze(1) div_term = torch.exp(torch.arrange(0, args.n_embd, 2) * - (math.log(10000.0) / args.n_embd)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) pe = pe.unsequeeze(0) self.register_buffer("pe",pe) def forward(self, x): x = x + self.pe[:, :x.size(1)].requires_grad_(False) return x
|
将所有组件按照下图拼接就可以搭建一个完整的 Transformer 模型:

但需要注意的是,上图是原论文《Attention is all you need》配图,LayerNorm 层放在了 Attention 层后面,也就是“Post-Norm”结构,但在其发布的源代码中,LayerNorm 层是放在 Attention 层前面的,也就是“Pre Norm”结构。
考虑到目前 LLM 一般采用“Pre-Norm”结构(可以使 loss 更稳定),本文在实现时采用“Pre-Norm”结构。
- 经过 tokenizer 映射后的输出先经过 Embedding 层和 Positional Embedding 层编码;
- 然后输入 Encoder 和 Decoder(在 Transformer 原模型中,N 取为 6 );
- 最后经过一个线性层和一个 Softmax 层得到最终输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| class Transformer(nn.Module): def __init__(self, args): super().__init__() assert args.vocab_size is not None assert args.max_len is not None self.args = args self.transformer = nn.ModuleList(dict( wte = nn.Embedding(args.vocab_size, args.n_embd), wpe = PositionalEncoding(args), drop = nn.Dropout(args.dropout), encoder = Encoder(args), decoder = Decoder(args), )) self.lm_head = nn.Linear(args.n_embd, args.vocab_size, bias = False) self.apply(self._init_weights) print("number of parameters: %.2f M" % (self.get_num_params() / 1e6, )) '''统计所有参数的数量''' def get_num_params(self, non_embedding = False): n_params = sum(p.numel() for p in self.parameters()) if non_embedding: n_params -= self.transformer.wte.weight.numel() return n_params '''初始化权重''' def _init_weights(self, module): if isinstance(module, nn.Linear): torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) if module.bias is not None: torch.nn.init.zeros_(module.bias) elif isinstance(module, nn.Embedding): torch.nn.init.normal_(module.weight, mean=0.0, std = 0.02) def forward(self, idx, targets = None): device = idx.device batch_size, seq_len = idx.size() assert seq_len <= self.args.max_len, f"无法计算该序列,该序列长度为{seq_len}, 最大序列长度只有{self.args.max_len}" print("idx", idx.size()) tok_emb = self.transformer.wte(idx) print("tok_emb", tok_emb.size()) pos_emb = self.transformer.wpe(tok_emb) x = self.transformer.drop(pos_emb) enc_out = self.transformer.encoder(x) print("enc_out", enc_out.size()) x = self.transformer.decoder(x, enc_out) print("x after decoder:", x.size()) if targets is not None: logits = self.lm_head(x) loss = F.cross_entropy(logits.view(-1, logits.size(-1)),targets.view(-1), ignore_index = -1) else: logits = self.lm_head(x[:, [-1], :]) loss = None return logits, loss
|
上述代码除搭建整个 Transformer 结构外,还额外实现了三个函数:
- get_num_params:用于统计模型的参数量
- _init_weights:用于对模型所有参数进行随机初始化
- forward:前向计算函数
在前向计算函数中,对模型使用 pytorch 的交叉熵函数来计算损失。
疑问?
为什么归一化要加可学习参数?有什么作用?
归一化会强行把数据变成均值 0、方差 1,但有些特征本来就不应该是标准正态分布,强行拉成 0 均值 1 方差,会丢失信息、限制模型表达能力,加入 γ(缩放)和 β(偏移),就是让模型自己决定:要不要恢复一部分原来的分布。
γ=1,β=0 → 完全使用归一化
γ=σ,β=μ → 完全取消归一化,恢复原始 x
中间值 → 部分归一化,保留部分原始特征
比如:假设两个词的特征向量(简化成 3 维):
“极其重要”:[10, 12, 11] ,数值大 → 代表语义强度高、权重高
“一般”:[1, 1.2, 0.9]`,数值小 → 代表语义弱、不重要
做 LayerNorm(不带 γ、β)之后,两者都会被强行拉成:
- 均值 0,方差 1
- 输出都变成类似
[-0.8, 0.9, 0.1] 这种分布
“极其重要” 和 “一般” 变得几乎一模一样! ,强度信息、重要度信息彻底丢失。
生活中的例子:
把所有衣服全部洗干净、熨平、叠成一样大小(强制归一化)。
但每个人穿衣风格不同,有的人喜欢宽松,有的人喜欢紧身。
γ 和 β 就是让模型自己 “重新拉扯、调整版型”,恢复适合自己的风格。
super().init()和super(类名, self).init()有什么区别?
在 Python 3 里,这俩功能完全一样,super().__init__() 是简化写法,更推荐这种写法;
Python 2 必须写完整: 因为 Python 2 不会自动推断类和实例
Python 3 做了智能推断: 你在哪个类里写 super(),它就自动绑定到 这个类 + self
为什么logits = self.lm_head(x[:, [-1], :])取最后一个词去预测下一个词?
假设序列长度 = 3, 索引:0, 1, 2 ,词:A, B, C
模型训练的目标就是:用 C 预测下一个字 D!
给模型看 A B C ,模型的任务是:
1 2 3
| A → 预测 B B → 预测 C C → 预测 D
|