ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零手写ResNet:PyTorch超详细注释与CIFAR-10实战

从零手写ResNet:PyTorch超详细注释与CIFAR-10实战 1. 先搞清楚为什么要手写一遍 ResNet这几年看过太多人学深度学习的方式打开教程torchvision.models.resnet50(pretrainedTrue)一行调用跑通一个分类任务然后就没有然后了。真到了要把 ResNet 的结构改一改、把残差块换掉、或者把某个模块搬进检测/分割网络里当 backbone 的时候立刻就卡壳——因为那行代码背后的东西从来没在自己脑子里过一遍。所以我一直觉得ResNet 代码复现是 PyTorch 学习路径上性价比最高的一次练习。它不像 Transformer 那样一上来就是几十行的注意力矩阵运算也不像某些轻量网络那样为了刷 FLOPs 塞一堆奇怪的算子。ResNet 的每一层你都能在纸上画出来但每一层又都埋着几个不写一遍就永远不知道的坑下采样时 shortcut 怎么对齐、stride 到底放在哪一层卷积上、BN 的 gamma 要不要零初始化、Bottleneck 的 expansion 为什么是 4。这篇内容我打算把这件事做透从残差块的设计动机讲起然后逐行写一遍带超详细注释的 PyTorch 实现注释会写到为什么这么写而不是这行在干什么的程度再在 CIFAR-10 上把训练流程完整跑一遍把学习率、权重衰减、标签平滑这些参数的选择理由讲清楚最后把我在复现过程中踩过的坑整理成一份排查速查表。适合谁看如果你已经能看懂nn.Conv2d的参数含义知道张量的四个维度分别是 batch、channel、height、width那就可以直接往下读。如果你连 PyTorch 环境都还没搭好第 5 节里我给了 conda 建环境的那几行命令照着敲就行不用先去啃一遍框架文档。全文的代码都是可以直接粘进.py文件里跑的我建议你边看边敲一遍尤其是那些注释敲一遍和读一遍完全是两个效果。2. ResNet 的设计逻辑拆解残差到底在解决什么2.1 退化问题不是过拟合是优化变难了在 ResNet 出现之前学界有个很反直觉的现象把网络从 20 层加到 56 层训练误差反而更高。注意是训练误差更高不是测试误差。这就排除了过拟合的可能——如果是过拟合训练误差应该更低才对。当时管这个叫退化degradation问题。为什么加深会变差一个比较直观的解释是深层网络的解空间里理论上包含浅层网络 后面一堆恒等映射这个解也就是说深层网络至少不该比浅层差。但实际训练中让一堆非线性层去逼近恒等映射f(x) x这件事本身就很难SGD 在这么大的参数空间里摸索很容易摸到一个比恒等映射更差的解。ResNet 的处理方式非常工程既然让你学恒等映射很难那我直接把恒等映射给你送到手上。结构上就是out relu(F(x) x)其中F(x)是卷积堆出来的残差分支x是直接连过来的 shortcut。这样网络要退化成恒等映射只需要把F(x)的权重全部压到 0比重新学出一个恒等函数容易得多。我个人理解这是一个降低优化难度的设计不是增加表达能力的设计。ResNet 的参数数量和同深度的 plain net 几乎一样多出来的计算量也就是个逐元素加法但可训练性完全不是一个量级。2.2 加法要求通道和空间尺寸严格一致残差连接用的是逐元素加法不是拼接。这就带来一个硬约束F(x)和x的形状必须完全相同包括通道数和 H/W。在同一个 stage 内部通道数不变、卷积用padding1的 3x3尺寸天然对齐直接加就行。但跨 stage 的时候通道数要翻倍64→128→256→512空间尺寸要减半这时候x和F(x)就对不上了。解决办法是在 shortcut 上加一个下采样分支通常是一个 1x1 卷积加 BNstride 设成和主干一致的 2。这个分支只在每个 stage 的第一个 block 上出现后面的 block 都不需要。代码里一般用一个downsample参数控制为None时就表示走纯恒等路径。提示1x1 卷积在这里只负责改通道数 降分辨率不改变感受野也不带 bias后面接 BNbias 会被抵消掉白算。2.3 BasicBlock 和 Bottleneck 的取舍ResNet 有两个主力残差块。18 层和 34 层用的是 BasicBlock两个 3x3 卷积堆叠50 层及以上用 Bottleneck结构是 1x1 降维、3x3 卷积、1x1 升维输出通道是输入中间通道的 4 倍这个 4 就是expansion。为什么要用 Bottleneck算一下就清楚了。假设输入输出都是 256 通道用 BasicBlock 是3x3x256x256 x 2 ≈ 1.18M参数用 Bottleneck 是1x1x256x64 3x3x64x64 1x1x64x256 ≈ 0.07M参数量降了一个数量级但深度还能继续堆。这就是为什么 50 层以上的版本反而比 34 层更轻的原因。选哪个块其实取决于你的算力预算和精度要求。我在小数据集上做实验时18 层的 BasicBlock 版本训练快、收敛稳是最省心的起点如果要做精度对标或者迁移学习50 层的 Bottleneck 更常见预训练权重也更好找。3. 逐模块编写带超详细注释的 PyTorch 实现3.1 BasicBlock 的完整实现与逐行注释先看 BasicBlock。这里的注释我会写得比较啰嗦因为它是理解整个残差思想的最小单元。import torch import torch.nn as nn class BasicBlock(nn.Module): ResNet-18/34 使用的基础残差块。 结构 x -- conv3x3 -- bn -- relu -- conv3x3 -- bn -- (x) -- relu | ^ ------------------ shortcut ------------------------- expansion 表示该 block 输出的通道数相对中间通道的放大倍数。 BasicBlock 不放大通道所以是 1Bottleneck 是 4。 expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone, norm_layerNone): Args: inplanes: 输入特征图的通道数 planes: 该 block 内部以及输出的通道数 stride: 第一个 3x3 卷积的步长跨 stage 时为 2其余为 1 downsample: shortcut 上的下采样分支形状不匹配时传入否则为 None norm_layer: 归一化层类型默认 BatchNorm2d方便后续替换成 GroupNorm super().__init__() if norm_layer is None: # 用参数把 BN 抽出来是为了支持替换成 GroupNorm 等变体 norm_layer nn.BatchNorm2d # 第一个 3x3 卷积承担改变分辨率的职责 # biasFalse 是必须的后面紧跟 BNBN 里有可学习的 beta 会平移 # conv 的 bias 会被 BN 完全吸收留着只是多一份无用的参数和显存 self.conv1 nn.Conv2d(inplanes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn1 norm_layer(planes) # 第二个 3x3 卷积stride 固定为 1只做特征变换不再改尺寸 self.conv2 nn.Conv2d(planes, planes, kernel_size3, stride1, padding1, biasFalse) self.bn2 norm_layer(planes) # inplaceTrue 省一点显存因为 ReLU 的值域是 [0, inf) # 原地操作不会破坏需要保留的数据但注意它会影响梯度计算图 # 如果后面还要复用 relu 的输入做别的运算就不能开 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) # 注意第二个 BN 之后不加 ReLU先做加法 # 形状不一致时才对 shortcut 做变换一致时 identity 原样传过去 if self.downsample is not None: identity self.downsample(x) # 残差相加这是整个网络的核心操作 out identity # 加法之后才做非线性保证主路径上存在恒等通路这一步很关键 # 如果 ReLU 加在加法之前残差路径上的信息会被截断 out self.relu(out) return out有几处细节值得单独拎出来说。第一第二个 BN 之后不能加 ReLU必须先把两支加起来再激活。原因在于残差的设计初衷是提供一条无损的恒等通路如果在相加之前就做了 ReLUF(x)的输出会被截断成非负恒等映射的语义就被破坏了。第二out identity用的是原地加法在早期 PyTorch 版本里这会影响 autograd 对out的版本追踪虽然现在框架已经处理得不错但我习惯写成out out identity更保险尤其是在做梯度裁剪或者需要保留中间变量的场景下。第三downsample分支不能省掉 BN。有些教程里 shortcut 只写一个 1x1 卷积这在浅层网络里勉强能跑但训练到后期容易出现某个通道的数值尺度失衡加了 BN 之后稳定性会明显好一截。3.2 Bottleneck 的实现与参数展开计算Bottleneck 的代码看起来长一些但逻辑就是降维 - 卷积 - 升维三步走。class Bottleneck(nn.Module): ResNet-50/101/152 使用的瓶颈残差块。 结构 x -- conv1x1(C-C/4) -- bn -- relu -- conv3x3(C/4-C/4, stride) -- bn -- relu -- conv1x1(C/4-C) -- bn -- (x) -- relu 中间通道是输出通道的 1/4所以 expansion 4。 expansion 4 def __init__(self, inplanes, planes, stride1, downsampleNone, norm_layerNone, groups1, base_width64): super().__init__() if norm_layer is None: norm_layer nn.BatchNorm2d # width 是中间层的通道数。对于标准 Bottleneckgroups1、base_width64 # 所以 width 就等于 planes。ResNeXt 通过调大 groups 让它变成 planes*2、planes*4... width int(planes * (base_width / 64.)) * groups # 1x1 降维把 C 通道压到 width减少后面 3x3 的计算量 self.conv1 nn.Conv2d(inplanes, width, kernel_size1, biasFalse) self.bn1 norm_layer(width) # 3x3 卷积在低维空间里做空间特征提取成本只有原来的 1/16 左右 # 下采样由这一层的 stride 负责 self.conv2 nn.Conv2d(width, width, kernel_size3, stridestride, padding1, groupsgroups, biasFalse) self.bn2 norm_layer(width) # 1x1 升维把通道数恢复到 output 维度即 planes * expansion self.conv3 nn.Conv2d(width, planes * self.expansion, kernel_size1, biasFalse) self.bn3 norm_layer(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和实际输出通道的关系。以 ResNet-50 的第一个 stage 为例layers[0] 3调用_make_layer(block, 64, 3)传进去的planes是 64但 block 输出的实际通道数是64 * 4 256。所以self.inplanes在_make_layer结束时必须更新为planes * block.expansion不然后面接层的输入通道数就对不上了。这个点我在第一次写的时候弄错过报的错是RuntimeError: Given groups1, weight of size [128, 256, ...]排查了半天才发现是inplanes没跟着 expansion 走。另外注意base_width这个参数。标准 ResNet 里它固定是 64看起来多余但它其实是给 ResNeXt 预留的接口。当你需要把网络改成 ResNeXt-50 (32x4d) 时只需要把groups32, base_width4传进去中间通道就变成了int(64 * 4 / 64) * 32 128代码几乎不用动。我一般会把这两个参数保留在构造函数里即使暂时用不上后期想做变体实验时省事。3.3 主干网络与 _make_layer 的组装逻辑有了两个 block接下来把它们串成完整的网络。def conv3x3(in_planes, out_planes, stride1): 3x3 卷积的快捷封装padding 固定为 1保证 stride1 时尺寸不变 return nn.Conv2d(in_planes, out_planes, kernel_size3, stridestride, padding1, biasFalse) def conv1x1(in_planes, out_planes, stride1): 1x1 卷积用于通道变换和下采样不改变空间尺寸stride1 时 return nn.Conv2d(in_planes, out_planes, kernel_size1, stridestride, biasFalse) class ResNet(nn.Module): def __init__(self, block, layers, num_classes1000, zero_init_residualFalse, norm_layerNone, groups1, base_width64): Args: block: BasicBlock 或 Bottleneck layers: 每个 stage 的 block 数量如 [3, 4, 6, 3] 对应 ResNet-50 num_classes: 分类数ImageNet 是 1000 zero_init_residual: 是否把残差分支最后一个 BN 的 gamma 初始化为 0 super().__init__() if norm_layer is None: norm_layer nn.BatchNorm2d self._norm_layer norm_layer self.inplanes 64 # 当前 stage 的输入通道数随网络推进而翻倍 self.groups groups self.base_width base_width # 第一层7x7 大卷积stride2 把 224x224 先降到 112x112 # 用 7x7 而不是 3x3是为了在浅层就获得较大的感受野 self.conv1 nn.Conv2d(3, self.inplanes, kernel_size7, stride2, padding3, biasFalse) self.bn1 norm_layer(self.inplanes) self.relu nn.ReLU(inplaceTrue) # 3x3 maxpool再降一次分辨率到 56x56 # padding1 是为了让 224/2112 的输入整除后得到 56而不是 55 self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) # 四个 stage通道数 64 - 128 - 256 - 512 # 注意 stride 从第二个 stage 开始才是 2第一个 stage 保持 56x56 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) # 全局平均池化把 HxW 压成 1x1不管输入多大都能自适应 # 这一步替代了早期网络里的巨大全连接层参数量从千万级降到零 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): # Kaiming 初始化fan_out 模式配合 ReLU 使用 # 它让每层输出的方差保持一致避免深层网络里数值爆炸或消失 nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)): # BN 的缩放参数初始为 1偏移为 0即初始时不做任何变换 nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) # 可选的零初始化把每个残差块最后一个 BN 的 gamma 置 0 # 效果是训练开始时 F(x)0整个网络等价于恒等映射 # 相当于从浅层网络出发逐步加深收敛更稳 if zero_init_residual: 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) def _make_layer(self, block, planes, blocks, stride1): 组装一个 stage内部包含 blocks 个残差块 Args: block: 残差块类型 planes: 该 stage 的基准通道数Bottleneck 的实际输出要乘 expansion blocks: 该 stage 的 block 个数 stride: 第一个 block 的步长跨 stage 时为 2 norm_layer self._norm_layer downsample None # 只要通道数会变或者空间尺寸会变shortcut 就需要下采样分支 if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( conv1x1(self.inplanes, planes * block.expansion, stride), norm_layer(planes * block.expansion), ) layers [] # 第一个 block 负责改变形状需要传入 stride 和 downsample layers.append(block(self.inplanes, planes, stride, downsample, norm_layer, self.groups, self.base_width)) # 更新通道数供后续 block 和下一个 stage 使用 self.inplanes planes * block.expansion # 剩下的 block 输入输出形状一致shortcut 走纯恒等路径 for _ in range(1, blocks): layers.append(block(self.inplanes, planes, 1, None, norm_layer, self.groups, self.base_width)) 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.view(x.size(0), -1) x self.fc(x) return x_make_layer里那个downsample的判断条件是整个实现中最容易写错的地方。stride ! 1好理解跨 stage 时要降分辨率self.inplanes ! planes * block.expansion这半句是给 Bottleneck 用的因为输入可能是 256 通道输出是 64*4256 通道看起来一样但如果是从上一个 stage 过来通道数其实是不同的。这两个条件用 or 连起来覆盖了改形状的所有情况。3.4 造出四个标准型号并做形状自检把工厂函数写好方便直接拿型号。def _resnet(block, layers, **kwargs): model ResNet(block, layers, **kwargs) return model def resnet18(**kwargs): return _resnet(BasicBlock, [2, 2, 2, 2], **kwargs) def resnet34(**kwargs): return _resnet(BasicBlock, [3, 4, 6, 3], **kwargs) def resnet50(**kwargs): return _resnet(Bottleneck, [3, 4, 6, 3], **kwargs) def resnet101(**kwargs): return _resnet(Bottleneck, [3, 4, 23, 3], **kwargs) if __name__ __main__: for name, fn in [(resnet18, resnet18), (resnet50, resnet50)]: net fn(num_classes10) n_param sum(p.numel() for p in net.parameters()) dummy torch.randn(2, 3, 224, 224) out net(dummy) print(f{name}: params{n_param/1e6:.2f}M, out{tuple(out.shape)})跑一下应该得到 ResNet-18 约 11.2M 参数、ResNet-50 约 23.5M 参数num_classes10 时比 1000 类略少。输出形状是(2, 10)。这里有个我强烈建议加进自检流程的动作逐层打印特征图尺寸。在forward里插几个 print或者用 hook确认每个 stage 的输出是56x56 → 56x56 → 28x28 → 14x14 → 7x7。我第一次复现时因为maxpool的 padding 写成了 0特征图变成了 55x55后面的整除一路错下去最后在avgpool那一步才报错排查起来很绕。与其这样不如一开始就把尺寸对齐这件事验证掉。另一个自检手段是和官方实现做数值对齐。做法是构造一组固定的随机输入分别喂给自己的模型和torchvision.models.resnet50(weightsNone)把后者的state_dict用load_state_dict加载进自己的模型前提是结构完全一致然后比较两边输出的最大绝对误差。我的经验是误差在 1e-5 量级就说明结构没错。这个技巧比盯着代码看有效得多。4. 关键参数的取舍几个官方文档不会告诉你的点4.1 stride 放在第一层还是第二层卷积这是复现时争议最多的一个细节。原论文里BasicBlock 的下采样是把 stride2 放在第二个 3x3 卷积上而 torchvision 的实现把 stride 放在了第一个卷积上。两者都能跑但结果会有细微差别。为什么会有这个差异论文版的说法是把降采样放在后面的层前面那层保留更多空间信息听起来有道理torchvision 版的做法则更简单直接——第一个卷积负责改形状第二个专注提特征。实测下来在 ImageNet 这种大数据集上两者精度差异在 0.3% 以内属于随机种子级别的波动。我的建议是如果你想复现论文的数值就按论文来如果你想加载 torchvision 的预训练权重就必须按 torchvision 来因为权重是跟结构绑死的。妥协方案是两者都写用一个参数控制我自己代码里一般是默认按 torchvision 排布方便直接吃预训练权重。Bottleneck 的情况稍微复杂点。torchvision 的实现里3x3 卷积上带 stride2两个 1x1 卷积的 stride 都是 1而论文版是把 stride 放在第一个 1x1 上。这两种写法在参数量上完全一样但中间特征图的分辨率不同前者中间层是降采样后的尺寸后者是降采样前的尺寸显存占用会差一倍左右。所以如果你的显存紧张用 torchvision 的排布更划算。4.2 BatchNorm 的训练/推理模式切换坑BN 在train()和eval()下行为完全不同。训练时用当前 batch 的均值和方差做归一化同时用滑动平均更新全局统计量推理时用全局统计量。这个切换不是自动的必须手动调model.eval()。我见过不止一个人踩这个坑训练完直接拿模型去测精度忘了eval()结果准确率比训练过程中还低百思不得其解。更隐蔽的问题是在做验证的时候忘了切回train()导致后面的训练全用的是冻结的统计量loss 曲线会呈现出一种缓慢但持续下降的假象。正确的写法是这样def evaluate(model, loader, criterion, device): model.eval() # 切换到推理模式BN 用滑动平均Dropout 关闭 total_loss, correct, total 0.0, 0, 0 with torch.no_grad(): # 关闭梯度计算省显存也更快 for images, targets in loader: images images.to(device, non_blockingTrue) targets targets.to(device, non_blockingTrue) outputs model(images) loss criterion(outputs, targets) total_loss loss.item() * images.size(0) correct (outputs.argmax(1) targets).sum().item() total images.size(0) model.train() # 一定要切回来 return total_loss / total, correct / total还有一个和 BN 相关的隐性问题batch size 太小会让 BN 的统计量失真。当 batch size 小于 8 时一个 batch 内的均值方差已经不能代表整体分布了训练会出现明显的震荡。我在小显存机器上做实验时遇到过这个问题解决方案有三个一是用梯度累积把等效 batch size 撑上去二是把 BN 换成 GroupNorm它对 batch size 不敏感三是冻结 BN 的统计量用预训练模型的全局统计量。选哪个取决于你的任务但千万别小 batch 硬跑 BN 然后怪模型不收敛。4.3 零初始化残差 BN 的 gammazero_init_residual这个参数在 torchvision 里默认是开着的部分版本但很多人不知道它在做什么。原理是把每个残差块最后一层 BN 的 gamma 初始化为 0那么训练开始时F(x) 0整个块输出就是relu(0 x) relu(x)网络整体退化成一系列恒等映射的叠加。从这个状态出发网络相当于一个很浅的模型梯度可以顺畅地传到底层然后随着训练推进gamma 逐渐长出非零值网络慢慢变深。这相当于给深层网络做了一个平滑的启动过程。论文里的实验显示这样做能让 ResNet-110 在 CIFAR-10 上的初始收敛速度快不少最终精度也能涨零点几个点。代价几乎为零所以我一般都会打开。要注意的是只对残差分支的最后一个 BN做零初始化不要全部 BN 都置零也不要对 shortcut 上的 BN 动手否则就破坏了残差相加的语义。5. 在 CIFAR-10 上把训练流程完整跑一遍5.1 环境准备与数据管道先把环境搭起来。用 conda 的话三行命令conda create -n resnet python3.10 -y conda activate resnet # 根据你自己的显卡情况选择 CUDA 版本这里以 11.8 为例 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118装完用python -c import torch; print(torch.__version__, torch.cuda.is_available())验证一下输出True就说明 GPU 可用。CIFAR-10 是 32x32 的小图不能直接套 ImageNet 那套预处理7x7 stride2 的 stem 加 maxpool 会把图压到 8x8信息损失太大。标准做法是换成 CIFAR 版stem 改成 3x3、stride1 的卷积去掉 maxpool后面的 stages 保持 stride2。这样 32x32 输入经过四个 stage 变成 4x4最后全局池化很合理。import torchvision.transforms as T from torchvision.datasets import CIFAR10 from torch.utils.data import DataLoader # 训练集的增强随机裁剪 随机翻转 # padding4 先把 32x32 补到 40x40 再随机裁回 32x32 # 目的是让物体出现在不同位置提升平移不变性 train_tf T.Compose([ T.RandomCrop(32, padding4), T.RandomHorizontalFlip(), T.ToTensor(), # 这里的均值和方差是 CIFAR-10 训练集的统计值写死就行 T.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) # 测试集只做归一化不做任何增强否则评测结果不可比 test_tf T.Compose([ T.ToTensor(), T.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) train_set CIFAR10(root./data, trainTrue, downloadTrue, transformtrain_tf) test_set CIFAR10(root./data, trainFalse, downloadTrue, transformtest_tf) train_loader DataLoader(train_set, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue) test_loader DataLoader(test_set, batch_size256, shuffleFalse, num_workers4, pin_memoryTrue)几个参数说明一下。num_workers4是数据加载的子进程数Windows 上如果报BrokenPipeError就改成 0 用主进程加载。pin_memoryTrue配合non_blockingTrue能把数据拷贝到 GPU 的过程变成异步训练速度能快 5% 到 10%。drop_lastTrue是丢掉最后一个不满的 batch避免 BN 在只有 3 个样本的 batch 上算出乱七八糟的统计量。5.2 优化器、学习率调度与训练循环超参方面CIFAR-10 上跑 ResNet-18 我常用的配置是这样参数取值选择理由batch size128平衡 BN 统计质量和显存占用优化器SGD momentum0.9小数据集上比 Adam 泛化更好收敛更平初始学习率0.1配合 batch 128 的经验值太大容易发散权重衰减5e-4只作用在权重上不作用在 BN 参数和 bias训练轮数200CIFAR 上充分收敛需要的量级标签平滑0.1抑制过拟合还能让置信度更合理warmup5 epoch前期线性升温避免初始梯度把参数打飞调度cosine后期平滑降到 0比阶梯式下降效果稍好权重衰减这里有个细节值得展开不要对 BN 的 gamma/beta 和所有 bias 施加 weight decay这是很多论文里的做法也是我在实测中确认有效的一个技巧。原因是这些参数本身就承担着调节数值尺度的职责给它们加衰减会让 BN 的归一化能力变弱。实现方式是把参数分成两组def build_param_groups(model, weight_decay): decay, no_decay [], [] for name, param in model.named_parameters(): if not param.requires_grad: continue # 一维参数一般是 BN 的 gamma/beta 和 bias不加权重衰减 if param.ndim 1: no_decay.append(param) else: decay.append(param) return [ {params: decay, weight_decay: weight_decay}, {params: no_decay, weight_decay: 0.0}, ] import math def build_scheduler(optimizer, warmup_epochs, total_epochs, base_lr): 线性 warmup 余弦退火 def lr_lambda(epoch): if epoch warmup_epochs: # 第 0 个 epoch 从 base_lr*0.1 开始逐步升到 base_lr return (epoch 1) / (warmup_epochs * 10) * 10 * 0.1 0.1 * (epoch / warmup_epochs) progress (epoch - warmup_epochs) / max(1, total_epochs - warmup_epochs) return 0.5 * (1 math.cos(math.pi * progress)) return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)warmup 那个 lambda 我写得有点绕实际用的时候更推荐直接写清楚前 5 个 epoch 从 0.01 线性升到 0.1之后余弦降到 0。warmup 的意义在于训练最开始模型的输出和标签差得很远梯度方向可能非常偏用大学习率很容易一步跨到坏区域用小学习率先走几步能把方向校正过来。训练循环本身没什么花活但有几个地方要写对device torch.device(cuda if torch.cuda.is_available() else cpu) model resnet18(num_classes10, zero_init_residualTrue).to(device) criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.SGD(build_param_groups(model, 5e-4), lr0.1, momentum0.9, nesterovTrue) scheduler build_scheduler(optimizer, 5, 200, 0.1) scaler torch.cuda.amp.GradScaler() # 混合精度省显存提速 for epoch in range(200): model.train() running_loss, seen 0.0, 0 for images, targets in train_loader: images images.to(device, non_blockingTrue) targets targets.to(device, non_blockingTrue) optimizer.zero_grad(set_to_noneTrue) # 比置 0 更快显存也更省 with torch.cuda.amp.autocast(): outputs model(images) loss criterion(outputs, targets) # 混合精度下的反向传播先放大 loss 再反传避免 fp16 下梯度下溢 scaler.scale(loss).backward() # 先 unscale 再做梯度裁剪否则裁剪阈值失去了物理意义 scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) scaler.step(optimizer) scaler.update() running_loss loss.item() * images.size(0) seen images.size(0) scheduler.step() lr_now optimizer.param_groups[0][lr] if (epoch 1) % 10 0 or epoch 5: acc evaluate(model, test_loader, criterion, device) print(fepoch {epoch1:3d} | lr {lr_now:.4f} | floss {running_loss/seen:.4f} | val_acc {acc[1]*100:.2f}%)梯度裁剪放在scaler.unscale_之后、scaler.step之前这个顺序不能乱。原因是在混合精度下loss 被放大了一个 scale 因子梯度也跟着被放大如果直接拿这个放大的梯度去算范数阈值 5.0 就变成了实际上的 5.0/scale几乎等于没裁剪。5.3 跑起来之后会看到什么按上面这套配置ResNet-18 在 CIFAR-10 上的典型表现是前 5 个 epoch 准确率快速爬到 70% 左右10 个 epoch 到 85%30 个 epoch 到 92%之后缓慢爬升200 个 epoch 结束时在 94.5% 到 95.5% 之间取决于随机种子和数据增强的具体实现。如果你用的是 ResNet-50 的 Bottleneck 版本参数量大很多但 CIFAR 上不会明显更好甚至可能因为过拟合略微更差。小数据集上瓶颈结构的收益不明显这是正常现象不用怀疑自己代码写错了。注意第一次跑的时候建议先把 epoch 数设成 5确认 loss 在下降、显存没爆、验证流程能跑通再放开跑完整训练。全流程在单张消费级显卡上大概 2 到 4 小时中途挂了重来很浪费时间。6. 常见报错与排查速查表复现过程中遇到的问题绝大多数能归到下面几类。我把典型报错、原因和解决办法整理成表遇到问题先来这里对一下。报错信息关键词根本原因解决方式size mismatch for fc.weight加载预训练权重时类别数不一致加载前先替换fc或用strictFalse过滤Given groups1, weight of size [64, 32, ...]inplanes没随expansion更新检查_make_layer末尾的通道数赋值The size of tensor a must match tensor bshortcut 和主干形状不一致检查downsample的触发条件确认 stride 和输出通道Expected 4D input, got 3D输入少了 batch 维度用x.unsqueeze(0)或检查 DataLoader 的collate_fnloss 一直是 2.30 左右不动输出恒为均匀分布通常是初始化或 lr 问题检查zero_init_residual是否误伤全部 BNlr 是否过大train acc 高但 val acc 低过拟合加数据增强、加 weight decay、开标签平滑train/val 都在震荡batch size 太小或 lr 太大梯度累积、换 GroupNorm、降 lrCUDA out of memory特征图太大或 batch 太大降 batch、开混合精度、把 Bottleneck 的 stride 前移训练几轮后 loss 变 NaN梯度爆炸或混合精度溢出开梯度裁剪、检查scaler的使用顺序验证结果每次都不一样忘了model.eval()或数据加载有随机性补上模式切换测试集关掉 shuffle多卡训练报KeyError: module.DataParallel/DDP 给参数加了前缀用state_dict的 key 重映射或strictFalse数值和官方实现对不上stride 位置、padding、stem 结构不一致用固定输入逐层对比重点查 maxpool 的 padding有几条我想再多说两句。loss 卡在 2.30这个现象很典型CIFAR-10 有 10 类ln(10) ≈ 2.303loss 稳定在这个值说明模型输出的 logits 几乎是常数等价于没学到任何东西。常见诱因有三个学习率设成了 0.01 以下的极小值看着在动但慢到看不出来、zero_init_residual把不该置零的 BN 也置零了前向输出全 0、数据归一化用了 ImageNet 的均值方差处理 CIFAR分布不匹配前期梯度很小。数值和官方对不上这个我在第 3 节提过补充一个具体做法用torch.manual_seed(0)固定随机性构造torch.randn(1, 3, 224, 224)的输入然后给自己的模型和官方模型分别挂上 forward hook把每一层的输出 shape 和均值打出来对比。差异通常会出现在两个地方一是maxpool的 padding官方是 1很多人写成 0二是layer2第一个 block 的 stride 位置。找到第一个出现差异的层问题基本就定位了。还有一个不太算报错但很影响体验的问题训练速度比预期慢很多。除了开混合精度和pin_memory还有一个常被忽略的点是torch.backends.cudnn.benchmark True。这个开关会让 cuDNN 在第一次前向时自动搜索最快的卷积算法输入尺寸固定的场景下能提速 10% 到 20%。代价是第一次迭代会慢几秒而且如果输入尺寸变化频繁反而会更慢所以只在输入尺寸固定的训练脚本里开。7. 加载预训练权重做迁移学习自己复现的模型最实用的价值之一是能无缝接上 torchvision 的预训练权重。前提是结构要严格对齐——这也是我在第 4 节建议默认按 torchvision 的 stride 排布写代码的原因。加载逻辑不复杂关键是处理fc层的形状不匹配import torchvision.models as tvm def load_pretrained(model, num_classes): 把 torchvision 的预训练权重灌进自己的模型 注意只有当你的结构与 torchvision 完全一致时才能严格加载 否则用 strictFalse 并检查 missing/unexpected keys。 ckpt tvm.resnet50(weightstvm.ResNet50_Weights.IMAGENET1K_V2).state_dict() # 分类头的形状和新任务不一致直接丢掉让它随机初始化 for key in [fc.weight, fc.bias]: ckpt.pop(key, None) missing, unexpected model.load_state_dict(ckpt, strictFalse) print(missing keys:, [k for k in missing if fc not in k]) print(unexpected keys:, unexpected) return modelmissing里出现fc.weight和fc.bias是预期的因为被我们主动丢掉了。如果missing里出现了layer3.2.conv2.weight这种主干参数说明你的结构和官方不一致得回去查结构。unexpected非空就更严重了说明官方有权重而你的模型里没这个层八成是少写了某个模块。迁移学习的两种典型用法我一般这么选如果目标数据集和 ImageNet 比较接近比如都是自然图像分类而且数据量在几千张以上我会只替换fc然后整体微调学习率用预训练的 1/10 左右比如 0.01 配 cosine。全部层一起动收敛最快。如果目标数据集很小几百张甚至几十张或者和 ImageNet 差异很大医学影像、工业缺陷图我会先冻结主干只训练fc跑几个 epoch 让分类头先稳定下来再解冻后面一两个 stage 做微调。这样能避免随机初始化的分类头产生的大梯度把预训练特征冲垮。# 冻结主干把除 fc 以外的参数 requires_grad 关掉 for name, param in model.named_parameters(): param.requires_grad (fc in name) # 只把需要梯度的参数交给优化器避免无效计算 optimizer torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr0.01, momentum0.9, weight_decay5e-4, )冻结的时候有个坑要注意requires_gradFalse只是不更新参数BN 层在 train 模式下依然会更新滑动平均统计量。如果你的小数据集分布和 ImageNet 差得远这些统计量会被带偏反而损害性能。稳妥的做法是冻结阶段把整个 BN 层设成 eval 模式def freeze_bn(model): for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() m.weight.requires_grad False m.bias.requires_grad False顺便提一点我做特征提取时也常用这套模型把fc换掉、把所有参数冻住然后拿avgpool之后的 512*42048 维向量当图像特征接一个线性分类器或者做检索。这种做法在小样本场景下比端到端微调稳得多训练时间也从小时级降到分钟级。理解了 ResNet 每一层在干什么之后你会发现这个特征提取器的用法其实是残差结构最被低估的一个价值点。
返回列表