news 2026/10/15 3:46:24

一文读懂ResNet:残差块、网络搭建与训练优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一文读懂ResNet:残差块、网络搭建与训练优化

1. 从退化问题说起:为什么ResNet非做不可

1.1 深层网络的"假象":层数堆上去反而变差

我在最初接触卷积神经网络的时候,和大家一样有个直觉:网络层数越多,模型容量越大,效果应该越好。但在实际训练图像分类模型时,我第一次把网络从20层加深到56层,验证集准确率反而掉了好几个点。最诡异的是,56层的训练误差也高于20层——这根本不是过拟合,而是网络根本学不动。

这个现象业内称为"退化问题"(Degradation Problem)。它和过拟合不一样:过拟合是训练集表现好、验证集差;退化问题是训练集和验证集一起变差。我当时反复检查数据预处理、学习率、权重初始化,都没发现问题。后来才意识到,根源在于深层网络在反向传播时,梯度经过多层连乘后极易消失或爆炸,即使通过Batch Normalization等手段规避了梯度爆炸,信息在逐层传递时也在不断损耗,网络难以有效地学习恒等映射。

换句话说,给网络增加层数时,我们希望新增的层至少能学到"什么都不做"——即恒等映射。但实际中,让一堆非线性层去拟合恒等映射是非常困难的,Sigmoid这类激活函数在饱和区梯度几乎为零,ReLU虽然缓解了一部分,但深层堆叠仍然让优化过程变得异常艰难。这就像你要让一个人在嘈杂环境下每次都会原封不动地复述一句话,而不是要求他做更复杂的加工——看似更简单的任务,在层层传递后反而失真的概率更大。

1.2 残差学习的核心思想:让网络学习"变化量"

ResNet的解法非常巧妙:既然直接让网络学到恒等映射很困难,那就改变学习目标。假设某一段网络的期望输出是H(x),原来的结构直接让网络拟合H(x);残差结构则是让网络去拟合F(x) = H(x) - x,然后通过一个捷径连接(Shortcut Connection)把输入x原样加到输出上,最终输出变为F(x) + x。

这样,如果理想映射确实是恒等映射,网络只需要把F(x)的权重学成接近零即可,这比直接拟合恒等映射容易得多。从梯度角度看,残差结构在反向传播时,梯度可以通过捷径连接直接回流到前层,相当于给梯度开了一条"高速公路",即使中间层的权重很小,前层依然能收到有效梯度。

我第一次在某个图像分类Demo里把普通的20层卷积网络改为带残差块的20层网络时,同样的超参数、同样的训练轮次,验证集准确率从88.2%提升到了91.6%。单层残差块的计算量增加可以忽略不计,效果却天差地别。正是这种"学习变化量"的思路,让ResNet在2015年拿下了多项视觉任务冠军,并且直到今天仍是各种模型backbone的默认选项。

2. ResNet的核心构建单元:残差块拆解

2.1 BasicBlock与Bottleneck:两种残差块的设计逻辑

残差块是ResNet的基本组成单位。实际构建网络时,根据网络的深度和计算资源,通常会选两种结构之一:BasicBlock和Bottleneck。

BasicBlock最常见于ResNet-18和ResNet-34。它由两个3x3卷积层组成,每个卷积层后面接Batch Normalization和ReLU。输入先经过第一个卷积层,再经过第二个卷积层,最后把输入通过捷径连接加到输出上。这里的卷积层通常保持相同的通道数,除非在阶段过渡处需要改变通道数或分辨率。

Bottleneck则用于ResNet-50及以上的深层网络。它包含三个卷积层:第一个是1x1卷积用于降低通道数,第二个是3x3卷积用于空间特征提取,第三个是1x1卷积用于恢复通道数。比如输入是256通道,Bottleneck可能先降到64通道,做3x3卷积,再恢复到256通道。这种方式能显著减少计算量:两个3x3卷积在256通道下的计算量远大于"1x1降维+3x3+1x1升维"的组合。

