☰
深度拆解ResNet-50:残差网络原理、结构实现与工程实践
2026/9/30 1:23:35 网站建设 项目流程

1. 先搞清楚ResNet-50到底解决了一个什么问题

做深度学习这一行,绕不开ResNet。2015年何恺明团队提出它的时候,直接把ImageNet分类的错误率刷到了3.57%,这个数字后来很长一段时间都在人类识别水平之下。但我更想说的是,ResNet之所以成为深度学习教科书级别的网络结构,不只是因为精度高,而是它解决了一个看起来几乎无解的问题:网络越深,效果反而越差。

在ResNet出现之前,主流的思路是网络越深,表达能力越强,效果应该越好。VGG已经堆到了19层,GoogleNet用Inception模块做到了22层。但真把VGG硬生生加到几十层甚至上百层,你会发现训练集上的loss都降不下去,这叫“退化问题”。这不是过拟合,因为过拟合是训练集表现好、测试集差;退化是连训练集都学不动了。这就很尴尬了——你加了很多参数,结果连训练数据都拟合不了。

我当时第一次看到这个现象的时候,脑子里冒出的类比是:一个学生学习压力过大的时候,不一定学得更好,反而可能直接摆烂。深层网络通过反向传播更新参数,层一多,梯度在反向传播过程中要么指数级消失,要么指数级爆炸,参数根本更新不动。梯度爆炸还能用BatchNorm和合适的初始化压一压,梯度消失才是真正的拦路虎。

ResNet给出的解法很聪明,也很简洁:与其让每一层去学习一个完整的映射(也就是从输入直接变换到输出),不如让每一层只学习输入和输出之间的残差。打个比方,一个任务的目标是从A点到B点,传统网络要求每一层自己走完整个路程,而ResNet允许每一层只在上一层的旁边做“微调”,上一层的成果可以直接“抄近路”传到后面。

这个“抄近路”的机制,在结构上就是一条skip connection,也就是恒等映射(identity mapping)。它保证了一件特别重要的事:即使新增的层什么都学不到,最差也能保持原来的效果。换句话说,深层网络的下限被兜住了。然后只要优化器给力,层能学一点东西就是赚到,这就把“加深网络”从一件高风险的事,变成了一件稳赚不赔的买卖。

这个思路后来几乎成了深度学习网络设计的默认标配。你去看现在各种SOTA模型,DenseNet、ResNeXt、EfficientNet,再到Transformer里的残差连接,多多少少都有ResNet的影子。所以吃透ResNet-50,等于拿到了理解现代网络结构的敲门砖,这也是我为什么建议每个刚入门深度学习的人都亲手把它的结构扒一遍的原因。

2. ResNet-50网络结构逐层拆解

2.1 50层到底是怎么数出来的

很多人第一次看到“ResNet-50”这个名字,会以为它是随随便便叫的,其实这个数字是有严格计算的。ResNet家族里,34层及以下的用的是BasicBlock(两个3x3卷积),50层及以上的用的是Bottleneck(三个卷积:1x1、3x3、1x1)。ResNet-50就属于后者,它的层数统计口径是:所有带权重的卷积层和全连接层加起来一共50层,池化层和BatchNorm层不算在内。

我们把ResNet-50掰开来看,它由这几大块组成:

阶段输出尺寸结构重复次数
Conv1112x1127x7卷积,64通道,步长21次
Conv2_x56x563x3最大池化 + Bottleneck,256输出通道3个
Conv3_x28x28Bottleneck,512输出通道4个
Conv4_x14x14Bottleneck,1024输出通道6个
Conv5_x7x7Bottleneck,2048输出通道3个
分类头1x1全局平均池化 + 全连接层(1000类)-

这里有个很有意思的点:整个ResNet里面的卷积几乎不会让特征图尺寸发生剧烈的突然变化,都是按照2的倍数在逐步减半:224 -> 112 -> 56 -> 28 -> 14 -> 7。每次减半的地方,通道数都会做一次翻倍,这是一种非常经典的设计哲学:空间分辨率变小,信息被压缩到更多的通道里。这样既控制了计算量,又不丢信息。

回到层数计算,Conv1那层7x7卷积算1层,后面Conv2_x到Conv5_x一共有3+4+6+3=16个Bottleneck,每个Bottleneck里有3个卷积层,所以是16x3=48层,再加上最后的全连接层,1+48+1=50,刚好对上。

