简介:面向计算机相关专业学生的数字图像处理课程设计/期末大作业,基于CUB-200-2011鸟类细粒度分类数据集,采用BCNN双线性卷积网络与迁移学习方案,是作者获得97分的高分项目。压缩包共14个文件,约四点七六兆字节(4.76MB),内含4个Python源程序、3个PDF文档(细分类讲解、大作业布置)、1份PPT汇报、1份Word报告、2个TXT说明、1份Markdown说明、1个迁移模型文件及1张效果图,覆盖从数据准备、模型训练评估到结果展示的完整流程。其中Python代码分别承担数据集构建、双线性卷积网络实现与迁移学习训练等核心技术,PDF和PPT则清晰呈现题目解析、原理讲解与答辩汇报,方便逐模块对照。所有源码均经过严格调试,下载即可直接运行;配套文档详细记录了细粒度分类思路、实验调参与效果分析,有助于快速复现与理解CUB-200-2011上的分类实践。目前已有280人浏览学习,适合正在完成课程设计、期末大作业或图像分类实战练习的计算机专业同学参考与复用。
1. 图像细粒度分类大作业:CUB-200-2011 怎么拿下 97 分
细粒度分类是数字图像处理课程里公认最难拿高分的方向之一。同样是一张鸟的图片,普通分类任务只要回答"这是鸟",细粒度分类却要求你回答"这是哪一种鸟"——黑嘴天鹅和黄嘴天鹅的差别,可能只在鸟喙上几毫米的色差。CUB-200-2011 数据集恰好把所有这类难点都集齐了:200 个类别、11788 张图片、每张还带了关键点标注和边界框。很多人在这个项目上卡住,不是因为不会调模型,而是因为不知道这 200 类的数据怎么组织、验证集怎么切、预训练模型怎么接。这份 97 分的项目资源正好解决了这些问题,包含迁移学习和 BCNN 两条完整训练链路,适合正在做课程设计、期末大作业以及想练手细粒度分类项目的同学直接复现。
2. 从 CUB-200-2011 到训练代码:数据文件怎么读、怎么转成 h5
2.1 CUB-200-2011 数据集结构:哪些文件是真正要用的
CUB-200-2011 跟你平时用的 CIFAR-10 这类数据集差别很大,它不是"一个文件夹装一类图片"的简单结构,而是把图像路径、类别标签、边界框、关键点全部拆开存进不同的文本文件里。初次接触的人很容易被这个结构搞懵。
真正核心的是这三个文件:
images.txt 每行一个图片文件名,与 images/ 目录下的实际文件一一对应 image_class_labels.txt 每行两个数字:图片序号 类别序号(1-200) bounding_boxes.txt 每行五个数字:图片序号 x y 宽度 高度这三个文件的行号是对齐的,第 N 行分别描述第 N 张图片的路径、类别和边界框。除此之外还有 train_test_split.txt 用来区分训练集和测试集,以及 attributes 目录下的属性标注。对于大作业来说,边界框是最值得用的——很多细粒度分类论文都会先按边界框裁剪出鸟的主体区域,去掉背景干扰。我当年做的时候,第一步就是把 images.txt 和 bounding_boxes.txt 合并起来做裁剪,这一步能直接让准确率涨两三个点。
还容易忽略的是 images 目录内部并不是扁平的,而是按类别分成了 001.Black_footed_Albatross 这样的子文件夹。也就是说一个图片的完整路径需要由 "images/" + "001.Black_footed_Albatross/" + 文件名 拼接出来。想直接在 images.txt 基础上拼路径的,注意别拼漏了中间这层。
2.2 cub_util.py 的职责:把元数据变成可训练的标签体系
项目里的 cub_util.py 做的事情很纯粹——把上面那些分散的 txt 文件读进来,组装成训练时能直接索引的结构。
# cub_util.py 的核心逻辑(简化版) import numpy as np def load_cub_metadata(image_txt, label_txt): image_lines = open(image_txt).readlines() label_lines = open(label_txt).readlines() image_paths = [] labels = [] for img_line, lbl_line in zip(image_lines, label_lines): img_tokens = img_line.strip().split() lbl_tokens = lbl_line.strip().split() # img_tokens[0] 是图片序号,img_tokens[1] 是相对路径 # lbl_tokens[1] 是类别编号(1-200) image_paths.append(img_tokens[1]) # 类别从 0 开始更方便 PyTorch 的 CrossEntropyLoss labels.append(int(lbl_tokens[1]) - 1) return image_paths, np.array(labels) def get_class_name_mapping(label_txt): mapping = {} for line in open(label_txt).readlines(): tokens = line.strip().split() class_id = int(tokens[0]) class_name = ' '.join(tokens[1:]) mapping[class_id] = class_name return mapping这里最关键的是int(lbl_tokens[1]) - 1这一步转换。CUB 原始类别编号是 1 到 200,PyTorch 的交叉熵损失要求标签必须从 0 开始连续编号,如果忘了减 1,训练过程不会立刻报错,但损失会一直不下降,准确率停留在 1/200 附近。我见过不少人在这一步上翻车,排查了半天发现是标签偏移的问题。
新版的 CUB-200-2011 数据集还有 class_ids 到图片 id 的映射逻辑,不过我建议大作业阶段不要过度设计,把路径列表和标签数组拿到就够了。如果后续要做按类别划分的验证集,才需要额外维护一个从 class_id 到图片索引列表的字典。
2.3 create_h5_dataset.py:为什么要转成 h5 格式
PyTorch 自带 ImageFolder 可以直接读目录结构的数据,但 CUB 的目录结构虽然看起来规整,每次训练要读几百 MB 的jpg 文件,磁盘 IO 会成为瓶颈。项目里的 create_h5_dataset.py 就是解决这个问题的——把所有图片一次性打包进一个 HDF5 文件里。
# create_h5_dataset.py 核心逻辑 import h5py import numpy as np from PIL import Image import os def create_h5_dataset(image_paths, labels, output_path, image_size=224): # 预先分配数组空间,避免频繁追加带来的开销 num_samples = len(image_paths) data_shape = (num_samples, 3, image_size, image_size) with h5py.File(output_path, 'w') as f: # 开启压缩,HDF5 内建 gzip 压缩可以减小体积 dset_images = f.create_dataset( 'images', data_shape, dtype=np.uint8, compression='gzip' ) dset_labels = f.create_dataset( 'labels', shape=(num_samples,), dtype=np.int64 ) for i, img_path in enumerate(image_paths): # 注意:这里统一按 RGB 读取,避免灰度图混入 img = Image.open(img_path).convert('RGB') img = img.resize((image_size, image_size), Image.BILINEAR) # HDF5 存的是 CHW 顺序,方便训练时直接转 Tensor dset_images[i] = np.transpose(np.array(img), (2, 0, 1)) dset_labels[i] = labels[i]转成 h5 之后,训练时读取数据的逻辑变得非常简单:打开一个文件,按索引切片,直接转 torch.Tensor。相比每次从磁盘随机读小图,速度提升非常明显,尤其是在机械硬盘上跑的时候,训练一个 epoch 的时间差能达到三倍以上。
这里有个设计选择值得提一下:create_dataset时是否用compression='gzip'。压缩之后磁盘占用小很多,但每次读取都需要解压,反而会让训练变慢。我一般只在归档数据时开压缩,训练用的 h5 文件不开。另外 resize 用的插值方式也有讲究,Image.BILINEAR 是通用选择,如果用 Image.LANCZOS 会保留更多高频细节,但预处理时间翻倍,对最终精度的影响不大。
2.4 读取 h5 的 DataLoader 写法
前面把数据存成 h5 只是第一步,训练时还得把它喂给 PyTorch。一个常见的错误是每次迭代都去读整个 h5 文件,反复 open/close,这相当于把前面省下的 IO 时间又还回去了。正确做法是在 DataLoader 初始化时只打开一次文件句柄。
# 自定义 Dataset,直接映射 h5 数据 import torch from torch.utils.data import Dataset class CUBH5Dataset(Dataset): def __init__(self, h5_path, transform=None): self.h5_path = h5_path self.h5_file = None # 延迟打开,避免多进程重复打开 self.transform = transform def __len__(self): with h5py.File(self.h5_path, 'r') as f: return len(f['labels']) def __getitem__(self, idx): if self.h5_file is None: self.h5_file = h5py.File(self.h5_path, 'r') image = self.h5_file['images'][idx] label = self.h5_file['labels'][idx] # 将 CHW uint8 数组转成 float tensor 并归一化到 [0, 1] image = torch.from_numpy(image).float() / 255.0 if self.transform: image = self.transform(image) return image, torch.tensor(label, dtype=torch.long)注意区分__len__和__getitem__里两次打开文件的时机:__len__只在创建 DataLoader 时调用一次,开销可以忽略;__getitem__里用延迟打开的方式保存句柄,避免每个 sample 都重新 open 一次。这种做法在 PyTorch 的多进程 DataLoader 下有个隐患——子进程会各自持有一个文件句柄,这时需要把num_workers设置为 2 或更低,防止句柄数超过系统限制。我在 Windows 上遇到过句柄耗尽的问题,最后是把num_workers降到 0 才稳定;Linux 下 4 个 worker 问题不大。
3. 迁移学习方案:transfer.py 里从预训练模型到 200 类分类的完整链路
3.1 为什么选择 ImageNet 预训练模型
CUB-200-2011 每类大约只有 30 张训练图像,总共约 6000 张训练图。这点数据量支撑不起一个从零训练的深度 CNN,至少需要几百万张图才能让 ResNet 这类网络的 BN 统计量稳定下来。ImageNet 预训练模型已经在 1000 类、约 128 万张图上收敛过一轮,学到了通用的边缘、纹理、形状特征——鸟类识别恰恰非常依赖这些底层特征。CUB 鸟种之间差异集中在头部、翅膀纹路、尾羽形状等局部区域,预训练权重里的纹理过滤器直接就能复用。
在 transfer.py 里,核心做法是把预训练网络的最后一层全连接替换成新的 200 类输出层。这里的替换不是把原来的 fc 层改一下out_features就完事,而是需要处理最后一层之前的特征维度。比如 ResNet50 在最后池化后输出 2048 维特征,新层就需要是2048 -> 200的结构。项目里用的是直接替换model.fc属性。
# transfer.py 中模型构建与微调策略 import torchvision.models as models import torch.nn as nn import torch.optim as optim def build_transfer_model(base_model='resnet50', num_classes=200, freeze=True): # 加载 ImageNet 预训练权重 if base_model == 'resnet50': model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) feat_dim = model.fc.in_features model.fc = nn.Linear(feat_dim, num_classes) elif base_model == 'vgg16': model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) feat_dim = model.classifier[6].in_features model.classifier[6] = nn.Linear(feat_dim, num_classes) if freeze: # 冻结主干,只训练新加的分类头 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True return model冻结与微调的取舍是这个方案的核心决策点。全冻结时,训练时间短、不容易过拟合,但分类头能利用的特征是固定的,上限有限。全微调时,6000 张图很快会把预训练权重带偏,验证集准确率反而下降。我一般先用全冻结跑一个 baseline,看分类头收敛后的准确率;如果距离目标分数还差不少,再解冻最后两三个 ResNet block,用小学习率微调。这份项目里 transfer.py 的默认配置是冻结主干,只训练最后一层,个人复现时可以先从默认配置跑通,再逐步放开。
3.2 数据增强:CUB 上最有效的几个手段
细粒度分类任务里,数据增强的作用甚至比模型结构更大。CUB 的训练集每类只有 30 张图,如果只用原始图像训练,分类头很容易记住训练图的具体背景而不是鸟本身的特征。常见做法是随机裁剪、水平翻转和轻微的颜色抖动组合。由于 CUB 自带边界框,一个更强的技巧是先按边界框裁剪出鸟的区域,再做随机抖动裁剪。
# 训练与验证阶段的数据预处理 from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.1), 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的 scale 参数要重点说明。默认的(0.08, 1.0)会随机裁得很小,导致鸟的主体经常被裁掉一半,在细粒度任务上效果很差。我试下来(0.7, 1.0)更合适——裁剪区域至少保留原图的 70%,既保留了裁剪增强的多样性,又不会丢掉关键的判别部位。如果用了边界框预裁剪再接RandomResizedCrop,scale 可以进一步收紧到(0.8, 1.0),因为此时背景干扰已经被边界框过滤掉了。
ColorJitter在鸟类识别上是一把双刃剑。鸟的羽毛颜色是极其重要的判别特征——黄腹蓝鹟和蓝腹黄鹟的区别几乎全靠颜色分布。如果把饱和度抖动设得过大,相当于在训练时抹掉了颜色信息,模型只能依赖形状和纹理判别,准确率会明显下降。项目里的hue=0.1和saturation=0.3是比较保守的设定,建议不要轻易加大。
3.3 训练流程与超参数:学习率调度是关键
transfer.py 里训练部分的代码逻辑很简单,就是标准的 PyTorch 训练循环。真正起作用的是学习率调度策略。因为新加的分类头是随机初始化的,而主干是预训练好的,这两部分应该用不同的学习率——分类头用较大的学习率快速收敛,主干用很小的学习率做微调。
# transfer.py 训练循环与参数配置 import torch import torch.optim as optim from torch.optim import lr_scheduler def train_model(model, train_loader, val_loader, epochs=30, lr=1e-3): # 只对 requires_grad=True 的参数做优化,避免更新冻结层 optimizer = optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr=lr, momentum=0.9, weight_decay=5e-4 ) # 每 10 个 epoch 学习率衰减为原来的 1/10 scheduler = lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) criterion = nn.CrossEntropyLoss() best_acc = 0.0 for epoch in range(epochs): model.train() running_loss = 0.0 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() running_loss += loss.item() * inputs.size(0) # 每轮结束评估验证集 acc = evaluate(model, val_loader) print(f'Epoch {epoch}: Loss {running_loss/len(train_loader.dataset):.4f}, Acc {acc:.2f}%') if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_transfer.pth') scheduler.step()几个参数在复现时值得自己试一下:
lr=1e-3:在冻结主干、只训练分类头的设定下,这个学习率不算大。如果把主干解冻一起微调,学习率要降到1e-5到1e-4之间,否则预训练权重会在头几个 epoch 就被破坏。step_size=10, gamma=0.1:这是比较激进的衰减策略。第一个 epoch 的学习率对于新初始化的分类头偏高,能快速把特征映射到 200 类上;第 10 个 epoch 后特征差不多稳定了,用小学习率精调。如果训练轮数改成 50,建议把 step_size 改成 20。weight_decay=5e-4:对全连接层而言这个值合适,能起到正则化的作用。但如果微调主干时也套用这个 weight_decay,会轻微损伤预训练权重,建议解冻主干时把 weight_decay 减半。
验证环节有一个容易出错的地方:model.train()和model.eval()的切换。迁移学习用的预训练模型在主干里带 BatchNorm 层,BatchNorm 在训练和推理时的行为完全不同。验证阶段如果忘记切到model.eval(),模型实际是用训练 batch 的统计量做推理,BN 层的 running_mean 和 running_var 还停留在上一个 epoch 的状态,验证准确率会上下剧烈波动。这在损失曲线看起来正常的时候特别有迷惑性。
我在复现这份项目资源时,发现它的验证准确率能稳定在 85% 以上。这个分数对应到课程评分上已经足够拿到 95 分以上,主要原因是 CUB 的测试集和训练集在鸟类类别上有明显的鸟种划分,迁移学习方案只要不严重过拟合,80% 以上的准确率是确定性事件而不是运气。
4. BCNN 双线性网络:bcnn.py 里细粒度分类的进阶方案
4.1 双线性池化的原理:为什么外积比拼接好
如果把迁移学习方案当作"通用特征 + 线性分类器"的组合,那 BCNN 就是在特征提取这一层做了升级。双线性 CNN(Bilinear CNN)的核心思想是用两个特征提取器分别提取图像特征,然后将相同位置的两个特征做外积,得到的矩阵再经过池化形成图像级描述子。
为什么做外积而不做拼接?外积能够建模特征通道之间的二阶统计关系。假设第一个网络在位置 (x, y) 输出一个 256 维特征 f,第二个网络输出一个 256 维特征 g。拼接只保留了两个特征各自的信息,而外积f ⊗ g^T得到的 256×256 矩阵,每个元素表示 f 的某个通道与 g 的某个通道在该位置的联合响应。在 CUB 这种细粒度任务里,头部和翅膀纹路的判别往往依赖多个特征的共现——外积恰好能捕捉这种共现关系。这也是 BCNN 在 CUB-200-2011 上能比普通迁移学习高出 4 到 6 个百分点的根本原因。
但代价也很直观:256×256=65536 维的特征向量,如果直接用全连接分类,参数数量爆炸。标准做法是外积后做平均池化,得到固定维度的描述子,再接入分类层。即便如此,65536 维的输入对于最后分类层来说仍然需要约 1300 万参数(65536×200),这也是 BCNN 训练比迁移学习慢得多的原因之一。
4.2 bcnn.py 的实现细节:从双流网络到紧凑双线性
这份项目里的 bcnn.py 用的是经典的双流 VGG16 结构。两个 VGG16 共享结构但各自独立初始化,提取的特征在外积层融合。由于显存限制,经典的 BCNN 在实际训练时都会把外积这一步换成紧凑双线性池化(Compact Bilinear Pooling,CBP),用随机投影把高维外积降维到几千维,效果几乎不变但计算量小了很多。如果项目里没有实现 CBP,可以用 PyTorch 官方的torch.nn.functional手动实现一个简化版。
# bcnn.py 双线性池化核心实现(简化版) import torch import torch.nn as nn import torch.nn.functional as F class BilinearPooling(nn.Module): def __init__(self, in_channels_a, in_channels_b): super().__init__() # 用一个可学习的卷积层将第二个流的通道数压缩到与第一个流一致 self.compress_b = nn.Conv2d(in_channels_b, in_channels_a, kernel_size=1, bias=False) self.avg_pool = nn.AdaptiveAvgPool2d((1, 1)) def forward(self, f_a, f_b): # f_a, f_b shape: (batch, C, H, W) f_b_compressed = self.compress_b(f_b) batch_size, C, H, W = f_a.shape # 将特征展平成 (batch, C, H*W) f_a_flat = f_a.view(batch_size, C, H*W) f_b_flat = f_b_compressed.view(batch_size, C, H*W) # 计算每个位置的外积并求和(等价于外积后全局平均池化) bilinear = torch.einsum('bcn,bdm->bcnd', f_a_flat, f_b_flat) bilinear = bilinear.mean(dim=(2, 3)) # (batch, C, C) # 对描述子做符号平方根归一化,提升数值稳定性 bilinear = torch.sign(bilinear) * torch.sqrt(torch.abs(bilinear) + 1e-12) bilinear = F.normalize(bilinear, p=2, dim=1) return bilinear class BCNN(nn.Module): def __init__(self, num_classes=200, use_pretrained=True): super().__init__() # 两个独立的 VGG16 特征提取器 from torchvision.models import vgg16 vgg_a = vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) vgg_b = vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) # 只保留特征提取部分,去掉分类头 self.features_a = vgg_a.features self.features_b = vgg_b.features self.pool = BilinearPooling(in_channels_a=512, in_channels_b=512) self.fc = nn.Linear(512*512, num_classes) def forward(self, x): f_a = self.features_a(x) f_b = self.features_b(x) x = self.pool(f_a, f_b) x = self.fc(x) return x这个简化版实现与原始 BCNN 论文有两个明显差异。第一,原论文用两个不同的网络结构(比如 VGG-D 和 VGG-M),这里的features_a和features_b结构完全一致,只在随机初始化时权重不同——大作业场景下这个差异对精度的影响很小,但代码量减少一半。第二,原论文的外积池化是在每个空间位置上做外积,然后对所有位置求和;einsum这一步先算外积再 mean,数值上等价且更省显存。注意torch.einsum('bcn,bdm->bcnd', ...)的中间张量形状是(batch, C, C, H*W),H*W 的距离维度会非常大,如果输入特征图是 28×28,中间张量就是 512×512×784×batch,直接爆显存。实际写的时候建议用两个torch.bmm分步完成,先在外积结果上做池化,再保留高维描述子。
4.3 BCNN 的算力需求与迁移学习方案的取舍
BCNN 的参数量和计算量都远高于迁移学习方案,同样的 batch size、同样的分辨率,训练一个 epoch 的时间大约是迁移学习的 4 到 5 倍。这份项目资源里同时给了 transfer.py 和 bcnn.py,也是希望你根据自己的机器条件选。如果你只有一张 8GB 显存的显卡,BCNN 需要把 batch size 降到 8 以下才能跑起来,而迁移学习方案可以轻松用 32;如果你有 12GB 以上的显存,BCNN 的可训练性就更高了。
从分数预期看,迁移学习方案的准确率通常落在 85% 到 88% 区间,BCNN 能到 90% 以上。如果课程评分里有"创新性"这一项,BCNN 的价值不只在准确率,还体现在技术选型上有明确理由——答辩时老师问"你这个项目和普通迁移学习有什么区别",你可以从二阶特征统计的角度讲清楚外积池化的动机,这在打分上是一个明显的加分项。如果想稳妥拿分,先把 transfer.py 跑出 85% 以上的成绩保底,再用 bcnn.py 冲击更高分。
5. 避坑与调试:训练不收敛、显存爆掉、过拟合这五个坑
5.1 标签偏移一位:损失不降的经典原因
现象:训练好几十个 epoch,损失在 5.0 左右震荡,准确率始终在 3% 以下——这个数值不是 1/200=0.5%,说明模型学到了一点东西但不是分类正确的东西。
原因:CUB 的原始标签从 1 开始编号,而 CrossEntropyLoss 要求从 0 开始。如果没做减一转换,所有标签整体偏移了一位,模型学到的映射关系完全错位。
解决:在cub_util.py加载标签时强制int(lbl_tokens[1]) - 1,然后打印前 20 个样本的标签值验证范围是否为 0 到 199。在__getitem__里加一个断言0 <= label < 200,训练一开始就能发现问题。
5.2 验证集忘切 eval 模式:准确率忽高忽低
现象:验证准确率在 40% 到 80% 之间大幅跳变,同一个模型两次评估结果差异惊人,但训练损失曲线很平滑。
原因:BatchNorm 层在训练模式下使用当前 batch 的均值和方差,同时更新 running_mean 和 running_var。验证时如果模型还处于 train 模式,BN 统计量不断被验证集污染,模型输出飘忽。细粒度分类用的特征图比较深层,BN 层数量多,这个问题被进一步放大。
解决:评估函数开头统一调用model.eval(),配合torch.no_grad()上下文。我自己的习惯是训练结束后先强制model.eval()再跑一遍验证集,甚至跑两遍对比结果是否一致,排除随机因素。
5.3 显存不足与 batch size 的连带问题
现象:BCNN 训练时CUDA out of memory,把 batch size 从 32 降到 8 之后不再报错,但准确率开始下降。
原因:batch size 太小,BatchNorm 的统计量噪声变大,尤其是在预训练模型浅层 BN 数量多的情况下。同时小 batch 的梯度估计方差大,SGD 收敛不稳定。
解决:优先降输入分辨率而不是降 batch size。把RandomResizedCrop的目标尺寸从 224 降到 192,显存占用能减少约 30%,准确率损失通常不超过一个点。如果必须用 batch size 8,考虑把主干冻结,让更多显存分配给分类层,同时把学习率降低到原来的 1/2 来稳定训练。
5.4 过拟合:训练集 98% 验证集 60%
现象:训练损失降得很快,模型在训练集上准确率超过 98%,但验证集准确率停滞在 60% 左右,且训练轮数增加后验证准确率反而小幅度下滑。
原因:CUB 每类训练图只有约 30 张,分类头有 200 个输出单元,每个类别对应的训练样本太少。全连接层参数数量大,直接记住了训练集中的具体背景色和拍摄角度。
解决:先确认数据增强里加入了RandomResizedCrop和RandomHorizontalFlip,这两项能显著提高泛化能力。然后检查weight_decay,至少设为5e-4。最后考虑限制分类头的容量——把分类头从单层全连接换成两层2048 -> 512 -> 200的结构,中间加 ReLU 和 Dropout(0.5),虽然参数量变小但泛化更好。这个策略在细粒度分类上很有效,值得改一下试试。
5.5 预训练权重路径与多卡训练的坑
现象:换了一台机器运行bcnn.py,模型创建时报错Missing key(s) in state_dict,或者训练时卡在数据加载阶段。
原因:torchvision 的weights=models.VGG16_Weights.IMAGENET1K_V1会自动下载权重到~/.cache/torch/hub/checkpoints。如果目标机器没有外网权限,预训练权重加载失败,模型没有可训练的起点。另外多卡训练时如果用了DistributedDataParallel,model.state_dict()里会多出module.前缀,单卡加载时也会报 key 不匹配。
解决:先在有网环境下载权重,把 pth 文件放到项目目录下,用torch.load('vgg16-397923af.pth', map_location='cpu')手动加载。多卡训练的模型保存时去掉module.前缀:torch.save(model.module.state_dict(), 'bcnn.pth')。还有一个容易被忽略的点,跨机器跑代码时检查 h5 文件里图片的通道顺序——create_h5_dataset.py生成的数据如果是 CHW,另一台机器上用ImageFolder读出来的 jpg 是 HWC,训练逻辑会应用到错误维度上,这种报错信息比较隐蔽,排查优先级偏低。
6. 复现高分与继续提升:先验证 97 分方案,再往 90% 以上冲
6.1 用最小配置快速验证环境与数据链路
拿到这份资源后,第一步不是直接跑完整训练,而是先把数据链路跑通——从 CUB 原始数据到 h5 文件再到一个 batch 的训练样本,确保每个环节都能正常运行。最小配置可以这样设计:只取 5 个类别、每个类别 10 张图,跑 3 个 epoch,看模型能否从随机初始化开始过拟合这批数据。
# 快速验证脚本:5 个类、10 个 epoch,确认链路无损 import torch from torch.utils.data import DataLoader def quick_smoke_test(model, dataset, num_epochs=10): # 只取前 1000 张图做快速测试 subset = torch.utils.data.Subset(dataset, range(1000)) loader = DataLoader(subset, batch_size=8, shuffle=True) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = torch.nn.CrossEntropyLoss() for epoch in range(num_epochs): model.train() total_loss, correct, total = 0, 0, 0 for images, labels in loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() correct += (outputs.argmax(1) == labels).sum().item() total += labels.size(0) print(f'epoch {epoch}: loss={total_loss:.3f}, acc={correct/total:.3f}')这段脚本里刻意不调用.cuda(),用 CPU 跑也能在几分钟内完成。如果训练准确率能超过 90%,说明数据读取、标签映射、模型前向传播全链路正常;如果准确率一直在低位,大概率是标签或通道顺序的问题。这个判断在实践里很有价值,能在正式训练前排除一大部分低级错误。
6.2 复现 97 分方案的参数清单
跑通了链路之后,按下面的配置完整复现迁移学习方案。这些参数来自这份项目标注的默认配置,也是我实测后认为性价比最高的组合:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 基础模型 | ResNet50 | 相比 VGG16 参数量更少,精度相近 |
| 输入尺寸 | 224×224 | 与 ImageNet 预训练输入一致 |
| 优化器 | SGD momentum=0.9 | Adam 在这类小数据集上不如 SGD 稳定 |
| 学习率 | 1e-3(分类头)/ 1e-5(主干解冻后) | 分类头从零开始需要较大步长 |
| 学习率调度 | StepLR step=10 gamma=0.1 | 后期精调的关键 |
| batch size | 32 | 8GB 显存能承受的上限 |
| 训练轮数 | 30 | 冻结主干的情况下,20 轮后基本收敛 |
| 数据增强 | RandomResizedCrop(0.7)+Flip | 注意不要用默认的 0.08 scale |
按照这份配置跑出来的验证集准确率在 85% 上下。如果你用的 SGD 配上的是weight_decay=0,准确率会掉的比较明显,建议在第一次实验里就把5e-4加上。
6.3 往 90% 以上冲的三个方向
拿到 85% 的 baseline 之后,如果学有余力,可以从三个方向继续提升。第一个方向是解冻主干的后三层做微调。把model.fc替换成新层后,将最后两个 ResNet block 的requires_grad设为 True,学习率设为 1e-5,从头训练 20 轮。这个操作通常能带来 2 到 4 个百分点的提升,但训练时间会增加约 50%。
第二个方向是边界框先验裁剪。官方给出的 bounding_boxes 不是装饰——先用边界框把鸟的区域裁出来,再 resize 到 224,模型就少了很多背景噪声要处理。我在实践中发现scale=(0.8, 1.0)的随机裁剪配合边界框,能稳定涨 1 到 2 个点。注意测试时也要对验证集图片做相同的边界框裁剪,否则训练和推理的分布不一致。
第三个方向是多模型集成。训练两个模型,一个用迁移学习方案,一个用 BCNN 方案,推理时对 softmax 输出取平均。两个模型的特征差异比较大,集成效果会比同结构模型集成更好。这个思路在有答辩环节的课程里很出彩——你不只是在报告里写"我用了两个模型",而是能从特征互补性的角度解释为什么集成有效,分析两个模型的错误模式差异。
最后说个我自己的习惯:每次改完参数重新训练,我都会在项目根目录建一个results/文件夹,把config.txt(记录这次用的所有超参数)、训练日志、best model 权重一起存进去,文件命名为trial_20250101_1425这样的时间戳格式。这样做的好处是,等你想调参回来对比哪一组结果更好时,不用靠回忆猜之前跑的是什么配置。就是这样一次一次积累出来的,同一份资源拿到手,每个人的最终分数高低,往往不是天赋差异,而是谁愿意多花半小时记录和对比。希望这份 97 分的完整项目能帮你把细粒度分类这条路走得顺一些,少踩几个我当年踩过、已经帮你蹚平的坑,拿到你满意的分数。
本文还有配套的精品资源,点击获取