ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

做图像分类的朋友应该都遇到过这种需求:手里的图片是小分辨率正方形,比如 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 次 stride=2 的下采样:stem 的 7×7 卷积一次、maxpool 一次、Layer2/Layer3/Layer4 的首个 Bottleneck 各一次。合计下来是 2^5=32 倍下采样,224×224 的输入到最后变成 7×7 的特征图,这正是标准训练时的设计预期。

我整理了一张各阶段的特征图尺寸变化表,方便对着看:

阶段关键操作224×224 输入时的输出尺寸
Stem7×7 conv, stride 2112×112
MaxPool3×3 pool, stride 256×56
Layer1Bottleneck ×356×56
Layer2首个 Bottleneck stride 228×28
Layer3首个 Bottleneck stride 214×14
Layer4首个 Bottleneck stride 27×7
GAP全局平均池化1×1
FC2048 → 10001000

可以看出,从卷积层到分类层之间没有一处是与“固定长宽”强绑定的。所谓“ResNet 只能吃 224”,本质是这条 32 倍下采样链在遇到小分辨率输入时,会把最后阶段的特征图压得太狠,甚至压到只剩一个像素。

1.2 不同分辨率输入下的前向推演

为了更直观,我把 32×32、48×48、112×112、224×224、512×512 五种输入分别过一遍标准 ResNet-50 的下采样链,看看 Layer4 之后的特征图尺寸:

输入尺寸经过 Stem+MaxPool 后Layer2 后Layer3 后Layer4 后
32×328×84×42×21×1
48×4812×126×63×32×2
112×11228×2814×147×74×4
224×22456×5628×2814×147×7
512×512128×12864×6432×3216×16

32×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 stride=2 的卷积换成 3×3 stride=1,并去掉或弱化 maxpool,否则信息被压得太早。
  • 目标尺寸小于 32×32:属于超小分辨率,此时再硬套标准 ResNet-50 意义不大,我更建议砍掉最后一个 stage,或者干脆换一个更浅的骨干网络。

这个分档不是拍脑袋,而是基于一个朴素的直觉:下采样倍率应该和输入尺寸匹配。理想情况下,进入分类头之前的特征图至少要保留 4×4 以上的空间信息,也就是“总下采样倍数不要超过输入尺寸的四分之一”。比如 32×32 的输入,总下采样倍数最好小于等于 8 倍;224×224 的输入,32 倍下采样正好得到 7×7,依然合理。

2.2 决策表:各档位推荐的改法

下面是我自己习惯用的决策表,基本覆盖了大多数实际场景:

输入尺寸推荐下采样总倍数具体做法预训练权重兼容性
< 32×324 或 8去 maxpool,conv1 改 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 改 stride=1 + 空洞卷积完全兼容

这个表只是起点,实际项目还要考虑显存和训练时长。我的建议是先用表格里的方案跑一次 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,通常能拿到不错的基线。

代码上最简单的方式就是改 transforms:

import 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 逻辑内嵌到模型里,方便部署时少写一段预处理,可以包一层 wrapper:

import torch import torch.nn as nn import torch.nn.functional as F class AdaptiveInputWrapper(nn.Module): def __init__(self, backbone, target_size=224): 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), mode="bilinear", align_corners=False) return self.backbone(x)

这样做的好处是模型接受任意长宽输入,内部统一变成固定大小,外面调用起来很干净。缺点是如果原始输入是 32×32 这种小图,强行放大到 224×224 并不会补出细节,只是把模糊放大了。所以这个方案适合作为基线,但不适合小分辨率为主的场景。

3.2 保留小分辨率信息的 Stem 改造

当目标分辨率在 32×32 到 64×64 之间,我通常推荐改 stem。核心思路是降低前端的下采样倍率,让后面的 stage 还能保留足够的空间信息。具体两步:把 conv1 从 7×7 stride=2 改成 3×3 stride=1,把 maxpool 去掉。

