在深度学习里,ResNet(残差网络)算是那种“绕不开”的模型:它解决了深层网络训练退化的问题,也成了迁移学习里最常见的起点之一。我私下和不少朋友交流,发现大家普遍会跑图像分类,但一遇到“网络加深了为什么不涨点”就会卡住;还有一部分人以为神经网络只能做分类,碰到人脸关键点这类连续坐标输出就不知道该怎么办。这篇文章就把两件事放在一起讲:先拆解ResNet的残差思想和迁移学习,再以人脸关键点检测为例,聊清楚神经网络怎么做回归。适合已经能独立训练简单CNN、对反向传播和全连接层不陌生,但还没系统接触过残差结构和回归任务的读者。
1. 网络越深效果反而越差?先认识“退化问题”
1.1 那个反直觉的实验现象
我最早接触深度网络是在某个模拟分类项目里。模型从几层加到十几层,效果确实一直在涨;但当我继续把网络往深度推,测试效果不但没提升,连训练集上的损失都变得比浅层网络更高。当时旁边的同学第一反应是“过拟合了”,但用过拟合无法解释训练误差也升高这件事——过拟合往往是训练误差低、测试误差高,而不是全面变差。这个现象有一个专门的名字,叫退化问题。
在公开的小图分类任务上,经典的对比是把普通堆叠卷积网络从20层加到50多层。结果深网络的训练误差和测试误差都明显高于浅网络。也就是说,网络容量更大了,但不仅没有利用好容量,反而连浅层网络能轻松达到的效果都学不到。我用一张简单的表说明这种关系:
| 对比项 | 浅层网络(约20层) | 深层网络(50层以上) |
|---|---|---|
| 训练误差 | 低 | 更高 |
| 测试误差 | 低 | 更高 |
| 是否过拟合表现 | 否 | 否 |
| 主要瓶颈 | 容量增长 | 优化困难 |
注意,这里并不是梯度完全消失导致训练停滞,也不是过拟合导致泛化变差。更准确地说,深层模型在训练时就停在一个比较差的局部解上,这个解甚至不如浅层模型容易到达的位置。
1.2 为什么深层网络“学不会”保持恒等
理论上,深层网络表达能力更强,它至少应该能模拟浅层网络的输出。比如10层网络能学到一个函数F,那20层网络的前10层可以完全复现它,后10层只要什么都不做就行。但问题是,在真实训练里,让一堆非线性层去学习“什么都不做”这件事非常困难。一个包含卷积、批归一化、ReLU的非线性模块,天然倾向于对输入做某种变换;要让它输出恰好等于输入,需要每一层的权重都处在一种很特殊的配置下,这对随机初始化和梯度优化来说并不友好。
训练堆叠层数越深,要同时协调的参数就越多,搜索空间也越大。在有限数据、有限迭代次数下,优化器很容易在一个“看起来还不错”但并不理想的解上停住。这就像让一个人记忆一整条长途路线,每一段都有细节和偏差,路线越长,累积误差就越可能让他在某个路口偏离原路。
ResNet的出发点正是这个:与其让网络直接学习最终映射,不如给每一小段网络配一条“高速公路”,让它只需要学习相对输入的变化量。这个思路看起来简单,却彻底改变了深层网络的训练方式。
2. 残差连接的核心:不学完整映射,改学差值
2.1 残差块的关键结构
残差块用数学语言表达很简单:我们希望某一段网络最终学到的映射是H(x),传统卷积网络直接拟合H(x);残差块则定义F(x)=H(x)-x,这一段网络只负责拟合差值,最后输出写成y=F(x,{Wi})+x。这里的Wi是网络层参数,x直接通过一条“捷径”加回来。
为什么学习F(x)比学习H(x)更容易?看两个极端情况:如果期望的输出和输入差不多,那么F(x)只需要逼近0;一组带ReLU的网络模块逼近0,远比逼近某个复杂恒等函数容易,因为只要压低激活值、让权重产生抵消就能做到。这给了深层网络一个很自然的“保底选项”——残差块学不出新东西时,至少可以直接把输入传递到后面,网络不会比浅层版本更差。
从反向传播的角度也能直观看到优势。假设残差块的输出是y=F(x)+x,损失对x求导时会出现一项常数1乘以后面的梯度。也就是说,梯度在反向传播过程中始终有一条不经过任何卷积计算、不经过任何非线性变换的“直达通道”。即使多层残差块的F部分梯度接近0,这条通道也能保证信号传到浅层,缓解梯度消失。
2.2 BasicBlock与Bottleneck的选择
残差块在实际工程里有两种常见形态。
| 结构 | 内部层次 | 典型应用 | 说明 |
|---|---|---|---|
| BasicBlock | 3x3卷积 + BN + ReLU + 3x3卷积 + BN | ResNet18、ResNet34 | 简单直接,适合中浅网络 |
| Bottleneck | 1x1降维 + 3x3卷积 + 1x1升维 | ResNet50及更深 | 减少计算量,提取特征更充分 |
Bottleneck的设计有一个很实际的原因:当网络到50层以上时,直接用两个3x3卷积堆叠会使计算量增长太快,训练速度难以接受。Bottleneck先用1x1卷积把通道数压低,比如从256压到64,做完3x3卷积后再用另一个1x1卷积把通道数拉回256。这样既保证了网络足够深,又控制了整体参数规模。
还有一个实现时绕不开的细节:当残差块的输入和输出形状不一致时,x不能直接相加。比较常见的做法有两种,一是用zero padding补齐缺失的维度,简单但携带的信息量弱;二是用1x1卷积对x做一个投影,让维度与之匹配。在实际项目中我一般选择1x1卷积投影,多出来的参数对特征融合有好处,收敛也稳定一些。
把残差思想连成一线,你会发现ResNet的核心看起来并不复杂,但正是这个“学差值”的转变,让网络可以稳定训练到几十层甚至上百层,深层网络的容量才真正被用上。也正因为ResNet能训练得足够深,它在各种视觉任务上能学到更有层次的表征,顺理成章地成了迁移学习里非常常见的主干选择。
3. 迁移学习实战:为什么你的小数据集也能用ResNet
3.1 特征通用性是迁移学习的底气
训练一个ResNet50从零开始,需要大量数据和很长时间。绝大多数项目拿不到那么多数据,更等不起训练时间,于是迁移学习成为实际落地时的常规操作:先在一个大规模通用图像分类任务上把ResNet训练好,再把学到的权重迁移到自己的目标任务上继续调整。这样做有三个层面的底气:其一,网络浅层提取的是边缘、纹理、颜色块这类高度通用的特征,这些特征在很多视觉任务里同样有效;其二,预训练过程相当于让模型具备了对自然图像的“常识”,新任务里同样要处理光照、遮挡、形变等问题;其三,需要重新调整的主要是靠近输出层的高层语义特征,改动范围远小于从零训练。
迁移学习在实际中分成两条路线。第一条是“冻结特征”:把预训练ResNet整个当作特征提取器,所有参数都不更新,只训练新加的头部网络。它适合数据量很小、任务与预训练任务比较接近的场景,训练速度快,也不太容易过拟合。第二条是“微调”:解冻全部或者部分原网络层,让它们继续在小数据集上更新。它适合数据量稍多一些、目标任务和预训练任务有差异的情况,精度上限更高,但需要更小心地控制学习率。
还有一种介于两者之间的折中策略:冻结网络的前几层,只解冻靠近高层的部分层,同时给回归头更高的学习率。这背后的直觉是,底层特征通用性最强,动它收益低、风险高;高层特征和目标任务关系更密切,适当调整收益更明显。我在人脸关键点这类任务上,通常先把网络整体当特征提取器跑一遍看效果,再逐步解冻高层做微调。
3.2 迁移到自己的任务前,先检查这三件事
第一件事是输入预处理必须和预训练一致。很多模型在预训练时会要求输入缩放到某个固定尺寸,并且像素值缩放到特定区间。如果你直接把0到255的原始像素喂进去,模型看到的统计分布和预训练时完全不同,之前学到的特征估计也派不上用场。
第二件事是替换输出层。ResNet的最后一个全连接层通常输出的是预训练任务的类别数,迁移到人脸关键点回归时,需要把它替换成输出关键点数量乘以2的新结构,并重新初始化。
第三件事是区分学习率。如果从头到尾用同一个学习率训练,很容易出现两种情况:学习率设大了,预训练权重被快速破坏,模型反而退化成从头训练的样子;学习率设小了,新加的头部学不动,损失长时间降不下去。我常用的配置是给预训练主干一个较小的学习率,给随机初始化的回归头一个较大的学习率。在常见的动态图框架里,分组参数配置大致是这样一个思路:
# 常见动态图框架的分组参数配置思路 optimizer = build_optimizer( [ {"params": backbone.parameters(), "lr": 1e-4}, {"params": reg_head.parameters(), "lr": 1e-3}, ] )这样既利用预训练特征,又给新任务足够的调整空间。迁移学习不是一个神奇开关,它的核心价值是让你从好的起点出发,而不是从零开始瞎撞。
4. 从分类到回归:人脸关键点任务的思维切换
4.1 分类和回归到底差在哪
图像分类的输出是一个离散标签,网络在最后一层通常会接softmax,把logits转成各个类别的概率,损失函数用交叉熵。人脸关键点检测则是另一类问题:输入一张人脸图像,输出若干关键点的像素坐标,比如常用的5点、68点甚至上百点。每个点都有x、y两个连续数值,整个输出是一个多维连续向量,本质上是回归任务。
很多新人习惯性地把关键点任务当作“分类任务”来想,会在最后输出层加softmax,这其实不对。softmax会强制所有输出加起来为1,而坐标值之间并不存在这种概率关系。回归任务的最后一层应该是一个不带非线性激活的全连接层,直接输出连续数值。如果把输出层替换成关键点数量乘以2的维度,比如68个点就是136维输出,那么网络每个输出位直接对应一个坐标值,这就是和分类最直观的区别。
回归和分类的另一个重要差异体现在损失函数上。交叉熵适合衡量离散分布之间的差距,但对坐标没有意义:预测坐标比真实坐标偏了5个像素,和偏了20个像素,在网络看来需要体现不同代价。分类损失无法表达这种连续距离关系,因此回归任务一般选用MSE、MAE或SmoothL1。
4.2 回归损失函数和评估指标的选择
先看三个常用回归损失。MSE是误差的平方,对大的偏差惩罚非常重,训练初期容易因为个别离群标注把梯度带偏。MAE直接取误差绝对值,对离群点更宽容,但它在零点附近梯度恒定,收敛到后期可能出现震荡。SmoothL1综合了二者的特点:误差绝对值较小时使用平方项,让收敛稳定;误差绝对值较大时改用线性项,避免被离群样本带着跑。人脸标注天然存在一定噪声,不同标注者对同一个点的位置判断可能有几个像素的偏差,因此我一般默认用SmoothL1。
评估指标也不能照搬分类的准确率。人脸关键点最常用的指标是归一化平均误差,也就是所有关键点的平均欧氏距离,再除以一个归一化参考距离,比如人脸框的宽度或两眼之间的瞳孔距离。之所以要除这个参考值,是因为一张半身照和一张大头照里的绝对像素误差没有可比性,必须把人脸尺寸因素去掉,不同输入、不同数据集之间的误差才能横向比较。表里面整理了三种指标的特点:
| 指标 | 计算方式 | 特点 |
|---|---|---|
| RMSE | 所有坐标误差平方后取平均再开根号 | 对离群点敏感 |
| MAE | 所有坐标误差取平均 | 更鲁棒,但梯度特性稍差 |
| NME | MAE除以归一化参考距离 | 跨数据集可比,工业界常用 |
输出数值的量级也需要注意。如果把原始像素坐标直接当训练目标,一次输入是长宽几百的图像,坐标取值范围可能到几百,网络回归头需要同时输出这个范围的数值,训练会非常吃力。我通常在预处理时就把坐标除以图像宽高,把目标压缩到0到1之间,让输出的数值范围和网络初始化分布更接近,损失也能更快下降。预测阶段再把网络输出的归一化坐标乘回图像尺寸即可。
5. 人脸关键点回归的最小可运行方案
5.1 数据准备和坐标编码
最小可运行方案的第一步是准备数据。你需要的是一个公开的人脸关键点数据集,每张图包含一个人脸框标注和若干关键点坐标。第一步先解析标注文件,把每张图的路径、人脸框、所有关键点读进内存;第二步把人脸区域从原图里裁剪出来并缩放到统一尺寸;第三步把关键点坐标做归一化,既可以用图像宽高作为分母,也可以用裁剪后的人脸框宽高作为分母。用图像宽高做分母比较省事,但不同图中人脸大小差异会导致坐标分布差异较大;用人脸框宽高做分母更接近“相对人脸位置”的语义,效果通常更好。
数据增强要谨慎处理。随机裁剪、旋转、亮度扰动都是有效手段,但有一个特别容易出错的细节:水平翻转。翻转后左眼会跑到右边,右眼会跑到左边,如果只翻转图像而不交换关键点索引,模型会看到“左边标注的是右眼”这种自相矛盾的标签。所以实现增强函数时,必须把左右对称的点一起交换,比如左眼坐标赋给右眼、右眼坐标赋给左眼,嘴角同理。这个细节我下面会再展开。
5.2 模型改造和训练循环
用ResNet做回归任务时,关键是改造输出层。为了让代码更直观,我用常见的动态图框架接口风格写一版伪代码,实际项目里换成自己熟悉的框架即可:
# 加载在通用图像分类任务上预训练好的残差网络 backbone = load_pretrained_resnet(resnet50) # 获取原分类层之前的特征维度,比如2048 in_features = get_last_feature_dim(backbone) # 替换成回归头:两层全连接加Dropout reg_head = Sequential( Linear(in_features, 512), ReLU(), Dropout(0.2), Linear(512, num_points * 2), # 不接激活函数 ) # 整个模型 model = replace_last_layer(backbone, reg_head)训练循环本身并不复杂,但有几个配置值得说。优化器我建议用带动量的随机梯度下降或自适应学习率优化器;损失函数用SmoothL1;训练时把前向输出的归一化坐标和真实归一化坐标直接计算损失。学习率沿用迁移学习里的分工:主干用小学习率,头部用大学习率。你可能会问为什么头部要重新初始化并且用大学习率,因为它刚随机初始化,特征还是一片空白,需要在较短时间内快速适应新任务。
for images, landmarks in dataloader: pred = model(images) # 输出范围:[0,1] loss = smooth_l1_loss(pred, landmarks) # landmarks也是[0,1] loss.backward() optimizer.step()验证阶段要做两件事。一是输出数值层面的指标,计算验证集上的归一化平均误差;二是把预测坐标乘回图像尺寸,在图上画出关键点。可视化的重要性怎么强调都不过分:指标只能告诉你误差大不大,可视化能告诉你误差到底来自“整体偏移”“局部错位”还是“某些点明显乱飞”,这几种情况的排查方向完全不同。
5.3 推理时的坐标还原和边界处理
推理阶段,模型输出的仍然是归一化坐标,记得乘回原始人脸框的宽高,并加上人脸框左上角的偏移量,才能得到原图坐标系下的关键点坐标。一个常见问题是预测坐标偶尔会落出人脸框,甚至落在负坐标位置,这通常是因为训练数据里某些关键点在裁剪后恰好靠近边缘,模型对边界位置的约束不足。简单有效的补救措施是把预测坐标强制裁剪回有效范围内,或者增加包含更多边缘样本的训练增强。更严谨的方案是在损失函数里加一个边界惩罚项,但对大部分小项目来说,先做好输出裁剪和可视化检查性价比更高。
6. 关键点训练里的几类典型坑与排查思路
6.1 一个典型的“loss不降”排查过程
有一段时间我在做某个关键点项目,训练前几轮损失下降很快,但到后面一直停在某个数值附近动不了,看预测结果,所有关键点都偏向人脸中心,像缩成一团的小点。第一反应是模型容量不够,但换更大的主干也没有明显变化。后来我打印了一批预测值和真实值的分布,发现真实坐标分布在0到1的很窄区间,而预测值已经收敛到区间中间——这是坐标归一化不当的典型症状。当时数据集里的人脸框裁剪不够严格,归一化分母用的是原图尺寸,导致人脸区域坐标只占很小范围,模型只需要预测一个均值就能拿到较低损失。
把分母换成裁剪后的人脸框宽高之后,坐标分布扩到接近整个0到1区间,损失很快就降下去了。这个问题的排查链路值得记一下:先看损失量级是否和训练目标匹配,再看预测坐标分布是否覆盖真实坐标的分布范围,最后检查数据增强代码里有没有改变坐标但忘记同步处理。大多数时候“loss不降”都不是网络本身有问题,而是训练目标或数据编码出了问题。
6.2 微调时验证集先降后升的教训
另一个让我印象深刻的坑出现在迁移学习微调阶段。一开始为了快速看到效果,我把整个ResNet解冻,所有参数都用同一个学习率训练。训练曲线看起来很漂亮,损失飞速下降,但验证集上的误差降了几天之后开始反弹,而训练损失还在继续降。这就是过拟合吗?有一部分关系,但更关键的原因是学习率太大,预训练权重逐步被破坏。底部卷积层的通用特征被改得面目全非,模型开始过度拟合少量训练样本的噪声。
后来我重新冻结了网络前几层,只解冻最后几个残差块,并且把主干学习率调到头部学习率的十分之一,验证集曲线就稳定多了。这件事让我养成了一个习惯:每次加载预训练模型都会把参数分成两个组,主干一组、新加层一组,分别设置学习率,只靠这一个改动就能避免大量微调灾难。
6.3 直接回归还是热力图回归
直接回归的方式输出维度低、训练速度快、推理开销小,对一般精度要求的项目足够用。热力图回归的思路则在每张特征图上预测每个关键点的置信度,网络输出的是一张二维热度图,通过求峰值或加权质心得到坐标。它的优点是空间分辨率更高,训练目标更稳定,精度通常更好;缺点是输出维度和计算量大幅增加,训练和推理都更慢。我的建议是:如果你做的项目对精度要求高且硬件资源充足,可以尝试热力图方案;如果只是快速验证一个想法,或者关键点数量不多,直接回归更省事。两者不矛盾,很多工程实践会先用直接回归搭出整个流程,再针对精度瓶颈把特定模块换成热力图。
最后再分享一个我自己总结的小习惯:拿到任何关键点数据,先不要急着写模型,先把原始图像和标注点画出来看一遍,确认坐标编码方式和人脸框范围;训练过程中至少设置一个固定频次把验证集结果可视化出来。很多问题在图上比在损失曲线里更容易看出来。ResNet这个主干真正珍贵的是它的预训练权重,而迁移学习的本质是找一个好起点;把人脸关键点当作回归问题之后,你要做的不过是在好起点上加一个合适的小头。我现在做新任务也一直是这个流程,剩下的问题基本都靠画图和调学习率分组解决。