minimind/course/06_pretrain_vs_sft.md

14 KiB
Raw Blame History

第 6 课:Pretrain 与 SFT 的数据目标对比

这一课只解决一个问题:Pretrain 和 SFT 都用 causal LM next-token loss,为什么训练目标却不一样。

目录

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. 本节检查

如果你真懂了本节,应该能不看答案说清楚:

  1. PretrainDataset 的 labels 为什么基本等于 input_ids。
  2. SFTDataset 为什么不能让 user token 参与 loss。
  3. Pretrain 和 SFT 为什么可以共用同一个 model.forward loss。
  4. ignore_index=-100 在两个数据集里分别忽略了什么。
  5. 为什么说两阶段差异主要在 Dataset,而不是训练循环。

10. 下一课

第 7 课进入 train_pretrain.py:我们会从训练入口开始,看模型、tokenizer、dataset、optimizer、loss、保存权重是如何串起来的。