李沐 + 李宏毅-- transformer原理和代码分析【笔记自用】
理论知识
借鉴大神如果我是泡橘子的transformer笔记 冒犯的话我会立即删除
- Transformer 模型是完全基于注意力机制,没有任何卷积层或循环神经网络
- Transformer 最初应用在文本数据上的序列到序列学习,现在已经推广到各种现代的深度学习中,如语言、视觉、语音和强化学习领域
Transformer 架构

基于编码器-解码器的架构来处理序列对,Transformer 的编码器和解码器是基于自注意力的模块叠加而成的,源(source,输入)序列和目标(target,输出)序列的嵌入(embedding)表示通过加上位置编码(positional encoding)加入位置信息,再分别输入到编码器和解码器中
1、Transformer 的编码器是由多个相同的层叠加而成的,每个层都有两个子层(每个子层都采用了残差连接,并且在残差连接的加法计算之后,都使用了层归一化,因此 Transformer 编码器都将输出一个 d 维表示向量)
第一个子层是多头自注意力汇聚
- Transformer 块中的多头注意力实际上就是自注意力(自注意力同时具有并行计算和最短的最大路径长度这两个优势)
- 在计算编码器的自注意力时,key 、value 和 query 的值都来自前一个编码器层的输出
第二个子层是基于位置的前馈网络
- Positionwise FFN 实际上是全连接
- 本质上和编码器-解码器的架构没有本质上的区别,将 Transformer 编码器最后一层的输出作为解码器的输入来完成信息的传递
2、Transformer 解码器也是由多个相同的层叠加而成的,每层都有三个子层,并且在每个子层中也使用了残差连接和层归一化
第一个子层是解码器自注意力(带掩码的多头自注意力)
- 在解码器自注意力中,key 、value 和 query 都来自上一个解码器层的输出
- 解码器中的每个位置只能考虑该位置之前的所有位置
- 带掩码的自注意力保留了自回归的属性,确保预测仅仅依赖于已生成的输出词元(为了在解码器中保留自回归的属性,带掩码的自注意力设定了有效长度(dec_valid_lens)作为参数,以便任何查询都只会与解码器中所有已经生成的词元的位置(即直到该查询为止)进行注意力计算,而不会对当前位置之后的 key-value 对进行注意力计算)
第二个子层是编码器-解码器注意力
- 除了编码器中所描述的两个子层之外,解码器还在这两个子层之间插入了编码器-解码器注意力层,作为第三个子层,它的 query 来自上一个解码器层的输出,key 和 value 来自整个编码器的输出
第三个子层是基于位置的前馈网络
对比 seq2seq

- 和使用注意力的 seq2seq 的不同之处在于:Transformer 是纯基于注意力(具体来讲,它是一个纯基于自注意力的架构,里面没有 RNN)
- Transformer 将使用注意力的 seq2seq 中的 RNN 全部换成了 Transformer 块
多头注意力(Multi-head attention)

1、对同一个 key 、value 、query 抽取不同的信息
- 例如短距离关系和长距离关系
2、多头注意力使用 h 个独立的注意力池化
- 合并各个头(head)输出得到最终输出

- key 、value 、query 都是长为 1 的向量,通过全连接层映射到一个低一点的维度,然后进入到注意力模块中

带掩码的多头注意力(Masked Multi-head attention)

1、解码器对序列中一个元素输出时,不应该考虑该元素之后的元素
- 注意力中是没有时间信息的,在输出中间第 i 个信息的时候,也能够看到后面的所有信息,这在编码的时候是可以的,但是在解码的时候是不行的,在解码的时候不应该考虑该元素本身或者该元素之后的元素
2、可以通过掩码来实现
- 也就是计算 xi 输出时,假装当前序列长度为 i
基于位置的前馈网络(Positionwise FFN)
- 基于位置的前馈网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机(MLP),这就是称前馈网络是基于位置的原因