我当时第一次自己推这个数字的时候,还有个容易忽略的细节:Conv2_x的第一个Bottleneck之前,有一个3x3的最大池化,这个池化是不占层的。但ResNet在实际工程实现里,经常把这个池化加上,有些变体甚至会去掉它来保留更多空间信息,这些细节在你改网络的时候很容易踩坑,后面我会详细说。

2.2 Bottleneck为什么是“1x1降维->3x3卷积->1x1升维”

先看一个Bottleneck的具体结构。以Conv2_x为例,输入是56x56大小、256通道的特征图,Bottleneck内部的操作是:

  1. 第一个1x1卷积,把256通道压缩到64通道,这一步的作用是降维;
  2. 中间是3x3卷积,在64通道上进行空间特征提取,这是整个Bottleneck的核心计算;
  3. 最后一个1x1卷积,把64通道升回256通道,作用是恢复维度,好让输入输出能够直接相加。

为什么要这样设计?直接用一个3x3卷积从256通道处理到256通道不行吗?算一笔账就明白了。假设输入特征图是56x56x256,直接做3x3卷积保持256通道输出,需要的计算量大约是56x56x256x256x3x3,约等于18.5亿次乘加运算。而用Bottleneck,第一步1x1卷积是56x56x256x64,约0.5亿;第二步3x3是56x56x64x64x3x3,约1.15亿;第三步1x1是56x56x64x256,约0.5亿。加起来约2.16亿次,只有原来的约1/8。

这就是“瓶颈”这个名字的由来:先用1x1卷积把通道数“掐细”,在低维空间做3x3卷积,再用1x1卷积把通道数“撑粗”。代价是3x3卷积的输入输出通道不对等,但它带来的计算量节省非常夸张。这也是为什么ResNet-50比VGG-19层数多一倍还多,但计算量反而更低的原因——VGG-19的FLOPs大约是196亿,ResNet-50只有约38亿,查了将近5倍。

从信息流动的角度看,这个设计还有一个额外的好处:1x1卷积相当于在通道维度上做了一个全连接操作,能够对通道之间的信息进行交互和压缩,提取出更紧凑的特征表示。实际训练时我也观察过,Bottleneck中间那层3x3卷积学到的特征往往更“精炼”,前面的降维层相当于给它做了一次特征筛选。

2.3 shortcut两副面孔:恒等映射和投影映射

残差连接是整个ResNet的灵魂,但它的实现方式有两种。当输入输出维度一致的时候,shortcut直接就是一个恒等映射,也就是把输入原封不动地加到残差块的输出上,用公式表达就是y = F(x, {W_i}) + x。这在Conv2_x内部很常见,因为每个Bottleneck都维持在256通道。

但是当输入输出维度不一致的时候,比如Conv3_x的第一个Bottleneck,输入是56x56x256,输出是28x28x512,这时候x直接加过去是加不了的。ResNet的做法是用一个1x1卷积,步长设为2,把x的尺寸和通道数都变成和输出一致,然后才能相加。这种叫做投影映射(projection shortcut)。

值得留意的是,ResNet作者在论文里还对比过几种不同的shortcut策略:一是恒等映射加补零来填充维度,二是投影映射只在维度不匹配时使用,三是所有shortcut都用投影映射。实验结果显示,第二种策略效果最好,而且参数量增加很少。所以标准的ResNet-50就是采用“能恒等就恒等,不能恒等才投影”的策略,这也成了后来大多数残差网络设计的默认规则。

我自己在复现的时候,这里有个高发坑点:PyTorch等框架里,如果用1x1卷积作为投影映射,步长要设置为2,这样特征图尺寸才能减半。但要注意数据排布,stride=2的1x1卷积本质上相当于在空间维度上每隔一个像素采样一次,如果你后续接的BatchNorm处理不当,有时候会导致信息丢失,尤其在目标检测这类需要精细空间定位的任务里,有些论文会特意把下采样改到3x3卷积上,而不是用1x1卷积来下采样。这个细节在原版ImageNet分类任务上影响不大,但迁移到检测分割任务时就很关键了。

3. 动手实现:用PyTorch从零搭建一个ResNet-50

光看结构图总觉得隔了一层,真正自己动手敲一遍代码,才对网络结构有肌肉记忆。下面这段代码基于PyTorch框架,我删掉了冗余的部分,保留了最核心的Bottleneck实现和整体组装逻辑。

