主题
第 17 章 先把 GPT 的训练题出对
给模型一段文字,让它猜下一个字符,这件事我们已经做过。现在要改变的不是题目,而是模型能利用的上下文:bigram 只看前一个字符,Transformer 要从前面的一串字符里选择有用信息。在安装注意力模块以前,先把数据、标签和损失的对应关系钉牢。否则模型可能跑得很快,却一直在解另一道题。
本章到第 20 章对应课程《Let's build GPT: from scratch, in code, spelled out》;本书沿用任务及构建顺序,使用原创小语料和缩小的 CPU 模型。原课以 tiny Shakespeare 为数据,不是本书实验语料。课程与官方章节;固定版课程代码。
17.1 一个字符对应一个编号,编号本身没有大小含义
先取训练文本中出现过的不同字符,排序后编号。假设文本只有 a、b、c 三个字符,字典可以是 a→0, b→1, c→2。0 不比 2 更接近“正确答案”;它们只是查表地址。模型随后用 embedding,也就是可训练的向量表,将编号变成特征。
这里的“字符”具体指 Python 字符串迭代得到的 Unicode 码点。它不一定等于人看到的一个字形;组合重音和 emoji 序列是反例。这个问题留到第 21 章处理。字符字典也有一个直接限制:训练时没出现的码点没有编号。给 encode 输入这样的字符会报错,不能假装任意文本都能编码。
python
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = dict(enumerate(chars))
data = torch.tensor([stoi[ch] for ch in text], dtype=torch.long)为什么用 long?Embedding 要的是整数索引,不是浮点数特征。字典也要随着模型一起保存。同一段权重若配了另一份字符排序,程序未必报错,但每个编号的含义已经换了。
原课先读文件、查看长度和前一段文字,再检查字符表。这个步骤很像后端工程里验输入协议:换行是否保留,是否混进 HTML,文件编码是否正确,都会改变实际训练对象。字符数量不等于 UTF-8 字节数量;文本清洗不能只看文件大小。PyTorch 2.6 Embedding
17.2 一段长度为 T 的输入,其实出了 T 道题
设完整编号序列为
输入和标签都长 abcd,取 abc,标签是 bcd。
| 输出位置 | 允许看到的输入 | 要预测的字符 |
|---|---|---|
| 0 | a | b |
| 1 | ab | c |
| 2 | abc | d |
因此,训练上下文不是只有完整的 abc。一个窗口同时训练了长度为 1、2、3 的前缀。模型会一次处理整个输入张量,但稍后安装的因果遮罩必须保证每个位置只看到对应前缀。否则位置 0 直接读到输入位置 1 的 b,答案就在桌面上。
若每批取 (B,T)。B 是独立样本的条数,T 是每条序列中的位置数。随机起点必须满足 torch.randint(N-T, (B,)) 的上界不包含在内,恰好覆盖
python
starts = torch.randint(len(data) - T, (B,))
x = torch.stack([data[s:s+T] for s in starts])
y = torch.stack([data[s+1:s+T+1] for s in starts])批量处理让这
17.3 切分数据的时间比抽窗口早
原课按文本顺序把前 90% 分给训练,后 10% 分给验证,再在各自区域内部抽窗口。这样同一窗口不会跨越切分边界。如果先从整篇文章随机抽大量重叠窗口,再将窗口随机分组,训练和验证可能含有几乎相同的片段,验证结果会过于乐观。
连续切分也不是万能保证。我们的实验语料是反复重复的两句话,即使两边没有重叠位置,内容仍然重复。因此本书的小实验只验证代码能够优化损失,不能证明模型学会了语言,更不能用它比较预训练模型的能力。真实项目还应按文档、作者、时间或数据来源去重和切分,具体选哪种取决于上线时要面对什么新数据。
17.4 bigram 是一张可训练的“下一项打分表”
词表大小记为 nn.Embedding(V,V):输入编号选中一行,行内的 (B,T) 的输入变成 (B,T,V) 的输出。模型参数是
对某个位置的 logits 向量
这个式子等于正确答案概率的负对数。正确答案概率为 1 时损失为 0;若所有答案都是
PyTorch 的交叉熵接收 logits,内部完成数值稳定的归一化。不要先 softmax 再传给 cross_entropy。我们的目标编号不是 one-hot,形状是 (B*T,);对应 logits 要整理成 (B*T,V):
python
loss = F.cross_entropy(logits.reshape(B*T, V), targets.reshape(B*T))这里把“第几批、第几个位置”合成一个样本轴,不会改变题目;只要 logits 与 targets 的展平次序一致即可。默认 reduction 为 mean,因此损失对全部有效位置取平均。生成阶段只取最后一个位置的 logits,训练阶段则利用所有位置的监督。CrossEntropyLoss 官方说明
17.5 训练与生成怎样使用同一个模型
训练时已经知道后文。输入真实前缀,计算真实下一项的损失,反向传播后更新参数。一次迭代的次序是:取批次、前向、清梯度、反向、更新。PyTorch 的梯度默认累加,所以省去清梯度会改变算法;zero_grad(set_to_none=True) 将旧梯度置空,让本次反向重新建立梯度。
生成时没有答案。把当前序列送进模型,取最后位置的概率分布,采样一个新编号,拼回序列,再预测下一个。若永远取 argmax,会得到确定性的贪心解码;torch.multinomial 则按概率抽样。采样不是随机乱选:低概率字符较少出现,但可以出现。固定随机种子是为了重现实验,不是为了改善模型。
bigram 即使收到了长前缀,最后的预测仍只取决于最后一个字符,因为它只有查表操作。下一章加入的注意力,才会让前面多个字符真正影响最后的表示。
17.6 动手验证:先检查数据,再看损失
完整 CPU 示例在 examples/part3/tiny_gpt.py。从代码包根目录运行:
bash
python examples/part3/tiny_gpt.py --steps 120另一个独立小检查 check_baseline.py 在重复的 0→1→2 序列上训练 bigram 100 步,损失由 1.137481 降到 0.008833;其最终采样仍有一个偏离循环的 2,说明低损失不等于随机采样绝不犯错。
完整 GPT 示例固定 seed 为 1729,CPU 线程为 1,使用 PyTorch 2.6.0+cpu;语料为重复的 the cat sat. the dog ran.,含换行。词表为 14,批量为 16,上下文为 16。后面三章会逐块解释其实现,本章先检查两条不变量:y[b,:-1] 必须等于 x[b,1:];张量中的所有编号必须小于词表大小。
练习与答案
- 补充题: 若
,合法起点有几个?最后一个标签读到哪里? - 补充题: 为什么把标签直接写成
y=x可能得到很低的损失,却不构成正确的下一字符模型? - 课程 EX2 的起步: 改成整数加法数据时,怎样只对答案计损失?先不要训练计算器,先写出一条样本的输入、标签与忽略位置。
答案。 第一题有 6 个起点,分别为 0 到 5;最后一个窗口的最后标签是 12+7=19 的错位序列,并把只负责提示题目的标签位置设为 ignore_index。PyTorch 默认 ignore_index 为 -100;原课程练习建议 -1 时,必须显式传 ignore_index=-1,不能只改标签。不忽略等号之后的第一个答案目标。该练习后续还需按数值组合拆分训练和测试,防止仅记住题库。
本章主要来源:课程原视频与勘误;固定版课程代码;PyTorch 2.6 交叉熵。实现和实验差异已在对应小节注明。