第4章:从零实现GPT模型
学习目标
- 弄清 LayerNorm、GELU、残差连接各自的作用。
- 实现 Feed-Forward 子层。
- 把第 3 章的多头注意力 + FFN 拼装为 Transformer Block。
- 堆叠成完整的 GPT 模型,并能跑通一次前向传播。
4.1 GPT 的总体结构
一个 GPT 模型可以画成下面的”汉堡”:
输入 ids ──► Token Embed ┐
├─► h0
位置 0..T-1 ─► Pos Embed ─┘
│
▼
Transformer Block × N (本章重点)
│
▼
Final LayerNorm
│
▼
Linear → logits (V 维)
每个 Transformer Block 的内部:
x ──► LayerNorm ──► MultiHeadAttention ──► Dropout ──► (+ x) ──► h'
h' ─► LayerNorm ──► FeedForward ────────► Dropout ──► (+ h') ──► out
注意 GPT-2 采用 Pre-LN(LayerNorm 放在子层之前),而原始 Transformer 论文是 Post-LN。Pre-LN 的训练稳定性显著更好,是后续几乎所有 LLM 的默认配置。
4.2 LayerNorm
LayerNorm 在最后一维(特征维)上做归一化:
$$\hat{x} = \frac{x - \mu}{\sqrt{\sigma^2 + \varepsilon}}, \quad y = \gamma \odot \hat{x} + \beta$$
其中 $\mu, \sigma$ 在每个样本、每个时间步独立计算;$\gamma, \beta$ 是可学习的逐通道缩放和偏置。
import torch
import torch.nn as nn
class LayerNorm(nn.Module):
def __init__(self, d_model, eps=1e-5):
super().__init__()
self.eps = eps
self.scale = nn.Parameter(torch.ones(d_model))
self.shift = nn.Parameter(torch.zeros(d_model))
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False)
x_hat = (x - mean) / torch.sqrt(var + self.eps)
return self.scale * x_hat + self.shift
和 BatchNorm 的差别:BatchNorm 在 batch 维度统计,依赖 batch 大小且推理时要”冻结统计量”;LayerNorm 在特征维统计,单样本就能算,对序列建模特别友好。
4.3 GELU 激活
GPT 系列默认使用 GELU(Gaussian Error Linear Unit):
$$\text{GELU}(x) = x \cdot \Phi(x)$$
直观理解:ReLU 是 0/1 硬开关,GELU 是用标准正态 CDF 做”软开关”,对小负值仍保留少量梯度,训练更平滑。
PyTorch 提供 nn.GELU(),也可以用近似版本:
$$\text{GELU}(x) \approx 0.5 x \left(1 + \tanh!\left[\sqrt{\tfrac{2}{\pi}} (x + 0.044715 x^3)\right]\right)$$
4.4 Feed-Forward 子层
每个 Transformer Block 中都有一个逐位置(point-wise)的两层 MLP:
class FeedForward(nn.Module):
def __init__(self, d_model, expansion=4, dropout=0.0):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_model, expansion * d_model),
nn.GELU(),
nn.Linear(expansion * d_model, d_model),
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
“逐位置”意味着对 (B, T, C) 张量来说,时间维 T 完全没有被混合,FFN 只在通道维做非线性变换。注意力负责”位置之间”的混合,FFN 负责”通道之间”的混合——这是 Transformer 的核心结构哲学。
4.5 残差连接
残差连接是 ResNet 的发明,在 Transformer 里同样关键:
$$x_{l+1} = x_l + \text{Sublayer}(\text{LN}(x_l))$$
它带来两个好处:
- 梯度直通:反向传播时 $\partial x_{l+1}/\partial x_l$ 至少为 1,缓解深层网络的梯度消失;
- 恒等初始化:当 Sublayer 输出接近 0 时,整个 Block 退化为恒等映射,深堆叠不会破坏初始信号。
4.6 Transformer Block
把上面所有零件拼起来:
class TransformerBlock(nn.Module):
def __init__(self, cfg):
super().__init__()
self.norm1 = LayerNorm(cfg["emb_dim"])
self.attn = MultiHeadAttention(
d_model=cfg["emb_dim"],
num_heads=cfg["n_heads"],
context_length=cfg["context_length"],
dropout=cfg["drop_rate"],
)
self.drop1 = nn.Dropout(cfg["drop_rate"])
self.norm2 = LayerNorm(cfg["emb_dim"])
self.ff = FeedForward(cfg["emb_dim"], dropout=cfg["drop_rate"])
self.drop2 = nn.Dropout(cfg["drop_rate"])
def forward(self, x):
x = x + self.drop1(self.attn(self.norm1(x)))
x = x + self.drop2(self.ff(self.norm2(x)))
return x
MultiHeadAttention即第 3 章实现的版本。
4.7 GPT 主类
最后把 N 层 Block 堆起来,前后接好 embedding 和输出头:
class GPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop = nn.Dropout(cfg["drop_rate"])
self.blocks = nn.Sequential(
*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])]
)
self.final_norm = LayerNorm(cfg["emb_dim"])
self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False)
def forward(self, idx): # idx: (B, T)
B, T = idx.shape
positions = torch.arange(T, device=idx.device)
x = self.tok_emb(idx) + self.pos_emb(positions)
x = self.drop(x)
x = self.blocks(x)
x = self.final_norm(x)
logits = self.out_head(x) # (B, T, vocab_size)
return logits
GPT-2 Small(124M)的标准配置:
GPT_CONFIG_124M = {
"vocab_size": 50257,
"context_length": 1024,
"emb_dim": 768,
"n_heads": 12,
"n_layers": 12,
"drop_rate": 0.1,
}
4.8 跑一次前向 + 参数量统计
import torch
model = GPTModel(GPT_CONFIG_124M)
model.eval()
x = torch.randint(0, 50257, (2, 16))
with torch.no_grad():
logits = model(x)
print(logits.shape) # (2, 16, 50257)
n_params = sum(p.numel() for p in model.parameters())
print(f"参数量: {n_params/1e6:.2f}M")
# 实际约 163M(包含位置嵌入),即所谓 "GPT-2 124M" 的口径仅算非嵌入部分
“124M” 的命名沿用了 OpenAI 的口径——只统计 Transformer 主体参数,不含 token/位置嵌入和输出头。
4.9 权重共享(可选优化)
GPT-2 把 token embedding 矩阵 $E \in \mathbb{R}^{V \times C}$ 和输出投影 $W_{out} \in \mathbb{R}^{C \times V}$ 绑定为同一组参数(转置共享)。这能:
- 减少约 $V \cdot C \approx 38\text{M}$ 参数;
- 让”词义空间”和”输出空间”对齐,通常略微提升困惑度。
实现:
self.out_head.weight = self.tok_emb.weight # 形状必须一致:(V, C)
检查清单
- 我能在白板上画出 Pre-LN Transformer Block 的全部数据流。
- 我能解释 LayerNorm 与 BatchNorm 在序列建模中的取舍。
- 我能跑通一次前向,并算出参数量。
练习题
- 把 Pre-LN 改成 Post-LN(即先做子层再 LN),用同一份训练数据训一两个 step,比较 loss 曲线的稳定性。
- 关掉残差连接(
x = self.drop1(self.attn(...))而不再加x +),观察前几个 step 内 loss 是否爆炸。 - 试着把 FFN 的
expansion从 4 改成 2 或 8,记录参数量与 loss 的变化。
📖 第4章补充材料 → — Pre-LN vs Post-LN、GELU/SwiGLU详解、GPT-2四尺寸配置
← 上一章 · 返回目录 · 下一章 · 预训练 →