import torch import torch.nn as nn class Bottleneck(nn.Module): expansion = 4 # 最后一个1x1卷积把通道数变为输入通道数的4倍 def __init__(self, in_channels, mid_channels, stride=1, downsample=None): super().__init__() # 第一个1x1:降维 self.conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(mid_channels) # 第二个3x3:空间特征提取,stride可能为2用于下采样 self.conv2 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(mid_channels) # 第三个1x1:升维,输出mid_channels * expansion self.conv3 = nn.Conv2d(mid_channels, mid_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(mid_channels * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out = self.relu(out) out = self.conv3(out) out = self.bn3(out) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out def make_layer(block, in_channels, mid_channels, blocks, stride=1): downsample = None if stride != 1 or in_channels != mid_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d(in_channels, mid_channels * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(mid_channels * block.expansion) ) layers = [] layers.append(block(in_channels, mid_channels, stride=stride, downsample=downsample)) in_channels = mid_channels * block.expansion for _ in range(1, blocks): layers.append(block(in_channels, mid_channels)) return nn.Sequential(*layers) class ResNet50(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.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = make_layer(Bottleneck, 64, 64, blocks=3) self.layer2 = make_layer(Bottleneck, 256, 128, blocks=4, stride=2) self.layer3 = make_layer(Bottleneck, 512, 256, blocks=6, stride=2) self.layer4 = make_layer(Bottleneck, 1024, 512, blocks=3, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * Bottleneck.expansion, num_classes) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.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 if __name__ == '__main__': model = ResNet50(num_classes=1000) fake_input = torch.randn(1, 3, 224, 224) output = model(fake_input) total_params = sum(p.numel() for p in model.parameters()) print(f"输出形状: {output.shape}") print(f"参数量: {total_params / 1e6:.2f}M")

运行这段代码,你会看到输出大约是25.6M参数量,也就是约2560万个参数。单张224x224的图跑一次前向,在消费级显卡上大概是毫秒级别,这个体量在深度学习模型里算非常友好的。

代码里有两个地方我要额外提醒你。一个是Bottleneck.expansion = 4,这个类变量是核心逻辑,它决定了每个Bottleneck最后一层输出的通道数是中间层的4倍。另一个是make_layer函数里对_downsample_的处理逻辑:只有步长不为1或者输入输出通道不一致时,才会生成投影映射的卷积,否则直接走恒等路径。这个条件判断就对应了我们在2.3节里讲的设计策略。

我在给实习生讲这段代码时经常说,layers列表添加第一个Bottleneck用的是stride=stride,后面的Bottleneck全部默认stride=1。这意味着每个阶段的第一个Bottleneck负责下采样,后续的都保持分辨率。你做目标检测时如果想把某个特征层作为输出,这个规律是必须记熟的。

4. 训练细节和踩坑实录:ResNet-50没那么玄,但坑是真不少

4.1 超参数和训练策略,照搬论文只是起点

ResNet-50的原始训练配置,对今天的硬件来说略显保守,但核心思路值得保留。原论文里ImageNet训练用SGD,momentum=0.9,weight decay=1e-4,初始学习率0.1,batch size是256,每30个epoch学习率除以10,总共训练90个epoch。这个配置在今天复现,精度一般能到75%以上,如果配合Cosine学习率调度和Label Smoothing,76%以上很轻松。

但实际操作中我强烈建议直接用迁移学习,别从零训练。ResNet-50官方预训练权重在ImageNet上的top-1准确率约76.1%,你换个数据集微调,往往几百个epoch就能收敛得很好,比你从零开始训练省下大量时间。微调有几个关键操作:

  • 替换最后的全连接层:把fc层输出改成你自己的类别数。
  • 冻结浅层:前几层学到的是通用特征(边缘、纹理、颜色),在小数据集上微调时可以冻结layer1甚至layer2,只训练深层和分类头,这样能防止过拟合,收敛还快。
  • 学习率设置:全连接层用较大的学习率,比如1e-3;主干用较小的学习率,比如1e-4甚至更低。很多框架里叫“分组学习率”,这个技巧在迁移学习里几乎是必选项。

我做过一个医学图像分类项目,数据集只有5000张图,最开始直接全量微调,验证集loss在几个epoch后就涨了,典型的过拟合。后来冻结了layer1和layer2,只微调后面三层和分类头,加了一点数据增强,AUC直接从0.88拉到0.93。这就是ResNet结构设计带来的“红利”:浅层特征足够通用,你的数据量再小,也能借助它在ImageNet上学到的能力。

