资讯动态

基于ResNet的2D图像多分类完整实战:从数据准备到模型部署

发布时间:2026/10/4 16:33:47 来源:尧图企业网站定制
简介基于ResNet的2D图像多分类完整项目面向深度学习初学者与图像分类任务学习者演示了从数据整理、模型训练到评估可视化的全流程实现。资源共20个文件压缩包仅382KB其中12个Python脚本构成完整代码框架涵盖数据预处理、图像增强、ResNet模型定义、训练主程序以及日志与进度条等工具模块另含6个pyc编译文件与2张输出示例图片便于直接查看运行效果。已有215人学习适合希望快速上手残差网络图像分类实践并理解训练调参细节的读者。通过该项目不仅可以掌握ResNet残差块的短路连接思想、多分类任务中的数据划分与标签生成方法还能学习如何借助验证集监控过拟合、利用可视化工具分析损失曲线与特征图为后续更复杂的视觉任务提供可复用的代码基座。1. 基于ResNet的2D图像多分类完整链路该怎么做才不返工做 2D 图像多分类很多人一上来就想甩一个 ResNet 跑通但真正动手才发现选哪个深度、用不用预训练权重、数据目录怎么摆、训练到什么时候算过拟合每一步都有细节。我经手的绝大多数简单多分类任务比如工业零件分拣、花叶病害分级、包装缺陷检测每类图几百张类别从十来个到上百个用 ResNet18 或 ResNet50 配合 ImageNet 预训练权重在单张消费级显卡上就能把基线做到可用。这个标题里的“完整版”三个字讲的正是从数据组织、训练脚本到评估部署这一整套流程适合做课程设计、小规模产线预研以及第一次把 ResNet 落地到自有数据集上的人。下面是我在类似任务里反复验证过的方案参数和边界一起给你。2. 为什么2D图像多分类选ResNet选型逻辑与数据准备2.1 残差结构解决的不是精度是“训练不动”ResNet 出现在 2015 年当时 ImageNet 分类的冠军模型是 152 层的残差网络。它解决的核心问题不是“精度不够”而是“网络深了之后训练误差反而上升”。这个现象叫退化问题跟过拟合无关——层数多了模型容量更大理论上应该拟合得更好但实际训练误差却更高。原因是梯度在反向传播里逐层衰减深层网络的浅层几乎拿不到有效梯度训练基本停摆。残差结构把每一层的目标从“学习完整映射”改成“学习残差”即输入加一个带参数的旁路。这样梯度可以从最后一层通过恒等映射直接回流到浅层网络再深也不会出现梯度消失。这个设计对 2D 图像多分类的直接意义是你可以放心用 18 层或 50 层的模型而不必担心训练不动。对小规模数据集我一般用 ResNet18参数量约 1100 万单张图前向在 CPU 上也能跑类别超过 50 个或图像纹理复杂时换 ResNet50参数约 2500 万精度提升明显但显存占用和训练时间也翻倍。选型上还有一个容易被忽略的点torchvision 现在用weights参数而不是老的pretrainedTrue。新写法更显式版本升级后不会因为参数过期而翻车。预训练权重的作用是让浅层卷积已经具备边缘、纹理、颜色块的识别能力小数据集上微调时这些特征直接迁移过来比从头训练快得多也更稳。2.2 数据目录三份分train / val / test 各管各数据准备是整个流程里最不该省的一步。我见过太多人把所有图片堆在同一个文件夹里用代码现场按比例切分。这种做法在复现时问题很大别人拿到你的代码不知道你当时随机切分用了什么种子结果可能完全不同。常见做法是在项目根目录下建data/train、data/val、data/test三个子目录每个子目录里按类别建文件夹放对应图片。训练集用来更新参数验证集用来挑模型和调超参测试集只在最后评估一次绝不能在训练过程中碰。这个组织方式直接对应 PyTorch 的ImageFolder接口它可以自动把子目录名映射成类别标签省去写 CSV 解析的功夫。类别数少时手工整理就行类别多时我会写一段脚本按比例随机拷贝同时保证每个类别在训练集和验证集里都有足够样本。一个常见基线是每个类别训练集不少于 100 张验证集不少于 30 张。如果某个类别只有 20 张图那不管用什么网络都很难学好优先考虑数据增强或换成小模型。2.3 ImageFolder 的字典序暗坑与类别映射保存ImageFolder有一个坑它是按文件夹名的字典序自动生成类别标签的不是按你创建目录的顺序。假设你的类别文件夹是cat和dog那cat一定是第 0 类dog是第 1 类不管你在文件系统里先建的是哪个。如果类别名是class_1、class_2、class_10排序后class_1、class_10、class_2和你直觉里的顺序完全不一样。这个暗坑的影响不是训练本身而是评估阶段你打印混淆矩阵时横轴纵轴的名字如果对应错了就会得出“模型把 A 错认成 B”的错误结论。我的习惯是训练脚本里先打印dataset.class_to_idx并把映射关系存成 JSON 文件评估时直接读这个文件来对齐类别名。import json import torchvision from torchvision import transforms data_root ./data train_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 torchvision.datasets.ImageFolder( rootf{data_root}/train, transformtrain_transform, ) # 把类别映射存下来评估和部署阶段要用 with open(class_to_idx.json, w) as f: json.dump(train_dataset.class_to_idx, f, indent2) num_classes len(train_dataset.classes) print(类别映射:, train_dataset.class_to_idx) print(类别数量:, num_classes)这段代码做的事情是读取训练集、打印类别映射、把映射存成 JSON。Resize((224, 224))是把所有图统一缩放到 224x224这是 ResNet 的标准输入尺寸。Normalize的参数是 ImageNet 的均值和标准差不能自己随便改。num_classes是模型最后一层的输出维度后面定义分类头时会用到。类别映射文件建议训练和评估脚本共用这样不会出现两边理解不一致的情况。3. 训练脚本三件套预处理、增强、断点续训3.1 用 ImageNet 的 mean 和 std不要自己统计数据集图像预处理里最容易犯的错是“自己算数据集的均值方差”。不少人觉得既然我用的是自己的数据就应该统计自己的 mean 和 std这个直觉本身没错但有一个前提只有当数据集在色彩分布上与 ImageNet 差异极大时才值得自己算比如灰度医学图像、红外图像、单通道工业检测图。自然光下的 2D 图像尤其是工业零件、商品、植物叶片这类色彩分布和 ImageNet 相差不大直接沿用 ImageNet 的归一化参数迁移学习效果反而更好。原因在于预训练权重是在 ImageNet 归一化后的特征空间里学出来的。如果你用自己统计的 mean 和 std相当于把输入分布强行挪到另一个坐标系前几层卷积的预训练参数就用不上了。这个说法不是玄学我做过多组对比自己统计均值的方案准确率普遍低 1 到 3 个百分点而且训练初期 loss 下降更慢。验证集和测试集的预处理必须只做尺寸调整和归一化不能加任何随机变换。原因很简单验证集和测试集要模拟真实推理环境同一个输入每次都应该得到同一个输出。如果验证集里也做随机翻转或裁剪那模型评估结果每次都不同你根本没法判断一个 checkpoint 到底改进了没有。3.2 数据增强要轻随机裁剪、翻转、色彩抖动是底线数据增强是防止过拟合的第一道防线但要控制力度。我见过有人把 AutoAugment、RandAugment、Mixup、CutMix 全堆上结果小数据集上训练集准确率反而上不去因为增强后图片已经严重偏离真实分布。对小规模多分类任务轻量增强就够用。from torchvision import transforms # 训练集轻量增强目标是让模型见过更多的位置和亮度变化 train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), 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]), ])RandomResizedCrop是随机裁剪一块区域再缩放回 224scale(0.8, 1.0)限制了裁剪面积不能小于原图的 80%这是为了避免把目标的关键部位裁掉。RandomHorizontalFlip只对左右对称的类别有效比如正面车牌就不适合翻转。ColorJitter的三个参数分别控制亮度、对比度和饱和度的随机幅度0.2 是个安全的经验值再大会导致颜色失真。一个重要的边界如果任务是医学影像等方向敏感的图水平翻转和随机裁剪都要关掉只能保留微小的色彩抖动。判断标准是“增强后的图片人眼是否还能正常辨认”如果人眼都觉得怪模型学出来的特征也靠不住。3.3 损失、优化器与学习率label_smoothing 和 cosine 退火多分类的标准损失函数是交叉熵PyTorch 的CrossEntropyLoss内部已经包含 softmax不需要在模型输出后再手动算。一个值得开的参数是label_smoothing它把 one-hot 标签里的一部分概率分配给其他类别让模型不会对训练集过于自信本质上是一个正则化手段。我一般设置为 0.1类别噪声大的数据集可以提到 0.15。优化器我优先选 AdamW它对小规模数据集足够鲁棒不需要像 SGD 那样精细调学习率和动量。学习率初始值用预训练权重时是 1e-4从头训练时是 1e-3。这个差异很重要预训练权重已经有不错的特征提取能力学习率太大容易把浅层参数冲坏从头训练则没有这个顾虑但需要更大的学习率加速收敛。权重衰减weight_decay我设置在 1e-4 到 5e-4 之间太小起不到约束作用过大会让模型欠拟合。学习率调度用余弦退火它能让学习率从初始值平滑降到接近 0比每隔多少轮减一半的阶梯式下降更稳。T_max设为总轮数eta_min设为初始学习率的百分之一。import torch import torch.nn as nn criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.AdamW( model.parameters(), lr1e-4, weight_decay1e-4, ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxepochs, eta_min1e-6, )CrossEntropyLoss(label_smoothing0.1)意味着真实标签占 0.9其余 0.1 平均分给其他类别。AdamW的weight_decay和 Adam 的实现不同它把权重衰减直接作用在权重上更符合原论文的语义所以用 AdamW 而不是 Adam。CosineAnnealingLR的T_max如果设得比实际轮数小学习率会提前降到最低点剩下的轮数里学习率不再变化效果会打折扣。3.4 checkpoint 与 resume断电后的后悔药训练过程最怕的是跑到一半断电或显存溢出全部从头再来。正确的做法是每个 epoch 结束都保存 checkpoint文件里不只存模型权重还要存优化器状态、调度器状态、当前 epoch 和最佳准确率。只存model.state_dict()的问题在于resume 时学习率会回到初始值因为调度器的状态丢了这可能让训练曲线直接崩掉。best_acc 0.0 start_epoch 0 # 从 checkpoint 恢复训练 resume_path checkpoints/best.pt if resume_path: ckpt torch.load(resume_path, map_locationcpu) model.load_state_dict(ckpt[model]) optimizer.load_state_dict(ckpt[optimizer]) scheduler.load_state_dict(ckpt[scheduler]) start_epoch ckpt[epoch] 1 best_acc ckpt[best_acc] for epoch in range(start_epoch, epochs): # 训练一个 epoch 并计算验证集准确率 train_one_epoch(model, train_loader, criterion, optimizer, scheduler) val_acc evaluate(model, val_loader) if val_acc best_acc: best_acc val_acc torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), epoch: epoch, best_acc: best_acc, }, checkpoints/best.pt) print(fEpoch {epoch}: 保存最佳模型, val_acc{val_acc:.4f})torch.load时用map_locationcpu可以避免 GPU 设备编号不一致时加载失败。load_state_dict是严格匹配的如果模型结构变了比如分类头输出维度不同会直接报错。保存优化器和调度器状态的意义是让检查点包含完整的学习率轨迹恢复后训练曲线能无缝衔接。我一般同时维护两个文件一个是最佳模型best.pt一个是最近模型last.pt后者用于训练中断时恢复前者用于最终评估和部署。4. 迁移学习ResNet预训练模型的正确打开方式4.1 冻结骨干只训分类头小数据集的稳妥起点ResNet 预训练模型下载自 ImageNet 分类任务靠weightsResNet50_Weights.IMAGENET1K_V2加载。迁移学习的第一步不是直接全量微调而是冻结骨干网络只训练最后的分类头。冻结的意思是让梯度不经过这些层反向传播时这些参数不会被更新。这样做的好处是训练速度快、显存占用低、不容易在小数据集上过拟合。分类头的输入维度由 ResNet 的fc层之前的输出维度决定ResNet18 和 ResNet34 是 512ResNet50 和 ResNet101 是 2048。替换分类头时输出维度必须等于你的类别数。import torch.nn as nn from torchvision import models # 加载带 ImageNet 预训练权重的 ResNet50 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) # 替换最后的全连接层输出改为自己的类别数 model.fc nn.Linear(2048, num_classes) # 冻结所有层只允许 fc 层的参数更新 for name, param in model.named_parameters(): if name.startswith(fc.): param.requires_grad True else: param.requires_grad Falseweightsmodels.ResNet50_Weights.IMAGENET1K_V2是 torchvision 当前推荐的加载方式老的pretrainedTrue在新版本里会触发弃用警告而且未来版本会移除。冻结时用name.startswith(fc.)判断是因为 torchvision 里最后一层就叫fc不同版本如果换成了分类器模块这个前缀要对应修改。这个阶段的训练轮数不需要多5 到 10 轮就能看到分类头是否有效。如果验证集准确率在这里就明显偏低问题大概率出在数据本身比如类别重叠、标注错误、训练集和验证集分布不一致。这时候不要急着解冻骨干先回去查数据。4.2 解冻微调学习率降一个量级粒度到层分类头训练到验证集准确率不再提升时进入微调阶段。这个阶段解开骨干网络的冻结让浅层特征也参与更新。但要控制节奏不是一次性解冻全部层而是一个贪婪地把最后几个残差块放开训练观察验证集变化后再决定是否进一步打开更多层。# 解冻最后两个残差块的参数只对它们设置 requires_grad True for name, param in model.named_parameters(): if layer4 in name or layer3 in name or fc in name: param.requires_grad True else: param.requires_grad False # 微调阶段把学习率降低一个量级 optimizer torch.optim.AdamW( [p for p in model.parameters() if p.requires_grad], lr5e-5, weight_decay1e-4, )layer3和layer4是 ResNet 中最后的两个残差阶段它们提取的是高层语义特征离具体任务最近微调收益最大。浅层的layer1和layer2学的是通用边缘和纹理保留原参数反而是好事。学习率从 1e-4 降到 5e-5因为此时需要微调的参数更多、可移动的余地更小。如果微调开始时验证集准确率反而下降第一反应是学习率太大降到 2e-5 再试。这里有一个很多新手忽略的细节冻结层后优化器里那些requires_gradFalse的参数会计算出梯度为 0但不参与更新的参数如果仍被传入优化器会浪费显存和算力。上面的写法把参数筛选了一遍只把需要更新的传进去。4.3 Early stopping给过拟合上最后一道保险即使加了数据增强和 label smoothing小数据集仍然容易过拟合。症状是训练集准确率接近 100%验证集准确率开始持续下降。Early stopping 是最后一道防线它不改变模型结构只在验证集表现恶化时提前终止训练。patience 5 wait 0 if val_acc best_acc: wait 1 if wait patience: print(f验证集连续 {patience} 轮未提升提前停止) break else: wait 0 best_acc val_accpatience的含义是允许验证集连续多少轮不提升。我一般设置为 5数据集大或噪声大时放宽到 8。注意这里的判断指标是验证集准确率不是损失。准确率更容易出现平台期容易误触发提前停止所以判断时还需要配合损失一起看。如果准确率持平但损失明显下降说明模型还在微调决策边界此时不应停。实际训练中我更喜欢把 Early stopping 和 cosine 退火配合使用cosine 负责把学习率平滑降下来Early stopping 负责在学习率降到很低但验证集仍然不提升时及时止损。两者不冲突因为 cosine 的T_max是预估的最大轮数Early stopping 只是提前结束。5. 实测中的5个坑现象、原因、解决一条条对5.1 数据侧的三个坑标签错位、泄露和类别不平衡坑一混淆矩阵里类别名对不上模型看起来“很蠢”现象是训练准确率很高但打印出的混淆矩阵里模型把一类物体系统性地错分成它的相邻类而且越看越有规律。原因多半是标签映射错了。ImageFolder按字典序排类名class_to_idx.json如果没保存评估脚本里重新加载数据集时会重新按字典序生成映射但实际上你的代码可能在某个地方把索引和类名直接画等号了。解决办法是评估时只读训练阶段保存的映射文件遇到未知类别名直接报错不要静默猜。import json # 评估时从文件读取映射保证和训练时的索引一致 with open(class_to_idx.json) as f: class_to_idx json.load(f) idx_to_class {v: k for k, v in class_to_idx.items()}另一个相关坑是nn.CrossEntropyLoss的标签必须从 0 开始连续整数。如果你的标注文件里类别编号是 1、2、5、9直接喂会报错或悄悄训练错误因为索引 3 和 4 没有对应类别。处理方式是训练前先把标签重新映射为连续索引并保存这个映射关系。坑二验证集准确率比训练集还高呈玄学波动现象是训练集准确率不是最高验证集却经常超过它而且换一个随机种子结果差好几个百分点。原因多数是验证集和训练集没有做好隔离比如同一个物体在不同照片里被同时分到了两个集合模型在训练集里“见过了”验证集的实体评估时等于开卷考试。这种情况在工业检测里尤其常见同一批零件在不同角度的照片按文件顺序切分时很容易被分到两边。解决方法是按物体实例而不是按单张图切分。也就是先对文件名字或所在目录做分组整个分组只能进入训练集或验证集中的一个。如果每个目录就是一类那至少要做到按目录整体切分。还有更严格的做法在训练阶段验证集只跑前向把所有model.train()和model.eval()的切换点检查一遍确认每轮验证用的都是 eval 模式。坑三类别不平衡导致模型只看得到大类现象是少数类准确率极低多数类准确率很高总体准确率看起来还行。原因很直接交叉熵损失在样本数量悬殊时优化器倾向于把参数调到“全预测为大类”的极端解因为这样 loss 最低。解决思路有两种按数据量选择。类别数少且差距不大时用WeightedRandomSampler给每个样本设定采样权重让每个 epoch 里每个类被抽中的概率接近。类别多或差距超过 10 倍时换成Focal Loss更稳。from torch.utils.data import WeightedRandomSampler # 计算每个类别的样本权重权重与样本数成反比 targets train_dataset.targets class_counts torch.bincount(torch.tensor(targets)).float() sample_weights torch.zeros(len(targets)) for i, t in enumerate(targets): sample_weights[i] 1.0 / class_counts[t] sampler WeightedRandomSampler( sample_weights, num_sampleslen(sample_weights), replacementTrue, )WeightedRandomSampler的作用是让数量少的类别里的图片被抽到的频率更高但这会带来一个副作用同一个 epoch 里某些图片反复出现训练速度变慢且容易在少数类上过拟合。因此配合使用时要同时加深数据增强和 weight decay。5.2 训练侧的两个坑3D堆叠误导和 resume 学习率重置坑四把 2D 图像堆叠为 3D 图像再用 3D 网络效果反而更差这是一个网上偶尔能看到的做法把 2D 图像的几张变换、多个通道或者其他样本堆叠成 3D 张量然后喂给 3D 卷积网络。这个思路在视频分类或医学影像里有依据因为那些数据在时间轴或空间深度上存在真正的关联。但静态 2D 图像堆叠成 3D 后新增的轴并没有引入任何新信息只是把同一张图的重复拷贝拼起来3D 卷积在这个轴上提不出有效特征。现象是模型容量变大、训练时间变长验证集准确率却比 2D ResNet 低好几个点。原因就是无效维度带来更多参数和过拟合风险而没有任何信息增益。正确的做法是坚持用 2D 卷积把堆叠的精力花在数据增强和模型微调上。如果确实需要多视角信息应采集同一物体的多个真实视角图像而不是对单张图做变换后伪堆叠。坑五resume 之后学习率和训练轮数错乱曲线平台期消失现象是训练中断后恢复前半段损失正常下降到接近之前中断点附近时损失突然重新从高位开始或者验证集准确率恢复后不再提升整体训练白白浪费。原因是 resume 时没有恢复优化器和调度器状态或者恢复了但start_epoch传错了。CosineAnnealingLR的T_max是总轮数恢复后如果直接从第 0 轮重新开始调度学习率会重新从初始值下降后面的轮数全部错位。解决方法是恢复时严格从torch.save的字典中取回optimizer、scheduler、epoch并把range(start_epoch, epochs)的起点改对。还有一个细节resume 后第一个 epoch 的 batch 顺序会变因为DataLoader的随机种子没有恢复这没关系不影响最终收敛无需为了这一点去保存torch.random状态。6. 评估和落地混淆矩阵、分类报告与导出模型6.1 多分类混淆矩阵代码三步画出像素级报告训练结束后的第一件事不是看总体准确率而是看每一类错在哪里。总体准确率会掩盖少数类的崩坏。混淆矩阵是最直接的诊断工具from sklearn.metrics import confusion_matrix, classification_report # all_labels 和 all_preds 是两个 list长度等于验证集总样本数 cm confusion_matrix(all_labels, all_preds) print(cm) # 分类报告直接给出每个类别的 precision, recall, f1-score print(classification_report( all_labels, all_preds, target_nameslist(idx_to_class.values()), digits3, ))confusion_matrix的行是真值、列是预测值对角线上的数字是正确分类的数量。classification_report的价值在于单类别的精确率和召回率某个类别的召回率特别低通常意味着它经常被错认成别的类。此时回到混淆矩阵看相邻类别再决定是补数据还是合并类别。画混淆矩阵时如果类别超过 20 个热力图的格子会过于密集直接打印矩阵数值更实用。我一般对超过 30 类的任务先看分类报告选出问题类别再用小范围的子矩阵展示。6.2 分类报告看单类精度别只看总体准确率分类报告里三个核心指标精确率是这个类别被预测的数量中有多少预测对了召回率是这个类别的真实样本中有多少被找回来了F1 是两者的调和平均。多分类任务里精确率和召回率天然互相制约只看 F1 取平均会掩盖结构性问题。正确的做法是把每个类别的这三个指标逐行看找出所有指标都低的类别再回到数据里确认这个类别是否本身存在标准不统一的问题。如果某个类别的召回率低且精确率高说明它经常被误判成其他类需要补充该类别更丰富的视觉形态如果精确率低但召回率高说明它的特征和其他类重叠严重考虑合并类别或重新梳理标注标准。6.3 导出 TorchScript 模型并验证评估完要落地常见做法是把最佳 checkpoint 转成 TorchScript 或 ONNX。TorchScript 的好处是脱离 Python 环境也能推理适合 C 服务和嵌入式场景model.eval() example torch.randn(1, 3, 224, 224) traced torch.jit.trace(model, example) traced.save(resnet50_multiclass.pt) # 导出后用同一张输入做推理和 pytorch eager 模式对比输出 with torch.no_grad(): ref model(example) out traced(example) print(最大误差:, (ref - out).abs().max().item())导出后验证是必须的一步。TorchScript 在trace模式下会固化控制流如果模型里有动态分支导出结果和原始模型可能不一致所以导出后务必用同一输入对比输出误差误差在 1e-5 以内才算通过。我现在的习惯是训练完成后把最佳 checkpoint、类别映射、混淆矩阵和分类报告一起归档标注好数据集版本和训练参数避免一个月后自己看着文件名猜不出是哪一轮的模型。这个习惯救过我很多次。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