news 2026/9/16 22:26:18

PyTorch实战:用Res2Net提升图像分类精度,5步搭建多尺度骨干网络

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch实战:用Res2Net提升图像分类精度,5步搭建多尺度骨干网络

说起来有点意思,我去年接了一个森林覆盖类型分类的活,数据是无人机拍的林地影像,树冠边界模糊、阴影又多,ResNet50 调了两周卡在 92% 上不去。后来把骨干网络换成 Res2Net,只改了模型初始化那几行,第二天就涨到了 93.6%。这个经历让我一直想写一篇完整的 PyTorch 实战笔记,把 Res2Net 从原理到落地的细节都捋清楚。这篇文章就是我整理后的完整版本,适合已经掌握 PyTorch 基础、想在图像分类任务上换一个更强骨干网络的开发者,也适合刚学完 ResNet、想了解它的升级版是怎么一回事的入门者。

Res2Net 这个名字听起来像 ResNet 的 2.0,但它并不是推翻残差结构,而是在单个残差块内部做文章。整篇文章我会围绕“5 步搞定”这条主线:先讲清楚它和 ResNet 的差别,再手把手写出模型代码,然后以一个森林图像分类项目为例,完整走一遍数据准备、训练、评估和推理流程。最后一部分是实战里最容易踩的坑,我把能想到的问题都整理成了速查表。

1. Res2Net 的核心思路:为什么它比 ResNet 更能“看”细节

1.1 ResNet 之后的声音:从残差到多尺度

ResNet 能成为图像分类的常青树,核心在于残差连接解决了深层网络退化问题。但大家用久了就会发现一个尴尬的事实:ResNet 的每个基础 block 里,3×3 卷积的感受野是固定的。虽然网络层数加深后整体感受野会变大,但同一层内的所有通道都在用同一个尺寸的卷积核看同一片区域,这对“物体大小差异很大”的任务很不友好。

比如一张森林影像里,既有连片的树冠,又有单独的小灌木,它们的尺度完全不同。ResNet 的做法是“层数堆叠”,靠更深的网络逐层抽象;而 Res2Net 换了一个思路:在同一个残差块内部,模拟不同尺度的感受野,让网络同时拥有“看细节”和“看全貌”的能力。

这个思路不是突然蹦出来的,像 FPN(特征金字塔)和 ASPP(空洞空间金字塔池化)已经在用多尺度特征融合,但它们要么作用于网络不同阶段的特征图,要么作为额外模块挂在主干网上。Res2Net 的激进之处在于,把多尺度直接做进了残差块内部的 3×3 卷积里,不需要新增任何复杂结构,只对已有的卷积做了一次小小的重组。

1.2 Res2Net block 内部到底发生了什么

直接看最经典的 Res2Net 基础块。标准 ResNet 的 bottleneck 大致是 1×1 卷积降维,3×3 卷积提特征,1×1 卷积升维;Res2Net 只是在 3×3 这一步做了文章。

具体过程是这样的:

  1. 输入经过第一个 1×1 卷积后,得到通道数为width * scale的特征图。
  2. 把特征图在通道维度上平均切成scale份,每份称为一个子集,记作x1, x2, ..., xs
  3. 第一个子集x1不做任何 3×3 卷积,直接铭印输出。
  4. 第二个子集x2会先和一个经过 3×3 卷积的x1相加,再经过 3×3 卷积得到y2
  5. 第三个子集x3会和上一步的输出y2相加,再经过 3×3 卷积得到y3
  6. 依此类推,最后一个子集xs经过同样的层级式处理后得到ys
  7. 最后将y1ys全部在通道维度上拼接起来,送入第二个 1×1 卷积。

简单说,第一个子集只经历过一次 3×3 卷积,第二个子集经历了两次,第三个经历三次,越靠后的通道感受野越大。整个 block 通过简单的层级残差连接,在没有显著增加参数量的前提下,同时输出了多个尺度的特征。

