# 第 17 课:从 Pretrain 权重到 Full SFT 权重 这一课只解决一个问题:MiniMind 如何把预训练阶段产出的权重,接到 Full SFT 阶段继续训练,并最终被推理脚本加载。 ## 目录 - [0. 本节主线](#l17-mainline) - [1. 原理讲解](#l17-principle) - [2. 源码阅读顺序图](#l17-reading-order) - [3. MiniMind 源码走读](#l17-source-walkthrough) - [4. 本节必须会写 / 暂时不要求](#l17-must-write) - [5. 手写模块](#l17-handwrite) - [6. 对齐测试](#l17-alignment-test) - [7. 阶段组装](#l17-stage-assembly) - [8. 本节检查](#l17-check) - [9. 下一课](#l17-next) ## 0. 本节主线 MiniMind 原项目的阶段切换是: ```text train_pretrain.py -> 保存 out/pretrain_768.pth -> train_full_sft.py --from_weight pretrain -> init_model 加载 out/pretrain_768.pth -> 用 SFTDataset 继续训练 -> 保存 out/full_sft_768.pth -> eval_llm.py --weight full_sft -> 加载 out/full_sft_768.pth 推理 ``` 教学版实现的阶段切换应该是: ```text train_pretrain_impl.py -> 保存 out/course_pretrain_*.pth -> train_sft_impl.py --from_weight course_pretrain -> 加载 course_pretrain 权重 -> 用 CourseSFTDataset 继续训练 -> 保存 out/course_sft_*.pth -> 用同一套 tokenizer + 模型结构做推理验证 ``` 一句话: ```text from_weight 决定“从哪个已有权重开始训练”,save_weight 决定“这一阶段训练完保存成什么权重”。 ``` ## 1. 原理讲解 ### 1.1 阶段训练不是重新开始 Pretrain 阶段学到的是基础语言建模能力: ```text 普通文本 -> next-token prediction -> 学语言统计、知识片段、基本续写能力 ``` SFT 阶段不是把模型随机初始化后重新学,而是在 pretrain 参数基础上继续优化: ```text pretrain 参数 -> 对话格式数据 -> assistant 区域监督 -> 学会按用户指令回答 ``` 参数变化可以写成: $$ \begin{aligned} \theta_{\text{pretrain}} &= \mathrm{Train}_{\text{pretrain}}(\theta_0, D_{\text{pretrain}}) \\ \theta_{\text{sft}} &= \mathrm{Train}_{\text{sft}}(\theta_{\text{pretrain}}, D_{\text{sft}}) \end{aligned} $$ 其中: | 符号 | 类型 | 含义 | |---|---|---| | $\theta_0$ | 参数集合 | 随机初始化参数 | | $\theta_{\text{pretrain}}$ | 参数集合 | 预训练后参数 | | $\theta_{\text{sft}}$ | 参数集合 | SFT 后参数 | | $D_{\text{pretrain}}$ | 数据集 | 普通文本预训练数据 | | $D_{\text{sft}}$ | 数据集 | 对话监督微调数据 | 这就是为什么 `train_full_sft.py` 默认 `from_weight='pretrain'`。 ### 1.2 `from_weight` 和 `from_resume` 不是一回事 这两个参数很容易混。 `from_weight` 解决的是“阶段初始化”: ```text 从 out/pretrain_768.pth 初始化模型参数; 然后开始一个新的 SFT 阶段。 ``` `from_resume` 解决的是“同一阶段中断后继续”: ```text 从 checkpoints/full_sft_768_resume.pth 恢复; 继续未完成的 Full SFT 阶段。 ``` 对比: | 参数 | 读取文件 | 恢复内容 | 使用场景 | |---|---|---|---| | `from_weight='pretrain'` | `out/pretrain_768.pth` | 只恢复模型权重 | 从 pretrain 进入 SFT | | `from_resume=1` | `checkpoints/full_sft_768_resume.pth` | model / optimizer / scaler / epoch / step | SFT 中断后继续 | 所以阶段切换时,通常是: ```text from_weight='pretrain' from_resume=0 ``` 如果 SFT 训练中断,再恢复时才用: ```text from_resume=1 ``` ### 1.3 权重文件名是阶段接口 MiniMind 用权重名前缀表达阶段: ```text pretrain_768.pth full_sft_768.pth dpo_768.pth grpo_768.pth ``` 文件名规则是: $$ \begin{aligned} \text{weight\_path} &= \text{save\_dir}/(\text{weight}\_\text{hidden\_size}\text{moe\_suffix}.pth) \\ \text{moe\_suffix} &= \begin{cases} \text{"\_moe"}, & \text{use\_moe=True} \\ \text{""}, & \text{use\_moe=False} \end{cases} \end{aligned} $$ 例子: | 阶段 | `weight` | `hidden_size` | `use_moe` | 文件 | |---|---|---:|---|---| | Pretrain | `pretrain` | 768 | False | `out/pretrain_768.pth` | | Full SFT | `full_sft` | 768 | False | `out/full_sft_768.pth` | | MoE Pretrain | `pretrain` | 768 | True | `out/pretrain_768_moe.pth` | | MoE SFT | `full_sft` | 768 | True | `out/full_sft_768_moe.pth` | 这一套命名让训练脚本和推理脚本能通过同一个 `weight` 参数找到对应权重。 ### 1.4 SFT 为什么需要更小学习率 Pretrain 是从随机初始化开始,学习率可以大一些。 SFT 是在已有模型上微调,如果学习率太大,可能破坏 pretrain 学到的语言能力。MiniMind 默认: ```text pretrain learning_rate = 5e-4 full_sft learning_rate = 1e-5 ``` 可以理解成: $$ \eta_{\text{sft}} \ll \eta_{\text{pretrain}} $$ 这里 $\eta$ 表示学习率。 SFT 的目标不是让模型从零学语言,而是让参数在已有能力附近移动到“更会对话”的区域。 ### 1.5 推理时为什么 `pretrain` 和 `full_sft` prompt 不同 `eval_llm.py` 里有一个分支: ```text 如果 weight 名里包含 pretrain: 输入 = bos_token + 用户原文 否则: 输入 = chat_template(conversation, add_generation_prompt=True) ``` 原因是: ```text pretrain 权重主要学普通文本续写,不一定学会 chat template; full_sft 权重学过对话格式,推理时应该喂同样的 chat template。 ``` 这体现了一个重要原则: ```text 训练时用什么格式,推理时就尽量用什么格式。 ``` ### 1.6 阶段切换的变量流 从 pretrain 到 full_sft,关键变量是: | 变量 | 类型/形状 | 阶段 | 含义 | |---|---|---|---| | `from_weight` | str | SFT 启动 | 要加载的旧阶段权重名前缀 | | `save_weight` | str | SFT 保存 | 当前阶段保存的新权重名前缀 | | `weight_path` | str | 加载 | `out/{from_weight}_{hidden_size}.pth` | | `ckp` | str | 保存/推理 | `out/{save_weight}_{hidden_size}.pth` | | `input_ids` | `[B, S]` | 训练 | SFTDataset 输出 | | `labels` | `[B, S]` | 训练 | assistant 区域 label | | `logits` | `[B, S, V]` | forward | 模型输出 | | `loss` | 标量 | backward | SFT 训练损失 | 你要把这条线记住: ```text from_weight 只影响训练开始; save_weight 影响训练结束和后续推理。 ``` ## 2. 源码阅读顺序图 这节源码按这个顺序读: ```text README 训练命令 -> train_pretrain.py 保存 pretrain 权重 -> train_full_sft.py 默认 from_weight / save_weight -> trainer_utils.init_model 加载 from_weight -> train_full_sft.py 保存 full_sft 权重 -> eval_llm.py 根据 --weight 加载推理权重 -> eval_llm.py 根据 weight 选择 prompt 格式 ``` 对应文件: ```text README.md trainer/train_pretrain.py trainer/train_full_sft.py trainer/trainer_utils.py eval_llm.py ``` 这节不要再陷进 `SFTDataset` 内部,第 16 课已经讲过。现在重点是阶段之间的权重如何流动。 ## 3. MiniMind 源码走读 ### 第 1 步:README 给出的阶段顺序 File: `README.md:322-340` Read this to understand: 原项目推荐的最小训练顺序就是先 pretrain,再 full_sft,再 eval。 Code/config/template excerpt: ```bash cd trainer && python train_pretrain.py cd trainer && python train_full_sft.py python eval_llm.py --weight full_sft ``` This shows: - `train_pretrain.py` 先产出 pretrain 权重。 - `train_full_sft.py` 再基于 pretrain 权重训练。 - `eval_llm.py --weight full_sft` 用 SFT 后的权重推理。 ### 第 2 步:Pretrain 默认保存成 `pretrain` File: `trainer/train_pretrain.py:84-103` Read this to understand: pretrain 阶段的默认保存名前缀。 Code/config/template excerpt: ```python parser.add_argument('--save_weight', default='pretrain', type=str, help="保存权重的前缀名") parser.add_argument('--from_weight', default='none', type=str, help="基于哪个权重训练,为none则从头开始") parser.add_argument("--data_path", type=str, default="../dataset/pretrain_t2t_mini.jsonl", help="预训练数据路径") ``` This code shows: - Pretrain 默认从头开始,`from_weight='none'`。 - Pretrain 默认保存名前缀是 `pretrain`。 - 默认会写出 `out/pretrain_768.pth`。 ### 第 3 步:SFT 默认加载 `pretrain`,保存 `full_sft` File: `trainer/train_full_sft.py:84-104` Read this to understand: SFT 阶段如何接上 pretrain 阶段。 Code/config/template excerpt: ```python parser.add_argument('--save_weight', default='full_sft', type=str, help="保存权重的前缀名") parser.add_argument("--learning_rate", type=float, default=1e-5, help="初始学习率") 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则不基于任何权重训练") parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)") ``` This code shows: - SFT 默认从 `pretrain` 权重初始化。 - SFT 默认保存成 `full_sft`。 - SFT 默认学习率是 `1e-5`。 - `from_resume` 是同一阶段续训,不是阶段初始化。 ### 第 4 步:`init_model` 把 `from_weight` 变成文件路径 File: `trainer/trainer_utils.py:119-131` Read this to understand: `from_weight='pretrain'` 如何定位到真实 `.pth` 文件。 Code/config/template excerpt: ```python def init_model(lm_config, from_weight='pretrain', tokenizer_path='../model', save_dir='../out', device='cuda'): tokenizer = AutoTokenizer.from_pretrained(tokenizer_path) model = MiniMindForCausalLM(lm_config) 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` 只是前缀,不是完整路径。 - `save_dir` 默认是 `../out`。 - `hidden_size` 和 MoE 后缀会参与文件名。 - 训练脚本里的 `from_weight='pretrain'` 会加载 `../out/pretrain_768.pth`。 ### 第 5 步:SFT 保存的是 `full_sft` File: `trainer/train_full_sft.py:61-70` Read this to understand: SFT 训练后会写出普通权重和 resume checkpoint。 Code/config/template excerpt: ```python if (step % args.save_interval == 0 or step == iters) and is_main_process(): model.eval() moe_suffix = '_moe' if lm_config.use_moe else '' ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth' raw_model = model.module if isinstance(model, DistributedDataParallel) else model raw_model = getattr(raw_model, '_orig_mod', raw_model) state_dict = raw_model.state_dict() torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp) lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scaler=scaler) ``` This code shows: - `ckp` 使用 `args.save_weight`。 - 默认 `args.save_weight='full_sft'`。 - 普通推理权重会保存到 `out/full_sft_768.pth`。 - resume checkpoint 会保存到 `checkpoints/full_sft_768_resume.pth`。 ### 第 6 步:`eval_llm.py` 加载 native torch 权重 File: `eval_llm.py:12-30` Read this to understand: 推理时 `--weight full_sft` 如何加载权重。 Code/config/template excerpt: ```python tokenizer = AutoTokenizer.from_pretrained(args.load_from) if 'model' in args.load_from: model = MiniMindForCausalLM(MiniMindConfig( hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe), inference_rope_scaling=args.inference_rope_scaling )) moe_suffix = '_moe' if args.use_moe else '' ckp = f'./{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth' model.load_state_dict(torch.load(ckp, map_location=args.device), strict=True) else: model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True) ``` This code shows: - `--load_from ./model` 会走 native torch 权重加载。 - `--weight full_sft` 会参与构造 `./out/full_sft_768.pth`。 - `hidden_size`、`num_hidden_layers`、`use_moe` 必须和权重对应。 - `--load_from ./minimind-3` 这类路径会走 HuggingFace transformers 格式。 ### 第 7 步:推理 prompt 根据权重阶段变化 File: `eval_llm.py:71-78` Read this to understand: pretrain 权重和 SFT 权重推理输入格式不同。 Code/config/template excerpt: ```python conversation.append({"role": "user", "content": prompt}) if 'pretrain' in args.weight: inputs = tokenizer.bos_token + prompt else: inputs = tokenizer.apply_chat_template(conversation, tokenize=False, add_generation_prompt=True, open_thinking=bool(args.open_thinking)) inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device) ``` This code shows: - `pretrain` 权重用普通文本续写式输入。 - `full_sft` 权重用 chat template。 - SFT 后的模型推理格式要和 SFT 训练格式保持一致。 ## 4. 本节必须会写 / 暂时不要求 必须会写: 1. 阶段权重路径: $$ \begin{aligned} \text{pretrain\_path} &= \text{out}/\text{pretrain}_{H}.pth \\ \text{sft\_path} &= \text{out}/\text{full\_sft}_{H}.pth \end{aligned} $$ 2. 阶段初始化关系: $$ \theta_{\text{sft,start}} = \theta_{\text{pretrain}} $$ 3. 阶段训练更新: $$ \theta_{\text{sft,end}} = \mathrm{Train}_{\text{sft}}(\theta_{\text{sft,start}}, D_{\text{sft}}) $$ 4. 推理加载关系: ```text eval_llm.py --load_from ./model --weight full_sft -> ./out/full_sft_768.pth ``` 5. 区分: ```text from_weight: 阶段开始时加载哪个普通权重 save_weight: 阶段结束时保存成哪个普通权重 from_resume: 当前阶段中断后恢复 optimizer/scaler/epoch/step ``` 暂时不要求: ```text 1. 真正训练出高质量 full_sft 权重 2. HuggingFace 格式转换 3. WebUI / OpenAI API 部署 4. LoRA 合并 5. 多阶段自动流水线脚本 ``` ## 5. 手写模块 本节你要看的是: ```text course/impl/train_sft_impl.py ``` 这节已经给它补了阶段脚本参数骨架。你要理解每个默认值为什么这样设置: ```python parser.add_argument("--data_path", default="course/labs/tiny_sft.jsonl") parser.add_argument("--from_weight", default="course_pretrain") parser.add_argument("--save_weight", default="course_sft") parser.add_argument("--learning_rate", type=float, default=1e-5) parser.add_argument("--accumulation_steps", type=int, default=1) ``` 教学版不用直接复用 `pretrain/full_sft` 这两个名字,而是用: ```text course_pretrain course_sft ``` 这样可以避免和原项目训练出来的正式权重混在一起。 ### 5.1 本节你要补的不是训练逻辑 完整 `train_sft_impl.py` 还需要: ```text CourseMiniMindForCausalLM CourseSFTDataset train_one_epoch save_course_checkpoint load_course_checkpoint ``` 这些模块有些还没全部实现。所以第 17 课先只要求你把阶段参数理解清楚。 真正要你能写出来的是: ```text from_weight 默认是 course_pretrain save_weight 默认是 course_sft data_path 默认是 tiny_sft.jsonl learning_rate 默认比 pretrain 小 ``` ### 5.2 后续组装时的伪流程 后续实现 `main()` 时应该长这样: ```text args = parse_args() tokenizer = AutoTokenizer.from_pretrained(args.tokenizer_path) model = CourseMiniMindForCausalLM(...) load weights from args.from_weight dataset = CourseSFTDataset(args.data_path, tokenizer, args.max_seq_len) loader = DataLoader(dataset, ...) optimizer = AdamW(model.parameters(), lr=args.learning_rate) train_one_epoch(...) save_course_checkpoint(..., weight=args.save_weight) ``` 注意:这里是流程说明,不是让你现在把所有代码一次写完。 ## 6. 对齐测试 本节新增一个轻量测试: ```text course/impl/tests/test_sft_stage_args.py ``` 运行命令: ```bash cd /home/sun/minimind python course/impl/tests/test_sft_stage_args.py ``` 这个测试不训练模型,只检查 SFT 阶段脚本默认参数是否符合课程路线: ```text data_path=course/labs/tiny_sft.jsonl from_weight=course_pretrain save_weight=course_sft learning_rate=1e-05 sft_stage_args=passed ``` 它的意义是:先把阶段接口固定,再逐步把数据、模型、训练循环接进去。 ## 7. 阶段组装 这一课结束后,你应该能画出两条阶段链。 原项目链路: ```text trainer/train_pretrain.py -> out/pretrain_768.pth -> trainer/train_full_sft.py --from_weight pretrain -> out/full_sft_768.pth -> eval_llm.py --load_from ./model --weight full_sft ``` 教学版链路: ```text course/impl/train_pretrain_impl.py -> out/course_pretrain_*.pth -> course/impl/train_sft_impl.py --from_weight course_pretrain -> out/course_sft_*.pth -> 教学版推理或原生加载验证 ``` 当前还缺的实现: ```text 1. CourseMiniMindForCausalLM 完整组装 2. CoursePretrainDataset 3. CourseSFTDataset 4. train_one_epoch 完整实现 5. save_course_checkpoint / load_course_checkpoint 完整实现 ``` 这几个补齐后,SFT 阶段就能真正跑起来。 ## 8. 本节检查 1. `train_pretrain.py` 默认保存成什么权重名前缀? 2. `train_full_sft.py` 默认从哪个权重名前缀加载? 3. `train_full_sft.py` 默认保存成什么权重名前缀? 4. `from_weight` 和 `from_resume` 的区别是什么? 5. `eval_llm.py --load_from ./model --weight full_sft` 会加载哪个文件? 6. 为什么 `pretrain` 权重推理时不用 chat template,而 `full_sft` 权重推理时要用? 7. 教学版为什么用 `course_pretrain` 和 `course_sft`,而不是直接覆盖 `pretrain/full_sft`? 8. 如果 `hidden_size=512` 且 `use_moe=True`,`save_weight='full_sft'` 对应的权重文件名是什么? ## 9. 下一课 第 18 课进入 LoRA 原理和手写实现。 下一课要解决: ```text 为什么 LoRA 可以只训练低秩增量; LoRA 如何插到 Linear 层; 哪些参数冻结,哪些参数训练; LoRA 权重如何保存和加载; 教学版 core/lora.py 应该怎么实现。 ```