写机器学习周报写到第四十期,说实话这个数字比我预想中来得快。第四十周这七天,我主要把时间花在一件很具体的事情上:用LoRA微调一个本地7B开源底座模型,目标是让它能按固定格式回答某个垂直场景的指令。这篇周报不会只报喜,微调跑通之后,我又遇到一个让我半夜睡不着的eval loss异常,前前后后追了两天才定位到根因。如果你也在坚持机器学习相关的长期学习记录,或者正准备做本地模型的轻量微调,这篇内容多少能有点参考价值。
1. 四十周之后的周报,终于变成了一张“地图”
先聊聊周报本身。四十期,按每周一篇算就是四十周,接近十个月。回看第一期写的东西和现在相比,差别不只是技术深度,而是整个记录逻辑都变了。前几期我的目标是“今天把某某算法跑通”,周报写出来像打卡;现在我的目标变成“这周弄明白一件事,并且能证明我确实明白”,周报更像一张地图。
1.1 周报从打卡变成了复盘
最开始写周报是痛苦的。一周下来没做几个大任务,就觉得没法落笔,结果要么记流水账,要么把别人的教程要点摘抄一遍,当时看有东西可交,事后看完全不知道自己在说什么。后来我给自己定死了四个问题:本周解决了什么问题、为什么选这个方案、踩了什么坑、下周是继续还是换方向。只允许回答这四个问题之后,周报立刻好写了,因为“跑通”在周报里不算答案,知道为什么跑通才算。
前二十期我还有个毛病,总想在下一次周报里塞一个“看起来厉害”的新主题,于是经常出现上周刚学完X,这周就跳到Y的情况。四十周之后再回头看,那些能形成积累的周,几乎都是围绕同一条主线连续推进的;而那些看起来很热闹的跳跃式学习,最后基本都断线了,连当时的代码都找不回来。所以现在我对周报的定位很明确:它不是给别人看的进度条,是给两周后的自己留的操作说明书。
1.2 四十周路线沉淀:四阶段回顾
四十周不算短,我按周数和主线内容大概整理了一下,其实分成了四个阶段:
| 阶段 | 周数范围 | 主线内容 | 这一阶段留下的核心资产 |
|---|---|---|---|
| 第一阶段 | 第1-8周 | Python、数据分析、线性代数与概率统计基础 | 稳定的代码习惯,能用数据工具完成清洗和可视化 |
| 第二阶段 | 第9-16周 | 经典机器学习算法、树模型与集成学习 | 建立评估意识,知道什么是过拟合,怎么做交叉验证 |
| 第三阶段 | 第17-32周 | 深度学习基础、NLP与CV两个方向的入门项目 | 能读懂论文核心思路,能把结构相对简单的模型复现出来 |
| 第四阶段 | 第33-40周 | 开源底座模型、RAG、微调与本地部署 | 低成本微调链路,能独立跑通从数据准备到模型部署的闭环 |
这个划分不是规划出来的,是被踩坑逼出来的。第二阶段我其实有点着急,总觉得经典机器学习不如深度学习酷,第八周之后就想往神经网络钻,结果到第十八周做序列任务时发现自己对特征工程和概率分布的直觉太弱,又回头补了好几周。如果重来一次,我会在集成学习上多停留几周,把偏差方差分解、树模型的可解释性这些内容吃得更透。
进入第四阶段之后,一个明显的转折是我对“机器学习的周期感”有了体会:一个项目从拿到数据到能上线,绝大多数时间花在数据清洗、模板设计、评估口径反复对齐上,真正模型的训练时间反而是小头。这也是第40周的主线选择背景——我不想再停留在“跑通别人的示例”上,而是想完整地把一个微调任务从零做到底。
2. 本周主线:本地7B模型的LoRA微调实验
第40周的主线任务不是凭空定的。从第35周开始,我连续在RAG和提示词优化上做尝试,但某个固定场景的效果始终卡在一个瓶颈上:不是检索不到相关内容,而是底座模型拿到内容之后,回答格式总是不够稳定。于是我决定进入微调环节,用LoRA低成本地调出一个更适合该场景的模型。
2.1 为什么在这个节点上选LoRA
先说为什么不是全量微调。本地机器只有一张24GB显存的显卡,7B规模的开源底座模型如果做全量微调,光是AdamW优化器的状态就要占掉参数量好几倍的显存,单卡根本塞不下;即使有多卡,全量微调的收敛稳定性也更难控制。LoRA的思路完全不同:冻结原始权重不动,在目标模块的旁边插入低秩矩阵,只训练这部分极小的参数。这样可训练参数量通常只有原模型的百分之几,显存占用大幅下降,效果在很多指令微调场景下又非常接近全量微调。
理解LoRA有个关键点:低秩矩阵和缩放系数不是随便设的。常见配置rank=8,alpha=16,也就是说最终更新量要乘以alpha/rank这个系数,16除以8等于2,更新幅度大概是0到2倍之间。alpha比rank大的时候,微调强度会相对高;如果让alpha远大于rank,容易把底座模型带偏,出现所谓的灾难性遗忘。我实测下来,指令微调场景r=8、alpha=16是一个很稳妥的起点,跑出来效果不对再按倍数调整,而不是一次改一个特别激进的组合。
还有一个容易忽略的选型点:target_modules的选择。如果你的目标是通用对话能力,覆盖q_proj和v_proj就够用;如果希望模型更充分地在特定领域内改变行为,最好把注意力投影层都包进去,也就是同时配置k_proj、q_proj、v_proj、o_proj。我这次用了四个投影层全量覆盖,训练时间稍微长一点,但回答稳定性比只调q/v要好不少。
2.2 数据与模板:最容易被低估的环节
微调效果好不好,数据模板的重要性占到一半以上,这一点我是在连续调Prompt失败后才彻底意识到的。LoRA只负责改变模型的行为倾向,但如果喂给模型的数据本身格式混乱,它学到的东西就是混乱的。我的做法是构造了一个约5000条的指令微调数据集,来源是某公开清洗后的对话集合,每条样本统一成“指令+回答”两段式。
def build_sample(instruction, response): # 模板符号必须全局统一,不能训练集一套验证集另一套 prompt = f"<指令>{instruction}\n<回答>" full_text = prompt + response + "<结束>" return full_text def mask_labels(tokenizer, full_ids): labels = full_ids.copy() # 只有<回答>之后的部分参与loss计算,指令部分全部置为-100 sep_id = tokenizer.convert_tokens_to_ids("<回答>") sep_pos = full_ids.index(sep_id) labels[:sep_pos] = [-100] * sep_pos return labels训练阶段我用了padding和截断,统一max_length为1024。这里有个很容易踩的细节:很多底座模型的tokenizer没有预设pad_token,如果不显式把pad_token_id设成eos_token_id,训练时padding位置会填成未知token,轻则浪费显存,重则让模型在生成时把填充符号当成有效内容。我这次一开始就处理了这个问题,没有在它身上浪费时间。
把数据准备好之后,我意识到另一个真相:模型并不会自动理解“指令”这两个字的含义,它理解的是你在数据里反复出现的模板结构。模板里的特殊符号一旦定下来,整个数据集和后续验证脚本都要保持一致,任何一处不一致都会让评估结果失真。这个点直接埋下了后文的坑。
2.3 配置与显存实测
下面是我本次实验实际用的配置,实测在单张24GB显存卡上能稳定跑完,峰值显存约12到13GB,余量比较多,如果有更大batch的需求还能往上加。
| 配置项 | 数值/内容 |
|---|---|
| 底座模型 | 某个7B开源底座模型 |
| 量化方式 | 4bit加载(加载时用bnb量化) |
| LoRA rank | 8 |
| LoRA alpha | 16 |
| LoRA dropout | 0.05 |
| 目标模块 | k_proj、q_proj、v_proj、o_proj |
| per_device_train_batch_size | 1 |
| gradient_accumulation_steps | 4 |
| max_length | 1024 |
| 优化器 | AdamW,lr=2e-4,warmup_ratio=0.03 |
| 训练数据 | 约5000条,2个epoch |
训练本身花费大约三小时二十分钟,速度不是亮点,真正的问题出现在训练结束之后。我满怀期待地跑了验证评估,结果弹出的数字让我一下清醒了:训练loss一路从1.8降到0.7,eval loss却从1.2涨到了1.5,这个对比怎么看怎么像灾难现场。
3. 一个让我半夜醒来的踩坑:loss在降,eval却一路飞升
你如果也做过微调,大概率见过这种场景:训练集上的loss平稳下降,图很漂亮,但验证集上的表现却越来越差。常规解释是过拟合,我当时也是这么判断的,事实证明这个“第一反应”恰恰让我多绕了一大圈。
3.1 现象与第一轮错误判断
训练日志显示,第一个epoch结束时eval loss最低,继续训练到第二个epoch,eval loss开始反弹。我当时的反应很自然:这模型过拟合了,于是先加了dropout,把LoRA dropout从0.05提到0.1,又把epoch从2改回1,重新训练之后eval依然没有改善。这时候我开始怀疑是学习率的问题,又把lr降到1e-4,结果eval loss还是维持在高位。
那两天做的最蠢的事是我连续改参数重训了三轮,每次都花三四个小时,最后得到一堆“好像有点用但说不清哪里有用”的结果。直到我停下来仔细看验证脚本,才意识到根子可能根本不在训练上,而在评估本身。训练loss和eval loss走的根本不是同一套计算口径。
3.2 用排除法找到真凶:验证集的“prompt泄漏”
我重新按顺序排查了一遍,这组排查链路值得记下来:
- 检查训练集和验证集是否有重复样本,用文本哈希对比,结果无重复,排除数据泄漏。
- 检查tokenizer的padding和truncation是否一致,确认pad_token已设置,排除填充符号问题。
- 检查训练时labels的计算方式:只有回答部分参与loss计算,指令部分全部置为-100,没问题。
- 检查验证时labels的计算方式:问题就出在这里,验证脚本直接拿完整序列计算交叉熵,没有把指令部分屏蔽掉。
这个问题的本质是prompt泄漏,不是数据泄漏,而是评估口径泄漏。训练时模型确实在学会“只看回答部分怎么生成”,但验证时我又让它去预测指令部分的每个token。指令部分的模板符号和引导语对底座模型来说并不是零困惑度的,它们大多在原始预训练里没有以这种固定顺序出现过,所以模型在这些位置上本来就表现得很差。于是eval loss被一大段prompt token拖高,看起来就像越训练越差。
更隐蔽的是,这种问题在训练曲线上的表现和过拟合几乎一模一样,loss下降但eval上升,如果第一反应是调正则、调lr,只会白白浪费大量训练时间。正确的第一反应应该是:先确认train和eval两条链路唯一的区别是数据而不是度量口径。
修复起来其实一句话:验证时也要给prompt部分打-100的掩码,只对回答部分计算loss。我改完验证脚本之后,eval loss立刻回到0.75左右,与训练loss的趋势基本同步。那一刻的感觉很复杂,一方面庆幸不是模型有问题,另一方面懊恼为什么第三天才发现。
3.3 修复后的对比与本轮经验
修复后的对比很直观:同一个模型权重、同一份验证集,eval loss从1.5降到0.75,完全是脚本改动带来的差异。从这件事里我提炼出一条原则:训练脚本和验证脚本必须共享同一套数据构建与掩码逻辑,哪怕只是一个小函数,也要抽成公共模块,而不是各写各的。
| 症状 | 第一反应 | 实际根因 |
|---|---|---|
| train loss下降,eval loss上升 | 过拟合,加dropout、减epoch | eval未对prompt部分做mask,度量口径不一致 |
| 改参数重训多轮仍无改善 | lr过高,继续降lr | 模型本身没问题,问题在验证脚本 |
这轮也让我对可视化更加警惕:只看loss曲线是不够的,还必须知道这条曲线是怎么算出来的。同一个指标,mask方式不同、损失函数对padding的处理不同、甚至batch拼装顺序不同,都会得到差异很大的数字。以后我每次写评估脚本,都会在周报里加一条:eval口径是什么版本。
4. 翻旧笔记时发现:我一直在重复犯同一个错误
第三周修复完eval问题之后,为了写这期周报,我翻了一遍前面十几个笔记文件,越翻越觉得脸黑。很多周记录的结尾都是“本周跑通了某某Demo”,然后下一周直接换主题,那个Demo再也没有被提起过。四十周之后再看,这种“跑通即完成”的习惯,其实是我最贵的隐性开销。
4.1 “跑通即完成”的坏习惯
“跑通即完成”意味着什么?意味着我把复现别人的示例误当成了自己的产出。示例代码能跑,是因为作者已经把环境、数据格式、参数顺序都安排好了,我只是按回车。两周之后我回头想继续用这段代码时,常常连当时怎么启动的都忘了,因为笔记里根本没记录启动命令和依赖版本。
我统计了一下,第三阶段也就是第17到32周期间,我至少做了六个小项目,但真正在后来被再次复用、继续迭代的只有一个。剩下五个全部卡在“当时能跑,现在跑不起来”的状态。这个教训比任何一个模型技巧都值钱:机器学习的进步不是靠跑通代码,是靠留下可以复现的上下文。别人看你周报里的一个结论如果无法复现,这个结论的价值就大打折扣;自己看自己的旧代码复现不了,更是直接浪费了当初的时间。
4.2 本周开始强制执行的三个复盘动作
基于这个反思,从第40周开始我给自己的周报加了三个强制动作,准确说是给实验流程加了三个约束:
| 原来的动作 | 现在的替代动作 | 为什么有效 |
|---|---|---|
| 周报只写“跑通了” | 实验目录里新增README,写下启动命令、关键参数和选择理由 | 两周后的我能看懂上下文,能直接复现 |
| 实验配置靠脑子记 | 每次训练都写一行记录:模型、数据量、显存峰值、耗时、eval loss | 对比不同配置时有据可查,不需要重跑盲猜 |
| 从不回看旧结论 | 每周抽20分钟回看30天前的笔记,给每个结论打标签 | 能及时发现当时判断是否成立,避免带着错误认知继续走 |
第一个动作看起来最笨,其实最有用。给实验目录写README不需要文采,只要回答三个问题:怎么跑、为什么这样配、当时卡在哪。我这两周写下来的感觉是,写完了心里会踏实很多,因为代码不再是一个黑盒。
第二个动作让我意识到以前浪费了多少对比机会。以前我在不同周里试过不同的lr,因为没有统一记录,根本分不清哪个更优,只能凭感觉重跑。现在用表格记录之后,横向对比几分钟就能完成。
第三个动作最反直觉:回看30天前的笔记会觉得很丢人,因为很多当初信誓旦旦的结论其实漏洞百出。但正是这种“打脸时刻”才是进步最快的信号。如果回看旧笔记觉得全都对,大概率说明这段时间没有新输入。
5. 下周计划:把实验“收口”成可复用的工程模块
第40周的LoRA微调实验已经跑通,评估口径也修好了,但我很清楚现在手里的东西还只是“实验”,不是“模块”。下周至少有三件事要推进,都是为了把这周的积累变成能长期使用的工具。
5.1 下周想推进的三件事
第一,把LoRA微调流程整理成可复用的模板脚本。这次踩的主要坑是验证脚本和训练脚本不一致,下周我会把数据构建、mask逻辑、训练、验证拆成几个独立函数,固定统一入口,这样换底座模型时只需要改模型路径和模板符号,不用再担心两边口径漂移。
第二,构造一个小型人工评测集,大约20条样例,覆盖这个垂直场景里最高频的几类问题。以后每次调参、换数据、换prompt模板,都统一在这套评测集上跑,按格式正确率和内容相关度两个维度打分。没有这个固定的尺子,任何“感觉效果变好了”都是不可信的。
第三,补充推理阶段的对比实验。微调后的4bit模型在GPU上的生成延迟和CPU上的生成延迟差距有多大、是否值得做推理加速、能不能用更小的batch换取更快的首token时间,这些问题我还没实测过。下周会专门跑一组延迟对比。
5.2 四十周坚持下来的真实感受
最后说句实在的:很多人问我怎么能每周坚持写一篇机器学习周报,我的回答是,靠意志力早就断了,靠的是把这件事变成系统。固定的时间、固定的格式、固定的复盘点,让“写周报”成了一个不需要做决定的行为。到第40周,它已经不再是负担,而是我判断自己有没有在进步的主要工具。
这周最大的收获不是eval loss从1.5降回了0.75,也不是LoRA微调模型终于能按格式输出了,而是我重新理解了“记录”在机器学习里的位置。模型参数会被忘掉,实验细节会被时间冲淡,只有那些写下来的、可复现的上下文,才是真正属于自己的积累。四十周走完,我现在才敢说,我大概知道怎么让下一个四十周走得更稳。