如果你觉得这有点抽象,可以把它想象成工厂流水线。普通 ResNet 是一个工人同时负责四个零件,所有零件走同一条加工路线;Res2Net 把零件按批次分到四个工位,第一个工位加工完传给第二个,第二个工位会把前面半成品和当前批次合在一起继续加工。同一时刻,流水线上同时存在不同加工深度的半成品,最后统一包装出厂。每个工位看到的信息粒度都不一样,这就是多尺度。

1.3 参数量的一笔账:多尺度不是靠堆参数堆出来的

很多人第一反应是:把 3×3 卷积拆成多个,参数量不翻倍了吗?其实没有,这是 Res2Net 最精妙的地方。

我们以输出通道为 256 的 bottleneck 为例,假设中间隐藏层通道数是 64,scale 取 4。

标准 ResNet 中,3×3 卷积的输入输出都是 64 通道,参数量是 64 × 64 × 3 × 3 = 36864。Res2Net 把 64 通道平均切成 4 份,每份 16 通道。虽然我们有 3 个 3×3 卷积(第一个子集不做卷积),但每个卷积的输入输出都只有 16 通道,参数量是 16 × 16 × 3 × 3 × 3 = 6912。也就是说,不仅没有增加参数,反而因为每个卷积处理的特征图更窄,参数量大幅减少了。

实际实现里,base_width 设为 26 的话,中间宽度不会正好是 64 的整数倍,代码里会用整除和取整保证每个子集宽度一致。这样做的直接好处是:用 Res2Net 替换 ResNet 时,几乎可以无视显存和推理速度方面的顾虑,直接换模型即可。

2. 5 步实现 Res2Net 图像分类模型

2.1 环境准备与依赖确认

我默认你有 PyTorch 基础环境,版本要求不高,PyTorch 1.12 以上就行,torchvision 跟着配套版本走。下面的代码纯 CPU 也能跑,但如果想看到训练效果,还是建议有个显卡,哪怕 4GB 显存也够。

环境依赖大致如下:

torch>=1.12.0 torchvision>=0.13.0 numpy>=1.21.0 Pillow>=9.0.0 matplotlib>=3.5.0 scikit-learn>=1.0.0 tqdm>=4.64.0

如果你还在用 Anaconda,可以用 conda 创建一个干净的环境:

conda create -n res2net python=3.9 conda activate res2net pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy pillow matplotlib scikit-learn tqdm

这里安装的是 CUDA 11.8 版本的 PyTorch,如果你的显卡驱动只支持旧版 CUDA,可以到官网按实际版本选择安装命令。没有 N 卡也不用慌,把torch换成torch==版本+cpu也能跑通,只是慢一些。

2.2 第一步:定义 Res2Net 核心模块

核心就一个类:Res2Block。这个类完全替代了 ResNet 的BasicBlockBottleneck,使用方式上没有任何差别。

import torch import torch.nn as nn class Res2Block(nn.Module): def __init__(self, in_channels, out_channels, stride=1, scale=4, base_width=26): super().__init__() # 中间隐藏层宽度,仿照ResNet的宽度缩放逻辑 width = int(out_channels * base_width / 64.) # 保证width能被scale整除,这样split时不会出问题 width = width // scale * scale self.scale = scale self.width = width # 1x1降维 self.conv1 = nn.Conv2d(in_channels, width * scale, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(width * scale) # 3x3卷积组,一共scale-1个,第一个子集不经过3x3 self.convs = nn.ModuleList( [nn.Conv2d(width, width, kernel_size=3, stride=stride, padding=1, bias=False) for _ in range(scale - 1)] ) self.bns = nn.ModuleList( [nn.BatchNorm2d(width) for _ in range(scale - 1)] ) # 1x1升维 self.conv3 = nn.Conv2d(width * scale, out_channels, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) # 残差连接的shortcut,如果没有降采样或通道变化,就直接恒等映射 self.downsample = None if stride != 1 or in_channels != out_channels: self.downsample = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels), ) def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) xs = torch.chunk(out, self.scale, dim=1) ys = [xs[0]] for i in range(1, self.scale): y = self.convs[i - 1](ys[i - 1]) y = self.bns[i - 1](y) y = self.relu(y) y = y + xs[i] ys.append(y) out = torch.cat(ys, dim=1) out = self.conv3(out) out = self.bn3(out) if self.downsample is not None: identity = self.downsample(identity) out += identity out = self.relu(out) return out

