你了解transformer吗?nanoGPT 完整解读
源码地址:github.com/karpathy/nanoGPT
全局流程图
第一阶段:准备数据(data/shakespeare/prepare.py,本文不展开)
第二阶段:初始化模型 ────────── model.py
第三阶段:训练循环(60万步)──── train.py + model.py
第四阶段:评估 ──────────────── train.py
第五阶段:生成文本 ──────────── model.py
下面按流程逐步拆解,每一步都标注源码位置。
第二阶段:初始化模型
📍 源码:model.py →
GPTConfig类 +GPT.__init__()
2.1 模型配置
# model.py — 第133行
@dataclass
class GPTConfig:
block_size: int = 1024 # 最大序列长度(上下文窗口能看多远)
vocab_size: int = 50304 # 词表大小(50257个词,对齐到64倍数)
n_layer: int = 12 # 堆叠12个Transformer层
n_head: int = 12 # 每层12个注意力头
n_embd: int = 768 # 每个token用768维向量表示
dropout: float = 0.0 # 预训练不用Dropout
bias: bool = True # 是否用偏置项
2.2 创建模型(GPT.__init__)
# model.py — 第143行
self.transformer = nn.ModuleDict(dict(
wte = nn.Embedding(config.vocab_size, config.n_embd), # ①词嵌入
wpe = nn.Embedding(config.block_size, config.n_embd), # ②位置嵌入
drop = nn.Dropout(config.dropout), # ③Dropout
h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]), # ④12个Block
ln_f = LayerNorm(config.n_embd, bias=config.bias), # ⑤最终归一化
))
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) # ⑥输出头
对应的结构图:
输入 token 索引
↓
① wte(词嵌入)—— "你" → [0.23, -0.51, 0.87, ...](768维向量)
② wpe(位置嵌入)—— 位置3 → [0.12, 0.34, -0.22, ...](768维向量)
↓ 相加
③ Dropout
↓
④ 重复12次Block(每个Block = 注意力 + MLP,见下方详述)
↓
⑤ ln_f(最终层归一化)
↓
⑥ lm_head(输出头)—— 768维 → 50304维(每个词的得分)
↓
Softmax → 概率分布 → 预测下一个词
2.3 权重共享(一个巧妙设计)
# model.py — 第156行
self.transformer.wte.weight = self.lm_head.weight
词嵌入矩阵和输出头的权重矩阵是同一个。直觉:输入"词→向量"和输出"向量→词概率"是互逆操作,共享参数减少参数量且语义一致。
2.4 特殊初始化
# model.py — 第159-160行
# 每层残差投影的权重用更小的标准差初始化
for pn, p in self.named_parameters():
if pn.endswith('c_proj.weight'):
torch.nn.init.normal_(p, mean=0.0, std=0.02/math.sqrt(2 * config.n_layer))
# 12层时:std = 0.02/√24 ≈ 0.0041(远小于普通的0.02)
为什么? 随着层数增加,残差路径上的信号会累积。缩小每层输出投影的初始值,防止深层网络的初始输出过大。
2.5 一个 Block 的内部结构
📍 源码:model.py →
Block类(第123行)+CausalSelfAttention+MLP
# model.py — 第131行
def forward(self, x):
x = x + self.attn(self.ln_1(x)) # 注意力(带残差连接 + Pre-Norm)
x = x + self.mlp(self.ln_2(x)) # MLP(带残差连接 + Pre-Norm)
return x
展开看每一部分:
(A)LayerNorm——层归一化
# model.py — 第17行
class LayerNorm(nn.Module):
def forward(self, input):
return F.layer_norm(input, self.weight.shape, self.weight, self.bias, 1e-5)
# 做什么:对每个样本的768维特征,算均值和方差,归一化到均值为0、方差为1
# 然后乘以可学习的 weight,加上可学习的 bias
(B)CausalSelfAttention——因果自注意力
这是整个 Transformer 的核心。
# model.py — 第23行
class CausalSelfAttention(nn.Module):
def __init__(self, config):
# 一次矩阵乘法同时生成 Q、K、V(比分别算3次更高效)
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
# 输出投影
self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
# 因果掩码:下三角矩阵,确保只能看到左边的内容
self.register_buffer("bias",
torch.tril(torch.ones(config.block_size, config.block_size))
.view(1, 1, config.block_size, config.block_size))
forward 里的完整计算过程:
# model.py — 第44行
def forward(self, x):
B, T, C = x.size() # B=批次, T=序列长度, C=768
# 步骤1:生成 Q、K、V
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
# 步骤2:拆成多头(12个头,每头64维)
# (B, T, 768) → (B, 12, T, 64)
q = q.view(B, T, 12, 64).transpose(1, 2)
k = k.view(B, T, 12, 64).transpose(1, 2)
v = v.view(B, T, 12, 64).transpose(1, 2)
# 步骤3:计算注意力分数 = Q × Kᵀ / √64
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(64))
# 步骤4:因果掩码(把未来位置设为-∞)
# 没有这步的话,"你"能看到"界",就变成了双向注意力(BERT风格)
att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))
# 步骤5:Softmax 归一化
att = F.softmax(att, dim=-1)
# 步骤6:加权求和 V
y = att @ v # (B, 12, T, T) × (B, 12, T, 64) → (B, 12, T, 64)
# 步骤7:拼回多头 → 输出投影
y = y.transpose(1, 2).contiguous().view(B, T, C)
y = self.c_proj(y)
return y
用一个例子理解:
输入:"从前有座山"
位置:0 1 2 3 4
计算 "山"(位置4)的注意力:
Q("山") 和 K("从") 的点积 → 分数低(关联小)
Q("山") 和 K("座") 的点积 → 分数高(关联大,因为"有座山")
Q("山") 和 K("界") → 被掩码屏蔽(位置4不能看位置4之后)
Softmax 归一化后:
"从": 5%, "前": 8%, "有": 15%, "座": 72%
用这些权重去加权 V → 输出一个融合了上下文的向量
(C)MLP——前馈网络
# model.py — 第85行
class MLP(nn.Module):
def __init__(self, config):
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd) # 768 → 3072
self.gelu = nn.GELU() # 激活函数
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd) # 3072 → 768
def forward(self, x):
x = self.c_fc(x) # 扩展4倍:768 → 3072("多路思考")
x = self.gelu(x) # 激活:决定哪些"路"有用
x = self.c_proj(x) # 压缩回来:3072 → 768("总结思考")
return x
一个 Block 的完整数据流
输入 x: (B, T, 768)
↓ ln_1(归一化)
↓ CausalSelfAttention(看上下文)
↓ + x(残差连接:原始信息直通)
↓ ln_2(归一化)
↓ MLP(消化信息)
↓ + x(残差连接)
输出: (B, T, 768) ← 形状完全不变,可以继续堆叠
第三阶段:训练循环
📍 源码:train.py 主循环(约第230行起)
3.1 取数据
# train.py — get_batch() 函数(约第120行)
def get_batch(split):
# 从磁盘内存映射中随机取一批
data = np.memmap(os.path.join(data_dir, 'train.bin'), dtype=np.uint16, mode='r')
# 随机选 batch_size 个起始位置
ix = torch.randint(len(data) - block_size, (batch_size,))
# 关键:x 和 y 是同一串数据,y 比 x 左移一位
x = torch.stack([torch.from_numpy((data[i:i+block_size]).astype(np.int64)) for i in ix])
y = torch.stack([torch.from_numpy((data[i+1:i+1+block_size]).astype(np.int64)) for i in ix])
return x, y
对应关系:
x = [你, 好, 世, 界, 真, 美] ← 输入
y = [好, 世, 界, 真, 美, 啊] ← 目标(x左移1位)
模型在每个位置都要预测下一个词:
- 给定"你" → 预测"好" ✓ 加分
- 给定"你好" → 预测"世" ✗ 扣分
- 给定"你好世" → 预测"界" ✓ 加分
- …
3.2 前向传播
📍 源码:model.py →
GPT.forward()
# model.py — 第172行
def forward(self, idx, targets=None):
b, t = idx.size()
pos = torch.arange(0, t, dtype=torch.long, device=device)
# ① 获取嵌入
tok_emb = self.transformer.wte(idx) # 词嵌入:(B, T, 768)
pos_emb = self.transformer.wpe(pos) # 位置嵌入:(T, 768)
x = self.transformer.drop(tok_emb + pos_emb) # 相加后Dropout
# ② 逐层处理
for block in self.transformer.h:
x = block(x) # 每个Block = LN → Attention(残差) → LN → MLP(残差)
# ③ 最终归一化
x = self.transformer.ln_f(x)
# ④ 计算logits和损失
if targets is not None: # 训练模式
logits = self.lm_head(x) # (B, T, 50304) 每个位置对50304个词的打分
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
训练 vs 推理的区别:
- 训练:计算所有1024个位置的预测和损失(并行,效率高)
- 推理:只算最后一个位置(因为前面的已经知道了)
3.3 算损失
# model.py — 第190行
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
交叉熵做了什么?
假设在位置3,输入"你好世",模型输出 logits:
词 logits 概率(Softmax后)
"界" 5.2 42% ← 正确答案,概率高 → 损失小 ✓
"的" 3.1 8%
"里" 2.8 5%
... ... ...
"猫" -8.5 0.0001% ← 不相关,概率低
交叉熵损失 = -log(0.42) = 0.87
(如果预测"界"的概率只有0.01,损失 = -log(0.01) = 4.6,惩罚很大)
所有位置的平均损失就是这一步的总损失。
3.4 反向传播
# train.py — 约第260行
scaler.scale(loss).backward()
PyTorch 自动完成:从损失出发,沿着计算图反向遍历,用链式法则计算每个参数的梯度。
3.5 学习率调度
# train.py — get_lr() 函数(约第205行)
def get_lr(it):
# 阶段1:预热(0 → 2000步)—— 线性增长
if it < warmup_iters:
return learning_rate * (it + 1) / (warmup_iters + 1)
# 阶段2:余弦衰减(2000 → 600000步)
decay_ratio = (it - warmup_iters) / (lr_decay_iters - warmup_iters)
coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
return min_lr + coeff * (learning_rate - min_lr)
# 阶段3:600000步后保持最小值
学习率变化曲线:
lr
↑ 6e-4 ───╲
| ╲
| ╲ ╱────── 6e-5
| ╲ ╱
| ╲ ╱
| ╲ ╱
└────────────────╲──────────────→ 训练步数
0 2000 600000
预热 余弦衰减 收尾
3.6 梯度裁剪 + 参数更新
# train.py — 约第265行
if grad_clip != 0.0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
# 如果某个参数的梯度 L2 范数 > 1.0,就缩放到 1.0
# 防止某次异常大的梯度"炸掉"模型
scaler.step(optimizer) # AdamW 更新参数
scaler.update() # 更新 float16 缩放因子
optimizer.zero_grad(set_to_none=True) # 清零梯度
3.7 梯度累积(模拟大 batch)
# train.py — 约第250行
for micro_step in range(gradient_accumulation_steps): # 循环40次
with ctx: # 混合精度
logits, loss = model(X, Y)
loss = loss / gradient_accumulation_steps # 损失除以40
X, Y = get_batch('train') # 异步预取下一批
scaler.scale(loss).backward() # 累积梯度(不更新参数)
# 40个micro-batch跑完后,才执行一次参数更新
为什么? 显存放不下480个样本,但可以放12个。分40次跑,效果等同。
第四阶段:评估
📍 源码:train.py →
estimate_loss()函数
# train.py — 约第195行
@torch.no_grad()
def estimate_loss():
out = {}
model.eval()
for split in ['train', 'val']:
losses = torch.zeros(eval_iters) # 200次取平均,减少随机波动
for k in range(eval_iters):
X, Y = get_batch(split)
with ctx:
logits, loss = model(X, Y)
losses[k] = loss.item()
out[split] = losses.mean()
model.train()
return out
每2000步评估一次:
step 0: train loss 10.82, val loss 10.85 ← 随机初始化,猜的
step 2000: train loss 6.21, val loss 6.35 ← 开始学到规律了
step 50000: train loss 3.45, val loss 3.67 ← 语法大致正确
step 200000: train loss 2.10, val loss 2.88 ← 有语义理解了
step 600000: train loss 1.50, val loss 1.80 ← 接近GPT-2水平
train loss 和 val loss 的差距:如果差距太大,说明过拟合(记住了训练数据,不会泛化)。
第五阶段:生成文本
📍 源码:model.py →
GPT.generate()
# model.py — 第228行
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
for _ in range(max_new_tokens):
# 如果序列太长,截断到block_size
idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:]
# 前向传播(推理模式:只算最后一个位置)
logits, _ = self(idx_cond)
# 取最后一个位置的 logits,除以温度
logits = logits[:, -1, :] / temperature
# Top-K 采样:只保留概率最高的 k 个选项
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = -float('Inf')
# Softmax → 概率分布
probs = F.softmax(logits, dim=-1)
# 从概率分布中采样(不是选最高概率,有一定随机性)
idx_next = torch.multinomial(probs, num_samples=1)
# 拼到序列末尾
idx = torch.cat((idx, idx_next), dim=1)
return idx
温度(temperature)的作用:
temperature = 0.1 → 概率分布非常尖锐 → 几乎总选最高概率的词 → 输出很确定、重复
temperature = 1.0 → 原始概率分布 → 平衡
temperature = 2.0 → 概率分布很平坦 → 随机性大 → 输出很有创意但可能不连贯
Top-K 的作用:
原始概率分布(50000个词):
"的" 15%, "是" 10%, "在" 8%, ... "企鹅" 0.0001%
Top-K=50 后:
只保留概率最高的50个词,其余设为0
重新归一化后采样
防止模型偶尔输出毫无意义的词(如"企鹅")
生成过程示例:
输入: [从前有座山]
↓ 模型预测
"的" (30%), "里" (25%), "上" (15%), ...
↓ 采样得到 "里"
输入: [从前有座山里]
↓ 模型预测
"有" (40%), "住" (20%), ...
↓ 采样得到 "有"
输入: [从前有座山里有]
↓ ...重复 max_new_tokens 次
输出: [从前有座山里有一座庙,庙里有个老和尚...]
优化器配置
📍 源码:model.py →
GPT.configure_optimizers()
# model.py — 第213行
def configure_optimizers(self, weight_decay, learning_rate, betas, device_type):
# 2D参数(权重矩阵、嵌入矩阵)→ 应用权重衰减
decay_params = [p for n, p in param_dict.items() if p.dim() >= 2]
# 1D参数(bias、LayerNorm的weight/bias)→ 不应用权重衰减
nodecay_params = [p for n, p in param_dict.items() if p.dim() < 2]
optimizer = torch.optim.AdamW([
{'params': decay_params, 'weight_decay': weight_decay}, # 0.1
{'params': nodecay_params, 'weight_decay': 0.0}
], lr=learning_rate, betas=(0.9, 0.95))
return optimizer
为什么分开? 权重衰减相当于在损失里加一个正则项,鼓励权重保持小值。但 bias 和 LayerNorm 不需要这个(它们的值应该由数据决定,不应该被人为压小)。
完整流程 × 源码索引
| 流程步骤 | 做什么 | 源码位置 |
|---|---|---|
| 初始化模型 | 创建12层Block、嵌入表、输出头 | model.py GPT.__init__ |
| 权重共享 wte = lm_head | model.py 第156行 | |
| 特殊初始化 c_proj | model.py 第159行 | |
| 取数据 | memmap随机读取,x和y左移一位 | train.py get_batch() |
| 前向传播 | 词嵌入+位置嵌入 → 12层Block → logits | model.py GPT.forward() |
| 注意力:QKV→分数→掩码→Softmax→加权V | model.py CausalSelfAttention.forward() |
|
| MLP:扩展4倍→GELU→压缩回来 | model.py MLP.forward() |
|
| 算损失 | 交叉熵(logits, targets) | model.py 第190行 |
| 更新学习率 | 预热+余弦衰减 | train.py get_lr() |
| 反向传播 | 自动求导计算梯度 | train.py scaler.scale(loss).backward() |
| 梯度裁剪 | L2范数限制在1.0以内 | train.py clip_grad_norm_ |
| 参数更新 | AdamW优化器 | train.py scaler.step(optimizer) |
| 评估 | 200次batch取平均损失 | train.py estimate_loss() |
| 生成文本 | 循环:预测→采样→拼接→再预测 | model.py GPT.generate() |
更多推荐
所有评论(0)