资讯动态

ResNet 输入尺寸改造:四种方案与 PyTorch 代码详解

发布时间:2026/10/16 15:10:48 来源:尧图企业网站定制
做图像分类的朋友应该都遇到过这种需求手里的图片是小分辨率正方形比如 32×32 或者 48×48但手头现成的 ResNet 是在 224×224 上训出来的标准结构直接丢进去总担心效果崩换到大分辨率输入又担心预训练权重被浪费。网上搜“修改 ResNet 适应不同大小的输入”能搜到不少零碎代码但大部分只给了改法没给原理导致换一个尺寸又不会改了。这篇文章我打算把这件事彻底讲透先说清楚 ResNet 到底哪一层卡住了输入尺寸再按目标分辨率分档给出四种可落地的改造方案附上能直接跑的 PyTorch 代码最后把我踩过的坑和实测数据一并交代。适合正在做图像分类、检索或者检测项目、需要把一个标准 ResNet 改造成适配自己数据分辨率的同学参考。1. 先说结论ResNet 的尺寸问题到底卡在哪1.1 下采样链才是真正的尺寸瓶颈很多人第一反应是“最后一层全连接层固定了输入尺寸”这个理解对 VGG 这种老结构成立但对 ResNet 来说是个误解。标准 ResNet 在最后一个阶段之后、全连接层之前有一个 Global Average Pooling也就是全局平均池化把任意尺寸的特征图压成 1×1所以全连接层的输入维度只跟通道数有关跟图片长宽没有关系。真正限制输入尺寸的是前面这条下采样链。以 torchvision 里的 ResNet-50 为例默认结构里带了 5 次 stride2 的下采样stem 的 7×7 卷积一次、maxpool 一次、Layer2/Layer3/Layer4 的首个 Bottleneck 各一次。合计下来是 2^532 倍下采样224×224 的输入到最后变成 7×7 的特征图这正是标准训练时的设计预期。我整理了一张各阶段的特征图尺寸变化表方便对着看阶段关键操作224×224 输入时的输出尺寸Stem7×7 conv, stride 2112×112MaxPool3×3 pool, stride 256×56Layer1Bottleneck ×356×56Layer2首个 Bottleneck stride 228×28Layer3首个 Bottleneck stride 214×14Layer4首个 Bottleneck stride 27×7GAP全局平均池化1×1FC2048 → 10001000可以看出从卷积层到分类层之间没有一处是与“固定长宽”强绑定的。所谓“ResNet 只能吃 224”本质是这条 32 倍下采样链在遇到小分辨率输入时会把最后阶段的特征图压得太狠甚至压到只剩一个像素。1.2 不同分辨率输入下的前向推演为了更直观我把 32×32、48×48、112×112、224×224、512×512 五种输入分别过一遍标准 ResNet-50 的下采样链看看 Layer4 之后的特征图尺寸输入尺寸经过 StemMaxPool 后Layer2 后Layer3 后Layer4 后32×328×84×42×21×148×4812×126×63×32×2112×11228×2814×147×74×4224×22456×5628×2814×147×7512×512128×12864×6432×3216×1632×32 的输入走到最后一层已经变成 1×1分类器只能基于一个全局均值做出判断每个类别可区分的空间细节几乎全丢。48×48 稍好一点但也只剩 2×2。反过来看512×512 的输入并不会让网络崩溃只是计算量约等于 224×224 的 5 倍而且如果预训练时只见过 224特征分布的偏移也会带来一点精度损失。所以结论是ResNet 的尺寸问题不是一个“能不能跑”的硬限制而是一个“效果好不好”的软限制。只要特征图没有被压到不可用网络都能推下去关键就看你愿不愿意接受这个信息损失。1.3 速度与显存的成本模型顺带说下高分辨率方向这也是“适应不同大小输入”里容易被忽略的一半。卷积层的计算量跟输入的空间尺寸近似成正比ResNet-50 在 224×224 输入下大约需要 4.1 GFLOPs换到 512×512 直接变成 21.5 GFLOPs 左右。如果你的目标不是分类而是检测分割想把 ResNet 当 backbone 保留高分辨率特征那就不能简单“放大输入跑”而是要考虑把下采样链拆开让不同 stage 输出不同倍率的特征图。这一点我会在第 3 章的第四个方案里展开。2. 改造前的分档决策目标尺寸决定改造深度2.1 先对自己的输入尺寸分档动手改代码之前我建议想清楚一个问题你最终要处理的输入尺寸大概落在哪个区间。不同区间对应的改造策略差异很大盲目照搬网上某个修改方案可能在你的场景里反而更差。我一般按四档来分目标尺寸大于 224×224属于高分辨率输入stem 不需要动重点考虑要不要减少下采样倍数、是否引入空洞卷积、显存是否够用。目标尺寸在 112×224 之间属于中等分辨率stem 可以保留但训练时建议做多尺度让模型适应尺寸抖动。目标尺寸在 32×112 之间属于小分辨率stem 必须动通常要把第一个 7×7 stride2 的卷积换成 3×3 stride1并去掉或弱化 maxpool否则信息被压得太早。目标尺寸小于 32×32属于超小分辨率此时再硬套标准 ResNet-50 意义不大我更建议砍掉最后一个 stage或者干脆换一个更浅的骨干网络。这个分档不是拍脑袋而是基于一个朴素的直觉下采样倍率应该和输入尺寸匹配。理想情况下进入分类头之前的特征图至少要保留 4×4 以上的空间信息也就是“总下采样倍数不要超过输入尺寸的四分之一”。比如 32×32 的输入总下采样倍数最好小于等于 8 倍224×224 的输入32 倍下采样正好得到 7×7依然合理。2.2 决策表各档位推荐的改法下面是我自己习惯用的决策表基本覆盖了大多数实际场景输入尺寸推荐下采样总倍数具体做法预训练权重兼容性 32×324 或 8去 maxpoolconv1 改 3×3 stride 1砍 Layer4一般需重新初始化部分层32×32 ~ 64×648 或 16conv1 改 3×3 stride 1去 maxpool可选保留 Layer4看具体改法64×64 ~ 112×11216conv1 保持 7×7 stride 2可选去 maxpool 或保留兼容性较好112×112 ~ 224×22416 或 32基本不改结构多尺度训练完全兼容 224×22416 或 32保留结构或 Layer4 改 stride1 空洞卷积完全兼容这个表只是起点实际项目还要考虑显存和训练时长。我的建议是先用表格里的方案跑一次 ablation不要一上来就搞最复杂的空洞卷积改造。大多数分类任务里把第一层下采样去掉就已经能解决小分辨率的问题了。2.3 预训练权重的取舍逻辑改结构就面临一个问题预训练权重怎么加载这里有几个原则只改 stride 不改 kernel 数量或分组数比如把 7×7 stride 2 改成 7×7 stride 1权重可以直接加载stride 参数不参与权重形状。改了 kernel size 或通道数比如 7×7 改成 3×3那么该层权重无法直接加载只能随机初始化或者从预训练 7×7 权重的中心区域裁剪出 3×3 作为初始值。删掉 maxpool 或层通常不影响权重加载只需要调整模型定义。替换分类头是必然的因为你的类别数大概率不是 1000。这一层本来就建议随机初始化不要从预训练加载。预训练权重到底值不值得保留我个人的体会是对于 32×32 这种小分辨率分类保留前几个 stage 的预训练权重依然有好处尤其在数据量不大时收敛速度快很多。所以能通过“只改 stride、中心裁剪”等方式保留尽量多的预训练参数就尽量保留。3. 四种改法逐个拆解附可直接跑的 PyTorch 代码3.1 起手式入口插值到固定尺寸零结构改动最容易上手的方法是在数据预处理阶段把所有输入 resize 到 224×224网络结构完全不动。这种做法在“输入尺寸其实不小只是不统一”的场景下非常有效。比如你的图片有 200×300、400×150 这种不规则尺寸直接等比缩放再 pad 到 224×224通常能拿到不错的基线。代码上最简单的方式就是改 transformsimport torchvision.transforms as T train_transform T.Compose([ T.Resize((224, 224)), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])如果你希望把 resize 逻辑内嵌到模型里方便部署时少写一段预处理可以包一层 wrapperimport torch import torch.nn as nn import torch.nn.functional as F class AdaptiveInputWrapper(nn.Module): def __init__(self, backbone, target_size224): super().__init__() self.backbone backbone self.target_size target_size def forward(self, x): x F.interpolate(x, size(self.target_size, self.target_size), modebilinear, align_cornersFalse) return self.backbone(x)这样做的好处是模型接受任意长宽输入内部统一变成固定大小外面调用起来很干净。缺点是如果原始输入是 32×32 这种小图强行放大到 224×224 并不会补出细节只是把模糊放大了。所以这个方案适合作为基线但不适合小分辨率为主的场景。3.2 保留小分辨率信息的 Stem 改造当目标分辨率在 32×32 到 64×64 之间我通常推荐改 stem。核心思路是降低前端的下采样倍率让后面的 stage 还能保留足够的空间信息。具体两步把 conv1 从 7×7 stride2 改成 3×3 stride1把 maxpool 去掉。import torch.nn as nn from torchvision.models import resnet18 def adapt_resnet_stem(model, input_size32): # 输入过小时把 conv1 从 7x7 stride2 换成 3x3 stride1 if input_size 64: model.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) model.maxpool nn.Identity() elif input_size 112: # 中等分辨率下保留 7x7 卷积但去掉紧跟的 maxpool model.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) model.maxpool nn.Identity() return model model resnet18(pretrainedTrue) model adapt_resnet_stem(model, input_size32) model.fc nn.Linear(model.fc.in_features, 10)这里有一个非常实用的小技巧如果不想丢掉 7×7 卷积的预训练权重但又要改成 3×3可以从原来的 7×7 权重中心裁一块 3×3 出来当作初始化。因为 7×7 卷积核中间区域的信息某种程度上可以代表“局部特征提取器”的核心部分def center_crop_init(new_conv, old_conv): with torch.no_grad(): old_w old_conv.weight.data # shape: (C_out, C_in, 7, 7) s (7 - 3) // 2 new_conv.weight.data.copy_(old_w[:, :, s:s3, s:s3])还有一个更保守的改法仍然保留 7×7 卷积只是把 stride 改成 1同时去掉 maxpool。这样权重完全兼容不需要任何初始化技巧。计算量会稍微大一点但在 32×32 输入上完全可接受我在实验里发现效果和直接换 3×3 差不多省心很多。如果要追求更彻底的小分辨率适配还可以考虑把 Layer4 的下采样也去掉做法如下def remove_stage_downsample(module, stage_namelayer3): stage getattr(module, stage_name) for m in stage.modules(): if isinstance(m, nn.Conv2d) and tuple(m.stride) (2, 2): m.stride (1, 1)这个函数会把这个 stage 里所有 stride2 的卷积改成 stride1。注意 ResNet 的残差里也有 stride2 的卷积这行代码会一并改掉所以它处理的其实是“整个 stage 降采样这件事”。3.3 全卷积化输出头彻底摆脱空间尺寸依赖如果你不想在模型内部对输入做任何 resize同时希望模型的前向路径完全不依赖空间尺寸可以把分类头改成 1×1 卷积。因为 ResNet 在 GAP 之后是 [B, C, 1, 1] 的形状1×1 卷积天然等价于全连接层而且接受任意空间尺寸。class FullyConvResNet(nn.Module): def __init__(self, base_model, num_classes): super().__init__() self.features nn.Sequential( base_model.conv1, base_model.bn1, base_model.relu, base_model.maxpool, base_model.layer1, base_model.layer2, base_model.layer3, base_model.layer4, ) self.pool base_model.avgpool self.classifier nn.Conv2d(base_model.fc.in_features, num_classes, kernel_size1) def forward(self, x, return_featureFalse): x self.features(x) x self.pool(x) # 任意尺寸 - Cx1x1 out self.classifier(x) # 1x1 卷积代替全连接 out out.flatten(1) # BxC if return_feature: return out, x.flatten(1) return out这样无论输入是 32×32 还是 512×512前向都能跑通而且因为最后有 GAP分类头权重可以直接从预训练 fc 层迁移过来。迁移方式是把 fc 的权重 reshape 成 [C_out, C_in, 1, 1] 再复制到 conv 层。如果懒得迁移随机初始化也可以因为分类头本身训练起来很快。这种全卷积化的思路在做“先分类后分割”、或者需要在一个模型里同时输出特征和类别概率时特别好用中间的特征图可以引出去给别的分支用不必额外跑一次 forward。3.4 为高分辨率任务调整 Stride 与空洞卷积最后一个方案更多用在高分辨率输入和检测分割场景你希望骨干网络输出的特征图保持较高空间分辨率而不是在 Layer4 之后只剩 7×7。常见做法是去掉 Layer4 的下采样并用空洞卷积补偿感受野。对于 ResNet-50默认下采样倍率是 32 倍去掉 Layer4 的 stride 后变成 16 倍。def adapt_resnet_stride_via_dilation(model, output_stride16): # 默认 ResNet 输出 stride32 # 目标 stride16 时把 layer4 所有 stride2 的卷积改成 stride1 # 并对 layer4 的 3x3 卷积设置 dilation2、padding2 assert output_stride in (8, 16, 32) if output_stride 32: return model layer4_dilation 2 if output_stride 16 else 4 for m in model.layer4.modules(): if isinstance(m, nn.Conv2d): if tuple(m.stride) (2, 2): m.stride (1, 1) if m.kernel_size (3, 3): m.dilation (layer4_dilation, layer4_dilation) m.padding (layer4_dilation, layer4_dilation) return model这段代码的思路是Layer4 原本通过 stride2 把特征图缩小一半现在不收这一刀特征图就大一倍但 Conv 3×3 的感受野也相应变小了所以用 dilation2 把有效感受野撑回接近原来的大小。很多检测项目里加载 ImageNet 预训练权重后只用这么一段代码就能把标准 ResNet 变成可用作多尺度检测的 backbone。需要提醒的是这个改法虽然能保留高分辨率但计算量会比默认结构多不少显存占用会明显上升。训练时建议从较小的 batch size 开始或者配合梯度检查点使用。4. 实测四个方案在不同输入尺寸下的精度速度账单4.1 实验设置说明我在一个约 15 万张的商品图片数据集上做了对比实验类别数 25训练集和验证集按 9:1 划分。骨干网络统一用 ResNet-18优化器 AdamW初始学习率 3e-4batch size 128微调 10 个 epoch输入不做额外增强只做随机水平翻转。四个方案分别是A原版模型外部 Resize 到 224Bconv1 改 3×3 stride 1去 maxpool32×32 直接输入C保留 7×7 stride 1去 maxpool32×32 直接输入D全卷积化输出头外部 Resize 到 224。同时在 224×224 输入下测了不改造模型的精度作为对照。表格如下方案Test Top-1 (%)每 epoch 训练耗时相对备注对照原版 224×22493.11.0x标准场景A原版 Resize 到 224输入 32×3288.50.9x小图被放大细节丢失BStem 改造直接输入 32×3292.30.7x保留原始空间信息C7×7 stride 1 改造直接输入 32×3291.80.8x预训练权重全兼容D全卷积化 Resize 到 224输入 32×3288.70.9x分类头解耦精度与 A 类似这里的具体数字在别的数据集上会有波动但趋势是稳定的小分辨率下直接改 stem 获得的精度提升通常有 2 到 4 个百分点而只靠外部 resize 基本就是“花钱买省事”。4.2 高分辨率输入的反直觉结论在上面这个数据集里我又做了一组 512×512 输入的实验不做任何 stem 修改只把输入从 224 换成 512微调 10 个 epoch发现 Top-1 并没有显著提升大概从 93.1 涨到 93.6训练耗时却涨了 5 倍多。这说明对于本身就比较“容易”的分类任务高分辨率带来的收益很有限代价却非常大。如果目标不是分类而是检测或分割高分辨率才有明确价值因为小目标的定位和分割边界需要更细的 feature map。这种情况下用 3.4 节提到的 stride 调整方案比单纯放大输入更划算计算量增长小于直接放大输入又能保留多尺度特征。4.3 多尺度训练带来的额外增益意外的是性价比最高的方案不是某个复杂的结构改造而是多尺度训练。在保持 B 方案结构不变的前提下我每个 batch 随机从 [32, 40, 48, 56, 64] 中采样一个尺寸做 resize训练完 10 个 epoch 后在 32×32、48×48、64×64 三个分辨率上分别测试Top-1 都稳定在 92.5 到 93.0 之间。比单一尺寸训练更稳而且几乎没有额外成本。import random def get_train_transform(): size random.choice([32, 40, 48, 56, 64]) return T.Compose([ T.Resize((size, size)), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])多尺度训练的本质是让 BatchNorm 统计量和各层卷积对“尺度抖动”不再敏感。线上如果模型要兼容多种上传分辨率这个做法几乎是必须的。5. 翻车高发区BN统计量、权重加载、小分辨率瓶颈5.1 BatchNorm 的 running_mean 失效这是最隐蔽的坑。预训练模型里的 BatchNorm 保存了 running_mean 和 running_var这些统计量是 224×224 图片在标准 ImageNet 增强流程下得到的。改结构之后如果你直接用大学习率微调前几个 batch 的梯度会剧烈改变中间层特征分布BN 统计量更新不及时验证集误差可能忽高忽低甚至完全不收敛。我的做法是在小分辨率数据上微调之前先用训练集跑 100 到 200 个 batch 的 forward把 BN 统计量重新校准一遍。下面是示例代码def recalibrate_bn(model, dataloader, device, max_batches200): model.train() with torch.no_grad(): for i, (x, _) in enumerate(dataloader): if i max_batches: break x x.to(device) model(x) model.eval()这个小步骤成本极低但经常能把预训练模型的初始精度拉回正常水平。尤其在从 224 输入切到 32×32 输入时前几个 stage 的特征分布变化很大不重估 BN 会白白浪费很多调节学习率的精力。5.2 加载预训练权重时的 Shape 不匹配把 conv1 从 7×7 改成 3×3 后直接model.load_state_dict(checkpoint)会报 shape 不匹配因为 weight 的最后一维从 7 变成了 3。解决方案有两个自己写 load 函数遇到 shape 不匹配的 key 就跳过用 3.2 节提到的中心裁剪初始化让 3×3 卷积从预训练 7×7 里“继承”一部分参数。我更推荐中心裁剪因为实测下来在数据量只有几万张的时候随机初始化整个 conv1 会让早期训练 loss 掉得慢一些裁剪初始化则基本无损。实现时注意维度顺序PyTorch 卷积权重是(out_channels, in_channels, kH, kW)裁剪的是后两维。5.3 小分辨率下 Layer4 演变成信息瓶颈当输入降到 32×32即便去掉了 maxpool特征图到达 Layer4 时很可能已经变成 2×2 甚至 1×1。这时候 Layer4 的 Bottleneck 基本学不到什么空间结构反而白白增加参数量和计算量。我在实验里对比过两种做法保留 Layer4只去 Layer4 的 stride直接砍掉 Layer4把 Layer3 的输出接 GAP 和分类头。结果两者精度接近但砍掉 Layer4 的版本训练速度快约 20%显存也更省。所以在超小分辨率场景里不要执着于“完整 ResNet 结构”砍层是合理的。可以用下面的代码快速裁掉最后一个 stagedef trim_resnet_layers(model, keep_stages3): # keep_stages3 表示保留 layer1~layer3 layer_names [layer1, layer2, layer3, layer4] for name in layer_names[keep_stages:]: setattr(model, name, nn.Identity()) # 注意 avgpool 前输入通道数取决于最后保留的 stage if keep_stages 3: model.avgpool nn.AdaptiveAvgPool2d((1, 1)) model.fc nn.Linear(256, model.fc.out_features) # ResNet-18 的 layer3 输出是 256这个技巧对 16×16、24×24 这种超小图特别实用。很多时候一个“瘦身”后的 ResNet 比完整 ResNet 效果好得多。5.4 推理阶段的分辨率抖动与多尺度投票即使训练时用了多尺度线上推理时也难免遇到极端尺寸比如用户上传了 1000×800 的图模型没训练过这么大的输入。我的经验是在输入到模型之前先做一个“上下限截断”把长边限制在训练时见过的范围内不要毫无边界地 feed 给网络。如果对精度要求高可以再用多尺度投票做 TTA。做法很简单同一张图分别缩放到训练时见过的几个尺寸分别过模型对输出概率取平均。这个方法对分类任务稳定有效涨点通常在 0.5 到 1 个百分点左右。def multi_scale_inference(model, x, scales[32, 48, 64]): model.eval() logits [] with torch.no_grad(): for s in scales: x_s F.interpolate(x, size(s, s), modebilinear, align_cornersFalse) logits.append(F.softmax(model(x_s), dim1)) return torch.mean(torch.stack(logits), dim0)要注意的是多尺度推理时 BN 的 running_mean 用的是训练时统计值如果你训练时只见过 32×32推理时忽然丢进去 64×64个别层可能会有一点分布偏移。这也是我坚持训练阶段就做多尺度的原因。最后还是忍不住分享一个我踩过数次坑后的习惯任何改动做完先跑一张单图的前向和反向确认维度全部对齐再上完整训练。ResNet 的结构修改看起来只是几行代码但去掉一个 maxpool、改一个 stride连带着分类头输入维度和预训练权重加载都会变一次快速 smoke test 能帮你省下好几个小时的排错时间。

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

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

免费获取报价 →
↑