资讯动态

CNN细粒度鸟类识别实战:预训练与分阶段学习率避坑

发布时间:2026/10/25 21:12:28 来源:尧图企业网站定制
简介面向深度学习与计算机视觉方向的学生、科研人员及技术人员这份文档完整记录了一次细粒度鸟类识别实验。内容以 AlexNet 与 ResNet-18 两套经典卷积网络为基线说明如何将输出层改为 200 类以适配 CUB-200-2011 数据集并用 ImageNet 预训练权重初始化、以小学习率微调从而兼顾收敛速度与识别精度。文中还交代了该数据集约 11788 张图像、200 个鸟类类别的划分方式对训练 loss 曲线与验证集 accuracy 变化做了可视化解读并对比了有无预训练权重、不同训练步数与学习率衰减策略下的结果差异点明欠拟合与过拟合的边界。压缩包共 1 个 docx 文件约 1.36MB内含网络结构图、数据集示例与边界框预测结果图便于对照复现。已有 184 人学习初学者可当作练习项目上手进阶读者可借鉴其中的微调与调参思路。1. 从一次失败开始200 类鸟识别里最值得投入的一步不是换网络同一份 200 类鸟类数据集我先用 AlexNet 从随机初始化开始训跑了三十来个 epoch验证集准确率还在低位徘徊换成加载预训练权重的 ResNet-18十几个 epoch 就明显抬起来了。真正拉开差距的不是网络换没换而是权重初始化和分阶段学习率这两件事有没有做对。这篇就把卷积神经网络做细粒度图像识别的完整链路拆开讲数据集怎么切、AlexNet 和 ResNet-18 怎么比、输出层改哪里、曲线读到什么程度算读懂、哪些坑会让你白跑一晚上。刚上手机器学习课程实验的能照着把流程跑通已经训过几轮 CNN 的能看到我每一步判断的依据尤其是那些“看起来在训练、其实什么都没学到”的假象。2. 任务先确定CUB-200-2011 的构成、划分与数据管道2.1 CUB-200-2011 里到底有什么200 类、约 1.2 万张、两套额外标注先把数据摸清楚再谈模型这一步偷懒后面全是坑。CUB-200-2011 是细粒度分类的经典基准200 个鸟类类别每类大约 60 张图总量约 11,788 张。类别分布并不均匀有的类不到 50 张有的类能到 60 多张这个不均衡程度不算夸张但足以让某些类的召回率明显偏低看混淆矩阵时会发现错分集中在样本少的那几类。图片尺寸不统一很多原图的长边只有三四百像素短边更小。这意味着你直接 resize 到 224×224 一定会有不同程度的放大失真鸟的喙、眼圈、翅斑这些真正有判别力的局部细节会被插值糊掉。细粒度识别的信息量恰恰藏在这些局部里所以预处理策略不能照搬 ImageNet 那套“先 Resize(256) 再 CenterCrop(224)”的默认流程得先统计一遍短边分布再决定是补边还是直接缩放。数据集除了类别标签还附带两套标注一份是每张图的鸟类边界框一份是属性标注喙的形状、尾巴颜色之类的语义描述。做纯分类时这两套标注用不上但它们有两个实在的用途一是训练完拿边界框做可视化验证模型到底是在看鸟还是在看背景二是如果后面想加注意力模块或者做多任务学习这些标注可以直接当监督信号不用重新标。2.2 细粒度识别的三个难点类间差异小、类内差异大、背景干扰细粒度分类和普通分类最大的区别在于类别之间的差异被压缩到了很局部的位置。两种不同的麻雀整体轮廓、体型、羽色分布几乎一致区别可能只在头部条纹的走向和眼后的斑纹。这带来第一个后果输入分辨率不能压得太低224 是底限条件允许时用 320 或 448 再配合随机裁剪局部纹理保留得更多。第二个难点是类内差异远大于类间差异。同一只鸟站着、飞着、侧身、正对镜头加上光照变化和遮挡像素层面的分布可能比两个不同类还远。所以数据增强的方向要选对颜色抖动、水平翻转、小角度旋转是安全的因为这些变换不会破坏判别性局部而大比例随机裁剪、强烈的透视变换就要谨慎一不小心把头部裁掉了标签还在模型只能去学背景。第三个难点是背景干扰。鸟类图片大多拍自野外树枝、天空、水面、芦苇的纹理有时比鸟本身更有“辨识度”。模型在训练集上学到“水面 某类水鸟”这种捷径换到测试集就崩。这也是为什么我只用分类标签训练时仍然会保留边界框做验证——它可以直观告诉你模型的注意力到底落在哪里。提示判断一个细粒度数据集难不难不用看类别数看两件事——每类样本量和类间差异出现的位置。样本量低于 50 且差异集中在局部时基本都要靠预训练权重和更强的数据增强来补。2.3 数据管道怎么写三份标注文件的对齐与增强策略CUB 的标注是几个用空格分隔的文本文件图像列表、类别标签、训练测试划分各自独立靠 img_id 关联。第一件事就是把它们对齐成一张表代码不长但少做一步后面全是错位。# 把三份标注文件按 img_id 对齐成一张训练用表 import pandas as pd from pathlib import Path root Path(data/CUB_200_2011) imgs pd.read_csv(root / images.txt, sep , names[img_id, file_name]) labels pd.read_csv(root / image_class_labels.txt, sep , names[img_id, class_id]) split pd.read_csv(root / train_test_split.txt, sep , names[img_id, is_train]) df imgs.merge(labels, onimg_id).merge(split, onimg_id) df[class_id] df[class_id] - 1 # 原始标签是 1~200要转成 0~199 df[path] df[file_name].map(lambda p: str(root / images / p)) # file_name 自带子目录前缀 train_df df[df.is_train 1].reset_index(dropTrue) test_df df[df.is_train 0].reset_index(dropTrue)三个细节值得说清楚。第一class_id在原始文件里从 1 开始计数如果直接喂给CrossEntropyLoss第 200 类会直接越界报错或者更糟——不报错但整体错位一位loss 卡在 5 以上怎么都降不下去这种问题排查起来最耗时间。第二file_name里已经包含了类似001.Black_footed_Albatross/xxx.jpg这样的子目录所以拼路径时加一层images/就够不要再去按类别拼目录。第三官方只给了 train/test 两段划分验证集必须自己从 train 里切出来绝不能拿 test 调超参数。增强策略我用的是组合式RandomResizedCrop(224, scale(0.7, 1.0))控制裁剪比例下限避免裁得太狠RandomHorizontalFlipColorJitter(0.2, 0.2, 0.2)控制幅度别太大最后接Normalize均值方差用 ImageNet 那组因为主干是预训练的输入分布必须和预训练时一致这一条错了微调基本等于白做。验证和测试阶段只做 Resize CenterCrop Normalize不引入任何随机性。# 训练/验证两套 transform验证集必须确定性否则指标会抖动 from torchvision import transforms train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), # ImageNet 统计量 ]) val_tf transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])2.4 训练/验证/测试怎么切别用测试集调参官方的 train/test 划分里train 大概占六成、test 占四成没有单独的验证集。常见做法是从 train 里按类别分层抽 15% 到 20% 出来当验证集剩下的训模型。必须按类别分层因为每类只有五六十张随机切很可能出现某类在验证集里只有一两张的情况指标波动会大到没法比较两组超参数的优劣。如果做多组对比实验我一般固定划分的随机种子让所有配置共用 mesma 的划分这样曲线才能横向对比。换一次种子换一次划分最后得出的“A 比 B 高两个点”很可能只是划分噪声这种经验教训吃过一次就忘不掉。3. AlexNet 与 ResNet-18 怎么选四个维度、两张改造图和一份参数表3.1 四个维度参数量、归一化、收敛速度、精度上限这两个网络放在一起比很多人只看准确率实际上对实验节奏影响最大的是参数量和归一化层。下面这张表是我做选型时的判断依据数字是架构的公开规格不是跑出来的结果。维度AlexNetResNet-18对 200 类鸟类任务的实际影响参数量约 6.0×10^7集中在三层全连接约 1.1×10^7AlexNet 参数多五倍1 万张图训练很容易过拟合归一化原版无 BN每层卷积后接 BN无 BN 时学习率稍大就发散调参窗口窄残差连接无有18 层不算深但残差结构让梯度更稳收敛更顺显存占用全连接层占大头batch 上不去以卷积为主同显存能塞更大 batch8G 卡上 AlexNet 大概 32~64ResNet-18 能到 64~128精度上限低适合当对照基线高适合出最终结果细粒度任务上差距比普通分类更明显结论我给得很直接AlexNet 用来做对照、验证流程是否跑通ResNet-18 用来出结果。原因是 AlexNet 的设计年代早没有 BN全连接层巨大在小数据集上过拟合倾向明显而且它对输入尺寸敏感、对学习率挑剔新手拿它起步很容易得到“怎么调都不涨”的挫败感。但也正因为这样它作为“从头训练 vs 预训练微调”的对照组很有说服力——同一个网络只换初始化方式曲线形态的差别一目了然。ResNet-18 的优势不只是残差结构本身。18 层在这个任务上不算深真正起作用的是 BN 带来的训练稳定性学习率能开大一点收敛更快不同随机种子之间的结果也更可复现。做实验写报告时“结果可复现”比“高零点几个点”重要得多。3.2 输出层改造两种写法与三个容易改错的位置两个网络都要把输出从 1000 类改成 200 类但改的位置不一样。AlexNet 的分类头是一个 Sequential最后那层 Linear 的输入维度是 4096ResNet-18 的分类头是单独一个fc层输入维度是 512。改错的表现很典型要么 shape 报错要么跑起来不报错但 loss 一动不动。# 两种架构的输出层改造注意改动位置不同 import torch.nn as nn from torchvision import models def build_model(name: str, num_classes: int 200, pretrained: bool True): weights DEFAULT if pretrained else None if name alexnet: model models.alexnet(weightsweights) in_dim model.classifier[6].in_features # 4096 model.classifier[6] nn.Linear(in_dim, num_classes) elif name resnet18: model models.resnet18(weightsweights) in_dim model.fc.in_features # 512 model.fc nn.Linear(in_dim, num_classes) else: raise ValueError(funknown backbone: {name}) return model model build_model(resnet18, num_classes200, pretrainedTrue)第一个易错点是 AlexNet 的索引。它的classifier是[Dropout, Linear, ReLU, Dropout, Linear, ReLU, Linear]最后一个 Linear 在索引 6只替换这一个元素前面的 Dropout 要保留。我见过把整个classifier换成单个 Linear 的写法参数量骤降正则也没了结果就是训练集准确率很高、验证集一塌糊涂。第二个易错点是num_classes传错。写 200 还是 1000代码都不报错但写 1000 时新初始化的那一层大部分输出永远学不到东西loss 下降极慢。检查方法很土但有效打印一下model.fc或者model.classifier[6]看输出维度是不是 200。第三个易错点是预训练权重的接口。新版 torchvision 用weightsDEFAULT老版本用pretrainedTrue两套接口混用会直接抛类型错误。写代码时统一成一种别在同一份代码里来回切换。3.3 分阶段学习率新头大、主干小的参数表新加的分类头是随机初始化的主干是训练好的两者用同一个学习率必然出问题。学习率按主干的标准设小了新头收敛慢按新头的标准设大了几个 batch 就能把预训练特征冲乱这就是典型的灾难性遗忘。我采用的是两段式先冻结主干只训新头几轮之后解冻全部主干用小学习率、新头用中等学习率。# 阶段一冻结主干只训练新分类头 for p in model.parameters(): p.requires_grad False for p in model.fc.parameters(): # AlexNet 换成 model.classifier[6] p.requires_grad True # 阶段二解冻全部用分组学习率主干小、头大 optimizer torch.optim.SGD([ {params: [p for n, p in model.named_parameters() if not n.startswith(fc.)], lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3}, ], momentum0.9, weight_decay5e-4)参数起点值我给一张表都是可以照着开的量级具体还要按显存和卡数微调。参数起点值调整依据输入尺寸224×224想更好利用局部细节可上 320但显存翻倍batch size32AlexNet/ 64ResNet-18按显存上限取别小到 BN 统计不稳主干学习率1e-4预训练权重超过 1e-3 容易冲坏新头学习率1e-3随机初始化可以大一些weight decay5e-4小数据集防过拟合优化器SGD momentum 0.9想省事可换 AdamW但对比实验要统一阶段一冻结轮次3~5新头稳定后解冻总轮次25~40配合早停3.4 预训练 vs 从头训练差距体现在哪两处同一个网络、同一份数据、同一套增强只改初始化方式差距体现在两个地方。第一是收敛速度从随机初始化开始时前几个 epoch 的 loss 下降非常慢因为网络要先学会“什么是边缘、什么是纹理”这些通用特征加载预训练权重后这部分知识已经有了第一个 epoch 结束的 loss 就明显更低。第二是最终性能的上限。小数据集从头训练模型容易记住训练集的样本外观而不是类别特征验证集准确率很早就停止上升继续训只会让训练集和验证集的差距越拉越大。这也解释了为什么原料里那组对比的结论是预训练微调显著更优——不是网络结构的问题是数据量撑不起从零学习通用特征的成本。我一般会保留一组“从头训练”的结果作为对照因为它在报告里能说明一个关键判断这份数据的规模决定了预训练是不是必需品而不是可选项。4. 曲线读到什么程度算读懂loss、accuracy 与验证策略4.1 四种曲线形态对应什么问题训练过程可视化不是画出来好看的四种典型形态对应四类问题看懂了能省掉大量试错。形态train lossval loss常见原因处理健康稳步下降后趋缓同步下降后趋平正常收敛继续观察是否进入平台期过拟合持续下降接近 0先降后升数据量小、模型容量大加增强、加 weight decay、早停震荡上下大幅波动不降同步波动学习率过大、未做归一化降学习率一个量级、检查 Normalize全平卡在高位几乎不动同步不动标签错位、梯度没传、学习率过小查标签范围、查 requires_grad第三种和第四种最容易误判成“模型不行”。尤其是全平这一种如果阶段一冻结主干后忘了在阶段二把requires_grad设回True整个主干梯度为零loss 会稳稳地停在一个值上曲线看起来毫无异常但模型实际上只训练了一个线性分类头。这种坑我踩过一次之后再写冻结逻辑都会加一句断言打印可训练参数数量确认。4.2 学习率衰减StepLR 与 Cosine 的差别衰减策略影响的是收敛后期能不能再往下压一点。StepLR 是每隔固定轮次乘一个 gamma比如每 7 个 epoch 乘 0.1形态是阶梯状的CosineAnnealingLR 是按余弦曲线平滑降到接近 0后期学习率非常小能更细致地逼近局部最优。我实测的感受是总轮次少十几轮时两者差不多StepLR 更省心总轮次多三十轮以上时 Cosine 的曲线更平滑验证集准确率的后期抖动更小。但不管用哪个最后的准确率差异通常远小于“有没有用预训练”带来的差异别在这上面纠结太久。# 两种衰减策略二选一和优化器一起构建 from torch.optim.lr_scheduler import StepLR, CosineAnnealingLR scheduler StepLR(optimizer, step_size7, gamma0.1) # 阶梯式 # scheduler CosineAnnealingLR(optimizer, T_maxepochs) # 平滑式 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, criterion) val_acc evaluate(model, val_loader) scheduler.step() # 每个 epoch 结束后推进注意scheduler.step()的调用位置。新版 torch 里如果每个 batch 调一次、而step_size是按 epoch 设计的学习率会掉得飞快十几个 step 之后几乎为零表现为 loss 突然不动了。判断方法是打印optimizer.param_groups[i][lr]看它是不是在预期轮次才下降。4.3 checkpoint 怎么选别拿最后一个 epoch 交差验证集准确率不是单调上升的最后一个 epoch 往往不是最好的那个。固定保存最优模型是最基本的操作代码很短但不写就得靠运气。best_acc 0.0 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, criterion) val_acc evaluate(model, val_loader) scheduler.step() if val_acc best_acc: # 严格大于避免同分反复覆盖 best_acc val_acc torch.save({model: model.state_dict(), epoch: epoch, acc: val_acc}, best.pt) print(fepoch {epoch} val_acc {val_acc:.4f} best {best_acc:.4f})保存字典而不只是state_dict是为了后面能查到“这个权重是第几个 epoch、验证集多少分”。写报告或者复现时这个信息比权重本身还重要。测试集评估只用这个best.pt不要用最后的模型也不要在测试集上再挑一次——那就等于拿测试集调参了。4.4 边界框可视化能告诉你什么训练完把预测结果和原图叠在一起看能发现很多指标看不出来的问题。重点是看模型的注意力落在哪如果鸟在画面中央模型表现正常如果背景是水面而鸟只占一小块模型却频繁把相似背景的图判成同一类那基本可以确定它在走捷径。具体做法是把边界框画在原图上同时标出预测类别和置信度。错分的样本优先看尤其是高置信度错分——这类样本往往暴露的是数据本身的问题比如标注错误、图片里根本没有主体鸟、或者同一张图里出现了两只不同种类的鸟。这一步花不了多少时间但它是判断“模型学到了什么”最直接的手段比盯着准确率数字有用。5. 避坑与排查六个把我拖到后半夜的问题5.1 六条踩坑记录现象、原因、解决第一条loss 稳稳卡在 5.3 附近不动。现象是训练十几轮loss 几乎一条直线验证集准确率等于随机猜。原因是类别标签没有减一范围是 1~200而CrossEntropyLoss期望 0~199第 200 类会越界其余类别整体错位。解决办法就是在构造表的时候统一class_id - 1并且在 Dataset 里加一句assert 0 label 200让错误在数据加载阶段就暴露。第二条训练 loss 掉得很快验证 loss 第三轮就开始回升。现象是训练集准确率很快冲到很高验证集停在低位。原因有两层一是学习率对预训练主干来说太大特征被冲乱二是数据增强太弱模型直接记住了样本。解决是先冻结主干训新头解冻后主干学习率压到 1e-4同时把RandomResizedCrop的 scale 下限从 0.9 调到 0.7增强强度上来后过拟合明显缓解。第三条显存溢出batch 只能开到 16。现象是CUDA out of memory调小 batch 后训练极慢且 BN 统计不稳。原因是 AlexNet 的三层全连接层占用巨大输入尺寸又是 224激活值撑爆显存。解决办法是换 ResNet-18或者用梯度累积模拟大 batch——每 4 个小 batch 才optimizer.step()一次效果接近 batch 翻四倍。第四条验证集准确率每次跑都不一样差好几个点。现象是同一个配置跑三遍结果波动很大。原因是评估时忘了model.eval()Dropout 和 BN 还在训练模式每次前向都在随机丢弃。解决办法是在评估函数里固定写model.eval()加torch.no_grad()评估结束后再model.train()切回来。第五条从头训练的模型怎么调都比预训练差一大截。现象是对照组始终上不去。原因不是配置错了而是 1 万张图确实不够从零学通用特征这是数据规模决定的上限。解决办法是接受这个结论把它写进对照分析而不是继续在超参数上耗时间。第六条图片路径全部报FileNotFoundError。现象是数据加载直接崩。原因是file_name字段里已经带了类别子目录前缀拼路径时又按类别拼了一次目录。解决办法是打印第一条拼出来的路径肉眼确认一遍这个习惯能省掉大量排查时间。5.2 排查顺序从十张图过拟合开始出了问题别一上来就怀疑模型结构按这个顺序排查效率最高。第一步取十张图、两个类别用一个极小网络或者干脆冻结全部主干只训分类头看能不能把训练准确率打到 100%。打不到问题一定在数据管道或标签上跟模型无关。这一步能在几分钟内排除掉最常见的错误。第二步检查标签分布和标签范围。打印每个类别的样本数、标签的最小值和最大值确认没有越界、没有空类。类别严重不均衡时看混淆矩阵比看总准确率更有信息量。第三步检查预处理一致性。训练和验证的 Normalize 参数必须完全一致预训练模型的输入统计量必须匹配这两条任何一条错了模型都会表现得像“学不会”。第四步才去看学习率、衰减策略、增强强度这些超参数。顺序反了你会花一晚上调参最后发现是标签少减了一个 1。注意任何一次“指标异常但代码不报错”的情况优先怀疑数据其次怀疑梯度最后才怀疑模型容量。6. 落地顺序与一条硬技巧把实验变成能重复跑的东西把上面的东西串起来一套能跑通、能复现、能写进报告的落地顺序是这样的先用官方划分对齐数据表并做标签断言再用 ResNet-18 加载预训练权重、替换fc层为 200 类接着冻结主干训 3 到 5 轮新头然后解冻全部用分组学习率训到 25 到 40 轮配合 StepLR 或 Cosine 衰减全程按验证集保存最优 checkpoint最后只用这个 checkpoint 在测试集上评一次。阶段关键动作判断是否通过数据三份标注对齐、标签减一、分层切验证集打印样本数和标签范围无异常建模型替换输出层、确认可训练参数数量输出维度是 200主干梯度正常阶段一冻结主干只训新头训练 loss 稳定下降阶段二解冻全部分组学习率验证集准确率持续上升评估加载 best.pt只跑一次测试集测试结果与验证集趋势一致这里补一条我认为最硬、但最容易被忽略的技巧在正式训练之前先拿十张图做一次过拟合测试。把 batch size 设成 10、不加任何增强、学习率开到 1e-3跑两百步看训练准确率能不能到 100%。能到说明模型、损失函数、标签、优化器这条链路是通的到不了后面所有的调参都是在错误的基础上做无用功。这个习惯我是一次惨痛经历之后养成的。当时连着两个晚上调学习率从 1e-2 试到 1e-5曲线始终不动最后发现是 Dataset 里返回的标签用了原始字符串而不是整数索引CrossEntropyLoss内部做了类型转换但语义全错。十张图的过拟合测试只要五分钟能拦下这类问题。从那以后我每次开新实验不管多简单的任务都强制先走一遍“十张图过拟合 标签范围断言 预处理一致性检查”这三步然后再开正式训练。看起来多花了十分钟实际上省下的是整晚的无效等待。希望这些能帮到你少走几个我已经替你走过的弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