1. 图像分类项目的前期准备
聊到基于 PyTorch 的图像分类,尤其是用 ResNet 这个经典骨干网络来做,很多初学者第一反应是“直接pip install torch然后跑模型”。但真正动手做过项目的人都知道,前期环境搭建和数据集准备这两件事,往往比训练模型本身更容易让人崩溃。这篇博文就以我实际跑通的一个森林图像分类项目为例,把从零到准确率落地的完整链路拆开讲清楚。
这个项目解决的痛点很明确:给定一批森林场景图片(比如包含不同树种、不同季节、不同光照条件),需要模型自动判断图片属于哪个类别。用 ResNet 是因为它在 ImageNet 上验证过的迁移学习能力非常成熟,预训练权重好找,推理速度快,部署也方便。适合的人群包括刚入门深度学习的学生、需要快速验证 CV 方案的工程团队,以及想在自己业务数据上做图像分类的开发者。
先说环境。我这次用的是 Ubuntu 系统加 Anaconda,显卡是 NVIDIA 的,所以需要 CUDA、cuDNN 和 PyTorch 三者版本匹配。很多人在这里栽跟头,原因很简单:PyTorch 的 CUDA 版本必须和你驱动支持的 CUDA 版本兼容,不是说你装个最新版就行。我的建议是,先查驱动支持的最高 CUDA 版本(用nvidia-smi查看右上角),然后去 PyTorch 官网用对应的命令安装。比如驱动支持 CUDA 11.8,那你就装pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118。
再用 Anaconda 创建独立的虚拟环境,避免把系统 Python 环境搞乱。具体操作是conda create -n forest python=3.9,然后激活环境再装包。这里有个细节很多人会忽略:Python 版本和 PyTorch 版本是有对应关系的,PyTorch 1.11 左右建议 Python 3.7-3.9,新版 PyTorch 2.x 建议 Python 3.8-3.11。装完后一定要在 Python 里执行import torch; print(torch.__version__, torch.cuda.is_available())验证 GPU 是否可用,输出True才能继续,否则后面训练时你会发现模型在默默用 CPU 跑,速度慢到怀疑人生。
数据集方面,我用了公开的森林图像分类数据集,包含约 14000 张图片,分为 6 个类别:落叶林、针叶林、热带雨林等。下载后先做数据清洗,去掉损坏图片和重复图片,然后按 8:1:1 划分训练集、验证集和测试集。目录结构就用 PyTorch 的ImageFolder标准格式,非常省事:
data/ train/ class1/ class2/ val/ class1/ class2/ test/ class1/ class2/1.1 为什么选 ResNet 而不是其他网络
我见过不少人一上来就上 Vision Transformer 或者 EfficientNet,不是说这些不行,而是在普通业务场景下,ResNet 的性价比真的很高。ResNet 的核心创新是残差连接,简单理解就是给网络加了一条“高速公路”,让梯度可以绕过某些层直接传到前面。这样即使网络很深(比如 ResNet50 有 50 层),梯度也不容易消失,训练更稳定。
从工程角度看,ResNet 的优势在三个方面。第一,预训练权重极其丰富,PyTorch 自带torchvision.models.resnet50(pretrained=True),不用自己从头训练,迁移学习几分钟就能见效。第二,模型结构简单清晰,相比 Transformer 那一套复杂的注意力机制,ResNet 的结构用一两张图就能看懂,出了问题也好排查。第三,推理速度适中,在 CPU 上也能跑,部署到服务端或者嵌入式设备都有成熟方案。
当然,如果追求极致精度,可以在 ResNet 基础上改成 ResNeXt 或者加注意力模块;如果追求极致速度,MobileNet 更合适。但作为图像分类的通用基线方案,ResNet 就是那个“怎么选都不会错”的选项。
2. 数据加载与增强策略
数据集准备好之后,接下来就是写 PyTorch 的数据加载流程。很多人直接ImageFolder一把梭,但其实这里面的细节直接决定模型能不能收敛、会不会过拟合。
2.1 数据增强的“度”怎么把握
数据增强是图像分类项目里最容易被低估的一环。它的本质是“免费”扩充数据集,通过随机变换让模型看到更多样的样本,从而提升泛化能力。但增强太猛也不行,比如把图片旋转 90 度,森林的语义可能就变了,树叶方向、光线角度都不自然。我在这个项目里用的增强策略是这样的:训练集做随机裁剪、水平翻转、颜色抖动、小范围旋转(±15 度);验证集和测试集只做 Resize 和归一化,不做任何随机增强,保证评估结果的稳定性。
具体参数上,RandomResizedCrop(224)是常用操作,因为 ResNet 的输入尺寸就是 224x224。颜色抖动的幅度设成 0.3 左右比较稳妥,太大会导致颜色失真。归一化用 ImageNet 的均值和标准差,这是迁移学习的标配操作,因为预训练权重是在 ImageNet 上训练出来的,输入分布保持一致效果才好。
2.2 DataLoader 的性能优化
训练速度慢很多时候不是 GPU 不行,而是 CPU 端的数据加载成了瓶颈。我踩过的坑是这样的:默认num_workers=0,数据加载完全串行,GPU 每个 step 都等着 CPU 喂数据,利用率上不去。后来改成num_workers=4,并把pin_memory=True打开,GPU 利用率直接翻倍。
还有一个小技巧,batch_size的选择要综合考虑显存大小和模型收敛效果。我在 8GB 显存的显卡上,ResNet50 用batch_size=32比较合适,再大就会显存溢出。如果显存不够又不想换小模型,可以用梯度累积来模拟更大的 batch,效果差不多但是训练时间会变长。
from torch.utils.data import DataLoader from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1), transforms.RandomRotation(15), 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/train', transform=train_transform) val_dataset = datasets.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)3. ResNet 模型改造与训练核心逻辑
模型部分,我用的是torchvision.models.resnet50(pretrained=True)。但直接拿来跑肯定不行,因为 ResNet50 原本是 1000 类输出,我的森林数据集只有 6 类,所以要替换最后的全连接层。
这个操作很经典,model.fc = nn.Linear(2048, 6),2048 是 ResNet50 最后一层卷积输出的特征维度,6 是我的类别数。替换之后,预训练权重依然保留着前面的所有卷积层特征提取能力,只重新训练最后这个分类头。
3.1 冻结与微调的策略
在训练初期,我选择了冻结所有卷积层,只训练新的全连接层。这样做的原因很简单:第一,预训练的卷积层已经能提取出非常通用的特征(边缘、纹理、形状),不需要大量数据去重新学习;第二,冻结层数能省大量显存,前向传播时不需要计算那些层的梯度;第三,训练速度快,几分钟就能完成一个 epoch。
等到分类头训练收敛之后,我再解冻所有层,用一个很小的学习率来做全网络微调。这个“先冻结后解冻”的顺序非常关键,如果一开始就用小学习率全网络微调,梯度从随机初始化的分类头传到后面,有可能会破坏预训练权重学到的良好特征。
损失函数我直接用了CrossEntropyLoss,这是多分类问题的标准选择,它内部已经包含了 Softmax 计算,所以不需要在网络输出层额外加 Softmax。优化器选了 Adam,初始学习率 2e-4,配合ReduceLROnPlateau在验证损失连续 3 个 epoch 不下降时自动把学习率降低到原来的 0.5 倍。
import torch import torch.nn as nn from torchvision import models model = models.resnet50(pretrained=True) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 6) # 冻结卷积层 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.fc.parameters(), lr=2e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=3 )3.2 训练循环与早停机制
训练循环的框架没什么稀奇,就是常规的前向传播、计算损失、反向传播、更新参数。但有两个细节值得强调。第一个是每个 epoch 结束后必须在验证集上计算损失和准确率,不能只看训练集表现,否则你根本不知道模型是过拟合了还是欠拟合。第二个是早停机制,当验证准确率连续 10 个 epoch 没有提升时,保存最佳模型并停止训练。这样既能防止过拟合,又能节省时间。
我习惯在训练过程中记录每一轮的训练损失、验证损失和验证准确率,用折线图可视化出来。从曲线形状能直观判断很多问题:如果训练损失持续下降但验证损失先降后升,就是过拟合;如果两者都不降,说明学习率太大或者模型容量不够;如果验证准确率有大幅震荡,可能是 batch size 太小或者数据增强太激进。
best_acc = 0.0 patience = 10 early_stop_counter = 0 for epoch in range(num_epochs): model.train() running_loss = 0.0 for inputs, labels in train_loader: 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) epoch_loss = running_loss / len(train_dataset) val_loss, val_acc = validate(model, val_loader, criterion, device) scheduler.step(val_loss) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') early_stop_counter = 0 else: early_stop_counter += 1 if early_stop_counter >= patience: print(f'Early stopping at epoch {epoch}') break4. 训练过程中的常见问题与排查技巧
这个部分分享我实际操作中踩过的坑,每一台都是真金白银换来的经验。
4.1 显存溢出怎么办
很多人一上来就想用大 batch size,直接把图片全部扔进 GPU,结果报CUDA out of memory。解决思路一般有三个方向。第一,减小 batch size,从 32 降到 16 或 8,这是最直接的方法。第二,降低图片输入分辨率,有些场景不需要 224x224,改成 160x160 能省近一半显存。第三,用混合精度训练,PyTorch 的torch.cuda.amp包能自动把部分计算改成 FP16 精度,显存占用能下降将近一半,速度反而更快。
4.2 验证准确率在 50% 左右徘徊
这个我遇到过好几次。50% 对于 6 分类问题来说就是随机猜测水平,基本等于模型完全没学到东西。排查顺序是这样的:先看训练损失有没有下降,如果没下降,检查数据增强是不是太猛,比如旋转角度过大把关键信息旋没了;如果训练损失降了但验证准确率不动,检查验证集的标签是不是对的,我犯过一次低级错误,ImageFolder按目录名顺序排类别,我手动改标签顺序导致类别映射错位。再一个常见原因是学习率设置不当,可以先试试在训练集上用小数据跑 5 个 epoch,看能不能达到比较低的训练损失,如果不能,说明模型本身有问题,不是数据的问题。
4.3 训练速度慢到无法忍受
除了前面说的调大num_workers和打开pin_memory之外,还有一个隐蔽的坑:模型一直在用 CPU 跑。很多人装完 PyTorch 之后没有验证 CUDA 是否可用,结果to(device)里 device 是cpu,整个训练过程都在 CPU 上煎熬。我就遇到过一次,训练一个 epoch 要 40 分钟还以为是数据量太大,后来发现是torch.cuda.is_available()返回了 False,检查之后才发现装的 PyTorch 是 CPU 版本,重装成 CUDA 版本之后一切正常。
5. 模型评估与部署延伸
模型训练好之后,光看准确率还不够,还需要进一步评估和部署。
5.1 混淆矩阵与错误案例分析
准确率是一个全局指标,它掩盖了很多细节。我用 sklearn 的confusion_matrix画了混淆矩阵,发现模型特别喜欢把“针叶林”错分成“落叶林”,原因是两者的树冠纹理在远处看非常接近。针对这个问题,我有两个选择:一是收集更多区分度高的样本,增强模型对这两类的判别能力;二是调整类别权重,让模型更关注容易混淆的类别。还有一个更实用的思路,是在预处理阶段把图像裁剪得更紧凑,让树冠的细节更清晰,而不是把整张风景照都塞进去。
5.2 导出 ONNX 模型
模型要上线部署的话,通常不会直接用 PyTorch 的.pth文件,因为生产环境不一定有 PyTorch,而且 PyTorch 的推理性能相对一般。比较通用的做法是导出成 ONNX 格式,然后用 ONNX Runtime 或者 TensorRT 推理。导出过程很简单,但有几个坑需要注意。
第一,必须固定输入尺寸,因为 ONNX 的输入 tensor 要指定 shape;第二,如果模型里有动态操作(比如自适应池化),导出时要小心,好在 ResNet 的结构是固定的,不存在这个问题;第三,导出前一定要把模型切到 eval 模式,否则 BN 层的行为会不一致,导出之后模型效果会和训练时对不上。
import torch from torchvision import models model = models.resnet50(pretrained=False) model.fc = torch.nn.Linear(2048, 6) model.load_state_dict(torch.load('best_model.pth')) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, 'forest_resnet50.onnx', input_names=['input'], output_names=['output'], opset_version=11, dynamic_axes=None ) print('ONNX export done')我个人实际操作下来,ONNX 模型在 CPU 上的推理速度比 PyTorch 原生部署提升了 20% 到 30%,这在业务场景里是很可观的收益。如果追求极致性能,还可以再转成 TensorRT,但那需要对 GPU 环境做更精细的配置,属于进阶玩法了。
6. 个人实践心得与后续扩展建议
最后分享一点我在整个项目中积累的体会。
做图像分类项目,我最深刻的感受是:模型结构其实不是瓶颈,数据和流程才是。用预训练 ResNet 做迁移学习,基本上一开始就能达到 85% 以上的准确率,后面提升的那几个百分点,全靠数据增强策略、类别平衡处理、混淆样本分析这些细节堆出来的。很多同学喜欢花大量时间调网络结构,但在业务场景里,先把 baseline 跑通、把训练流程稳定下来,再去优化精度,是效率高得多的路径。
还有一个实用的小技巧:每次训练前把随机种子固定住,这样不同轮次的实验结果才能公平对比。我用random.seed(42)、torch.manual_seed(42)和torch.cuda.manual_seed_all(42)一起固化随机性,省去了很多“玄学调参”的烦恼。
如果你打算把这个项目进一步扩展,方向其实不少。比如从单标签分类扩展到多标签,森林图片里可能同时出现水域和树木,这时候损失函数要换成BCEWithLogitsLoss;或者结合目标检测,先框出树冠区域再做分类,精度还能再上一截;再或者把训练好的 ResNet 当特征提取器,接入其他下游任务,比如图像检索或者零样本分类。路径很多,关键是先把一个完整流程跑通,后面的一切才有基础。