一、引言
Transformer的编码器(Encoder)是模型最核心的部分,它由两个子层堆叠而成:
多头注意力层(Multi-Head Attention):让模型关注输入序列中不同位置的信息
前馈全连接层(Feed-Forward Network):对注意力结果进行非线性变换,增强特征表达
每个子层都配备了残差连接(Residual Connection)和层归一化(Layer Normalization),这是保证深层网络可训练的关键设计。
本文将从自注意力机制开始,逐步实现编码器的每一个组件。
二、自注意力机制回顾
2.1 数学公式
自注意力(Self-Attention)的核心计算公式:
Q(Query):查询向量,表示"我想关注什么"
K(Key):键向量,表示"我有什么可以被关注"
V(Value):值向量,表示"我的实际内容是什么"
√d_k:缩放因子,防止点积过大导致softmax梯度饱和
2.2 代码实现
def attention(query, key, value, mask=None, dropout=None): """ 自注意力计算函数 :param query: [batch_size, seq_len, d_model] :param key: [batch_size, seq_len, d_model] :param value: [batch_size, seq_len, d_model] :param mask: 掩码张量,用于遮挡未来位置 :param dropout: 随机失活层 :return: (注意力输出, 注意力权重) """ d_k = query.size(-1) # 获取特征维度 # 1. 计算缩放点积注意力分数 scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k) # 2. 掩码处理(关键!) if mask is not None: # 将遮挡位置设为负无穷,softmax后权重趋近于0 scores = scores.masked_fill(mask == 0, -1e9) # 3. 计算注意力权重(在最后一个维度上归一化) p_attn = F.softmax(scores, dim=-1) # 4. 随机失活(可选) if dropout is not None: p_attn = dropout(p_attn) # 5. 加权求和得到最终输出 return torch.matmul(p_attn, value), p_attn🔴 重点强调:掩码机制
掩码(Mask)是注意力机制中非常重要的概念,它的作用是控制每个位置能看到哪些其他位置。
# 掩码张量的可视化理解 def dm02_test_triu(size): # 生成上三角矩阵(对角线以上为1,以下为0) temp = np.triu(m=np.ones((1, size, size)), k=1).astype('uint8') # 取反得到下三角矩阵(对角线以下为1,以上为0) return torch.from_numpy(1 - temp)掩码矩阵示例(size=5):
# 下三角掩码(用于Decoder,防止看到未来位置) [[1, 0, 0, 0, 0], [1, 1, 0, 0, 0], [1, 1, 1, 0, 0], [1, 1, 1, 1, 0], [1, 1, 1, 1, 1]]横向(列):表示当前行位置能看到哪些列位置
1(不遮挡):可以看到
0(遮挡):看不到,被设为负无穷
三、多头注意力机制(Multi-Head Attention)
3.1 核心思想
多头注意力的本质是:将词向量在特征维度上切分成多个"头",每个头独立计算注意力,最后拼接起来。
这样做的好处:
多视角:每个头可以关注不同的特征子空间
并行计算:所有头同时计算,效率高
增强表达能力:模型可以学习到更丰富的关系
3.2 完整代码实现
class MultiHeadAttention(nn.Module): def __init__(self, embed_dim, head, dropout_p=0.1): super().__init__() assert embed_dim % head == 0 # 必须能整除 self.d_k = embed_dim // head # 每个头的维度:512/8=64 self.head = head # 4个线性层:Q、K、V投影 + 最终输出 self.linears = clones(nn.Linear(embed_dim, embed_dim), 4) self.dropout = nn.Dropout(p=dropout_p) self.attn = None # 保存注意力权重用于可视化 def forward(self, query, key, value, mask=None): if mask is not None: mask = mask.unsqueeze(0) # 增加头维度 batch = query.size(0) # 1. 线性变换 + 分头处理 # [batch, seq_len, embed_dim] -> [batch, seq_len, head, d_k] -> [batch, head, seq_len, d_k] query, key, value = [ model(x).view(batch, -1, self.head, self.d_k).transpose(1, 2) for model, x in zip(self.linears, (query, key, value)) ] # 2. 计算多头注意力 x, self.attn = attention(query, key, value, mask, self.dropout) # 3. 合并多头结果 # [batch, head, seq_len, d_k] -> [batch, seq_len, head, d_k] -> [batch, seq_len, embed_dim] x = x.transpose(1, 2).contiguous().view(batch, -1, self.head * self.d_k) # 4. 最终线性变换 return self.linears[-1](x)3.3 维度变化详解(重点!)
| 步骤 | 操作 | 形状变化 |
|---|---|---|
| 输入 | query, key, value | [2, 4, 512] |
| 线性投影 | 分别通过4个Linear层 | [2, 4, 512] |
| 分头 | view + transpose | [2, 8, 4, 64] |
| 注意力计算 | attention() | 输出[2, 8, 4, 64],权重[2, 8, 4, 4] |
| 合并头 | transpose + view | [2, 4, 512] |
| 最终输出 | 第4个Linear层 | [2, 4, 512] |
🔴 重点关注:transpose(1, 2)的作用
# 原始形状:[batch, seq_len, head, d_k] # 转置后:[batch, head, seq_len, d_k] x.transpose(1, 2)这样做是为了让注意力计算在 seq_len 维度上进行,而 head 维度可以并行计算。每个头独立计算注意力,互不干扰。
四、前馈全连接层(Feed-Forward Network)
4.1 设计原理
前馈全连接层是一个两层的全连接网络,中间使用ReLU激活函数:
设计特点:
输入维度 → 中间维度(4倍) → 输出维度
例如:512 → 2048 → 512
中间维度扩大4倍,增加模型的非线性表达能力
4.2 代码实现
class FeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout_p=0.1): super().__init__() self.linear1 = nn.Linear(d_model, d_ff) # 512 -> 2048 self.linear2 = nn.Linear(d_ff, d_model) # 2048 -> 512 self.dropout = nn.Dropout(p=dropout_p) def forward(self, x): # 第1层:线性变换 x = self.linear1(x) # 激活函数 + 随机失活 x = self.dropout(F.relu(x)) # 第2层:线性变换回原始维度 x = self.linear2(x) return x五、克隆工具函数
在Transformer中,多个子层结构相同,我们使用深拷贝来创建它们:
def clones(module, N): """ 克隆N个相同的模块 :param module: 要克隆的模块 :param N: 克隆次数 :return: ModuleList,包含N个独立参数的模块 """ return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])为什么用深拷贝?
每个模块需要独立的参数,不能共享
copy.deepcopy()创建完全独立的副本用
nn.ModuleList包装,便于索引和迭代
六、完整测试流程
def use_multihead(): # 1. 创建多头注意力层 my_attention = MultiHeadAttention(embed_dim=512, head=8) # 2. 获取输入数据(词嵌入 + 位置编码) position_x = use_position() # [2, 4, 512] # 3. 自注意力:Q=K=V query = key = value = position_x # 4. 创建掩码(8个头,每个4x4) mask = torch.zeros(8, 4, 4) # 5. 前向传播 result = my_attention(query, key, value, mask) print(f'多头注意力输出: {result.shape}') # [2, 4, 512] return result def use_ff(): # 1. 获取多头注意力输出 attn_x = use_multihead() # [2, 4, 512] # 2. 创建前馈全连接层 my_ff = FeedForward(d_model=512, d_ff=2048) # 3. 前向传播 result = my_ff(attn_x) print(f'前馈全连接输出: {result.shape}') # [2, 4, 512] return result七、关键知识点总结
| 组件 | 核心操作 | 作用 | 输入形状 | 输出形状 |
|---|---|---|---|---|
| 自注意力 | Softmax(QK^T/√d_k)V | 建立词间依赖关系 | [b, n, d] | [b, n, d] |
| 掩码 | masked_fill(mask==0, -1e9) | 控制可见范围 | [b, n, n] | [b, n, n] |
| 多头注意力 | 分头计算 + 拼接 | 多视角建模 | [b, n, d] | [b, n, d] |
| 前馈网络 | Linear→ReLU→Linear | 非线性增强 | [b, n, d] | [b, n, d] |
八、面试高频追问
Q1:为什么Q和K的维度要相同?
因为要计算点积Q·K^T,矩阵乘法要求Q的最后一维等于K的倒数第二维。
Q2:多头注意力中,每个头的维度为什么是d_model / head?
为了保持总计算量不变。8个头,每个64维,总维度还是512维,并行计算效率高。
Q3:掩码中的负无穷为什么用-1e9而不是-inf?
-inf在softmax中可能导致NaN,-1e9足够小,softmax后权重≈0,数值稳定。
Q4:前馈网络为什么中间维度要扩大4倍?
这是Transformer论文中的经验设计,扩大4倍能提供足够的非线性表达能力,同时计算量可控。