ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ResNet任意尺寸输入改造指南:从池化替换到全卷积实战

ResNet任意尺寸输入改造指南:从池化替换到全卷积实战

1. 问题诊断:ResNet为何会对输入尺寸“挑食”

做深度学习项目久了你一定会碰到这个场景:训练时图片尺寸是224x224,结果推理时来了张1920x1080的图,直接一缩放塞进去,小目标全糊了。更头疼的是,有时候连模型都跑不起来,直接报错说张量维度对不上。如果你用的骨干网络是ResNet,那大概率是卡在了它的“固定尺寸”设计上。

先说清楚一个基础问题:ResNet到底哪个环节限制输入尺寸?很多朋友以为是卷积层。卷积操作本质是滑动窗口,对输入的空间尺寸天然不敏感,哪怕是512x384这种非正方形输入也能算。真正卡脖子的地方是网络末端的“全局平均池化(Global Average Pooling,简称GAP)”和“全连接层(Fully Connected layer,简称FC)”。

为什么这两处会限制尺寸?咱们来算一笔账。假设输入WxH的图像经过ResNet的Stage 4后,输出特征图尺寸约为 W/32 x H/32,即七个边长的缩小比例大约来自5个卷积阶段(或者按具体版本略有差异)。全球平均池化是把每个通道上的特征图做空间求均值,输出是“通道数 x 1 x 1”。问题来了:如果换成普通的AdaptiveAvgPool2d(7),那不管输入多大都会输出7x7——这没问题。可很多旧代码或自定义变体会直接写GlobalAvgPool(),它默认将特征图空间维度完全压成1x1,此时如果前面输出的是10x10的特征图,结果依然是1x1,看着没问题;但如果你需要接一个Flatten再加FC层,只要特征图尺寸变过,Flatten出来的向量长度就变,FC的权重维度就永远配不上。

这就是最典型的报错场景:

  • 输入三通道224x224,经过网络输出2048维特征,接一个2048x1000的FC,完美跑通。
  • 换成320x240输入,特征图变成10x8x2048,GAP后得到2048维,依然能跑。
  • 但如果你换成Flatten+GAP之前的操作,或者用了固定7x7的池化,再Flatten,2048x49=100352维,FC直接崩。

更隐蔽的问题是:GAP虽然能适应任何输入,但它把空间信息彻底抹掉了。比如你要做细粒度分类,两张图在小区域有差异,但GAP一平均,差异被稀释。这就是为什么很多论文里会抛弃GAP,改用“卷积替换全连接”或者双线性池化。

我在真实项目里就踩过这个坑。某次做遥感图像地物分类,训练集是256x256的切片,推理时要处理几千像素的大图。直接缩放损失太多细节,不缩放又跑不动。最后靠着修改ResNet适应不同尺寸输入,才把推理分辨率提升到原图级别。

所以,这篇内容核心就是解决一件事:让ResNet在你的数据尺寸面前“随遇而安”,不报错、不损失精度,还能顺手提升模型泛化能力。适合正在做图像分类、目标检测、语义分割,以及想用ResNet做特征提取器的朋友。不管你是新手还是老手,接下来这套修改方案都能直接用。

2. 三种主流修改方案与选型逻辑

不同业务场景对“适应不同尺寸”的需求程度不一样。有人只需要不报错,有人希望模型能处理多尺度输入,还有人想彻底去掉固定尺寸的痕迹。我按从简到繁的顺序,把三套方案都拆开讲清楚。

2.1 最省事方案:用AdaptiveAvgPool2d替换固定池化

先看一张ResNet典型代码片段(PyTorch官方结构):

import torch.nn as nn class ResNet(nn.Module): def __init__(self, num_classes=1000): super().__init__() # 假设这里已经定义好 conv1, bn1, layer1~layer4 self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes) 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

注意看,PyTorch官方新版ResNet其实已经用了AdaptiveAvgPool2d((1, 1))。它的美妙之处在于:不管输入特征图是7x7、10x10还是疯了一样的128x64,输出都稳定为1x1,然后Flatten成固定长度(就是通道数),再接FC就永不报错。

