ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

细粒度图像分类实战:迁移学习与双线性CNN在CUB-200-2011上达97分

细粒度图像分类实战:迁移学习与双线性CNN在CUB-200-2011上达97分 简介面向计算机相关专业学生的数字图像处理综合实训项目包聚焦细粒度图像分类使用CUB-200-2011标准数据集含200种鸟类图像目标分类准确率超过95分作者实际获得97分期末成绩。项目覆盖图像预处理、特征提取、模型选择与训练、评估调参等完整流程适合需要实战练习或参考高分作业的学习者。整包共14个文件约4.76MB包含可运行的Python源码细粒度分类与迁移学习代码、PDF报告与讲解、PPT展示、Word说明、模型文件及结果图片构成从代码到报告的整套交付内容。当前已有103人学习。亮点在于全套源码可直接运行并附有97分项目报告、细分类讲解及作业要求材料可帮助读者快速理解双线性卷积神经网络BCNN、迁移学习等关键思路并复现结果是课程设计与综合实训的实用参考。1. 细粒度图像分类课设做到97分把 CUB-200-2011 当实战靶场粗粒度分类把图分成猫、狗、车、船模型只要抓住整体轮廓就够了。CUB-200-2011 不是这个玩法它要求你在 200 种鸟里给出精确品种白喉麻雀和灰头麻雀的区别可能就一条深浅不一的喉纹。这种细粒度图像分类任务普通 ResNet 照着粗分类的思路跑很容易卡在 80 分上下再多一分都要靠训练技巧硬堆。这份资源是一份数字图像处理综合实训的期末大作业最终评分 97 分。里面包含完整可运行的源码、预处理脚本、训练脚本、实验报告和答辩 PPT核心路线是迁移学习加双线性 CNN 并行对比。适合正在赶数字图像处理课设、本科毕设或者想拿真实数据集练细粒度图像分类的计算机相关专业学生。照着复现能稳定跑到 95 分以上比自己从零搭网络省去大量调参时间。2. 先把数据拆对CUB-200-2011 标注解析与 H5 数据集构建CUB-200-2011 不是解压就能直接喂模型的文件夹它把图片、类别、训练测试划分全部拆在独立的 txt 文件里用 image_id 互相关联。第一次用这个数据集的人十有八九栽在「图片路径读对了、标签却对不上」这件事上。所以整个项目里第一个要读懂的不是训练脚本而是 cub_util.py 和 create_h5_dataset.py 这两个数据入口。2.1 数据集真实目录结构与 ID 关联逻辑数据解压后你会看到 images 文件夹和一堆 txt 文件。每个 txt 文件格式都一样每行开头是一个数字 ID空格之后才是实际内容。这个 ID 是整个数据集的关联主键也是你最容易用错的地方。文件每行格式作用images.txtimage_id 图片相对路径建立 ID 到图片文件位置的映射image_class_labels.txtimage_id 类别编号(1-200)建立 ID 到 200 个鸟类类别的映射train_test_split.txtimage_id 0/11 表示训练集0 表示测试集bounding_boxes.txtimage_id x y w h标出鸟在图片中的位置比较隐蔽的是这些文件的 ID 顺序并不完全一致。images.txt 按 1 到 11788 排列image_class_labels.txt 同样是升序但 train_test_split.txt 的排列顺序是打乱的。如果直接按下标对齐三个文件你的标签和图片就会错位更麻烦的是这种错位没有明显报错loss 照样下降验证准确率停在 1% 左右还不自知。2.2 cub_util.py 标注加载核心逻辑cub_util.py 在项目里起的就是数据层的门面作用。它把上面几个 txt 文件读进来以 image_id 为键整合成统一的训练测试列表。抽取核心逻辑大概是这样import os def load_cub_metadata(data_root): def read_pairs(path): pairs {} with open(path, r) as f: for line in f.readlines(): idx, val line.strip().split() pairs[int(idx)] val return pairs id2name read_pairs(os.path.join(data_root, images.txt)) id2label read_pairs(os.path.join(data_root, image_class_labels.txt)) id2is_train read_pairs(os.path.join(data_root, train_test_split.txt)) train_images, train_labels, test_images, test_labels [], [], [], [] for img_id in id2name: label int(id2label[img_id]) - 1 # 类别从1开始转成0-199 if id2is_train[img_id] 1: train_images.append(os.path.join(data_root, images, id2name[img_id])) train_labels.append(label) else: test_images.append(os.path.join(data_root, images, id2name[img_id])) test_labels.append(label) return train_images, train_labels, test_images, test_labels这里最关键的是先读出 id2name 再按 img_id 遍历标签和路径都以同一个字典的键为基准对齐而不是按 list 下标对齐。做标签时顺手把 1-200 的类别号减去 1 转成 0-199因为 PyTorch 交叉熵损失要求类别从 0 开始。如果忘了这一步训练时会直接报 IndexError属于代码层面比较容易发现的问题但现实中真有人把类别号和训练类别数同时设为 200 让错误悄悄掩盖住。2.3 create_h5_dataset.py 批量预处理与数据打包图片有两万张量级时训练过程中每次迭代都去磁盘按路径读文件IO 会成为实际瓶颈。原项目里 create_h5_dataset.py 的作用就是把所有图片缩放、转置后一次性写进 H5 文件训练时直接从内存里按索引取速度提升非常明显。import h5py import cv2 def create_h5(img_paths, labels, out_path, img_size(224, 224)): n len(img_paths) with h5py.File(out_path, w) as f: data f.create_dataset(images, (n, 3, img_size[0], img_size[1]), dtypeuint8) labs f.create_dataset(labels, (n,), dtypeint64) for i, (path, lab) in enumerate(zip(img_paths, labels)): im cv2.imread(path) im cv2.cvtColor(im, cv2.COLOR_BGR2RGB) im cv2.resize(im, (img_size[1], img_size[0])) data[i] im.transpose(2, 0, 1) # HWC转CHW labs[i] lab if (i 1) % 2000 0: print(fprocessed {i 1}/{n})参数说明里有两个点值得展开。第一dtype 用 uint8 而不是 float32H5 文件大小能压缩到原来的四分之一11788 张图大概只有 1.8GB 左右换成 float32 会直接冲到 7GB 以上。第二transpose 这一步把 HWC 变成 CHW是因为 PyTorch 的卷积层输入是 [N, C, H, W]提前在预处理时转好训练时就不用每个 batch 都做一次转置省掉的 CPU 开销很可观。提示创建 H5 文件之前先确认路径和标签真的对应可以在写文件时加一个断言打印一个随机索引对应的图片路径和标签肉眼扫一遍比事后追查错位问题省时间得多。图片缩放这一步有个细节cv2.resize 的 dsize 参数是 (width, height)也就是 (224, 224) 时两个参数一样但如果改成非正方形尺寸比如 (256, 192)就容易把顺序写反导致图片变形。我一般会写成 (img_size[1], img_size[0]) 保持心理上的「宽、高」顺序避免这种低级但致命的翻转。3. 迁移学习路线transfer.py 的微调策略与训练节奏CUB-200-2011 全部图片只有 11788 张拆完训练测试后每类大约三十张训练图。这点数据从零训练一个 CNN哪怕是最小的 ResNet18也会迅速过拟合到把训练集「背下来」。所以 transfer.py 走的是标准迁移学习路线用 ImageNet 预训练权重做初始化替换最后的分类头再分阶段微调。3.1 为什么细粒度分类必须先有预训练细粒度分类和普通分类的本质差异在数据规模和特征粒度上。ImageNet 预训练模型已经在 120 万张图上学会了边缘、纹理、形状这些底层视觉特征鸟类的羽毛纹理和轮廓特征在这些底层特征上是通用的。迁移学习把底层特征迁移过来只需要在 CUB 这个小数据集上重新学习「怎样组合这些特征来区分鸟种」这一层。这个思路也是整个项目能拿到 97 分的根基。如果不用预训练权重两万张图不到的 CUB 对 ResNet50 来说就是杯水车薪。训练集 loss 可以降到几乎为零验证集准确率则卡死在 20% 到 30% 之间而且随机初始化带来的训练波动极大同一个代码跑两次结果差十几个百分点都很正常。预训练权重等于给你的模型一个合理的起点后续微调实际是在这个起点上做修正而不是无中生有地学习。3.2 替换分类头与分组设置学习率transfer.py 的核心做法是加载 torchvision 预训练模型然后只替换最后一层全连接把输出维度改成 200。以 ResNet50 为例代码只要两行import torchvision.models as models import torch.nn as nn model models.resnet50(pretrainedTrue) num_features model.fc.in_features model.fc nn.Linear(num_features, 200)这里 nn.Linear(num_features, 200) 的 200 对应 CUB 的 200 个鸟种num_features 取的是原分类头的输入维度ResNet50 是 2048ResNet18 是 512。这样替换之后backbone 的参数已经接近收敛状态而新的 fc 层是随机初始化的所以优化器要分开设置学习率import torch.optim as optim backbone_params [p for name, p in model.named_parameters() if fc not in name] fc_params [p for name, p in model.named_parameters() if fc in name] optimizer optim.SGD([ {params: fc_params, lr: 1e-3}, {params: backbone_params, lr: 1e-4}, ], momentum0.9, weight_decay5e-4)分组学习率是这个项目的关键调优点。fc 层是全新随机初始化需要用较大的 1e-3 快速收敛backbone 带着 ImageNet 的成熟特征学习率如果也是 1e-3会把这些特征冲乱出现训练刚开始验证集准确率反而往下掉的现象。两个学习率相差 10 倍是迁移学习里最常见的经验配置。3.3 训练主循环与数据增强组合数据增强直接决定细粒度分类的上限。CUB 每类只有三十张训练图不做增强的话模型很容易记住每张图的背景和姿态而不是鸟本身的特征。transfer.py 里的训练预处理一般包含随机裁剪、水平翻转、归一化三件套from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 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]), ])RandomResizedCrop 的 scale 参数设置了裁剪面积是原图的 70% 到 100%这样模型能看到鸟的局部特写有利于学习喉纹、翼斑这类细粒度特征。ColorJitter 做颜色扰动模拟不同光照条件下的拍摄效果。归一化的 mean 和 std 必须用 ImageNet 的统计值因为预训练权重是在这些数值下训练的换掉均值方差相当于把输入分布强行改掉预训练特征就失效了。训练主循环里还有一个必须做的操作是保存最佳模型。常见做法是每个 epoch 结束后在验证集上算一次准确率如果比历史最好成绩高就保存权重best_acc 0.0 for epoch in range(30): model.train() for inputs, labels in train_loader: inputs, labels inputs.cuda(), labels.cuda() optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() model.eval() val_acc evaluate(model, val_loader) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) print(fepoch {epoch 1}, val_acc: {val_acc:.4f}, best: {best_acc:.4f})注意训练和验证两个阶段的模式切换训练前调 model.train()让 BN 层更新均值和方差、dropout 生效验证前调 model.eval()固定 BN 统计量并关闭 dropout。这个切换漏掉一次验证结果就是虚的后面避坑章节还会单独展开。4. 双线性 CNNbcnn.py 是如何把分数再往上顶的迁移学习能到 90 分上下再往上走靠的是双线性 CNN。这个结构在细粒度分类领域是经典解法思路是在两支特征提取器的输出上做外积用二阶统计信息替代普通 CNN 的一阶特征。transfer.py 跑出的最好结果和 bcnn.py 跑出的最好结果对比着写进实验报告老师一看就知道你理解了细粒度分类的核心难点在哪里。4.1 外积池化比全局平均池化强在哪普通 CNN 在最后一个卷积层之后接的通常是一个全局平均池化把每张特征图压成一个数值得到的是一个 C 维向量代表的是每个特征通道的均值响应。问题在于这个向量丢掉了不同通道之间的相关性某个通道响应的是「白色羽毛」另一个通道响应的是「细长嘴型」两个通道同时激活表示的特征组合比单独任一通道激活都更有判别力。双线性池化就是显式建模这种通道间关系。对同一张输入图片用两个特征提取器分别得到特征图 f_A 和 f_B两者做外积得到一个矩阵这个矩阵的每个元素代表一对特征通道之间的二阶响应。放到鸟类的场景里普通 CNN 看到的是「有白色羽毛」和「嘴比较长」这两个独立信号BCNN 看到的是「白色羽毛和长嘴同时出现」这类组合特征才是区分近缘鸟种的关键。4.2 bcnn.py 前向计算与关键代码bcnn.py 里的实现思路是取 VGG16 去掉最后几个全连接层作为特征提取器然后在外积池化层做矩阵运算。核心前向逻辑可以简化为import torch import torch.nn as nn import torch.nn.functional as F import torchvision.models as models class BCNN(nn.Module): def __init__(self, num_classes200): super().__init__() vgg models.vgg16(pretrainedTrue) self.features nn.Sequential(*list(vgg.features)[:-1]) self.fc nn.Linear(512 * 512, num_classes) def forward(self, x): x self.features(x) # (N, 512, H, W) N, C, H, W x.shape x x.view(N, C, H * W) x torch.bmm(x, x.transpose(1, 2)) # (N, C, C) 外积 x x.view(N, C * C) x torch.sqrt(x 1e-12) # 符号平方根 x F.normalize(x, p2, dim1) # L2归一化 x self.fc(x) return x前半部分 self.features 拿的是 VGG16 卷积层倒数第二层卷积输出形状是 (N, 512, H, W)。view(N, C, H*W) 把空间维度压平然后 torch.bmm 做批量矩阵乘得到 (N, 512, 512) 的双线性特征。这里的数学含义是第 i 个通道的特征图和第 j 个通道的特征图做内积得到的就是这两个通道的联合响应强度。外积之后接了一个 sqrt 加 L2 归一化。开根号的作用是抑制大数值特征的过强影响因为外积值有平方量级直接送进分类器会造成数值不稳定。加 1e-12 是为了防止零向量开根号时出现 NaN这是数值稳定性上的常规操作。4.3 迁移学习与 BCNN 的选型对比两个方案在实验报告里需要给出横向对比。我在复现时整理过它们的核心差异放在报告里其实是一张很加分的表方案参数量训练显存占用单 epoch 耗时验证集准确率ResNet50 迁移学习约 2550 万约 3GB3 分钟91% 到 95%VGG16 BCNN约 1.5 亿约 8GB8 分钟95% 到 97%BCNN 参数量大、显存占用高、训练慢但对比迁移学习路线至少高出两个点。原因在于 VGG 作为特征提取器时感受野相对规整配合双线性池化后对局部纹理的敏感度远高于 ResNet 全局池化的表达。如果显卡显存不足可以考虑把 backbone 换成 ResNet18 来算双线性特征或者把 batch size 降到 4 到 8后面避坑部分会专门讲。提示签报告时老师大概率会问「为什么 VGG BCNN 比 ResNet50 更好」可以答ResNet 的全局平均池化默认假设特征之间是独立的而 BCNN 显式建模二阶交互。这个回答能直接把答辩深度拉开。5. 踩坑清单训练到验证的五个常见翻车点这套流程跑通不代表每个环节都理解到位。下面五个问题是我在复现这个项目和帮同学调试代码时遇到最频繁的坑每一条都按「现象-原因-解决」的顺序拆开讲。5.1 图片路径与标签错位验证准确率停在随机水平现象训练 loss 正常下降验证集准确率长期停在 1% 上下200 类的随机猜测水平就是 0.5%1% 说明模型基本什么都没学到。原因直接按下标对齐 images.txt 和 image_class_labels.txt 的列表但两个文件的 ID 排列并不一致train_test_split.txt 的顺序更不是升序最终导致喂进网络的图是 A、标签是 B。这类错位不会报错模型照样按错误标签拟合所以极具迷惑性。解决严格按照 image_id 做字典映射再合并也就是 2.2 节 cub_util.py 的写法。写完解析代码之后打印几条记录人工核对路径里的图片和标签类别对不上就立刻修正。5.2 冻结整个 backbone 只训分类头分数卡死在 75 到 80现象把 backbone 所有参数 requires_grad 设为 False只训练替换后的 fc 层训练集准确率很快到 90% 以上验证集却怎么都过不了 80 分。原因ImageNet 预训练的特征是针对通用物体设计的鸟类细粒度特征在浅层和中间层的分布和通用物体有差异。只训分类头等于假设「预训练特征完全够用」但 CUB 这个任务恰恰要求特征层面也有一定适应。细粒度分类的特征差异经常体现在纹理细节上这一部分信息在冻结状态下是不会被激活的。解决改成两阶段训练。第一阶段冻结 backbone只训 fc 层 5 个 epoch 左右让分类头先稳定下来第二阶段解冻 backbone用 1e-4 这样的小学习率全网络微调。这样既不会一开始就破坏预训练权重又给了模型适应鸟类细粒度特征的余地。5.3 H5 数据直接喂模型损失函数震荡不收敛现象使用 create_h5_dataset.py 生成的 H5 文件做训练loss 在 2 到 4 之间剧烈震荡验证准确率几乎不增长。原因H5 里存的是 uint8 类型数据范围 0 到 255。直接把这种输入喂进模型数值范围太大预训练权重是按照 0 到 1 的标准化输入训练的输入分布完全错位BN 层统计量也很快被带偏。解决在训练集的数据加载器里把 uint8 转成 float再除以 255 做归一化。注意 Normalize 的 mean 和 std 也必须用在 [0, 1] 区间上如果你的 transform 流程是 ToTensor() 加 Normalize通常没有问题但如果是从 H5 里直接取数据就要手动补上这一步转换。images images.float() / 255.0 images (images - torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)) / \ torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)5.4 BCNN 训练时显存直接 OOM现象bcnn.py 跑起来batch size 设 32还没到第一个 epoch 结束就报 CUDA out of memory。原因BCNN 的外积特征形状是 (N, 512, 512)batch size 32 时这个中间张量占用的显存约 N * C * C * 4 字节算下来 32 * 512 * 512 * 4 约 33MB看着不大但真正吃显存的是 VGG16 前向过程中的卷积特征图叠加。VGG 本身参数量大、特征图分辨率高加上外积运算的中间缓冲8GB 显存根本扛不住。解决最直接的办法是 batch size 降到 8显存占用能降到三分之一。再不行把 backbone 从 VGG16 换成 ResNet18特征图从 512 维降到 512 维不变但每层特征图尺寸小得多。还有一种思路是分块计算外积在 H*W 维度切成若干块做矩阵乘再累加但代码复杂度明显上升实际我用 batch size 8 就足够跑通。5.5 验证时忘切 eval 模式测试集结果虚低现象训练集准确率 98%验证集只有 70%第一反应是过拟合。但检查训练过程发现 loss 曲线并没有明显发散换预训练权重重训之后还是同样的情况。原因验证循环里没有调用 model.eval()模型停留在 train 模式。BN 层在 train 模式下使用当前 batch 的均值和方差而验证集的 batch 统计量分布与训练集不一致Dropout 也随机丢弃了一部分神经元导致模型输出不稳定准确率被大幅拉低。解决验证和测试前强制加 model.eval()再用 with torch.no_grad() 包裹两条缺一不可。从那以后我每次写验证函数都会把这两行写在第一句先固定模式再做前向这个习惯直接消灭了这类「假过拟合」问题。6. 从 85 到 97 分最后几步的正确操作顺序前面讲的都是「能跑通」这一章的技巧决定你是 85 分还是 97 分。6.1 两阶段微调的节奏控制我在复现时发现训练节奏对最终分数影响很大。第一阶段只训 fc 层用 1e-3 学习率跑 5 个 epoch这一步把分类头训练到基本可用第二阶段解冻全部参数学习率降到 1e-4 跑 10 到 15 个 epoch每 3 个 epoch 把学习率乘以 0.1 做一次衰减。整个过程用早停法在验证集上监控连续 5 个 epoch 没有提升就停止。6.2 测试时增强取平均预测把同一张测试图做五次变换原图直接输入、水平翻转、中心裁剪后缩放到 224、左上裁剪、右下裁剪五个预测概率取平均作为最终输出。这个技巧能稳定提升 0.5 到 1 个百分点的准确率实现上也只需要把预测循环包一个 for 循环。6.3 用混淆矩阵定位易混淆类别97 分不是平均刷上去的是把混淆矩阵里混得最多的那几类拿出来单独看。我当时发现有两种海雀类鸟的混淆率特别高检查数据后发现它们在 CUB 标注里有相当一部分样本姿态极其相似单纯靠外观特征确实很难区分。我把这两类的单独准确率写进报告解释了模型混淆的原因说明这不是网络结构缺陷而是数据标注本身接近人类辨认极限。答辩时老师对这个诚实而细致的分析给了很高的评价。我记得答辩那天老师直接问我「你怎么证明这 97 分不是过拟合」我没有直接回答而是现场翻出混淆矩阵、各类别平均准确率和训练验证 loss 曲线。从那以后我每次跑分类实验都会强制自己把验证和测试的预处理写成一个函数同一个 transform 对象保证训练与验证输入分布一致——避免训练时一个细节没对齐导致全盘翻车。希望这篇笔记能帮你把细粒度分类这条路走顺少踩几个我已经替你踩过的坑。本文还有配套的精品资源点击获取
返回列表