其实就是全连接层,将输入形状由(b,n,d)变成(bn,d),然后作用两个全连接层,最后输出形状由(bn,d)变回(b,n,d),等价于两层核窗口为 1 的一维卷积层
- b:batchsize
- n:序列长度
- d:dimension
- 在做卷积的时候是将 n 和 d 合成一维,变成 nd ;但是现在 n 是序列的长度,会变化,要使模型能够处理任意的特征,所以不能将 n 作为一个特征,因此对每个序列中的每个元素作用一个全连接(将每个序列中的 xi 当作是一个样本)
残差连接和归一化(Add & norm)

1、Add 就是一个 Residual_block

1、加入归一化能够更好地训练比较深的网络,但是这里不能使用批量归一化,批量归一化对每个特征/通道里元素进行归一化
- 这里的特征指的是每个序列中 D 中的一维,所以在做归一化的时候就是将其方差变 1 ,均值变 0
- 在做 NLP 的时候,如果选择将 d 作为特征的话,那么批量归一化的输入是 n*b ,b 是批量大小,n 是序列长度,序列的长度是会变的,所以每次做批量归一化的输入大小都不同,所以会导致不稳定,训练和预测的长度本来就不一样,预测的长度会慢慢变长,所以批量归一化不适合长度会变的 NLP 应用
2、层归一化对每个样本里的元素进行归一化

- b 代表 batchsize
- d 代表特征维度
- len 表示序列长度
- 层归一化和批量归一化的目标相同,但是层归一化是基于特征维度进行归一化的
- 层归一化和批量归一化的区别在于:批量归一化在 d 的维度上找出一个矩阵,将其均值变成 0 ,方差变成 1,层归一化每次选的是一个元素,也就是每个 batch 里面的一个样本进行归一化
- 尽管批量归一化在计算机视觉中被广泛应用,但是在自然语言处理任务中,批量归一化通常不如层归一化的效果好,因为在自然语言处理任务中,输入序列的长度通常是变化的
- 虽然在做层归一化的时候,长度也是变化的,但是至少来说还是在一个单样本中,不管批量多少,都给定一个特征,这样对于变化的长度来讲,稍微稳定一点,不会因为长度变化,导致稳定性发生很大的变化
信息传递

假设编码器中的输出是 y1,… ,yn ,将其作为解码中第 i 个 Transformer 块中多头注意力的 key 和 value
- 一共有三个多头注意力(包括一个带掩码的多头注意力),位于带掩码的多头注意力与其它两个不同,其他两个都是自注意力(key 、value 和 query 都相同),而它是普通的注意力(它的 key 和 value 来自编码器的输出, query 来自目标序列)
这就意味着编码器和解码器中块的个数和输出维度都是一样的
架构
Encoder 和 Decoder架构
Encoder:
给一排向量,输出一排向量
Encoder的一个Block(在自注意力和FC上加了residual,并且使用了layer norm):

更具体地,Encoder的结构:

Decoder:
Autoregressive
Decoder会把自己的输出当做下一个输入。输入时加一个BEGIN符号。

Transformer的Decoder和Encoder的差别只在于中间部分,以及最后多了一个Softmax。
Decoder中的Masked,即在算bi时,不考虑i之后的aj。这样更接近于decoder的运作方式。

decoder如何决定输出序列的长度?
用特殊符号END表示"断"。让机器产生END的概率最大时表示结束。

AT vs NAT
NAT会给多个BEGIN,一次性生成完成完整的句子(注意位置嵌入的作用)。要由特殊方法判断终止。
NAT可以平行化,可以控制输出长度(在语音中可以据此控制说话速度)。但NAT通常不如AT。

Cross attention
decoder产生一个q,去和encoder的输出a’做注意力,结果进入FC。(原论文中decoder中每一层都是用encoder最后一层输出,但也可以变化)