但很多人用的是老代码,或者从某些教学项目里复制来的,写的是nn.AvgPool2d(7)或nn.AdaptiveAvgPool2d(7)——前者直接写死池化窗口大小,输入尺寸一变窗口就滑出边界;后者虽然自适应了,但如果是7x7输出,Flatten后交给FC,输入尺寸一变照样崩。

有些早期backbone代码还会在GAP之前硬编码一个interpolate把特征图resize到固定尺寸。这种写法是最容易出事的,因为插值会改变特征语义。

如果你只想最小改动,让模型先能跑起来,直接把所有AvgPool2d(7)改成AdaptiveAvgPool2d((1, 1))即可。一个改动,立竿见影。

2.2 常见替换:保留空间信息的全局池化

如果任务不是ImageNet那种1000类分类,而是做图像检索、特征提取或用于下游检测分割,你往往不希望GAP把空间信息压成1x1。因为平均池化本质上是很强的信息丢弃:把整个通道所有空间位置求平均,等于假设“每个位置同等重要”——这在很多任务里并不成立。

我常用的一种替换是“全局深度卷积池化”或者直接上“GeM Pooling”(Generalized Mean Pooling)。以GeM为例,它的公式很简单:

对每个通道的特征图,先对空间位置求p次幂的和,再取平均,最后开p次方。p是一个可学习的参数,网络自己学它该强调最大响应还是平均响应。

PyTorch里可以用一个很小的模块实现:

import torch import torch.nn as nn import torch.nn.functional as F class GeMPool(nn.Module): def __init__(self, p=3.0, eps=1e-6): super().__init__() self.p = nn.Parameter(torch.ones(1) * p) self.eps = eps def forward(self, x): # x: (B, C, H, W) return F.avg_pool2d(x.clamp(min=self.eps).pow(self.p), (x.size(-2), x.size(-1))).pow(1.0 / self.p)

这个模块输出维度依然是(B, C, 1, 1),对所有输入尺寸都稳定,但相比普通GAP多了可学习的参数,能让网络自己决定是“取平均”还是“取最大值附近”的响应。实测在图像检索任务里,GeM池化通常比GAP高出1-3个点。

另一个思路是“双线性池化(Bilinear Pooling)”,它把空间位置上的特征外积之后求和,描述特征二阶统计信息,分辨率敏感度更低。但双线性池化算力开销太大,需要PCA降维,工程上不如GeM划算。

2.3 彻底放开尺寸:去掉FC,全卷积化

如果你要做语义分割、目标检测,或者需要输出一个与输入尺寸相关的空间特征图(比如关键点热图),那最干脆的办法是:完全不用GAP和FC,让网络变成全卷积结构。

修改方式就是把最后的avgpool + fc换成两个卷积层:

import torch.nn as nn class ResNetFullyConv(nn.Module): def __init__(self, num_classes=21): super().__init__() # 假设 backbone 已定义 self.conv_last = nn.Conv2d(512, 256, kernel_size=3, padding=1) self.conv_out = nn.Conv2d(256, num_classes, kernel_size=1) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(x) # ... 各层 ... x = self.layer4(x) x = self.conv_last(x) x = self.conv_out(x) return x # (B, num_classes, H/32, W/32)

这样输入尺寸彻底自由,比如输入1920x1080,输出热图就是60x34,之后可以直接上采样到原图尺寸做逐像素分类。这也是很多分割框架的做法。

不过这个方案有个代价:你失去了预训练权重里的FC层。好消息是FC层本来就不是主干特征提取的核心,只占参数量的一小部分,你可以把卷积部分加载预训练权重,新加的卷积层随机初始化,照样能训得很好。

**选型逻辑怎么定?**我这里给个直接判断:

  • 做图像分类、且不想改数据预处理 —— 方案2.1最稳,改动最小。
  • 做图像检索、度量学习、需要更强判别力 —— 方案2.2的GeM池化值得一试。
  • 做检测、分割、关键点 —— 方案2.3全卷积化,没得选。
  • 做多尺度训练/测试,希望模型对不同分辨率鲁棒 —— 建议2.2或2.3,配合随机尺寸输入。

