ARTICLE DETAIL

资讯详情

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

CUB-200-2011细粒度图像分类实战:双线性CNN训练与避坑指南

CUB-200-2011细粒度图像分类实战:双线性CNN训练与避坑指南 简介面向计算机专业课程设计与期末大作业的图像细粒度分类项目基于CUB-200-2011鸟类数据集由个人97分结课作品整理而成可直接运行并作为高分作业参考。压缩包共14个文件约4.76MB其中包含4个Python源码双线性卷积网络、迁移学习、数据预处理与H5数据集构建等、3份PDF报告结题报告、细分类讲解、作业要求、PPT答辩幻灯片、Word数据集解读、模型转换文件及README说明完整覆盖从代码实现到最终答辩的交付材料。项目已通过严格调试下载即可直接运行可对照报告逐步复现细粒度分类全流程也可参考其目录划分与实验记录方式。已有280人学习浏览适合正在准备课程设计或需要项目实战练习的计算机专业学习者尤其有助于理解BCNN双线性模型与迁移学习在细粒度分类中的实际应用。1. 数字图像处理大作业与CUB-200-2011为什么“跑通”和“95分”是两种难度CUB-200-2011是细粒度图像分类课程作业里最常见的选题200种北美鸟类共11788张图官方训练测试划分是5994和5794张。很多同学第一反应是直接套一个ResNet训练结果验证集准确率卡在70%到80%分数自然不理想。要说清“95分以上的项目”到底做了什么得从数据解析、模型选型、训练稳定性、报告可视化四个方面一起看。这篇笔记就是按这条线把完整方案写一遍从CUB的四个标注txt对齐方法讲起给出可复现的双线性卷积网络训练代码再列出我实际踩过的五个坑。目标是把验证集准确率稳定推到90%以上同时让你交上去的报告有图、有数据、有可解释性。适合正在赶数字图像处理大作业、有Python和PyTorch基础的同学照着复现。2. 先把CUB-200-2011数据盘清楚四个txt、图像ID与边界框对齐2.1 目录结构与四份标注文件在做什么CUB-200-2011整个数据集解压后的根目录是CUB_200_2011里面并没有把训练集和测试集分成两个文件夹而是用train_test_split.txt告诉算法每一张图该归哪边。这个设计在科研上很干净但做大作业时容易出错你必须先把images.txt、image_class_labels.txt、bounding_boxes.txt、train_test_split.txt四份文件按图像ID对齐才能正确构建数据集。images.txt给出的是相对路径比如第一行经常是“1 Black_Footed_Albatross_0001.jpg”路径相对于根目录下的images文件夹image_class_labels.txt给出该图像的类别号范围1到200bounding_boxes.txt给出鸟所在位置的外接框参数是x、y、width、heighttrain_test_split.txt用0/1标注当前图像属于测试还是训练。attributes目录里还有312个二值属性标注像“翅膀颜色”“喙的形状”这类第二轮做消融实验时能派上用场第一轮训练可以先不读。关键点是四份txt的行号来自各自文件排列顺序并不完全同步所以“按行读”和“按ID查”的结果可能不同。凡是发现训练和验证样本互串多半是在这个环节用行号对齐出了错。2.2 用字典按图像ID对齐解析脚本与样本数校验这里给出我平时用的解析脚本全部转成字典key是图像ID# parse_cub.py import os base_dir CUB_200_2011 def read_map(path): id2val {} with open(path) as f: for line in f: parts line.strip().split() id2val[int(parts[0])] parts[1] return id2val images read_map(os.path.join(base_dir, images.txt)) class_ids read_map(os.path.join(base_dir, image_class_labels.txt)) split_map read_map(os.path.join(base_dir, train_test_split.txt)) boxes {} with open(os.path.join(base_dir, bounding_boxes.txt)) as f: for line in f: iid, x, y, w, h line.strip().split() boxes[int(iid)] (float(x), float(y), float(w), float(h)) train_ids [iid for iid in images if int(split_map[iid]) 1] test_ids [iid for iid in images if int(split_map[iid]) 0] print(len(images), len(train_ids), len(test_ids)) # 期望输出11788 5994 5794逻辑说明第一部分把三个txt读成三个字典键都是图像ID第二部分单独读bounding_boxes因为它一行有4个数字第三部分按官方划分筛出训练和测试ID。用字典的好处是后续不管从哪个文件取信息都是按ID直接定位不需要关心文件行序差异。参数说明images.txt里的ID是全局唯一的遍历images字典时顺序可能不是1到11788但代码只在ID层面操作顺序无所谓。真正要校验的是len(images)为11788train_ids与test_ids之和等于11788如果输出的训练数是5994、测试数是5794说明解析没问题。注意train_test_split.txt里1代表训练0代表测试不少人会读反先记下来。2.3 边界框裁剪不裁剪会把模型带偏CUB-200-2011原始图像的分辨率不一致很多鸟只占画面中心一小块背景里有树枝、水面、草地如果直接整图resize到224背景像素可能占60%以上模型很容易学到“附近有水面就是某种鸥”而不是靠鸟本身的特征分类。所以裁剪到bounding_boxes是细粒度分类的第一道关键预处理。裁剪时我习惯往外扩15%把鸟的尾巴和头冠包含进来避免边界框裁太紧导致判别部位被切断def crop_by_bbox(img, bbox, margin0.15): x, y, w, h bbox x1 max(0, int(x - margin * w)) y1 max(0, int(y - margin * h)) x2 min(img.width, int(x (1 margin) * w)) y2 min(img.height, int(y (1 margin) * h)) return img.crop((x1, y1, x2, y2))margin是相对原框宽高的比例不是像素数所以对不同分辨率的图表现稳定。若裁得太紧导致训练loss下降缓慢把margin调到0.2若发现模型总在按背景判别重点检查是不是没扩边。2.4 一个Dataset类把预处理全部装起来我一般在工程里把读图、裁剪、增强、归一化全部写进Dataset的__getitem__这样训练和评估共用同一套读取逻辑不会出现“训练时预处理做了预测时却漏掉”的问题# dataset.py import os from PIL import Image from torch.utils.data import Dataset import torchvision.transforms as T class CUBDataset(Dataset): def __init__(self, ids, images, class_ids, boxes, root, trainTrue): self.ids ids self.images images self.class_ids class_ids self.boxes boxes self.root root if train: self.transform T.Compose([ T.Resize((256, 256)), T.RandomResizedCrop(224, scale(0.7, 1.0)), T.RandomHorizontalFlip(0.5), T.ColorJitter(0.2, 0.2, 0.2), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) else: self.transform T.Compose([ T.Resize((256, 256)), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.ids) def __getitem__(self, idx): iid self.ids[idx] img Image.open(os.path.join(self.root, self.images[iid])).convert(RGB) crop crop_by_bbox(img, self.boxes[iid]) return self.transform(crop), int(self.class_ids[iid]) - 1逻辑说明先把图像按ID从images.txt里拿到路径然后裁剪再交给transform。类别ID全部减1是因为交叉熵损失要求从0开始的softmax索引而标注文件里是1到200。iid在这里没有随样本返回后面生成提交文件时需要用别的方式对齐我会在第4章补上。参数说明训练集用RandomResizedCrop做尺度扰动scale设为0.7到1.0避免把测试时能看到的边缘内容在训练时裁没ColorJitter的强度只给0.2因为CUB的颜色本身就是重要判别信息抖动太大会把种类颜色学坏。Normalize用ImageNet的统计量预训练模型就是基于这个标准来的。3. 细粒度分类方案选型为什么通用CNN会翻车哪条路线更容易拿高分3.1 细粒度分类与普通图像分类的不同先想清楚任务普通图像分类里区分猫、狗、车、船主要靠整体形状和上下文而CUB-200-2011要区分的是200个子类比如黑脚信天翁和短尾信天翁两者外形几乎一样差异集中在喙的弧度、翅膀边缘色带和尾羽形状。类间差异小于类内差异是细粒度分类最典型的特征——同一只鸟在不同姿态、不同光照下看起来可能比不同品种间更像。所以通用CNN在ImageNet预训练时学到的“整物体特征”并不够用它不太关注局部判别区域。这个数据集还有一个硬约束训练集平均每类只有30张图。VGG之类的深层网络从头训练在这种数据量上会严重过拟合这就是为什么几乎所有可行方案都基于ImageNet预训练权重做微调。真正要做的并不是“再训一个新网络”而是在预训练特征的基础上把判别性信息找出来这也是后面所有模型选型的出发点。3.2 四条技术路线对比把这学期大概率会遇到的四条路线放在一起比一下路线代表方法计算成本仅靠它拿高分的难度报告可解释性手工特征 分类器SIFT/颜色直方图 词袋 SVM低CPU可跑难准确率约50%-65%最强全是图像处理语言普通CNN微调ResNet50 特征 全连接层中单卡可跑中等准确率约80%-87%一般只能贴accuracy双线性CNNB-CNN、紧凑双线性较高显存吃紧较好可达85%-91%强可讲特征交互注意力/TransformerSE模块、ViT/DeiT、Swin高需要调参强可达88%-93%中注意力图可直接画双线性CNN的思路很有意思把同一个特征图在空间位置上做外积得到“特征与特征两两组合”的矩阵再全局平均池化。它能捕捉“喙的形状”与“翅膀纹理”之间的共现关系比单纯把两个特征拼接更符合细粒度分类的物理含义。代价是外积矩阵维数很大后面必须降维。B-CNN的经典论文里在CUB-200-2011上的准确率约为84%这几年加上降维和更强的骨干能到90%以上作为大作业完全够用。3.3 面向课程要求的取舍深度学习不是唯一答案这里要看课程的具体规定。“数字图像处理”在不同学校差异极大有的用MATLAB强调滤波、边缘检测、SIFT这类传统算法有的用Python允许用深度学习但希望你能讲清图像处理层面的道理。我的建议是先确认两个边界是否允许直接导入预训练模型以及是否限制框架。如果课程允许PyTorch但要求体现数字图像处理原理最稳的做法是“双线性CNN做主线同时跑一个SIFT词袋线性SVM的基线”。在报告里对比两条路线的精度与特征复杂度既展示了图像处理基本功又用深度学习方法拿到了高精度。如果课程明确禁止深度学习那就别硬蹭老老实实基于SIFT/颜色直方图做特征编码把SVM的C参数、核函数和特征降维调好再靠报告里的可视化冲击高分。还有一类极少数情况课程指定用MATLAB的OOP架构搭一套可扩展的图像处理系统那本笔记的PyTorch代码不能直接迁移但数据解析和实验设计思路一样把第2章脚本改成MATLAB的table或容器类模型换成“提取HOG 训练SVM”的经典流程即可。3.4 我最终选的组合在这篇笔记的落地方案里主线采用ResNet50骨干网络加一层1x1卷积把2048维通道压到512维再做双线性池化最后接一个全连接分类头。为什么这样组合CUB的类别数有200纯手工特征很难在这数据集上打高精度普通微调ResNet的判别性不够报告也没太多可写的东西。双线性池化能把“局部特征共现”的关系显式建模同时下采样后的512维外积矩阵在8GB显存的GPU上勉强能跑显存不足也能降到256维。这个选择在准确率、报告深度和作业可行性之间比较平衡。4. 基于ResNet50Bilinear Pooling的训练流程模型、超参与提交文件4.1 模型定义降维双线性池化代码里用两层1x1卷积做通道压缩再对空间位置做外积。直接用2048维做双线性外积会得到2048x2048的矩阵显存和全连接层参数都爆炸所以先压缩到512维# model.py import torch import torch.nn as nn import torchvision.models as models class BilinearFineGrained(nn.Module): def __init__(self, num_classes200, compress_dim512): super().__init__() base models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) # 去掉 ResNet 最后的池化层和全连接层保留到第4层输出 self.backbone nn.Sequential(*list(base.children())[:-2]) # 用两层1x1卷积把 2048 通道压缩到 compress_dim self.compress nn.Sequential( nn.Conv2d(2048, compress_dim, 1), nn.BatchNorm2d(compress_dim), nn.ReLU(inplaceTrue), ) # 分类头上三角双线性特征长度为 compress_dim*(compress_dim1)//2 self.fc nn.Linear(compress_dim * (compress_dim 1) // 2, num_classes) self.compress_dim compress_dim def forward(self, x): feat self.backbone(x) # [B, 2048, 7, 7] feat self.compress(feat) # [B, 512, 7, 7] B, C, H, W feat.shape v feat.view(B, C, H * W) # [B, 512, 49] outer torch.bmm(v, v.transpose(1, 2)) / (H * W) # [B, 512, 512] # 取上三角去掉对称重复项 iu torch.triu_indices(C, C, devicex.device) bilinear outer[:, iu[0], iu[1]] # [B, 512*513/2] # 符号平方根 L2归一化对应 B-CNN 论文里的后处理 bilinear bilinear.sign() * torch.sqrt(bilinear.abs() 1e-8) bilinear torch.nn.functional.normalize(bilinear, dim1) return self.fc(bilinear)逻辑说明ResNet50去掉尾部池化层后输出是7x7的2048维特征图。压缩层负责把特征通道从2048降到512外层外积是把每个空间位置的特征向量两两求内积。取上三角是因为外积矩阵是对称的上三角已经包含全部信息。符号平方根和L2归一化能让双线性特征的数值分布更稳定分类头训练时收敛更快。参数说明compress_dim默认512显存不够时可以改成256分类头参数量会从约2620万降到约570万准确率会降1到2个点。triu_indices每次forward都生成索引我习惯把它的设备对齐到x的device避免在CPU和GPU之间来回拷贝。注意backbone里的BatchNorm参数默认是可训练的后面第5章会专门说batch size对它的影响。4.2 训练脚本分学习率、余弦退化与最佳模型保存微调预训练模型时最忌讳把全连接层和骨干网络设成同一个学习率。fc层是从0开始训练的需要大学习率快速收敛backbone已经有ImageNet特征只需要小步调整。所以优化器用分组参数# train.py import torch from torch.utils.data import DataLoader from dataset import CUBDataset from model import BilinearFineGrained # 参数配置 BATCH_SIZE 16 EPOCHS 30 LR_HEAD 1e-3 LR_BACKBONE 1e-5 WEIGHT_DECAY 1e-4 # 数据准备路径按你的实际情况改 root CUB_200_2011/images train_ds CUBDataset(train_ids, images, class_ids, boxes, root, trainTrue) test_ds CUBDataset(test_ids, images, class_ids, boxes, root, trainFalse) train_loader DataLoader(train_ds, batch_sizeBATCH_SIZE, shuffleTrue, num_workers4, pin_memoryTrue) test_loader DataLoader(test_ds, batch_sizeBATCH_SIZE, shuffleFalse, num_workers4, pin_memoryTrue) # 模型与优化器 model BilinearFineGrained(num_classes200).cuda() backbone_params list(model.backbone.parameters()) list(model.compress.parameters()) head_params list(model.fc.parameters()) optimizer torch.optim.AdamW([ {params: backbone_params, lr: LR_BACKBONE}, {params: head_params, lr: LR_HEAD}, ], weight_decayWEIGHT_DECAY) criterion torch.nn.CrossEntropyLoss() scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxEPOCHS)# 训练与验证循环 best_val_acc 0.0 def evaluate(model, loader): model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in loader: out model(images.cuda()) pred out.argmax(dim1) correct (pred.cpu() labels).sum().item() total labels.size(0) return correct / total * 100 for epoch in range(EPOCHS): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() out model(images) loss criterion(out, labels) optimizer.zero_grad() loss.backward() optimizer.step() pred out.argmax(dim1) correct (pred labels).sum().item() total labels.size(0) total_loss loss.item() train_acc correct / total * 100 val_acc evaluate(model, test_loader) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_model.pt) scheduler.step() print(fepoch {epoch1}/{EPOCHS} floss {total_loss/len(train_loader):.4f} ftrain_acc {train_acc:.2f}% val_acc {val_acc:.2f}%)逻辑说明每个epoch结束后跑一次测试集验证集上准确率更高就保存当前权重。这里把测试集当作验证集用因为CUB官方只给了这一份划分做作业时如果想再抠细一点可以从训练集里再切200张当真正的验证集但提交时仍然用官方测试集报告最终结果。参数说明LR_HEAD给1e-3是fc层的学习率LR_BACKBONE给1e-5是预训练骨干的学习率这两个数量级差不要小于50倍。Weight decay统一用1e-4双线性特征高维权重衰减能稍微压制过拟合。CosineAnnealingLR让学习率从初始值按余弦曲线降到接近0比固定学习率在最后几个epoch更稳。epoch先按30设如果训练集acc没到90%可以加10轮如果val_acc连续5轮不涨直接停掉不要浪费算力。4.3 生成提交文件预测、类别映射与结果校验课程一般要求提交一个结果文件常见格式是“图像ID 预测类别ID”ID都是1到200顺序无关。生成时只保留best_model.pt里的权重# predict.py import torch from dataset import CUBDataset model.load_state_dict(torch.load(best_model.pt)) model.eval() lines [] with torch.no_grad(): for images, labels in test_loader: out model(images.cuda()) preds out.argmax(dim1).cpu().numpy() 1 # 类别ID加回1 for p in preds: lines.append(f{p}) with open(submission.txt, w) as f: f.write(\n.join(lines))逻辑说明test_loader的样本顺序由test_ids决定而test_ids和test_loader里的样本一一对应所以生成的预测列表长度一定是5794。注意这里只输出了类别ID如果要附图像ID需要把iid也返回出来推荐和第一个样本的iid做顺序对齐后再写文件。参数说明argmax后加1是因为网络输出是0到199必须还原成官方标注的1到200。生成的submission.txt每一行是一个整数没有表头行数必须是5794。提交前做一个简单校验行数是否正确类别ID是否都在1到200之间不要让评分程序在第一行就报错。参数名取值作用BATCH_SIZE16与BatchNorm稳定性强相关EPOCHS30余弦变化周期长度LR_HEAD1e-3分类头学习率LR_BACKBONE1e-5骨干网络学习率WEIGHT_DECAY1e-4全连接层正则化compress_dim512双线性外积前的通道数5. 避坑验证集准确率卡在85%的五个真实原因与排查顺序5.1 坑1四份txt对齐出错训练集里混进测试样本现象训练acc很快超过95%验证acc也高得反常但换一台机器复现时结果完全不同或者提交文件被评分程序判定为“ID与类别不匹配”。原因解析时用了行号对齐而train_test_split.txt、images.txt、bounding_boxes.txt的行顺序并不一致导致类别ID和图像文件张冠李戴。解决统一用图像ID做字典的key然后写一个自检函数随机抽10个ID打印该ID的图片路径、类别、bbox人工核对一下。不要相信“打印出来看起来正常”要看bounding_boxes是否落在鸟身上。5.2 坑2训练和预测用了两套预处理预测时忘掉裁剪现象训练acc不错测试acc比验证时低5到8个点。原因训练流程里做了bbox裁剪和数据增强但predict时只对原图做了resize模型看到的输入分布和训练时不一致。解决把裁剪函数和transform统一封装在Dataset里训练和预测都只通过同一个Dataset类加载数据。我自己的教训是不要在predict脚本里手写一遍预处理数据管线只写一次两边复用。5.3 坑3学习率设置错误微调把ImageNet权重冲坏了现象前两个epoch loss下降很快第三个epoch开始loss震荡上升验证acc反而比随机初始化还低。原因backbone的lr给得太高预训练的卷积权重被大梯度覆盖尤其BatchNorm层的running_mean会被带偏。解决按第4章的配置backbone用1e-5fc层用1e-3。如果显存允许也可以先把backbone的BatchNorm参数固定只训练fc层10轮再解冻骨干小学习率训练20轮效果会更稳。5.4 坑4batch太小BatchNorm统计量不稳定现象单卡显存不足把batch size降到4训练acc波动很大验证acc上不去。原因BatchNorm在小batch上计算的均值和方差噪声大running_statistics更新也不稳定。解决把图像尺寸从256缩到224或192或者把双线性compress_dim降到256来腾出显存尽量保证batch不小于16。如果实在凑不够就把backbone里的BatchNorm层设为eval模式用ImageNet预训练时的统计量前向只训练后面双线性层和分类头。5.5 坑5只保存最后一轮权重错过验证最优点现象训练过程中val_acc在20轮时达到89%结束后看final_acc只有86%。原因余弦退火后期可能出现轻微的过拟合最后一轮并不等于最优解。解决每轮结束都计算验证acc只要比历史最优高就覆盖保存best_model.pt。这个习惯能提1到3个点几乎是零成本的。报告里也可以放出训练曲线说明你没有“挑最好的说”而是按验证集动态选择模型这本身就是严谨性的体现。6. 冲击95分的验证技巧Grad-CAM可视化与报告三张图准确率高不高是一回事能不能证明模型“看对地方”是另一回事。评分老师看到90%的acc确实会认可但要上95分以上报告里必须有三类材料学习曲线、混淆矩阵、样本可视化。学习曲线直接从训练日志里画混淆矩阵用sklearn的confusion_matrix重点标出模型最容易混淆的10类。如果这些类别在生物学上确实相似说明模型学到了合理特征如果不相似说明你的数据预处理还有问题。可视化建议用Grad-CAM它能把模型分类时的注意力区域叠到原图上。PyTorch下可以这样走from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget # 取backbone最后一个残差块的最后一个卷积层作为目标层 target_layers [model.backbone[-1][-1]] cam GradCAM(modelmodel, target_layerstarget_layers) # img_tensor是预处理后的单张输入 grayscale_cam cam(input_tensorimg_tensor.unsqueeze(0).cuda(), targets[ClassifierOutputTarget(pred_id)])把热力图和原图加权叠加能直接看出模型是在看喙、看翅膀还是看背景。正确样本选3张错误样本选3张错误图旁边写上你推测的失败原因比如“同一类幼鸟与成鸟姿态差异过大”这部分写得越具体报告的可信度越高。我自己把这套做完的体会是Grad-CAM不是锦上添花它经常能暴露你没发现的数据问题。有一次我发现某类鸟的热力图全落在水面而不是鸟身上回头看是bbox裁剪后扩边不够训练样本里水面占比太大。把margin从0.15调到0.2后该类的准确率从71%涨到79%。所以可视化一定要亲眼看不要只贴图不分析。最后提一个习惯所有实验固定随机种子日志里记录每个epoch的时间、显存占用和数据增强配置。作业答辩时老师一旦问“你试过什么”能报出具体数据和对比结果比任何空话都管用。希望这些步骤和踩坑经验能帮你把这次数字图像处理大作业稳稳推到95分以上。本文还有配套的精品资源点击获取
返回列表