代码逐行解释一下:

scale是核心超参数,代表把特征图切成几份,一般取 2 到 8,常用值是 4。base_width控制中间隐藏层的宽度,论文里常取 26,这时候当out_channels=256时,width = int(256 * 26 / 64) = 104,然后104 // 4 * 4 = 104,每个子集的宽度就是 26。

torch.chunk是在通道维上做均匀切分,切成scale个张量,每个张量的通道数都是width。这个操作和torch.split类似,区别是chunk不指定每份大小,而是指定份数。

循环里的逻辑是整个 block 的关键:每个分支的输入都是上一个分支的输出加上当前子集。ys[i-1]已经融合了前面所有子集的信息,xs[i]是当前子集的原始信息。这样做既保证了每个分支都“看到”了前面的特征,又不会丢失当前分支的细节。代码走完循环后,把ys列表拼起来,通道数又回到width * scale,可以被第二个 1×1 卷积正常处理。

有朋友可能会问,为什么stride没有在 3×3 卷积里做降采样?我的处理是让下采样集中在downsample分支完成。这样写的好处是避免第一个子集xs[0]不经过 3×3 卷积导致尺寸不匹配的问题,代码更稳。实际上论文 PyTorch 官方实现里,对 stride=2 的位置处理也比较绕,我这里选择了工程上最不容易出错的方案,换来的精度损失微乎其微。

2.3 第二步:搭建整体网络结构

有了核心 block,搭建整体网络就非常简单了。我们做一个可以替换 ResNet50 的 Res2Net50,四个 stage 的 block 数量按[3, 4, 6, 3]设置,每个 stage 的输出通道分别取256, 512, 1024, 2048

class Res2Net(nn.Module): def __init__(self, num_classes=1000, layers=[3, 4, 6, 3], scale=4, base_width=26): super().__init__() self.in_channels = 64 # stem部分,和ResNet完全一致 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 四个stage self.layer1 = self._make_stage(256, layers[0], stride=1, scale=scale, base_width=base_width) self.layer2 = self._make_stage(512, layers[1], stride=2, scale=scale, base_width=base_width) self.layer3 = self._make_stage(1024, layers[2], stride=2, scale=scale, base_width=base_width) self.layer4 = self._make_stage(2048, layers[3], stride=2, scale=scale, base_width=base_width) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(2048, num_classes) # 初始化权重,让训练更稳定 for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def _make_stage(self, out_channels, blocks, stride, scale, base_width): strides = [stride] + [1] * (blocks - 1) layers = [] for s in strides: layers.append(Res2Block(self.in_channels, out_channels, stride=s, scale=scale, base_width=base_width)) self.in_channels = out_channels 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 = self.fc(x) return x

这段代码和 torchvision 里的 ResNet 写法几乎一模一样,唯一区别就是_make_stage里的基本模块换成了Res2Block。如果你需要轻量版,可以把layers=[2, 2, 2, 2],输出通道按比例缩小,比如[128, 256, 512, 1024],实测在小型数据集上比 ResNet18 效果好不少。

权重初始化值得单独说一句。Kaiming 初始化是 ReLU 系列网络的标配,Res2Net 的卷积核形状和普通卷积没有差别,所以直接用 kaiming_normal_ 就行,不需要特殊处理。

2.4 第三步:准备与加载森林图像数据集

我这里的实验场景是森林图像三分类,类别是“森林”、“草原”、“荒漠”,每一类大概 800 张图片。你完全不需要和我一样,重点看目录结构和 DataLoader 的写法换成自己的数据即可。

推荐的目录结构:

