十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

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 卷积后得到通道数为width * scale的特征图。把特征图在通道维度上平均切成scale份每份称为一个子集记作x1, x2, ..., xs。第一个子集x1不做任何 3×3 卷积直接铭印输出。第二个子集x2会先和一个经过 3×3 卷积的x1相加再经过 3×3 卷积得到y2。第三个子集x3会和上一步的输出y2相加再经过 3×3 卷积得到y3。依此类推最后一个子集xs经过同样的层级式处理后得到ys。最后将y1到ys全部在通道维度上拼接起来送入第二个 1×1 卷积。简单说第一个子集只经历过一次 3×3 卷积第二个子集经历了两次第三个经历三次越靠后的通道感受野越大。整个 block 通过简单的层级残差连接在没有显著增加参数量的前提下同时输出了多个尺度的特征。如果你觉得这有点抽象可以把它想象成工厂流水线。普通 ResNet 是一个工人同时负责四个零件所有零件走同一条加工路线Res2Net 把零件按批次分到四个工位第一个工位加工完传给第二个第二个工位会把前面半成品和当前批次合在一起继续加工。同一时刻流水线上同时存在不同加工深度的半成品最后统一包装出厂。每个工位看到的信息粒度都不一样这就是多尺度。1.3 参数量的一笔账多尺度不是靠堆参数堆出来的很多人第一反应是把 3×3 卷积拆成多个参数量不翻倍了吗其实没有这是 Res2Net 最精妙的地方。我们以输出通道为 256 的 bottleneck 为例假设中间隐藏层通道数是 64scale 取 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 显存也够。环境依赖大致如下torch1.12.0 torchvision0.13.0 numpy1.21.0 Pillow9.0.0 matplotlib3.5.0 scikit-learn1.0.0 tqdm4.64.0如果你还在用 Anaconda可以用 conda 创建一个干净的环境conda create -n res2net python3.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 的BasicBlock或Bottleneck使用方式上没有任何差别。import torch import torch.nn as nn class Res2Block(nn.Module): def __init__(self, in_channels, out_channels, stride1, scale4, base_width26): 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_size1, biasFalse) self.bn1 nn.BatchNorm2d(width * scale) # 3x3卷积组一共scale-1个第一个子集不经过3x3 self.convs nn.ModuleList( [nn.Conv2d(width, width, kernel_size3, stridestride, padding1, biasFalse) 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_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) # 残差连接的shortcut如果没有降采样或通道变化就直接恒等映射 self.downsample None if stride ! 1 or in_channels ! out_channels: self.downsample nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride, biasFalse), 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, dim1) 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, dim1) 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_channels256时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 官方实现里对 stride2 的位置处理也比较绕我这里选择了工程上最不容易出错的方案换来的精度损失微乎其微。2.3 第二步搭建整体网络结构有了核心 block搭建整体网络就非常简单了。我们做一个可以替换 ResNet50 的 Res2Net50四个 stage 的 block 数量按[3, 4, 6, 3]设置每个 stage 的输出通道分别取256, 512, 1024, 2048。class Res2Net(nn.Module): def __init__(self, num_classes1000, layers[3, 4, 6, 3], scale4, base_width26): super().__init__() self.in_channels 64 # stem部分和ResNet完全一致 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) # 四个stage self.layer1 self._make_stage(256, layers[0], stride1, scalescale, base_widthbase_width) self.layer2 self._make_stage(512, layers[1], stride2, scalescale, base_widthbase_width) self.layer3 self._make_stage(1024, layers[2], stride2, scalescale, base_widthbase_width) self.layer4 self._make_stage(2048, layers[3], stride2, scalescale, base_widthbase_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, modefan_out, nonlinearityrelu) 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, strides, scalescale, base_widthbase_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(brightness0.2, contrast0.2, saturation0.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, transformtrain_transform) val_dataset datasets.ImageFolder(data/forest_cls/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) 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, descTraining): 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, descValidating): 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(fUsing device: {device}) model Res2Net(num_classes3, layers[3, 4, 6, 3], scale4, base_width26) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.025, momentum0.9, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60) num_epochs 60 best_acc 0.0 best_model_wts copy.deepcopy(model.state_dict()) for epoch in range(num_epochs): print(fEpoch {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(fTrain Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}) print(fVal 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(fSave best model, val_acc{val_acc:.4f}) print(fBest 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.1batch size 256线性缩放得到的。如果你的 batch size 是 64就把学习率翻倍到 0.05方向反了降一半。这里有一个公式可以参考lr lr_base * batch_size / 256lr_base一般是 0.1 到 0.2。如果你习惯用 AdamW也不影响结果只是学习率要调小一个数量级比如 1e-3 起步配合 weight_decay0.01 到 0.05。两种优化器都能用但 SGD 在 60 epoch 左右的小训练任务里更容易看出 Res2Net 带来的增量因为 Adam 族对学习率更敏感随机涨落会更大一些。CosineAnnealingLR的T_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_namesclass_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, transformNone): 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, dim1) 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 发挥威力的是参数选择。很多人直接把scale4当成万能配置其实不是所有任务都适合。我按自己的实验经验列一个参考表参数建议值范围我的经验scale4 是默认2 到 8 可选细粒度分类用 4 或 8任务简单、数据量少用 2 防止过拟合base_width26 是论文标配16 到 64 可选想要更轻量就用 16追求精度用 26 足够再大收益很小输入分辨率224 为基础COCO 检测常用 320 或 448目标物体小适当提到 320 会改善明显显存吃紧就别上训练 epoch60 起小型数据集 30 也可以有预训练权重时 20 个 epoch 足够微调学习率SGD 参考 lr0.025batch32数据量大或 batch 大时按比例上调scale越大每个分支的通道越窄多尺度分支数越多但相邻分支的差异是否会带来有效信息并不一定。我在森林数据集上测过scale 从 4 提到 8精度几乎持平但训练速度降了大约 15%所以不是越大越好。小数据集上scale8 反而更容易过拟合因为更多分支意味着模型容量间接变大了。数据量不够大的时候收敛到 2 和 4 之间是最稳的。base_width的作用很多人不太理解。它控制的是中间隐藏层通道数的缩放比例。给定一个输出通道C中间宽度约等于C * base_width / 64。当base_width26时输出通道为 256 的阶段中间宽度约 104如果base_width64中间宽度就是 256 本身此时参数量和表达能力会显著上升但训练难度也变大。我的建议是在自己数据上先跑base_width26如果明显欠拟合再往上提。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(p0.3, scale(0.02, 0.2)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomResizedCrop的scale参数我从默认的(0.08, 1.0)改成了(0.5, 1.0)因为这个参数控制裁剪区域占原图的比例相当于模拟不同尺度的物体。Res2Net 擅长多尺度特征给它更多尺度变化的样本它能学得更充分。RandomErasing主要用来防止过拟合对森林这种背景单一的数据集有明显帮助但概率不宜设太高0.3 左右就够。有一点要特别注意RandomErasing是在 ToTensor 之后执行的所以你的 transform 顺序不能搞错。曾见过有朋友把它放在 ToTensor 之前结果直接报错因为RandomErasing只接受 Tensor 输入。4. 常见问题与排查实录4.1 训练不收敛或 loss 震荡的排查思路训练中遇到 loss 不下降先别急着换模型。我通常按这个顺序排查检查数据预处理是否正确。Normalize 的 mean 和 std 用错了损失会从一开始就偏高难降。检查标签是否有类别不平衡。三分类里森林图片特别多荒漠特别少模型可能直接全部预测为森林acc 看似很高但具体类别 recall 很差。检查学习率。SGD 配上 0.025 一般情况下没问题但如果 batch size 特别小比如 8初始学习率要相应降到 0.006 左右。公式实际学习率 参考学习率 × 实际 batch size / 参考 batch size。参考 batch size 取 256参考学习率取 0.1 到 0.2。检查 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算出来是 100scale 取 8100 没法整除 8。我代码里已经做了width // scale * scale处理理论上不会触发。但如果你自己改base_width64就要留意out_channels和 scale 的搭配确保每个子集的通道数至少是 1别太小。比如输出通道是 32scale 却取 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换过去可能只需要十分钟但前提是你要理解每个参数背后的意义这篇笔记希望能帮你少走这些弯路。
返回列表