我在实际构建网络时,一般遵循这条经验:层数不超过34,用BasicBlock更简单直观;层数达到50或以上,必须用Bottleneck,否则训练速度慢到让人怀疑人生。某次我在一张中端显卡上尝试直接用BasicBlock搭建ResNet-50,单个步长(一个batch)的前向加反向时间直接拉了3倍,而且显存迅速吃紧。换成Bottleneck之后,训练时间才回到可接受范围。

2.2 恒等映射与捷径连接:维度匹配的两种处理方式

残差块要求输入x与输出F(x)的维度保持一致,才能直接相加。但网络在阶段之间经常要改变特征图尺寸或通道数,这时候捷径连接就不能简单地"原样相加"了。常见做法有两种。

第一种是补零填充(Zero Padding):当通道数增加时,在x上额外补零通道,使维度与F(x)输出一致。这种做法不引入额外参数,但在特征图相加时,补零通道只是把信息"空转"过去,没有经过任何变换。它通常用于BasicBlock的快捷路径。

第二种是1x1卷积投影(Projection Shortcut):使用一个1x1卷积,步长与主路径的降采样步长一致,把输入x的通道数和空间尺寸调整到与F(x)输出相同。这个1x1卷积带自己的权重参数,能够学习输入到输出的线性映射。我在构建ResNet-50时,遇到通道翻倍的残差块都会在捷径上放一个1x1卷积,同时设置stride=2配合主路径下采样。

当时我踩过一个坑:在主路径的第一个卷积上设置stride=2,但捷径上的1x1卷积忘记加同样的stride,导致两者空间尺寸不一致,相加时直接报错。后来我把捷径的stride检查和主路径第一个卷积对齐封装成一个函数,每次生成残差块时自动判断并设置,这才从根上消除了这类维度不匹配问题。

下表是我常用的残差块参数对比:

项目BasicBlockBottleneck
卷积层数2个3x31x1 + 3x3 + 1x1
适用网络ResNet-18 / 34ResNet-50 / 101 / 152
中间通道变化不变先降后升
计算量较低深层网络下更优
捷径处理通常恒等或补零升维时常用1x1投影

3. 整体网络结构搭建:从残差块到完整ResNet

3.1 各阶段通道数与层数的配置逻辑

一个完整的ResNet在结构上遵循"卷积层下采样 + 多个残差阶段"的设计范式。以ResNet-50为例,它由四个阶段组成,每个阶段包含若干个Bottleneck块。输入图像先经过一个7x7卷积,步长为2,将图像尺寸从224x224缩小到112x112,再经过一个步长为2的3x3最大池化,得到56x56的特征图。

第一个阶段(Layer1)通常不进行下采样,特征图尺寸保持56x56,通道数为64。每个阶段内部包含3个Bottleneck块,但第一块的输入和输出通道都是64,所以捷径连接可以直接用恒等映射。

第二个阶段(Layer2)将空间尺寸从56x56降到28x28,通道数从64翻倍到128。这个阶段的第一个Bottleneck块主路径上使用了stride=2的卷积,捷径上也同步加1x1卷积投影,保证输出尺寸一致。后面的块就都维持28x28和128通道。

第三、第四个阶段依此类推:Layer3将尺寸降到14x14,通道数增至256;Layer4将尺寸降到7x7,通道数增至512。最后经过全局平均池化,把7x7的特征图压缩成1x1的向量,再接全连接层输出分类概率。

这里有一个关键细节:每个阶段内部的Bottleneck块数量并不相同。ResNet-50是3块、4块、6块、3块,ResNet-101是3块、4块、23块、3块。为什么第三阶段最多?因为14x14分辨率已经不高,但通道数较大,堆叠更多块可以在合理计算量下增加网络深度与表达力。这也是我参考原论文配置时最容易理解、复制时最不容易出错的部分——直接按照配置表逐层堆叠即可。

3.2 关键细节:下采样、激活函数顺序、BN位置

我刚开始照着论文搭ResNet时,犯过一个很典型的错误:在Bottleneck的后两个卷积后都加了ReLU,但第二个卷积(3x3卷积)后有时会忘记加BN。很多复现文档里都强调卷积后要先BN再ReLU,但BN层的位置一旦错乱,训练初期Loss下降会非常慢,甚至直接NaN。