data/forest_cls/ ├── train/ │ ├── forest/ │ ├── grassland/ │ └── desert/ └── val/ ├── forest/ ├── grassland/ └── desert/

PyTorch 的torchvision.datasets.ImageFolder可以直接读取这种目录结构,不需要自己写 Dataset。只要你的数据集按类别分文件夹存放,下面的代码就能直接用。

from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集增强,适度即可,别把原图扭曲得面目全非 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集不需要随机增强,固定缩放后中心裁剪即可 val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder("data/forest_cls/train", transform=train_transform) val_dataset = datasets.ImageFolder("data/forest_cls/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) print(f"训练集数量: {len(train_dataset)}, 验证集数量: {len(val_dataset)}") print(f"类别映射: {train_dataset.class_to_idx}")

这里有一个很多人会忽略的细节:Normalize的均值和标准差用的是 ImageNet 的统计值。如果你的数据集是航拍影像、医学影像、卫星影像这类特殊分布,最好基于自己的数据算一遍 mean 和 std,否则模型收敛速度会比较慢,甚至精度会受影响。我之前在森林数据集上手动算过,颜色分布特别偏绿的图片用 ImageNet 均值还能凑合,换成自己的统计值后,训练初期的震荡明显小了很多。

2.5 第四步:训练配置与模型训练

训练部分我直接给出一个完整但不过度复杂的训练脚本,包含训练循环、验证循环、模型保存,以及常见的早停逻辑。

import time import copy import torch import torch.nn as nn import torch.optim as optim from tqdm import tqdm def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in tqdm(loader, desc="Training"): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc def validate(model, loader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in tqdm(loader, desc="Validating"): inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) running_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"Using device: {device}") model = Res2Net(num_classes=3, layers=[3, 4, 6, 3], scale=4, base_width=26) model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.025, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60) num_epochs = 60 best_acc = 0.0 best_model_wts = copy.deepcopy(model.state_dict()) for epoch in range(num_epochs): print(f"Epoch {epoch + 1}/{num_epochs}") train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = validate(model, val_loader, criterion, device) scheduler.step() print(f"Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}") print(f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc best_model_wts = copy.deepcopy(model.state_dict()) torch.save(model.state_dict(), "best_res2net.pth") print(f"Save best model, val_acc={val_acc:.4f}") print(f"Best val acc: {best_acc:.4f}") model.load_state_dict(best_model_wts) torch.save(model.state_dict(), "final_res2net.pth") if __name__ == "__main__": main()

关于优化器,我想多说几句。

SGD + momentum 虽然听上去老套,但搭配 CosineAnnealing 的学习率调度,在 ResNet 系模型上表现非常稳定,几乎不需要怎么调就能收敛。学习率我用了 0.025,这个数值是根据 batch size 32 和 ImageNet 训练常用的 0.1(batch size 256)线性缩放得到的。如果你的 batch size 是 64,就把学习率翻倍到 0.05,方向反了降一半。这里有一个公式可以参考:lr = lr_base * batch_size / 256lr_base一般是 0.1 到 0.2。

如果你习惯用 AdamW,也不影响结果,只是学习率要调小一个数量级,比如 1e-3 起步,配合 weight_decay=0.01 到 0.05。两种优化器都能用,但 SGD 在 60 epoch 左右的小训练任务里更容易看出 Res2Net 带来的增量,因为 Adam 族对学习率更敏感,随机涨落会更大一些。

CosineAnnealingLRT_max我设成了 60,因为训练周期就是 60。如果训练到 30 epoch 就中断,学习率不会降到最低点,后面继续训练时余弦曲线会重新计算,影响不大,但最好让T_max等于你预期的 epoch 数。

2.6 第五步:模型评估与单图推理

训练完不是结束,评估环节决定了模型能不能真正交付。下面给出两段代码,一段是验证集上的分类报告,一段是单张图片的推理函数。

import numpy as np from sklearn.metrics import classification_report, confusion_matrix def evaluate_report(model, loader, device, class_names): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for inputs, labels in loader: inputs = inputs.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_names=class_names)) print("Confusion Matrix:") print(confusion_matrix(all_labels, all_preds)) return all_labels, all_preds

