资讯动态

PyTorch 从零复现 ResNet:残差结构、预训练权重与迁移学习

发布时间:2026/9/30 5:09:23 来源:尧图企业网站定制
1. 为什么我坚持手写 ResNet而不是一行代码调库做深度学习这几年我复现过的网络少说也有二三十个ResNet 是我反复回头去看的一个。原因很简单它是几乎所有视觉任务的默认骨干网络检测、分割、人脸、检索背后多多少少都有它的影子。你只要把 ResNet 的代码真正啃透一遍后面看 FPN、DETR、甚至最近的视觉大模型都能少走一大半弯路。这篇内容讲的就是怎么用 PyTorch 从零把 ResNet 写出来并且把每一行代码为什么要这么写都注释清楚。先说说适合谁看。如果你已经会 PyTorch 的基础张量操作能看懂nn.Conv2d、nn.BatchNorm2d这些层的含义但每次看到别人复现的网络结构还是有种“能跑起来但说不清细节”的感觉那你就是我要找的读者。反过来如果你连卷积的输入输出通道关系都还没搞明白建议先把张量维度、卷积核、步长这几个概念过一遍再回来。ResNet 复现这件事门槛不在写代码而在于你要理解每一个stride、每一个padding背后的设计意图。很多人第一反应是torchvision.models.resnet50(pretrainedTrue)一行就搞定了何必自己写这话没错工程上我也经常直接调。但调库和复现是两件事。调库你拿到的是一个黑盒你只知道它能用不知道它内部怎么处理下采样、怎么初始化权重、预训练权重是怎么映射到每一层的。一旦你要做结构魔改——比如把某个 stage 换成可变形卷积、加一个注意力模块、或者砍掉最后两层做轻量化——黑盒就卡住你了。手写一遍的价值就是把这个黑盒拆开让你有条件在它身上做任何手术。还有一个更现实的原因面试和项目落地都爱问。我被问过不止一次“ResNet 的残差块为什么能解决退化问题”“Bottleneck 为什么是 1x1 加 3x3 加 1x1”“下采样分支为什么用 1x1 卷积”这类问题。这些答案全藏在代码里你不亲手写一遍光看论文图是记不牢的。所以这篇的定位很明确不追求最快跑通追求每一行都能讲清楚来龙去脉。1.1 复现的目标与验收标准我给自己定的复现目标有这么几条也建议你照着这个标准检查自己的成果。第一网络结构要和原论文一致包括 stem 部分的 7x7 大卷积、四个 stage 的通道数变化、以及残差块内部的卷积顺序。第二要能加载官方预训练权重且加载后没有 missing keys 或 unexpected keys 的告警这一条是检验你结构写没写对的硬指标。第三要能在 CIFAR-10 或者自己的小数据集上跑起来loss 正常下降验证集准确率合理。第四代码要支持 BasicBlock 和 Bottleneck 两种块能通过参数一键切换 ResNet18/34/50/101/152。这几条标准里第二条最容易被忽略也最能暴露问题。很多人自己写完了直接拿预训练权重加载结果一堆 key 对不上就开始瞎改最后结构越改越乱。我踩过这个坑后来学聪明了先把结构写对再用strictTrue加载报错了就一个 key 一个 key 对比漫无目的地猜高效得多。所以复现这件事本质上是一个“对照检查”的过程你得有一个可靠的参照物——官方权重就是最好的参照物。另外我想强调注释不是写给别人的是写给自己未来看的。我这篇里的注释密度会比较高尤其是张量维度变化的地方我会把[N, C, H, W]每一步都标出来。你会发现一旦你把维度写进注释很多形状不匹配的报错一眼就能定位这比 debug 半天强太多。1.2 关于版本和依赖的选择版本这块我有几个建议。PyTorch 我用的是 1.13 往上到 2.x 都可以行为基本一致nn.Conv2d、nn.BatchNorm2d的接口这么多年没变过。Conda 建环境是最省事的我一般这么干conda create -n resnet python3.9然后装 PyTorch。如果你有 GPU就去 PyTorch 官网按自己的 CUDA 版本选对应命令没 GPU 就用 CPU 版跑小数据集完全够用只是慢点。我实测在 CIFAR-10 上用 CPU 跑 ResNet18 一个 epoch 大概几分钟调试结构完全没问题。依赖就三个torch、torchvision、tensorboard可选用来记录 loss 曲线。torchvision 主要是拿数据集和预训练权重如果你只跑自己的数据其实可以不要。有人习惯用 Anaconda PyCharm 的组合有人用 VSCode这都无所谓关键是环境干净别把 PyTorch 和 TensorFlow 装一个环境里版本冲突能折腾你一整天。我吃过这个亏后来学乖了一个项目一个环境。还有个小细节如果你打算加载预训练权重最好装和权重对应的 torchvision 版本。老版本 torchvision 的预训练权重下载地址有时候会变报 403 之类的错误这时候更新一下 torchvision 基本就好了。2. 残差结构到底解决了什么问题在写代码之前我想先把“为什么需要残差”这件事说透因为不理解动机代码就是一堆符号的堆砌。深度学习里有个朴素的经验网络越深表达能力越强效果应该越好。但 2015 年之前大家发现一个反直觉的现象——把网络堆到几十层之后训练误差反而比浅层网络更高。注意这不是过拟合是训练集上的误差都涨了。过拟合是训练好测试差而这个是训练本身就差说明深层网络压根没训好。当时主流的猜测是梯度消失或者梯度爆炸。但 Batch Normalization 出现之后这个问题已经缓解了很多深层网络按理说应该能训了可退化问题还在。ResNet 的作者给出了一个非常漂亮的解释假设一个浅层网络已经能训得很好那么我在它后面加几层如果这几层学到的都是恒等映射也就是输入等于输出什么都不做那深层网络的性能至少不应该比浅层差。也就是说深层网络其实“有能力”达到浅层的效果只是优化器在训练时找不到那个恒等映射的解。基于这个观察作者干脆把恒等映射做成一条捷径让网络去学“残差”。数学上很简单原本一层要学的是目标映射 H(x)现在改成让这一层去学 F(x) H(x) - x最后输出是 F(x) x。如果最优解就是恒等映射那网络只需要把 F(x) 的权重压到接近 0 就行这比从零学一个恒等映射容易得多。这条从输入直接连到输出的连线就是 shortcut connection也叫 skip connection。理解了这一点你就明白为什么残差块里通常会有一个 BN 层的权重被初始化为 0后面代码里我会细说因为这样一来整个残差块在初始状态下就是一个恒等映射相当于网络一开始就是个浅层网络训练过程中再慢慢把残差“打开”。这个设计非常巧妙也是 ResNet 能堆到 100 多层甚至上千层的核心原因。2.1 残差块内部的每个组件都在干什么一个标准的残差块内部通常是这样的顺序卷积、BN、ReLU、卷积、BN最后和 shortcut 相加再过一个 ReLU。我逐个说下每个组件的职责。卷积负责提取特征这个不用多说。BN 负责把每个通道的激活值标准化到均值 0、方差 1 附近同时保留可学习的缩放和平移参数它最大的作用是让深层网络的训练更稳定、对学习率不那么敏感。ReLU 提供非线性没有它卷积全是线性变换堆再多层也等价于一层。关键点在相加那一步。out F(x) x这里要求 F(x) 和 x 的形状完全一致通道数、空间尺寸都得对上。如果对不上怎么办这就是下采样分支 downsampling 存在的原因。当输入输出通道数不同或者空间尺寸因为 stride 变小了shortcut 这条路上就得加一个 1x1 卷积步长和主路一致把 x 投影成和 F(x) 一样的形状才能相加。这个 1x1 卷积同样是卷积加 BN不带激活。很多人第一次看代码会疑惑为什么不直接用池化把 x 降下来答案是池化会丢信息而且用 1x1 卷积投影是带参数的网络可以根据需要学习怎么投影比固定的池化更灵活。当然输出通道翻倍、尺寸减半这些事主路也是靠 stride2 的卷积完成的两条路一配合形状就对齐了。另外提醒一句相加之后还有一个 ReLU。这个顺序不能反必须先把残差和捷径加起来再激活。如果你在 F(x) 那边就激活了再相加会破坏恒等映射的性质这是新手常犯的错误之一。2.2 BasicBlock 和 Bottleneck 的取舍ResNet 有两种残差块。BasicBlock 用在浅层网络ResNet18/34结构是两个 3x3 卷积通道数前后不变输出通道数就是输入通道数。Bottleneck 用在深层网络ResNet50/101/152结构是三层先用 1x1 卷积把通道数压到原来的四分之一再用 3x3 卷积做特征提取最后用 1x1 卷积把通道数放大回四倍。这里的“四倍”就是 expansion 参数等于 4。为什么要这么设计算一笔账你就明白了。假设输入输出都是 256 通道如果用两个 3x3 卷积参数量大概是 256×256×9×2 ≈ 118 万。如果用 Bottleneck中间压到 64 通道参数量是 256×64×1 64×64×9 64×256×1 ≈ 1.6 万 3.7 万 1.6 万 ≈ 7 万差了将近 17 倍。也就是说Bottleneck 用更少的参数达到了相近甚至更好的表达能力所以深层网络能堆得动。这就是参数效率的权衡。所以选块的逻辑很简单网络浅、追求简单用 BasicBlock网络深、要控制参数量用 Bottleneck。ResNet18 和 34 用 BasicBlock50 及以上用 Bottleneck。你复现的时候最好把两者都实现写成一个可切换的配置这样一套代码就能覆盖所有常见的 ResNet 变体。有一处细节特别容易写错Bottleneck 里判断是否需要下采样分支条件要比 BasicBlock 多乘一个 expansion。因为 Bottleneck 的输出通道数是out_channels * 4而下采样分支也要输出同样的通道数所以判断条件是in_channels ! out_channels * self.expansion。我在第一次写的时候就没乘这个 4结果 ResNet50 加载预训练权重时报了一堆形状不匹配排查了半天。3. 环境搭建与数据准备环境这块我不想写太多废话直接给能跑的命令。我先建环境装了 PyTorch 2.x 的 CPU 版因为这台机器没显卡调试结构够用。如果你有 GPU把安装命令换成官网给你的那条就行。数据我用 CIFAR-10 来验证原因有两个一是它小下载快几十兆二是它自带torchvision.datasets接口不用自己写 dataloader能把精力集中在网络结构上。当然ResNet 原生是为 ImageNet 设计的直接用在 CIFAR 这种 32x32 的小图上stem 部分的 7x7 大卷积加 maxpool 会把尺寸压得太狠训练效果会打折所以后面我会提一个 CIFAR 版的调整方案。先看环境命令。建环境、激活、装包三步走conda create -n resnet python3.9 -y conda activate resnet # CPU 版本GPU 请去官网选对应命令 pip install torch torchvision pip install tensorboard装完之后随手验一下python -c import torch; print(torch.__version__)能打印出版本号就说明环境没问题。这一步虽然简单但我见过太多人卡在环境上所以别嫌啰嗦。3.1 数据的组织与增强数据增强这块CIFAR-10 上我一般用随机裁剪加随机水平翻转加上标准化。标准化用的均值和方差是(0.4914, 0.4822, 0.4465)和(0.2470, 0.2435, 0.2616)这是 CIFAR-10 训练集统计出来的经验值。为什么要标准化因为原始像素值在 0 到 255 之间直接喂给网络会让梯度尺度过大训练不稳定标准化到均值 0、方差 1 附近之后优化器收敛得更顺。这是预处理里最容易被忽视但又最影响效果的一步。如果你是拿自己的数据做分类记住一个原则训练集做增强验证集只做 resize 和标准化不要做随机裁剪和翻转否则验证指标会忽高忽低你也没法判断模型到底学到没有。我早期就犯过这个错验证集也加了随机翻转结果 val loss 震荡得厉害以为模型有问题其实是我自己把验证集搞脏了。数据加载器的num_workers我设成 2 或 4太大在某些系统上会有问题太小又慢。还有个坑Windows 下如果用多进程加载数据代码必须包在if __name__ __main__:里否则会重复启动进程报错。这个坑我踩过不止一次每次换新机器都忘。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms train_tf transforms.Compose([ transforms.RandomCrop(32, padding4), # 先 pad 4 再随机裁回 32 transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) test_tf transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) train_set datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtrain_tf) test_set datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtest_tf) train_loader DataLoader(train_set, batch_size128, shuffleTrue, num_workers4) test_loader DataLoader(test_set, batch_size128, shuffleFalse, num_workers4)这段代码没什么花哨的但注释我想强调一点RandomCrop(32, padding4)是先四周补 4 个像素再随机裁回 32x32这样既做了增强又保持了输入尺寸不变。如果你写成了RandomResizedCrop尺寸会变后面网络里的全连接层可能就得跟着调。4. 核心代码逐段实现与超详细注释这一节是重头戏我按从底向上的顺序写先写卷积-BN-ReLU 的组合再写两种残差块最后拼出完整的 ResNet。每一段我都会把维度变化标出来你照着读一遍基本就能自己默写出来了。先说一个贯穿全文的约定所有卷积默认biasFalse因为后面紧跟 BNBN 自带平移参数卷积再加偏置是多余的还会浪费一点点计算。这是工程上的小优化也是原实现里的做法。4.1 卷积、BN、激活的标准组合残差块里反复出现“卷积 BN ReLU”这个组合我习惯把它封装成一个函数或者小模块这样代码干净也不容易写错顺序。这里我直接按 PyTorch 官方实现里那种“显式写出来”的方式方便你看清每一步。import torch import torch.nn as nn def conv3x3(in_planes, out_planes, stride1): 3x3 卷积padding1 保证 stride1 时尺寸不变 return nn.Conv2d(in_planes, out_planes, kernel_size3, stridestride, padding1, biasFalse) def conv1x1(in_planes, out_planes, stride1): 1x1 卷积用于升维、降维和下采样投影 return nn.Conv2d(in_planes, out_planes, kernel_size1, stridestride, biasFalse)conv3x3里padding1这个设计很关键。3x3 卷积、步长 1、padding 1输出的空间尺寸和输入完全一样如果步长是 2输出就是输入的一半这正好对应残差块里的下采样。你可以自己算输出尺寸 (输入尺寸 2×padding − kernel) / stride 1代入 padding1、kernel3、stride2就是 (H 2 − 3) / 2 1 (H − 1) / 2 1向下取整后正好是 H/2H 为偶数时。所以这个 padding 不是随便填的它保证了尺寸变化完全由 stride 决定逻辑很干净。4.2 BasicBlock 的实现与逐行注释先写 BasicBlock它是 ResNet18/34 的基础。两个 3x3 卷积通道数不变输出通道 输入通道。class BasicBlock(nn.Module): expansion 1 # BasicBlock 不升维输出通道不变 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() # 第一个 3x3 卷积stride 由外部传入用于下采样 self.conv1 conv3x3(inplanes, planes, stride) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) # 第二个 3x3 卷积stride 固定为 1通道数不变 self.conv2 conv3x3(planes, planes) self.bn2 nn.BatchNorm2d(planes) # 下采样分支None 表示输入输出形状一致可直接相加 self.downsample downsample self.stride stride def forward(self, x): identity x # 保存原始输入作为 shortcut out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) # 注意这里不接 ReLU等相加之后再激活 if self.downsample is not None: identity self.downsample(x) # 投影成和 out 一样的形状 out identity # 核心残差相加 out self.relu(out) # 相加后再激活 return out这里有几个注释点值得展开。第一identity x保存的是原始输入哪怕后面 x 被卷积改了identity 还在这是 Python 引用的特性identity指向的还是原来那个张量对象out是新的张量不会互相干扰。第二conv2之后不接 ReLU这是刻意为之必须等残差相加之后再激活。第三inplaceTrue的 ReLU 会原地修改输入省显存但如果你在别处还要用这个张量的原始值就会出问题这里因为相加已经做完了所以安全。downsample传进来的是什么呢是一个 Sequential 模块内部是 1x1 卷积加 BN步长和主路的stride一致。这样做的好处是下采样逻辑统一在外面处理块内部只负责用结构清晰。4.3 Bottleneck 的实现与参数计算Bottleneck 稍微复杂一点三层卷积中间窄、两头宽。输出通道是planes * 4这个 4 就是 expansion。class Bottleneck(nn.Module): expansion 4 # 输出通道是中间通道的 4 倍 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() # 1x1 降维inplanes - planes self.conv1 conv1x1(inplanes, planes) self.bn1 nn.BatchNorm2d(planes) # 3x3 特征提取stride 决定是否下采样 self.conv2 conv3x3(planes, planes, stride) self.bn2 nn.BatchNorm2d(planes) # 1x1 升维planes - planes * 4 self.conv3 conv1x1(planes, planes * self.expansion) 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.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) out self.bn3(self.conv3(out)) # 最后一个 BN 不接激活 if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out举例算一下 ResNet50 第一个 Bottleneck输入 64 通道planes64输出就是 256 通道。中间先降到 64再 3x3 卷积再升到 256。下一个 stage 输入就是 256planes128输出 512以此类推。整个网络的通道数走向是 64 → 256 → 512 → 1024 → 2048这个序列你要能背下来面试经常考。有个细节Bottleneck 的第一个 1x1 卷积的 stride 默认是 1真正做下采样的是中间的 3x3 卷积。所以如果你看到别人把 stride 放在第一个 1x1 上那是另一种实现风格参数分布会不一样加载官方权重时可能对不上。我建议严格按官方实现stride 放在 3x3 上。4.4 把残差块拼成完整的 ResNet有了两种块接下来是 ResNet 主体。核心是把网络分成 stem 和四个 stage每个 stage 由若干残差块堆叠第一个块负责可能的下采样后面的块保持形状不变。class ResNet(nn.Module): def __init__(self, block, layers, num_classes1000): super().__init__() self.inplanes 64 # 当前通道数随 stage 变化 # stem7x7 大卷积步长 2快速降采样 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) # 四个 stage每个 stage 的通道数依次翻倍 self.layer1 self._make_layer(block, 64, layers[0], stride1) # 64 - 256 self.layer2 self._make_layer(block, 128, layers[1], stride2) # 128 - 512 self.layer3 self._make_layer(block, 256, layers[2], stride2) # 256 - 1024 self.layer4 self._make_layer(block, 512, layers[3], stride2) # 512 - 2048 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( conv1x1(self.inplanes, planes * block.expansion, stride), nn.BatchNorm2d(planes * block.expansion), ) layers [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.relu(self.bn1(self.conv1(x))) # [N,3,H,W] - [N,64,H/2,W/2] x self.maxpool(x) # - [N,64,H/4,W/4] x self.layer1(x) # - [N,256,H/4,W/4] x self.layer2(x) # - [N,512,H/8,W/8] x self.layer3(x) # - [N,1024,H/16,W/16] x self.layer4(x) # - [N,2048,H/32,W/32] x self.avgpool(x) # - [N,2048,1,1] x torch.flatten(x, 1) # - [N,2048] x self.fc(x) # - [N,num_classes] return x_make_layer是整个网络最绕的地方我拆开说。它接收四个参数块类型、本 stage 的基础通道数 planes、块的数量 blocks、以及是否下采样的 stride。判断下采样条件时用的是self.inplanes ! planes * block.expansion注意这里的block.expansionBasicBlock 是 1Bottleneck 是 4。第一个块负责处理下采样并把通道数切换过去之后更新self.inplanes剩下的块用新的通道数构造形状不再变化。这样设计的好处是每个 stage 只在下采样这件事上花一次“形状变换”的成本后面全是等形状的残差块计算规整。stem 部分的维度变化我也标在了注释里。输入 224x224经过 stride 2 的 7x7 卷积变成 112再经过 stride 2 的 maxpool 变成 56然后四个 stage 各下采样一次layer1 不下采样最后变成 56 / 2 / 2 / 2 7。所以 ResNet 在这个尺寸下的最后一层特征图是 7x7再用全局平均池化压成 1x1。这个尺寸的推演你最好自己动手算一遍比看论文图印象深。4.5 权重初始化的讲究上面初始化那段里卷积用了 Kaiming 正态初始化BN 的 weight 初始化为 1、bias 初始化为 0。Kaiming 初始化是专门为 ReLU 激活设计的它考虑到了 ReLU 会丢掉一半的负值所以方差的计算里带了个 2 的因子能保证前向传播时每一层的激活方差大致稳定。如果你用默认的初始化深层网络前面几层的输出方差会快速衰减训练就慢。但这里还有一个 ResNet 特有的技巧我没写进去——原论文里每个残差块最后一个 BN 的 weight 被初始化为 0。为什么要这么做因为这样一来残差块的输出 F(x) 在初始状态下是 0整个块等价于恒等映射网络初始时就是一个“浅层网络”训练过程中这些 weight 慢慢从 0 长大残差被逐步激活。这是个很优雅的设计能让深层网络训练更稳。不过要注意如果你加载官方预训练权重这个 0 初始化就被覆盖了所以在做迁移学习自己搭网络时用到才最有意义。# 在 _make_layer 或者块初始化后把每个块最后 BN 权重置零 for m in self.modules(): if isinstance(m, Bottleneck): nn.init.constant_(m.bn3.weight, 0) elif isinstance(m, BasicBlock): nn.init.constant_(m.bn2.weight, 0)这段我一般放到构建网络之后单独执行或者写进块的__init__里。两种都行看你习惯。5. 装配网络并加载预训练权重结构写好之后我们用工厂函数把不同深度的 ResNet 拼出来。这一段代码很短但很实用你可以直接抄。def resnet18(num_classes1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet34(num_classes1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes) def resnet50(num_classes1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes) def resnet101(num_classes1000): return ResNet(Bottleneck, [3, 4, 23, 3], num_classes)这里的列表[3, 4, 6, 3]就是每个 stage 的块数量。有个好记的规律ResNet50 是[3,4,6,3]ResNet101 只改了第三个 stage从 6 变成 23ResNet152 是[3,8,36,3]。你记住 50 的那一组其余的按规律推就行。5.1 怎么验证结构写对了最直接的验证方法就是加载官方预训练权重而且用strictTrue。如果所有 key 都能对上说明你的结构和官方完全一致如果有 missing 或 unexpected它会明确告诉你哪些层没对上。我一般这么写import torch from torchvision import models # 自己写的模型 my_model resnet50(num_classes1000) # 官方模型 official models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) # 对比 key 是否一致 my_keys set(my_model.state_dict().keys()) off_keys set(official.state_dict().keys()) print(只在自定义模型里:, my_keys - off_keys) print(只在官方模型里:, off_keys - my_keys) # 尝试加载 my_model.load_state_dict(official.state_dict(), strictTrue) print(加载成功结构一致)第一次跑很可能不一致别慌。常见的对不上是layer1.0.downsample.0.weight这种说明你下采样分支的命名或者结构位置和官方不一样。还有一种是把 BN 的num_batches_tracked这种 buffer 也算进去了那属于正常不算真的不一致。我建议你先打印两个集合的差集一般问题一眼就能看出来。还有一个高频问题加载了预训练权重之后把num_classes改成自己的类别数结果fc层的形状对不上加载报错。正确的做法是加载时用strictFalse或者先把fc层的 key 从 state_dict 里删掉再加载。我一般这么做state official.state_dict() state.pop(fc.weight); state.pop(fc.bias) # 去掉分类头 my_model.load_state_dict(state, strictFalse)这样只报 fc 层 missing其余全部对上属于预期行为。5.2 迁移学习的两种常见做法迁移到自己的任务上有两种玩法。第一种是冻结骨干网络只训练最后的全连接层适合数据量小的场景训练快、不容易过拟合。第二种是全部解冻用很小的学习率微调整个网络适合数据量中等以上的场景效果通常更好。我一般先冻结训几个 epoch把 fc 层调到差不多再解冻全局微调学习率降到原来的十分之一。冻结的实现很简单把不想训练的参数的requires_grad设成 Falsefor name, param in my_model.named_parameters(): if fc not in name: # 只训练分类头 param.requires_grad False # 优化器只传需要梯度的参数避免浪费 optimizer torch.optim.SGD( filter(lambda p: p.requires_grad, my_model.parameters()), lr0.01, momentum0.9, weight_decay5e-4)这里filter那行是个小技巧不训练的参数不传给优化器能省一点显存和计算。别小看这种细节网络大的时候积少成多。6. 训练循环、学习率与调试实战训练这块我把最关键的几点讲清楚。优化器用 SGD 加动量这是 ResNet 原论文的配置动量 0.9weight decay 5e-4。为什么不用 Adam因为在图像分类上SGD 加动量配合合适的学习率调度通常泛化更好虽然收敛慢一点。学习率我用余弦退火从 0.1 降到 0配合 warmup 更稳。import math device torch.device(cuda if torch.cuda.is_available() else cpu) model resnet18(num_classes10).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) for epoch in range(50): model.train() running_loss, correct, total 0.0, 0, 0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() # 清梯度 outputs model(imgs) # 前向 loss criterion(outputs, labels) # 计算损失 loss.backward() # 反向 optimizer.step() # 更新参数 running_loss loss.item() * imgs.size(0) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) scheduler.step() # 每个 epoch 调一次学习率 print(fEpoch {epoch}: loss{running_loss/total:.4f}, facc{correct/total:.4f}, lr{scheduler.get_last_lr()[0]:.5f})几个注释点。optimizer.zero_grad()必须在backward()之前否则梯度会累加这是新手最常犯的错误之一。loss.item()取标量要乘上 batch size 再累加这样算出来的平均 loss 才不受最后一个不满 batch 的影响。argmax(1)是沿着类别维度取最大值的下标作为预测类别。验证的时候记得切到model.eval()并且用torch.no_grad()包起来前者让 BN 用滑动统计量而不是当前 batch 的统计量后者省显存、加速。我见过有人忘了eval()导致验证指标比训练指标还低排查半天发现是 BN 的锅。model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in test_loader: imgs, labels imgs.to(device), labels.to(device) outputs model(imgs) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) print(fTest Acc: {correct/total:.4f})6.1 学习率调度的选择学习率是训练里最影响结果的超参没有之一。我的经验是能跑通之后先把学习率调对再去折腾别的。ResNet 在 ImageNet 上标准配置是 batch size 256、初始学习率 0.1如果你 batch size 减半学习率也大致减半。用 CIFAR-10 这种小图我一般从 0.1 起步观察第一个 epoch 的 loss如果 loss 不降或者震荡就降到 0.01。余弦退火的好处是前期保持较大学习率快速下降后期学习率变小时精细收敛。如果你想更稳可以在前面加几个 epoch 的 warmup学习率从很小的值线性升到初始值避免一开始梯度太大把网络带偏。warmup 在 BatchNorm 还没稳定统计量的时候尤其有用。另外提醒weight decay 在 PyTorch 的 SGD 里默认作用在所有参数上包括 BN 的 weight 和 bias严格来说 BN 参数和 bias 不该加 weight decay但实践中影响很小很多人也不管。如果你追求极致可以手动把这几类参数分到不同 group不给它们 weight decay。这个细节在原论文里没细说属于工程经验。6.2 CIFAR 小图上的结构微调ResNet 原生是为 224x224 的 ImageNet 设计的其中 stem 的 7x7 卷积加 maxpool 会一次性把尺寸压到原来的四分之一。如果你直接用在 32x32 的 CIFAR 上压完只剩 8x8再经过四个 stage 的下采样到最后一层就剩 1x1 了空间信息几乎被压没效果会打折扣。标准做法是针对小图改 stem把 7x7 卷积换成 3x3、stride 1去掉 maxpool这样输入 32 经过四个 stage 后还能保留 4x4 的特征图信息量足够。# 针对 CIFAR 的 stem 替换在 ResNet.__init__ 里改 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) # self.maxpool 这一层直接删掉或者换成 nn.Identity() self.maxpool nn.Identity()我实测下来这个改动能让 ResNet18 在 CIFAR-10 上的准确率从百分之八十几提升到九十三以上差别很明显。改完之后同样要注意这种情况下就不能再加载官方的 ImageNet 预训练权重了因为第一层卷积的形状变了。所以迁移学习和结构微调有时候是二选一你得根据手头数据量权衡。7. 常见问题与排查技巧实录这一节是我最想写的因为网上教程大多只给能跑的代码不讲炸了怎么办。下面这些问题我基本都踩过有的还踩了好几次。维度不匹配报size mismatch。九成出在下采样判断条件上。你回头检查_make_layer里那句self.inplanes ! planes * block.expansion是不是漏了expansion或者 stride 传错位置。最快的定位方式是打印每个 stage 输出的 shape一层一层对。加载预训练权重报 unexpected keys。多半是你多写了层或者命名不一致。用state_dict的 key 集合做差集我前面给过代码。还有一种情况是num_batches_tracked这种 BN 的 buffer 差异属于正常。loss 一直不降。先看数据标签有没有对错再看学习率是不是太大或太小最后看你有没有忘记zero_grad。我遇到过一次是数据增强太狠把图裁得只剩半边模型学不到东西。验证准确率比训练低很多。检查有没有model.eval()有没有用torch.no_grad()。如果数值差异特别大也可能是训练集和验证集的预处理不一致。显存不够。优先降 batch size其次用混合精度训练再不然梯度累积。别一上来就换小模型先做前两步基本能解决。BN 层报错Expected more than 1 value per channel。这是 batch size 太小BN 在一个通道上只有一个值算不出方差。解决办法是把 batch size 加大或者换GroupNorm、InstanceNorm。用 CIFAR 类小图时最后一个 stage 特征图只有 4x4如果 batch 又小很容易触发这个。我把这些整理成一个速查表方便你对着排查现象大概率原因处理办法size mismatch下采样判断漏了 expansion补上planes * block.expansionunexpected keys层命名/结构不一致用 key 差集定位loss 不降学习率不当或数据问题调 lr检查 labelval 远低于 train忘了 eval/no_grad加上model.eval()显存溢出batch 太大或未释放图降 batch用no_gradBN 报错batch 太小加大 batch 或换 Norm准确率上不去stem 不匹配小图换成 3x3 无 maxpool7.1 我自己总结的几条实操心得第一先保证结构对再追求训练效果。很多人一上来就调超参结果是结构写错了白忙活。加载官方权重是检验结构最省事的办法一定要用。第二注释里的维度变化要坚持写写到你能闭眼推出来为止。ResNet 的形状变换是有规律的掌握规律之后改结构就是填数字。第三遇到问题先缩小问题范围用一张图、一个小 batch 单独跑前向看输出形状比盯着整段代码发呆高效得多。还有一条不要照抄别人的代码然后改一定要自己从零敲一遍。我复现第一个网络的时候就是复制粘贴结果遇到问题完全不知道从哪查因为代码不是我想出来的。后来逼自己关掉参考手写一遍虽然慢但理解完全不一样了。ResNet 值得你花这个时间。最后分享一个小技巧调结构的时候我习惯加一行打印放在forward的末尾print(x.shape)配合一个 batch 的输入跑一次整个网络的维度变化就全出来了。看完就删掉别留在正式代码里不然训练会刷屏。这套代码我从最早只会调库到能自己改 Bottleneck 加注意力、改 stem 做小图、加载预训练做迁移中间反复重构了无数次。ResNet 这东西表面上你一天就能复现出来但要真正吃透每一处设计动机得反复回头看。等你哪天能不看论文、不查代码直接默写出 ResNet50 的完整结构和每个 stage 的通道数变化这关就算过了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