源码地址: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()

更多推荐