多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PyTorch图像分类实战:ResNet训练调参与部署全流程解析

PyTorch图像分类实战:ResNet训练调参与部署全流程解析 接手一个基于PyTorch的图像分类ResNet项目看似是经典的入门路线但真正动手做起来从环境搭建的坑到训练收敛的调参再到最后把模型跑起来做推理每一步都有不少值得记录的地方。这篇文章我打算把整个项目的思考过程和实操链路都梳理一遍包括为什么选ResNet、环境怎么配、数据怎么处理、训练脚本怎么写以及我在实际运行中踩过的一些坑。1. 为什么第一件正经事选ResNet不只看它经典很多人学PyTorch第一个正式项目不是LeNet手写数字识别就是VGG分类CIFAR。我自己的建议是如果时间只够做一个项目直接上ResNet。原因很朴素LeNet太老了很多现代训练技巧用不上VGG结构简单但参数爆炸显存吃不消。ResNet正好卡在一个甜点上——结构有讲究但又不至于复杂到难以理解而且它是目前绝大多数视觉任务backbone的事实起点后续做检测、分割、迁移学习都绕不开它。先说ResNet解决的到底是什么问题。在ResNet出现之前大家发现一个奇怪的现象网络越深训练集上的错误率反而越高。这不是过拟合而是纯粹的优化困难——几十层网络堆上去梯度在反向传播中很容易消失或者爆炸网络根本训练不动。ResNet的做法用一个很简单的类比就能讲清楚与其让每一层硬学一个完整的映射不如让每一层只学和输入的差异。比如你希望网络深层能学到某个理想映射 H(x)传统网络让层直接拟合 H(x)ResNet把结构改成 F(x) x即让层去拟合残差 H(x) - x。如果某个阶段已经最优了那网络只需要把残差学成0比从零学一个恒等映射容易得多。这个跳跃连接skip connection不引入额外参数但给梯度提供了一条高速公路深层网络也能稳定训练。实操层面还有两个更直接的理由让我推荐用它起步PyTorch官方torchvision里直接集成了ResNet的预训练权重ResNet18、ResNet34、ResNet50都一键可下先跑通推理再从头训练阶梯感很舒服。这个模型结构非常规整BasicBlock和Bottleneck两种模块划分清晰后续哪怕自己改结构做实验也是在它骨架上做文章。2. 环境准备PyTorch安装里最容易翻车的三个细节这个项目虽然模型是主角但真正花掉我最多时间的反而是环境搭建。PyTorch安装和普通Python库不一样它牵扯到CUDA版本和Python版本的匹配问题热搜词里那一堆ubuntu安装pytorchpytorch环境搭建wslcuda和pytorch对应其实都是同一个痛点。我建议无论你是Windows、Linux还是WSL都先统一走Anaconda路线虚拟环境隔离省心太多。2.1 先确认CUDA再决定安装命令PyTorch的加速依赖NVIDIA显卡驱动和CUDA。注意一个常见误区你系统里装的显卡驱动版本并不直接等于CUDA版本。PyTorch安装的是CUDA runtime库它只要求你的显卡驱动版本 某个最低版本即可。所以你第一步应该是打开命令行输入nvidia-smi看右上角的CUDA Version那个数字才是驱动支持的上限。比如显示CUDA 12.1那么你安装cu121版本的PyTorch就没问题。如果驱动显示CUDA 11.8就装cu118版本的。这个对应关系搞错了最常见的报错就是PyTorch is not compiled with CUDA enabled或者运行torch.cuda.is_available()返回False。2.2 用conda还是pip我的建议是Windows下用conda创建环境然后用pip安装PyTorch。原因是conda的PyTorch频道有时候解析依赖很慢而且容易把环境里的其他包搞乱。pip安装的PyTorch同样能正常用GPU两者区别没那么大。创建环境的常规操作conda create -n resnet python3.9 -y conda activate resnetPython版本我推荐3.9或3.10PyTorch 2.x对这两个版本的支持最成熟。如果你用的老代码或者CUDA版本很低建议先到PyTorch官网首页的Get Started页面查一下自己适合的命令。2.3 装完必须做的一次性验证装完先别急着写模型两条命令确认GPU可用import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果输出True和显卡型号恭喜环境这块你通关了。如果输出False八成是三个原因CUDA版本命令装错了、驱动太老、或者你机器上装了多个Python环境导致torch装到了别的环境里。排查顺序先看torch.__version__里的cu后缀和你的驱动CUDA版本是否匹配。3. 数据准备不是所有图像分类都得从零收集数据集图像分类项目最核心的资源就是数据。如果你有特定需求比如做森林图像分类、猫狗识别、工业缺陷检测那就需要自己整理数据集。但作为学习项目我强烈建议先从CIFAR-10或ImageNet子集入手别一上来就挑战从零训练一个大数据集分类器。CIFAR-10是60,000张32x32的彩色小图分为10个类别训练集50,000张测试集10,000张。它的大小非常合适——ResNet18在单卡上训练一个几十轮的版本普通GPU半小时到几小时能跑完。你完全可以在CIFAR-10上把整个pipeline跑通、把超参调明白然后再用相同代码去跑自己的数据。3.1 自定义数据集的两种组织方式如果你确实想用自己的图片数据做分类最简单的目录结构是这样dataset/ train/ class_a/ img1.jpg img2.jpg class_b/ img1.jpg val/ class_a/ class_b/torchvision的datasets.ImageFolder可以直接吃这种结构不用手写Dataset类from torchvision import datasets, transforms transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(dataset/train, transformtransform) val_dataset datasets.ImageFolder(dataset/val, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4)这里有个很多人没注意的细节Normalize的mean和std用的是ImageNet的统计值。因为这个项目我们会用到ImageNet预训练权重所以输入数据的分布必须和预训练时保持一致否则迁移效果会打折扣。如果你是从零训练建议用自己数据集的均值方差但大多数人没必要绕这个弯。3.2 数据增强性价比极高的涨点手段别急着堆模型深度先看看数据增强。这是整个项目里性价比最高的模块几个简单的随机操作就能让模型鲁棒性明显提升。我在训练脚本里最常配的增强组合是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]), ])随机裁剪模拟目标在画面中位置和大小变化水平翻转利用对称性成倍扩充样本颜色抖动让模型不依赖颜色绝对取值。这三件套加上去即使不调任何模型结构测试集准确率都能涨一两个点。训练集和验证集必须分开用不同的transform验证集只用Resize和Normalize不做随机增强——否则验证集每次迭代都在变你没法判断模型真实好坏。还有一个特别容易踩的坑DataLoader的num_workers参数。在Windows上如果设置num_workers 0经常会出现死锁或者内存爆炸的情况需要把代码包在if __name__ __main__:里。我第一次在公司Windows机器上跑就卡在这里后来改num_workers0或者放到Linux服务器上才顺利解决。4. 模型搭建ResNet的两种残差块和PyTorch实现细节PyTorch的torchvision里已经有现成的ResNet实现平时直接用torchvision.models.resnet18(pretrainedTrue)就能拿到。但既然要搞明白原理我建议至少手写一遍核心模块再回到官方实现上做对比。4.1 BasicBlock和BottleneckResNet18和ResNet34用的是BasicBlock结构是两条3x3卷积串起来然后和跳跃连接的输入相加import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) # 输入输出通道不一致时跳跃连接也要调整 self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity self.shortcut(x) out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out identity out self.relu(out) return out注意几个关键点卷积层biasFalse因为后面紧跟BatchNorm卷积的偏置会被BN层抵消留着反而浪费参数shortcut通道不匹配时用1x1卷积做投影步长为2时刚好把空间尺寸减半。ResNet50以上用的是Bottleneck先1x1降维、3x3卷积、再1x1升维计算量小很多适合更深的网络。用ResNet50做迁移学习时预训练权重的效果通常比ResNet18更好但训练和推理开销也大不少。4.2 预训练模型和自定义类别数的适配如果你做的是十分类以内的小任务强烈建议用预训练模型做迁移学习。torchvision里加载预训练权重的标准做法是import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, num_classes)这里最关键的一步是替换最后一层全连接fc因为预训练模型是在ImageNet的1000类上训的你得改成自己数据集的类别数。fc层之前的卷积特征提取部分全部保留它会提供非常强的通用视觉特征比如边缘、纹理、形状。就算你的数据和ImageNet差别很大前面几层学到的底层特征依然能用。4.3 冻结特征层还是全量微调这取决于你的数据量。数据量小每个类别几百张以下推荐冻结前面的所有层只训练最后新加的fc层for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True这样训练非常快而且不用担心在少量数据上把预训练特征破坏掉。数据量足够大每类几千张以上就放开全量微调让模型根据你的数据特性做进一步适配效果上限更高。我自己通常先冻结卷积层跑到验证集准确率不再上升再解冻最后几个Block做低学习率微调效果比一上来就全量训练稳定得多。刚开始学习的时候最好还是先从训练集和测试集分布差异的角度去理解冻结的意义预训练模型已经见过了数千万张图片的通用分布你的任务只是把新数据映射到那套特征分布里边强行微调反而是舍近求远。5. 训练流程Loss、优化器、学习率以及完整的训练脚本5.1 损失函数为什么用交叉熵图像分类是离散标签预测最合适的损失是交叉熵CrossEntropyLoss。PyTorch的nn.CrossEntropyLoss内部把LogSoftmax和NLLLoss合二为一了所以当你用它的时候模型的最后一层不需要额外加Softmax直接输出未归一化的logits就行。这一点很容易搞混——很多新手在最后一层加了Softmax再用CrossEntropyLoss结果训练时梯度不稳定或者数值出现问题。交叉熵可以简单理解为我们希望模型对正确类别的预测概率尽量接近1对其他类别的概率尽量接近0损失函数度量当前预测分布和真实分布的差异。它比MSE在这种离散分类任务上收敛快得多因为Softmax输出的梯度形式更有利于模型快速调整。5.2 优化器和学习率的搭配优化器我推荐SGD的动量版本optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4)现在Adam系列很流行但在这个项目里SGD Momentum的表现通常更稳尤其配合预训练模型做微调时。Adam的优点是自适应学习率、少调参但泛化性能在不少视觉任务上略逊于精调的SGD。如果你经验不够建议先固定用SGDlr从0.01开始碰到不收敛就降到0.001大部分情况这个范围都能兜住。训练过程中最常用的学习率策略是余弦退火CosineAnnealingLR或者阶梯下降StepLR。余弦退火前段下降平缓、后段接近收敛时精细调优我现在基本固定用这个scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs)每轮结束后调用scheduler.step()。Batch size这个问题也值得单独提一句。在显存允许范围内batch size尽量往大了设但不要贪心。同样的学习率下batch size翻倍会导致梯度估计更稳定但收敛步数变多。我常用的区间是32到128小数据集64就够用。5.3 完整训练循环这是我项目里实际在用的训练核心循环做了适当精简但关键逻辑都保留了def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() 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 evaluate(model, val_loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), 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这里最值得说的是model.train()和model.eval()的切换。因为网络里有BatchNorm层它在训练时用当前batch的均值方差做归一化在评估时用训练阶段统计好的全局均值方差。忘了切model.eval()是新手最容易掉进去的坑后果是验证时准确率忽高忽低还找不到原因。device这块我用的是一个很简洁的写法device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)不要忽略model.to(device)很多人模型定义时在CPU上数据集加载也在CPU上两边的参数和数据张量在不同设备一forward就报错或者巨慢。每次训练循环里还要记得images, labels images.to(device), labels.to(device)数据不搬上GPU照样报TypeError。主循环加日志输出每5轮打印一次epochs 50 best_acc 0.0 for epoch in range(epochs): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch1}/{epochs} | fTrain Loss: {train_loss:.4f} Acc: {train_acc:.4f} | fVal Loss: {val_loss:.4f} Acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)保存模型的时候保存state_dict而不是整个模型是推荐做法体积小、可移植性好加载时只需要事先重建好模型结构再load_state_dict。5.4 一个常被忽略的参数weight_decay在优化器里加weight_decay1e-4相当于L2正则化给大权重加惩罚抑制过拟合。这个参数某种程度上比调dropout更省心。尤其在迁移学习全量微调阶段如果不加weight_decay很快就能看到验证集指标开始挣扎训练集指标却仍然很漂亮——典型的过拟合信号。加了之后差距会更平滑。6. 评估与推理混淆矩阵比准确率更能说明问题训练完之后很多人看一眼准确率就收工了。但真正想把这个项目做好我建议再做两层工作混淆矩阵分析和单张图片的可视化推理流程。6.1 混淆矩阵看清模型到底错在哪准确率只能告诉你多少对混淆矩阵能告诉你在哪些类别上翻车。比如在森林图像分类场景里你很快会发现模型总把落叶林和针叶林搞混但火烧迹地分得很准。这种信息对后续优化数据集结构很有价值——是不是这两类的训练样本太少是不是角度、光照特征太相似我用sklearn提供的混淆矩阵计算方法然后配合matplotlib画出来from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay all_preds [] all_labels [] model.eval() with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) disp ConfusionMatrixDisplay(confusion_matrixcm) disp.plot(cmapBlues)注意推理阶段同样要model.eval()和torch.no_grad()前者关掉BN的batch统计后者避免计算图保存中间变量省显存而且加速。6.2 单张图片的推理入口为了方便我把单张推理封装成一个简单的函数后续接API或者做批量测试都很顺手from PIL import Image def predict_image(model, image_path, class_names, device): image Image.open(image_path).convert(RGB) transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): output model(input_tensor) probs torch.softmax(output, dim1) confidence, pred_idx torch.max(probs, 1) return class_names[pred_idx.item()], confidence.item()有个关键点是PyTorch模型的输入是四维张量[N, C, H, W]单张图片只有三维[C, H, W]必须用unsqueeze(0)在最前面加一维batch维度。我最初写推理代码时漏过这一步报了一堆维度不匹配的错。6.3 关于置信度的一个提醒softmax输出的概率分布不一定代表模型真的这么有把握。当输入图像完全不在训练数据分布里时比如一张空白的纯色图模型照样会输出某个类别的较高置信度。这是深度模型的常见毛病解决思路是额外做OOD检测或者设置置信度阈值低于阈值就返回不确定。这个项目阶段不用做得很复杂但要在心里有这根弦。7. 模型部署从PyTorch到ONNX的转换经验热搜词里有人搜pytorch转onnx这确实是这个项目往后走的一个自然需求。训练完的模型要跑到生产环境通常不会直接在服务上装PyTorch——环境太重量级。ONNXOpen Neural Network Exchange是主流的中间表示格式可以转成ONNX Runtime、TensorRT等运行时推理轻量且高效。7.1 导出ONNX的基本流程如果你的输入是动态尺寸需要提前指定一批固定尺寸model.eval() dummy_input torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, resnet18.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, opset_version13 )这里用dummy_input做了一次假推理目的就是让PyTorch记录一遍模型结构。dynamic_axes允许batch维度可变部署时请求多少张图都行。但如果你的场景固定batch1可以省略dynamic_axes转出的模型在某些推理框架下能再优化一些。7.2 转换之后必须做一致性验证转换完不是直接上线先跑一下对比。拿同一张图片分别用PyTorch模型和ONNX Runtime推理看输出差异有多大。import onnxruntime as ort ort_session ort.InferenceSession(resnet18.onnx) ort_inputs {ort_session.get_inputs()[0].name: input_tensor.numpy()} ort_outputs ort_session.run(None, ort_inputs) print(torch_output.numpy().flatten()[:5]) print(ort_outputs[0].flatten()[:5])正常情况下两者的输出应该是几乎完全一致的差几个小数点后几位。如果出现明显差异优先怀疑BatchNorm层在导出时的行为——ONNX会固化BN的统计参数如果转换前没有调用model.eval()导出的模型行为会和在训练模式下一样推理结果就错乱了。这个问题检查方式很直接导出前打印model.training确保是False。7.3 ONNX Runtime的推理优化感受ONNX Runtime有个特点它根据CPU或GPU的硬件指令集做图优化同样的模型推理速度通常比原生PyTorch在CPU上快不少。我这里没有具体到数字的跑分因为在不同机器上差异很大但体感是——同样一个ResNet18在普通CPU机器上转ONNX之后跑延迟能压到几十毫秒级别这已经能支撑不少实时性要求不高的业务场景了。8. 整个项目做完后的个人体会和复盘8.1 最浪费时间的坑排序我把这次项目里踩过、以及带人时常见的问题按消耗时间排个序给后来人一个预期的心理建设环境问题CUDA版本对应错误、conda和pip环境混了、Windows下num_workers死锁。这些加起来可能占整个项目40%的时间。训练不收敛学习率设太高导致loss震荡、BN层忘了切换model.train()/model.eval()。数据问题图片没统一resize、Normalize参数用错、类别的目录名错位。模型设计问题最后一层类别数和数据集不一致。这些看起来都是小问题但排查起来一个比一个隐蔽。我自己的习惯是每到一个阶段先在控制台打印几行关键信息——数据加载后验证一下batch的shape和类别数、模型拿到后打印一下结构、训练第一个batch前打印一次loss初始值。这些习惯能省下大量事后debug的时间。8.2 关于跑通和做好的差距跑通一个ResNet图像分类项目跟着教程走快的话一个下午就能完成。但从跑通到做好之间还有很长的路要磨。比如在CIFAR-10上ResNet18随便训练就能有75%以上的准确率但想上到90%以上你就必须认真做数据增强、精细调学习率、多跑几个epoch看趋势。这个过程才是最值得花时间的部分——它教给你的不是某个API的用法而是我的模型距离真实可用还差多少的工程判断力。8.3 后续可以扩展的方向如果你做完这个项目还想继续深入我建议按下面的顺序尝试把自己的数据集换成更复杂的场景比如森林图像分类增加类别数和样本量体会数据规模对模型选择的影响。把ResNet18换到ResNet50对比效果和显存开销理解模型容量和数据集规模的匹配关系。尝试用相同的代码结构跑一个目标检测模型比如Faster R-CNN或YOLO感受从分类到回归分类联合任务的跳跃。封装一个简单的HTTP推理服务把ONNX模型接上去走一遍真正的部署链路。最后说一个个人很强烈的体会图像分类是深度学习中麻雀虽小五脏俱全的典型任务它把数据集组织、模型设计、损失函数、优化器、评估、部署这些核心概念全部串了一遍。认真做完你对PyTorch的理解不会停留在API调用层面而是建立起了数据流向模型、梯度回流、评估闭环的整体直觉。这份直觉比任何单独的知识点都值钱。
返回列表