每产生一个字就是从几千个字中进行的分类问题。训练时希望所有的字(含END)的交叉熵最少。
如何理解:例如文本输入的数据通过one-hot处理,模型每次输出的值都是回答的词的one-hot下标,因为为了让结果准确,需要将正确答案对应的下标和输出结果的下标进行交叉熵( i - j )^2 ,使其更接近于正确答案
训练decoder时input是正确答案来训练,即teacher forcing。

Copy Mechanism:输出从输入中复制一部分,如聊天机器人、摘要。
Guided Attention:强迫要看到所有输入,要求机器有固定的方式,如训练时Attention必须从左到右。在语音辨识和语音合成中用。
Beam Search:每次找最多的是Greedy Decoding。可能开始选的不好,但是后面很好。Beam Search就是寻找这些路径的方法,但Beam Search有时有用、有时没用,因为有时找出分数最高的路不一定就是最好的,如需要一些创造力、没有单一答案的任务(如故事补全、语音合成,TTS加noise才能产生正常的声音,加入一些随机性反而是人类觉得较好的)
BLEU score:最小化交叉熵 ≠ 最大化BLEU score。但是BLEU无法微分!方法:当遇到无法optimize,就把它当做RL硬做!最小化交叉熵 只是为了使得让预测答案更精确,并不能评价性能

训练和测试不一致(Exposure Bias):
一步错步步错
方案是给encoder的输入加一些噪声,称为Scheduled Sampling,在Transformer中为了不伤害其并行性会有一定改变。

预测

