第5章:在无标注数据上预训练

学习目标

  • 写出语言模型的交叉熵损失,理解为什么它就是”困惑度”的对数。
  • 实现一个完整的训练循环:前向、损失、反向、优化、评估。
  • 掌握采样阶段的温度、top-k、top-p 三种主流策略。
  • 知道如何加载 OpenAI 公开的 GPT-2 权重。

5.1 损失函数:next-token 交叉熵

预训练的目标极其简洁:让模型对序列的下一个 token 给出尽可能高的概率。形式化为最大似然:

$$\mathcal{L}(\theta) = -\frac{1}{B \cdot T}\sum_{b=1}^{B}\sum_{t=1}^{T} \log P_\theta(x_{t+1}^{(b)} \mid x_{1:t}^{(b)})$$

PyTorch 实现:

import torch
import torch.nn.functional as F

def loss_batch(model, x, y):
    """
    x: (B, T)  输入 ids
    y: (B, T)  目标 ids(输入右移一位)
    """
    logits = model(x)                                  # (B, T, V)
    loss = F.cross_entropy(
        logits.flatten(0, 1),                          # (B*T, V)
        y.flatten(0, 1),                               # (B*T,)
    )
    return loss

困惑度(Perplexity)= $\exp(\mathcal{L})$,可以理解为”模型平均在多少个候选 token 之间犹豫”。完全随机猜的困惑度等于词表大小 $V$,越低越好。

5.2 训练循环骨架

import time

def train(model, train_loader, val_loader, optimizer, device,
          num_epochs=1, eval_freq=200, eval_iter=20):
    train_losses, val_losses = [], []
    seen = 0

    for epoch in range(num_epochs):
        model.train()
        for step, (x, y) in enumerate(train_loader):
            x, y = x.to(device), y.to(device)

            optimizer.zero_grad()
            loss = loss_batch(model, x, y)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)  # 防梯度爆炸
            optimizer.step()

            seen += x.numel()
            if step % eval_freq == 0:
                tr = evaluate(model, train_loader, device, eval_iter)
                va = evaluate(model, val_loader,   device, eval_iter)
                train_losses.append(tr); val_losses.append(va)
                print(f"step {step:5d} | train {tr:.3f} | val {va:.3f}")

    return train_losses, val_losses


@torch.no_grad()
def evaluate(model, loader, device, n_batches):
    model.eval()
    total = 0.0
    for i, (x, y) in enumerate(loader):
        if i >= n_batches: break
        x, y = x.to(device), y.to(device)
        total += loss_batch(model, x, y).item()
    model.train()
    return total / max(n_batches, 1)

几个新手常踩的坑

  1. 忘记 optimizer.zero_grad()——梯度会在 batch 间累加,loss 看起来”很好”但实际是错的;
  2. 忘记 .to(device)——把 GPU 模型和 CPU 数据混用会报错;
  3. eval 时忘记 @torch.no_grad()——白白占显存;
  4. eval 时忘记 model.eval()——dropout 仍在生效,损失会偏高。

5.3 优化器与超参数

GPT-2 / GPT-3 系列都使用 AdamW

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=3e-4,
    weight_decay=0.1,
    betas=(0.9, 0.95),
)

经验值:

  • lr 在 $1\text{e-}4$ 到 $6\text{e-}4$ 之间,越大越省训练时间但越容易炸;
  • weight_decay 0.1,但不要给 LayerNorm 参数和 bias 加 decay(参考附录 D);
  • batch tokens 越大越稳,主流做法是用梯度累积凑出 0.5M~4M tokens/step。

5.4 文本生成:从 logits 到字符串

训练好的模型本质上是一个”概率发生器”。生成一段新文本,需要反复执行:

当前序列 ──模型──► logits[-1] ──采样策略──► 下一个 id ──追加到序列 ──循环

最朴素的版本(贪心解码):

@torch.no_grad()
def generate_greedy(model, idx, max_new_tokens, context_size):
    model.eval()
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -context_size:]          # 截断到上下文窗口
        logits = model(idx_cond)[:, -1, :]         # 只看最后一个时间步: (B, V)
        next_id = logits.argmax(dim=-1, keepdim=True)
        idx = torch.cat([idx, next_id], dim=-1)
    return idx

贪心解码的输出极其单调,所以实践中我们用三种”加噪”手段:

5.4.1 温度采样

把 logits 除以温度 $\tau$ 后再 softmax:

$$P(x) = \mathrm{softmax}(\text{logits} / \tau)$$

  • $\tau \to 0$:等价于贪心;
  • $\tau = 1$:原始分布;
  • $\tau > 1$:分布更平、更”有创意”,但更容易胡说。

5.4.2 Top-k 采样

只在概率最高的 k 个 token 中采样,把其余 logits 设为 $-\infty$。常用 k=40 或 50。

5.4.3 Top-p(Nucleus)采样

按概率从大到小累加,直到累计概率超过阈值 p(如 0.9),只在这个集合内采样。优点是集合大小自适应:分布尖锐时集合小,分布平坦时集合大。

def sample_with_temperature_topk(logits, temperature=1.0, top_k=None):
    if top_k is not None:
        v, _ = torch.topk(logits, top_k)
        logits = torch.where(logits < v[..., -1, None],
                             torch.full_like(logits, float("-inf")),
                             logits)
    probs = torch.softmax(logits / temperature, dim=-1)
    return torch.multinomial(probs, num_samples=1)

5.5 加载官方 GPT-2 权重

OpenAI 在 GPT-2 的官方 TF 实现里发布了 124M / 355M / 774M / 1558M 四个尺寸的权重。原仓库提供了 gpt_download.py,把 TF checkpoint 转换成 numpy 数组后,按字段映射到我们写的 GPTModel

加载完成后,可以直接:

out = generate(model,
               tokenizer.encode("Once upon a time"),
               max_new_tokens=50,
               temperature=0.8,
               top_k=40)
print(tokenizer.decode(out))

如果你的实现正确,输出会出现”语义连贯”的英文段落——这就是预训练赋予模型的能力。

5.6 关于”完整复现 GPT-2 预训练”的现实

完全从零预训练一个 124M 模型,在主流公开语料(OpenWebText 等)上需要:

  • ~8B tokens;
  • 单张 A100 上约 1 周;
  • 8 卡 A100 约 1 天。

这超出了大多数读者的硬件预算。课程中我们只用一两本古登堡公版书做”形式上的训练”(约 100k tokens),目标是验证训练流程跑得通,而不是得到一个有用的模型。要拿到能生成连贯文本的模型,请直接加载官方权重。

检查清单

  • 我能在不查文档的情况下写出 next-token 交叉熵的实现。
  • 我理解贪心、温度、top-k、top-p 四种解码策略的差异。
  • 我能把 OpenAI 的 TF 权重映射到自己的 GPT 实现。

练习题

  1. 在同一段提示上比较温度 $\tau \in {0.2, 0.7, 1.2}$ 的输出,写下你对”创意 vs 一致性”权衡的观察。
  2. 自己实现 top-p 采样(不要用现成库),并验证当 $p=1.0$ 时它退化为温度采样。
  3. 给训练循环加一个”余弦学习率衰减 + 线性热身”的 scheduler,对比 loss 曲线(参考附录 D)。

📖 第5章补充材料 → — 学习率调度、GPT权重加载、GPT→Llama架构转换


← 上一章 · 返回目录 · 下一章 · 分类微调 →