14 KiB
第 6 课:Pretrain 与 SFT 的数据目标对比
这一课只解决一个问题:Pretrain 和 SFT 都用 causal LM next-token loss,为什么训练目标却不一样。
目录
- 0. 本节主线
- 1. 本节要懂的 5 个原理
- 2. 变量流转
- 3. 原理一:同一个训练接口
- 4. 原理二:Pretrain 的训练目标
- 5. 原理三:SFT 的训练目标
- 6. 原理四:同一个 causal LM loss
- 7. 原理五:真正差异在哪里
- 8. 实验验证
- 9. 本节检查
- 10. 下一课
0. 本节主线
Pretrain 和 SFT 的共同点是:
都把文本变成 input_ids
-> 都构造 labels
-> 都调用 model(input_ids, labels=labels)
-> 都用 causal LM shift 后的 cross entropy
它们的关键差异是:
Pretrain: 普通文本几乎全段参与 next-token loss
SFT: 对话 prompt 里只让 assistant 区域参与 loss
所以本节的核心不是训练循环,而是:
同一个 loss 函数
不同的数据格式
不同的 labels mask
不同的学习目标
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 目标”。
2. 变量流转
Pretrain:
{"text": "..."}
-> tokenizer(text)
-> [bos] + text_tokens + [eos] + padding
-> labels = input_ids.clone()
-> padding labels = -100
SFT:
{"conversations": [...]}
-> chat template prompt
-> tokenizer(prompt)
-> padding
-> labels 默认全 -100
-> assistant 区域 labels = 对应 input_ids
共同进入训练循环后:
model(input_ids, labels=labels)
-> logits[..., :-1, :]
-> labels[..., 1:]
-> cross_entropy(ignore_index=-100)
3. 原理一:同一个训练接口
原理讲解
Pretrain 和 SFT 看起来是不同训练阶段,但进入训练循环时,接口是一样的:
DataLoader 产出 input_ids, labels
模型接收 input_ids, labels
模型返回 loss
优化器反向传播
也就是说,训练脚本不需要知道“这条样本是普通文本还是对话”。差异已经提前被 Dataset 编码进了 labels。
源码证据 A:Pretrain 训练循环接收 input_ids, labels
文件:trainer/train_pretrain.py:27-37
看它是为了理解:预训练循环最终也是把 labels 传给模型。
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 训练循环和预训练循环在核心调用上相同。
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 里完成。
理解到这一步就够
你应该能说清楚:
Pretrain 和 SFT 的训练循环核心接口一样;
差异主要在 Dataset 如何构造 input_ids 和 labels。
暂时不用看:
- 学习率、混合精度、梯度累积。
- checkpoint 保存逻辑。
4. 原理二:Pretrain 的训练目标
原理讲解
Pretrain 的数据是一段普通文本。
目标很直接:
给模型一段文本,让它学会预测下一个 token。
所以 labels 基本就是 input_ids 的复制。只有 padding 位置不该训练,因此 padding label 会设成 -100。
注意:虽然 labels = input_ids.clone(),模型 forward 里会做 shift:
logits[..., :-1, :] 对齐 labels[..., 1:]
所以真正监督的是“前一个位置预测下一个 token”。
源码证据 A:PretrainDataset 读取 text 字段
文件:dataset/lm_dataset.py:47-50
看它是为了理解:预训练样本从普通 text 字段开始。
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
看它是为了理解:预训练目标几乎覆盖整段文本。
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。
理解到这一步就够
你应该能说清楚:
Pretrain 学的是普通文本的 next-token prediction;
labels 基本复制 input_ids;
只有 padding 位置被 -100 忽略。
暂时不用看:
- 数据集来源和清洗流程。
- 超长文本如何分块采样。
5. 原理三:SFT 的训练目标
原理讲解
SFT 的数据是对话 prompt,不是普通文本。
一条 SFT prompt 里既有 user 输入,也有 assistant 回复:
<|im_start|>user
问题<|im_end|>
<|im_start|>assistant
回答<|im_end|>
训练时,模型应该看到完整 prompt 作为上下文,但只学习 assistant 回复区域。
所以 SFT 的 labels 是:
user/system 区域 -> -100
assistant 区域 -> token id
padding 区域 -> -100
这和 Pretrain 最大的差异就是 labels mask。
源码证据 A:SFT prompt 先由 chat template 生成
文件:dataset/lm_dataset.py:71-86
看它是为了理解:SFT 先把对话变成完整 prompt。
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 默认不训练任何位置。
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 区域如何被选出来。
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。
理解到这一步就够
你应该能说清楚:
SFT 让模型看到完整对话上下文;
但 loss 只训练 assistant 回复区域;
这靠 labels 中的 -100 mask 实现。
暂时不用看:
- Tool use SFT 样本。
- 多轮 assistant 区域的复杂情况。
6. 原理四:同一个 causal LM loss
原理讲解
模型 forward 并不知道数据来自 Pretrain 还是 SFT。
它只做一件事:
如果传了 labels,就计算 next-token cross entropy。
Pretrain 和 SFT 的差异不是 loss 函数不同,而是 labels 不同。
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。
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。
理解到这一步就够
你应该能说清楚:
同一个 causal LM loss 可以训练 Pretrain 和 SFT;
因为 Dataset 已经通过 labels 决定哪些位置参与 loss。
暂时不用看:
aux_loss。- MoE router loss。
- label smoothing 等其它 loss 变体。
7. 原理五:真正差异在哪里
原理讲解
现在可以把两阶段差异归纳成一句话:
Pretrain 和 SFT 的模型 forward 与训练循环几乎一样;
真正差异在 Dataset 构造 input_ids/labels 的方式。
这也是很多 LLM 训练代码的常见结构:
模型保持 causal LM
loss 保持 next-token prediction
通过数据格式和 mask 改变训练目标
源码证据 A:预训练脚本使用 PretrainDataset
文件:trainer/train_pretrain.py:134-136
看它是为了理解:预训练阶段选择的是普通文本数据集。
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 阶段选择的是对话数据集。
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 区域。
理解到这一步就够
你应该能说清楚:
训练阶段名称不同,不代表模型 forward 一定不同;
Pretrain 与 SFT 的主要区别在 Dataset 和 labels mask。
暂时不用看:
- 从 pretrain 权重加载到 SFT 的完整流程。
- 训练超参数如何选择。
8. 实验验证
实验 A:对比 tiny pretrain 和 tiny SFT
这个实验验证:
同样输出 input_ids / labels;
Pretrain 和 SFT 的 labels mask 完全不同。
运行:
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/compare_pretrain_sft.py --show_rows 65
记录:
Pretrain non_ignored_labels =
SFT non_ignored_labels =
Pretrain 第一个 padding label =
SFT 第一个 assistant label != -100 的位置 =
SFT user token 的 label =
你应该看到:
Pretrain: labels 大部分复制 input_ids,padding 是 -100。
SFT: user/system/padding 是 -100,assistant 回复区才是 token id。
实验 B:换一条 SFT 样本
运行:
PYTHONDONTWRITEBYTECODE=1 python course/labs/compare_pretrain_sft.py --sft_index 2 --show_rows 80
记录:
system token 是否参与 SFT loss?
assistant 区域从哪个 token 后开始?
padding 是否参与 loss?
9. 本节检查
如果你真懂了本节,应该能不看答案说清楚:
- PretrainDataset 的 labels 为什么基本等于 input_ids。
- SFTDataset 为什么不能让 user token 参与 loss。
- Pretrain 和 SFT 为什么可以共用同一个
model.forwardloss。 ignore_index=-100在两个数据集里分别忽略了什么。- 为什么说两阶段差异主要在 Dataset,而不是训练循环。
10. 下一课
第 7 课进入 train_pretrain.py:我们会从训练入口开始,看模型、tokenizer、dataset、optimizer、loss、保存权重是如何串起来的。