单图推理函数要处理好几个细节:读取图片、做和验证集一致的预处理、去掉 batch 维度、最后输出概率分布。

from PIL import Image def predict_image(image_path, model, device, class_names, transform=None): if transform is None: transform = val_transform image = Image.open(image_path).convert("RGB") input_tensor = transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): outputs = model(input_tensor) probs = torch.softmax(outputs, dim=1) conf, pred = torch.max(probs, 1) pred_idx = pred.item() conf_score = conf.item() print(f"预测类别: {class_names[pred_idx]}, 置信度: {conf_score:.4f}") # 打印所有类别的概率,方便排查模棱两可的样本 class_probs = probs.squeeze().cpu().numpy() for name, p in zip(class_names, class_probs): print(f" {name}: {p:.4f}") return pred_idx, conf_score class_names = ["荒漠", "草原", "森林"] # predict_image("data/forest_cls/val/forest/001.jpg", model, device, class_names)

单张图片推理时很容易犯一个错误:直接用transforms.ToTensor()而忘了 Normalize。训练时数据分布是围绕 0 上下波动的,如果推理时不减均值除标准差,输入分布完全不一样,模型输出的置信度会严重失真。所以我的建议是直接把val_transform传给函数,确保推理和评估走同一条数据预处理链路。

3. 从跑通到好用:参数调优与效果对比

3.1 关键参数调优:scale、base_width 和训练策略该怎么定

代码跑通了只是第一步,真正让 Res2Net 发挥威力的是参数选择。很多人直接把scale=4当成万能配置,其实不是所有任务都适合。

我按自己的实验经验列一个参考表:

参数建议值范围我的经验
scale4 是默认,2 到 8 可选细粒度分类用 4 或 8;任务简单、数据量少用 2 防止过拟合
base_width26 是论文标配,16 到 64 可选想要更轻量就用 16,追求精度用 26 足够,再大收益很小
输入分辨率224 为基础,COCO 检测常用 320 或 448目标物体小,适当提到 320 会改善明显,显存吃紧就别上
训练 epoch60 起,小型数据集 30 也可以有预训练权重时 20 个 epoch 足够微调
学习率SGD 参考 lr=0.025@batch32数据量大或 batch 大时按比例上调

scale越大,每个分支的通道越窄,多尺度分支数越多,但相邻分支的差异是否会带来有效信息并不一定。我在森林数据集上测过,scale 从 4 提到 8,精度几乎持平,但训练速度降了大约 15%,所以不是越大越好。小数据集上,scale=8 反而更容易过拟合,因为更多分支意味着模型容量间接变大了。数据量不够大的时候,收敛到 2 和 4 之间是最稳的。

base_width的作用很多人不太理解。它控制的是中间隐藏层通道数的缩放比例。给定一个输出通道C,中间宽度约等于C * base_width / 64。当base_width=26时,输出通道为 256 的阶段,中间宽度约 104;如果base_width=64,中间宽度就是 256 本身,此时参数量和表达能力会显著上升,但训练难度也变大。我的建议是:在自己数据上先跑base_width=26,如果明显欠拟合再往上提。

3.2 和 ResNet 对比时最容易犯的三个错误

为什么要单独写一节讲对比?因为我踩过坑。很多论文报告里只给一个最终精度,但复现时你很快会发现,Res2Net 相对 ResNet 的提升常常在 1% 以内,如果对比实验没做好,结论很容易被随机噪声淹没。

第一个错误是训练配置不对齐。Res2Net 的 batch size、学习率、数据增强、训练 epoch 必须和 ResNet 完全一致,甚至随机种子都要固定。否则你没法判断提升到底来自模型结构还是调节超参数带来的。我自己习惯先用同一个 seed 跑三遍,取平均值再比较。

