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

文章详情

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

ResNet 2D图像多分类实战:从原理到部署的完整链路

ResNet 2D图像多分类实战:从原理到部署的完整链路 简介这份资源面向深度学习入门与进阶学习者提供基于ResNet的2D图像多分类任务完整实现适合希望掌握图像分类全流程的开发者练手。内容围绕数据准备、残差块结构、训练验证与结果可视化展开涵盖图像预处理、数据集划分、优化器与损失函数选择、学习率调度以及预测后处理等关键环节帮助读者理解深度网络如何缓解梯度消失并完成多类别判别。压缩包共20个文件以12个Python源码为主辅以6个编译缓存文件与2张png图片整体约382KB代码按数据、模型、工具与可视化等模块组织结构清晰便于阅读与二次修改。目前已有215人学习下载。通过该资源读者可获得可直接运行的训练与评估脚本、ResNet模型定义、数据增强与标签生成工具以及损失曲线和特征图可视化示例从而快速搭建自己的图像分类基线并积累调参排错经验。1. 从一堆散图到能用的分类器ResNet 做 2D 图像多分类到底难在哪手里有几千张甚至几万张 2D 图像分属十几个类别想训一个能上线的分类器——这是很多工程师接到的第一个真实视觉任务。它不像论文里那样干净类别不均衡、标注有噪声、显存不够、训完发现混淆矩阵惨不忍睹。ResNet 是这条路上最稳的起点残差结构让几十层网络能真正训起来预训练权重又能把收敛速度拉快一大截。但“能跑”和“能用”之间隔着数据划分、增强策略、学习率调度、类别权重、阈值选择这一整套工程细节。这篇笔记就按一条完整链路走先把 ResNet 做 2D 多分类的原理和选型讲清楚再给可复现的训练脚本、参数配置和混淆矩阵分析最后把踩过的坑摊开。适合刚接手图像分类任务、想一次把流程跑通并知道边界在哪的从业者。2. ResNet 为什么适合 2D 图像多分类残差、预训练与输出层选型2.1 残差连接解决的到底是什么问题2D 图像分类的核心是把 H×W×3 的像素张量映射到 N 个类别的概率分布。卷积层负责逐层抽取边缘、纹理、部件、语义但网络一深就出问题梯度在反向传播中反复相乘要么爆炸要么消失训练误差不降反升。ResNet 的残差块把目标函数从直接拟合 H(x) 改成拟合 F(x)H(x)−x输出走一条 shortcut 把输入恒等加回来。这样梯度至少能沿恒等路径无损回传几十上百层才训得动。对 2D 图像来说残差块通常成对出现两个 3×3 卷积中间夹 BatchNorm 和 ReLU再和 shortcut 相加。空间尺寸靠 stride2 的卷积或池化下采样通道数逐段翻倍。以 ResNet-50 为例主干分四个 stage通道从 64 一路到 2048最后接全局平均池化把每个通道压成一个数再送全连接层出类别 logits。全局平均池化替代了早期的巨型全连接参数量小、抗过拟合是 2D 分类的常见收尾方式。选 ResNet 而不是 VGG 或纯 CNN理由很实际同等精度下参数更少、显存占用更低预训练权重生态成熟ImageNet 上训过的权重拿来微调小数据集也能收敛。常见做法是从 torchvision 或 timm 加载预训练权重把最后的 fc 换成 N 类输出。2.2 预训练权重怎么选、怎么冻、怎么解冻预训练权重的使用分三种策略选哪种取决于你的数据量数据量级策略可训练参数适用场景每类 100 张冻结主干只训 fc极少快速验证、基线每类 1001000 张冻结前几个 stage微调后段中等大多数工业任务每类 1000 张全网络微调小学习率全部追求最高精度冻结的实现很直接遍历 parameters 把 requires_grad 置 False只留最后分类层。解冻时不要一次性全开先训几轮分类头让随机初始化的 fc 稳定再以更小的学习率解冻主干否则预训练权重会被大梯度冲垮。这是血泪经验直接全网络大学习率微调前几个 epoch 损失经常直接飙到 nan。import torch import torch.nn as nn from torchvision import models def build_resnet(num_classes, backboneresnet50, pretrainedTrue, freeze_backboneTrue): # 加载预训练主干weights 参数在新版 torchvision 中替代了 pretrained weights models.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None model models.resnet50(weightsweights) # 替换分类头输出维度对齐你的类别数 in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) if freeze_backbone: # 冻结除 fc 外的所有参数 for name, param in model.named_parameters(): if not name.startswith(fc.): param.requires_grad False return model model build_resnet(num_classes12, freeze_backboneTrue) # 只把需要梯度的参数交给优化器避免无谓计算 trainable [p for p in model.parameters() if p.requires_grad] optimizer torch.optim.AdamW(trainable, lr1e-3, weight_decay1e-4)这段代码的关键点有三个。第一weights 用枚举而不是布尔值新版 torchvision 已经弃用 pretrainedTrue 的写法继续用会有警告甚至行为不一致。第二冻结判断用 name.startswith(fc.)因为 ResNet 的分类层属性名固定是 fc比按顺序切片更稳。第三优化器只接收 requires_grad 为 True 的参数冻结部分不参与更新也不占优化器状态显存。参数上冻结阶段学习率可以给到 1e-3解冻后要降到 1e-4 甚至 1e-5。2.3 输出层与损失函数多分类不是只有 CrossEntropy多分类默认用 CrossEntropyLoss它内部把 LogSoftmax 和 NLLLoss 合在一起输入是原始 logits不要自己先 softmax。类别不均衡时给它加 weight 参数按类别频率的倒数或平方根倒数赋权。如果任务里一张图可能属于多个类那就不是多分类而是多标签要把损失换成 BCEWithLogitsLoss输出层不加 softmax。输出维度等于类别数标签用 0 到 N−1 的整数不要用 one-hot 传给 CrossEntropyLoss。推理阶段取 argmax 得到预测类但如果某些类误判代价高可以调阈值或做后处理。常见误区是把 softmax 后的概率当置信度直接卡 0.5多分类里各类概率和恒为 1卡固定阈值没有意义应该看相对大小或做温度缩放校准。3. 从原始图片到可训练数据集划分、增强与 DataLoader 配置3.1 目录结构与数据划分的三种做法最常见的目录组织是按类分文件夹每个文件夹放该类所有图片torchvision 的 ImageFolder 能直接读。但 ImageFolder 不做训练验证划分需要自己切。三种做法各有取舍第一种是随机切分用 random_split 按 8:1:1 分训练验证测试。简单但类别不均衡时某个类可能全被分到验证集。第二种是分层切分用 sklearn 的 train_test_split 带 stratify 参数保证每个类在三个集合里比例一致这是推荐做法。第三种是固定文件清单把划分结果写进 csv训练时按清单读可复现性最好适合需要多次实验对比的场景。import os from sklearn.model_selection import train_test_split def build_file_list(root): paths, labels [], [] classes sorted(os.listdir(root)) for idx, cls in enumerate(classes): cls_dir os.path.join(root, cls) for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png, .bmp)): paths.append(os.path.join(cls_dir, fname)) labels.append(idx) return paths, labels, classes paths, labels, classes build_file_list(data/train) # 先切出测试集stratify 保证类别比例一致 X_train, X_test, y_train, y_test train_test_split( paths, labels, test_size0.2, stratifylabels, random_state42) # 再从训练集切验证集 X_train, X_val, y_train, y_val train_test_split( X_train, y_train, test_size0.125, stratifyy_train, random_state42) print(ftrain{len(X_train)} val{len(X_val)} test{len(X_test)})stratify 参数是这段代码的核心它按标签分层抽样避免小类别在某个集合里消失。random_state 固定后每次划分一致方便复现。切分比例上数据少时验证集可以只留 10%但要保证每个类至少有几张。注意测试集要留到最后再动调参阶段只看验证集否则测试集就失去了评估意义。3.2 数据增强训练集和验证集必须用不同的 transform增强只作用于训练集验证和测试集只做 resize 和归一化。这是新手最容易翻车的地方给验证集也加了随机翻转和裁剪导致评估结果每次都不一样指标忽高忽低。训练增强的常见组合是 RandomResizedCrop 加 RandomHorizontalFlip颜色抖动看任务决定。医学图像、工业缺陷检测这类对颜色和方向敏感的翻转和颜色抖动要慎用可能把有意义的特征破坏掉。归一化用 ImageNet 的均值和标准差因为预训练权重是在这个分布上训的输入分布对齐才能发挥微调效果。from torchvision import transforms # 训练集随机裁剪 翻转 颜色抖动 归一化 train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 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]), ]) # 验证/测试集只做确定性 resize 和归一化 val_tf 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 的 scale 下限设 0.7 而不是默认的 0.08是因为 2D 分类任务里主体通常占画面较大比例裁得太狠会把关键区域切掉。输入尺寸 224 是 ResNet 的标准输入改了也能跑但预训练权重的空间先验会失配非必要不改。归一化的均值标准差必须和预训练时一致写错会导致收敛变慢甚至不收敛。3.3 DataLoader 的 num_workers 与 pin_memory 怎么设DataLoader 有两个参数直接影响训练速度。num_workers 是数据加载的并行进程数设 0 表示主进程加载设 4 到 8 能明显提速但设太大反而因为进程切换开销变慢一般取 CPU 核数的一半左右。pin_memory 设为 True 会把数据锁在页锁定内存加速 CPU 到 GPU 的拷贝用 GPU 训练时建议开。from torch.utils.data import Dataset, DataLoader from PIL import Image class ImageListDataset(Dataset): def __init__(self, paths, labels, transform): self.paths paths self.labels labels self.transform transform def __len__(self): return len(self.paths) def __getitem__(self, idx): # 统一转 RGB防止灰度图或带 alpha 通道的图报错 img Image.open(self.paths[idx]).convert(RGB) return self.transform(img), self.labels[idx] train_ds ImageListDataset(X_train, y_train, train_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue)drop_lastTrue 在训练集上丢掉最后一个不满 batch 的数据避免 BatchNorm 在 batch 大小为 1 时统计量失真报错。验证集的 DataLoader 不要 shuffle也不要 drop_last保证评估覆盖全部样本。batch_size 受显存限制ResNet-50 在 224 输入下11GB 显存大约能跑 32 到 64显存不够就降 batch 或上混合精度。4. 训练循环、学习率调度与混淆矩阵分析4.1 训练循环的骨架与混合精度训练循环的骨架是标准五步前向、算损失、清梯度、反向、更新。混合精度用 torch.cuda.amp 把部分计算降到 float16显存省一半、速度提一截配合 GradScaler 防止梯度下溢。这是 ResNet 训练里性价比最高的优化之一。import torch from torch.cuda.amp import autocast, GradScaler def train_one_epoch(model, loader, criterion, optimizer, scaler, device): model.train() total_loss, correct, total 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() with autocast(): logits model(imgs) loss criterion(logits, labels) # scaler 放大损失避免 float16 梯度下溢 scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss loss.item() * imgs.size(0) correct (logits.argmax(1) labels).sum().item() total imgs.size(0) return total_loss / total, correct / totalautocast 上下文里前向自动选精度scaler 负责反向的梯度缩放。注意 loss.item() 要乘 batch 大小再累加最后除以总样本数否则平均损失算错。argmax(1) 取维度 1 上的最大索引作为预测类。训练准确率只是参考真正要看的是验证集指标。4.2 学习率调度余弦退火与 warmup 的配合学习率是训练里最玄学的超参。固定学习率要么前期太慢要么后期震荡常见做法是余弦退火从初始值按余弦曲线降到接近 0。前几个 epoch 再加 warmup让学习率从很小线性升到初始值避免随机初始化的分类头在训练初期把主干权重带偏。from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) # 前 3 个 epoch 线性 warmup之后余弦退火到 1e-6 warmup LinearLR(optimizer, start_factor0.1, total_iters3) cosine CosineAnnealingLR(optimizer, T_max47, eta_min1e-6) scheduler SequentialLR(optimizer, schedulers[warmup, cosine], milestones[3])start_factor0.1 表示 warmup 起点是初始学习率的十分之一。T_max 是余弦周期设成总 epoch 数减去 warmup 轮数。milestones[3] 表示第 3 个 epoch 切换到余弦。注意 scheduler.step() 每个 epoch 调一次不要每个 batch 调否则学习率降得过快。解冻主干时记得重建优化器或手动调小学习率否则预训练权重会被大学习率破坏。4.3 混淆矩阵看出模型到底错在哪准确率只告诉你对了多少混淆矩阵告诉你错在哪。多分类的混淆矩阵是 N×N 矩阵行是真实类列是预测类对角线是正确数。用 sklearn 的 confusion_matrix 一行出结果再配合 classification_report 看每个类的精确率、召回率和 F1。import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, classification_report def evaluate(model, loader, device, classes): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.to(device) logits model(imgs) preds logits.argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_namesclasses)) # 归一化后画图颜色深浅反映误判比例 cm_norm cm.astype(float) / cm.sum(axis1, keepdimsTrue) fig, ax plt.subplots(figsize(10, 8)) im ax.imshow(cm_norm, cmapBlues) ax.set_xticks(range(len(classes))) ax.set_yticks(range(len(classes))) ax.set_xticklabels(classes, rotation45, haright) ax.set_yticklabels(classes) for i in range(len(classes)): for j in range(len(classes)): ax.text(j, i, f{cm_norm[i, j]:.2f}, hacenter, vacenter) plt.colorbar(im) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150) return cmmodel.eval() 必须调否则 BatchNorm 用 batch 统计量、Dropout 还在随机丢弃评估结果不可信。torch.no_grad() 关掉梯度记录省显存提速。归一化按行除每行和为一颜色深浅直接反映该类被误判到各类的比例。看矩阵时重点找两类某一行非对角线普遍偏高说明这个类召回低某一列非对角线偏高说明这个类精确率低容易被别的类误判进来。针对性地补这类样本或调类别权重比盲目加数据有效。5. 避坑与排查ResNet 多分类最常见的五个翻车点5.1 损失不降或直接变 nan现象训练几个 batch 后损失变成 nan或者一直卡在 ln(类别数) 附近不动。原因通常是学习率太大、混合精度下梯度溢出、或者标签越界。先检查标签最大值是否小于类别数越界会让 CrossEntropyLoss 直接报错或产生异常梯度。再确认混合精度用了 GradScaler没用的话 float16 梯度很容易下溢。最后把学习率降一个数量级试解冻主干阶段尤其要小。解决顺序是查标签范围、查 scaler、降学习率。5.2 验证准确率远低于训练准确率现象训练集准确率 95%验证集只有 60%。原因一般是过拟合或数据分布不一致。先看两个集合的 transform 是否用错验证集误用了训练增强会导致指标虚低且波动。再确认训练验证是否来自同一批数据源不同设备、不同光照采集的图混在一起模型学到的可能是采集特征而非类别特征。解决手段是加数据增强、加 weight_decay、早停或者检查划分时有没有同类图片泄漏到两个集合。5.3 某个类别指标特别差现象混淆矩阵里某一类召回率极低几乎全被预测成另一类。原因通常是类别不均衡小类别样本太少模型倾向于预测多数类。解决是给 CrossEntropyLoss 加 weight按类别频率倒数赋权或者用重采样让每个 batch 里各类比例接近。如果两类视觉上确实相似光靠调权重不够要回到数据层面补充区分性样本或者考虑更细粒度的特征。5.4 显存溢出现象训练中途报 CUDA out of memory。原因可能是 batch 太大、输入分辨率太高、或者验证阶段没关梯度。先降 batch_size再考虑用混合精度。验证和推理一定要包在 torch.no_grad() 里否则中间激活全保留显存翻倍。如果还不行用梯度累积模拟大 batch小 batch 跑几次再 step 一次优化器。5.5 推理结果和验证结果对不上现象验证集指标很好部署后单张推理结果却很差。原因多半是预处理不一致。训练时用了 Normalize推理时忘了或者推理输入尺寸和训练不一致又或者 PIL 读图和 OpenCV 读图的通道顺序不同。解决是把预处理封装成一个函数训练和推理共用同一份代码杜绝两处各写一遍。这是最隐蔽也最常见的翻车点。6. 进阶技巧用特征可视化与错误样本回流把精度再推一截训练跑通、混淆矩阵看完接下来是怎么把精度从“能用”推到“好用”。我一般做两件事看模型到底关注哪里以及把错判样本捞回来分析。第一件是特征可视化。ResNet 的最后一个卷积 stage 输出是 7×7×2048 的特征图用 Grad-CAM 能画出模型决策时的高响应区域。如果高响应落在背景而不是主体上说明模型学到了虚假相关这时候加数据增强或裁剪主体比调参有用。Grad-CAM 的核心是对目标类得分反向求特征图梯度按通道平均得到权重再对特征图加权求和。这一步不需要重训加载最好的 checkpoint 就能跑。第二件是错误样本回流。把验证集里预测错的样本按“真实类→预测类”分组导出人工看几十张往往能发现标注错误、模糊样本、或者两个类本身定义就有重叠。标注错误直接修模糊样本考虑剔除或单独处理类定义重叠则要回到任务定义层面拆类。这一步的收益经常比换 backbone 还大因为 ResNet-50 换成 ResNet-101 通常只涨一两个点而修一批错标能涨好几个点。还有一个实用技巧是测试时增强TTA推理时对同一张图做几次不同的裁剪或翻转把 softmax 概率平均后再取 argmax。代价是推理时间翻几倍但在精度敏感、延迟不敏感的场景值得做。TTA 对随机性强的类别提升明显对本身就很确定的样本几乎无影响。最后说一个我自己的习惯每次实验都固定随机种子把配置、数据划分、指标写进一个 json 日志和 checkpoint 放一起。ResNet 多分类的调参空间很大没有日志两周后你根本记不清哪个 checkpoint 对应哪组参数想复现最好的那次只能靠猜。这个习惯帮我省了无数次重跑的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表