1. 项目概述与核心需求解析
1.1 这个项目到底解决什么问题
做图像分类的读者应该都有这种感觉:从LeNet、VGG一路用过来,ResNet几乎成了大家的“默认基线”。残差连接解决了深层网络难训练的问题,但有一个点始终没有完美处理——多尺度特征的表达能力。同一个画面里的物体,比如森林航拍图,既有大片连绵的林冠(大尺度),又有单棵树木的纹理和边缘(小尺度),普通卷积核的感受野是固定的,很难同时捕捉这两种尺度信息。
Res2Net的出现就是针对这个痛点的。它的思路很直观:不靠加深网络,也不靠加宽网络,而是在基础残差块内部把特征图按通道拆分成几个小组,每组用不同的感受野去处理,再把结果融合起来。这个结构当时在CVPR 2020上发表,我们团队验证下来,在同类参数量的前提下,用Res2Net替换普通ResNet,分类准确率平均能提升1%到2%。对比赛刷点、论文做基线对比,或者实际项目里要压榨模型性能,这已经是性价比很高的改动了。
这篇博文的目标读者,主要是有PyTorch基础、想用前沿结构提升模型精度的人。即使你之前没读过Res2Net原论文,只要会基本的PyTorch模型训练流程,按照下面的5个步骤走一遍,也能完整跑通一个Res2Net图像分类项目。我会把环境搭建、模型结构拆解、数据准备、训练评估全链路都讲清楚,关键部分直接给出可运行的代码。
1.2 为什么选择Res2Net而不是其他新模型
这个问题的答案,直接决定了你是否值得花时间读这篇文章。Vision Transformer这类模型虽然热,但有两个门槛:一是需要海量数据预训练,自己从零训效果很难保证;二是显存占用和训练时间对普通单卡玩家不太友好。而Res2Net属于“低成本高收益”的升级:网络结构上只替换了基础块,参数量和计算量几乎不增加,却能在多数数据集上稳定涨点。
选择PyTorch作为实现框架,原因也比较实际。Res2Net官方虽然给了源码,但接口略老,直接拿来用往往会遇到版本兼容问题。PyTorch生态里的预训练模型库(尤其是timm库)对Res2Net族系支持得比较完整,几行代码就能加载在ImageNet上训好的权重,非常省事。而且PyTorch的动态图特性,在调试通道分组、维度广播这类自定义结构时的便利性,是TensorFlow静态图比不了的。
2. 环境准备与PyTorch安装避坑指南
2.1 Anaconda创建隔离环境
在动手写模型之前,先把Python环境收拾利索。我很推荐用Anaconda管理Python环境,理由就一条:它能把PyTorch的复杂依赖关系隔离得干干净净,不会出现“为了跑A项目升级了numpy,结果B项目直接崩掉”的尴尬情况。
打开命令行,执行下面这条命令创建干净的虚拟环境:
conda create -n res2net python=3.8选Python 3.8不是随意拍的,PyTorch 1.x到2.x的多数稳定版本对它的支持兼容性最好。如果你后续要装CUDA 11.8以上版本,Python 3.9、3.10也没问题,但我习惯保守一点,用3.8能把很多潜在的依赖坑挡在门外。
创建完成后记得激活环境:
conda activate res2net2.2 GPU版与CPU版PyTorch安装的差别
这一步是新手最容易卡壳的地方。PyTorch的安装命令不是一把梭的,需要根据你有没有NVIDIA独立显卡来决定。
先说结论:只要有NVIDIA显卡,优先装GPU版。即使显存只有4G,模型照样能训,只是batch size小一点而已。而CPU版的训练效率,Res2Net这种残差结构还好,换成大一点的模型那真是每分钟都在煎熬。
安装GPU版PyTorch前,先确认两件事。第一,显卡驱动是否正常:
nvidia-smi注意看右上角的CUDA Version,这个表示驱动支持的最高CUDA版本,不是当前已经安装的CUDA。比如驱动显示CUDA Version: 12.1,那你可以放心装支持CUDA 11.8或12.1的任意PyTorch版本。
第二,用conda安装容易出现下载慢、依赖冲突的问题。我实测下来,用pip加国内镜像是最稳的方案。以CUDA 11.8对应的PyTorch 2.0.1为例:
pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118网络环境不太好的话,可以改用阿里云镜像:
pip install torch==2.0.1 torchvision==0.15.2 -i https://mirrors.aliyun.com/pypi/simple/CPU版就简单多了:
pip install torch torchvision -i https://mirrors.aliyun.com/pypi/simple/安装完成后,快速验证是否可用:
import torch print(torch.__version__) print(torch.cuda.is_available())如果打印的cuda.is_available()是False,大概率是CUDA版本与PyTorch不匹配,或者驱动没装好。不要继续往下写代码,先解决环境问题,不然后面每一步都在浪费时间。
提示:用PyCharm或VS Code时,记得把项目的Python解释器切换到刚创建的res2net环境,否则运行代码用的还是系统自带的Python,装了也白装。
3. Res2Net核心结构拆解:为什么它能涨点
3.1 Res2Net-Block的完整工作流程
理解Res2Net,关键看它的基础块内部怎么改造。我以最常用的Res2Net-50为例,标准ResNet50的Bottleneck结构里,中间层是一个3x3卷积处理224x224输入经过降维后的56x56特征图。Res2Net做的事情,是把这个3x3卷积“拆开”来做文章。
具体流程是这样的:假设经过第一个1x1卷积后,输出一个维度为C的特征图(比如标准的256维)。先按通道维度把这256维均分成4个子集,每个子集64维。接着,对这4个子集进行分组卷积操作:
- 第一个子集不做任何卷积变换,直接作为原始信息保留下来
- 第二个子集经过一个3x3卷积,然后与第一个子集的输出相加,再送进第三个分支的3x3卷积
- 第三个子集是在第二个子集处理后的结果上再做一次3x3卷积
- 第四个子集以此类推
论文里的公式表达更严谨,我用大白话总结:每一个分支的输入,都是“前一个分支的输出加上自己的原始输入”,这样层层递进,每个尺度上的信息都经过了一次特征融合。最后,把4个子集的输出重新拼接起来,用1x1卷积聚合维度,再接残差连接。
这样做的好处非常直接:不同通道子集拥有不同的感受野,组合起来就是“多尺度特征表达”。我的理解是,传统ResNet的一个残差块只有一个3x3卷积,每层只能“看”到固定大小的局域;而Res2Net一个块里,有的分支看得近、有的分支看得远,相当于在极低的计算开销下仿制了类似特征金字塔的效果。
3.2 参数量与计算量的真实数据对比
很多读者关心一个问题:Res2Net涨点是不是靠堆参数量堆出来的?我直接给你放一组数字。
标准ResNet50的参数量约25.6M,Flops约4.1G。Res2Net-50在相同深度和宽度配置下,参数量约25.7M,Flops约4.3G,几乎可以忽略不计的增加。但ImageNet Top-1准确率,ResNet50大约是76.1%,Res2Net-50能达到78.1%左右,涨了整整2个百分点。
这个幅度放在ImageNet那种几百万数据量、竞争惨烈的情况下,已经相当不容易了。更关键的是,在MS COCO目标检测、实例分割等下游任务中,用Res2Net替换ResNet做骨干网络,同样有稳定的涨点。所以它不仅仅是一个“分类专用”的结构,而是可以嵌入各种视觉任务骨架的通用模块。
在我个人测试的森林图像分类场景里,数据是5类常见树种(松树、橡树、桦树、枫树、杉木)的叶片特写和树干纹理图,总量只有2万张左右。用Res2Net-50做迁移学习,比同参数的ResNet50准确率高出1.8%。不管是大数据集还是小数据量的细粒度分类,它都挺能打的。
3.3 与ECA-Net、SKNet等注意力机制的区别
说到提升多尺度表达能力,你可能也知道SKNet(Selective Kernel Networks)这类方法。SKNet的思路是让网络“自适应地学习应该用多大的卷积核”,相当于在多个不同kernel size之间做软选择。而Res2Net的思路更简单粗暴——不选择,全都用,只是通过通道分组把不同大小的感受野组合进同一个块。
ECA-Net则是从通道注意力的角度出发,用一个很轻量的一维卷积来建模通道之间的依赖关系。它和Res2Net是完全正交的改进方向。实际工程里,有不少团队会把Res2Net和ECA-Net叠加使用,效果还能再拔高一点。
不过我在实际使用中的建议是:不要在刚开始接触时就叠加太多模块。先把Res2Net本身的训练流程跑通,确认它在你自己的数据集上确实有涨点,再去尝试多模块融合,否则出了性能问题你都不知道该排查哪一部分。
4. 5步搞定Res2Net图像分类:实操全流程
4.1 第一步:数据集准备与预处理
我拿森林图像分类举例。先把数据整理成ImageFolder标准格式:
data/ train/ pine/ pine_001.jpg pine_002.jpg oak/ oak_001.jpg ... val/ pine/ oak/ ...每一个类别的图片放在对应名字的文件夹里,torchvision的ImageFolder能直接读取这种结构。数据量少的话,记得做数据增强。我用的是一套经过实测的增强组合:
import torchvision.transforms as transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), 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]), ])注意,ImageNet的mean和std是通用的,如果你做的是灰度图或医学影像这种分布差异很大的数据,最好重新统计一下。但是用ImageNet预训练权重时,沿用ImageNet的归一化参数一般是最稳妥的。
加载数据时设置好DataLoader的batch_size和num_workers:
from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_dataset = ImageFolder('data/train', transform=train_transform) val_dataset = ImageFolder('data/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, )这里num_workers在Windows上如果设置过大容易报错,建议设为2到4。
4.2 第二步:加载Res2Net预训练模型并改造分类头
Res2Net官方GitHub仓库提供了预训练权重,但用起来略麻烦。我推荐优先用timm库,一行代码搞定:
pip install timm加载模型并改造分类头:
import timm import torch.nn as nn def create_res2net_model(num_classes=5): model = timm.create_model('res2net50_26w_8s', pretrained=True) in_features = model.num_features # 替换分类头,适应自己的类别数 model.fc = nn.Linear(in_features, num_classes) return model关于命名规则,'res2net50_26w_8s'的含义是:50层结构,宽度26维,每组8个分支。timm里还有各种变体,比如res2net101_26w_4s,层数更深、分支更少。如果你显存不够,可以试试res2net50_14w_8s,宽度更窄,速度更快。
如果不想依赖timm,也可以直接用官方仓库的核心代码文件(res2net.py)放到项目里,然后手动加载pth权重。但对于大多数人,timm已经够用且省心。
改完分类头之后,注意一点:如果你用了timm自带的预训练权重,它会自动把最后全连接层维度设置成ImageNet的1000类,所以替换新分类头是必要的且不会影响特征提取层的权重。
4.3 第三步:训练配置与关键超参数选择
选择优化器时,我建议先用SGD做基准测试。原因有三点:SGD加momentum的泛化能力在中等规模数据集上普遍比AdamW更好;Res2Net的预训练权重是在SGD策略下训出来的,沿用同类优化器能更快收敛;AdamW需要额外调weight_decay和学习率,对新手不友好。
具体的配置如下,学习率选择0.01配合余弦退火,这是我在ImageFolder数据上实测稳定有效的组合:
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model = create_res2net_model(num_classes=5) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6)T_max设为训练epoch数,eta_min是最低学习率。我建议训练30到40个epoch,Res2Net的收敛速度不快,需要耐心等它跑完。
损失函数用CrossEntropyLoss基本没问题。如果你的类别严重不均衡,可以考虑给loss加权:
class_weights = torch.tensor([1.0, 2.0, 0.8, 1.5, 1.0]).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)4.4 第四步:完整的训练和验证循环
训练和验证循环的代码看起来繁琐,但每个部分都有讲究。我直接把完整训练脚本贴出来,关键行添加了注释:
import torch import time def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() 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 images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc best_acc = 0.0 for epoch in range(30): start_time = time.time() 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() elapsed = time.time() - start_time print(f'Epoch {epoch+1}/{30} | ' f'Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | ' f'Val Loss: {val_loss:.4f} Acc: {val_acc:.4f} | ' f'Time: {elapsed:.1f}s') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_res2net_model.pth') print(f' Best model saved (val_acc={best_acc:.4f})')这段代码没有什么花哨的黑科技,但足够完成一轮标准的训练验证。有几个细节值得展开说说。
第一,model.train()和model.eval()必须成对出现。对初学者来说最常犯的错误是训练完忘了切回eval模式就去验证,导致Dropout和BatchNorm行为不一致,验证精度忽高忽低。
第二,torch.no_grad()必须在验证循环里加。它告诉PyTorch不需要记录梯度,能省下不少显存和计算时间。如果显存紧张,还可以加一句with torch.cuda.amp.autocast()做混合精度推理,稍后我会展开讲。
第三,保存模型时保存state_dict而不是保存整个model,这样只存参数文件,通用性更好,加载时只需要和模型结构匹配就行。
4.5 第五步:加载模型做推理与评估
训练完的模型,最终要能对任意一张新图做预测。写一个简单的推理函数:
from PIL import Image def predict_image(image_path, model, class_names, device): model.eval() image = Image.open(image_path).convert('RGB') image = val_transform(image).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(image) probabilities = torch.softmax(outputs, dim=1) confidence, predicted = torch.max(probabilities, 1) class_name = class_names[predicted.item()] return class_name, confidence.item() class_names = ['pine', 'oak', 'birch', 'maple', 'cedar'] # 加载最佳模型 model = create_res2net_model(num_classes=5) model.load_state_dict(torch.load('best_res2net_model.pth', map_location=device)) model = model.to(device) result, conf = predict_image('test_images/sample_pine.jpg', model, class_names, device) print(f'预测类别: {result}, 置信度: {conf:.4f}')如果希望在验证集上看到更全面的评估指标,可以用sklearn的classification_report,打印precision、recall和F1,这块特别适合类别不均衡的数据集。
5. 完整代码整合与详细说明
5.1 项目文件结构与主流程一站式串讲
为了避免代码散落在各处,我整理了一个完整的项目骨架。你直接照着这个结构建文件,改一改路径配置就能跑:
res2net-classification/ data/ train/ val/ train.py predict.py utils.pytrain.py是主训练脚本,把前面所有代码片段整合起来。utils.py放数据增强、模型构建函数,保持代码整洁。
为了让大家少走弯路,我把完整版的train.py放在下面,这个版本是可运行的:
import argparse import time import torch import torch.nn as nn import torch.optim as optim import timm from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder from torchvision import transforms def get_transforms(): train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), 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]), ]) return train_transform, val_transform def create_model(num_classes): model = timm.create_model('res2net50_26w_8s', pretrained=True) model.fc = nn.Linear(model.num_features, num_classes) return model def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return running_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return running_loss / total, correct / total def main(): parser = argparse.ArgumentParser() parser.add_argument('--data_dir', type=str, default='data') parser.add_argument('--num_classes', type=int, default=5) parser.add_argument('--batch_size', type=int, default=32) parser.add_argument('--epochs', type=int, default=30) parser.add_argument('--lr', type=float, default=0.01) args = parser.parse_args() device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print(f'Using device: {device}') train_transform, val_transform = get_transforms() train_dataset = ImageFolder(f'{args.data_dir}/train', transform=train_transform) val_dataset = ImageFolder(f'{args.data_dir}/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, num_workers=4, pin_memory=True) model = create_model(args.num_classes).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=args.lr, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=args.epochs, eta_min=1e-6) print(model) best_acc = 0.0 for epoch in range(args.epochs): start = time.time() 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() elapsed = time.time() - start print(f'Epoch {epoch+1}/{args.epochs} | ' f'Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | ' f'Val Loss: {val_loss:.4f} Acc: {val_acc:.4f} | ' f'Time: {elapsed:.1f}s') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_res2net_model.pth') print(f' Best model saved (val_acc={best_acc:.4f})') if __name__ == '__main__': main()5.2 模型加载与微调的关键细节
在集成到自己的项目时,有几点值得拿出来具体说说。
第一,关于预训练权重的用法。timm加载的pretrained权重是在ImageNet上训好的,特征提取器的底层纹理、边缘、颜色特征具有很强的通用性。所以我们只替换最后一层全连接层,前面的层都保留下来当特征提取器。如果你的数据集和ImageNet分布差异极大,比如医学X光片,可以考虑冻结前面几层的权重,只训练后面几层和分类头,这样能防止过拟合,训练速度也会快很多。
第二,冻结权重的方式很简单。设一个requires_grad开关即可:
for param in model.parameters(): param.requires_grad = False # 只解冻最后一层和分类头 for param in model.fc.parameters(): param.requires_grad = True然后优化器只传入requires_grad为True的参数,这样反向传播时冻结层不更新梯度,省算力又省内存。
第三,关于输入尺寸。Res2Net的默认输入是224x224,训练时尽量不要缩小到192以下,多尺度特征信息会被切掉一大块。
5.3 混合精度训练加速
如果你用的是RTX 30系以上显卡,可以开自动混合精度(AMP),训练速度提升明显,而且显存占用能省不少。PyTorch 2.0开始,API已经比较成熟:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() def train_one_epoch_amp(model, loader, criterion, optimizer, device, scaler): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images = images.to(device) labels = labels.to(device) optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return running_loss / total, correct / total混合精度的逻辑是:前向计算用float16做矩阵运算,速度更快、占用内存更小;反向传播和参数更新时用float32维持数值稳定性。GradScaler负责在梯度值太小时自动放大,防止下溢出到0。
我在3080Ti上做过对比,开了混合精度之后,Res2Net-50的训练耗时大约能缩短30%到40%,精度的损失几乎可以忽略不计。如果你的显卡不支持AMP,这段代码会自动忽略提速部分,不会报错。
6. 训练中常见问题与排查技巧实录
6.1 CUDA out of memory显存不足
这个问题在图像分类里太常见了。如果你跑上面这段代码时遇到out of memory,先别急着骂显卡,按这个顺序排查。
第一步,调低batch_size。32的batch已经不小了,224x224的输入、Res2Net-50的规模,大约需要6到8G显存。如果你的显卡是8G以下的独显,直接改成16或8:
parser.add_argument('--batch_size', type=int, default=16)第二步,开启梯度累积,保持有效的batch_size不变:
accumulation_steps = 4 # 模拟batch_size=64 for i, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()第三步,使用混合精度减少显存占用,前面已经讲过了。第四步,如果还是不够,只能换更小的模型变体(res2net50_14w_8s),或者降低输入分辨率到192。
6.2 在Windows上num_workers报错
Win系统下DataLoader的num_workers大于0时,经常会出现BrokenPipeError或者被强制kill的情况。这不是代码逻辑问题,而是Windows的多进程启动机制跟Linux不一样导致的。
最简单的解决方案是,训练主流程加上ifname== 'main':保护,这个上面代码里已经有了。再把num_workers降到2。如果问题依然存在,直接把num_workers设为0,虽然数据加载效率低一点,但至少不会报错。
我自己的经验是,Windows下num_workers=2和num_workers=8的实际速度差距没那么大,为了稳定性,开2就够了。如果是Linux服务器,可以放心开到CPU核数减一。
6.3 验证集loss不降反升或振荡严重
训练时如果发现验证集acc上下乱跳、不稳定,第一个要查的是学习率是否过大。0.01在ImageFolder这样千级数据量的数据集上,前期还可以,后期配合余弦退火会逐步降下来。如果你把lr设成0.1甚至更高,SGD+Res2Net的loss极大概率发散。
第二个要查的是数据增强强度。RandomRotation(15)对叶片、树木这类自然图像问题不大,但如果是字符识别、工业零件这种对方向敏感的细粒度数据集,旋转增强反而会引入大量伪样本,导致验证集表现变差。这类数据建议去掉旋转,只用翻转和裁剪。
第三个要查的是类别平衡问题。比如森林图像分类中,杉木样本只有几十张,松树有几万张,模型很容易把所有图片都预测成松树,因为这样loss就已经很低了。遇到这种情况,用前面提过的CrossEntropyLoss的weight参数,或者干脆对少数类做过采样。
6.4 训练集准确率很高但验证集上不去
这是经典的过拟合信号。Res2Net参数量有25M+,如果训练集只有几千张,过拟合几乎是必然的。
我排查过的最佳组合是这样一个流程:先降低模型宽度(用14w版本),再加强数据增强(加RandomErasing),最后在分类头前加Dropout:
model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(model.num_features, num_classes) )预训练权重的好处这时就体现出来了。哪怕你的任务数据量很小,只要特征分布不是完全偏离ImageNet,它能极大缓解过拟合,这也是为什么我一直强调要用pretrained=True的原因。
6.5 预训练权重加载报错
timm库偶尔会加载不出某些版本的权重,报错信息往往是连接超时或404。这种时候不要慌,手动下载官方pth文件放到本地,然后通过create_model的checkpoint_path参数加载:
model = timm.create_model( 'res2net50_26w_8s', pretrained=False, checkpoint_path='./res2net50_26w_8s.pth' )如果timm没有对应版本,可以访问Res2Net官方GitHub仓库,它提供了基于PyTorch的res2net.py代码和ImageNet预训练pth文件。下载后放进项目目录,在create_model里先构建模型结构,再手动load_state_dict。
这种手动方式推荐给所有遇到网络下载问题的读者:
model = timm.create_model('res2net50_26w_8s', pretrained=False) state_dict = torch.load('./res2net50_26w_8s.pth', map_location='cpu') model.load_state_dict(state_dict)注意:官方提供的权重文件,键名可能带有module.前缀(DataParallel训练后遗留),加载时会报unexpected key。解决办法是过滤掉前缀:
from collections import OrderedDict new_state_dict = OrderedDict() for k, v in state_dict.items(): if k.startswith('module.'): k = k[7:] new_state_dict[k] = v model.load_state_dict(new_state_dict)
6.6 一张速查表应对训练异常
我把自己这几年踩过的坑整理成表,方便大家对照排查:
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| Loss为NaN或inf | 学习率过大、数据存在NaN像素 | 降低lr、检查数据、加梯度裁剪 |
| 验证Acc几乎为随机值 | 模型未收敛、类别标签错乱 | 增加epoch、检查label文件夹、可视化batch数据 |
| 训练时间超长 | 使用了CPU、num_workers=0 | 换GPU、开多进程、降低batch_size |
| 显存溢出 | batch太大、分辨率太高 | 减小batch、开AMP、用更小模型 |
| 加载权重报unexpected key | 预训练模型分类头类别数不同 | 替换fc层后再加载,或用strict=False |
| 效果和ResNet50差不多 | 训练epoch太少、没有微调 | 增加epoch、用余弦退火、确认多尺度特征生效 |
7. 训练过程的观察记录与调参经验
7.1 一次典型训练的完整过程记录
我在自己的测试环境里,用森林数据集做了一次完整的Res2Net-50训练实验,记一下过程供大家参考。硬件是i7-12700 + RTX 3070 Ti,数据是5类、每类约3500张训练图、500张验证图,batch_size=16,初始学习率0.01,训练30个epoch。
前3个epoch,训练集loss从1.5附近快速降到0.8,验证集acc从50%左右升到70%。这说明迁移学习的底层特征在起作用,收敛速度远快于从零训练。
第5到第15个epoch,训练集loss缓慢下降,验证集acc在80%到85%之间震荡。这个阶段是最难熬的,因为会看到验证集精度一会涨一会掉,很容易让人怀疑是不是模型出问题了。实际上这就是SGD在鞍点和局部最优之间跳动的正常状态,学习率还比较大,梯度更新乱跳是正常的。
第20个epoch之后,余弦退火把学习率降到比较低,验证集acc开始稳步爬升,最终停在88.2%。整次训练耗时约55分钟,最佳模型保存在第26个epoch。
对比同数据下ResNet50的最佳效果86.4%,Res2Net的涨点幅度符合预期。最后只涨了不到2个百分点,但考虑到参数量几乎没增加,这个性价比已经很让人满意了。
7.2 学习率策略的调参心得
学习率可以说是整个训练流程中最敏感的超参数。Res2Net的残差结构和预训练权重决定了它不像Transformer那样对学习率极度敏感,但也不能掉以轻心。
我的通用建议是:SGD初始lr设为0.01,搭配CosineAnnealingLR完成整个周期。如果数据集很小(每类几百张),可以降到0.005;如果数据集很大(每类上万张),可以升到0.02。一切以你验证集的表现来调整,不需要过度迷信某个固定值。
一个比较实用的技巧是,前5个epoch用warmup。它的作用是让模型从预训练权重出发时,先用小学习率慢慢“热身后”再进入主训练阶段,避免前期大幅度更新把预训练信息冲掉。PyTorch里边warmup实现要自己写,一个简单的做法是在前几个epoch把学习率线性放大到初始值:
def adjust_learning_rate(optimizer, epoch, warmup_epochs, init_lr): if epoch < warmup_epochs: lr = init_lr * (epoch + 1) / warmup_epochs for param_group in optimizer.param_groups: param_group['lr'] = lr7.3 如何处理类别不均衡问题
森林图像分类里,如果松树的样本数量是杉木的10倍,模型会倾向于把所有图像都判成松树。这时候光看accuracy意义不大,得看F1 Score或者混淆矩阵。
我常用的手段优先级是这样的:先统计每一类的样本数量;然后设置loss权重,样本少的类别给更高的权重;如果还不行,就对少数类做重采样,比如用torch.utils.data.WeightedRandomSampler让每个类别在每次epoch里被采到的概率大致相同。
from torch.utils.data import WeightedRandomSampler labels_in_train = [train_dataset.targets[t] for t in range(len(train_dataset))] class_counts = torch.bincount(torch.tensor(labels_in_train)) weights = 1.0 / class_counts.float() sample_weights = weights[labels_in_train] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True ) train_loader = DataLoader( train_dataset, batch_size=args.batch_size, sampler=sampler, num_workers=4 )8. 模型部署与效果优化建议
8.1 从PyTorch模型到ONNX导出的实践
训练完成后,模型往往是放在PyTorch的生态里用。但如果实际项目需要部署到服务器上,或者要接入其他推理框架,导出为ONNX格式是最通用的做法。Res2Net的卷积结构和1x1分支在导出时并不复杂,PyTorch的torch.onnx.export可以自动处理:
dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, 'res2net50.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}, opset_version=12 )这里特意设置了dynamic_axes,允许batch维度是动态的,这样部署时一次可以送多张图进行批处理,吞吐量更高。导出成功之后,可以再用onnxruntime跑一遍推理,验证导出的模型和PyTorch原模型的输出是否一致。一般来说,误差在1e-4级别以下都是正常的。
8.2 模型压缩与推理提速的可行思路
Res2Net的参数量对服务器部署问题不大,但如果要部署到手机或边缘设备,就有优化空间。量化是最直接有效的手段——把float32的权重转成int8,模型体积能缩到四分之一,推理速度在支持INT8加速的硬件上能提升1倍以上,准确率掉得不多。
PyTorch提供了torch.quantization模块,动态量化的代码很简单:
quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear}, dtype=torch.qint8 )不过Res2Net的计算量主要来自卷积层,只量化全连接层的效果有限。更彻底的是做静态量化或QAT(量化感知训练),复杂度上了一个台阶,一般项目用不到。
另外一个思路是知识蒸馏。拿训练好的Res2Net-50当教师模型,蒸馏一个参数量更小的ResNet18或MobileNetV3给学生模型,在保持较高精度的同时把推理速度拉上去。这种方案在工程上很实用,属于“老师先学到最好,再教给轻量学生”的思路。
9. 写在最后的经验分享
Res2Net从发表到现在已经有好几年时间,它在图像分类、目标检测、语义分割这些大方向上得到的验证,远比很多新出的花哨结构要多得多。如果你正在做图像分类,想找一个“涨点稳定、改动不大、代码成熟”的方案,Res2Net基本不会让你失望。
我个人在实际操作中的体会是,它的调参难度比Vision Transformer低太多了。不需要像Transformer那样做一大堆学习率warmup、权重衰减精细化调节,只要按本文的SGD+余弦退火配置来跑,效果就很稳定。如果你之前一直在用ResNet48、ResNet50,这次把backbone替换成Res2Net-50,大概率能感受到准确率的提升。
最后分享一个小技巧:在训练结束后,除了保存state_dict,记得把timm的配置信息或者你自己定义的参数也存一份。比如类别映射关系、归一化的均值和标准差、输入尺寸这些,部署时经常需要用到。我习惯用一个config.json把这些参数存起来,和模型权重放在同一个目录下,这样整个模型随时可以复现,不会过几个月再看代码时一头雾水。