预测第 t+1 个输出时,解码器中输入前 t 个预测值
- 在自注意力中,前 t 个预测值作为 key 和 value ,第 t 个预测值还作为 query
关于序列到序列模型,在训练阶段,输出序列的所有位置(时间步)的词元都是已知的;但是在预测阶段,输出序列的次元是逐个生成的
- 在任何解码器时间步中,只有生成的词元才能用于解码器的自注意力计算中
总结
1、和 seq2seq 有点类似,不同之处在于 Transformer 是一个纯使用注意力的编码-解码器
2、编码器和解码器都有 n 个 Transformer 块
3、每个块里使用多头(自)注意力(multi-head attention),基于位置的前馈网络(Positionwise FFN),残差连接和层归一化
- 编码器和解码器中各有一个自注意力,但是在编码器和解码器中传递信息的是一个正常的注意力
- 基于位置的前馈网络使用同一个多层感知机,作用是对所有序列位置的表示进行转换,实际上就是一个全连接,等价于 1*1 的卷积
- Add & norm:Add 实际上就是 Residual block 可以帮助将网络做的更深,norm 使用的是 Layer Norm 使得训练起来更加容易;Transformer 中的残差连接和层规范化是训练非常深度模型的重要工具
4、在 Transformer 中,多头注意力用于表示输入序列和输出序列,但是解码器必须通过掩码机制来保留自回归属性
代码分析
代码1:前馈网络:
#@save
class PositionWiseFFN(nn.Module):
"""基于位置的前馈网络"""
def __init__(self, ffn_num_input, ffn_num_hiddens, ffn_num_outputs,
**kwargs):
super(PositionWiseFFN, self).__init__(**kwargs)
self.dense1 = nn.Linear(ffn_num_input, ffn_num_hiddens)
self.relu = nn.ReLU()
self.dense2 = nn.Linear(ffn_num_hiddens, ffn_num_outputs)
def forward(self, X):
return self.dense2(self.relu(self.dense1(X)))
前馈网络的意义在于什么?
基本作用
PositionWiseFFN 对序列中每个位置的向量独立进行非线性变换
python
# 输入: [batch_size, sequence_length, num_hiddens]
# 输出: [batch_size, sequence_length, num_hiddens] (相同形状)
具体意义分析
1. 逐位置处理
python
# 对序列中的每个词向量独立处理
# 位置1的词向量 → 变换 → 新向量1
# 位置2的词向量 → 变换 → 新向量2
# ...
# 位置n的词向量 → 变换 → 新向量n
# 注意:不同位置之间没有信息交换
# 这与自注意力机制形成互补
2. 非线性能力增强
python
# 自注意力主要是线性变换 + softmax
# FFN通过ReLU引入非线性,增强模型表达能力
输入 → 线性层 → ReLU → 线性层 → 输出
↓
非线性变换
3. 特征变换和抽象
python
# 实现维度变换:压缩 → 扩展 → 压缩
def forward(self, X): # X: [..., num_hiddens]
# 第一步:扩展到更大维度 (ffn_num_hiddens)
hidden = self.dense1(X) # [..., ffn_num_hiddens]
# 第二步:非线性激活
hidden = self.relu(hidden) # 引入非线性
# 第三步:压缩回原维度
return self.dense2(hidden) # [..., num_hiddens]
与自注意力的对比
| 组件 | 作用 | 信息流动 |
|---|---|---|
| 自注意力 | 捕捉词与词之间的关系 | 位置间交互 |
| FFN | 对每个词进行深度特征提取 | 位置内变换 |
实际例子说明
假设输入序列:“猫 吃 鱼”
自注意力之后:
- "猫"向量包含了"吃"和"鱼"的信息
- "吃"向量包含了"猫"和"鱼"的信息
- "鱼"向量包含了"猫"和"吃"的信息
FFN之后:
- "猫"向量被深度加工,提取更抽象的特征
- "吃"向量被深度加工,提取更抽象的特征
- "鱼"向量被深度加工,提取更抽象的特征
每个词都获得了更丰富的表示,但没有混合其他词的信息
为什么需要FFN?
1. 增加模型容量
python
# 如果没有FFN,Transformer就只是多个自注意力层的堆叠
# FFN提供了额外的参数和计算,让模型更强大
2. 非线性变换
python
# 自注意力本质是加权求和(线性操作)
# FFN通过ReLU等激活函数引入关键的非线性
3. 特征重校准
python
# 对自注意力输出的特征进行"精加工"
# 类似于:先找到相关词(注意力),再深度理解每个词(FFN)
典型配置
python
# 在Transformer中通常:
ffn_num_input = num_hiddens # 输入维度,如512
ffn_num_hiddens = 4 * num_hiddens # 中间层维度,如2048(4倍扩展)
ffn_num_outputs = num_hiddens # 输出维度,如512
# 这种"瓶颈"结构:
# 512 → 2048 → 512
# 既增加了模型容量,又保持了输入输出维度一致
在完整Transformer中的作用
python
class EncoderBlock(nn.Module):
def forward(self, X, valid_lens):
# 第一步:自注意力(词间关系)
attention_output = self.attention(X, X, X, valid_lens)
Y = self.addnorm1(X, attention_output) # 残差连接
# 第二步:FFN(词内深度加工)
ffn_output = self.ffn(Y) # ← 这里使用PositionWiseFFN
return self.addnorm2(Y, ffn_output) # 残差连接
总结意义
- 特征增强:对每个位置的向量进行深度非线性变换
- 容量扩展:增加模型参数,提高表达能力
- 分工明确:与自注意力机制形成互补(关系 vs 特征)
- 维度保持:输入输出形状一致,支持残差连接
- 通用逼近:两层网络可以逼近任何连续函数
简单来说:PositionWiseFFN就像是给每个词向量单独配备的一个"微型大脑",让每个词都能被深度理解和加工!
这就是为什么它在Transformer中如此重要,没有它,模型的表现力会大大降低。
代码2:残差连接和层规范化
#@save
class AddNorm(nn.Module):
"""残差连接后进行层规范化"""
def __init__(self, normalized_shape, dropout, **kwargs):
super(AddNorm, self).__init__(**kwargs)
self.dropout = nn.Dropout(dropout)
self.ln = nn.LayerNorm(normalized_shape)
def forward(self, X, Y):
return self.ln(self.dropout(Y) + X)
LayerNorm和BatchNorm1d的区别:
- LayerNorm是对于每个样本的所有特征做规范化
- BatchNorm1d是对每个特征的所有样本做规范化
代码3:编码器