4.2 训练时长困惑、显存溢出、权重初始化问题

训练ResNet-50时,很多人第一个困惑是“为什么比我预想的慢”。这里要区分是数据加载慢还是计算慢。如果你的GPU利用率上不去,先用nvidia-smi看一眼,如果利用率在30%以下来回跳,多半是数据加载瓶颈。把DataLoader的num_workers调大,用上pin_memory=True,甚至把数据预处理放到GPU上用torchvision.transforms的GPU版本,都能明显缓解。我之前在一台8核的机器上跑,num_workers从默认0改成8之后,训练速度直接翻了将近一倍。

还有一个高频问题是显存溢出(OOM)。ResNet-50本身不重,224x224输入、batch size 64在12GB显存上绰绰有余。但你要是做目标检测或者高分辨率输入,OOM就会频繁出现。这时候优先检查有没有在不该开梯度的地方开了梯度,比如BatchNorm的track_running_stats设置是否合理。然后把batch size调小,配合梯度累积来模拟大batch。PyTorch 2.0以上的版本,还可以用torch.compile对模型做编译优化,实测能省10%~20%显存,速度还更快。

最后一个经常被忽略的点是权重初始化。如果你用PyTorch官方实现的ResNet-50,它默认的初始化是Kaiming初始化,专门为ReLU这类激活函数设计的。但如果自己改动了网络结构,特别是加了一些自定义层,一定要确认这些层是否被正确初始化。之前我加了一个自定义注意力模块,忘记做Xavier初始化,结果前面十几个epoch的loss完全不动,排查了半天才发现是初始化问题。这种问题最坑的地方在于它不报错,只是模型不收敛,特别考验经验。

4.3 从分类任务改成检测分割任务时的结构性坑

ResNet-50除了做图像分类,还广泛用作检测、分割网络的backbone,比如Faster R-CNN、Mask R-CNN、DeepLabV3这些经典模型,只要你去翻它们的代码,十有八九都能看到ResNet-50的身影。

但直接拿来用有一个明显的坑:原始ResNet-50输出特征图的分辨率只有7x7,经过5次下采样,原图缩小了32倍,这种高倍下采样对小目标非常不友好。因此检测分割任务里,往往需要利用多尺度特征图,比如FPN(特征金字塔)就用了layer2、layer3、layer4三层的输出,分别对应原图的1/8、1/16、1/32分辨率。这时候你在用ResNet-50时,就需要注意前向传播过程中把这三层的特征都保存下来,而不是只保留最后一层。

还有一个细节是空洞卷积。在分割任务中,如果既想保留更大的感受野,又不想进一步降低特征图分辨率,就需要把ResNet-50中layer4或layer3的卷积替换成带空洞的版本。但替换的时候,卷积步长格式和padding都要重新计算,比如把layer4的3x3卷积stride从2改成1,那么对应的空洞率要设置为2,才能保持感受野不缩水。这些改动都没有现成的公式,得根据特征图尺寸变化手动推算。这也是为什么我建议你先把ResNet-50自带的参数数量、特征图尺寸变化表背下来,改起来才不容易出错。

5. ResNet-50的江湖地位与选型建议

5.1 和VGG、DenseNet、EfficientNet比,它到底强在哪

每个模型都有它的时代背景和适用场景。VGG是2014年的王者,结构极其规整,全是3x3卷积堆叠,但它参数实在太大,光全连接层就占了上亿参数,现在基本只在风格迁移等任务里当特征提取器使用。DenseNet通过密集连接实现了特征复用,参数效率更高,但显存占用非常大,训练的时候让人一言难尽。EfficientNet用NAS搜出来的复合缩放方案在精度和效率上都很惊艳,但结构没那么直观,魔改空间小。

相比之下,ResNet-50的优势在于极佳的平衡性:精度处于一个不错的水平,参数量适中,结构简单清晰,预训练权重到处都能找到,硬件要求也不高。这些东西叠加在一起,就意味着它是工业界和学术界最通用的默认选项。你想在CIFAR-10或者自己的小数据集上快速验证一个idea?用ResNet-50准没错。你想把模型部署到边缘设备上?ResNet-50配合剪枝和量化,能在精度损失很小的情况下跑得很欢。

5.2 什么时候不该用ResNet-50

