FeedLens反馈透镜

训练记录

本文记录实际执行过的训练。所有数字来自 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%,同样是分钟级 执行,与全参数对比

选择理由:

  1. 任务不需要重新学语言。 官方权重已经在同样的结构和分词器下学过中文,任务微调要教的只是“读一句反馈、按格式输出”。
  2. 模型很小,全参数微调没有资源压力。 LoRA 通常是为了省显存和存储;64M 的模型全参数训练只占约 9 GB 统一内存。所以两种都跑,按验证集结果选,而不是预设 LoRA 更好。
  3. 用对照实验回答“预训练权重有多大用”。 额外跑一组:同结构随机初始化、只用任务数据训练。

准确的说法:本项目发布的 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 选择规则

在第一轮训练之前定下的规则是“按验证集结果选”。具体执行时分两层:

  1. 同一次训练内选哪一步:训练脚本保存验证损失最低的检查点(best.pth)。
  2. 不同训练之间选哪一个:比较各候选在 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/,说明卡见 模型说明卡。

需要说明的三点:

8. 完整训练的资源估算

“完整训练”在这里有两种含义,都没有执行:

场景 估算 估算依据
数据扩大 10 倍(约 5 万条,3 轮),仍在本机 约 74 分钟 本机实测吞吐约 35 条/秒线性外推
从零预训练 + 对话微调(上游语料),单张 RTX 3090 约 2.3 小时,租卡约 3 元 上游 README 给出的经验值,非本项目实测

命令和注意事项见 GPU 训练指南。

9. 未执行的事项

本页由 docs/08-training.md 生成。