
1. 为什么还要自己手写一遍ResNet如果你翻过PyTorch官方仓库里的torchvision.models.resnet第一反应大概是这东西不是已经现成了吗一行resnet50(pretrainedTrue)就完事了为什么还要自己从头实现一遍我一开始也是这么想的直到有一次需要把一个自定义的注意力模块插到残差分支里才发现如果对ResNet内部结构没有逐层拆过一遍改起来会非常别扭——你不知道该在BasicBlock还是Bottleneck里动手不清楚downsample那条捷径到底在什么条件下才生效更搞不明白layer1到layer4的通道数是怎么一步步翻倍的。所以这篇东西不是教你“如何调用ResNet”而是把ResNet在PyTorch里从零搭一遍把每个模块的设计意图、每个参数的来龙去脉讲清楚。适合两类人看一类是刚学完卷积网络、想找一个结构清晰又不算太复杂的网络练手的同学另一类是需要在ResNet骨架上做魔改、加模块、换结构的从业者。读完你应该能做到不看官方源码自己写出一个结构和权重都能对得上的ResNet并且知道每一行为什么这么写。ResNet本身的核心思想其实一句话就能概括让网络层去学习残差而不是直接学习目标映射。但真正落到代码上坑比想象中多——尤其是当你想让它和官方预训练权重兼容的时候一个stride放错位置权重就加载不上了。2. 残差结构到底解决了什么问题2.1 退化问题深网络的“越深越差”怪象在ResNet出现之前大家普遍认为网络越深表达能力越强。但实验发现当网络堆到几十层以后训练误差反而比浅层网络更高。注意这不是过拟合——过拟合是训练误差低、测试误差高而这里是训练误差本身就上去了。这个现象叫退化问题。退化问题的根源不是梯度消失那么简单。梯度消失可以通过BN、好的初始化缓解但退化依然存在。真正的原因是深层网络很难学出“恒等映射”。也就是说如果某一层什么都不做、直接把输入传出去理论上网络至少不会变差但普通的卷积堆叠很难让一堆非线性层整体逼近一个恒等函数。2.2 残差连接给网络一条“抄近道”的路ResNet的解法非常直接既然让F(x)去逼近x很难那就让它去逼近F(x) H(x) - x也就是残差。最终输出是H(x) F(x) x。如果最优解就是恒等映射那网络只需要把F(x)的权重压到接近0就行这比学一个恒等映射容易得多。这条 x的路径就是shortcut connection捷径连接也叫skip connection。它不引入额外参数不增加计算量却让梯度可以几乎无衰减地反向传播。从梯度角度看反向传播时x这一项的梯度是1直接原样传回去深层网络的梯度就不会被层层卷积“稀释”掉。2.3 两种残差块BasicBlock与BottleneckResNet里其实只有两种基本积木BasicBlock两层3x3卷积用在ResNet-18和ResNet-34上。Bottleneck1x1降维 → 3x3卷积 → 1x1升维三层结构用在ResNet-50/101/152上。为什么深网络要用Bottleneck算一笔账就明白了。假设输入输出都是256通道BasicBlock两个3x3卷积参数量约3*3*256*256*2 ≈ 1.18M。Bottleneck1x1把256降到643x3在64通道上做再1x1升回256。参数量约256*64 3*3*64*64 64*256 ≈ 0.07M。差了将近17倍。所以ResNet-50虽然比ResNet-34深得多参数量却只多了不到一倍。这个设计思路后来被大量网络借鉴成了深层网络的标配。3. 从零搭建ResNet的完整实现3.1 整体架构与参数规划先把整体骨架定下来。ResNet由这几部分组成stem层一个7x7、stride2的大卷积 BN ReLU 3x3最大池化。作用是快速降采样把224x224的输入降到56x56。四个stagelayer1到layer4每个stage由若干个残差块堆叠通道数依次是64、128、256、512。分类头全局平均池化 全连接层。不同深度的配置差异只在每个stage的块数和块类型网络块类型layer1layer2layer3layer4ResNet-18BasicBlock2222ResNet-34BasicBlock3463ResNet-50Bottleneck3463ResNet-101Bottleneck34233ResNet-152Bottleneck38363注意一个关键点只有每个stage的第一个块做stride2降采样其余块都是stride1。这个细节直接决定了后面shortcut要不要加downsample。3.2 BasicBlock的实现细节先写BasicBlock。它的结构是conv3x3 → BN → ReLU → conv3x3 → BN然后加上shortcut最后再过一次ReLU。import torch import torch.nn as nn def conv3x3(in_planes, out_planes, stride1): return nn.Conv2d(in_planes, out_planes, kernel_size3, stridestride, padding1, biasFalse) class BasicBlock(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() self.conv1 conv3x3(inplanes, planes, stride) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 conv3x3(planes, planes) self.bn2 nn.BatchNorm2d(planes) self.downsample downsample self.stride stride 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几个容易写错的地方biasFalse因为后面紧跟BN卷积的偏置会被BN的减均值操作抵消掉纯属浪费参数。这是几乎所有现代CNN的共识。downsample的判断当stride ! 1或者inplanes ! planes * expansion时主分支的输出和identity的形状对不上必须用1x1卷积调整。BasicBlock的expansion1Bottleneck是4。ReLU的位置残差相加之后才做ReLU不能在相加之前。如果提前ReLU会把负值截断破坏残差路径的恒等性。3.3 Bottleneck的实现与通道变化Bottleneck多了一层通道变化更绕。它的结构是1x1降维 → 3x3 → 1x1升维中间还有一个expansion4的放大系数。class Bottleneck(nn.Module): expansion 4 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(inplanes, planes, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.conv2 nn.Conv2d(planes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.conv3 nn.Conv2d(planes, planes * self.expansion, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(planes * self.expansion) self.relu nn.ReLU(inplaceTrue) self.downsample downsample self.stride stride 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这里planes是中间层的通道数实际输出通道是planes * 4。比如ResNet-50的layer1planes64输出就是256通道。这个expansion的设计是Bottleneck省参数的关键。3.4 组装完整的ResNet类有了两种块就可以写主类了。核心是_make_layer方法它负责堆叠同一个stage里的所有块并处理第一个块的stride和downsample。class ResNet(nn.Module): def __init__(self, block, layers, num_classes1000): super().__init__() self.inplanes 64 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(block, 64, layers[0]) self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512 * block.expansion, num_classes) for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def _make_layer(self, block, planes, blocks, stride1): downsample None if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(planes * block.expansion), ) layers [] layers.append(block(self.inplanes, planes, stride, downsample)) self.inplanes planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes)) return nn.Sequential(*layers) 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_make_layer里有个容易忽略的点第一个块之后要更新self.inplanes。因为第一个块的输出通道已经变成了planes * expansion后续块的输入通道必须跟着变。如果忘了这一步第二个块就会因为通道对不上而报错。3.5 权重初始化Kaiming初始化的必要性上面代码里用了kaiming_normal_这不是可选项。ResNet有几十层卷积如果用默认的初始化前向传播时激活值方差会逐层变化深层要么爆炸要么消失。Kaiming初始化专门针对ReLU设计让每层输出的方差保持一致。BN层的初始化也有讲究weight1, bias0。这样BN在训练初期相当于一个恒等变换不会打乱已经初始化好的卷积输出分布。这两个初始化配合起来才能让几十层的网络在训练一开始就稳定。4. 训练配置与实操验证4.1 数据预处理与增强策略ResNet原论文用的是224x224输入训练时的标准预处理是随机裁剪先resize到256再RandomCrop到224。随机水平翻转概率0.5。归一化mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]这是ImageNet统计出来的。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])验证集不做随机增强只做中心裁剪val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])注意归一化的mean和std必须和预训练权重匹配。如果你加载官方权重做微调却用了自己算的mean/std精度会莫名其妙掉几个点而且很难排查。4.2 优化器与学习率调度ResNet原论文用的是SGD momentum这个配置到现在依然是训练ResNet的稳妥选择optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.1)几个关键参数的经验值初始学习率0.1配合batch size 256。如果batch size减半学习率也要相应减小一般按线性缩放。weight_decay 1e-4这是只对权重生效的BN的weight和bias、所有bias都不应该加weight decay。官方实现里会把它们分到单独的param group。StepLR每30个epoch降10倍总共训90到120个epoch。如果显存不够用Adam也行初始学习率设1e-3但最终精度通常比精调过的SGD低零点几个点。追求复现论文结果的话还是老老实实用SGD。4.3 加载官方权重验证实现正确性自己写的ResNet最容易验证的一点就是能不能加载官方预训练权重。如果能加载且推理结果一致说明结构完全正确。import torchvision.models as models my_resnet ResNet(Bottleneck, [3, 4, 6, 3]) official models.resnet50(pretrainedTrue) my_resnet.load_state_dict(official.state_dict(), strictTrue) my_resnet.eval() x torch.randn(1, 3, 224, 224) with torch.no_grad(): out_mine my_resnet(x) out_official official(x) print(torch.allclose(out_mine, out_official, atol1e-5))strictTrue会严格检查每一层的名字和形状。如果报错说缺key或者多key说明你的模块命名和官方不一致。官方的命名规则是layer1.0.conv1这种_make_layer返回的nn.Sequential会自动生成数字索引所以只要块内部的属性名对得上整体就能对上。实测下来只要conv1、bn1、layer1~4、fc这些命名一致加载是没问题的。我踩过的坑是早期把downsample写成了shortcut结果权重加载时缺了一堆key排查了半天。4.4 小数据集上的微调技巧实际工作中很少从头训ResNet更多是在小数据集上微调。这时候有几个实用技巧冻结stem和layer1这两个部分学的是底层边缘、纹理特征通用性很强小数据集上没必要动。只训layer3、layer4和fc能显著减少过拟合。换掉fc层model.fc nn.Linear(512 * block.expansion, num_classes)新层的初始化用默认的就行。分层学习率底层用小学习率新加的fc用大学习率。比如底层1e-4fc用1e-3。for name, param in model.named_parameters(): if layer1 in name or layer2 in name: param.requires_grad False冻结之后记得把模型设成train()模式但被冻结的BN层要设成eval()否则它们的running_mean和running_var还会被更新反而破坏预训练学到的统计量。5. 常见问题与排查实录5.1 形状不匹配报错速查自己实现ResNet时90%的报错都是形状对不上。下面这张表基本能覆盖常见情况报错信息原因解决size mismatch for fc.weightnum_classes和预训练不一致换fc层或忽略fc的keyGiven groups1, weight of size...卷积输入通道和实际不符检查inplanes是否更新The size of tensor a must match tensor b残差相加时形状不一致检查downsample条件mat1 and mat2 shapes cannot be multipliedflatten后维度不对确认avgpool输出是1x1其中最常见的是残差相加失败。根本原因通常是stride放错了位置。记住stride2只放在stage的第一个块上而且放在第一个3x3卷积Bottleneck是第二个卷积里。如果放到第二个卷积上主分支输出的空间尺寸和identity就对不上了。5.2 训练不收敛的排查思路如果loss一直不降或者震荡按这个顺序排查学习率是不是太大先降到0.01试试如果loss开始降了说明是学习率问题。BN的momentum默认0.1小batch size下可以调到0.01让统计量更新更平滑。权重初始化确认用了KaimingBN的weight初始化为1。数据归一化确认mean/std和预训练一致或者至少是自己数据集统计出来的。梯度裁剪如果loss偶尔爆炸加clip_grad_norm_(model.parameters(), max_norm5.0)。我遇到过一次loss完全不降的情况最后发现是downsample里的BN忘了加导致残差分支的输出分布和主分支差太多相加之后激活值直接饱和。这种细节错误不会报错但会让训练彻底失败。5.3 显存优化与推理加速ResNet-50在batch size 256、224x224输入下训练大概需要11GB显存。显存不够的话有几个办法混合精度训练用torch.cuda.amp显存能省将近一半速度还能快30%左右。梯度累积batch size设小一点累积几次再更新等效于大batch。梯度检查点用torch.utils.checkpoint用计算换显存适合特别深的网络。推理阶段可以用torch.jit.trace导出成TorchScript或者转ONNX。实测ResNet-50转ONNX后在CPU上推理比原生PyTorch快20%到30%。如果部署到移动端还可以做量化INT8量化后模型大小缩小到四分之一精度掉不到1个点。5.4 几个容易忽略的实现细节最后列几个我踩过的坑都是那种不报错但影响精度的inplaceTrue的ReLU省显存但在某些需要保留输入的场景比如可视化中间激活会出问题。调试阶段可以设成False。AdaptiveAvgPool2dvsAvgPool2d前者对输入尺寸不敏感换输入分辨率时不用改代码。官方用的是前者。torch.flatten(x, 1)从第1维开始展平保留batch维。写成x.view(x.size(0), -1)效果一样但flatten更直观。BN的track_running_stats训练时用batch统计量推理时用running统计量。如果忘了在推理前调model.eval()结果会差很多。这些细节单独看都不起眼但组合起来就是“为什么我的实现比官方低两个点”的答案。ResNet结构本身不复杂难的是把每个细节都做对。把上面这套代码跑通、和官方权重对齐之后再去做魔改就有底气了——你知道每一层在干什么改哪里、怎么改心里都有数。