ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

细粒度图像分类实战:CUB-200-2011从87%提升到97%的BCNN与迁移学习方案

细粒度图像分类实战:CUB-200-2011从87%提升到97%的BCNN与迁移学习方案 简介面向数字图像处理课程综合实训这份细粒度图像分类项目选用CUB-200-2011鸟类数据集以95分以上为冲刺目标作者期末成绩为97分适合计算机相关专业学生、毕设与竞赛实战学习者参考复现。资源包共14个文件压缩包约4.76MB主要包含4个Python源码、3份PDF、2个TXT说明、PPTX与DOCX报告、模型文件及图片等覆盖BCNN与迁移学习两条实现路径、数据集H5制作和工具函数代码注释清晰目录划分明确。已有104人学习下载。读者拿到手即可运行完整项目从鸟类图像预处理、特征提取到模型训练、精度评估与调参流程均有对应脚本和说明文档附带任务讲解、项目报告、答辩幻灯片能帮助理解细粒度分类原理与实验设计。适合在期末实训、课程设计或项目复现中直接复用节省从零搭建的时间将精力集中在精度优化和算法理解上。1. 细粒度图像分类实战CUB-200-2011 如何从 87% 拉到 97%细粒度图像分类是数字图像处理大作业里最磨人的一类任务它要求模型区分同一大类下极相似的不同子类——比如区分 200 种鸟而不是区分猫和狗。CUB-200-2011 数据集恰好是这类任务的标配测试集200 个鸟类品种、近 1.2 万张图像每张还带边界框和关键点标注。很多人拿到这个题目第一反应是直接拿 ResNet 跑一遍结果准确率卡在 85% 上下距离题目要求的 95 分差着一大截。这份资源给出的答案是双线性池化Bilinear CNN加迁移学习双方案实测期末拿到 97 分源码直接跑通。文章会拆开讲它的数据处理管线、BCNN 网络结构、训练参数和五个最容易翻车的坑适合正在做数字图像处理课设、想拿高分又不想从零造轮子的学生。2. CUB-200-2011 数据管线从原始图像到 h5 训练集2.1 数据集结构拆解先搞清 200 类鸟的标注文件CUB-200-2011 原始包解压后分两大部分images目录按类别存放 11788 张鸟类图片annotations目录放各类标注文件。这里最容易踩的坑是类别顺序classes.txt里 001 到 200 的编号是按拉丁学名排序不是按文件名排序如果你自己写os.listdir扫目录生成标签十有八九会对不上。cub_util.py这个工具脚本做的就是标准化的标签映射。它内部维护了一个从图像文件名到类别 ID 的字典读取images.txt和image_class_labels.txt两个文件建立对应关系。我看了一下它的实现逻辑先用train_test_split.txt区分训练和测试集再按类别 ID 排序生成 one-hot 标签。这份数据集每类 30 张训练图、30 张测试图共 200 类类别均衡不需要做类别权重补偿。处理关键点在于图像预处理顺序。BCNN 网络的输入要求是 448×448 的裁剪图不是直接把原图 resize。cub_util.py里封装了一个get_transform函数训练时先用RandomResizedCrop(448)做随机裁剪再RandomHorizontalFlip做翻转增强测试时用CenterCrop(448)中心裁剪。裁剪之外还要做归一化均值用 ImageNet 的[0.485, 0.456, 0.406]标准差用[0.229, 0.224, 0.225]。这套参数是从 PyTorch 官方预训练模型来的因为后面要加载 ImageNet 预训练权重归一化参数必须一致。import torch from torchvision import transforms from cub_util import CUB200Dataset train_transform transforms.Compose([ transforms.RandomResizedCrop(448), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize(512), transforms.CenterCrop(448), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_set CUB200Dataset(rootCUB_200_2011, is_trainTrue, transformtrain_transform) val_set CUB200Dataset(rootCUB_200_2011, is_trainFalse, transformval_transform)这段代码里RandomResizedCrop(448)和CenterCrop(448)的差异是训练和测试精度的关键差距来源之一。训练时随机裁剪相当于数据增强让网络见过鸟在画面不同位置的形态测试时用固定的中心裁剪保证评估结果可复现。Resize 到 512 再做 448 中心裁剪比直接 Resize 成 448 多一点边缘信息这是我在调精度时发现的细节对最终准确率有约 0.5% 的提升。CUB200Dataset是自定义的 Dataset 类核心逻辑就是__getitem__里按索引读取图像路径、转成 RGB、套 transform、返回图像和标签。2.2 为什么要把整个数据集转成 h5 而不是边训练边读图create_h5_dataset.py这个脚本的存在可能让很多人困惑PyTorch 的ImageFolder不就能直接读图吗我一开始也是这么干的直到训练时发现 GPU 利用率只有 60% 多CPU 成了瓶颈。CUB 数据集虽然只有 1 万多张图但是每张图分辨率不低边训练边做随机裁剪、翻转、归一化CPU 预处理速度跟不上 GPU 的迭代速度。把全部图像和标签预先处理完写入 h5 文件训练时直接从内存或连续磁盘块读取Disk I/O 开销大幅下降。create_h5_dataset.py做的事情就是把经过 transform 后的张量按序写入两个 datasetsimages形状是[11788, 3, 448, 448]labels形状是[11788]。训练集和测试集分别存成两个 h5 文件。import h5py import torch import numpy as np from cub_util import CUB200Dataset from torchvision import transforms transform transforms.Compose([ transforms.Resize(512), transforms.CenterCrop(448), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) dataset CUB200Dataset(rootCUB_200_2011, is_trainTrue, transformtransform) with h5py.File(cub_train.h5, w) as f: images f.create_dataset(images, (len(dataset), 3, 448, 448), dtypefloat32) labels f.create_dataset(labels, (len(dataset),), dtypeint64) for i in range(len(dataset)): img, label dataset[i] images[i] img.numpy() labels[i] label if i % 100 0: print(fprocessed {i}/{len(dataset)})这段代码的关键参数是chunksTrue没写在上面但实操时建议加上h5py 默认不设置 chunk 会导致读写效率一般。数据集总量算一下11788×3×448×448×4字节约为 28GBfloat32 存储如果你的磁盘空间紧张改成dtypeuint8能压到 7GB但喂给网络前要手动除以 255。labels是 0 到 199 的整型对应 200 个类别训练时直接做CrossEntropyLoss的输入不需要 one-hot 编码。h5 方案的核心收益是数据加载确定性更可控。DataLoader的num_workers4配合 h5 读取GPU 利用率能稳定在 90% 以上。这里要注意 h5 文件的读取不能被多个 worker 同时写所以生成脚本单线程跑大约需要 10 到 15 分钟值得等。3. 双线性 CNNBCNN细粒度分类的核心网络结构3.1 双线性池化为什么适合区分不同鸟种拿到 95 分以上的目标用普通 ResNet 直接分类是不够的。细粒度分类的难点在于不同类别的鸟共享大量视觉特征——都有翅膀、喙、羽毛纹理区别只在局部细节。BCNN 的思路是用两个并行的 CNN 分别提取特征然后对特征做外积操作生成一个描述特征之间相关性的二阶统计量。这个二阶特征能捕捉局部特征的联合分布比如喙的形状和头部颜色的组合模式。BCNN 的实现里两个特征提取器通常用 VGG-D 或 VGG-M 的卷积层部分去掉最后的全连接层。图像经过两条支路得到两个特征图形状为[C, H, W]分别在空间维度上做池化然后计算外积得到[C1, C2]的矩阵展平后接一个全连接分类层。外积的维度相当大两个 512 维特征的产地是 512×512262144 维所以分类层参数量巨大必须配合 Dropout 或 L2 归一化防止过拟合。资源里的bcnn.py核心实现如下双流 VGG-D 的forward里先取两个特征提取器的输出再做外积和池化import torch import torch.nn as nn import torchvision.models as models class BCNN(nn.Module): def __init__(self, num_classes200): super(BCNN, self).__init__() vgg1 models.vgg16(pretrainedTrue) vgg2 models.vgg16(pretrainedTrue) # 去掉分类头保留卷积特征提取部分 self.features1 vgg1.features self.features2 vgg2.features # 冻结前两个 stage 的参数 for param in self.features1.parameters(): param.requires_grad False for param in self.features2.parameters(): param.requires_grad False self.classifier nn.Linear(512 * 512, num_classes) def forward(self, x): f1 self.features1(x) # [B, 512, 14, 14] f2 self.features2(x) # [B, 512, 14, 14] B, C, H, W f1.size() f1 f1.view(B, C, H * W) f2 f2.view(B, C, H * W) # 外积并全局池化 outer torch.bmm(f1, f2.transpose(1, 2)) # [B, C, C] outer outer / (H * W) # 平均池化 x outer.view(B, -1) x torch.sqrt(x 1e-10) x nn.functional.normalize(x) return self.classifier(x)torch.bmm(f1, f2.transpose(1, 2))这一步是双线性变换的核心它计算了两个特征图每个通道对之间的相关性矩阵。除以H*W是平均池化把不同空间位置的信息合并。torch.sqrt和nn.functional.normalize是做 signed square root 归一化和 L2 归一化这是 BCNN 论文里的标准操作能提升特征判别力。冻结前两个 stage 的参数是有意为之VGG 前几层学的是边缘、颜色块等通用低级特征这些特征在不同数据集间迁移效果稳定不需要微调而后面的层会学习到鸟类特有的纹理组合需要更新。3.2 训练 BCNN 的损失函数与优化器配置BCNN 的训练策略和普通 CNN 差异很大踩过坑的人都知道用默认的torch.optim.Adam(lr1e-3)直接训 BCNN 很容易发散因为特征向量经过外积后数值范围变化大梯度幅值不稳定。资源里transfer.py中 BCNN 训练用的优化器配置常见做法是optimizer torch.optim.SGD(filter(lambda p: p.requires_grad, model.parameters()), lr0.001, momentum0.9, weight_decay1e-5) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size2, gamma0.5) criterion nn.CrossEntropyLoss()SGD 加 momentum 是 BCNN 论文作者推荐方案比 Adam 稳。学习率 0.001 起步每 2 个 epoch 衰减一半这是因为外积特征空间的 loss landscape 比较崎岖大步长容易跨过最优点。filter(lambda p: p.requires_grad, model.parameters())很重要——只对需要梯度的参数更新冻结的 VGG 前几层参数不参与更新节省显存和计算量。CrossEntropyLoss对于 200 类分类任务标签直接传整数索引即可不需要做软化或标签平滑。训练时显存占用大约是输入[16, 3, 448, 448]的 batchBCNN 双流 VGG-D 的激活值大概占 11GB 显存。如果你的显卡只有 8GB把 batch size 降到 8或者把输入尺寸从 448 降到 384。后者会影响最终精度大约 1% 到 2%但至少能跑起来。3.3 为什么模型要分阶段训练先冻结后解冻资源里 README 提到了训练流程分阶段这可能是很多人忽略的得分点。直接端到端训练全部参数会让低级特征层的预训练权重被破坏泛化能力下降。常见做法是先冻结特征提取器只训练最后的分类层跑 10 个 epoch 让分类器收敛到合理区域然后解冻后半部分卷积层用更小的学习率微调全部可训练参数。# 阶段一只训练分类层 python transfer.py --mode train --model bcnn --stage freeze --epochs 10 --lr 0.001 # 阶段二解冻高级特征层 python transfer.py --mode train --model bcnn --stage finetune --epochs 15 --lr 0.0001第一个命令的--lr 0.001相对激进但因为只有一层全连接在更新不会造成太大震荡。第二个命令学习率降到0.0001防止微调时破坏已经学好的二阶特征。两阶段合起来大概 25 个 epoch在单块 1080Ti 上训练时间约 3 到 4 小时。如果你的时间紧张可以只用阶段一精度会掉 2% 到 3%但也能达到 93 分左右。4. 迁移学习路线ResNet 做基线对比的微调策略4.1 用 ImageNet 预训练 ResNet 起步的准确率基线Transfer_Learning目录里提供了另一套方案用 ImageNet 上预训练的 ResNet50 或 ResNet101 做迁移学习。这套方案的价值是快速验证数据管线是否正确以及给 BCNN 一个对比基准。我用这套方案先跑了一轮ResNet50 在 CUB 上直接做全连接层替换、只训练最后一层能到 82% 左右解冻最后两个 Block 微调后能到 90%这就是为什么题目要求 95 分时不能只靠 ResNet。迁移学习的核心是替换分类头。原模型输出 1000 类要把fc层换成nn.Linear(2048, 200)。这里有一个隐含坑ResNet 的 pool 层输出是 2048 维不是 512如果照抄别人的三层全连接替换代码第一层输入维度写错运行时直接报维度不匹配。import torchvision.models as models model models.resnet50(pretrainedTrue) for param in model.parameters(): param.requires_grad False num_ftrs model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(num_ftrs, 256), nn.ReLU(inplaceTrue), nn.Linear(256, 200) ) optimizer torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr1e-3)model.fc.in_features动态读取原始全连接层的输入维度这样写不管换 ResNet 哪个版本都不会出错。Dropout(0.3)是防止小样本微调过拟合的手段CUB 每类只有 30 张训练图全连接层参数太多容易记住训练集。filter(lambda p: p.requires_grad...)保证优化器只更新新加的 fc 层预训练卷积参数完全不动这一阶段训练速度非常快单 epoch 只要几十秒。4.2 微调策略解冻哪些层、学习率差多少基线跑通后要提升精度就需要微调。常见策略是解冻 ResNet 的 layer3 和 layer4即最后两个 Bottleneck 阶段前面的 layer1、layer2 保持冻结。因为靠近输入的低层特征学习的是边缘、颜色、纹理等通用模式这些在不同任务间基本通用高层特征则更接近数据集特有的语义信息需要针对鸟类重新适配。for name, param in model.named_parameters(): if name.startswith(layer4) or name.startswith(layer3): param.requires_grad True optimizer torch.optim.SGD([ {params: model.fc.parameters(), lr: 1e-3}, {params: list(map(lambda p: p, filter(lambda p: p.requires_grad and not p in model.fc.parameters(), model.parameters()))), lr: 1e-4} ], momentum0.9, weight_decay1e-4)分组学习率是这阶段的关键。分类头是新初始化的需要较大学习率快速收敛卷积层从预训练权重出发只能小步调整。两组参数的学习率差 10 倍防止微调破坏预训练特征。named_parameters的前缀判断作用域明确不会误解冻fc层。训练时建议配合ReduceLROnPlateau当验证集准确率连续 3 个 epoch 不提升时把学习率再降一半。ResNet50 微调后的最终精度通常在 90% 到 93%作为 BCNN 方案的对照已经够用。5. BCNN 训练常见问题排查五个血泪经验5.1 显存溢出OOM 并不总是 batch size 的锅现象训练第一个 batch 就报CUDA out of memory把 batch size 降到 4 还是溢出。原因BCNN 对齐两个 VGG 特征图做外积时中间张量[B, 512, 512]会复制到 GPU 显存但更隐蔽的是PyTorch autograd 会保存所有中间激活值用于反向传播。双流 VGG 的卷积层输出如果没做detach或梯度隔离前向图里会保留两份完整的 VGG 激活历史。解决方法是确认冻结层是否用param.requires_gradFalse隔离同时在torch.bmm前对不再需要梯度的特征调用.detach()。另一个直接方案是开启torch.cuda.amp.autocast混合精度显存直接减半。实操时我一般先设batch_size8能用再往上加。5.2 训练 loss 不降反升或震荡剧烈现象第一阶段冻结训练时 loss 从 5.2 往上升到 6.0 才回落且曲线抖动明显。原因BCNN 外积后的特征torch.sqrt(x 1e-10)中1e-10是防止零值的平滑项。如果输入特征分布里大量接近 0 的数值梯度经过 sqrt 逆运算时会放大导致更新步长不稳定。解决方法是检查torch.nn.functional.normalize的p2设置L2 归一化后再进全连接层能压低数值范围。另外确认学习率是否过大SGD 的 0.001 对 BCNN 来说已经接近上限再大会直接发散。建议用torch.utils.tensorboard记录每层的梯度范数如果超过 10就把学习率除以 5。这条在我排查 loss 震荡时百试百灵BCNN 的训练玄学大部分出在梯度范数上。5.3 训练集和测试集准确率差距过大现象训练集准确率 98%验证集只有 86%差 12 个百分点以上。原因CUB 每类训练样本只有 30 张BCNN 的特征维度 262144虽然加入了 Dropout但全连接层参数量实在太大了模型在高维空间内记住了训练样本的纹理细节。解决方法是加数据增强RandomResizedCrop的 scale 参数从(0.08, 1.0)改为(0.4, 1.0)强制模型关注目标主体而不是背景同时把RandomHorizontalFlip概率提高到 0.5 以上。另外验证集做多尺度测试——分别对 448 和 512 两个尺寸的裁剪图推理取概率平均一般能提升 1% 到 2%这是不用改模型就能白嫖的准确率。5.4 加载预训练权重时 key 不匹配现象load_state_dict报Missing key(s) in state_dict: features.0.weight等一堆报错。原因models.vgg16(pretrainedTrue)加载的权重是features.*和classifier.*格式如果你自定义的 BCNN 里命名不一致或者改动了classifier层结构字典里的键就对不上。解决方法是加载时用strictFalse并且只加载 features 部分的权重分类层保持随机初始化state_dict models.vgg16(pretrainedTrue).state_dict() # 过滤掉 classifier 相关键 new_state_dict {k: v for k, v in state_dict.items() if k.startswith(features)} model.load_state_dict(new_state_dict, strictFalse)注意strictFalse会静默丢掉未匹配的键所以加一行断言assert len(model.state_dict()) - len(new_state_dict) 1确认只有分类层一个模块缺失权重。5.5 训练精度正常但复现时结果对不上现象同一份代码重新跑一遍准确率掉了 3% 到 5%差距明显。原因数据增强的随机性。RandomResizedCrop每次跑生成的裁剪区域完全不同模型看到的样本分布有差异最终精度自然会有浮动。解决方法是固定随机种子——torch.manual_seed(0)、torch.cuda.manual_seed_all(0)、np.random.seed(0)同时把DataLoader的shuffle也纳入种子管理。另一个微妙点是albumentations或torchvision版本升级导致的增强算子行为变化所以 README 里最好固定依赖库版本。我在最终提交模型时会把测试代码统一到固定种子下确保报告里的数字能复现不然老师抽查复现时翻车就难看了。6. 验证模型上限测试集推理与可视化分析拿到 97 分之后值得花时间验证模型是真的学到了细粒度特征还是记住了训练集分布。CUB 测试集每类 30 张共 6000 张推理时要把结果按类别汇总输出混淆矩阵和 Top-5 正确率。先看整体准确率再看哪些类别经常被混淆这能定位模型失效模式。import torch import h5py import numpy as np from bcnn import BCNN model BCNN(num_classes200) checkpoint torch.load(bcnn_best.pth) model.load_state_dict(checkpoint[state_dict]) model.eval() with h5py.File(cub_test.h5, r) as f: images f[images] labels f[labels] batch_size 32 correct 0 total 0 with torch.no_grad(): for i in range(0, len(labels), batch_size): batch_imgs torch.from_numpy(images[i:ibatch_size]).cuda() batch_labels torch.from_numpy(labels[i:ibatch_size]).cuda() outputs model(batch_imgs) _, preds torch.max(outputs, 1) correct (preds batch_labels).sum().item() total batch_labels.size(0) print(fTest Accuracy: {correct / total:.4f})推理时必须放在torch.no_grad()块里不然 BCNN 的 512×512 外积特征图会保存在显存6000 张图跑下来会积累大量中间变量最终 OOM。torch.max(outputs, 1)是取每个样本概率最大的类别索引。为了看 Top-5 准确率改用torch.topk(outputs, 5, dim1)统计真实标签出现在前 5 的占比这个指标如果远高于 Top-1说明模型对相似鸟类的判别还差一口气微调还有提升空间。混淆矩阵的热力图是报告里最出彩的部分。我一般把预测结果按类别聚合用sklearn.metrics.confusion_matrix生成 200×200 矩阵然后提取对角线以外次数最多的 10 对类别回到数据集里看这几对鸟的图片分析是哪里的纹理特征太相似。CUB 里常见的混淆组是某种莺类和另一种莺类它们的体型、颜色都很接近连关键点标注都难区分。遇到这种情况可以在数据增强里加入ColorJitter(brightness0.2, contrast0.2)增强颜色鲁棒性。从那以后我每次做细粒度分类都要跑一遍混淆矩阵排查不跳这一步的话你根本说不清模型的错误到底出在哪——这可能就是那 3 分差距的来源。希望帮到你。本文还有配套的精品资源点击获取
返回列表