ResNet这个网络,在深度学习里算是"国民级"的存在了。不管你是做分类、检测、分割,还是现在各种Transformer架构的骨干网络,里面多少都带着残差思想的影子。网上讲ResNet原理的文章多如牛毛,但很多都停留在"跳连接"这四个字上。这次我结合自己从零搭网络的经验,把ResNet构建过程中那些容易忽略的细节、需要做的关键决策,以及代码层面的具体实现,从头到尾梳理一遍。
整篇文章我会先讲清楚残差结构的本质,然后带你一行行拆解BasicBlock、Bottleneck的构建逻辑,再通过PyTorch代码把ResNet-18到ResNet-50的完整搭建过程过一遍,最后分享几个我在实际训练和调试中踩过的坑。无论你是刚入门想搞懂ResNet为什么这么设计,还是已经能跑通代码但想加深理解,这篇应该都能给你一些启发。
1. 残差到底是给网络加了一条什么"路"
很多文章直接把ResNet的成功归功于"跳连接"解决了梯度消失。这个说法没错,但不够本质。构建ResNet之前,先把这个问题想清楚,你才能理解后面每一个结构选择。
1.1 退化问题:深层网络为什么反而更差
在ResNet出现之前,研究者发现一个很奇怪的现象:网络层数加深,训练集上的loss反而升高。这不是过拟合,因为训练集loss高说明模型根本就没学好。理论上更深的网络表达能力更强,至少不应该比浅层网络差。
这个问题被称为退化问题。它的核心原因在于:深层网络在反向传播时,梯度要一层层乘回去。假设每一层梯度的缩放因子在0.8到1.2之间波动,经过几十层之后,梯度要么指数级衰减到接近0,要么指数级爆炸。不管哪种情况,浅层参数都得不到有效更新,网络虽然"深"了,但实际起作用的可能只有最后那十几层。
我见过一个很形象的比喻:没有残差的深层网络,就像一本特别厚的教材,每一页都建立在上一页的基础上。只要中间某一页印错了,后面所有内容都会跟着错。而残差结构相当于给每一页都画了一条"回到正确信息"的索引,就算某页内容有问题,后面的章节仍然可以直接获取到前面的原始知识。
1.2 残差块的数学本质:F(x) + x
残差单元的核心表达式很简洁:输出 = F(x) + x。其中F(x)代表堆叠的卷积层要学习的映射,x是输入。
关键是理解这个加法对网络行为的影响:
- 如果F(x)趋近于0,那么整个块的输出就是x,网络相当于恒等映射。这给了深层网络一个"退化成浅层网络"的退路,而普通网络没有这条退路。
- 反向传播时,梯度可以通过两条路径传回:一条经过F(x)内部的卷积层,另一条是直接从输出传到输入,这条捷径的梯度始终是1。这就是为什么残差结构能天然缓解梯度消失。
我在纸上推导过几次这个反向传播过程,说实话,第一次看到梯度公式里那个"1"的时候,我才真正理解为什么何恺明要在文章里特意强调identity mapping的重要性。那个"1"意味着不管网络多深,梯度都能有一条无损通道传回浅层,这是普通卷积结构给不了的属性。
1.3 为什么叫"学习残差"而不是"学习映射"
传统的卷积块直接学习H(x),而残差块学习的是F(x) = H(x) - x,也就是输入和输出之间的差量。所以叫"残差"。
学习残差比直接学习映射更容易,原因很直观:如果最优映射接近恒等映射,那F(x)的参数自然全为0,学习的目标很简单。就算最优映射偏离恒等很多,在输入x已经携带了大部分信息的前提下,网络只需要去拟合那个"偏差部分",工作量小得多。
我自己的理解是:普通网络像一个从零开始写文章的作家,每层都要重新组织语言;残差网络像一个编辑,拿着上一版稿子,只需要在上面做批注和修改。修改比重写容易得多,这就是残差结构表达效率更高的原因。
2. 构建ResNet前必须搞清楚的四个结构细节
ResNet的代码在网上随便一搜就有,但很多人照着敲完还是云里雾里。我建议在动手写代码之前,先把四个关键结构细节想明白。这些细节决定了你写出来的代码能不能work,也决定了你能否灵活改造网络。
2.1 BasicBlock与Bottleneck的设计意图
ResNet家族里有两个基础块。ResNet-18和ResNet-34用的是BasicBlock,结构是:3x3卷积 + 3x3卷积,中间夹着BN和ReLU。ResNet-50及以上用的是Bottleneck,结构是:1x1卷积 + 3x3卷积 + 1x1卷积。
BasicBlock很简单,两个3x3卷积堆起来。它计算量小,适合层数不太深时使用。如果把它原封不动堆到50层以上,参数量和计算量会变得很大,而且训练时间会显著拉长。
Bottleneck的设计更巧妙,它先用1x1卷积把通道数降下来(比如256降到64),在低维空间做3x3卷积,最后再用1x1卷积把通道数升回去。这样做的目的是降低计算量:同样输入256通道,直接用3x3卷积处理的计算量大,但先降维再卷积再升维,总计算量可能只有原来的四分之一甚至更少。代价是Bottleneck的表达能力理论上略弱于直接大卷积核,但通过加深层数可以弥补。
我在实际项目中通常会做一个对比:如果硬件资源充足且追求精度,优先考虑ResNet-50以上的结构;如果追求速度和轻量化部署,ResNet-18或ResNet-34更合适。这是第一层取舍。
2.2 stride=2的降采样位置
ResNet在每次进入新的stage时,第一个卷积块的stride会设置为2,用来降低特征图的空间分辨率。降采样放在块的哪个位置,不同实现有不同做法,但主流做法是:把stride=2放在块内第一个卷积层上。
以BasicBlock为例,新stage的第一个块输入分辨率是56x56,第一个3x3卷积stride=2,输出变成28x28。这样整个块的输出就是28x28,实现了降采样。Bottleneck同理,stride=2放在中间那个3x3卷积上。
这里有个容易写错的点:如果stride=2,kernel size=3,不补padding的话,输出尺寸会变成(56-3)/2+1=27.5,无法整除。所以卷积层的padding必须设为1,才能保证输出尺寸恰好减半。很多新手写的代码在这里会出问题,特征图尺寸对不上,后续拼接直接报错。
2.3 输入通道与输出通道不匹配时,shortcut怎么处理
残差块的加法要求F(x)和x的shape严格一致。如果两者通道数或分辨率不同,必须对x做变换再相加。一般有两种方案:
- 方案A:在shortcut上用1x1卷积调整通道数,配合stride调整分辨率。这是最灵活的做法,但会引入额外参数。
- 方案B:用1x1卷积同时改变通道和分辨率,或者用average pooling降采样加padding补通道。后者参数为零,但表达能力弱一些。
PyTorch官方实现里采用的是1x1卷积方案,stride设为与块内第一个卷积相同的值。这样x经过shortcut后,shape和F(x)完全对齐,可以直接相加。
我在自己搭网络时也试过用pad补零的方式处理通道不匹配,结果是训练初期loss下降明显变慢。原因不难理解:补零相当于往特征里塞了没意义的0,这些0参与加法后会在早期给梯度带来噪声。用1x1卷积做投影虽然参数多一点,但信息利用更充分。
2.4 BN和ReLU的顺序为什么这么讲究
残差块内部的标准顺序是:Conv -> BN -> ReLU,但在块输出那个位置,有个细节和普通卷积块不同。
以BasicBlock标准实现为例:
- 第一个Conv(3x3, stride=1或2) -> BN -> ReLU
- 第二个Conv(3x3, stride=1) -> BN
- 最后和shortcut相加,再经过ReLU
也就是说,第二个卷积后面先做BN,先不要接ReLU,让经过BN的F(x)与x相加之后,再做一次ReLU。这个顺序是"先加后激活",而不是"先激活后加"。
如果先对F(x)做ReLU再相加,ReLU会把F(x)里的负值都置零,这会让残差块强制学习非负映射,表达空间受限。先加后ReLU则允许残差映射取任意实数值,网络更容易学到恒等映射,这在理论上有更优的性质。PyTorch官方实现就是"先加后ReLU",这个细节千万别写错。
3. PyTorch一步步搭建ResNet-18/34的完整过程
接下来进入正题:用PyTorch从零搭一个可运行的ResNet。我会以ResNet-18为主,因为它的每个细节都看得到,理解清楚后扩展到ResNet-50只是替换块定义的事。
3.1 定义BasicBlock:残差单元的基石
先定义BasicBlock。它接收四个参数:输入通道数、输出通道数、stride以及是否使用投影shortcut。代码是核心逻辑,我在关键行后面加了注释。
import torch import torch.nn as nn class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(BasicBlock, self).__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.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) 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) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out有几个点需要注意:
bias=False:卷积层之后接BN,BN有平移参数,卷积的bias会被BN吸收。如果不设False,参数冗余,还容易带来数值不稳定。- ReLU用了
inplace=True,省显存,实测在深网络上能省不少内存。缺点是会覆盖输入值,但残差块这个位置用inplace没有副作用。 downsample是一个可选的模块,传进来就直接用,不传就是恒等shortcut。
3.2 定义Bottleneck:通往深层ResNet的关键
ResNet-50以及更深的版本必须用Bottleneck。它的expansion属性为4,意思是一个Bottleneck块的最终输出通道数是out_channels * 4。这个设计是为了统一stage间的通道衔接。
class Bottleneck(nn.Module): expansion = 4 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(Bottleneck, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, stride=1, bias=False) self.bn3 = nn.BatchNorm2d(out_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 outBottleneck的通道变化是:输入-256 -> 降-64 -> 3x3卷积 -> 升-256。中间是瓶颈,因此得名。stride=2的位置在中间的3x3卷积上。第一个1x1卷积只改变通道不改变分辨率,最后一个1x1卷积也只改变通道,真正把分辨率减半的是中间那个3x3。
3.3 组装ResNet主体:stem、四个stage与分类头
ResNet的总体结构分三大部分:
- stem:一个7x7卷积+BN+ReLU,然后接3x3 MaxPool
- 四个stage:每层由多个残差块堆叠
- 分类头:全局平均池化 + 全连接层
先看stem部分的代码逻辑:
class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super(ResNet, self).__init__() self.in_channels = 64 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) # 后续是四个stagestem里的7x7卷积在224x224输入时,输出是112x112,经过MaxPool后变成56x56。这是ResNet最初期的降采样策略:前4倍降采样交给stem完成,后面每个stage再降一倍。
接下来是四个stage的构建逻辑,核心是通过循环动态生成,避免手写一大段重复代码:
self.layer1 = self._make_layer(block, 64, layers[0], stride=1) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes)_make_layer是这段代码的灵魂。它负责处理"一个stage内的多个block"以及"stage间降采样时的downsample"两个逻辑。
def _make_layer(self, block, out_channels, blocks, stride=1): downsample = None if stride != 1 or self.in_channels != out_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * block.expansion), ) layers = [] layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels = out_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) return nn.Sequential(*layers)downsample的触发条件是"stride != 1 或者输入通道不等于输出通道",对应"分辨率需要减半"或"通道需要对齐"两种情况。stage内第一个block用downsample,后续block的输入输出通道已对齐,直接用恒等shortcut。
3.4 定义各规格ResNet的构建函数
有了主体类,剩下的就是用不同layers参数实例化不同版本:
- ResNet-18: layers = [2, 2, 2, 2]
- ResNet-34: layers = [3, 4, 6, 3]
- ResNet-50: layers = [3, 4, 6, 3] + Bottleneck
- ResNet-101: layers = [3, 4, 23, 3] + Bottleneck
以官方重建的简单函数为例:
def resnet18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes=num_classes) def resnet34(num_classes=1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes) def resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes)搭建完成后,建议先跑一个随机输入的前向传播验证尺寸链路是否正确。比如输入torch.randn(1, 3, 224, 224),检查四个stage后的特征图尺寸是否符合预期:56x56、28x28、14x14、7x7。这一步能快速暴露padding或stride设置错误。
3.5 预训练权重的加载与微调适配
实际项目中很少有人从零训练完整的ResNet,更常见的是加载ImageNet上的预训练权重,迁移到自己的任务里。
PyTorch官方提供了torchvision.models.resnet18(weights=...)可以一键加载。但如果自己手动定义了网络,加载权重时要注意一个坑:分类头fc的输出维度如果不是1000,直接加载会报形状不匹配。解决办法是:
model = ResNet(BasicBlock, [2, 2, 2, 2], num_classes=10) pretrained = torchvision.models.resnet18(weights=torchvision.models.ResNet18_Weights.IMAGENET1K_V1) state_dict = pretrained.state_dict() # 去掉fc层的权重 for k in list(state_dict.keys()): if k.startswith('fc.'): del state_dict[k] model.load_state_dict(state_dict, strict=False)这样加载后,主干网络就带上了预训练知识,只有fc层随机初始化。如果你用的是标准ResNet结构,直接model = torchvision.models.resnet18(pretrained=True)再替换fc更方便;但如果改了block结构或通道数,就需要手动处理state_dict。
我在迁移学习实验中验证过:加载预训练权重比随机初始化在目标数据集上收敛更快,最终精度通常更高。这个结论在小样本数据集上尤其明显。
4. 从ResNet-18升级到ResNet-50的完整替换逻辑
很多初学者会问:代码里把BasicBlock换成Bottleneck,是不是就够了?答案是:不只是换block,还要同步调整通道数和层数配置。
4.1 通道数翻倍策略与expansion的含义
BasicBlock的expansion=1,所以每个block的输入输出通道一致,比如64进64出。Bottleneck的expansion=4,意味着一个Bottleneck块最终输出通道是中间瓶颈通道的4倍。比如Bottleneck接收64通道输入,第一个1x1卷积降到64,3x3卷积还是64,最后一个1x1卷积升到256。这个256就是下一个stage的输入通道。
所以当你用_make_layer(Bottleneck, 64, 3, 1)时,传入的out_channels=64,实际输出通道是256。这也是ResNet-50的layer1输出是256通道的原因。升级时不用手动乘4,_make_layer内部用block.expansion自动处理了。
4.2 为什么ResNet-50的第一个1x1要降维
Bottleneck的1x1降维策略,业内有个通俗的说法叫"bottleneck design"。它的核心动机是计算效率。
假设输入是256通道的14x14特征图。如果用BasicBlock直接做两个3x3卷积,每个卷积的计算量大约是14x14x256x256x3x3,四次乘加。如果用Bottleneck,先1x1降到64通道,计算量是14x14x256x64,3x3卷积在64通道上算,计算量是14x14x64x64x3x3,最后1x1升回256,计算量又是14x14x64x256。总计算量大概只有BasicBlock方案的三分之一到四分之一。在同样的算力预算下,网络可以堆更深,精度反而更高。
这也是我从实际训练中得到的直观体会:相同显存预算下,ResNet-50能比ResNet-34堆更多层,单step耗时并没有成倍增长。Bottleneck的价值不是省参数,而是省计算。
4.3 两种网络在数值行为上的差异
训练过程中,我发现ResNet-50的loss下降曲线比ResNet-18平滑一些,训练初期的震荡更小。原因可能是Bottleneck的1x1降维结构天然带有信息瓶颈,对特征做了压缩,相当于一种隐式的正则化。
另外,由于Bottleneck的计算量更低,可以用更大的batch size。我曾在同一个任务上对比,单卡可以跑ResNet-50 batch=64,换成ResNet-34只能跑到48左右。batch大的好处是BN统计更稳,梯度更新更平滑。这算是Bottleneck的间接收益。
5. 训练三个epoch就崩?我在构建和调试中踩过的坑
这部分分享几个我在自己搭ResNet时真实遇到过的问题。这些问题不算罕见,但网上很少被系统说清楚。
5.1 显存溢出:不只是batch size的锅
我用ResNet-50训练时,batch size设成32,显存直接爆掉。检查后发现,问题出在两个地方:一是ReLU用了inplace=False,每个block多保留了一份激活值;二是torch.no_grad()在验证阶段没写好,梯度图被错误保留。
把inplace=True打开后,显存立刻降了约12%。验证阶段加上with torch.no_grad():后,显存占用进一步降低。这里的小技巧是:PyTorch的反向传播需要保存激活值用于梯度计算,如果在某个block里ReLU保持输入,就可以覆盖输入张量,省掉一份临时内存。ResNet的残差加法本身不依赖保留下来的旧值,所以inplace安全。
5.2 训练初期loss下不去:检查BN的momentum
有一次我用自定义方式初始化网络,结果训练了三个epoch,loss几乎不动。后来换用官方初始化,问题消失。排查后发现,问题出在BatchNorm的momentum上。
默认momentum=0.1在大多数任务里没问题,但当batch size特别小,比如4或8时,BN的统计量抖动很大,模型很难稳定。把momentum调低到0.01或0.001,并用较大的batch size训练,loss就能正常下降。
另外,加载预训练权重后,如果直接在小batch size下微调,BN的running_mean和running_var更新会很慢,前几个epoch的loss会偏高。我一般会在微调时固定BN参数,或者前几个epoch只训练分类头,等loss稳定后再解锁全部层。
5.3 复现性:为什么同样的代码每次跑出来不一样
深度学习训练天然有随机性。但如果你在某次实验中发现两遍结果差异巨大,可以先检查是否设置了随机种子。PyTorch里需要同时设置三处:
import random, torch random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0)需要注意的是,即使设置了这些种子,cudnn的卷积算法选择仍会引入不确定性。如果想完全复现,还要开启torch.backends.cudnn.deterministic = True,代价是训练速度略降。实践中我建议:做对比实验时开deterministic,正式训练时关闭,换取速度。
5.4 数据增强配置:ResNet训练容易忽略的一环
ResNet这类深网络对数据增强非常敏感。官方ImageNet训练用得比较多的是随机裁剪、水平翻转、颜色抖动等。如果换成小数据集,这些增强不够,模型很容易过拟合。
我常用的增强组合是:随机ResizedCrop到224,随机水平翻转,加上color jitter或者mixup、cutout。Mixup在小数据集上提升尤其明显,它把两张图按比例混合,打乱标签分布,逼迫网络学更鲁棒的特征。在CIFAR-10这类小数据集上,mixup配合ResNet-18,最好成绩能从92%提升到94%左右。
6. 构建ResNet之外,我还想聊的延伸思路
ResNet本身解决的是"深网络的退化问题",但它的思想远不止在CNN里适用。理解构建过程后,你会发现很多现代架构都能从中找到影子。
6.1 从残差到DenseNet:shortcut的强化
DenseNet的思路可以理解为"给每一层都接上前面所有层的shortcut"。它的concat操作比ResNet的add更激进,特征利用率更高,但显存消耗也更大。在实际使用时,ResNet的加法更省显存,训练更稳定;DenseNet在同样参数量下精度略高,但显存和推理成本更高。这是我做轻量化模型时做过的一个明确替换决策。
6.2 从残差到Transformer的residual connection
Transformer里每个子层后面都有一个Add & Norm,那本质上也是残差连接。它让梯度在多层自注意力结构中能稳定回传,这是Transformer能堆到几十层的重要原因。理解了ResNet的shortcut,你再看Transformer的残差结构,会觉得特别眼熟。
6.3 残差思想在目标检测backbone中的应用
现在的检测网络基本都用ResNet或ResNet的变体做backbone。FPN特征金字塔的每一层都复用了不同stage的输出,这些输出之所以可靠,很大程度上是因为残差结构让每个stage的信息都足够丰富。我自己在做检测任务时,首选backbone仍然是ResNet-50,不是因为它最先进,而是因为它无论从预训练权重、文档、社区支持还是部署工具链,都最成熟可靠。
6.4 构建网络时的模块化设计思路
_make_layer这种设计模式值得记下来。它通过传入block类型和层数列表,动态构建不同深度的网络,极大减少了重复代码。我后续搭其他结构的网络,比如自己设计一个轻量backbone,也沿用了这个思路:先定义基础block,再写一个builder函数接收超参数,最后用一个配置文件生成不同规格的模型。这套模式在工程上真的很好用。
7. 动手实践:从构建到训练的个人操作流程
最后,我把自己搭ResNet时的完整操作流程整理出来,供你参考。这个流程不一定是最优解,但经过多次项目验证,稳定性和可复现性都不错。
7.1 流程总览
- 明确任务类型和数据规模,决定用ResNet-18还是ResNet-50
- 基于PyTorch实现基本模型,先跑通前向流程
- 用小数据集(或者造随机数据)验证loss能下降
- 加载预训练权重迁移学习,或者用合适的初始化从零训练
- 设置合适的优化器、学习率调度、数据增强
- 训练时持续监控train/val loss和acc曲线
7.2 优化器与学习率建议
我常用的配置是:SGD with momentum=0.9,weight_decay=1e-4,初始学习率0.1(batch=256时),采用cosine annealing或step decay。Adam在这些配置下通常收敛更快但最终精度略低,可能是因为Adam对BN统计的影响更复杂。
在batch size比较小时,学习率要按比例缩小。一个可参考的规则是:线性缩放原则。batch 256配lr=0.1,batch 64配lr=0.025左右。这个规则不完美,但作为起点很靠谱。
7.3 监控指标与常见信号
训练时我主要盯着几个信号:
- train loss稳步下降说明模型在学习
- val loss先降后升说明过拟合开始,此时应增强数据增强或减小模型
- BN running_mean/running_var偏离初始值太多,说明分布偏移严重,需要更大的batch或更低的lr
这组监控习惯帮我省了很多调试时间。训练是个系统工程,每个环节都可能在影响全局。ResNet这个模型虽然经典,但把它跑好,靠的还是这些细节。
构建过程中我印象最深的不是精度数字,而是第一次自己把shortcut加进去后,亲眼看到梯度在深层网络里还能稳定流动时的那种踏实感。希望这篇分析也能让你在搭网络的时候,少踩一些我已经踩过的坑,更从容地把精力和时间花在真正有意思的问题上。