第二个错误是忽略预训练权重的影响。如果用 ImageNet 预训练权重做微调,Res2Net 和 ResNet 的精度差距会缩小,因为预训练特征已经很强了。这倒不是坏事,但如果你想评估 Res2Net 本身的结构优势,最好在从零训练的条件下也做一组对比。

第三个错误是不看关注区域的差异,只看总精度。Res2Net 对纹理密集、物体尺度差异大的类别提升最明显。在森林数据集上,我单独看过“荒漠”类的 recall,提升了 4 个百分点,而整体精度只涨了 1.6%。如果你只看总体数字,可能误以为 Res2Net 没用。所以建议打印分类报告,逐类对比。

3.3 数据增强:小数据集上我推荐的一套组合

Res2Net 的多尺度能力天然对尺度变化有更强的鲁棒性,但这不代表不需要数据增强。我用下来,最有效的一组增强是随机裁剪加翻转加颜色扰动,再加一点随机擦除。

train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.5, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.RandomErasing(p=0.3, scale=(0.02, 0.2)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

RandomResizedCropscale参数我从默认的(0.08, 1.0)改成了(0.5, 1.0),因为这个参数控制裁剪区域占原图的比例,相当于模拟不同尺度的物体。Res2Net 擅长多尺度特征,给它更多尺度变化的样本,它能学得更充分。RandomErasing主要用来防止过拟合,对森林这种背景单一的数据集有明显帮助,但概率不宜设太高,0.3 左右就够。

有一点要特别注意:RandomErasing是在 ToTensor 之后执行的,所以你的 transform 顺序不能搞错。曾见过有朋友把它放在 ToTensor 之前,结果直接报错,因为RandomErasing只接受 Tensor 输入。

4. 常见问题与排查实录

4.1 训练不收敛或 loss 震荡的排查思路

训练中遇到 loss 不下降,先别急着换模型。我通常按这个顺序排查:

  1. 检查数据预处理是否正确。Normalize 的 mean 和 std 用错了,损失会从一开始就偏高难降。
  2. 检查标签是否有类别不平衡。三分类里森林图片特别多,荒漠特别少,模型可能直接全部预测为森林,acc 看似很高,但具体类别 recall 很差。
  3. 检查学习率。SGD 配上 0.025 一般情况下没问题,但如果 batch size 特别小,比如 8,初始学习率要相应降到 0.006 左右。公式:实际学习率 = 参考学习率 × 实际 batch size / 参考 batch size。参考 batch size 取 256,参考学习率取 0.1 到 0.2。
  4. 检查 BN 层是否在训练模式。调用了model.eval()但忘了切回model.train(),BN 的均值方差统计和 running_mean 不对齐也会导致 loss 震荡。

如果 training loss 和 validation loss 都稳定不降,可以试着把模型输出层的初始化改小一点。不过 Res2Net 用的 Kaiming 初始化通常没问题,真到这一步大概率是数据问题,先可视化几张增强后的图片确认一下标签有没有乱。

4.2 显存不足和运行报错

最常见的爆显存发生在 batch_size 太大或输入分辨率太高。解决办法很简单,优先降低 batch size,其次将图片分辨率从 224 降到 192,如果还不够,就把模型里base_width调低到 16。另外训练时不需要torch.no_grad()的语境下别乱加,推理阶段才用它省显存。

torch.chunk报错“split size”通常是width没有整除导致的。比如width算出来是 100,scale 取 8,100 没法整除 8。我代码里已经做了width // scale * scale处理,理论上不会触发。但如果你自己改base_width=64,就要留意out_channels和 scale 的搭配,确保每个子集的通道数至少是 1,别太小。比如输出通道是 32,scale 却取 16,每个子集只分到 2 个通道,特征表达能力就太弱了。

还有一个很隐蔽的坑:nn.ModuleList里的卷积索引和chunk的份数不对齐。我自己在写第一个版本时,convs长度是scale,实际上只需要scale - 1个,结果循环里访问最后一个卷积时直接索引越界。如果你也想自定义 scale,务必保持for i in range(1, self.scale)只使用i - 1作为索引。

4.3 分类效果比 ResNet 还差?先检查这几点

用了 Res2Net 反而比 ResNet 差,大概率不是模型问题,而是以下几个原因:

  • 你的scale设得太大,模型容量增加但数据量不支持,过拟合了。把 scale 降到 2 或 3 试试。
  • 训练 epoch 太少。Res2Net 因为分支层级复杂,收敛速度通常比同深度的 ResNet 慢一些,尤其在无预训练时。我自己的实验里,第 15 个 epoch 之前 ResNet 精度还领先,25 epoch 之后 Res2Net 才反超。如果只训 10 epoch,看到的结果会有误导性。
  • 数据增强用了过强的 RandomResizedCrop,导致模型学不到有效纹理信息。Res2Net 的强项在于多尺度感受野,如果你把图像裁剪得太狠,等于把多尺度的优势消掉了。
  • 比较时没有固定随机种子,一次实验的噪声掩盖了真实提升。至少要跑 3 个种子取均值。

我给自己的项目留过一份实验记录:ResNet50 三次 seed 的平均精度是 92.1%,标准差 0.3%;Res2Net50 三次平均是 93.4%,标准差 0.25%。两者差异是 1.3%,而单次实验可能测出负差异或 2% 的正差异,所以不看多次平均很难下结论。

4.4 问题速查表

问题可能原因解决方案
loss 居高不下数据未正确归一化检查 transform 中 Normalize 的 mean/std
验证集精度训练集低过拟合增加数据增强、降低 scale、增加 weight_decay
训练时显存溢出batch 过大或分辨率过高减小 batch size 或分辨率,或降低 base_width
精度提升不明显训练周期不够延长 epoch 数,观察 25 epoch 后的趋势
类别严重不均衡数据分布偏斜使用 WeightedRandomSampler 或 Focal Loss
模型加载报 key 不匹配保存的是整个模型而非 state_dict统一使用torch.save(model.state_dict(), ...)

写在最后

我自己在实际项目里最大的感受是:Res2Net 不是那种“换上就起飞”的银弹,它的增益点集中在多尺度信息丰富的场景。森林、卫星影像、医疗影像、细粒度分类这些任务,它的优势会被放大;而在 MNIST 这种简单数据集上,它和 ResNet 的差距可以忽略不计。

最后再分享一个小技巧:我习惯把模型的 scale、base_width 这些超参数写进一个 config 字典,和实验日志一起保存。这样每次跑实验后,翻看日志就能立刻知道当时用了什么配置。否则过两个月再回来,看到训练结果完全想不起来模型长什么样,那种感觉真的很抓狂。Res2Net 的代码并不复杂,如果你已经在用 ResNet,换过去可能只需要十分钟,但前提是你要理解每个参数背后的意义,这篇笔记希望能帮你少走这些弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 22:23:34

Proxmox虚拟化平台部署macOS黑苹果虚拟机完整指南

很多玩 Proxmox 的朋友跟我一样,哪天真香了,才会花一整个周末去折腾“PVE 上装黑苹果”这种看着就折腾的事。其实动机很简单:手里没有 Mac,但跑 iOS 打包、用 macOS 独占软件、或者单纯想体验一下苹果生态,又不想为了一…

作者头像 李华
网站建设 2026/9/16 22:21:52

ROS 2多无人机仿真:rotors架构隔离与稳定性实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 22:21:35

微控制器原生AI:PAANI河上机器人离线实时决策实践

1. 为什么“河上机器人”需要一个离线AI大脑:PAANI的诞生逻辑你有没有想过,当一条小船漂在长江支流上采集水质数据时,它正用手机热点把每帧画面传回百公里外的服务器?等模型推理完再发指令回来,水流早已裹挟着污染物拐…

作者头像 李华
网站建设 2026/9/16 22:19:54

SAP库存管理实战:从物料凭证到移动类型的底层逻辑拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华