训练记录
本文记录实际执行过的训练。所有数字来自 runs/<运行名>/ 下的日志和摘要文件;汇总表由脚本生成,见 训练运行汇总。未执行的部分在文末单独列出。
1. 环境检查
| 项目 | 实际情况 |
|---|---|
| 设备 | Apple M5(4 性能核 + 6 能效核),24 GB 统一内存 |
| GPU | 集成 10 核 GPU,Metal 4;没有 NVIDIA 显卡,CUDA 不可用 |
| 系统 | macOS 27.0.1(arm64) |
| Python | 3.12.13,uv 创建的项目内虚拟环境 .venv |
| PyTorch | 2.14.1,MPS 后端可用 |
| Transformers | 4.57.6(与上游 requirements.txt 固定的版本一致) |
| 磁盘 | 可用约 700 GB |
2. 上游兼容性确认
使用的上游版本:MiniMind 提交 f659b55761b754d306bd140573493a6543cafd7f(2026-09-23,minimind-3 主线)。
| 检查项 | 结论 | 怎么确认的 |
|---|---|---|
| 模型结构 | 8 层、hidden 768、8 个注意力头(4 个 KV 头)、RoPE、RMSNorm,词嵌入与输出层共享权重 | 阅读 model/model_minimind.py;实例化后统计参数量为 63.91M |
| 官方权重 | full_sft_768.pth、pretrain_768.pth 均可用 strict=True 加载 |
下载后 SHA256 与 ModelScope 接口返回值一致;加载无缺失或多余的键 |
| 分词器 | 6400 词表的 BPE;特殊符号 <|im_start|> <|im_end|>;生僻字退化为字节 |
对含表情、英文、数字的句子做编码再解码,结果与原文一致 |
| 对话模板 | ChatML 格式;关闭思考时在助手回答前补一段空的 <think></think> |
打印 apply_chat_template 的输出 |
| 数据格式 | 上游 SFT 数据是 {"conversations": [...]};本项目用自己的 JSONL,在训练时套用同一个对话模板渲染 |
阅读 dataset/lm_dataset.py |
| LoRA | 上游实现只给输入输出维度相等的线性层加适配器。在这个结构里就是每层的 q_proj 和 o_proj,共 16 个,rank 16 |
阅读 model/model_lora.py;统计可训练参数为 0.393M |
| 训练脚本 | 上游脚本的混合精度写死为 CUDA,没有验证环节;本项目重写了训练循环 | 阅读 trainer/train_full_sft.py;差异见 技术架构 |
加载官方权重后先做了一次推理,确认“训练前”的起点:
输入:新版本一打开就闪退,根本用不了
基础模型输出:1. 新版本一打开就闪退,根本用不了
2. 新版本一打开就闪退,根本用不了
3. 新版本一打开就闪退,…
未微调的模型不知道目标格式,这是意料之中的。
3. 路线选择
| 路线 | 需要什么 | 本机可行性 | 结论 |
|---|---|---|---|
| 从零预训练 + 对话微调 + 任务微调 | 上游语料约 2.8 GB;上游给出的经验值是单张 RTX 3090 约 2.3 小时 | 本机 MPS 实测训练吞吐约 35 条/秒(序列长约 100)。预训练语料是数百万条、序列长 768,外推需要数十小时以上 | 不执行,只提供命令和估算,见 GPU 训练指南 |
| 在官方权重上全参数微调 | 官方权重 138 MB;任务数据约 5 千条 | 一次约 7 分钟 | 执行 |
| 在官方权重上 LoRA 微调 | 同上 | 可训练参数只有 0.6%,同样是分钟级 | 执行,与全参数对比 |
选择理由:
- 任务不需要重新学语言。 官方权重已经在同样的结构和分词器下学过中文,任务微调要教的只是“读一句反馈、按格式输出”。
- 模型很小,全参数微调没有资源压力。 LoRA 通常是为了省显存和存储;64M 的模型全参数训练只占约 9 GB 统一内存。所以两种都跑,按验证集结果选,而不是预设 LoRA 更好。
- 用对照实验回答“预训练权重有多大用”。 额外跑一组:同结构随机初始化、只用任务数据训练。
准确的说法:本项目发布的 FeedLens-MiniMind 是基于 MiniMind 官方权重微调的模型,不是从零训练的模型。 对照组里的 scratch-v1 才是随机初始化训练的,但它只见过约 5 千条任务数据,仅用于对比。
设备选择的实测依据(无其他任务干扰时的短测,批大小 32):
| 设备 | 训练吞吐 |
|---|---|
| MPS | 约 35 条/秒 |
| CPU | 约 27 条/秒 |
4. 冒烟测试
正式训练前先用小配置确认流程是通的。配置:configs/smoke.yaml(随机抽 256 条训练、128 条验证、2 轮、批大小 16)。
实际日志(runs/smoke/train.log,MPS,用时 20 秒):
step 0 val_loss 2.4871 (训练前)
step 4 loss 4.9659
step 8 loss 2.6147 val_loss 1.1870 ← 最佳,已保存 best.pth
step 16 loss 0.5855 val_loss 0.5329 ← 最佳
step 24 loss 0.2829 val_loss 0.4013 ← 最佳
step 32 loss 0.1688 val_loss 0.3177 ← 最佳
结束:steps=32/32 best_val=0.3177@32 用时=20s 参数已更新=True
把保存下来的 runs/smoke/best.pth 重新加载并推理:
$ python -m feedlens.infer --weights runs/smoke/best.pth "一打开就闪退"
类别:性能体验
情感:负面
紧急度:中
摘要:启动速度慢
[格式合规 · 21 tokens · 109 ms]
只训练了 32 步、256 条样本,格式已经学会,标签还是错的(应为 功能故障 / 高)。冒烟测试只验证流程,这个权重不用于任何结论。
四项检查:
| 检查 | 结果 | 依据 |
|---|---|---|
| 数据被正确读取 | 通过 | 日志首行的样本数与配置一致;数据构建时已校验每条样本的目标输出都能被解析器读回 |
| 损失被正常记录且下降 | 通过 | 见上方日志;脚本在损失出现非有限值时会直接中止 |
| 模型参数确实更新 | 通过 | summary.json 中 params_updated: true,训练前后参数绝对值之和不同 |
| 权重保存后可重新加载并推理 | 通过 | 自动化测试 test_train_updates_params_and_reload_matches:重新加载后的验证损失与保存时一致,并能完成一次生成 |
5. 正式实验
第一轮的三组实验使用同一份训练集(5146 条)、同一份验证集(969 条)、同一随机种子(42)、同一批大小(32),在 MPS 上顺序执行。第二轮(v0.2)只换数据。
| 运行 | 配置 | 起点 | 训练数据 | 目的 | 状态 |
|---|---|---|---|---|---|
ft-full-v1 |
configs/full.yaml | 官方 full_sft |
v1 | 主线候选:全参数微调 | 已执行 |
ft-lora-v1 |
configs/lora.yaml | 官方 full_sft |
v1 | 主线候选:LoRA | 已执行 |
scratch-v1 |
configs/scratch.yaml | 随机初始化 | v1 | 对照:没有预训练权重会怎样 | 已执行 |
ft-full-v2 |
configs/full_v2.yaml | 官方 full_sft |
v2 | 第二轮:依据失败案例改数据有没有用 | 已执行 |
ft-full-from-pretrain-v1 |
configs/full_from_pretrain.yaml | 官方 pretrain |
v1 | 对照:上游的对话微调阶段有没有帮助 | 未执行(配置已写好,权重已下载,为控制总时长没有跑) |
ft-lora-v2 |
configs/lora_v2.yaml | 官方 full_sft |
v2 | LoRA 在 v2 数据上的表现 | 未执行 |
5.1 结果汇总
完整表格由脚本生成,见 训练运行汇总。主要数字:
| 运行 | 训练样本 | 可训练参数 | 步数 | 训练前验证损失 | 最低验证损失(所在步数) | 结束时训练损失 | 用时 |
|---|---|---|---|---|---|---|---|
ft-full-v1 |
5146 | 63.91M(100%) | 483 | 2.460 | 0.193(第 483 步) | 0.0001 | 931 秒 |
ft-lora-v1 |
5146 | 0.393M(0.61%) | 966 | 2.460 | 0.149(第 350 步) | 0.0015 | 1125 秒 |
scratch-v1 |
5146 | 63.91M(100%) | 483 | 9.044 | 0.200(第 450 步) | 0.0061 | 692 秒 |
ft-full-v2 |
8408 | 63.91M(100%) | 789 | 2.513 | 0.191(第 150 步) | 0.00005 | 1182 秒 |
四次运行的 params_updated 均为真,均跑满预定步数。全参数训练时 MPS 已分配内存约 9.2–9.6 GB,LoRA 约 7.2 GB。
用时说明:这些是墙钟时间,而且训练期间机器上并不总是空闲。ft-full-v1 训练时我同时在 CPU 上跑评测,速度掉到平时的一半左右。所以这一列不能用来比较训练方式的快慢;无干扰时的吞吐见第 3 节。
ft-full-v1 和 ft-full-v2 的验证集不同(val 与 val_v2),两者的验证损失不能直接比较。
5.2 曲线
曲线上的点都是训练时记录的原始值,没有平滑。
5.3 从曲线里看到的
训练集被完全记住了,验证损失却停在 0.2 附近。 ft-full-v1 的训练损失在第 300 步降到 0.002,结束时是 0.0001;验证损失在第 50 步就到了 0.21,之后一直在 0.19–0.22 之间波动。验证集里是训练中没见过的核心说法,这个差距说明:模型把 247 种训练说法背了下来,对新说法的泛化在很早就停止进步了。瓶颈在数据的多样性,不在训练步数。 多训几轮不会有帮助。
从零训练也能把验证损失压到 0.20。 scratch-v1 从 9.04 降到 0.200,和微调的 0.193 相差无几。只看这个数字会以为预训练权重没什么用。实际的生成准确率差得很远(验证集三标签全对 58.7% 对 78.9%),见 评测报告。
验证损失最低的不是准确率最高的。 LoRA 的验证损失(0.149)明显低于全参数(0.193),但它在验证集上的三标签全对率只有 64.0%,全参数是 78.9%。一个可能的解释(没有专门验证):两者犯错的方式不同。全参数微调的模型把训练集完全记住后,对错误答案也非常自信,而交叉熵对“自信的错误”罚得很重;LoRA 的输出分布更平缓,损失低,但取最大概率的那个答案时错得更多。
这直接影响了检查点的选择,见第 7 节。
6. 断点续训
训练每隔 eval_interval 步保存一次 checkpoint_last.pt,内容包括可训练参数、优化器状态、已完成步数、最优验证损失。写入方式是先写临时文件再原子替换,中途被杀不会留下损坏的检查点。
python -m feedlens.train --config configs/full.yaml --resume
续训时会核对当前配置与检查点里保存的配置(训练方式、批大小、种子、轮数、学习率、模型尺寸、数据划分),不一致则拒绝继续。每一轮的样本顺序只由(种子,轮次)决定,所以能精确跳到中断的位置。
验证方式:自动化测试 tests/test_pipeline.py::test_resume_is_bit_identical。它用一个很小的随机初始化模型做两次训练:一次从头跑完;另一次在第 6 步停下,再用 --resume 跑完。然后断言两者最终的权重逐位相同。该测试已通过。
局限:这项逐位一致的验证是在 CPU 上、用小模型做的。在 MPS 上,浮点运算的顺序不保证完全确定,续训结果应当在数值上非常接近,但我没有验证过逐位相同。
7. 发布哪个检查点
7.1 选择规则
在第一轮训练之前定下的规则是“按验证集结果选”。具体执行时分两层:
- 同一次训练内选哪一步:训练脚本保存验证损失最低的检查点(
best.pth)。 - 不同训练之间选哪一个:比较各候选在
val上的三标签全对率。
test 和 ood_test 不参与任何选择。
用 val 而不是 val_v2 来做跨版本比较的原因:val_v2 新增的那部分样本与 v2 训练数据来自同一批新增语法,天然对 v2 模型有利;val 对所有候选都是同等独立的。
7.2 发现第一层规则有问题
评测时发现,验证损失最低的检查点并不是生成准确率最高的。为了确认,我把两次训练结束时的权重也拿出来评了一遍(训练脚本随后改为每次都另存 last.pth):
| 训练 | 检查点 | 验证损失 | val 三标签全对率 |
|---|---|---|---|
| LoRA(v1 数据) | 验证损失最低,第 350 步 | 0.149 | 64.0% |
| LoRA(v1 数据) | 训练结束,第 966 步 | 0.160 | 69.0% |
| 全参数(v2 数据) | 验证损失最低,第 150 步 | 0.191 | 68.3% |
| 全参数(v2 数据) | 训练结束,第 789 步 | 0.207 | 75.0% |
两次都是验证损失更高的那个检查点更准。其中 LoRA 这一对的差距(5.1 个百分点)按核心表达聚类的 95% 区间是 +1.7 到 +9.1,不含 0;v2 那一对的区间是 −2.0 到 +15.3,方向一致但不能排除偶然。
第 150 步连一轮都没训完(一轮是 263 步),按验证损失把它选成“最佳”显然不合理。
结论:对这个任务,应当按验证集上的生成准确率选检查点,而不是按验证损失。 这一轮没有来得及把选模指标改进训练脚本,记为下一步的第一项改动。本报告因此同时给出两种检查点的结果,不替换、不隐藏。
7.3 候选模型在验证集上的表现
| 候选 | val 三标签全对率 |
按核心表达聚类的 95% 区间 |
|---|---|---|
ft-full-v1(全参数,v1 数据) |
78.9% | 69.8–86.9 |
ft-full-v2-last(全参数,v2 数据,训练结束时) |
75.0% | 65.3–84.1 |
ft-lora-v1-last(LoRA,训练结束时) |
69.0% | 59.3–78.2 |
ft-full-v2(全参数,v2 数据,验证损失最低) |
68.3% | 59.3–76.8 |
ft-lora-v1(LoRA,验证损失最低) |
64.0% | 54.1–73.6 |
scratch-v1(随机初始化) |
58.7% | 48.7–68.8 |
| 参考:关键词规则 | 74.3% | 64.7–83.1 |
7.4 决定
发布 ft-full-v1,命名为 FeedLens-MiniMind v0.1.0。 它在 val 上最高,且它“验证损失最低”的检查点恰好就是训练结束时的那一步(第 483 步),不受 7.2 的问题影响。
打包命令:python -m feedlens.package --run ft-full-v1 --name feedlens-minimind-v0.1 --version 0.1.0。产物在 models/feedlens-minimind-v0.1/,说明卡见 模型说明卡。
需要说明的三点:
- 区间很宽,候选之间大多分不出高下。
ft-full-v1与ft-full-v2-last相差 3.9 个百分点,差值的 95% 区间是 −13.6 到 +5.5。验证集有 969 条,但只来自 69 个核心表达,真正独立的观测只有几十个。 - 这个选择在测试集上并不是最好的。
ft-full-v1在test上是 66.8%,低于ft-lora-v1-last的 71.4% 和ft-full-v2-last的 73.7%。我没有因此改选——按测试集选模型,测试集就不再是测试集了。这件事本身说明:只有一份验证集、一个随机种子时,“选出最好的模型”这一步的可靠性很有限。 - v0.2(v2 数据)没有发布。 它在自己针对的数据上进步很大,但在独立的
ood_test上没有进步,详见 评测报告 第 7 节。
8. 完整训练的资源估算
“完整训练”在这里有两种含义,都没有执行:
| 场景 | 估算 | 估算依据 |
|---|---|---|
| 数据扩大 10 倍(约 5 万条,3 轮),仍在本机 | 约 74 分钟 | 本机实测吞吐约 35 条/秒线性外推 |
| 从零预训练 + 对话微调(上游语料),单张 RTX 3090 | 约 2.3 小时,租卡约 3 元 | 上游 README 给出的经验值,非本项目实测 |
命令和注意事项见 GPU 训练指南。
9. 未执行的事项
- 没有在 NVIDIA GPU 上运行过任何训练;
configs/gpu_full.yaml未经实测。 - 没有做从零预训练。
ft-full-from-pretrain-v1和ft-lora-v2两组配置已写好但没有运行。- 没有做超参数搜索。每种训练方式只跑了一组超参数、一个随机种子,因此不同方式之间的小差距可能只是随机波动,不能据此断言某种方式更好。
- 没有尝试上游提供的蒸馏、DPO、强化学习等其他训练方式。
本页由 docs/08-training.md 生成。