按照TorchVision官方实现的做法,残差块内部的顺序是:Conv -> BN -> ReLU,然后下一个Conv -> BN -> ReLU,最后一个Conv -> BN,最后在相加后才做ReLU。也就是说,残差块的最后一个卷积层后面不跟ReLU,而是等捷径连接和主路径相加后再统一激活。这么做的理由是:如果最后一个卷积后立刻加ReLU,那么加法前的特征就会经过一次非线性截断,恒等映射x的值域也会被限制在非负区间,削弱残差学习的表达能力。

关于下采样,主路径的第一个卷积负责缩放空间尺寸,捷径投影负责同步缩放。我习惯把下采样统一定义在"每个阶段第一个残差块的主路径第一层"上,方便阅读和调试。另一个细节是全局平均池化之前的最终卷积输出通道是512,如果需要适配不同的分类任务,只需替换最后的全连接层,特征提取部分完全可以直接复用预训练权重。

ResNet的BN层在PyTorch里有三个重要参数:running_mean、running_var、num_batches_tracked。默认momentum=0.1,在正常批次大小下可以直接用默认值。但如果batch size很小,建议调低momentum或改用GroupNorm,否则BN层的统计量震荡会很严重。这个我在做小批次微调时切身体会过——batch size从32降到8,同一套代码训练Loss直接不稳定。

4. 实操构建要点与常见问题排查

4.1 手写ResNet时的结构顺序与初始化细节

这里我直接在PyTorch里写一段可运行的BasicBlock和ResNet-18骨架,方便大家对照结构顺序。

import torch import torch.nn as nn class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels * self.expansion: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels * self.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * self.expansion) ) def forward(self, x): identity = self.shortcut(x) out = self.conv1(x) out = self.bn1(out) out = torch.relu(out) out = self.conv2(out) out = self.bn2(out) out = out + identity out = torch.relu(out) return out class ResNet18(nn.Module): def __init__(self, num_classes=1000): super().__init__() self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(64, 64, 2, stride=1) self.layer2 = self._make_layer(64, 128, 2, stride=2) self.layer3 = self._make_layer(128, 256, 2, stride=2) self.layer4 = self._make_layer(256, 512, 2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride): layers = [] layers.append(BasicBlock(in_channels, out_channels, stride)) for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = torch.relu(x) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x

这段代码里有两个容易被忽略的细节:第一个是所有卷积都设置bias=False。因为卷积层后面紧跟着BN,BN层自带偏置项,如果卷积层再带bias,相当于多了一个被BN清零的冗余参数,不仅白白浪费显存,还可能导致初始化阶段不稳定。第二个是BasicBlock里的shortcut只在步长改变或通道数改变时才生效,其他情况用空Sequential包裹输入本身。

初始化方面,PyTorch默认的卷积初始化是均匀分布,对于深层网络来说并不理想。我建议照搬原论文方案:对卷积层使用Kaiming正态初始化(He初始化),BN层的权重初始化为一,偏置初始化为零。PyTorch中可以通过apply方法统一处理:

def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) model.apply(init_weights)

我对比过默认初始化与这个初始化方式的差异:在某个图像分类Demo上,默认初始化在第一个epoch的准确率大约在15%左右,使用Kaiming初始化后第一个epoch直接到了33%。深层网络对参数初始分布的敏感度远超我们的想象,这个步骤一定不能省。

4.2 训练中遇到的典型问题与解决思路

Loss不下降。这种情况最常见的原因是学习率设置过大或BN层的参数没有正确冻结/更新。我在做迁移学习时,如果加载了预训练权重,通常冻结前几个阶段的参数不参与训练,只更新最后阶段和全连接层。但有一次忘记冻结BN层,BN的running_mean照样更新,导致特征分布偏移,Loss在epoch 5以后开始剧烈波动。后来我把BN层也设为requires_grad=False,或者直接置为eval模式,问题立刻消失。

显存OOM。深层ResNet在训练时占用显存很大,尤其是Bottleneck中同时保存多份特征图用于反向传播。我的经验是先确认输入尺寸。比如原始实现是针对224x224图像设计的,如果实际数据集图像是512x512,特征图占用的显存会呈平方级增长。解决方法是适当减小batch size,或者使用混合精度训练(PyTorch的autocast + GradScaler),显存占用能降低接近一半,速度还有提升。

