跟着LAP啥都学 跟着LAP啥都学

第 5 章 预训练:从随机权重到连贯文本

      +

      本章导读:给第 4 章的模型补上损失函数、训练循环与解码策略三块组件, 完整跑一遍从随机初始化到生成连贯文本的流程,最后加载 OpenAI 官方权重, 验证整条实现链路的正确性。

      学完本章你将能够:

      1. 推导交叉熵与困惑度,并解释其作为生成质量度量的含义;

      2. 实现五步训练循环与训练/验证损失评估;

      3. 实现温度缩放与 Top-k 采样,理解二者对生成分布的影响;

      4. 完成权重的保存、加载与 OpenAI safetensors 权重的键名映射装载。

      产出与代码:chapters/chapter05_pretraining/(include/training.h、 include/dataloader.h、include/safetensors.h、src/main.cpp)。

      5.1 无标签数据的自回归训练

      预训练不需要人工标注数据。标签由数据自身提供:把输入右移一位,标签就 出来了——"every effort moves" 的预测目标是 " effort moves you"。 这个设定叫自回归:模型的任务永远是「预测下一个词元」。语言本身就是 「下一个词接什么」的序列,监督信号与数据的天然形态重合,因此免费且 无限。

      本章的五步流水线:

      Diagram

      这个任务有一个可以直接算出来的起点:随机初始化的模型在 50257 个词元里 猜下一个,猜中正确词元的概率是多少?概率质量分散在 5 万词表上,答案从 数学上就能得出。两个数字贯穿全章,先记住:

      • 地板 loss = \(\ln(V) \approx 10.82\):未训练模型对正确词元的概率约 1/50257, \(-\log(1/V) = \ln(V) \approx 10.82\)。这是所有训练的起点,也是能从数学上算出来 的起跑线;

      • 困惑度 \(\mathrm{PPL} = \exp(\mathrm{loss})\):loss 为 10.82 时,PPL ≈ 5 万——模型在约 5 万个词元里犹豫不决;loss 降到 1.7 时,PPL ≈ 5.5——候选范围已经 收窄。

      这个起跑线在本章后面会反复出现。它还有一个实用用途:检验数据管道 有没有 bug。

      5.2 手算交叉熵与困惑度

      未训练模型算出的第一个 loss 是 10.79。这个数是好是坏,要先弄清它的 计算过程。本节在 2×3 的小批次上把交叉熵手推一遍,再引入困惑度。

      手算一遍交叉熵

      以下代码(src/main.cpp 的 demo_loss)构造一个 2×3 的小批次, targets 是各自右移一位的结果:

      // inputs 两句话,targets 是各自右移一位的结果
      auto inputs  = torch::tensor({{16833L, 3626L, 6100L},   // "every effort moves"
                                    {40L,    1107L,  588L}},  // "I really like"
                                   torch::kLong);
      auto targets = torch::tensor({{3626L, 6100L,  345L},    // " effort moves you"
                                    {1107L,  588L, 11311L}},  // " really like chocolate"
                                   torch::kLong);

      前向 → softmax → 取目标词元概率 → 取对数 → 求均值 → 取负,得到平均对数 概率的负值——这正是交叉熵的定义。其中三步值得单独说明:

      1. 为什么取目标词元的概率:未训练模型对正确词元的概率约 1/50257 ≈ 2e-5 (初始随机权重把概率质量分散在 5 万词表上)——模型「猜中」目标词的概率, 就是衡量它离学会语言有多远的标尺;

      2. 为什么取对数:概率的对数比概率本身更容易操作——连乘变连加, 数值范围压缩;

      3. 为什么取负:优化的惯例不是「把平均对数概率升到 0」,而是 「把负平均对数概率降到 0」——这个负平均对数概率就叫交叉熵损失, 它衡量两个概率分布的差异:数据集词元的真实分布 vs 模型的预测分布。 实践中「交叉熵」与「负平均对数概率」经常互换使用。

      六步可以浓缩成一条公式:对批次内每个位置上目标词元的对数概率取负, 再求均值。

      \[\mathcal{L} = -\frac{1}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \log p_\theta\!\left(t_i \mid t_{<i}\right)\]

      这条公式对概率的惩罚不是线性的:模型把正确答案的概率压得越低, 损失涨得越狠,在对数轴下尤其直观。

      05 ce vs prob
      Figure 1. 公式图像:交叉熵 \(\mathcal{L} = -\log p\) 只关心「正确答案分到了多少概率」

      C++ 里取「目标位置的概率」的固定写法是先按 batch 维索引,再取对角线:

      auto probas = torch::softmax(logits, -1);               // (2, 3, 50257)
      // probas[0, :, [3626, 6100, 345]] 后取 diagonal —— 每个位置上目标词元的概率
      auto target_probas_1 = probas
          .index({0, torch::indexing::Slice(),
                  torch::tensor({3626L, 6100L, 345L})})
          .diagonal();
      auto log_probas = torch::log(torch::cat({target_probas_1, target_probas_2}));
      auto neg_avg = torch::mean(log_probas) * -1;            // 手工交叉熵

      生产代码直接用内置交叉熵,一步到位且数值更稳。calc_loss_batch 是 全章最核心的函数(include/training.h):

      inline torch::Tensor calc_loss_batch(const torch::Tensor& input_batch,
                                           const torch::Tensor& target_batch,
                                           ch4::GPTModel& model) {
          auto logits = model->forward(input_batch);
          auto loss = torch::nn::functional::cross_entropy(
              logits.flatten(0, 1),          // (batch*seq, vocab)
              target_batch.to(logits.device()).flatten());
          return loss;
      }

      flatten(0, 1) 把 (batch, seq, vocab) 展平成 (batch*seq, vocab),每个 位置的预测于是都变成一个独立的 50257 分类问题。这一步必须做:内置 交叉熵要求 logits 形如 [N, C]、targets 形如 [N],三维张量直接喂 进去会报形状错误。

      对损失再作一层解读,得到困惑度(perplexity)= \(\exp(\mathrm{loss})\):直觉上, 它是模型在每一步对多少个词元感到「不确定」。初始损失 10.79 对应困惑度 48,726——模型相当于在 4.9 万个词元里做选择。困惑度比原始损失更易于 解读:loss 从 10.79 降到 3.0 没有直观感受,困惑度从 48,726 降到 20 则一目了然。

      \[\mathrm{PPL} = \exp(\mathcal{L})\]

      exp 曲线的形状决定了读法:损失在高端每降一点,困惑度塌得飞快;到了 低端(损失 < 1)再往下压就越来越费劲。解读时先看损失落在曲线哪一段, 再评估困惑度变化的幅度:

      05 ppl vs loss
      Figure 2. 公式图像:\(\mathrm{PPL} = \exp(\mathcal{L})\)(标注本章实测的训练 0.671 / 验证 6.417 两点)

      遍历整个 DataLoader 求平均损失

      calc_loss_loader 把单批次损失扩展到整个数据集:torch::NoGradGuard 关闭梯度,model→eval() 切换评估模式,计算完用 model→train() 切回来:

      inline double calc_loss_loader(ch4::GPTModel& model,
                                     const std::vector<ch2::GPTBatch>& loader,
                                     c10::optional<int64_t> num_batches = c10::nullopt) {
          torch::NoGradGuard no_grad;
          model->eval();
          for (int64_t i = 0; i < n; ++i) {
              total_loss += calc_loss_batch(loader[i].inputs, loader[i].targets,
                                            model).item<double>();
          }
          model->train();                       // 评估完切回训练模式
          return total_loss / n;
      }

      对随机初始化的模型实测(demo_dataset_loss):The Verdict 在文本上按 90/10 切分训练/验证集、两侧各自分词(4612 / 534 个词元,边界词元被切开, 合计比全文的 5145 多 1);初始损失 train 10.99 / val 10.98,略高于地板 ln(50257)=10.82——随机初始化的 logits 并非均匀分布,平均交叉熵本来就会 比 \(\ln V\) 略高。量级对得上,说明数据管道没有 bug。 数值核对在后面的 章节会反复用到:理论上能算出来的数,实测必须对上;对不上就是代码 有问题。

      两个 DataLoader 的配置并不对称,每一处差异都有原因:

      参数 训练集 验证集 差异原因

      shuffle

      true

      false

      训练要打乱;验证要保持顺序(可复现)

      drop_last

      true

      false

      训练丢弃不足一批的尾巴;验证集保留全部数据

      batch_size

      2

      2

      数据集太小,用小批次减少计算需求

      规律:训练侧的所有配置都为「梯度质量」服务,验证侧的所有配置都为 「可比性」服务。同一份数据,两种读法。

      本章还把 context_length 从第 4 章的 1024 临时改为 256,减少计算量 让笔记本能跑完训练;5.6 节加载官方权重时再改回 1024。

      5.3 五步训练循环与 AdamW

      第 0 章 §H 的五步模板,在这里实现为完整的训练循环。完整的 PyTorch 训练循环是 8 步(epoch → batch → zero_grad → loss → backward → step → 评估监控 → 生成样本监控),核心计算只占前 6 步。下面的 train_model_simple 是任何 LLM 训练的最小骨架(include/training.h):

      inline void train_model_simple(ch4::GPTModel& model,
                                     const std::vector<ch2::GPTBatch>& train_loader,
                                     const std::vector<ch2::GPTBatch>& val_loader,
                                     torch::optim::AdamW& optimizer, int64_t num_epochs,
                                     int64_t eval_freq, int64_t eval_iter,
                                     const std::string& start_context,
                                     ch2::BpeTokenizer& tokenizer) {
          for (int64_t epoch = 0; epoch < num_epochs; ++epoch) {
              model->train();
              for (const auto& batch : train_loader) {
                  optimizer.zero_grad();                              // 1. 清梯度
                  auto loss = calc_loss_batch(batch.inputs, batch.targets, model);  // 2. 前向
                  loss.backward();                                    // 3. 反向传播
                  optimizer.step();                                   // 4. 更新参数
                  tokens_seen += batch.inputs.numel();                //    记账
                  if (global_step % eval_freq == 0) {                 // 5. 定期评估
                      auto [train_loss, val_loss] =
                          evaluate_model(model, train_loader, val_loader, eval_iter);
                      std::cout << "Ep " << (epoch + 1) << " (Step " << global_step
                                << "): Train loss " << train_loss << ", Val loss "
                                << val_loss << "\n";
                  }
              }
              generate_and_print_sample(model, tokenizer, start_context);  // 每轮末尾打印生成样本
          }
      }

      一个能跑通 GPT-2 预训练的循环,核心就是这五步。第 8 章加入的学习率 调度与梯度裁剪,都只是在这副骨架上扩展。

      main.cpp 用 AdamW(lr=4e-4, weight_decay=0.1) 跑 10 轮:

      torch::optim::AdamWOptions options(4e-4);
      options.weight_decay(0.1);
      auto optimizer = torch::optim::AdamW(model->parameters(), options);

      为什么是 AdamW:Adam 是训练深度网络的常见选择;AdamW 是它的变体, 改进了权重衰减(weight_decay)的实现方式——通过对较大的权重施加 惩罚来控制模型复杂度、防止过拟合,正则化更有效,泛化更好。这是 LLM 训练普遍使用 AdamW 的原因。model→parameters() 返回全部可训练参数 (含 weight 与 bias),正好是优化器需要的输入。

      Adam 与 AdamW 的差别值得单独说清:

      AdamW 与 Adam 的差别究竟在哪?在 Adam 里加 L2 正则(把 \(\lambda w\) 加进梯度) 并不能等效于真正的权重衰减:Adam 的更新步长会被二阶矩估计 \(\bar{v}\) 归一化, 而 \(\bar{v}\) 恰好也包含了正则项自身——结果是梯度越大的参数受到的正则越弱, 惩罚被自适应学习率「稀释」了。AdamW 的做法是把衰减从梯度里拆出来: 更新时直接执行 \(w \leftarrow w - \eta\, \lambda\, w\),与梯度大小、\(\bar{v}\) 完全无关。一句话: L2-in-Adam 把正则强度耦合进了梯度尺度,AdamW 把两者解耦—— 这就是论文标题 "Decoupled Weight Decay Regularization" 的含义。

      每轮结束的 generate_and_print_sample 是人工检查点:打印当前模型的 生成样本,肉眼确认训练在推进。样本的演化轨迹有明确规律——从只会 接逗号,到学会语法,最后逐字背诵原文:

      Ep 1:  Every effort moves you,,,,,,,,,,,,.          ← 只会接逗号
      Ep 2:  Every effort moves you, and, and, and, ...   ← 学到一点语法
      Ep 9:  ...quite insensible to the irony. She wanted
             him vindicated--and by me!...                ← 背诵原文(过拟合的直接证据)
      Ep 10: ...was one of the axioms he laid down across
             the Sevres and silver of an exquisitely
             appointed luncheon-table...                  ← 逐字复现训练集

      5145 个词元训练 124M 参数的模型——过拟合是必然的,val loss 先降后升 (最终 train 0.67 / val 6.42 的分叉)不是 bug,而是这个实验的教学目的: 让你直观看到「小数据 + 大模型 = 记忆」。过拟合的直接证据是生成的 quite insensible to the irony 逐字出现在 The Verdict 原文中。 规模核算:9 批/轮 × 10 轮 = 90 步,每步 512 词元 → 共见过 46,080 个 词元——数据集只有 5145 个,每个词元平均被看了 9 遍。 实践准则:在足够大的数据集上,预训练通常只训一轮。

      看到曲线分叉,先不要怀疑代码:本教程故意不做早停,让分叉和逐字复现 都出现在运行结果里。参数量是词元数的两万多倍,模型除了记忆 训练数据没有别的出路。

      预训练的真实代价(为什么「加载权重」是刚需):以 Llama 2 7B 为例—— 2 万亿词元、184,320 个 A100 GPU 小时,按 8×A100 服务器 30 美元/小时计 约 69 万美元(此为按 GPU 时数折算的估算口径,官方模型卡只公布 GPU 时数)。5.6 节加载 OpenAI 权重,省下的就是这个量级的成本。教程里 从零预训练是体验流程用的;实际项目中获取模型,加载现成权重是常规做法。

      5.4 解码策略:温度缩放与 Top-k 采样

      贪心解码(argmax)始终选择概率最高的词元,输出缺乏多样性——第 1 章 bigram 模型的 the the the 就是这种表现。要控制生成的随机性,需要 两个参数:温度与 Top-k。

      温度缩放:把 logits 除以 T

      用 9 词迷你词表做实验(demo_decode_strategies):

      auto next_token_logits = torch::tensor(
          {4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79});
      //        closer every effort forward inches moves pizza toward you

      multinomial 按原始概率采样 1000 次的分布,与三种温度下的概率对比:

      multinomial 采样 1000 次:  582 x forward, 343 x toward, 73 x closer, 2 x inches
      T=1.0   P(forward)=0.5721   P(pizza)=0.0001   // 原始分布
      T=0.1   P(forward)=0.9910   P(pizza)=0.0000   // 趋近 one-hot,确定性强
      T=5.0   P(forward)=0.2421   P(pizza)=0.0454   // 趋近均匀,"pizza"也有机会

      multinomial 替代 argmax 是概率采样:forward 仍是最可能的词元,大多数 (但不是每次)被选中——582/1000 = 58.2% 与真实概率 57.2% 吻合, 大数定律的现场演示。温度参数的三条结论:

      1. T = 1 相当于不做温度缩放(除以 1 就是原始 softmax);

      2. T < 1 → 分布更集中 → multinomial 几乎 100% 选最可能词元 → 接近 argmax 行为;

      3. T > 1 → 分布更均匀 → 增加变化,但更容易生成无意义文本 (T=5 时约 4.5% 概率生成 "every effort moves you pizza")。

      概括成一句:温度低,分布尖锐,输出稳定但单调;温度高,分布平坦, 输出多样但更容易不连贯。

      公式只有一行——除以 T 再 softmax,T=1 退化为原始分布:

      \[p_i = \frac{\exp(z_i / T)}{\sum_{j} \exp(z_j / T)}\]

      把实测表画出来,三种温度对同一组 logits 的重塑一目了然:

      05 temperature
      Figure 3. 公式图像:同一组 logits 在 T=0.5 / 1.0 / 2.0 下的概率分布

      Top-k:先截断再采样

      温度控制分布形状,Top-k 控制候选范围:把候选限制在概率最高的 k 个 词元里,其余置 −∞(include/training.h 的 generate):

      if (top_k.has_value()) {
          auto top_values = std::get<0>(torch::topk(logits, k));
          auto threshold  = top_values.narrow(1, k - 1, 1);      // 第 k 大的值
          logits = torch::where(logits < threshold,
                                torch::full_like(logits, -std::numeric_limits<double>::infinity()),
                                logits);
      }
      torch::Tensor idx_next;
      if (temperature > 0.0) {                                   // 温度采样
          auto probs = torch::softmax(logits / temperature, -1);
          idx_next = torch::multinomial(probs, 1);
      } else {                                                   // 贪心
          idx_next = torch::argmax(logits, -1, /*keepdim=*/true);
      }
      if (eos_id.has_value() && idx_next.item<int64_t>() == eos_id.value()) break;

      温度与 Top-k 组合使用:先截断(控上限),再按温度采样(控形状)。 eos_id 让模型自己输出结束符——第 6/7 章微调后会用到。Top-k 的公式 与因果掩码同构:k 名之外的 logit 全部置 −∞,softmax 后概率自动归零、 剩余项自动重新归一化。第 3 章的因果注意力掩码用的正是同一个机制:

      \[\tilde{z}_i = z_i \;\; (z_i \in \mathrm{TopK}(z)), \qquad \tilde{z}_i = -\infty \;\; (\mathrm{otherwise})\]
      05 topk
      Figure 4. 公式图像:Top-3 截断前后——灰色词元被丢弃,蓝色词元概率整体抬高

      两个跨章呼应与一个踩坑点:

      1. Top-k 的 −inf 掩码与第 3 章的因果注意力掩码是同一招——softmax 会把 \(e^{-\infty}\) 算成 0,被掩位置概率归零,剩余概率自动重新归一化为 1。 一处机制,两处使用;

      2. generate 的参数语义要读准:temperature=0.0 是默认贪心解码 (走 else 分支),top_k=None 不筛选,eos_id=None 不提前终止—— 三个默认值合起来就是第 4 章的 generate_text_simple;

      3. 踩坑:掩码比较的是 top_logits[-1](前 k 个里最小的那个), 不是某个固定阈值——如果 logits 有并列值,可能保留超过 k 个词元。

      贪心与采样的实际对比(训练后的 124M 模型):贪心解码给出的是 训练集中被逐字记住的段落("was one of the axioms he laid down…​"); top_k=25, temperature=1.4 生成全新的、未被记忆的文本 ("stand to work on surprise, a one of us had gone with random-")。 解码策略的意义:减少记忆、增加独创性。

      调试生成效果时的对应关系:输出过于单调,提高温度或增大 k;输出容易 不连贯,反向调整。四个组件各管一段——温度控制分布形状,Top-k 控制 候选范围,multinomial 执行采样,eos_id 提前终止。

      5.5 保存与加载权重

      训练完成的模型应当保存,避免每次使用都重新训练。LibTorch 的保存与 加载各用一行:

      torch::save(model, dir + "/model.pth");       // 整个模型(含 state_dict)
      ch4::GPTConfig cfg;
      cfg.context_length = 256;                     // 必须与训练时一致
      ch4::GPTModel model2(cfg);
      torch::load(model2, dir + "/model.pth");

      注意 cfg.context_length 必须与训练时一致,否则权重形状对不上或行为 异常。加载到新实例后,与原模型对同一提示各生成 5 个词元, t1.equal(t2) 输出 yes——权重往返无损。model.pth 约 622 MB (163,009,536 个参数 × 4 字节)。动机很直接:预训练代价高昂,保存 权重才能避免重复训练。

      只保存模型权重并不完整。AdamW 这类自适应优化器会为每个参数维护额外 状态,续训时必须一并恢复:

      为什么保存优化器状态也很重要:AdamW 这类自适应优化器为每个模型权重 存储额外的参数(动量等),用历史数据动态调整每个参数的有效学习率。 如果不保存,优化器重置,续训可能效果不佳甚至无法正确收敛—— 续训后的模型可能失去生成连贯文本的能力。Python 的标准做法是把 model.state_dict() 与 optimizer.state_dict() 打包进同一个 checkpoint (恢复时必须先建优化器实例再 load_state_dict)。

      保存的复杂度不在 IO,而在恢复目标:推理只需要权重,续训还需要 优化器状态。C++ 侧另有一个平台限制:

      C++ 版 torch::save(model, path) 保存整个模块;LibTorch 没有保存 优化器状态的对应接口——要续训得自己序列化。这也是第 10 章把「训练」与 「推理」拆成两个程序的原因之一:推理程序只需要权重,不需要优化器。

      5.6 加载 OpenAI 预训练权重

      这一步是本章的综合检验:把 HuggingFace 版 GPT-2 权重(与 OpenAI 原版 同数值)装进第 4 章手写的 GPTModel,生成连贯英文。工程拆成三层: 格式解析、键名映射、形状翻转。

      safetensors 格式解析

      第一层是格式解析。include/safetensors.h 实现了一个 60 行的解析器, 格式只有三段:

      [8 字节 uint64 LE]  header 长度 N
      [N 字节 JSON]       {"wte.weight": {"dtype":"F32","shape":[50257,768],
                                            "data_offsets":[0,154389504]}, ...}
      [数据区]            各张量按 data_offsets 连续存放(float32, row-major)

      不需要任何第三方库:std::ifstream 读进来,torch::from_blob 包一下 就是张量。

      键名映射 + 形状翻转

      真正的难点在第二、三层:两边的参数命名不一致,需要一张映射表 (src/main.cpp 的 map_key):

      我们的 GPTModel HuggingFace gpt2 形状处理

      tok_emb.weight

      wte.weight

      同形 [50257,768]

      pos_emb.weight

      wpe.weight

      同形 [1024,768]

      att.W_query/key/value→weight

      h.N.attn.c_attn.weight

      split 成 3 份 + 转置

      att.out_proj→weight

      h.N.attn.c_proj.weight

      转置

      ff→layers 第 0/2 层

      h.N.mlp.c_fc/c_proj.weight

      转置

      norm1/norm2/final_norm

      h.N.ln_1/ln_2、ln_f

      同形 [768]

      out_head.weight

      wte.weight

      weight tying,再赋一次

      最关键的形状翻转:HF 的 GPT-2 用 Conv1D,权重存成 [in, 3*emb]—— Q/K/V 三合一;我们的 nn::Linear 权重是 [out, in]。所以要先按 最后一维切成三份、再各自转置:

      auto c_attn_w = params("blocks." + std::to_string(b) + ".attn.c_attn.w");
      auto split = c_attn_w.split(cfg.emb_dim, /*dim=*/1);        // [in,emb] × 3
      block.att->W_query->weight =
          assign(block.att->W_query->weight, split[0].t().contiguous());
      block.att->W_key->weight =
          assign(block.att->W_key->weight, split[1].t().contiguous());
      block.att->W_value->weight =
          assign(block.att->W_value->weight, split[2].t().contiguous());

      整套装载的可靠性依赖一个叫 assign 的小工具:形状不匹配立刻抛异常, 且必须在 NoGradGuard 下 copy_(参数是 requires_grad 的叶子张量, 直接赋值会报错,等价 Python 的 with torch.no_grad(): param.copy_(…​)):

      auto assign = [](torch::Tensor left, const torch::Tensor& right) {
          if (left.sizes() != right.sizes())
              throw std::runtime_error("Shape mismatch");
          { torch::NoGradGuard g; left.copy_(right); }
          return left;
      };

      它防的是静默失败:形状不匹配却不报错时,模型照常运行、输出错误结果, 排查难度远高于直接崩溃。装载代码的原则:让失败显式、尽早地暴露。

      加载时切换到 OpenAI 原生配置:context_length = 1024、 qkv_bias = true。第 4 章的 GPTConfig 提供这两个开关,此处取 OpenAI 原生值:不改前者,位置嵌入会越界;不改后者,权重里多出来的偏置没有 地方放。最后 out_head→weight = assign(…​, params("wte")) 完成 weight tying——输出层与词嵌入共享同一份权重,显式还原第 4 章参数 核算时讨论的那份共享。

      5.7 编译运行与预期输出

      ./scripts/run.sh chapter05_pretraining           # 默认 10 轮训练
      ./scripts/run.sh chapter05_pretraining 0         # epochs=0:跳过训练,只做评估/解码/加载

      数据目录默认 data/(the-verdict.txt + encoder.json + vocab.bpe
      gpt2-model.hf.safetensors,均由第 0 章脚本自动下载)。关键输出(节号为程序内的演示序号,与本章小节号略有错位):

      === 5.1.2 计算文本生成损失
      Loss (cross_entropy): 10.79
      Perplexity: 48726
      
      === 5.1.3 计算训练集和验证集的损失
      train_data tokens: 4612, val_data tokens: 534
      Train loader batches: 9
      Training loss: 10.99
      Validation loss: 10.98          ← 略高于地板 ln(50257)≈10.82(随机 logits 非均匀所致)
      
      === 5.2 训练大语言模型
      Ep 1 (Step 0): Train loss 9.72, Val loss 10.02
      ...(train 一路下降,val 先降后升)
      train 范围 9.72 → 0.67,val 范围 10.02 → 6.42
      Every effort moves you as Herm the one of the axioms he laid down...   ← 每轮末尾的生成样本人工检查
      
      === 5.4 保存与加载
      已保存模型权重到 data/model.pth(约 622 MB)
      加载后生成与训练模型一致: yes
      
      === 5.5 从 OpenAI 加载预训练权重
      Output text:
       Every effort moves you toward finding an ideal new way to practice ...

      注:训练轮数与显存不同会导致 loss 曲线细节略有差异,但 「起点 ≈ 11、train 降到 1 以内、val 反弹到 6–7」的形态应当一致。

      生成出连贯英文,加载正确性就有了最终检验。整个装载过程由五部分组成: 格式解析(60 行)、键名映射(一张表)、形状翻转(split + 转置)、 配置切换(两处)、assign 形状校验(不匹配立即抛错)。

      本章小结

      • 交叉熵(负平均对数概率)衡量预测分布与真实分布的差异;困惑度 \(\exp(\mathrm{loss})\) 是更直观的等价表述;未训练模型的 loss ≈ ln(50257) ≈ 10.82,是能从数学上算出来的起跑线,也是数据管道的核对基准。

      • 训练循环五步骨架(清梯度 → 前向 → 反向传播 → 更新 → 定期评估)适用于一切 LLM 训练;AdamW 把权重衰减从梯度尺度中解耦,是 LLM 训练的正则首选。

      • 5145 词元 × 124M 参数必然过拟合:train/val 分叉、逐字背诵原文、「每个词元被看 9 遍」都是实验的教学目的;大数据集上预训练通常只训一轮。

      • 温度与 Top-k 两个解码参数分别控制分布形状与候选上限;−∞ 掩码与第 3 章因果注意力同源。

      • 权重保存要连优化器状态一起考虑(622 MB 的 model.pth + checkpoint 设计);官方权重经 safetensors 解析、键名映射、Conv1D split + 转置、qkv_bias 切换与 weight tying 装入自研模型;assign 做形状校验,形状不匹配立即抛错。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. 未训练模型 loss ≈ 11.0,与 ln(50257)≈10.82 量级对上(略高属正常; 数据管道无 bug 的证明)

      2. 9 个训练批次、train/val 切分 90/10;两 DataLoader 的 shuffle/drop_last 差异说得出理由

      3. 交叉熵手算六步说得顺:前向 → softmax → 取目标概率 → 取对数 → 均值 → 取负

      4. 10 轮训练后 train loss 进入 1 以下、val loss 明显反弹(过拟合现象直接确认)

      5. 生成的 "quite insensible to the irony" 能在 The Verdict 原文中找到 (记忆的直接证据)

      6. T=0.1 时 P(forward)≈0.99,T=5 时 pizza 也有非零概率

      7. 能说出 AdamW 与 Adam 的差别是「解耦权重衰减」以及稀释问题出在哪

      8. model.pth 约 622 MB,加载后生成与原模型完全一致(yes)

      9. 说得出为什么只存权重不够——优化器状态丢了会怎样

      10. 官方权重生成连贯英文,无乱码(键名映射 + 形状翻转全部正确)

      习题

      1. 把 num_epochs 改成 1 和 20 各跑一次,观察 val loss 的「先降后升」拐点在哪里。

      2. 把 top_k 改成 1(退化为贪心)、temperature 改成 0.5,对比生成文本的多样性。

      3. 把训练/验证比例改成 50/50,val loss 会怎样变化?为什么?

      4. 在 generate 里传入 eos_id(<|endoftext|> 的 ID 是 50256),验证生成提前停止。

      5. 把 evaluate_model 里的 eval_iter 加大到全部批次,观察评估值与 eval_iter=5 时的偏差——「用少量批次快速估算损失」的精度代价。

      6. (思考)为什么加载官方权重时把 context_length 改回 1024 的同时 必须改 qkv_bias=true?两处不改分别会出什么事?

      拓展阅读