minimind/course/05_sft_dataset_and_labels.md

15 KiB
Raw Blame History

第 5 课:SFT 数据、Prompt 与 Labels

这一课只解决一个问题:SFT 数据里的 conversations 如何变成模型训练用的 input_ids 和 labels。

目录

0. 本节主线

SFT 数据处理的本质是:

读取 conversations
-> 用 chat template 渲染成 prompt
-> tokenizer 编码成 input_ids
-> 构造 labels
-> user/system/padding 位置设为 -100
-> assistant 回复位置保留 token id
-> 交给 causal LM loss 训练 assistant 回复

这条链对应本节的 5 个原理:

SFT 样本格式 -> prompt 构造 -> labels mask -> padding/truncation -> loss 如何使用 labels

本节不是讲“怎么训练出好模型”,而是讲清楚 SFT 数据在进入模型前到底变成了什么。

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 被忽略。

2. 变量流转

把本节主线对应到变量:

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 的位置计算

你要盯住一个核心差异:

input_ids 是模型看到的完整上下文;
labels 是哪些位置要让模型学习。

3. 原理一:SFT 样本格式

原理讲解

预训练样本通常是一段普通文本;SFT 样本是对话。

SFT 的目标不是让模型背整段 prompt,而是让模型在看到 user/system 上下文后,学会生成 assistant 回复。

所以 SFT 数据要保留角色信息:

user 问了什么
assistant 应该怎么答
system 是否给了行为约束

这些信息在 jsonl 里用 conversations 表示。每条 message 至少有:

role: user / assistant / system
content: 消息正文

源码证据 A:tiny SFT 样本长什么样

文件:course/labs/tiny_sft.jsonl:1-3

看它是为了理解:SFT 数据的最小结构。

{"conversations":[
  {"role":"user","content":"MiniMind 是什么?"},
  {"role":"assistant","content":"MiniMind 是一个小型语言模型教学项目,用来学习从预训练到后训练的完整流程。"}
]}

这段数据说明:

  • 一条样本的核心字段是 conversations。
  • user 是输入上下文的一部分。
  • assistant 是我们希望模型学会生成的目标回复。

源码证据 B:SFTDataset 声明 conversations schema

文件:dataset/lm_dataset.py:59-66

看它是为了理解:代码期望 SFT 样本有哪些字段。

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 对话,工具调用后面再讲。

理解到这一步就够

你应该能说清楚:

SFT 数据不是普通文本,而是带 role 的对话列表;
user/system 提供上下文,assistant 提供训练目标。

暂时不用看:

  • tools 和 tool_calls 的完整格式。
  • DPO/RL 数据格式。

4. 原理二:conversations 如何变成 prompt

原理讲解

模型不能直接吃 Python 列表:

[{"role": "user", "content": "..."}]

它只能吃 token id,而 token id 来自字符串。所以 SFTDataset 必须先把 conversations 渲染成 prompt 字符串。

这一步和第 4 课的 chat template 是同一件事,只是训练时有一个关键差异:

推理时:add_generation_prompt=True,因为还没有 assistant 回复,要提示模型开始回答。
SFT 时:add_generation_prompt=False,因为样本里已经有完整 assistant 回复。

源码证据 A:SFTDataset 调用 chat template

文件:dataset/lm_dataset.py:71-86

看它是为了理解:SFT prompt 是怎么从 conversations 生成的。

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 标签。

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 序列。

理解到这一步就够

你应该能说清楚:

SFTDataset 先把 conversations 变成完整 prompt;
SFT 用 add_generation_prompt=False,因为 assistant 回复已经在 prompt 里。

暂时不用看:

  • tool 模板分支。
  • random system prompt 的训练策略细节。

5. 原理三:labels 为什么要 mask

原理讲解

SFT 的关键不是“把整段 prompt 都学一遍”。

一条 SFT prompt 里通常包含:

system 指令
user 问题
assistant 回复
padding

模型训练时应该主要学习 assistant 怎么回答,而不是学习 user 问题本身。

所以 labels 会做 mask:

user/system/padding 位置 -> -100
assistant 回复位置 -> 对应 token id

在 PyTorch 的 cross entropy 里,ignore_index=-100 表示这个位置不参与 loss。

源码证据 A:SFTDataset 先找 assistant 起止标记

文件:dataset/lm_dataset.py:65-66

看它是为了理解:代码如何识别 assistant 回复区域。

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。

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。

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。

理解到这一步就够

你应该能说清楚:

input_ids 是完整 prompt;
labels 是训练目标;
SFT 只让 assistant 回复区域参与 loss,其它位置用 -100 忽略。

暂时不用看:

  • DPO 的 chosen/rejected mask。
  • PPO/GRPO 的 rollout mask。

6. 原理四:padding 和 truncation

原理讲解

训练通常按 batch 处理样本。一个 batch 里的 tensor 需要统一长度。

所以每条 prompt 会被处理成固定长度:

太长 -> 截断到 max_length
太短 -> 用 pad_token_id 补齐

padding 只是为了形状对齐,不应该参与训练。因此 padding 对应的 labels 也应该保持 -100。

源码证据 A:SFTDataset 截断并补齐 input_ids

文件:dataset/lm_dataset.py:106-113

看它是为了理解:prompt 如何变成固定长度的 input_ids。

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 最终向训练脚本返回什么。

return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long)

这段代码说明:

  • 每条样本最终返回两个 tensor。
  • 两者长度相同。
  • 训练脚本不再关心原始文本,只看 input_ids 和 labels。

理解到这一步就够

你应该能说清楚:

SFTDataset 把不同长度的 prompt 变成固定长度 input_ids;
padding 是形状占位,不参与 loss;
labels 和 input_ids 等长。

暂时不用看:

  • DDP sampler。
  • DataLoader 的性能参数。

7. 原理五:loss 如何使用 labels

原理讲解

Causal LM 的训练目标是预测下一个 token。

如果输入是:

[t0, t1, t2, t3]

模型在位置 0 的 logits 用来预测 t1,位置 1 的 logits 用来预测 t2,以此类推。

所以计算 loss 时会做 shift:

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)。

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。

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。

理解到这一步就够

你应该能说清楚:

SFTDataset 决定哪些 token 是训练目标;
model.forward 通过 shift 做 next-token loss;
-100 是“这个位置不要算 loss”的标记。

暂时不用看:

  • aux_loss 的 MoE 细节。
  • 混合精度、梯度累积、优化器。

8. 实验验证

实验 A:打印一条 tiny SFT 样本

这个实验验证:

conversations -> prompt -> input_ids -> labels

运行:

cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_sft_dataset.py --show_rows 70

记录:

input_ids.shape =
labels.shape =
non_ignored_labels =
assistant span 的起始 token =
第一段 label != -100 的位置 =
padding 位置的 label =

你应该看到:

user token 的 label 是 -100;
assistant 回复 token 的 label 是对应 token id;
padding token 的 label 是 -100。

实验 B:换一条样本

运行:

PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_sft_dataset.py --index 2 --show_rows 90

记录:

system 消息是否出现在 prompt 里?
system/user token 是否参与 loss?
assistant token 从哪里开始参与 loss?

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。

10. 下一课

第 6 课会正式对比 PretrainDataset 和 SFTDataset:同样是 causal LM loss,为什么数据格式、labels 构造和训练目标不同。