本文是「GPT 训练分步学习路径」的第一步,基于
GPT_teacher-3.37M-cn这个教学项目,讲清楚「喂给模型的训练数据到底长什么样」。文中所有示例均来自本项目真实的data/train.jsonl和src/data.py。分步学习路径共五步:① 看懂数据长什么样 → ② 模型核心(注意力+因果掩码)→ ③ 训练循环 → ④ 验收评估 → ⑤ 推理与部署。本文是第一步。
目录
- 这一步属于哪个环节
- 原始数据长什么样
- 从一条问答到一串数字:拼接
- 输入与目标:右移一位的秘密
- -100 是什么:让模型只学答案
- 真实数据实测:一条样本的完整对齐表
- 超长怎么办:截断策略
- 组批:collate 如何对齐一个 batch
- 核心结论速览
1. 这一步属于哪个环节
整个项目的全流程是:
1 | 构建分词器 → 数据处理 → 训练 → 验收 → Web 演示 |
本文讲的是 数据处理 环节,对应代码 src/data.py。它的作用只有一个:
把人类能读的问答对(
prompt/completion),加工成模型能吃的两条等长数字序列 —— 输入x和 目标y。
这一步是「构建分词器」的下游(要用分词器把文字变数字),也是「训练」的上游(训练循环直接吃它产出的 x / y)。
2. 原始数据长什么样
打开 data/train.jsonl,每一行就是一条 JSON,一个问答对:
1 | {"prompt": "你知道什么是因果掩码?", "completion": "下三角矩阵,把未来位置权重设为负无穷,确保模型只看到历史信息。"} |
prompt:用户的问题。completion:期望模型给出的答案。
这就是所谓的 指令微调(instruction tuning) 数据格式:一问一答,教模型「看到这样的问题,该这样回答」。
3. 从一条问答到一串数字:拼接
模型不认识文字,只认识数字 id。src/data.py 里 InstructDataset.__init__ 把一条问答拼成一串 id:
1 | prefix = [self.tok.bos_id] + self.tok.encode("用户:" + prompt + "\n助手:", add_special_tokens=False) |
拆开看,一条样本被拼成这样的结构:
1 | [BOS] 用户: {问题} \n助手: {答案} [EOS] |
[BOS]:开始符(begin of sentence),告诉模型「一段话开始了」。用户:...\n助手::把问题包进一个固定模板,模型靠这个格式知道「现在轮到助手回答了」。comp:答案本身。[EOS]:结束符(end of sentence),告诉模型「答到这里就停」。
我们把 [BOS] + 用户:问题\n助手: 这一整段叫 prefix(前缀),它是「已知条件」;把答案 comp 叫「要学着生成的部分」。这个区分是下面所有逻辑的基础。
4. 输入与目标:右移一位的秘密
GPT 的训练目标是 预测下一个 token:看着前面所有字,猜下一个字是什么。所以对同一串 ids,要造出两条序列:
1 | tar = ids[1:] + [self.tok.eos_id] |
- 输入
ids:[A, B, C, D] - 目标
tar:[B, C, D, EOS]—— 就是把输入整体 左移/右错一位。
对齐起来看:
| 位置 | 输入(模型看到) | 目标(要预测的下一个) |
|---|---|---|
| 0 | A | B |
| 1 | B | C |
| 2 | C | D |
| 3 | D | EOS |
也就是「在位置 i 看到输入的第 i 个字,去预测第 i+1 个字」。这就是所谓的 teacher forcing:训练时不用模型自己上一步的输出,而是直接拿标准答案的前一个字当输入,效率高、收敛稳。
5. -100 是什么:让模型只学答案
现在有个问题:输入里既有「问题」又有「答案」,我们只想让模型学会写答案,不想让它去背问题(问题是用户给的,不需要模型生成)。
怎么办?用 -100 把「问题部分」的目标屏蔽掉:
1 | ignore = min(max(0, len(prefix) - 1), len(tar)) |
-100是 PyTorchCrossEntropyLoss(ignore_index=-100)约定的「忽略暗号」。目标位置只要是-100,这个位置就 不计算损失、不参与训练。- 注意它和数值 100 毫无关系,纯粹是个约定的特殊标记。
为什么是 len(prefix) - 1(减 1)?
因为目标 tar 已经整体右移了一位。原本前缀占 [0, len(prefix)-1] 这些输入位置,右移后「预测答案第一个字」这件事落在了索引 len(prefix)-1 上 —— 这个位置我们要保留(它负责生成答案的第一个字)。所以只屏蔽它前面的 len(prefix)-1 个位置。
一句话:问题段的目标全设成 -100(不批改),答案段保留真实目标(要批改)。模型只为「答案答得对不对」挨罚。
6. 真实数据实测:一条样本的完整对齐表
光看代码抽象,我们拿项目里第一条真实数据跑一遍(用真实分词器),看看到底长什么样。
样本:prompt = "你知道什么是因果掩码?",completion = "下三角矩阵,把未来位置权重设为负无穷,确保模型只看到历史信息。"
实测结果:prefix 长度 = 8,拼完 ids 总长 = 15,ignore = 7。对齐表如下(截取关键部分):
1 | 索引 输入(ids) 目标(tar) 是否算损失 |
从这张表能一眼看懂三件事:
[0]~[6]目标全是 -100:问题段不参与训练,模型不会去”背题目”。[7]是第一个要学的答案位置:输入是助手:的最后一个 token,目标是答案的第一个字「下三角」—— 这正是「看到助手:就该开口答」的能力。- 最后一位目标是
EOS:模型还要学会「答完就停」,否则会啰嗦不止。
提示:想自己复现这张表,可以用项目里的分词器对这条样本走一遍
InstructDataset的处理逻辑打印出来。
7. 超长怎么办:截断策略
模型有最大长度限制(本项目 seq_len=128)。如果一条问答拼完超过了这个长度,src/data.py 会截断:
1 | if len(ids) > seq_len: |
策略很有讲究:
- 优先保留完整的问题(prefix),砍掉过长的答案尾巴 —— 因为问题不完整的话,模型根本无从答起。
- 只有当问题本身就超长、连 prefix 都放不下时,才退而砍前缀。
- 无论怎么截,结尾都补上
[EOS],保证「结束」这件事永远被学到。
8. 组批:collate 如何对齐一个 batch
训练时是一批(batch)样本一起算的,但每条样本长度不一,得先「对齐」成同样长度,才能堆成一个张量。这就是 collate 干的事:
1 | def collate(batch, seq_len, pad_id): |
两个关键点:
- 输入
x用pad_id补齐:补的是「填充符」,占位用,不影响语义。 - 目标
y用-100补齐:这些填充位置同样不算损失 —— 又一次用上了-100这个忽略暗号。
补齐后,一个 batch 就是形状 [batch_size, seq_len] 的整齐张量,可以直接喂进模型了。
9. 核心结论速览
- 数据处理是全流程第二环节,作用是把问答对加工成模型能吃的输入
x和目标y。 - 原始数据是
jsonl,每行一条{prompt, completion}问答对(指令微调格式)。 - 一条样本被拼成
[BOS] 用户:问题\n助手: 答案 [EOS],前半段叫 prefix。 - 目标
tar是输入右移一位得到的(预测下一个 token,teacher forcing)。 - 用
-100屏蔽问题段目标,让模型 只学答案、不背题目;-100是忽略暗号,与数值无关。 - 屏蔽长度是
len(prefix)-1(因为目标右移了一位,答案第一个字的预测位要保留)。 - 超长时优先砍答案、保住问题,且结尾永远补
[EOS]。 collate把一个 batch 补齐成等长张量:输入补pad_id,目标补-100。
一句话:这一步就是把「人话问答」翻译成「模型作业本」——输入是题面,目标是标准答案,-100 圈出「只批改答案区、不批改题目区」。
下一步预告(第二步):模型核心。我们会打开
src/model.py,看注意力是怎么算 Q·K 的、因果掩码长什么样,以及模型如何一步步把这些 id 变成「下一个字的预测」。