# 第 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) ## 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 不同的学习目标 ``` ## 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 {"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) ``` ## 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 保存逻辑。 ## 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 忽略。 ``` 暂时不用看: - 数据集来源和清洗流程。 - 超长文本如何分块采样。 ## 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 区域的复杂情况。 ## 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 变体。 ## 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 的完整流程。 - 训练超参数如何选择。 ## 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? ``` ## 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、保存权重是如何串起来的。