
简介面向需要实践图像分类任务的深度学习者这份CUB鸟类数据集训练工程基于CUB-200-2011数据集使用CNN实现对200种鸟类的分类识别覆盖从数据处理到模型训练的完整流程。压缩包内共5个文件包含3个Python脚本和2个编译后的pyc文件脚本分工明确分别负责数据读取与预处理、网络结构定义以及训练与验证流程整体大小仅15KB轻量便携便于直接运行或二次开发。目前已有648人学习浏览适合作为计算机视觉课程设计、鸟类细粒度识别入门或算法对比的基线工程。通过阅读代码读者可快速掌握CUB200数据集的加载方式、CNN模型搭建思路及训练循环的常用写法也能借助这份精简代码复现基础分类结果为后续调参、改进或迁移到其他细粒度任务提供清晰起点。1. CUB200 鸟类数据集训练为什么一个 200 类的小鸟数据集值得认真对待CUB-200-2011Caltech-UCSD Birds-200-2011是细粒度图像分类绕不开的基准200 种鸟类、11788 张图像平均每类只有 30 到 60 个样本。类间差异极小换一个姿态、换一束背景光同一种鸟都可能被误判成另一种。网上流传的 CUB.zip_CUB200_CUB鸟类数据集_cub200-2011 训练包本质就是把官方图片和五个标注 txt 打包成一个压缩包省去你逐个文件下载的时间。这篇笔记从解压开始按“文件解析 → DataLoader → 训练配置 → 验证技巧”的顺序带你跑通一次标准 CUB 训练顺手把我踩过的坑标清楚。适合刚接触细粒度分类、想在 CUB200 上跑出第一个可提交 baseline 的人。2. 解压 CUB.zip 并解析标注五个 txt 文件的对齐方法很多人拿到 CUB.zip 的第一反应是按 COCO 或 YOLO 的习惯去找 JSON 或 XML结果翻遍整个压缩包只看到 images 文件夹和一堆 txt当场愣住。CUB-200-2011 的数据组织方式很老派但逻辑极清晰所有标注文件都用 image_id 作为主键只要在合并标注之前先把这条线理顺后面就顺了。2.1 CUB200 的官方文件构成image_id 是连接一切的那把钥匙解压后你会看到这么几样东西缺一样都说明打包不完整images 目录存放全部鸟图按物种子目录组织路径形如001.Black_footed_Albatross/Black_Footed_Albatross_0001_1.jpg。images.txt两列image_id和filenameimage_id 从 1 连续编号到 11788。image_class_labels.txt两列image_id和class_idclass_id 范围是 1 到 200。train_test_split.txt两列image_id和is_train1 表示训练0 表示测试。classes.txt两列class_id和class_name也就是 200 个物种的字母序名称。bounding_boxes.txt只有在你打算按目标框裁剪或做注意力增强时才用得上包含image_id, x, y, w, h坐标相对原图。这五个文件的共同点是没有表头列与列之间用空格或制表符分隔。第一列全都是 image_id这就是关联所有信息的钥匙。一个常见翻车点是用 Excel 打开这些 txt 再另存为 csv分隔符会被改成逗号之后 pandas 解析就会莫名多出空列。我的建议是全程用脚本读取不要人工编辑原始标注。值得先做一步完整性校验统计 images.txt 的行数应该是 11788。如果少了多半是压缩包没下载全或者解压中断。检查完行数再看文件名里有没有反斜杠某些老压缩包在 Windows 下解压会把路径分隔符弄乱这属于老掉牙但真实存在的坑。2.2 用 pandas 把四张表合并成一张训练清单既然所有 txt 都靠 image_id 对齐最省事的做法就是直接 merge 成一个大表后面写 Dataset 只用这张表不用再去翻原始文件。import pandas as pd from pathlib import Path base Path(CUB_200_2011) df_img pd.read_csv(base / images.txt, sep\s, names[image_id, filename]) df_lbl pd.read_csv(base / image_class_labels.txt, sep\s, names[image_id, class_id]) df_split pd.read_csv(base / train_test_split.txt, sep\s, names[image_id, is_train]) df df_img.merge(df_lbl, onimage_id) df df.merge(df_split, onimage_id) print(df.head()) print(df[is_train].value_counts())逻辑说明先给每个 txt 命名列然后按 image_id 依次 merge。sep\s是为了兼容空格和制表符混用的情况比写死sep 更稳。names参数必须有否则 pandas 会把第一行数据当表头。运行后你应该看到 is_train 列统计大约是训练 5994、测试 5794加起来正好 11788。如果数字差很多先回到 2.1 检查文件完整性。表里保留原始 class_id 从 1 到 200后面写 DataLoader 时再做减一操作不要在 DataFrame 里预先改掉免得和 classes.txt 对不上。如果你要用 bbox 做目标区域裁剪再把bounding_boxes.txt按相同方式 merge 进来。注意它给的是原图绝对坐标一旦你提前把图 resize 过坐标就作废了必须拿着原始尺寸的 PIL Image 来裁。df_bbox pd.read_csv(base / bounding_boxes.txt, sep\s, names[image_id, x, y, w, h]) df df.merge(df_bbox, onimage_id, howleft)参数说明how 用 left 而不是 inner是为了在个别 bbox 缺失时保留原行避免训练样本数量悄悄变化。CUB 官方的 bbox 应该是全的但保一手没坏处。3. 写成 PyTorch DatasetCUB200 的图片读取与数据增强配置表已经拼好了下一步是把这张表喂给 PyTorch。很多人图省事把图片按类别目录塞进torchvision.datasets.ImageFolder但这样 train_test_split 就废了因为你无法在 ImageFolder 的目录结构里精确表达官方指定的训练/测试划分。老老实实自己写一个 Dataset 类几十行代码而已换来的是分割可控、采样可控、调试可控。3.1 实现 CUB200Dataset用一张表驱动图像加载下面这个 Dataset 类接受之前合并好的 DataFrame根据 split 参数过滤出对应的行在__getitem__里按 filename 读图。import torch from torch.utils.data import Dataset from PIL import Image class CUB200Dataset(Dataset): def __init__(self, df, base_path, splittrain, transformNone): # split 取 train 或 test对应 is_train 的 1 和 0 flag 1 if split train else 0 self.df df[df[is_train] flag].reset_index(dropTrue) self.base_path base_path self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path self.base_path / images / row[filename] img Image.open(img_path).convert(RGB) label int(row[class_id]) - 1 # class_id 从 1 开始网络输出是 0~199 if self.transform: img self.transform(img) return img, label逻辑说明split参数决定从 DataFrame 里筛哪部分行训练时只拿 is_train1 的 5994 张测试时只拿 is_train0 的 5794 张。路径拼接用 pathlib 的/运算符自动适配 Linux 和 Windows 的分隔符避免字符串手拼反斜杠出问题。.convert(RGB)会把灰度图或带透明通道的图统一成三通道防止个别图片让 DataLoader 报 shape 不匹配。参数说明label 减一是必须的模型输出层是 200 个神经元索引对应 0~199而 class_id 是 1~200。这个细节漏掉的话训练不会报错但混淆矩阵会整体偏移一格。拿到 Dataset 后配合 DataLoader 使用from torch.utils.data import DataLoader train_set CUB200Dataset(df, base, splittrain, transformtrain_transform) test_set CUB200Dataset(df, base, splittest, transformtest_transform) train_loader DataLoader(train_set, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) test_loader DataLoader(test_set, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue)参数说明shuffle只在训练集开测试集必须关闭否则评估结果的顺序每次都不一样num_workers视 CPU 和磁盘性能调整Windows 上建议不超过 4Linux 可以开到 8pin_memoryTrue能略微减少 CPU 到 GPU 的拷贝时间但要求 DataLoader 所在的进程持有 CUDA 上下文。3.2 transform 设置与三个关键参数均值标准差、裁剪尺度、增强强度CUB200 的图像不是规整的模型输入尺寸原始长宽从几百到上千像素都有所以 transform 是训练流程里非常重要的一环。用 ImageNet 预训练模型做迁移时归一化必须沿用预训练时的参数这是最容易被忽略但影响极大的细节。from torchvision import transforms mean [0.485, 0.456, 0.406] std [0.229, 0.224, 0.225] train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize(mean, std), ]) test_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean, std), ])参数说明RandomResizedCrop的 scale 下限定在 0.7不要学 ImageNet 里常用的 0.08。CUB 的鸟通常占画面比例不小裁到 0.08 很容易只剩下一片羽毛或背景等于把标签毁了。ColorJitter三个通道的扰动幅度可以给到 0.3 甚至 0.4颜色抖动对细粒度分类的收益比通用分类更明显因为模型很容易拿颜色差异当捷径。测试侧的 Resize 定 256、CenterCrop 定 224这是迁移学习里最标准的配置如果你训练时用了 384 分辨率测试侧就对应 Resize(448) CenterCrop(384)。增强强度这个参数很值得单独说一说。CUB200 每类只有三十几张训练图训练集整体的样本量也就 5994 张属于典型的小数据细粒度任务。我在实际跑的时候发现RandAugment 或 TrivialAugment 这类自动增强带来的提升比换一个更大的预训练模型还明显。但别把增强堆到失真尤其是 MixUp 和 CutMix 这类会混合标签的增强在细粒度任务上要谨慎。CUB 里鸟类形态本就高度相似MixUp 之后一张图里出现半只海鸥和半只燕鸥模型很难学到干净的特征。注意如果你用了 timm 的预训练模型它可能有自己推荐的 mean/std务必去读模型卡或源码确认不要闭眼套 ImageNet 的固定值。4. 训练配置与完整训练脚本在 CUB200 上微调 ResNet-50 / ViT-B/16数据管线就绪后真正决定最终准确率的几个因素按重要程度排预训练模型的选择、骨干网络是否冻结、学习率和调度策略、损失函数与增强。CUB200 训练集太小从零训练一个 CNN 几乎不可能超过 60% 的 top-1微调预训练模型是唯一现实路径。4.1 预训练模型怎么选一张表看懂成本和准确率先给一个大致参考区间方便你判断自己的配置属于什么水平。下面的数字是在官方分割、图像级训练/测试划分的标准协议下用 ImageNet-1k 预训练权重微调得到的常见结果区间不是精确排行榜但能帮你快速发现自己有没有跑偏。模型骨干参数量CUB200 top-1 典型区间单卡显存参考batch32, 224pxResNet-50约 25M83% - 86%8 - 10 GBResNet-101约 45M84% - 88%10 - 12 GBViT-B/16约 86M87% - 90%10 - 13 GBViT-L/16约 307M89% - 92%20 GB 起选型逻辑很简单先拿 ResNet-50 跑通整条链路确认数据、代码、评估都没问题再升级到 ViT。很多论文里 CUB 的 SOTA 数字来自 ImageNet-21k 甚至 CLIP 预训练跟 torchvision 自带的 ImageNet-1k 权重不是一回事你拿本机跑出来的数去硬碰那些动辄 90% 的结果属于自己给自己找不痛快。ViT 有个玄学点同样的 ViT-B/16 权重用 224 输入训练稳定到 88% 左右换成 384 输入后往往能再涨一两个点但显存消耗几乎翻倍。新手阶段我建议先把 224 跑通再去折腾分辨率。4.2 网络改造与分阶段学习率先训分类头再解冻骨干拿 torchvision 的 ResNet-50 举例改造只有两件事替换最后一层全连接把 1000 类输出改成 200 类然后设置不同参数的初始学习率。import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR from torchvision import models model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 200) # 先冻结骨干只训练新分类头让随机初始化的 fc 先稳定下来 for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True optimizer optim.AdamW([ {params: model.fc.parameters(), lr: 1e-3, weight_decay: 0.01}, {params: model.parameters(), lr: 1e-4, weight_decay: 0.05}, ]) scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-6)逻辑说明随机初始化的 fc 层直接丢进低学习率环境里收敛会很慢所以给它 10 倍学习率。骨干网络已经具备很强的视觉特征用低一档的学习率微调避免破坏预训练学到的通用表征。这个两段式设计是微调任务里最常见也是最有效的做法。前几个 epoch 跑完分类头基本稳定后再解锁整个骨干。解锁代码很简单for param in model.parameters(): param.requires_grad True解锁后接着用同一个优化器跑剩余 epoch。我这里 T_max 设 30对应总训练计划 30 个 epoch如果你想改成 60就把 T_max 同步改掉。CosineAnnealing 的 eta_min 不要设成 0设成 1e-6 这样的极小值防止学习率跌到零后模型在最后几步出现权重抖动。4.3 损失函数、BatchSize、Epoch 的选择与训练循环损失函数直接用 CrossEntropyLoss但把 label_smoothing 打开。CUB200 的类间相似度太高硬标签会把模型逼成过度自信标签平滑在小数据集细粒度任务上的收益非常稳定。criterion nn.CrossEntropyLoss(label_smoothing0.1)参数说明label_smoothing0.1 的含义是真实标签不再给 1.0而是给 0.9剩下 0.1 均分给其余 199 个类。这会略微降低训练集上的拟合速度但能有效抑制过拟合测试集上的 top-1 通常会升 0.5 到 1 个百分点。完整训练循环里关键动作是每个 epoch 结束都在测试集上做一次评估并保存最优权重不要只盯着训练 loss。epochs 30 best_top1 0.0 for epoch in range(epochs): model.train() total_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) model.eval() test_correct 0 test_total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) test_correct (preds labels).sum().item() test_total labels.size(0) test_top1 test_correct / test_total print(fepoch {epoch1}: loss{total_loss:.4f}, test_top1{test_top1:.4f}) if test_top1 best_top1: best_top1 test_top1 torch.save(model.state_dict(), cub200_best.pth)逻辑说明训练时用model.train()让 BatchNorm 和 Dropout 处于训练行为评估时必须切回model.eval()否则 BatchNorm 会用当前 batch 的统计量猛然导致评估分数偏低。每个 epoch 评估一次虽然慢一点但能让你直观看到过拟合是从第几个 epoch 开始的。Batch 大小的取值我一般固定 batch32 起步。显存够就提到 64学习率同步按比例放大比如从 1e-3 提到 1.5e-3不要只改 batch 不改学习率。CUB 每类只有三十几张训练图batch128 以上对梯度估计的帮助不大反而容易提前过拟合。epoch 数不要少于 20超过 60 也基本没必要ViT 类模型 30 到 40 个 epoch 足够ResNet 系列 40 到 60 个 epoch 更稳超过 80 个基本是纯烧时间。训练环境方面torch2.0、torchvision0.15、pandas、Pillow 这几样就够。GPU 显存低于 8GB 时可以把 batch 降到 16或使用混合精度。PyTorch 2.0 之后直接用torch.compile(model)也能白捡 5% 到 15% 的吞吐在图幅小的 CUB 上收益尤其明显。5. CUB200 训练避坑与常见问题排查五个让我翻过车的细节这一章内容是我自己跑 CUB 过程里的血泪经验每一条都直接对应某个具体的翻车现场。你如果按 2、3、4 章的步骤走大概率不会踩全但其中几条隐蔽性很强值得提前扫一眼。5.1 验证集和训练集的 transform 没有分开导致评估结果失真现象训练 loss 一直降训练准确率已经接近 95%但测试集 top-1 只有 70% 出头而且波动很大。排查代码发现验证时用的 transform 里也包含了 RandomResizedCrop 和 RandomHorizontalFlip。原因随机增强让同一张图每次评估的结果都不一样模型输出和标签的对齐变得不稳定评估指标失去可比性。解决把train_transform和test_transform严格分开。测试侧只保留 Resize、CenterCrop、ToTensor、Normalize四个操作不允许出现任何随机性。每次打印测试结果之前先确认当前 transform 里没有 Random 开头的方法。5.2 图片读取报错“cannot identify image file”或数量不对现象训练跑到中途抛 OSError提示某个 jpg 无法识别或者统计 DataLoader 长度时发现图片总数不是 11788。原因压缩包在传输或解压过程中有文件损坏也可能是图片路径拼接出错Windows 下用反斜杠拼出来的路径在 Linux 上失效。解决先用 2.2 的合并结果统计行数确认 11788。再用脚本遍历 images 目录逐个用 PIL 打开一次遇到打不开的记录下来。实际处理时我习惯在 Dataset 的__getitem__里把打开失败时的异常信息打出来包含 filename 和 idx直接定位到具体文件。如果只是个把损坏文件删掉对应行即可不用重下整个压缩包。5.3 模型学到的是背景而不是鸟过拟合与捷径特征现象训练集准确率几乎满格验证集卡在 75% 左右上不去把图里的鸟抠掉换成纯色背景模型仍然以极高置信度判成同一个类别典型的表现之一是换背景后预测结果几乎不变。原因CUB 中同一物种的图像经常来自少数几个场景背景对类别有很强的相关性模型直接记住了背景特征。细粒度分类任务里这种情况比通用分类严重得多。解决增强强度加大RandAugment 或自研的 ColorJitter 多给一点另一个常用做法是用 bounding_boxes.txt 把鸟裁剪出来训练让模型从第一眼就只能看到鸟本身。裁剪半径可以给 bbox 外扩 20% 到 30%保留一点上下文防止裁掉飞行的翅膀。# 在 Dataset 里启用 bbox 裁剪的示例 row self.df.iloc[idx] img Image.open(self.base_path / images / row[filename]).convert(RGB) if self.use_bbox: x, y, w, h int(row[x]), int(row[y]), int(row[w]), int(row[h]) pad_x, pad_y int(w * 0.2), int(h * 0.2) left max(0, x - pad_x) top max(0, y - pad_y) right min(img.width, x w pad_x) bottom min(img.height, y h pad_y) img img.crop((left, top, right, bottom))参数说明pad_x 和 pad_y 是 bbox 外扩量按宽高的 20% 计算。裁剪后图像尺寸就不固定了但后续 transform 里的 RandomResizedCrop 或 Resize 会自动统一到目标尺寸前处理不用多操心。5.4 class_id 从 1 开始但网络输出是 0 到 199类索引整体错位现象训练过程一切正常损失也在下降但打印混淆矩阵时发现绝大多数预测类别都比真实类别大一格比如真实类是第 3 类模型总预测成第 4 类。原因CUB 官方 class_id 从 1 开始而 PyTorch 的 CrossEntropyLoss 要求标签从 0 开始。如果没有做减一操作等于每张图的真实标签都被整体平移了一位。解决在 Dataset 的__getitem__里对 class_id 做int(row[class_id]) - 1。这里有个隐藏的坑如果只在训练时减一而测试时不减测试评估会异常地高因为模型输出的 0 号类在测试集里不存在预测会偏向 0 号附近的类反而碰巧命中不少样本。排查这类问题最直接的办法是打印一个 batch 的 labels看最大值是不是 199。5.5 分割协议混淆官方图像级分割 vs 类级分割现象自己按目录随机抽了 80% 做训练跑出来的 top-1 比论文里高 3 到 5 个点但一换到官方训练/测试划分分数立刻掉回正常区间让人以为模型训练过程出了问题。原因CUB200 官方train_test_split.txt是图像级分割每个物种的图片都被按比例拆到训练和测试两个集合模型在训练时见过全部 200 个类。而少样本学习或零样本学习的论文里常使用类级分割即前 100 个类训练、后 100 个类测试模型在测试时面对的是完全没见过的类别。两者指标完全不可比。解决凡是跟论文比数字先确认对方用的是哪种协议。跑标准微调 baseline 时一律使用官方 train_test_split.txt并且绝不修改标签分布。保存 checkpoint 时把协议信息写进文件名或 notes比如cub200_official_split_resnet50_best.pth避免三个月后自己忘了当时怎么跑的。6. 验证技巧把 CUB200 的准确率拆开看才知道该调什么只盯着一个总体 top-1 数字很难判断模型是真正掌握了细粒度特征还是靠运气蒙对了几类常见鸟。我一般会多做两步验证把准确率拆开看再决定要不要继续投入训练成本。6.1 按类统计准确率找出模型的“盲区物种”CUB200 的 200 个类里有些类之间长得极像比如各种鸥类、莺类模型犯错的模式往往集中在特定几个类簇。把测试集预测结果按类聚合输出 per-class accuracy并按准确率从低到高排序能直接暴露问题。import pandas as pd results [] with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) for true_l, pred_l in zip(labels.cpu().numpy(), preds.cpu().numpy()): results.append({true: true_l, pred: pred_l}) res_df pd.DataFrame(results) res_df[correct] (res_df[true] res_df[pred]).astype(int) class_acc res_df.groupby(true)[correct].mean().sort_values() print(class_acc.head(20))逻辑说明true 列是标签pred 列是预测按 true 分组计算每个类的平均正确率。排序后看最低的 20 个类如果某个类的准确率不到 20%说明模型在这个物种上有系统性缺陷。这时再回去翻那几类图片通常能发现它们跟某个高频正确类长得特别像模型把两者混为一谈。6.2 TTA 与模型权重平均不增加训练数据就能拿回一两个点水平翻转 TTATest Time Augmentation在鸟类识别上几乎是无脑有效因为鸟类图像左右翻转不改变语义。做法很简单测试时把同一张图的原图和水平翻转图都送进模型把两个 softmax 概率平均后取 argmax。model.eval() with torch.no_grad(): logits_a model(images) logits_b model(torch.flip(images, dims[3])) probs (logits_a.softmax(dim1) logits_b.softmax(dim1)) / 2 preds probs.argmax(dim1)参数说明torch.flip 的 dims[3] 是针对形状为 [batch, 3, H, W] 的张量在宽度维上翻转。做 TTA 时同样要在 no_grad 下进行并且模型是 eval 模式。这个操作几乎不增加训练成本只在推理时多跑一次前向通常能换来 0.3 到 1 个点的提升。另一个更省事的技巧是权重平均。把训练过程中保存的最近几个 checkpoint 的权重直接做平均再作为最终模型。CUB 这类小数据集上权重平均对 ViT 的收益比 ResNet 更明显因为 ViT 的微调过程更容易在后期振荡。我第一次跑 CUB 时就是死在 5.5 的分割协议上拿着类级分割的结果去跟论文对比费了半天劲才发现协议根本不同。现在我的习惯是先打印df[is_train].value_counts()再打印df.groupby(is_train)[class_id].nunique()确保训练集和测试集的类别数都是 200才开始下一步。这两个验证动作耗时不到十秒却能避免一整天白干。希望这些经验能帮你绕开我踩过的坑顺利跑出属于自己的 CUB baseline。本文还有配套的精品资源点击获取