
简介围绕卷积神经网络的鸟类识别实验报告面向希望深入细粒度图像分类的机器学习学习者、学生与科研人员重点解决如何基于预训练CNN完成迁移学习与参数微调的问题。文档以AlexNet和ResNet-18为主干网络说明输出层改造、ImageNet预训练权重初始化及小学习率微调方法并梳理CUB-200-2011数据集的200类划分与精细标注特点。实验部分展示Loss与Accuracy曲线、bounding box预测结果对比有无预训练权重以及不同训练步数、学习率和衰减策略下的表现可帮助读者理解过拟合、欠拟合与收敛速度的判别。压缩包共1个docx文件大小约1.36MB已有184人学习。适合作为课程实验、项目练手或细粒度分类入门参考便于按章节复现实验流程并积累调参经验。1. 从一次失败的鸟类识别实验说起一次微调策略的修正比换网络有用得多那次实验里最反直觉的一个结论是把 AlexNet 换成 ResNet-18 带来的精度提升远不如把微调策略改对一次带来的提升大。我们做的是基于卷积神经网络的鸟类识别数据集用 CUB-200-2011200 个类别、约 11788 张图属于典型的细粒度图像识别任务——同属不同种的鸟差别可能只在喙的弧度、翼斑的颜色、尾羽的长度上。这类任务里模型不缺“这是一只鸟”的判断力缺的是“这是哪一只鸟”的分辨力。整篇内容围绕这份实验报告展开把我自己复现时踩过的坑、能直接抄的配置、以及每一步为什么要这么选讲清楚。如果你正准备上手一个 CNN 细粒度分类项目或者手里已经有一份跑不通的实验代码这篇能帮你少走两三天弯路。2. 网络选型AlexNet 与 ResNet-18 的四个维度对比选 AlexNet 还是 ResNet-18不是“新的一定好”这么简单。这两个架构在这份实验里被同时摆上台面本身就有它的道理一个是 2012 年的老将一个是残差连接的入门款参数量、归一化方式、收敛行为差别都很大。我在复现时把两个网络都跑了一遍下面按我实际关心的四个维度拆开讲。2.1 参数量、归一化与感受野两个架构的真实差异先看硬指标。AlexNet 是 5 个卷积层加 3 个全连接层参数量约 60M其中绝大部分压在最后两个 4096 维的全连接层上ResNet-18 是 18 层带残差跳连的结构参数量约 11.7M比 AlexNet 少了五分之四。这个差异直接决定了两件事显存占用和过拟合风险。维度AlexNetResNet-18卷积层数517含 1 个 7×7 主干参数量约 60M全连接层占大头约 11.7M归一化无 BatchNorm原版用 LRN每个卷积后接 BN默认输入尺寸224×224原论文 227224×224输出层改造点classifier[6]的Linear(4096, 1000)fc的Linear(512, 1000)batch32 时显存约 2.5G约 1.8G微调收敛所需 epoch更多前期 loss 抖动明显更少前 3 个 epoch 就能看到稳定下降感受野这块AlexNet 靠的是堆叠 11×11、5×5 的大卷积核ResNet-18 全是 3×3 小核加跳连。对鸟类这种细粒度目标小核堆叠出来的特征更关注局部纹理跳连又保证了梯度能顺利回传这是我后来更倾向 ResNet-18 的主要原因。但有一点要说清楚AlexNet 没有 BN训练对初始化和学习率特别敏感学习率稍微大一点就发散这一点在微调阶段尤其明显血泪经验。2.2 改输出层两行代码两个容易翻车的细节两个网络都是在 ImageNet 上预训练的输出是 1000 类而 CUB-200-2011 是 200 类所以必须换掉最后一层。这一步看着简单翻车的人不少。import torch.nn as nn from torchvision import models NUM_CLASSES 200 # CUB-200-2011 的类别数必须和标签最大值对齐 def build_alexnet(num_classesNUM_CLASSES, pretrainedTrue): weights models.AlexNet_Weights.IMAGENET1K_V1 if pretrained else None net models.alexnet(weightsweights) in_features net.classifier[6].in_features # 4096 # AlexNet 的 classifier 是 Sequential索引 6 才是最后的 Linear net.classifier[6] nn.Linear(in_features, num_classes) return net def build_resnet18(num_classesNUM_CLASSES, pretrainedTrue): weights models.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None net models.resnet18(weightsweights) in_features net.fc.in_features # 512 net.fc nn.Linear(in_features, num_classes) # 只替换 fc其余层保留预训练权重 return netin_features分别取 4096 和 512原因是 AlexNet 在卷积之后接了两层 4096 维全连接而 ResNet-18 全局平均池化后直接接 512 维的fc。新加的这层默认按 kaiming uniform 初始化对 200 类任务够用不需要手工设计。第二个细节是初始 loss 的检查。200 类均匀分布下随机猜的交叉熵是 ln(200)≈5.3。如果你第一次打印出来的 loss 是 6.9 左右那说明输出层还是 1000 类没换成功如果 loss 直接是 0.0x多半是标签泄漏或者标签没对齐。我一般会在训练脚本第一步就把这个数打出来比后面盯着曲线猜要省事得多。2.3 冻结策略与分层学习率微调效果的关键预训练权重是从 ImageNet 学来的通用视觉特征浅层是边缘、颜色、纹理深层才开始有物体部件。微调时如果给所有层同一个学习率等于用鸟类的梯度去覆盖掉通用的底层特征小数据集上很容易把预训练的好处冲没。常见的做法是分层设置学习率分类头给大一点主干给小一点。def param_groups(net, backbone_lr1e-4, head_lr1e-3): 把参数分成主干和分类头两组分别给不同的学习率 head_prefix (fc, classifier) # 两个网络的分类头前缀不同这里一起兼容 backbone, head [], [] for name, p in net.named_parameters(): if not p.requires_grad: continue (head if name.startswith(head_prefix) else backbone).append(p) return [ {params: backbone, lr: backbone_lr}, # 预训练参数小学习率微调 {params: head, lr: head_lr}, # 新初始化层大学习率从头学 ]两组学习率差 10 倍是我试下来比较稳的区间。头层 1e-3、主干 1e-4配 SGD momentum 0.930 个 epoch 内验证集准确率能稳定爬到 60% 以上。如果你把主干学习率也提到 1e-3通常会看到一个典型现象前两个 epoch loss 先往上飙然后慢慢降回来最后收敛到一个更差的点。这就是预训练特征被破坏的过程。至于要不要冻结主干我的判断标准是数据量。CUB-200-2011 每类只有 60 张左右属于典型的小样本细粒度任务前 3 个 epoch 我会先冻结主干只训头层让随机初始化的分类头先跟上整体分布再解冻全部参数做联合微调。这个两段式做法比一上来就全量微调稳定尤其是在 batch size 只有 16 到 32 的时候。2.4 结论什么情况下选哪个把上面的维度收一下如果实验目的是“快速验证数据集和流程能不能跑通”选 ResNet-18收敛快、显存省、BN 让超参不敏感一晚上能出结果如果实验目的是“对比预训练权重的价值”或者“展示经典架构的演进”AlexNet 值得保留但没有 BN 意味着你得花时间调学习率和 warmup不然很容易在第一个 epoch 就发散。我自己的默认选择是 ResNet-18 打底AlexNet 作为对照。这样实验报告里既有主模型的性能又有一个“老架构在小数据集上确实不如残差网络”的结论支撑比只跑一个模型有说服力。3. 数据集与数据管道CUB-200-2011 的正确打开方式细粒度任务里数据管道的正确性对最终指标的影响往往比网络结构大。这份实验用的 CUB-200-2011 有 200 类、约 11788 张图每张图都带边界框和属性标注是细粒度分类研究的基准数据集之一。数据本身不难找难的是把标注文件读对、把划分做对、把增强调对。3.1 划分、类别不平衡与标签对齐数据集官方给的划分是约 5994 张训练、5794 张测试但实验报告里需要验证集来调超参和监控性能所以我一般会从训练集里按类别分层抽 10% 出来做验证集保证每个类别在三个子集里的比例一致。随机抽不行200 类里数量少的类一旦被抽空那一类的准确率就没意义了。标签对齐是最容易出错的地方。原始标注文件里的类别 id 是 1 到 200而 PyTorch 的CrossEntropyLoss要求标签在 0 到 199 之间。差 1 不会报错训练照跑loss 照降但模型实际在学一个错位的映射最后准确率可能掉十几个点还找不到原因。from pathlib import Path def load_cub_meta(root): 读取 CUB-200-2011 的三个核心标注文件 root Path(root) # images.txt: 1 001.Black_footed_Albatross/xxx.jpg id2path {} for line in (root / images.txt).read_text().splitlines(): idx, rel line.strip().split( , 1) id2path[int(idx)] root / images / rel # image_class_labels.txt: 1 1 图片 id - 类别 id1..200 id2label {} for line in (root / image_class_labels.txt).read_text().splitlines(): idx, cls line.strip().split() id2label[int(idx)] int(cls) - 1 # 关键转成 0..199 # train_test_split.txt: 1 1 图片 id - 是否属于训练集 id2split {} for line in (root / train_test_split.txt).read_text().splitlines(): idx, flag line.strip().split() id2split[int(idx)] int(flag) return id2path, id2label, id2split三个文件的行数都应该是 11788读完之后可以先断言len(id2path) len(id2label) len(id2split)这一步能挡掉下载不完整、解压出错这类低级问题。类别分布也可以顺手统计一下200 类每类 60 张左右但实际会有浮动浮动超过一半的类别要单独看一眼是不是标注有问题。3.2 预处理与增强细粒度任务不能照抄 ImageNet 配方ImageNet 那套增强RandomResizedCrop 到 224、RandomHorizontalFlip、较强 ColorJitter、RandomErasing搬到鸟类识别上要打折。原因是细粒度分类的判别信息很多来自颜色和局部纹理比如某种鸟的喉部是栗红色、某种鸟的眉纹是白色的你把颜色抖狠了等于把判别特征抹掉了。我实际用的组合是RandomResizedCrop(224, scale(0.6, 1.0))保证主体不被裁没RandomHorizontalFlip()因为鸟的左右对称性成立所以安全ColorJitter(brightness0.2, contrast0.2, saturation0.2, hue0.05)只做轻微扰动hue 尤其要小。归一化用 ImageNet 的 mean/std因为预训练权重是在那个分布上学的这一步换掉等于人为制造分布偏移。验证集和测试集只做Resize(256) CenterCrop(224) Normalize不加任何随机增强。这一点我在第 5 章还会提一次因为它是“验证集准确率异常偏高”的头号元凶。3.3 用边界框做可视化和弱定位bounding_boxes.txt的格式是“图片 id 4 个浮点数”这四个数的顺序是 (x, y, w, h)也就是左上角坐标加宽高不是 (x1, y1, x2, y2)。这个细节坑过不少人画出来的框要么偏到画面外要么只剩下一个小角。import matplotlib.pyplot as plt import matplotlib.patches as patches def draw_boxes(img, boxes, labelsNone): boxes: [(x, y, w, h), ...]格式来自 bounding_boxes.txt fig, ax plt.subplots(1, figsize(6, 6)) ax.imshow(img) for i, (x, y, w, h) in enumerate(boxes): # 注意 (x, y, w, h) 要转成 Rectangle 需要的 (x, y), w, h ax.add_patch(patches.Rectangle((x, y), w, h, linewidth2, edgecolorr, facecolornone)) if labels is not None: ax.text(x, y - 5, labels[i], colorr, fontsize9) ax.axis(off) plt.show()实验报告里那几张预测结果图本质上就是把模型输出类别对应的真实框画回原图上用来做定性分析。它的价值不在“好看”而在于你能一眼看出模型错在哪是整张图都找错了鸟还是找到了鸟但把相近种搞混了。前者是检测/定位问题后者才是细粒度分类本身的难点两种错误的改进方向完全不一样。我一般会挑 8 到 12 张错分样本单独看比盯着一个总体准确率数字有用得多。4. 训练配置与超参学习率、步数、衰减策略怎么定这份实验报告里有一节专门讲超参对比结论是“训练步数和学习率设置对性能至关重要”。这话是对的但太笼统。下面把我实际调过的几组配置和判断依据摊开讲。4.1 一份可以直接抄的训练配置先给基线。ResNet-18、输入 224、batch 32、SGD momentum 0.9、weight decay 5e-4、主干 lr 1e-4、头层 lr 1e-3、余弦退火、30 个 epoch。import torch from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingLR EPOCHS 30 optimizer SGD(param_groups(net, backbone_lr1e-4, head_lr1e-3), momentum0.9, weight_decay5e-4) # 细粒度小数据集上正则要跟上5e-4 起步 # T_max 设成总 epoch 数eta_min 给一个极小值而不是 0避免最后阶段完全不学 scheduler CosineAnnealingLR(optimizer, T_maxEPOCHS, eta_min1e-6) for epoch in range(EPOCHS): train_one_epoch(net, loader_train, optimizer, epoch) # 内部按 batch 迭代 acc evaluate(net, loader_val) # 验证集只做 center crop scheduler.step() # 按 epoch 走不要按 batch print(fepoch {epoch:02d} val_acc{acc:.4f} lr{scheduler.get_last_lr()})几个参数为什么这么定优化器选 SGD 而不是 Adam是因为微调场景下 SGD 的泛化通常更好Adam 虽然前期降得快但后期容易停在尖锐极小值上weight decay 5e-4 是给全连接层和卷积层统一加的正则小数据集上这个值能明显压住过拟合余弦退火按 epoch 走而不是按 batch 走是因为 batch 级别的余弦在小 step 数下会把学习率过早压到接近 0模型还没学完就不动了。4.2 loss 和 accuracy 曲线到底在告诉你什么实验报告里的可视化图很多人只会看“降了没降”。实际上至少能读出三件事。第一件是下降速率。训练初期 loss 快速下降说明模型在学数据的基本分布这一段陡不陡取决于学习率大小和分类头初始化。如果前 3 个 epoch loss 几乎是一条水平线先怀疑三件事输出层没换、标签没转 0-based、学习率太小。第二件是训练和验证的间距。两条 accuracy 曲线之间的 gap 持续扩大是过拟合的典型信号。这时候的处置顺序是先加 weight decay 或 dropout再考虑减模型容量最后才动数据增强。反过来如果两条曲线几乎重合但都很低那是欠拟合得加 epoch 或者提学习率。第三件是平台期。准确率在最后 8 到 10 个 epoch 只涨 0.1 个百分点说明已经到这套配置的上限了。这时候继续加 epoch 基本是浪费算力收益远不如换更强的骨干或补数据。4.3 衰减策略的对比step、cosine、plateau这三种我都跑过差异比想象中大。策略配置现象适用场景StepLR每 10 epoch × 0.1每次衰减后 loss 有一个小台阶最终精度中等训练长、有明确阶段划分时CosineAnnealingLRT_max总 epocheta_min1e-6曲线平滑末段收敛更稳同 epoch 下精度最高默认首选ReduceLROnPlateaupatience3factor0.1自适应但阈值参数敏感容易过早降 lr不确定总步数时余弦退火在这份实验的配置下表现最好30 个 epoch 就能把验证集准确率推到稳定区间。step 策略的问题是衰减点前后的学习率断层会让 loss 抖一下小数据集上这一抖有时要 2 到 3 个 epoch 才恢复。plateau 策略最大的问题是 patience 和 threshold 两个参数互相牵扯新手很容易设成“刚有点波动就把 lr 砍掉”结果模型提前进入微调末尾的状态精度上不去还找不到原因。4.4 预训练 vs 随机初始化对照实验怎么设计才可信报告里有一组对比结论是预训练微调在性能和收敛速度上都显著优于从头训练。这个结论本身没问题但要做成可信的对照得控制变量。我的做法是两组实验用同一个网络、同一份数据划分、同一个 batch size、同一套增强、同样的总 epoch 数区别只在weightsNone还是weightsIMAGENET1K_V1。学习率不能完全一样——从头训练需要更大的学习率比如 1e-2 起才能有效学习微调则要用 1e-4 级别。所以严格说这不是“单一变量”实验而是“各自最优配置下的比较”报告里要把这句话写清楚不然容易被质疑。实际的差异也很直观从头训练的模型往往要 20 个 epoch 以上才开始有明显的准确率爬升30 个 epoch 结束时准确率大概在 30% 到 40% 区间预训练微调的模型第 3 个 epoch 就能到 40% 以上30 个 epoch 稳定在 60% 到 70%。这个差距在细粒度任务上尤其大因为 200 类、每类 60 张的数据量根本不足以让一个 11M 参数的网络从零学出好的底层特征。5. 避坑与排查五个让精度掉点的细节下面这五条每一条我都在实际复现时遇到过现象和原因都写清楚照着排查基本能定位。5.1 现象loss 稳定在 5.3 附近不动原因5.3 就是 ln(200)说明模型输出接近均匀分布等于什么都没学到。最常见的是输出层没换成功——还在输出 1000 类而标签只有 0 到 199模型学到的是一种“把 200 个类摊平到 1000 维里”的畸形映射。解决在训练第一行前打印net.fc.out_features或net.classifier[6].out_features确认等于 200。同时打印一个 batch 的labels.max()确认是 199 而不是 200 或 999。5.2 现象验证集准确率比训练集高出一大截原因验证集用了数据增强或者模型没切到eval()模式。ResNet 的 BN 层在 train 模式下用当前 batch 的统计量在 eval 模式下用滑动平均两者结果能差好几个点。另一种可能是划分时数据泄漏同一张图同时出现在训练集和验证集里。解决验证和测试必须走net.eval()加torch.no_grad()数据管道只保留 resize、centercrop、normalize。划分后用图片路径做一次集合去重set(train_paths) set(val_paths)必须是空集。5.3 现象加载预训练权重后前两个 epoch loss 反而上升原因主干学习率给太大了。预训练权重的浅层特征被鸟类数据的梯度快速覆盖模型先“忘掉”了通用特征再重新学表现出来就是 loss 先飙后降最终收敛点还更差。解决把主干学习率降到 1e-4 或更低头层保持 1e-3或者前 3 个 epoch 先冻结主干只训分类头再解冻做联合微调。判断标准是看第 1 个 epoch 结束时的 loss如果比初始值高 20% 以上学习率就是大了。5.4 现象预测框画出来全挤在左上角或者只剩一小块原因bounding_boxes.txt里是 (x, y, w, h)被当成了 (x1, y1, x2, y2) 用。绘图库的 Rectangle 需要左上角坐标加宽高如果传进去的是两个角点坐标框的位置和大小都会错。解决统一在读取层做一次转换把 (x, y, w, h) 显式命名成四个变量再往下传不要用裸的下标。可视化时先挑 3 张图画出来肉眼确认再批量跑。5.5 现象预训练和从头训练的对比结论站不住脚原因两组实验的 epoch 数、学习率、增强策略不一样等于同时动了多个变量最后观察到的差异分不清是预训练带来的还是超参带来的。解决把两组实验的配置写进同一张表里逐项对齐学习率按“各自最优”设定但在报告里注明总 epoch 数、数据划分、batch size、增强策略必须完全一致。最好每组跑 3 个不同随机种子取均值单次结果在细粒度任务上波动能有 2 到 3 个点只跑一次就下结论不太稳。6. 复现清单一份能交差的实验报告长什么样把前面几章收成一张可执行的清单。我给自己的标准是别人拿着这张表和配套代码不改一行就能跑出和我一样的曲线这份实验报告才算合格。环节关键参数验收标准网络ResNet-18 为主AlexNet 为对照输出层改 200打印out_features 200权重ImageNet 预训练主干 lr 1e-4头层 1e-3第 1 个 epoch loss 不高于初始值 20%数据官方划分 分层抽 10% 验证集标签转 0-based三个子集类别分布比例一致增强训练随机裁剪 水平翻转 轻色抖验证只 center crop验证准确率不高于训练准确率训练SGD 0.9wd 5e-4cosine 30 epoch末 5 个 epoch 准确率波动小于 0.5 点记录每 epoch 存 loss、train_acc、val_acc、当前 lr曲线可复现随机种子写进日志最后说一个具体技巧。实验报告里的对比表格不要只写最终准确率把“达到某个准确率所需 epoch 数”也列进去。比如“预训练微调第 4 个 epoch 就达到 50%从头训练到第 24 个 epoch 才达到 50%”这个数字比最终精度更能说明预训练权重的价值因为它把“收敛速度”这个维度量化了。我第一次写报告时只放最终精度评审时被问“快多少”答不上来只能回去重跑。从那以后我每次跑对照实验都会在日志里强制记录每个 epoch 的 val_acc事后画一张“准确率-epoch”的对照折线谁快谁慢一目了然。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。