#@save
class EncoderBlock(nn.Module):
"""Transformer编码器块"""
"""norm_shape""" 层归一化的形状,通常是一个整数(表示最后一个维度)或一个元组(表示要归一化的维度)
def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, use_bias=False, **kwargs):
super(EncoderBlock, self).__init__(**kwargs)
self.attention = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout,
use_bias)
self.addnorm1 = AddNorm(norm_shape, dropout)
self.ffn = PositionWiseFFN(
ffn_num_input, ffn_num_hiddens, num_hiddens)
self.addnorm2 = AddNorm(norm_shape, dropout)
def forward(self, X, valid_lens):
Y = self.addnorm1(X, self.attention(X, X, X, valid_lens))
return self.addnorm2(Y, self.ffn(Y))
参数介绍:
1. 注意力机制相关参数
key_size, query_size, value_size, num_heads
key_size(键维度):每个注意力头中key向量的维度query_size(查询维度):每个注意力头中query向量的维度value_size(值维度):每个注意力头中value向量的维度num_heads(注意力头数):多头注意力中并行的注意力头数量
计算关系:
# 总隐藏维度 = 头数 × 每个头的维度
num_hiddens = num_heads * value_size # 通常这样设置
2. 维度参数
num_hiddens, ffn_num_input, ffn_num_hiddens
num_hiddens(隐藏层维度):编码器块的输入和输出维度ffn_num_input(前馈网络输入维度):通常等于num_hiddensffn_num_hiddens(前馈网络隐藏层维度):前馈网络中间层的维度
3. 归一化参数
norm_shape
-
norm_shape: 指定需要标准化的最后几个维度的大小。
-
层归一化的形状,通常是:
- 单个整数:
num_hiddens(对最后一个维度归一化) - 元组:
[num_hiddens]或[-1](自动推断)
- 单个整数:
具体含义
- 对于不同输入形状的
norm_shape
python
import torch.nn as nn
# 情况1: 输入是2D张量 [batch_size, features]
# norm_shape 应该是 features
norm_shape = 512
layer_norm = nn.LayerNorm(norm_shape)
# 输入: [32, 512] → 输出: [32, 512]
# 对每个样本的512个特征进行归一化
python
# 情况2: 输入是3D张量 [batch_size, seq_len, features]
# norm_shape 应该是 [features] 或 512
norm_shape = 512 # 或者 norm_shape = [512]
layer_norm = nn.LayerNorm(norm_shape)
# 输入: [32, 50, 512] → 输出: [32, 50, 512]
# 对每个样本每个位置的512个特征进行归一化
python
# 情况3: 输入是4D张量 [batch_size, channels, height, width]
# norm_shape 应该是 [channels, height, width] 或 [C, H, W]
norm_shape = [64, 28, 28]
layer_norm = nn.LayerNorm(norm_shape)
# 输入: [32, 64, 28, 28] → 输出: [32, 64, 28, 28]
# 对每个样本的所有空间位置进行归一化
在Transformer中的具体应用
典型配置
python
# 在EncoderBlock中:
norm_shape = num_hiddens # 例如 512
# 这意味着:
# 对每个样本每个位置的512维特征向量进行独立的归一化
具体计算过程
python
# 假设输入 X: [batch_size=2, seq_len=3, num_hiddens=512]
X = torch.randn(2, 3, 512)
# LayerNorm操作:
# 对每个 (512,) 向量计算均值和方差
# 样本1, 位置1: 计算512个特征的均值方差 → 归一化
# 样本1, 位置2: 计算512个特征的均值方差 → 归一化
# 样本1, 位置3: 计算512个特征的均值方差 → 归一化
# 样本2, 位置1: 计算512个特征的均值方差 → 归一化
# ...
4. 正则化参数
dropout, use_bias
dropout:Dropout比率,防止过拟合use_bias:是否在线性层中使用偏置项
代码4:transformer编码器

