Transformer 核心机制 —— 代码对照笔记
基于 self-attention.py(从零构建 GPT 风格语言模型)逐段分析,对照 Attention Is All You Need 论文中的核心设计。
tags:
- transformer
- self-attention
- deep-learning
- NLP
- GPT
created: 2026-06-02 source: "[[self-attention.py]] 代码逐段分析" paper: "[[Attention Is All You Need]]"
基于
self-attention.py(从零构建 GPT 风格语言模型)逐段分析,对照 Attention Is All You Need 论文中的核心设计。
一、超参数设定
batch_size = 32 # B: 每次并行处理的序列数量
block_size = 8 # T: 上下文的最大长度 (时间步)
max_iters = 5000 # 训练迭代次数
eval_interval = 500 # 评估间隔
learning_rate = 1e-3 # 学习率
n_embd = 32 # C: 模型内部的思考维度 (嵌入维度)
n_head = 4 # 多头注意力的头数
n_layer = 6 # Transformer Block 的层数
dropout = 0.2 # Dropout 比率
| 参数 | 论文对应 | 说明 |
|---|---|---|
n_embd=32 | $d_{\text{model}}$ | 嵌入维度,所有子层的输出维度 |
n_head=4 | $h$ | 多头注意力的头数 |
n_layer=6 | $N$ | Encoder/Decoder 堆叠层数(这里只有 Decoder) |
dropout=0.2 | $P_{\text{drop}}$ | 论文中每个子层后都有 Dropout |
二、自注意力机制(Scaled Dot-Product Attention)
论文公式: $\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^T}{\sqrt{d_k}}\right) V$
2.1 单个注意力头 Head
class Head(nn.Module):
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
self.dropout = nn.Dropout(dropout)
def forward(self, x):
B, T, C = x.shape
k = self.key(x) # (B, T, head_size)
q = self.query(x) # (B, T, head_size)
# 缩放点积注意力
wei = q @ k.transpose(-2, -1) * (C ** -0.5) # (B, T, T)
# ↑ 除以 sqrt(d_k) 防止点积方差过大
# 因果掩码:防止"偷看未来"
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
# Softmax 归一化 → 得到注意力权重
wei = F.softmax(wei, dim=-1)
wei = self.dropout(wei)
# 加权聚合 Value
v = self.value(x)
out = wei @ v # (B, T, head_size)
return out
🔑 关键细节拆解
① Query / Key / Value 三个线性投影
self.key、self.query、self.value是三个独立的线性层(无偏置)- 输入
x形状(B, T, n_embd)→ 分别投影到head_size维度 - 这是论文中的 $W^Q, W^K, W^V$ 矩阵
② 缩放因子 $\frac{1}{\sqrt{d_k}}$
wei = q @ k.transpose(-2, -1) * (C ** -0.5)
- 为什么需要缩放? 当 $d_k$ 很大时,$QK^T$ 的点积值会很大
- 不做缩放 → Softmax 梯度会进入极小区间 → 梯度消失
- 除以 $\sqrt{d_k}$ 让方差回归 1,保持 Softmax 输出的概率分布"不极端"
③ 因果掩码(Causal Mask)
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
- 使用下三角矩阵
tril,把未来位置的注意力分数设为-inf - Softmax 后,未来位置的权重变为 0
- 这是 GPT 风格 Decoder-only 的核心:只能看到过去,不能看到未来
论文原文:masked self-attention in the decoder prevents positions from attending to subsequent positions.
④ 注意力 Dropout
wei = self.dropout(wei)
- 在注意力权重矩阵上施加 Dropout
- 随机丢弃部分注意力连接,增加鲁棒性
三、多头注意力(Multi-Head Attention)
论文思想: 用多组 $W^Q, W^K, W^V$ 并行计算,让模型从不同"子空间"关注信息。
class MultiHeadAttention(nn.Module):
def __init__(self, num_heads, head_size):
super().__init__()
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
self.proj = nn.Linear(n_embd, n_embd)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# 每个头独立计算注意力,沿最后一维拼接
out = torch.cat([h(x) for h in self.heads], dim=-1)
# 线性投影回 n_embd 维度
out = self.proj(out)
return out
论文公式: $\text{MultiHead}(Q,K,V) = \text{Concat}(\text{head}1, ..., \text{head}h) W^O$
🔑 关键细节
| 步骤 | 代码 | 论文对应 |
|---|---|---|
| 多头并行 | [Head(head_size) for _ in range(num_heads)] | $h$ 个并行头 |
| 拼接 | torch.cat(..., dim=-1) | Concat 操作 |
| 输出投影 | self.proj(out) | $W^O$ 线性变换 |
- 这里
head_size = n_embd // n_head = 32 // 4 = 8 - 每个头处理 8 维子空间,拼起来恢复 32 维
四、前馈神经网络(Position-wise Feed-Forward)
论文公式: $\text{FFN}(x) = \max(0, xW1 + b1)W2 + b2$
class FeedForward(nn.Module):
def __init__(self, n_embd):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd), # 扩张到 4 倍
nn.ReLU(), # 非线性激活
nn.Linear(4 * n_embd, n_embd), # 压缩回原维度
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
🔑 关键细节
- 扩张比 4 倍: 论文中的 $d{ff} = 4 \times d{\text{model}}$,这里
4 * n_embd - 逐位置(Position-wise): 对序列中每个位置独立做相同的线性变换
- ReLU 激活: 论文原始用的是 ReLU(后来 GPT 系列改用 GELU)
- Dropout: 在 FFN 输出后施加,防止过拟合
五、残差连接(Residual Connections)
论文原文: We employ a residual connection around each of the two sub-layers.
class Block(nn.Module):
def forward(self, x):
x = x + self.sa(self.ln1(x)) # 残差:注意力子层
x = x + self.ffwd(self.ln2(x)) # 残差:前馈子层
return x
🔑 关键细节
x = x + f(x)就是残差连接的标准写法- 为什么需要残差?
- 没有残差 → 深层网络梯度消失 → 无法训练
- 有残差 → 梯度可以走"短路"直接回传 → 堆再多层也不怕
- 论文中把残差连接写为:$\text{LayerNorm}(x + \text{Sublayer}(x))$
- ⚠️ 注意:这里的实现是先 LN 再子层(Pre-LN),和论文原始的 Post-LN 顺序不同
Pre-LN vs Post-LN
| 方式 | 顺序 | 特点 |
|---|---|---|
| Post-LN(论文原版) | $x + \text{Sublayer}(\text{LN}(x))$ ← 这里写成 x + Sublayer(LN(x)) 是 Pre-LN,Post-LN 是 LN(x + Sublayer(x)) | 需要 warmup,训练不稳定 |
| Pre-LN(现代实践) | $\text{LN}(x)$ → Sublayer → 加回 x | 训练更稳定,不需要 warmup |
当前代码用的是 Pre-LN,这是 GPT-2 之后的业界标准做法。
六、层归一化(Layer Normalization)
论文原文: We employ layer normalization after each sub-layer.
self.ln1 = nn.LayerNorm(n_embd) # 注意力子层前
self.ln2 = nn.LayerNorm(n_embd) # 前馈子层前
self.blocks.append(nn.LayerNorm(n_embd))
🔑 Batch Norm vs Layer Norm
| 特性 | Batch Normalization | Layer Normalization |
|---|---|---|
| 归一化维度 | 跨 batch 维度(同一特征的不同样本) | 跨特征维度(同一样本的所有特征) |
| 对 batch size 依赖 | 强依赖,小 batch 效果差 | 不依赖,天然适合 NLP |
| 序列长度敏感 | 需要 padding 到相同长度 | 每个位置独立归一化 |
| NLP 适用性 | ❌ 差 | ✅ 好 |
LN 的数学形式
$$\text{LN}(x) = \gamma \cdot \frac{x - \mu}{\sigma} + \beta$$
- $\mu, \sigma$:在特征维度上计算均值和标准差
- $\gamma, \beta$:可学习的缩放和平移参数
- 输入
(B, T, n_embd)→ 在最后一维 $n_{\text{embd}}$ 上归一化
七、Dropout —— 防止过拟合
论文原文: We apply dropout to the output of each sub-layer, before it is added to the sub-layer input and normalized. In addition, we apply dropout to the sums of the embeddings and the positional encodings.
dropout = 0.2 # 每次随机丢弃 20% 的神经元
代码中使用 Dropout 的位置
| 位置 | 代码 | 作用 |
|---|---|---|
| 注意力权重 | wei = self.dropout(wei) 在 Head 中 | 随机丢弃注意力连接 |
| 多头输出投影 | self.dropout 在 MultiHeadAttention | 防多头拼接后过拟合 |
| 前馈网络后 | nn.Dropout(dropout) 在 FeedForward | FFN 输出正则化 |
Dropout 的工作原理
- 训练时: 以概率 $p$ 随机将神经元输出置零
- 推理时: 所有神经元参与,但输出乘以 $(1-p)$(PyTorch 自动处理)
model.eval()会自动关闭 Dropout,model.train()会重新打开
训练时相当于训练了 $2^n$ 个不同子网络的集成,推理时等价于它们的平均。
八、位置编码(Positional Encoding)
论文原文: Since our model contains no recurrence and no convolution, we must inject some information about the relative or absolute position of the tokens in the sequence.
self.position_embedding_table = nn.Embedding(block_size, n_embd)
pos_emb = self.position_embedding_table(torch.arange(T, device=device))
x = tok_emb + pos_emb # 词义 + 位置 → 融合
🔑 关键细节
- 论文原文使用正弦/余弦固定编码(Sinusoidal)
- 这里的代码使用的是可学习的位置嵌入(Learnable Position Embedding)
- 这是 GPT 系列的做法,实践中效果相当甚至更好
- 原理:让模型知道 "第 0 个位置" 和 "第 3 个位置" 是不同的
九、Transformer Block 完整组装
class Block(nn.Module):
""" Transformer 块:通信 (Attention) + 思考 (FeedForward) """
def __init__(self, n_embd, n_head):
super().__init__()
head_size = n_embd // n_head
self.sa = MultiHeadAttention(n_head, head_size) # 通信
self.ffwd = FeedForward(n_embd) # 思考
self.ln1 = nn.LayerNorm(n_embd) # 归一化 1
self.ln2 = nn.LayerNorm(n_embd) # 归一化 2
def forward(self, x):
x = x + self.sa(self.ln1(x)) # Pre-LN + 注意力 + 残差
x = x + self.ffwd(self.ln2(x)) # Pre-LN + FFN + 残差
return x
一张图理解 Block
输入 x
│
├─→ LayerNorm → Multi-Head Attention ─→ (+) ─┐
│ ↑ │
│ 残差连接 (x) │
│ ▼
└─→ LayerNorm → Feed-Forward ────────→ (+) ─→ 输出
↑
残差连接 (x)
十、多层堆叠
self.blocks = nn.Sequential(
*[Block(n_embd, n_head=n_head) for _ in range(n_layer)]
)
self.blocks.append(nn.LayerNorm(n_embd)) # 最终 LayerNorm
- 论文中 Encoder 和 Decoder 各堆 6 层($N=6$)
- 这里
n_layer=6,每个 Block 里注意力 + FFN 各一个子层 → 共 12 个子层 - 最后的
LayerNorm是 GPT-2 风格,确保输出归一化后再做预测
十一、训练相关
11.1 优化器 —— AdamW
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
- AdamW = Adam + 解耦的权重衰减(Decoupled Weight Decay)
- Transformer 训练的标配优化器
- 论文原版用的是 Adam,AdamW 是后来的改进
11.2 学习率 Warmup(论文中有,本代码未实现)
论文使用公式:$lr = d{\text{model}}^{-0.5} \cdot \min(\text{step\num}^{-0.5}, \text{step\num} \cdot \text{warmup\steps}^{-1.5})$
- 特征:先线性增长,再按步数平方根衰减
- 本代码因为用了 Pre-LN,不需要 warmup 也能稳定训练
11.3 训练/评估模式切换
model.eval() # 评估模式:关闭 Dropout
model.train() # 训练模式:开启 Dropout
十二、架构总览图
BigramLanguageModel
│
┌────────────┼────────────┐
▼ ▼ ▼
Token Embed Position Embed 输入 idx
│ │
└───── (+) ──┘
│
┌───────▼────────┐
│ Block × 6 │
│ ┌──────────┐ │
│ │ LN → MHA│ │ ← 通信(词与词交互)
│ │ + 残差 │ │
│ │ LN → FFN│ │ ← 思考(逐位置加工)
│ │ + 残差 │ │
│ └──────────┘ │
└───────┬────────┘
│
Final LN
│
lm_head (Linear)
│
logits
十三、关键 Q&A
Q1: 为什么 Attention 叫"通信",FFN 叫"思考"?
Attention 让序列中不同位置的词相互"交流"信息(跨位置操作);FFN 在每个位置上独立地对融合后的信息做非线性变换(不跨位置)。所以一个是"开会讨论",一个是"各自消化"。
Q2: 残差连接为什么能让深层网络可训练?
反向传播时,梯度可以走残差支路(恒等映射)直接回传,不会因为经过太多层而衰减到零。数学上:$\frac{\partial}{\partial x}(x + f(x)) = 1 + \frac{\partial f}{\partial x}$,那个 $+1$ 保证了梯度不会消失。
Q3: Pre-LN 和 Post-LN 到底差在哪?
Post-LN:
LN(x + Sublayer(x))—— 残差加完再归一化,梯度路径经过 LN 可能被压缩,需要 warmup。 Pre-LN:x + Sublayer(LN(x))—— 先归一化再做子层、再加回残差,梯度走残差路径时不经过 LN,训练更稳定。
Q4: Dropout 加在不同位置有什么不同效果?
| 位置 | 效果 |
|---|---|
| 注意力权重上 | 防止模型过度依赖某几个特定的词间关系 |
| FFN 输出上 | 防止 FFN 过拟合训练数据中的特征组合 |
| 嵌入层上(本代码未加) | 防止词嵌入过拟合 |
参考资料
- [[Attention Is All You Need]] (Vaswani et al., 2017)
self-attention.py—— Andrej Karpathy 风格的从零实现- GPT-2 论文:Language Models are Unsupervised Multitask Learners
Comments · 0
No comments yet. Be the first to leave one.
Log in to leave a comment.