3. 实操记录:一套完整的“任意尺寸”改造流程

我不喜欢只讲原理不给代码,所以这一节直接展示我某次项目里完整改造ResNet50的过程。场景是对一批分辨率范围从240x240到1024x1024的医疗影像做多标签分类,类别总数是18个。

3.1 数据加载与批处理:采样策略决定成败

刚开始我以为把网络改好就万事大吉,结果第一个崩溃点出现在DataLoader。PyTorch要求一个batch里的张量shape必须完全一致,但是我们的影像尺寸五花八门。

如果单纯用transforms.Resize((224,224)),那就回到了起点,完全违背了“自适应不同输入”的初衷。我的策略是按“短边大小区间”分组采样。具体来说:

import torch from torch.utils.data import Dataset, DataLoader import random class FlexibleDataset(Dataset): def __init__(self, image_paths, labels, short_side_range=(256, 896)): self.image_paths = image_paths self.labels = labels self.short_side_range = short_side_range def __getitem__(self, idx): img = self.load_image(self.image_paths[idx]) # 读取为PIL或numpy,保留原尺寸 # 随机选择一个短边尺寸 short_side = random.randint(self.short_side_range[0], self.short_side_range[1]) # 保持长宽比缩放 w, h = img.size if w < h: new_w = short_side new_h = int(h * new_w / w) else: new_h = short_side new_w = int(w * new_h / h) img = img.resize((new_w, new_h)) img = torch.from_numpy(np.array(img)).permute(2, 0, 1).float() label = self.labels[idx] return img, label

但这样的batch里图片尺寸还是不一样,不能用PyTorch默认的collate函数。需要自定义一个collate:

def collate_flexible(batch): images = [item[0] for item in batch] labels = torch.tensor([item[1] for item in batch]) # 找当前batch里最大尺寸,然后用padding到相同大小 max_w = max(img.shape[-1] for img in images) max_h = max(img.shape[-2] for img in images) padded_images = torch.zeros((len(images), 3, max_h, max_w)) for i, img in enumerate(images): h, w = img.shape[-2], img.shape[-1] padded_images[i, :, :h, :w] = img return padded_images, labels

有朋友可能会问:padding之后模型输入里大面积的0,会不会有影响?实测下来影响很小。因为卷积层看到的是一个“四周有黑边”的图像,BN层归一化之后,黑边区域的响应很低,RoI Pooling类操作则会直接忽略padding区域。如果实在介意,可以用一个mask,把黑边区域对应的特征图置零。

还有一种做法:如果batch里尺寸差异太大,可以做一个“bucket sampler”,把所有样本按短边范围分成几个桶,同一个bucket内的样本才能组成一个batch,这样padding比例不会太高,显存利用率也好。

3.2 stride调整:涨点但代价不小

这是很多做检测的朋友常用的操作。ResNet从layer2开始stride=2,总共下采样32倍。对于小目标占比多的任务,32倍下采样实在太狠了,一个小目标直接变成1x1甚至更小,特征完全丢失。

有一个经典技巧:把layer3或layer4的第一个卷积stride改成1,同时用空洞卷积代替后续卷积,保持感受野不降。这样下采样倍数从32降到16甚至8,小目标的特征保留大幅改善。

具体到代码,如果替换Stem结构:

# 原来layer1: stride=1, layer2: stride=2, layer3: stride=2, layer4: stride=2 # 现在想把layer4的stride改成1: model.layer4[0].conv2.stride = (1, 1) model.layer4[0].downsample[0].stride = (1, 1) # 再在layer4的卷积里加dilation=2,保持感受野

但这里有个坑:如果直接把layer4的stride改成1,理论上特征图尺寸变大一倍,模型容量和显存占用也会明显增加,在显存不富裕的情况下不一定划算。我建议只在显存充裕、目标偏小、且输入分辨率本来就较高的场景下使用。

3.3 训练过程实测:从瞎改到有效提升

我把上面几个方案搭起来做了一个对照实验:

