minimind/course/16_full_sft_training_overvi...

735 lines
19 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.

# 第 16 课:Full SFT 训练脚本总览
这一课只解决一个问题:MiniMind 如何从预训练权重出发,用对话数据做 Full SFT。
## 目录
- [0. 本节主线](#l16-mainline)
- [1. 原理讲解](#l16-principle)
- [2. 源码阅读顺序图](#l16-reading-order)
- [3. MiniMind 源码走读](#l16-source-walkthrough)
- [4. 本节必须会写 / 暂时不要求](#l16-must-write)
- [5. 手写模块](#l16-handwrite)
- [6. 对齐测试](#l16-alignment-test)
- [7. 阶段组装](#l16-stage-assembly)
- [8. 本节检查](#l16-check)
- [9. 下一课](#l16-next)
<a id="l16-mainline"></a>
## 0. 本节主线
Full SFT 的训练链路是:
```text
读取 sft_t2t_mini.jsonl
-> SFTDataset 把 conversations 渲染成 chat prompt
-> tokenizer 编码成 input_ids
-> generate_labels 只保留 assistant 区域,其他位置设为 -100
-> 从 pretrain 权重初始化模型
-> 复用 pretrain 的 train_epoch 训练循环
-> 保存 full_sft 权重和 resume checkpoint
```
一句话:
```text
Full SFT 和 Pretrain 的训练循环几乎一样,真正不同的是数据目标:SFT 只让 assistant 回复区域参与 loss。
```
本节要把两条线连起来:
```text
第 5 课:SFT 数据如何构造 input_ids / labels
第 14-15 课:训练循环和 checkpoint 如何工作
```
<a id="l16-principle"></a>
## 1. 原理讲解
### 1.1 Full SFT 是什么
Full SFT 指的是“全参数监督微调”。
它和 LoRA 不同:
```text
Full SFT:
更新模型全部可训练参数。
LoRA:
通常冻结原模型,只训练额外插入的小矩阵。
```
它和 Pretrain 也不同:
```text
Pretrain:
用普通文本学习 next-token prediction。
SFT:
用对话数据学习 assistant 应该怎么回答。
```
训练目标仍然是 causal LM next-token loss,但 labels 的有效区域不同。
### 1.2 SFT 为什么仍然是 causal LM loss
SFT 并没有把模型改成分类器。模型输入仍然是:
```text
input_ids: [B, S]
```
模型输出仍然是:
```text
logits: [B, S, V]
```
loss 仍然是 shifted next-token cross entropy:
$$
L_{\text{lm}}
= -\frac{1}{|\mathcal{A}|}\sum_{t \in \mathcal{A}}
\log p_{\theta}(x_t \mid x_{<t})
$$
其中:
| 符号 | 形状/类型 | 含义 |
|---|---|---|
| $B$ | 标量 | batch size |
| $S$ | 标量 | sequence length |
| $V$ | 标量 | vocab size |
| $x_t$ | 标量 token id | 第 `t` 个目标 token |
| $\mathcal{A}$ | token 位置集合 | assistant 回复区域 |
| $p_{\theta}$ | 概率分布 | 模型对下一个 token 的预测 |
关键是 $\mathcal{A}$。Pretrain 里大部分非 padding token 都参与 loss;SFT 里通常只有 assistant 回复 token 参与 loss。
### 1.3 `labels=-100` 决定哪些 token 不训练
PyTorch 的 `cross_entropy` 支持 `ignore_index=-100`。
所以 SFTDataset 会构造:
```text
input_ids: 完整 prompt,包括 system/user/assistant/padding
labels: 只有 assistant 区域保留 token id,其它位置都是 -100
```
形状是:
| 变量 | 形状 | 含义 |
|---|---|---|
| `input_ids` | `[S]` | 一条样本的完整 token 序列 |
| `labels` | `[S]` | 同长度训练目标 |
| `input_ids batch` | `[B, S]` | dataloader 组成 batch 后 |
| `labels batch` | `[B, S]` | dataloader 组成 batch 后 |
可以把一条样本理解成:
```text
system/user token -> labels = -100
assistant token -> labels = token_id
padding token -> labels = -100
```
这样模型能看到完整上下文,但只因为 assistant 回复区域产生梯度。
### 1.4 SFTDataset 如何找到 assistant 区域
MiniMind 的 tokenizer chat template 会把 assistant 回复包在特殊标记附近。
SFTDataset 初始化时构造两个模式:
```text
bos_id = tokenizer(f"{bos_token}assistant\n", add_special_tokens=False).input_ids
eos_id = tokenizer(f"{eos_token}\n", add_special_tokens=False).input_ids
```
然后扫描 `input_ids`:
```text
找到 bos_id
-> bos_id 后面是 assistant 内容开始
-> 找到 eos_id
-> assistant 内容 + eos_id 这段 labels 保留 token id
-> 其它位置保持 -100
```
用公式表示 label mask:
$$
y_t =
\begin{cases}
x_t, & t \in \mathcal{A} \\
-100, & t \notin \mathcal{A}
\end{cases}
$$
其中 $\mathcal{A}$ 是 assistant 回复和结束标记所在位置。
### 1.5 Full SFT 从哪个权重开始
MiniMind 的 Full SFT 默认:
```text
from_weight = pretrain
save_weight = full_sft
```
意思是:
```text
读取 out/pretrain_768.pth
-> 训练 SFT
-> 保存 out/full_sft_768.pth
```
这体现了后训练的基本顺序:
```text
pretrain 权重
-> SFT 权重
-> DPO/RL 等偏好或强化学习阶段
```
如果 `from_weight='none'`,那就是从随机初始化直接 SFT。能跑,但通常不是正常路线。
### 1.6 Full SFT 训练循环和 Pretrain 有什么不同
训练循环几乎一样:
```text
get_lr
autocast forward
loss = res.loss + res.aux_loss
loss / accumulation_steps
backward
clip
optimizer.step
checkpoint
resume
```
不同主要在配置和数据:
| 项目 | Pretrain | Full SFT |
|---|---|---|
| dataset | `PretrainDataset` | `SFTDataset` |
| 默认数据 | `pretrain_t2t_mini.jsonl` | `sft_t2t_mini.jsonl` |
| 默认权重来源 | `none` | `pretrain` |
| 默认保存名 | `pretrain` | `full_sft` |
| 默认学习率 | `5e-4` | `1e-5` |
| 默认 max_seq_len | `340` | `768` |
| 默认 accumulation | `8` | `1` |
SFT 学习率更小,是因为它是在已有预训练能力上微调,不希望剧烈破坏已有语言建模能力。
<a id="l16-reading-order"></a>
## 2. 源码阅读顺序图
这节源码按这个顺序读:
```text
train_full_sft.py 参数默认值
-> init_model(..., from_weight='pretrain')
-> SFTDataset 初始化
-> SFTDataset.create_chat_prompt
-> SFTDataset.generate_labels
-> train_full_sft.py::train_epoch
-> checkpoint / resume 流程
```
对应文件:
```text
trainer/train_full_sft.py
dataset/lm_dataset.py
trainer/trainer_utils.py
model/model_minimind.py
```
先看训练脚本,是为了知道 SFT 阶段怎么启动。
再看 `SFTDataset`,是为了知道 SFT 和 Pretrain 的真正差异。
最后回到 `train_epoch`,确认它复用的是同一套 causal LM 训练机制。
<a id="l16-source-walkthrough"></a>
## 3. MiniMind 源码走读
### 第 1 步:Full SFT 的默认训练参数
File: `trainer/train_full_sft.py:84-107`
Read this to understand: SFT 阶段默认用什么数据、从什么权重开始、保存成什么名字。
Code/config/template excerpt:
```python
parser.add_argument('--save_weight', default='full_sft', type=str, help="保存权重的前缀名")
parser.add_argument("--epochs", type=int, default=2, help="训练轮数")
parser.add_argument("--batch_size", type=int, default=16, help="batch size")
parser.add_argument("--learning_rate", type=float, default=1e-5, help="初始学习率")
parser.add_argument('--max_seq_len', default=768, type=int, help="训练的最大截断长度(中文1token≈1.5~1.7字符)")
parser.add_argument("--data_path", type=str, default="../dataset/sft_t2t_mini.jsonl", help="训练数据路径")
parser.add_argument('--from_weight', default='pretrain', type=str, help="基于哪个权重训练,为none则不基于任何权重训练")
```
This code shows:
- SFT 默认保存为 `full_sft`。
- SFT 默认从 `pretrain` 权重开始。
- SFT 默认数据是 `sft_t2t_mini.jsonl`。
- SFT 学习率比 Pretrain 小。
### 第 2 步:SFT 初始化模型和数据
File: `trainer/train_full_sft.py:115-139`
Read this to understand: Full SFT 的模型来自 `from_weight`,数据来自 `SFTDataset`。
Code/config/template excerpt:
```python
lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe))
ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
...
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)
train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
```
This code shows:
- `init_model(..., args.from_weight)` 会加载 `pretrain` 权重。
- `SFTDataset` 替代了 `PretrainDataset`。
- optimizer、scaler、sampler 的结构和 pretrain 一样。
- 如果 `from_resume=1`,会优先查 `full_sft` 的 resume checkpoint。
### 第 3 步:`init_model` 如何加载 pretrain 权重
File: `trainer/trainer_utils.py:119-131`
Read this to understand: `from_weight='pretrain'` 到底加载哪个文件。
Code/config/template excerpt:
```python
if from_weight!= 'none':
moe_suffix = '_moe' if lm_config.use_moe else ''
weight_path = f'{save_dir}/{from_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
weights = torch.load(weight_path, map_location=device)
model.load_state_dict(weights, strict=False)
```
This code shows:
- `from_weight='pretrain'` 会加载 `../out/pretrain_768.pth`。
- MoE 模型会加载带 `_moe` 后缀的权重。
- `strict=False` 允许有些 key 不完全匹配。
- 这里加载的是普通权重文件,不是 resume checkpoint。
### 第 4 步:SFTDataset 声明样本格式
File: `dataset/lm_dataset.py:58-66`
Read this to understand: SFT 数据以 `conversations` 为核心字段。
Code/config/template excerpt:
```python
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)
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
```
This code shows:
- 每条样本包含一个 `conversations` 列表。
- `bos_id` 用来识别 assistant 回复开始。
- `eos_id` 用来识别 assistant 回复结束。
- `generate_labels` 后面会用这两个 token 序列扫描 assistant 区域。
### 第 5 步:conversations 渲染成 prompt
File: `dataset/lm_dataset.py:71-86`
Read this to understand: SFT 训练时不是直接拼字符串,而是使用 tokenizer 的 chat template。
Code/config/template excerpt:
```python
def create_chat_prompt(self, conversations):
messages = []
tools = None
for message in conversations:
message = dict(message)
if message.get("role") == "system" and message.get("tools"):
tools = json.loads(message["tools"]) if isinstance(message["tools"], str) else message["tools"]
if message.get("tool_calls") and isinstance(message["tool_calls"], str):
message["tool_calls"] = json.loads(message["tool_calls"])
messages.append(message)
return self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
tools=tools
)
```
This code shows:
- `add_generation_prompt=False`,因为 SFT 样本已经包含 assistant 回复。
- 普通对话只需要 `role/content`。
- 工具调用字段会在 template 前转成 JSON 对象。
- 输出是 prompt 字符串,还不是 token id。
### 第 6 步:generate_labels 只保留 assistant 区域
File: `dataset/lm_dataset.py:88-104`
Read this to understand: SFT labels mask 的核心实现。
Code/config/template excerpt:
```python
labels = [-100] * len(input_ids)
i = 0
while i < len(input_ids):
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]
i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
else:
i += 1
```
This code shows:
- labels 初始全是 `-100`。
- 只有匹配到 assistant 开始标记后,才开始保留 label。
- 保留区域包含 assistant 内容和 eos 标记。
- user/system/padding 默认都不参与 loss。
### 第 7 步:`__getitem__` 产出训练 batch 的单样本
File: `dataset/lm_dataset.py:106-119`
Read this to understand: DataLoader 最终拿到的是 `input_ids` 和 `labels` 两个 tensor。
Code/config/template excerpt:
```python
sample = self.samples[index]
conversations = pre_processing_chat(sample['conversations'])
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)
return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long)
```
This code shows:
- prompt 会被截断到 `max_length`。
- 不足 `max_length` 的部分用 pad token 补齐。
- labels 和 input_ids 等长。
- 返回值和 PretrainDataset 一样都是 `(input_ids, labels)`,所以训练循环可以复用。
### 第 8 步:Full SFT 复用 causal LM 训练循环
File: `trainer/train_full_sft.py:24-81`
Read this to understand: SFT 的训练循环和 Pretrain 几乎同构。
Code/config/template excerpt:
```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)
lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
...
with autocast_ctx:
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss
loss = loss / args.accumulation_steps
scaler.scale(loss).backward()
```
This code shows:
- SFT 仍然调用 `model(input_ids, labels=labels)`。
- `labels=-100` 决定哪些 token 不算 loss。
- optimizer、lr、AMP、checkpoint 逻辑和 Pretrain 一样。
- 因为 dataset 返回格式一致,训练循环不用关心这是 pretrain 还是 SFT。
<a id="l16-must-write"></a>
## 4. 本节必须会写 / 暂时不要求
必须会写:
1. SFT prompt 渲染:
```text
conversations
-> tokenizer.apply_chat_template(..., tokenize=False, add_generation_prompt=False)
-> prompt
```
2. assistant 区域 labels mask:
$$
y_t =
\begin{cases}
x_t, & t \in \mathcal{A} \\
-100, & t \notin \mathcal{A}
\end{cases}
$$
3. padding/truncation:
```text
input_ids = tokenizer(prompt).input_ids[:max_length]
input_ids += [pad_token_id] * (max_length - len(input_ids))
labels = generate_labels(input_ids)
```
4. Full SFT 初始化:
```text
from_weight = pretrain
save_weight = full_sft
dataset = SFTDataset
train_loop = 和 pretrain 同构
```
暂时不要求:
```text
1. tools / tool_calls 的完整训练细节
2. thinking 数据比例控制
3. 多轮复杂截断策略优化
4. SFT 训练质量评估
5. LoRA SFT
```
<a id="l16-handwrite"></a>
## 5. 手写模块
本节你要补的是:
```text
course/impl/core/datasets.py::CourseSFTDataset
```
### 5.1 补 `__init__`
对齐源码:
```text
dataset/lm_dataset.py:58-66
```
你要实现的行为:
- 读取 jsonl 数据。
- 保存 `tokenizer` 和 `max_length`。
- 构造 `bos_id`:
```python
tokenizer(f"{tokenizer.bos_token}assistant\n", add_special_tokens=False).input_ids
```
- 构造 `eos_id`:
```python
tokenizer(f"{tokenizer.eos_token}\n", add_special_tokens=False).input_ids
```
第一版可以直接用 `datasets.load_dataset`,和原源码对齐。
### 5.2 补 `create_chat_prompt`
接口:
```python
def create_chat_prompt(self, conversations):
...
```
对齐源码:
```text
dataset/lm_dataset.py:71-86
```
你要实现的行为:
- 遍历 `conversations`。
- 普通 message 用 `dict(message)` 复制一份。
- 如果有 `tools/tool_calls` 字符串,转成 JSON。
- 调用 `tokenizer.apply_chat_template`。
- `tokenize=False`。
- `add_generation_prompt=False`。
### 5.3 补 `generate_labels`
接口:
```python
def generate_labels(self, input_ids: list[int]) -> list[int]:
...
```
对齐源码:
```text
dataset/lm_dataset.py:88-104
```
你要实现的行为:
- 创建 `labels = [-100] * len(input_ids)`。
- 扫描 `input_ids`。
- 找到 `bos_id` 后,从 assistant 内容开始。
- 找到 `eos_id` 后停止。
- 把 assistant 内容和 eos 区域的 labels 设为对应 `input_ids[j]`。
- 返回 labels。
### 5.4 补 `__getitem__`
对齐源码:
```text
dataset/lm_dataset.py:106-119
```
你要实现的行为:
```text
sample
-> pre_processing_chat
-> create_chat_prompt
-> post_processing_chat
-> tokenizer
-> truncate
-> pad
-> generate_labels
-> return LongTensor(input_ids), LongTensor(labels)
```
注意:
- 要从原项目导入并复用 `pre_processing_chat` 和 `post_processing_chat`,不要重写这两个工程细节。
- 返回 tensor dtype 必须是 `torch.long`。
- `input_ids` 和 `labels` 长度都必须等于 `max_length`。
<a id="l16-alignment-test"></a>
## 6. 对齐测试
本节新增对齐测试:
```text
course/impl/tests/test_sft_dataset_alignment.py
```
运行命令:
```bash
cd /home/sun/minimind
python course/impl/tests/test_sft_dataset_alignment.py
```
现在还没有实现时,这个测试会因为 `NotImplementedError` 失败。等你补完 `CourseSFTDataset` 后,它应该打印类似:
```text
sft_sample_0_input_diff=0
sft_sample_0_label_diff=0
sft_sample_0_non_ignored_labels=...
...
sft_dataset_alignment=passed
```
这个测试做了什么:
```text
1. 用同一个 tokenizer 读取 tiny_sft.jsonl。
2. 实例化原版 SFTDataset。
3. 实例化 CourseSFTDataset。
4. 对每条样本比较 input_ids。
5. 对每条样本比较 labels。
6. 确认至少有 assistant label 没被 mask。
```
如果 input diff 是 0,但 label diff 不是 0,通常说明 `generate_labels` 的 assistant 区域边界写错了。
<a id="l16-stage-assembly"></a>
## 7. 阶段组装
本节完成后,SFT 阶段会多出数据处理能力:
```text
course/impl/core/datasets.py::CourseSFTDataset
```
后续 `course/impl/train_sft_impl.py` 会这样组装:
```text
load tokenizer
-> load CourseMiniMindForCausalLM
-> load course_pretrain checkpoint 或 MiniMind pretrain 权重
-> CourseSFTDataset
-> DataLoader
-> train_one_epoch
-> save_course_checkpoint(save_weight="course_sft")
```
当前 SFT 阶段还缺:
```text
1. 教学版模型 forward/loss 完整组装
2. train_sft_impl.py 脚本入口
3. 从 course_pretrain 到 course_sft 的权重加载策略
4. SFT 后推理验证
```
第 16 课先把 SFT 数据目标和训练脚本结构对齐。
<a id="l16-check"></a>
## 8. 本节检查
1. Full SFT 和 Pretrain 的训练循环为什么可以几乎一样?
2. Full SFT 真正不同的地方在哪里?
3. 为什么 SFT 仍然使用 causal LM next-token loss?
4. `labels=-100` 在 SFT 里屏蔽了哪些 token?
5. `add_generation_prompt=False` 的原因是什么?
6. `from_weight='pretrain'` 会加载哪个文件?
7. `save_weight='full_sft'` 会保存成什么文件?
8. 如果 `input_ids.shape = [B, S]`,写出 `labels`、`logits`、`loss` 的形状。
<a id="l16-next"></a>
## 9. 下一课
第 17 课讲从 pretrain 权重到 full_sft 权重。
下一课要解决:
```text
如何真正跑一条 tiny pretrain -> tiny SFT 链路;
from_weight 和 from_resume 在阶段切换时有什么区别;
训练前后同一个 prompt 的输出如何变化;
教学版 train_sft_impl.py 如何组装并保存 course_sft 权重。
```