import torch.nn as nn from torchvision.models import resnet18 def adapt_resnet_stem(model, input_size=32): # 输入过小时,把 conv1 从 7x7 stride2 换成 3x3 stride1 if input_size <= 64: model.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) model.maxpool = nn.Identity() elif input_size <= 112: # 中等分辨率下,保留 7x7 卷积,但去掉紧跟的 maxpool model.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) model.maxpool = nn.Identity() return model model = resnet18(pretrained=True) model = adapt_resnet_stem(model, input_size=32) 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:s+3, s:s+3])

还有一个更保守的改法:仍然保留 7×7 卷积,只是把 stride 改成 1,同时去掉 maxpool。这样权重完全兼容,不需要任何初始化技巧。计算量会稍微大一点,但在 32×32 输入上完全可接受,我在实验里发现效果和直接换 3×3 差不多,省心很多。如果要追求更彻底的小分辨率适配,还可以考虑把 Layer4 的下采样也去掉,做法如下:

def remove_stage_downsample(module, stage_name="layer3"): 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 里所有 stride=2 的卷积改成 stride=1。注意 ResNet 的残差里也有 stride=2 的卷积,这行代码会一并改掉,所以它处理的其实是“整个 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_size=1) def forward(self, x, return_feature=False): 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_stride=16): # 默认 ResNet 输出 stride=32 # 目标 stride=16 时,把 layer4 所有 stride=2 的卷积改成 stride=1 # 并对 layer4 的 3x3 卷积设置 dilation=2、padding=2 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 原本通过 stride=2 把特征图缩小一半,现在不收这一刀,特征图就大一倍;但 Conv 3×3 的感受野也相应变小了,所以用 dilation=2 把有效感受野撑回接近原来的大小。很多检测项目里加载 ImageNet 预训练权重后只用这么一段代码,就能把标准 ResNet 变成可用作多尺度检测的 backbone。

需要提醒的是,这个改法虽然能保留高分辨率,但计算量会比默认结构多不少,显存占用会明显上升。训练时建议从较小的 batch size 开始,或者配合梯度检查点使用。

4. 实测四个方案在不同输入尺寸下的精度速度账单

4.1 实验设置说明

我在一个约 15 万张的商品图片数据集上做了对比实验,类别数 25,训练集和验证集按 9:1 划分。骨干网络统一用 ResNet-18,优化器 AdamW,初始学习率 3e-4,batch size 128,微调 10 个 epoch,输入不做额外增强只做随机水平翻转。四个方案分别是:

  • A:原版模型,外部 Resize 到 224;
  • B:conv1 改 3×3 stride 1,去 maxpool,32×32 直接输入;
  • C:保留 7×7 stride 1,去 maxpool,32×32 直接输入;
  • D:全卷积化输出头,外部 Resize 到 224。

同时在 224×224 输入下测了不改造模型的精度作为对照。表格如下:

方案Test Top-1 (%)每 epoch 训练耗时(相对)备注
对照:原版 224×22493.11.0x标准场景
A:原版 + Resize 到 224,输入 32×3288.50.9x小图被放大,细节丢失
B:Stem 改造,直接输入 32×3292.30.7x保留原始空间信息
C:7×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_batches=200): 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 结构”,砍层是合理的。可以用下面的代码快速裁掉最后一个 stage:

def trim_resnet_layers(model, keep_stages=3): # keep_stages=3 表示保留 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), mode="bilinear", align_corners=False) logits.append(F.softmax(model(x_s), dim=1)) return torch.mean(torch.stack(logits), dim=0)

要注意的是,多尺度推理时 BN 的 running_mean 用的是训练时统计值,如果你训练时只见过 32×32,推理时忽然丢进去 64×64,个别层可能会有一点分布偏移。这也是我坚持训练阶段就做多尺度的原因。

最后还是忍不住分享一个我踩过数次坑后的习惯:任何改动做完,先跑一张单图的前向和反向,确认维度全部对齐,再上完整训练。ResNet 的结构修改看起来只是几行代码,但去掉一个 maxpool、改一个 stride,连带着分类头输入维度和预训练权重加载都会变,一次快速 smoke test 能帮你省下好几个小时的排错时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进