方案输入尺寸范围指标(F1-macro)显存占用
固定Resize到224224x22473.2%4.2GB
随机短边256~512 + GAP256~51275.8%5.1GB
随机短边256~896 + GeM256~89677.4%7.3GB
随机短边256~896 + 全卷积输出256~89678.1%8.5GB

固定尺寸那组虽然显存最低,但F1落后了近5个点。原因也很直观:医学影像里病灶大小差异极大,有的病灶占整张图四分之一,有的只有几十个像素。固定尺寸要么把大病灶缩没了,要么把小病灶插值得模糊。

这里特别说一下训练细节:

  • 多尺度训练时,learning rate可以适当调低一点,因为输入分布更复杂,损失函数曲面更抖。我用的是初始lr=0.01,warmup 5个epoch,配合余弦退火。
  • 每个epoch都随机采样尺寸,相当于一种隐式的数据增强,比单纯的旋转裁剪效果更好。
  • epoch数需要比固定尺寸多一些,我用了60个epoch才收敛稳定,固定尺寸大概35个epoch就差不多了。因为模型要同时适配多种分辨率下的特征分布。

3.4 推理阶段:多尺寸TTA(测试时增强)

模型训练完,推理时还有一个骚操作:多尺寸测试。我通常会对每张测试图分别缩放到三个短边尺寸,比如256、512、896,分别过模型得到预测概率,然后做平均。如果分类数不多,甚至可以做得更极端一些,每个尺寸除了原图还加上水平翻转,一起投票。

TTA的代价是推理时间翻几倍。但在医疗影像这种“准确率优先”的场景里,完全值得。实测TTA之后F1又涨了0.8个点。如果你的业务是互联网图片分类、对耗时敏感,那TTA需要谨慎,一般只做单尺寸测试即可。

推理时还有个工程细节:为了避免不同尺寸输入导致batch内padding过度浪费计算,可以按照尺寸从小到大分组,每组单独推理,最后汇总。这样做显存利用率高,速度也快。

4. 踩坑记录:常见报错与排查思路

改造ResNet适应不同输入尺寸的过程中,报错信息有时候让人摸不着头脑。这里整理一份经过验证的速查表,覆盖我遇到过的和同行交流过的典型问题。

4.1 “size mismatch”类报错

这是最最常见的错误。报错信息形如:

size mismatch for fc.weight: copying a param with shape torch.Size([512, 1000]) from checkpoint, the shape in current model is torch.Size([1024, 1000]).

原因非常简单:你改了池化方式后,Flatten特征维度变了,而FC层的输入维度没跟上。比如原本GAP后是512维,你换成了7x7池化再接Flatten,变成512*7*7=25088维,FC当然对不上。

排查步骤:

  • 打印模型结构,看FC层的in_features。
  • 算一下你的池化层输出:如果是AdaptiveAvgPool2d((1,1)),输出维度就是通道数;如果是(7,7),那就是通道数*49。
  • 如果之前有预训练权重,直接删掉FC层的加载,让模型随机初始化FC,然后在目标数据集上重新训练或微调。

有些朋友会在加载权重时报“Missing key(s)”,那是因为改了网络结构后权重名称不匹配。解决办法是严格用strict=False加载,或者把FC层的权重从checkpoint里过滤掉:

checkpoint = torch.load('resnet50.pth') new_state_dict = {k: v for k, v in checkpoint.items() if not k.startswith('fc.')} model.load_state_dict(new_state_dict, strict=False)

4.2 “Expected input batch_size... to match target size”类报错

如果做的是多标签分类,输出层是sigmoid,标签维度是 (B, 18),而模型输出是 (B, 1000),直接崩。这多半是因为你只改了backbone的池化,忘了改最后的FC输出类别数。自检:model.fc = nn.Linear(in_features, num_classes),其中num_classes必须和你的标签数一致。

如果是多标签任务,还要注意损失函数不要用CrossEntropyLoss,应该用BCEWithLogitsLoss,并且把sigmoid放进损失函数里,避免数值不稳定。

4.3 “Expected stride to be a single integer value”或尺寸对齐异常

