minimind/course/06_pretrain_vs_sft.md

537 lines
14 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.

# 第 6 课:Pretrain 与 SFT 的数据目标对比
这一课只解决一个问题:Pretrain 和 SFT 都用 causal LM next-token loss,为什么训练目标却不一样。
## 目录
- [0. 本节主线](#l06-mainline)
- [1. 本节要懂的 5 个原理](#l06-principles)
- [2. 变量流转](#l06-flow)
- [3. 原理一:同一个训练接口](#l06-same-interface)
- [4. 原理二:Pretrain 的训练目标](#l06-pretrain-target)
- [5. 原理三:SFT 的训练目标](#l06-sft-target)
- [6. 原理四:同一个 causal LM loss](#l06-causal-lm-loss)
- [7. 原理五:真正差异在哪里](#l06-real-difference)
- [8. 实验验证](#l06-experiment)
- [9. 本节检查](#l06-check)
- [10. 下一课](#l06-next)
<a id="l06-mainline"></a>
## 0. 本节主线
Pretrain 和 SFT 的共同点是:
```text
都把文本变成 input_ids
-> 都构造 labels
-> 都调用 model(input_ids, labels=labels)
-> 都用 causal LM shift 后的 cross entropy
```
它们的关键差异是:
```text
Pretrain: 普通文本几乎全段参与 next-token loss
SFT: 对话 prompt 里只让 assistant 区域参与 loss
```
所以本节的核心不是训练循环,而是:
```text
同一个 loss 函数
不同的数据格式
不同的 labels mask
不同的学习目标
```
<a id="l06-principles"></a>
## 1. 本节要懂的 5 个原理
| 原理 | 要理解什么 | 源码证据 |
|---|---|---|
| 同一个训练接口 | Pretrain 和 SFT 最后都返回 `(input_ids, labels)` | `trainer/train_pretrain.py:27-37`, `trainer/train_full_sft.py:27-37` |
| Pretrain 目标 | 普通文本加 BOS/EOS,非 padding token 基本都参与 loss | `dataset/lm_dataset.py:37-55` |
| SFT 目标 | 对话 prompt 中只保留 assistant 区域作为 labels | `dataset/lm_dataset.py:58-119` |
| 同一个 causal LM loss | 模型 forward 不知道数据来自 Pretrain 还是 SFT,只看 labels | `model/model_minimind.py:245-253` |
| 真正差异 | 两阶段的训练差异主要由 Dataset 构造的 labels 决定 | `trainer/train_pretrain.py:134-136`, `trainer/train_full_sft.py:134-137` |
学完本节,你应该能解释:为什么换一个 Dataset,就能把同一个模型 loss 变成“预训练目标”或“SFT 目标”。
<a id="l06-flow"></a>
## 2. 变量流转
Pretrain:
```text
{"text": "..."}
-> tokenizer(text)
-> [bos] + text_tokens + [eos] + padding
-> labels = input_ids.clone()
-> padding labels = -100
```
SFT:
```text
{"conversations": [...]}
-> chat template prompt
-> tokenizer(prompt)
-> padding
-> labels 默认全 -100
-> assistant 区域 labels = 对应 input_ids
```
共同进入训练循环后:
```text
model(input_ids, labels=labels)
-> logits[..., :-1, :]
-> labels[..., 1:]
-> cross_entropy(ignore_index=-100)
```
<a id="l06-same-interface"></a>
## 3. 原理一:同一个训练接口
### 原理讲解
Pretrain 和 SFT 看起来是不同训练阶段,但进入训练循环时,接口是一样的:
```text
DataLoader 产出 input_ids, labels
模型接收 input_ids, labels
模型返回 loss
优化器反向传播
```
也就是说,训练脚本不需要知道“这条样本是普通文本还是对话”。差异已经提前被 Dataset 编码进了 `labels`。
### 源码证据 A:Pretrain 训练循环接收 input_ids, labels
文件:`trainer/train_pretrain.py:27-37`
看它是为了理解:预训练循环最终也是把 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 输出 `(input_ids, labels)`。
- 模型 forward 接收 `labels` 并计算 loss。
- 训练循环本身没有特殊的“预训练 loss”逻辑。
### 源码证据 B:SFT 训练循环接口相同
文件:`trainer/train_full_sft.py:27-37`
看它是为了理解:SFT 训练循环和预训练循环在核心调用上相同。
```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
```
这段代码说明:
- SFT 也是 `model(input_ids, labels=labels)`。
- 训练循环不负责区分 user/assistant。
- 区分逻辑已经在 `SFTDataset` 的 labels 里完成。
### 理解到这一步就够
你应该能说清楚:
```text
Pretrain 和 SFT 的训练循环核心接口一样;
差异主要在 Dataset 如何构造 input_ids 和 labels。
```
暂时不用看:
- 学习率、混合精度、梯度累积。
- checkpoint 保存逻辑。
<a id="l06-pretrain-target"></a>
## 4. 原理二:Pretrain 的训练目标
### 原理讲解
Pretrain 的数据是一段普通文本。
目标很直接:
```text
给模型一段文本,让它学会预测下一个 token。
```
所以 labels 基本就是 input_ids 的复制。只有 padding 位置不该训练,因此 padding label 会设成 `-100`。
注意:虽然 `labels = input_ids.clone()`,模型 forward 里会做 shift:
```text
logits[..., :-1, :] 对齐 labels[..., 1:]
```
所以真正监督的是“前一个位置预测下一个 token”。
### 源码证据 A:PretrainDataset 读取 text 字段
文件:`dataset/lm_dataset.py:47-50`
看它是为了理解:预训练样本从普通 `text` 字段开始。
```python
sample = self.samples[index]
tokens = self.tokenizer(
str(sample['text']),
add_special_tokens=False,
max_length=self.max_length - 2,
truncation=True
).input_ids
tokens = [self.tokenizer.bos_token_id] + tokens + [self.tokenizer.eos_token_id]
```
这段代码说明:
- 预训练样本只需要 `text` 字段。
- 文本先 tokenized。
- 代码手动加 BOS 和 EOS。
- `max_length - 2` 是为了给 BOS/EOS 留位置。
### 源码证据 B:Pretrain labels 基本复制 input_ids
文件:`dataset/lm_dataset.py:51-55`
看它是为了理解:预训练目标几乎覆盖整段文本。
```python
input_ids = tokens + [self.tokenizer.pad_token_id] * (self.max_length - len(tokens))
input_ids = torch.tensor(input_ids, dtype=torch.long)
labels = input_ids.clone()
labels[input_ids == self.tokenizer.pad_token_id] = -100
return input_ids, labels
```
这段代码说明:
- `input_ids` 会被 padding 到固定长度。
- `labels = input_ids.clone()`,非 padding 位置都保留 token id。
- padding 位置改成 `-100`,不参与 loss。
### 理解到这一步就够
你应该能说清楚:
```text
Pretrain 学的是普通文本的 next-token prediction;
labels 基本复制 input_ids;
只有 padding 位置被 -100 忽略。
```
暂时不用看:
- 数据集来源和清洗流程。
- 超长文本如何分块采样。
<a id="l06-sft-target"></a>
## 5. 原理三:SFT 的训练目标
### 原理讲解
SFT 的数据是对话 prompt,不是普通文本。
一条 SFT prompt 里既有 user 输入,也有 assistant 回复:
```text
<|im_start|>user
问题<|im_end|>
<|im_start|>assistant
回答<|im_end|>
```
训练时,模型应该看到完整 prompt 作为上下文,但只学习 assistant 回复区域。
所以 SFT 的 labels 是:
```text
user/system 区域 -> -100
assistant 区域 -> token id
padding 区域 -> -100
```
这和 Pretrain 最大的差异就是 labels mask。
### 源码证据 A:SFT prompt 先由 chat template 生成
文件:`dataset/lm_dataset.py:71-86`
看它是为了理解:SFT 先把对话变成完整 prompt。
```python
return self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
tools=tools
)
```
这段代码说明:
- SFT 使用 chat template。
- `add_generation_prompt=False`,因为训练样本里已经有 assistant 回复。
- prompt 里同时包含 user 和 assistant。
### 源码证据 B:SFT labels 默认全部忽略
文件:`dataset/lm_dataset.py:88-90`
看它是为了理解:SFT 默认不训练任何位置。
```python
def generate_labels(self, input_ids):
labels = [-100] * len(input_ids)
i = 0
```
这段代码说明:
- SFT labels 初始全是 `-100`。
- 只有后面识别出的 assistant 区域会被恢复成 token id。
### 源码证据 C:只训练 assistant span
文件:`dataset/lm_dataset.py:91-104`
看它是为了理解:assistant 区域如何被选出来。
```python
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
...
for j in range(start, min(end + len(self.eos_id), self.max_length)):
labels[j] = input_ids[j]
```
这段代码说明:
- 找到 assistant 消息开头后,才开始恢复 labels。
- assistant 回复和结尾 `<|im_end|>` 会参与训练。
- user/system 区域仍然是 `-100`。
### 理解到这一步就够
你应该能说清楚:
```text
SFT 让模型看到完整对话上下文;
但 loss 只训练 assistant 回复区域;
这靠 labels 中的 -100 mask 实现。
```
暂时不用看:
- Tool use SFT 样本。
- 多轮 assistant 区域的复杂情况。
<a id="l06-causal-lm-loss"></a>
## 6. 原理四:同一个 causal LM loss
### 原理讲解
模型 forward 并不知道数据来自 Pretrain 还是 SFT。
它只做一件事:
```text
如果传了 labels,就计算 next-token cross entropy。
```
Pretrain 和 SFT 的差异不是 loss 函数不同,而是 labels 不同。
```text
Pretrain labels:大部分位置是 token id
SFT labels:只有 assistant 位置是 token id
```
`ignore_index=-100` 让同一个 loss 可以适配两种训练目标。
### 源码证据 A:模型 forward 做统一的 shift loss
文件:`model/model_minimind.py:245-253`
看它是为了理解:模型只根据 labels 计算统一的 causal LM loss。
```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, :]` 用前面位置的输出。
- `labels[..., 1:]` 是下一个 token 的目标。
- `ignore_index=-100` 让 masked 位置不参与 loss。
- 这段代码没有区分 Pretrain/SFT。
### 理解到这一步就够
你应该能说清楚:
```text
同一个 causal LM loss 可以训练 Pretrain 和 SFT;
因为 Dataset 已经通过 labels 决定哪些位置参与 loss。
```
暂时不用看:
- `aux_loss`。
- MoE router loss。
- label smoothing 等其它 loss 变体。
<a id="l06-real-difference"></a>
## 7. 原理五:真正差异在哪里
### 原理讲解
现在可以把两阶段差异归纳成一句话:
```text
Pretrain 和 SFT 的模型 forward 与训练循环几乎一样;
真正差异在 Dataset 构造 input_ids/labels 的方式。
```
这也是很多 LLM 训练代码的常见结构:
```text
模型保持 causal LM
loss 保持 next-token prediction
通过数据格式和 mask 改变训练目标
```
### 源码证据 A:预训练脚本使用 PretrainDataset
文件:`trainer/train_pretrain.py:134-136`
看它是为了理解:预训练阶段选择的是普通文本数据集。
```python
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
train_ds = PretrainDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
```
这段代码说明:
- 预训练脚本把数据交给 `PretrainDataset`。
- 也就是由 `PretrainDataset` 决定 labels 基本复制 input_ids。
### 源码证据 B:SFT 脚本使用 SFTDataset
文件:`trainer/train_full_sft.py:134-137`
看它是为了理解:SFT 阶段选择的是对话数据集。
```python
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
train_ds = SFTDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
```
这段代码说明:
- SFT 脚本把数据交给 `SFTDataset`。
- 也就是由 `SFTDataset` 决定只训练 assistant 区域。
### 理解到这一步就够
你应该能说清楚:
```text
训练阶段名称不同,不代表模型 forward 一定不同;
Pretrain 与 SFT 的主要区别在 Dataset 和 labels mask。
```
暂时不用看:
- 从 pretrain 权重加载到 SFT 的完整流程。
- 训练超参数如何选择。
<a id="l06-experiment"></a>
## 8. 实验验证
### 实验 A:对比 tiny pretrain 和 tiny SFT
这个实验验证:
```text
同样输出 input_ids / labels;
Pretrain 和 SFT 的 labels mask 完全不同。
```
运行:
```bash
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/compare_pretrain_sft.py --show_rows 65
```
记录:
```text
Pretrain non_ignored_labels =
SFT non_ignored_labels =
Pretrain 第一个 padding label =
SFT 第一个 assistant label != -100 的位置 =
SFT user token 的 label =
```
你应该看到:
```text
Pretrain: labels 大部分复制 input_ids,padding 是 -100。
SFT: user/system/padding 是 -100,assistant 回复区才是 token id。
```
### 实验 B:换一条 SFT 样本
运行:
```bash
PYTHONDONTWRITEBYTECODE=1 python course/labs/compare_pretrain_sft.py --sft_index 2 --show_rows 80
```
记录:
```text
system token 是否参与 SFT loss?
assistant 区域从哪个 token 后开始?
padding 是否参与 loss?
```
<a id="l06-check"></a>
## 9. 本节检查
如果你真懂了本节,应该能不看答案说清楚:
1. PretrainDataset 的 labels 为什么基本等于 input_ids。
2. SFTDataset 为什么不能让 user token 参与 loss。
3. Pretrain 和 SFT 为什么可以共用同一个 `model.forward` loss。
4. `ignore_index=-100` 在两个数据集里分别忽略了什么。
5. 为什么说两阶段差异主要在 Dataset,而不是训练循环。
<a id="l06-next"></a>
## 10. 下一课
第 7 课进入 `train_pretrain.py`:我们会从训练入口开始,看模型、tokenizer、dataset、optimizer、loss、保存权重是如何串起来的。