#@save
class TransformerEncoder(d2l.Encoder):
"""Transformer编码器"""
def __init__(self, vocab_size, key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, use_bias=False, **kwargs):
super(TransformerEncoder, self).__init__(**kwargs)
self.num_hiddens = num_hiddens
self.embedding = nn.Embedding(vocab_size, num_hiddens)
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
self.blks = nn.Sequential()
for i in range(num_layers): # 可以定义多个encoder
self.blks.add_module("block"+str(i),
EncoderBlock(key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, dropout, use_bias))
def forward(self, X, valid_lens, *args):
# 因为位置编码值在-1和1之间,
# 因此嵌入值乘以嵌入维度的平方根进行缩放,
# 然后再与位置编码相加。
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
self.attention_weights = [None] * len(self.blks) # 为了到后面画图
for i, blk in enumerate(self.blks):
X = blk(X, valid_lens)
self.attention_weights[
i] = blk.attention.attention.attention_weights
return X
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
完整流程分解
python
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
1. self.embedding(X)
python
# 将离散的token ID转换为连续的向量表示
# 输入X: [batch_size, sequence_length] (token indices)
# 输出: [batch_size, sequence_length, num_hiddens] (词嵌入向量)
2. \* math.sqrt(self.num_hiddens) ⭐ 关键缩放
为什么需要这个缩放?
python
embedding_output = self.embedding(X) # 方差 ≈ 1 (默认初始化)
scaled_embedding = embedding_output * math.sqrt(self.num_hiddens) # 方差 ≈ num_hiddens
# 位置编码的值范围大约在 [-1, 1]
原因:
若embedding_output 和 位置编码的方差一样的话,说明两者同等重要,错误❌
因此要增大embedding_output在中的权重,让它起决定性作用
数学解释:
- 词嵌入初始化通常方差为1
- 经过线性层时,方差会放大
num_hiddens倍 - 预先缩放可以保持网络中的数值稳定性
3. self.pos_encoding(...)
python
# 添加位置信息到缩放后的词嵌入
# 使用加性位置编码:词向量 + 位置向量
为什么这个顺序很重要?
正确的信息流:
text
原始token → 词嵌入 → 缩放 → 位置编码 → Transformer块
如果顺序错了会怎样?
python
# 错误示例1:先位置编码再缩放
X = self.embedding(X) * math.sqrt(self.num_hiddens)
# 位置编码的相对重要性会被改变
# 错误示例2:不缩放
X = self.pos_encoding(self.embedding(X))
# 词嵌入的数值可能太小,位置编码主导
与原始论文的对应
这遵循了 原始Transformer论文 的设计:
“We apply dropout to the sums of the embeddings and the positional encodings in both the encoder and decoder stacks.”
并且由于嵌入层通常用方差为1的初始化,乘以√d_model可以确保在通过网络的线性层之前,嵌入向量的方差大致为d_model。
具体数值示例
假设:
num_hiddens = 512- 词嵌入值范围:[-0.1, 0.1](方差约0.01)
- 位置编码值范围:[-1, 1]
python
# 不缩放的情况:
词嵌入贡献 ≈ 0.01
位置编码贡献 ≈ 1.0
# 位置编码会完全主导!
# 缩放后:
词嵌入贡献 ≈ 0.01 * √512 ≈ 0.01 * 22.6 ≈ 0.226
位置编码贡献 ≈ 1.0
# 两者贡献更加平衡
总结
这个设计确保了:
- 数值平衡:词嵌入和位置编码的贡献相当
- 训练稳定性:避免梯度消失或爆炸
- 遵循标准:与原始Transformer论文保持一致
- 信息融合:词义信息和位置信息能有效结合
代码5:解码器
class DecoderBlock(nn.Module):
"""解码器中第i个块"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, i, **kwargs):
super(DecoderBlock, self).__init__(**kwargs)
self.i = i
self.attention1 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
self.addnorm1 = AddNorm(norm_shape, dropout)
self.attention2 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
self.addnorm2 = AddNorm(norm_shape, dropout)
self.ffn = PositionWiseFFN(ffn_num_input, ffn_num_hiddens,
num_hiddens)
self.addnorm3 = AddNorm(norm_shape, dropout)
def forward(self, X, state):
enc_outputs, enc_valid_lens = state[0], state[1]
# 训练阶段,输出序列的所有词元都在同一时间处理,
# 因此state[2][self.i]初始化为None。
# 预测阶段,输出序列是通过词元一个接着一个解码的,
# 因此state[2][self.i]包含着直到当前时间步第i个块解码的输出表示
if state[2][self.i] is None:
key_values = X
else:
key_values = torch.cat((state[2][self.i], X), axis=1)
state[2][self.i] = key_values
if self.training:
batch_size, num_steps, _ = X.shape
# dec_valid_lens的开头:(batch_size,num_steps),
# 其中每一行是[1,2,...,num_steps]
dec_valid_lens = torch.arange(
1, num_steps + 1, device=X.device).repeat(batch_size, 1)
else:
dec_valid_lens = None
# 自注意力
X2 = self.attention1(X, key_values, key_values, dec_valid_lens)
Y = self.addnorm1(X, X2)
# 编码器-解码器注意力。
# enc_outputs的开头:(batch_size,num_steps,num_hiddens)
Y2 = self.attention2(Y, enc_outputs, enc_outputs, enc_valid_lens)
Z = self.addnorm2(Y, Y2)
return self.addnorm3(Z, self.ffn(Z)), state
代码块1:
if state[2][self.i] is None:
key_values = X
else:
key_values = torch.cat((state[2][self.i], X), axis=1)
state[2][self.i] = key_values
实现的功能:自注意力的要求
解码器自注意力需要看到所有之前生成的内容,但不能看到未来的:

举例
假设:
X的形状:[batch_size=1, seq_len=1, num_hiddens=512]- 初始
state[2][self.i] = None
时间步1
python
# 输入: "我" 的嵌入向量
X = tensor([[[0.1, 0.2, ..., 0.5]]]) # shape: [1, 1, 512]
# 由于state[2][self.i]是None
key_values = X # 直接使用X
# key_values形状: [1, 1, 512]
# 更新缓存
state[2][self.i] = key_values # 现在缓存里是"我"
时间步2
python
# 输入: "爱" 的嵌入向量
X = tensor([[[0.3, 0.4, ..., 0.6]]]) # shape: [1, 1, 512]
# 现在state[2][self.i]不是None了,需要拼接
key_values = torch.cat((state[2][self.i], X), axis=1)
# state[2][self.i]形状: [1, 1, 512] (包含"我")
# X形状: [1, 1, 512] (包含"爱")
# 拼接后key_values形状: [1, 2, 512] (包含"我" + "爱")
# 更新缓存
state[2][self.i] = key_values # 现在缓存里是["我", "爱"]
时间步3
python
# 输入: "你" 的嵌入向量
X = tensor([[[0.5, 0.6, ..., 0.7]]]) # shape: [1, 1, 512]
# 继续拼接
key_values = torch.cat((state[2][self.i], X), axis=1)
# state[2][self.i]形状: [1, 2, 512] (包含"我", "爱")
# X形状: [1, 1, 512] (包含"你")
# 拼接后key_values形状: [1, 3, 512] (包含"我", "爱", "你")
实际效果
通过这种缓存机制:
- 训练时:一次性处理,效率高
- 推理时:逐步生成,避免重复计算
- 两者:都确保自注意力只能看到当前及之前的位置
总结
这段代码实现了解码器的记忆机制:
- 累积历史:把之前所有时间步的输出保存起来
- 逐步构建:每次生成新token时都添加到历史中
- 支持自回归:让模型能基于之前生成的内容来生成下一个词
简单来说:就像人说话一样,每说一个新词时,都要记得之前说过什么词,这样才能保证说话的连贯性!
这就是Transformer能够进行自回归文本生成的核心秘密。
代码块2
if self.training: # 训练模型
batch_size, num_steps, _ = X.shape
# dec_valid_lens的开头:(batch_size,num_steps),
# 其中每一行是[1,2,...,num_steps]
dec_valid_lens = torch.arange(
1, num_steps + 1, device=X.device).repeat(batch_size, 1)
else: #预测模型
dec_valid_lens = None
核心概念:解码器掩码
在Transformer解码器中,自注意力层只能看到当前及之前的位置,不能看到未来的位置。这就是掩码的作用。
训练阶段 (self.training = True)
具体例子
假设我们训练时处理一个批次,序列长度=4:
python
# 输入X形状: [2, 4, 512] (2个样本,每个4个词)
# 生成的dec_valid_lens:
dec_valid_lens = tensor([
[1, 2, 3, 4], # 第一个样本的有效长度
[1, 2, 3, 4] # 第二个样本的有效长度
])
这个掩码如何工作?
在自注意力中,对于序列中的每个位置:
- 位置1:只能看到位置1 (有效长度=1)
- 位置2:能看到位置1-2 (有效长度=2)
- 位置3:能看到位置1-3 (有效长度=3)
- 位置4:能看到位置1-4 (有效长度=4)
这样就形成了下三角掩码,防止信息泄露。
推理阶段 (self.training = False)
python
else:
dec_valid_lens = None
为什么推理时是None?
因为在推理阶段:推理时,看不到未来,因此不用掩码
- 序列逐步生成:每次只处理一个token
- 缓存机制:
state[2][self.i]已经包含了所有历史信息 - 自动掩码:自注意力机制天然只能看到当前和之前的位置

可视化理解
训练阶段掩码矩阵
对于序列长度=4:
text
位置1: [1, 0, 0, 0] # 只能看自己
位置2: [1, 1, 0, 0] # 能看到位置1-2
位置3: [1, 1, 1, 0] # 能看到位置1-3
位置4: [1, 1, 1, 1] # 能看到所有位置
推理阶段
假设已经生成了3个token,现在生成第4个:
text
当前输入: 位置4的token
可以访问: 位置1,2,3,4 (通过缓存)
不需要额外掩码,因为天然看不到未来
在注意力机制中的使用
python
# 在多头注意力中,dec_valid_lens用于创建掩码
X2 = self.attention1(X, key_values, key_values, dec_valid_lens)
# 训练时: dec_valid_lens是矩阵,创建精确的下三角掩码
# 推理时: dec_valid_lens=None,依赖缓存机制自然形成掩码
实际例子对比
训练场景:翻译整个句子
text
输入: "I love you" → 目标: "我 爱 你"
一次性处理: [<start>, "我", "爱", "你"]
需要掩码确保生成"爱"时看不到"你"
推理场景:逐步生成
text
步骤1: 输入<start> → 输出"我"
步骤2: 输入"我" → 输出"爱" (只能看到<start>和"我")
步骤3: 输入"爱" → 输出"你" (只能看到<start>, "我", "爱")
总结
这段代码的核心作用是:
- 训练时:创建下三角掩码,确保并行处理时不会信息泄露
- 推理时:依赖缓存机制,自然形成掩码,无需额外处理
- 保证一致性:两种模式下都确保自注意力只能看到当前及之前的位置
简单来说:训练时我们"假装"逐个生成来创建掩码,推理时我们"真正"逐个生成所以自然有掩码效果!
这就是Transformer能够同时支持高效训练和灵活推理的关键设计。
更多推荐



所有评论(0)