minimind/course/05_sft_dataset_and_labels.md

556 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 第 5 课:SFT 数据、Prompt 与 Labels
这一课只解决一个问题:SFT 数据里的 `conversations` 如何变成模型训练用的 `input_ids` 和 `labels`。
## 目录
- [0. 本节主线](#l05-mainline)
- [1. 本节要懂的 5 个原理](#l05-principles)
- [2. 变量流转](#l05-flow)
- [3. 原理一:SFT 样本格式](#l05-sft-format)
- [4. 原理二:conversations 如何变成 prompt](#l05-conversations-prompt)
- [5. 原理三:labels 为什么要 mask](#l05-label-mask)
- [6. 原理四:padding 和 truncation](#l05-padding-truncation)
- [7. 原理五:loss 如何使用 labels](#l05-loss-labels)
- [8. 实验验证](#l05-experiment)
- [9. 本节检查](#l05-check)
- [10. 下一课](#l05-next)
<a id="l05-mainline"></a>
## 0. 本节主线
SFT 数据处理的本质是:
```text
读取 conversations
-> 用 chat template 渲染成 prompt
-> tokenizer 编码成 input_ids
-> 构造 labels
-> user/system/padding 位置设为 -100
-> assistant 回复位置保留 token id
-> 交给 causal LM loss 训练 assistant 回复
```
这条链对应本节的 5 个原理:
```text
SFT 样本格式 -> prompt 构造 -> labels mask -> padding/truncation -> loss 如何使用 labels
```
本节不是讲“怎么训练出好模型”,而是讲清楚 SFT 数据在进入模型前到底变成了什么。
<a id="l05-principles"></a>
## 1. 本节要懂的 5 个原理
| 原理 | 要理解什么 | 源码证据 |
|---|---|---|
| SFT 样本格式 | SFT 数据以多轮 `conversations` 表达 user/assistant 消息 | `course/labs/tiny_sft.jsonl:1-3`, `dataset/lm_dataset.py:59-66` |
| prompt 构造 | conversations 先经过 chat template 变成纯文本 prompt | `dataset/lm_dataset.py:71-86` |
| labels mask | SFT 不是所有 token 都算 loss,只训练 assistant 区域 | `dataset/lm_dataset.py:65-66`, `dataset/lm_dataset.py:88-104` |
| padding/truncation | prompt 被截断/补齐到固定长度,padding 不参与 loss | `dataset/lm_dataset.py:106-119` |
| loss 使用 labels | 模型 forward 会 shift logits/labels,并忽略 `-100` | `model/model_minimind.py:245-253`, `trainer/train_full_sft.py:27-37` |
学完本节,你应该能看懂一条 SFT 样本中哪些 token 参与训练,哪些 token 被忽略。
<a id="l05-flow"></a>
## 2. 变量流转
把本节主线对应到变量:
```text
sample: jsonl 里的一条样本
conversations: [{"role": "...", "content": "..."}]
prompt: chat template 渲染后的字符串
input_ids: prompt 编码后的 token id,并 pad 到 max_length
labels: 和 input_ids 等长;assistant 区域是 token id,其它位置是 -100
loss: 只在 labels != -100 的位置计算
```
你要盯住一个核心差异:
```text
input_ids 是模型看到的完整上下文;
labels 是哪些位置要让模型学习。
```
<a id="l05-sft-format"></a>
## 3. 原理一:SFT 样本格式
### 原理讲解
预训练样本通常是一段普通文本;SFT 样本是对话。
SFT 的目标不是让模型背整段 prompt,而是让模型在看到 user/system 上下文后,学会生成 assistant 回复。
所以 SFT 数据要保留角色信息:
```text
user 问了什么
assistant 应该怎么答
system 是否给了行为约束
```
这些信息在 jsonl 里用 `conversations` 表示。每条 message 至少有:
```text
role: user / assistant / system
content: 消息正文
```
### 源码证据 A:tiny SFT 样本长什么样
文件:`course/labs/tiny_sft.jsonl:1-3`
看它是为了理解:SFT 数据的最小结构。
```json
{"conversations":[
{"role":"user","content":"MiniMind 是什么?"},
{"role":"assistant","content":"MiniMind 是一个小型语言模型教学项目,用来学习从预训练到后训练的完整流程。"}
]}
```
这段数据说明:
- 一条样本的核心字段是 `conversations`。
- `user` 是输入上下文的一部分。
- `assistant` 是我们希望模型学会生成的目标回复。
### 源码证据 B:SFTDataset 声明 conversations schema
文件:`dataset/lm_dataset.py:59-66`
看它是为了理解:代码期望 SFT 样本有哪些字段。
```python
features = Features({'conversations': [{
'role': Value('string'),
'content': Value('string'),
'reasoning_content': Value('string'),
'tools': Value('string'),
'tool_calls': Value('string')
}]})
self.samples = load_dataset('json', data_files=jsonl_path, split='train', features=features)
```
这段代码说明:
- `SFTDataset` 会从 jsonl 读取 `conversations`。
- 除了 `role/content`,它还预留了 reasoning、tools、tool_calls 字段。
- 本节只关心普通 user/assistant 对话,工具调用后面再讲。
### 理解到这一步就够
你应该能说清楚:
```text
SFT 数据不是普通文本,而是带 role 的对话列表;
user/system 提供上下文,assistant 提供训练目标。
```
暂时不用看:
- `tools` 和 `tool_calls` 的完整格式。
- DPO/RL 数据格式。
<a id="l05-conversations-prompt"></a>
## 4. 原理二:conversations 如何变成 prompt
### 原理讲解
模型不能直接吃 Python 列表:
```python
[{"role": "user", "content": "..."}]
```
它只能吃 token id,而 token id 来自字符串。所以 SFTDataset 必须先把 `conversations` 渲染成 prompt 字符串。
这一步和第 4 课的 chat template 是同一件事,只是训练时有一个关键差异:
```text
推理时:add_generation_prompt=True,因为还没有 assistant 回复,要提示模型开始回答。
SFT 时:add_generation_prompt=False,因为样本里已经有完整 assistant 回复。
```
### 源码证据 A:SFTDataset 调用 chat template
文件:`dataset/lm_dataset.py:71-86`
看它是为了理解:SFT prompt 是怎么从 conversations 生成的。
```python
def create_chat_prompt(self, conversations):
messages = []
tools = None
for message in conversations:
message = dict(message)
...
messages.append(message)
return self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
tools=tools
)
```
这段代码说明:
- `messages` 是从 `conversations` 整理出来的。
- `apply_chat_template(..., tokenize=False)` 返回 prompt 字符串。
- `add_generation_prompt=False`,因为训练样本已经包含 assistant 回复。
- 如果有 tools,会传给模板;普通 SFT 样本可以先忽略。
### 源码证据 B:训练前可能做轻量 prompt 后处理
文件:`dataset/lm_dataset.py:31-35`
看它是为了理解:prompt 生成后可能会随机移除空 thinking 标签。
```python
def post_processing_chat(prompt_content, empty_think_ratio=0.2):
if '<think>\n\n</think>\n\n' in prompt_content and random.random() > empty_think_ratio:
prompt_content = prompt_content.replace('<think>\n\n</think>\n\n', '')
return prompt_content
```
这段代码说明:
- SFT prompt 中可能有空 `<think></think>` 块。
- 作者会按概率移除空 thinking 标签。
- 这是轻量数据增强,不影响本节核心:最终训练的是 prompt token 序列。
### 理解到这一步就够
你应该能说清楚:
```text
SFTDataset 先把 conversations 变成完整 prompt;
SFT 用 add_generation_prompt=False,因为 assistant 回复已经在 prompt 里。
```
暂时不用看:
- tool 模板分支。
- random system prompt 的训练策略细节。
<a id="l05-label-mask"></a>
## 5. 原理三:labels 为什么要 mask
### 原理讲解
SFT 的关键不是“把整段 prompt 都学一遍”。
一条 SFT prompt 里通常包含:
```text
system 指令
user 问题
assistant 回复
padding
```
模型训练时应该主要学习 assistant 怎么回答,而不是学习 user 问题本身。
所以 labels 会做 mask:
```text
user/system/padding 位置 -> -100
assistant 回复位置 -> 对应 token id
```
在 PyTorch 的 cross entropy 里,`ignore_index=-100` 表示这个位置不参与 loss。
### 源码证据 A:SFTDataset 先找 assistant 起止标记
文件:`dataset/lm_dataset.py:65-66`
看它是为了理解:代码如何识别 assistant 回复区域。
```python
self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n', add_special_tokens=False).input_ids
self.eos_id = tokenizer(f'{tokenizer.eos_token}\n', add_special_tokens=False).input_ids
```
这段代码说明:
- `bos_id` 不是普通 BOS,而是 assistant 消息开头:`<|im_start|>assistant\n`。
- `eos_id` 是消息结束:`<|im_end|>\n`。
- 后面 `generate_labels` 会靠这两个 token 序列定位 assistant 区域。
### 源码证据 B:labels 默认全是 -100
文件:`dataset/lm_dataset.py:88-90`
看它是为了理解:默认所有位置都不参与 loss。
```python
def generate_labels(self, input_ids):
labels = [-100] * len(input_ids)
i = 0
```
这段代码说明:
- labels 和 input_ids 等长。
- 初始状态下每个位置都是 `-100`。
- 后面只有 assistant 区域会被改成真实 token id。
### 源码证据 C:只把 assistant 区域写回 labels
文件:`dataset/lm_dataset.py:91-104`
看它是为了理解:哪些 token 参与 SFT loss。
```python
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
end = start
while end < len(input_ids):
if input_ids[end:end + len(self.eos_id)] == self.eos_id:
break
end += 1
for j in range(start, min(end + len(self.eos_id), self.max_length)):
labels[j] = input_ids[j]
```
这段代码说明:
- 找到 `<|im_start|>assistant\n` 后,assistant 正文从 `start` 开始。
- 一直找到 `<|im_end|>\n` 为止。
- 只有这个区间的 labels 会从 `-100` 改为真实 token id。
- user/system 仍然是 `-100`,不会贡献 loss。
### 理解到这一步就够
你应该能说清楚:
```text
input_ids 是完整 prompt;
labels 是训练目标;
SFT 只让 assistant 回复区域参与 loss,其它位置用 -100 忽略。
```
暂时不用看:
- DPO 的 chosen/rejected mask。
- PPO/GRPO 的 rollout mask。
<a id="l05-padding-truncation"></a>
## 6. 原理四:padding 和 truncation
### 原理讲解
训练通常按 batch 处理样本。一个 batch 里的 tensor 需要统一长度。
所以每条 prompt 会被处理成固定长度:
```text
太长 -> 截断到 max_length
太短 -> 用 pad_token_id 补齐
```
padding 只是为了形状对齐,不应该参与训练。因此 padding 对应的 labels 也应该保持 `-100`。
### 源码证据 A:SFTDataset 截断并补齐 input_ids
文件:`dataset/lm_dataset.py:106-113`
看它是为了理解:prompt 如何变成固定长度的 input_ids。
```python
prompt = self.create_chat_prompt(conversations)
prompt = post_processing_chat(prompt)
input_ids = self.tokenizer(prompt).input_ids[:self.max_length]
input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids))
labels = self.generate_labels(input_ids)
```
这段代码说明:
- `self.tokenizer(prompt).input_ids` 把 prompt 编码成 token id。
- `[:self.max_length]` 截断过长样本。
- 不足 `max_length` 的部分用 `pad_token_id` 补齐。
- `generate_labels(input_ids)` 在补齐后生成 labels,padding 位置会保持 `-100`。
### 源码证据 B:DataLoader 取到的是 input_ids 和 labels
文件:`dataset/lm_dataset.py:119`
看它是为了理解:SFTDataset 最终向训练脚本返回什么。
```python
return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long)
```
这段代码说明:
- 每条样本最终返回两个 tensor。
- 两者长度相同。
- 训练脚本不再关心原始文本,只看 `input_ids` 和 `labels`。
### 理解到这一步就够
你应该能说清楚:
```text
SFTDataset 把不同长度的 prompt 变成固定长度 input_ids;
padding 是形状占位,不参与 loss;
labels 和 input_ids 等长。
```
暂时不用看:
- DDP sampler。
- DataLoader 的性能参数。
<a id="l05-loss-labels"></a>
## 7. 原理五:loss 如何使用 labels
### 原理讲解
Causal LM 的训练目标是预测下一个 token。
如果输入是:
```text
[t0, t1, t2, t3]
```
模型在位置 0 的 logits 用来预测 `t1`,位置 1 的 logits 用来预测 `t2`,以此类推。
所以计算 loss 时会做 shift:
```text
logits[..., :-1, :] 对齐 labels[..., 1:]
```
这意味着:`labels[j]` 监督的是前一个位置对 token `j` 的预测。
当 `labels[j] == -100` 时,这个目标会被忽略。SFT 正是通过这个机制只训练 assistant 区域。
### 源码证据 A:训练脚本把 labels 传给模型
文件:`trainer/train_full_sft.py:27-37`
看它是为了理解:SFT 训练真正使用的是 `model(input_ids, labels=labels)`。
```python
for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
input_ids = input_ids.to(args.device)
labels = labels.to(args.device)
...
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss
```
这段代码说明:
- DataLoader 从 `SFTDataset` 得到 `(input_ids, labels)`。
- labels 会被传进模型 forward。
- 模型内部计算 `res.loss`。
### 源码证据 B:模型 forward 做 shift 并忽略 -100
文件:`model/model_minimind.py:245-253`
看它是为了理解:labels 如何真正进入 cross entropy。
```python
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
```
这段代码说明:
- `logits[..., :-1, :]` 去掉最后一个位置,因为最后一个位置没有下一个 token 可预测。
- `labels[..., 1:]` 去掉第一个 label,让每个位置预测下一个 token。
- `ignore_index=-100` 表示 label 为 `-100` 的位置不参与 loss。
### 理解到这一步就够
你应该能说清楚:
```text
SFTDataset 决定哪些 token 是训练目标;
model.forward 通过 shift 做 next-token loss;
-100 是“这个位置不要算 loss”的标记。
```
暂时不用看:
- `aux_loss` 的 MoE 细节。
- 混合精度、梯度累积、优化器。
<a id="l05-experiment"></a>
## 8. 实验验证
### 实验 A:打印一条 tiny SFT 样本
这个实验验证:
```text
conversations -> prompt -> input_ids -> labels
```
运行:
```bash
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_sft_dataset.py --show_rows 70
```
记录:
```text
input_ids.shape =
labels.shape =
non_ignored_labels =
assistant span 的起始 token =
第一段 label != -100 的位置 =
padding 位置的 label =
```
你应该看到:
```text
user token 的 label 是 -100;
assistant 回复 token 的 label 是对应 token id;
padding token 的 label 是 -100。
```
### 实验 B:换一条样本
运行:
```bash
PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_sft_dataset.py --index 2 --show_rows 90
```
记录:
```text
system 消息是否出现在 prompt 里?
system/user token 是否参与 loss?
assistant token 从哪里开始参与 loss?
```
<a id="l05-check"></a>
## 9. 本节检查
如果你真懂了本节,应该能不看答案说清楚:
1. SFT 的 `conversations` 和普通文本预训练样本有什么不同。
2. 为什么 SFT 用 `add_generation_prompt=False`。
3. `input_ids` 和 `labels` 分别表示什么。
4. 为什么 user/system token 的 label 要设为 `-100`。
5. `labels[..., 1:]` 和 `logits[..., :-1, :]` 为什么要 shift。
6. padding token 为什么不应该参与 loss。
<a id="l05-next"></a>
## 10. 下一课
第 6 课会正式对比 PretrainDataset 和 SFTDataset:同样是 causal LM loss,为什么数据格式、labels 构造和训练目标不同。