模型输出全是同一类。有时候训练多分类问题,模型学到最后只输出一个类别,概率接近1。我遇到过这种情况,排查后发现是最后一个全连接层的输入没有做全局平均池化,而是直接把7x7的特征图拉平,导致参数爆炸,优化陷入局部最优。正确做法是在进入全连接层之前,用AdaptiveAvgPool2d把特征图压缩为1x1,彻底抛弃空间位置信息。

残差块内部维度不匹配。这个问题在自定义数据集时尤为常见。比如你想在某个中间层接入另一个任务分支,需要保证分支输入通道与当前特征图一致。建议在构建网络时写一个测试前向传播的脚本,用随机的4D张量(1, 3, 224, 224)跑一遍模型,打印每一层的输出尺寸。我第一次搭建ResNet-50时就用这个办法,几分钟内就抓到了一个Bottleneck里输出通道写错的问题,比训练中报错提醒高效太多。

下面是我整理的一个常见问题速查表:

现象可能原因排查/解决思路
训练Loss不下降学习率过大/过小先用1e-3左右测试,观察曲线走势
验证集准确率明显低于训练集Dropout或数据增强过度调整增强强度,检查训练模式正确性
某一阶段Loss出现NaNBN层权重数值异常或输入含NaN减小学习率,检查数据加载是否干净
显存不足输入分辨率过大或batch太大降低分辨率、减小batch、开混合精度
不同batch间性能波动大BN的batch size过小增大batch或换用GroupNorm

再补充一个独家经验:ResNet在较浅层(ResNet-18)时,可以适当减少第一个7x7卷积的stride,比如把stride从2改为1,这样特征图分辨率更高,在中小尺寸数据集上往往能提升准确率。但这样做会显著增大计算量,如果数据集本身不超过256x256,建议维持原设置。

我在实际项目里还发现,把最后一个残差阶段的stride从2改成1,可以有效提升小目标检测任务的效果。代价是全局池化前的特征图从7x7变成14x14,全连接层之前的flatten维度变大,显存消耗增加。这种调整需要结合任务目标权衡,没有绝对正确的配置。

最后分享一个小技巧:在训练ResNet时,无论你用哪个框架,都建议保存一个"结构校验"脚本,每次改动模型后先用随机输入跑一遍前向和反向。不要怕麻烦,这个脚本能帮你节省大量排错时间。做图像分类、目标检测甚至自监督学习时,ResNet的这部分构建逻辑都是通用的,把基础打牢了,后面换什么结构都会顺手很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 3:44:06

为什么外贸企业需要AIGEO优化?AI搜索时代下海外获客的新机会

为什么外贸企业需要AIGEO优化?AI搜索时代下海外获客的新机会核心摘要海外采购者的搜索行为正在发生根本性变化:越来越多人通过ChatGPT、Perplexity、Copilot等AI工具获取供应商信息,而非仅仅依赖传统搜索结果页。AIGEO(AI生成引擎…

作者头像 李华
网站建设 2026/10/15 3:44:05

庆祝冬余数据联合广东省商业地产租赁协会举办“AI 赋能商业企业创新实践案例”活动取得圆满成功!

庆祝冬余数据联合广东省商业地产租赁协会举办“AI 赋能商业企业创新实践案例”活动取得圆满成功!近日,由冬余数据携手广东省商业地产租赁协会及数智经济专委会共同主办的“AI赋能商业企业创新实践案例”主题活动,在各方与会嘉宾的热烈交流中圆…

作者头像 李华
网站建设 2026/10/15 3:43:36

C/C++避坑指南:内存管理、指针与多线程实战经验

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 3:42:43

基于 Spring Boot 的公考线上组卷系统的设计与实现

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 1. 项目背景与意义 随着公务员考试报考人数的持续增长,传统线下组卷方式存在出题效率低、试卷复用难、知识点覆盖不均衡等问题。教师或培训机构需要花费大量…

作者头像 李华