深度学习笔记写到第三篇,我越来越觉得,真正卡住人的往往不是某个高深的理论推导,而是训练过程中那些看起来不起眼的琐碎细节。比如学习率调小之后为什么反而一点不收敛,验证集准确率突然掉到谷底,或者loss已经降得很低但实际推理效果就是不行。这些场景我全都撞过,而且每次回过头去查,原因都不复杂,但如果不记录,下一次大概率还要再踩一遍。这篇笔记就把我近期完整跑通一个图像分类项目的实战过程做一个系统回顾,重点放在数据组织、模型设计、训练细节和问题排查这几块,希望能帮你在自己的实验里少走几段弯路。
1. 动手训练之前:先把任务本身想清楚
很多人拿到一个深度学习项目的习惯是立刻翻模型结构、找公开代码,然后直接开始训练。但我的经验是,这一步越急,后面返工的概率越大。任务类型、数据划分、评价指标这三件事没有定清楚,后续所有的调参和优化都像在流沙上盖楼。
1.1 任务定义决定模型结构
先问自己一个问题:这个项目本质上是分类、回归、检测,还是更复杂的生成任务?同样是卷积网络,做图像分类时最后一层通常用Softmax输出概率分布,做回归任务时则需要改成线性输出。这个选择不只是“最后换个损失函数”这么简单,它会影响中间层的特征表达方式、输出的激活函数选择,甚至影响数据标注方案。
我近期做的项目是工业场景下的产品表面缺陷分类,目标是判断一张图像属于正常、划伤、污渍、变形四类中的哪一类。这个任务定义看起来很直接,但其实里面有个陷阱:缺陷类别之间存在重叠,比如“划伤”和“污渍”在低分辨率图像里非常像。如果一开始不把这个边界捋清,训练出来的模型就会在交界区域反复出错。
所以我建议动手前先做一张类别的定义表,写清每一类的典型特征、容易混淆的情况、标注的优先级规则。这张表不需要很复杂,但一定要让标注的人和训练模型的人看同一份说明。深度学习模型的性能上限很大程度由数据质量决定,而数据质量的第一步就是任务边界的清晰度。
1.2 数据划分:训练、验证、测试不是随便切
数据划分看起来太基础了,以至于很多人直接按比例随机切分就完事,但这一步处理不当,会让后续所有实验结果失真。我见过好几次,有人在同一份测试集上反复调参选模型,最终测试准确率虚高得一塌糊涂,换到新数据上直接崩盘。
正确的做法是严格隔离验证集和测试集。训练集用来更新参数,验证集用来做模型选择和超参数调整,测试集只能作为最终结果的一次性检验。按照这个原则,我的划分方式是训练集70%、验证集15%、测试集15%,并且测试集单独放在另一个目录里,训练过程中完全不去碰它。
在划分前还要注意数据的分布问题。比如我这个项目的图像是从不同产线收集来的,如果随机切分,很可能某条产线的图像全部进了测试集,训练集里基本没见过这种风格,最后评估结果就带了很大的随机噪声。更稳妥的做法是按来源分组切分,保证同一产线的图像只出现在一个集合里。类似的做法在真实场景里很关键,因为在部署时你面对的往往是全新来源的数据。
1.3 评价指标要匹配业务目标
分类任务里,准确率是最常用也最容易误导人的指标。当四类样本的占比大约是正常70%、划伤10%、污渍10%、变形10%时,模型就算把所有图像都判断为正常,准确率也有70%,看起来好像还行,实际上完全是个废物模型。
所以项目中我同时关注三个指标:准确率、宏平均F1和混淆矩阵。宏平均F1会把少数类的影响放大,能更真实地反映模型对缺陷类别的判别能力。如果业务方更在意某种错误是不能接受的,比如把缺陷品当成正常品流出,那还要针对这个特定错误类型设计额外指标。
评价指标最好一开始就想清楚并写进项目文档,因为指标定义会影响损失函数的选择、阈值调整方式,以及最终的模型筛选标准。中途想到什么换什么,是最浪费时间的操作。
2. 模型结构与数据预处理:先求跑通,再求冠军
基线模型的追求不是刷榜,而是让整个训练流程先跑通。第一次训练时,输出NaN、形状对不上、显存溢出这一堆问题可能会让新手焦头烂额,先用一个简单稳定的结构把流程理顺,后面所有优化才有支撑。
2.1 先从一个经典卷积网络开始
接到缺陷分类任务后,我的第一个模型并没有直接上很深的网络,而是用一个经典的基础卷积网络跑通流程:两个卷积块接最大池化,然后展平接全连接层。这个网络结构大约只有几十万参数,优点是训练快、显存占用小、问题容易定位,不会一上来就陷入复杂的调参泥潭。
为什么强调“先跑通”?因为训练流程里变量太多了:数据加载方式对不对、图像尺寸是否匹配网络输入、损失函数输出的维度是否正确、优化器更新是否正常……任何一个环节报错都可能让人误以为是网络结构不够强。如果基线网络能顺利训练到收敛,说明整个链路没有问题,之后再替换成更复杂的结构,目标就很明确:纯粹的精度提升。
小模型的另一个好处是迭代速度极快。一个epoch只要几十秒,几分钟就能看出一组超参数方案是否靠谱。到了深层次模型阶段,一个epoch动辄几分钟甚至十几分钟,试错成本完全不同。对于个人学习和中小项目,高效迭代比“起步即巅峰”重要得多。
2.2 数据预处理与数据增强的关键操作
很多初学者拿到图像就直接输入网络,结果训练时损失曲线抖得跟心电图一样。问题往往不是网络不行,而是数据输入的数值范围太随意了。
我先做的是把所有图像统一缩放到固定尺寸,比如224×224,然后转换成张量,再按均值0.5、方差0.5做标准化。标准化的本质是把像素值从0到255的分布拉到接近均值为0、方差为1的标准正态分布,这样可以避免某些特征数值过大导致梯度更新不稳。更讲究一点的做法是用ImageNet数据集的统计均值做标准化,因为预训练模型是在这个分布下收敛的,迁移时保留同样的标准化方式会让特征分布更匹配。
数据增强是提升模型泛化能力性价比最高的手段之一。我的训练集只有不到一万张图像,很容易过拟合,我用的增强策略包括随机水平翻转、随机旋转±15度、随机裁剪后缩放填充,以及轻微的颜色抖动。这些操作等于在有限样本上构造了更多的训练变体,让网络不至于死记硬背个别样本的外观。
值得提醒的是,增强策略不是越狠越好。我在测试时发现,颜色抖动幅度过大的话,模型会学到一些与缺陷无关的颜色变换,导致在原始测试图像上表现下降。所以增强强度也要通过验证集来调,不能想当然地认为多加一定好。
2.3 batch size与显存之间的取舍
batch size是一个容易被低估的参数。理论上更大的batch size能让梯度估计更稳定,但也会带来两个问题:显存不够,以及泛化能力可能变差。大量实验表明,过大的batch size会让模型收敛到泛化较差的最小值区域,有时需要同步调大学习率来补偿。
我做这个项目时先用batch size为32跑通,然后根据显存余量逐渐试着加大到64、128。比较下来,64在这个数据集上表现最稳,既不会在训练初期震荡太厉害,也不会因为batch过大而明显牺牲泛化能力。如果你使用的是预训练模型微调,显存不够时可以考虑用梯度累积技术,它可以在不改变batch大小的情况下等效增加batch数量。
一个小技巧是选择batch size时尽量让样本总数能被整除,最后的不足一批的数据要么丢弃要么单独处理,避免某些框架在动态shape上出现潜在问题。
3. 训练环节的参数细节:学习率、损失函数与正则化
训练过程是整个深度学习项目中最像“炼丹”的部分,核心变量无外乎学习率、损失函数、正则化手段和优化器。把这几个旋钮理解到位,实验的成功率会明显上升。
3.1 学习率:最敏感的一个旋钮
学习率决定了参数每次更新的步长。步长太大,损失函数容易在最优解附近来回震荡甚至发散;步长太小,训练时间成倍拉长,还可能陷入局部极小值。我的调试顺序一般是先把学习率设为0.001,跑几十个step看损失变化,如果损失在快速下降则保持,如果剧烈震荡就调小到0.0003,如果几乎不下降则考虑调大。
实际训练中我偏好分段调整学习率的策略。前几个epoch用一个稍高的学习率快速靠近最优区域,后面把学习率逐步降下来精细调节,比如到第30个epoch降为原来的十分之一。这种做法很像先大步找方向、再小步微调,收敛速度和最终精度通常都好于全程固定学习率。
如果想让这个过程更自动化,可以用学习率预热加余弦退火的调度方案。预热阶段从很小的学习率线性涨到目标值,避免训练一开始就对着错误的梯度方向猛冲,后续余弦退火又能平滑降低学习率,让 loss 在后期尽量逼近最低点。实际跑下来的结果,收敛稳定性确实有明显改善。
3.2 损失函数需要匹配项目目标
图像分类里最常见的组合是Softmax加交叉熵损失。交叉熵逐类别比较预测概率与真实标签的差异,对分类边界附近的错误有很强的惩罚信号,梯度回传也更平稳。相比之下,如果错误地把任务当成回归问题用均方误差,训练时梯度会变小,收敛速度和最终精度通常都会打折扣。
在我这个类别不平衡的项目里,交叉熵的默认形式会让模型偏向样本量大的类别。解决办法是给损失函数加上类别权重,让少数类的损失贡献变大。比如缺陷样本只有几百张,它们却在总损失里占了更大的比例,模型才会真正重视这部分的学习。
我试过Focal Loss,它可以在样本类别不平衡的同时压低易分样本的损失、放大难分样本的影响。实验结果显示,在很少量数据下Focal Loss比加权交叉熵更容易导致过拟合,因为难分样本里也包含噪声标签,过分强调反而会让模型去学噪声。所以最后我采用的是加权交叉熵,效果更稳定。
3.3 正则化与归一化:过拟合的防火墙
过拟合的表现训练集指标很高、验证集指标上不去,这是中小数据集最常遇到的问题。我用三件套搭配处理:早停、权重衰减和数据增强。
早停的机制很简单,每隔若干epoch在验证集上算一次损失,如果连续多个epoch没有改善就把最优权重保存下来,避免后期模型在训练集上过度拟合。权重衰减是在损失函数中加一个对参数大小的惩罚项,相当于让模型的复杂度别太高。这里需要注意权重衰减的系数不能设太大,否则模型会偏向欠拟合。我常见的一个尝试范围是1e-4到1e-2,具体值还是要靠验证集验证。
还有归一化层,现在几乎所有标准网络里都带批量归一化,它的作用是把每层输入的数据分布拉回均值为0方差为1,训练会稳定得多,也允许使用更大的学习率。在微调预训练模型时,我会先冻结前面的骨干网络,只训练最后几层,等新数据上的指标稳定后再解冻全部层做整体微调,这个顺序对防过拟合很有帮助。
3.4 训练过程中应该记录哪些指标
训练日志如果不完整,出了问题就很难定位是数据、模型还是优化的问题。我至少会记录每个epoch的以下内容:训练损失、训练准确率、验证损失、验证准确率、学习率、当前epoch耗时。这些信息都汇总到一个训练日志文件里,便于训练结束后画曲线对比。
验证损失比验证准确率更敏感。如果你发现训练损失持续下降,验证损失却开始上升,这就是过拟合开始的最早信号。如果训练损失和验证损失都降不下去,则要考虑模型的表达力是否不足、学习率是否过小或数据是否存在标注问题。
4. 常见训练问题的排查思路与速查表
训练中遇到的坑数不胜数,但大部分问题都有比较固定的表现形式和排查路径。我把实际踩过的几个典型问题整理一下,方便按图索骥。
4.1 损失不下降:先分清是“不降”还是“降得慢”
遇到损失不下降的情况,我的第一步是看训练集上的损失变化,而不同时看验证集和测试集。如果训练损失也不降,就说明模型没有在拟合数据,问题可能在数据预处理、学习率或模型结构。
先用小样本过拟合实验排除模型本身的问题。我会挑32张图像,强制模型在这些样本上反复训练,如果损失能降到接近0,说明模型结构没问题、反向传播也没断,问题大概率出在整体训练设置上。如果连小样本都拟合不了,那大概率是数据标签错了、特征标准化出问题,或者网络结构某个地方的计算姿势不对。
学习率过大或过小是另一个常见原因。学习率过大时,损失通常会在初始值附近震荡甚至越涨越高;学习率过小时,损失下降得极其缓慢,看起来像没在学。验证方式比较简单:把学习率调成0.1和1e-5各跑一小段时间,看损失的变化形态,就能大致判断问题方向。
4.2 训练集指标好、验证集指标差:过拟合的确认与缓解
这是最典型的中小数据集问题。我做这个项目时,第一次模型训练到第40个epoch,训练准确率已经98.5%,但验证准确率只有82%左右。这个差距是典型的过拟合警示。
第一步先看验证曲线和训练曲线的分裂点,通常在某个epoch开始后验证损失不再下降、训练损失继续下降。我用早停机制自动把模型保存点选在最优点上,避免反复手动猜测。
第二步是加正则化强度。我调高了权重衰减系数,同时增加数据增强的强度,给训练数据加入了随机擦除操作。这里也有个取舍,增强过强会让训练损失降不到原来的水平,但验证指标反而可能更好,关键是看总体的泛化差距。
如果条件允许,增加训练数据量更有效。我用简单粗糙的方式对缺陷图像做了扩充,收集了更多真实样本,效果远好于纯靠增强硬撑。
4.3 训练指标剧烈波动:多因素排查
训练准确率在80%和95%之间剧烈跳变时,第一反应通常是学习率太大,但还有一种可能是batch内数据分布差异过大。当batch size太小、样本比较杂时,每一批的梯度方向差异会很大,训练曲线自然抖动。
我的处理办法是先减小学习率看看是否有改善,再把batch size增大一些。如果抖动仍然存在,就要检查数据加载时是否做了正确的shuffle。我遇到过一个问题,数据加载顺序完全没有打乱,导致每个batch都是同一类样本,训练曲线呈现周期性波动,虽然最后能收敛,但速度明显更慢,泛化也受影响。
验证指标的波动也要留意。如果你的验证集太小,比如只有几百张图像,验证准确率本身的随机性就很大,这种情况不是训练的锅,而是验证集代表性的问题。适当增大验证集或者采用多次采样再平均的评估方法都可以缓解。
4.4 常见问题速查表
| 表现 | 优先排查方向 | 建议手段 |
|---|---|---|
| 训练损失不下降 | 数据标准化、标签正确性、学习率过小 | 小样本过拟合实验、调整学习率至0.001左右 |
| 损失震荡发散 | 学习率过大 | 降低学习率至1e-4级别,或使用学习率预热 |
| 训练好验证差 | 过拟合 | 增加数据增强、权重衰减、早停 |
| 训练验证都差 | 模型表达力不足、数据噪声大 | 换更强模型、检查标注、增加有效数据 |
| 验证曲线乱跳 | 验证集太小、batch太小 | 增大验证集和batch size,按要求shuffle |
| 输出NaN | 学习率过大、数据存在极端值 | 降低学习率、检查标准化、缩小数值范围 |
5. 训练完成之后:从指标到落地还要做什么
模型在验证集上表现不错,并不等于可以直接交付。从训练到真正可用,中间还隔着评估深化、性能优化和可复现性整理这几步。年轻人最容易在这一步放飞自我,结果实训时漏洞百出。
5.1 多看看混淆矩阵和错误样本
准确率只给一个抽象数字,真正能指导优化的是混淆矩阵。我拿到训练好的模型后,会把测试集全部跑一遍,输出混淆矩阵。结果发现,模型把7%的“污渍”样本错分成了“划伤”,这个错误在实际业务场景里不太能接受,因为这两类缺陷的后续处理方式完全不同。
顺着错误样本再往下看,发现这些被错分的图像都有一个共同点:光照偏暗、纹理不明显。于是我有意识地在数据增强里加入了光照扰动,同时补充了一批暗光条件下的真实样本。第二版模型的混淆矩阵改善非常明显,多数情况已经能正确识别。如果没有这一步,只看准确率根本不知道模型在哪些地方会犯系统性错误。
5.2 推理速度与模型大小的优化
训练是在GPU上进行的,落地推理很多时候却要跑在CPU或者边缘设备上。这时候模型的推理耗时和参数量变得非常重要。我做了两件优化:第一是把训练好的模型导出为轻量格式,第二是开启低精度推理。
开启低精度推理后,推理速度提升显著,精度受影响很小。这里要注意的是,低精度推理对部分网络结构会产生数值溢出风险,所以要实际跑一遍评测集对比量化前后的准确率,不能只看速度。
如果部署需求特别严苛,还可以考虑剪枝和知识蒸馏。这类操作会明显压缩模型体积,但操作不当会掉精度,需要把蒸馏训练当成新的小项目来做。
5.3 训练结果的可复现性整理
我见过太多项目,模型明明当时很好,过两周却怎么也复现不出来。原因就是没记清楚随机种子、数据增强参数、学习率调度和所有超参数。
现在我在每次实验开始前都会写好一份实验配置,包括随机种子、数据集路径、预处理细节、模型结构、优化器参数、学习率调度、batch size、训练轮数等。训练结束后把最优权重、训练曲线和配置一起存档。这样后面无论是重新跑实验做对比,还是换个人接管项目,都有据可查。
随机种子的固定也很重要。某些框架里数据加载和Dropout等操作本身带随机性,如果不固定种子,同一个配置每次训练结果都可能差异很大。固定种子后再配合随机数生成器的控制,同一配置基本能复现出相近的结果。
另外一个容易被忽略的点是环境版本。框架小版本升级之后,同样代码可能跑出完全不一样的结果。最好在项目文档里记录框架、驱动和依赖库的版本号,条件允许的话用容器镜像锁定环境。
我自己在这个项目中的收获,靠的是一轮轮“出现错误、记录原因、修正参数”的循环。如果你的项目也正卡在某个诡异的现象上,先别急着怀疑自己的模型结构不够先进,回到数据、学习率、正则化这些最基础的层面做排查,往往能更快找到出路。最后再提一个小技巧:把每次实验的日志文件保留好,哪怕是一次失败实验,后来也会成为排查问题的重要线索。