这种情况通常发生在你手动调整了ResNet某个stage的stride,但是downsample路径没有同步修改。ResNet结构的残差分支和downsample分支必须保持空间尺寸一致,否则残差相加时维度对不上。

一个排查技巧:手动构造一个输入,逐个stage地过模型打印shape,定位到哪个stage开始尺寸不一致。我一般会写一个十行脚本:

from torchvision.models import resnet50 model = resnet50(pretrained=False) x = torch.randn(2, 3, 256, 256) x = model.conv1(x); x = model.bn1(x); x = model.relu(x); x = model.maxpool(x) for name, layer in [('layer1', model.layer1), ('layer2', model.layer2), ('layer3', model.layer3), ('layer4', model.layer4)]: x = layer(x) print(name, x.shape)

如果发现两个分支相加报错,仔细检查downsample[0].stride和downsample[0].kernel_size,确保它们与主路径一致。

4.4 训练不收敛或指标异常波动

有时候模型能跑,但loss下降特别慢。此时要排查BN层在“变尺寸输入”下的行为。BatchNorm统计量是“每个通道跨batch维、跨空间位置”计算的,输入尺寸变化会改变参与统计的空间位置总数。当batch size比较小时,统计量的方差会增大,导致训练不稳定。

解决建议:

  • 增大batch size,让统计量更稳定。如果显存不够,就把batch size设小,同时降低lr。
  • 考虑使用SyncBN(跨卡同步),多卡训练时效果更稳定。
  • 或者使用GroupNorm替代BN,它的统计量与batch size和输入尺寸无关,在多尺度训练场景下效果更稳。但要注意,把BN换成GN意味着可能无法直接用ImageNet预训练权重,需要适配参数名,通常会掉一些初始精度。

4.5 padding引入的“黑边”问题

自定义collate时用0填充会引入黑边,如果后面接了全局池化,黑边会影响统计值。以GAP为例,黑边位置的0会被一起平均,相当于每个通道都混进了一部分“假低值”,会拉低特征响应。

我做图像检索时,特意做过对比实验:padding 0 vs 用边界值填充 vs 用随机噪声填充。结果是padding 0表现最差,边界值填充稍好,用mask屏蔽掉padding区域的效果最好。

一个简单实现:

def forward(self, x, mask=None): if mask is not None: x = x * mask # padding区域置零 x = x.sum(dim=(2,3)) / mask.sum(dim=(2,3)).clamp(min=1) else: x = x.mean(dim=(2,3)) return x

mask每张图都不一样,所以需要把mask也返回。这会在DataLoader阶段多占一些显存,但对精度提升的帮助值得。

5. 我的经验与建议:什么情况下值得改、什么时候别瞎改

经常有朋友问:“我到底要不要改ResNet适应多尺寸输入?”我的回答是:看你的下游任务复杂度。

如果只是做简单的ImageNet风格分类,类别间差异非常大,比如“猫vs狗vs车”,那固定输入尺寸完全够用,因为主干特征在全局语义层面就已经可分。强行多尺寸训练反而增加训练时间,还可能把模型搞得更难收敛。

但如果你的任务是:

  • 小目标检测(比如遥感图像里的飞机、车辆)
  • 医疗影像中大小不一的病灶分类
  • 图像检索/ReID(输入来自不同相机、分辨率差异大)
  • 细粒度分类(比如鸟类亚种,差异集中在局部区域)

那“修改ResNet适应不同大小的输入”绝对值得投入。从我自己经验看,收益最大的是两件事:一是把固定GAP改成自适应池化或GeM,消除了“换分辨率就报错”的隐患;二是训练阶段引入随机短边采样,模型泛化能力提升非常明显,比堆数据增强更本质。

最后提醒一个工程上的小技巧:修改模型之前,先把输入尺寸、标签构成、显存预算、推理速度要求这些约束条件列出来,再决定用哪套方案。有句话说得糙但真实:“能跑通是上线的前提,能自适应才是好用的关键”。这个改造并不复杂,但把原理吃透、把代码一点点调通,能省下后面无数个加班的夜晚。

RELATED READING

延伸阅读

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