虽然我很喜欢ResNet-50,但它并不是万能的。如果你面对的任务非常简单,比如只有几千张图、两三类的二分类任务,一个ResNet-18甚至MobileNet就够用了,没必要上50层。ResNet-50在小数据集上如果不做迁移学习,反而容易过拟合。反过来,如果你的任务特别复杂,比如高分辨率遥感图像分割,那ResNet-50可能显得力不从心,这时候可以考虑更深、更强的backbone,比如Swin Transformer或ConvNeXt。

选型时还有一个很容易被忽略的维度:推理速度。ResNet-50在一张T4显卡上跑224x224输入,大概能到700~800 FPS(仅推理模型本身,不含前后处理和数据加载)。如果你用的是CPU推理,一张图可能要20~50毫秒,这就要看你项目的实时性要求了。我们之前做一个工业瑕疵检测项目,客户要求单张检测时间小于50毫秒,我们当时对比了ResNet-50和EfficientNet-B3,发现ResNet-50在CPU上慢一些,后来换成了ResNet-18才达标。所以“高精度”不总是第一优先级,部署约束必须提前想清楚。

5.3 改良思路:ResNet-50的经典变体

ResNet-50之所以值得学,还因为它的变体几乎遍布整个深度学习生态。ResNet-101在ImageNet上能再提高一个点左右,但计算量增加了近一倍。ResNeXt把3x3卷积替换成分组卷积,在同等参数下精度比ResNet更高,但分组数量调节起来比较麻烦。SE-ResNet通过Squeeze-and-Excitation模块在通道维度上做注意力,今天看很多模型里都有它的影子。还有RegNet、Res2Net等等,底层逻辑都是从ResNet衍生出来的。

理解这些变体有一个方法论:它们都在回答同一个问题——“如何在增加模型表达能力的同事,控制住计算开销”。ResNet的Bottleneck用1x1卷积控制计算量,ResNeXt用分组卷积控制计算量,EfficientNet用复合缩放控制计算量,思路一脉相承。你把ResNet-50的结构和设计动机吃透了,后面看任何新提出的网络,都会有一种“万变不离其宗”的感觉。

6. 我踩过的一些坑,今天就替你踩明白了

最后分享几个我在实际项目中遇到的、和ResNet-50密切相关的坑,这些往往是文档里查不到的。

第一个,用torchvision自带的ResNet-50预训练权重时,注意输入归一化。ImageNet预训练模型要求输入按mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225]做标准化。很多人直接加载权重却不做这个预处理,效果立刻掉几个点。这个问题排查起来特别隐蔽,因为模型不报错,loss也正常下降,就是精度上不去。

第二个,BatchNorm在训练和推理模式下的行为差异。ResNet-50里大量使用BatchNorm,训练时它统计每个batch的均值和方差,推理时用的是训练过程中保存的全局统计量。如果你在做推理时忘记调用model.eval(),模型会继续用batch内的统计量,结果在batch size=1的情况下,特征分布完全乱掉,输出结果可能差到离谱。这种情况在写部署脚本时尤其容易踩,不仔细查你根本想不到是这个问题。

第三个,微调时不要一股脑全部解冻。很多时候你用ResNet-50做迁移学习,直接把所有层都解冻训练,结果在小数据集上迅速过拟合。正确做法是先冻结浅层,只训练后面几层和分类头,跑几个epoch看验证集表现,然后再逐渐解冻更多层。也有人反着来,先全部解冻加正则,效果也一样,但我觉得分层解冻控制在直觉上更舒服一些。

第四个,ResNet-50的FLOPs参数在工程测算里和论文数字对不上。论文里的FLOPs往往只算乘加次数,不把BatchNorm和ReLU计算进去,也不一定考虑输入尺寸。你自己用thop库计算时,224x224输入得到的FLOPs大约是4.1G(这个数字包含乘和加,所以是论文里3.8G的近两倍)。这个差异不是bug,理解了口径不同就能解释。

我自己现在做一个新的视觉项目时,首选backbone十有八九还是ResNet-50,不是因为它最先进,而是因为它的“确定性”最高:预训练权重可靠,复现路径成熟,调试体验稳定。这套确定性在节奏紧张的工业项目里价值千金。你如果刚入门,我建议你花一个下午,对照论文把ResNet-50的每一层结构手动画一遍,再用代码复现一遍,最后用预训练权重在CIFAR-10或者自己的小数据集上做一次完整的微调。这套流程走完,你对深度学习网络结构的理解会从“看着好像懂了”变成“真正上手会用”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询