简介这是一份面向深度学习初学者与细粒度图像分类研究者的实验报告文档围绕卷积神经网络鸟类识别任务展开可帮助读者理解如何将 AlexNet、ResNet-18 等经典架构迁移到 CUB-200-2011 数据集并完成输出层改造与预训练权重微调。压缩包内仅含 1 个 docx 文件大小约 1.36MB内容以实验说明、网络结构图、数据集示例和结果图表为主便于集中阅读与复盘。文档结合 Loss、Accuracy 曲线展示训练过程并对比是否使用预训练权重、不同学习率与训练步数等设置的效果还给出边界框预测结果分析。已有 184 人学习适合希望掌握 CNN 模型构建、参数调整和细粒度分类实验流程的学生及技术人员参考。1. 从 200 类鸟说起这套卷积神经网络鸟类识别实验要解决什么卷积神经网络做鸟类识别难点不在把网络跑起来而在 200 个长相接近的类别里把细微差异分开。CUB-200-2011 正好是这种场景200 类鸟、约 1.18 万张图每类约 60 张类间差异小、类内姿态和背景差异大拿它做课设或毕设能逼着人把数据划分、预训练微调、学习率策略和结果对照讲清楚。这套实验用 AlexNet 和 ResNet-18 两条基线改输出层到 200 类用 ImageNet 预训练权重初始化再对新输出层和主干分别设学习率最后用 loss 曲线、验证集 accuracy 和预测框可视化把训练过程摊开。适合要交完整实验报告的学生也适合想搭一套细粒度分类基线的从业者。2. 总体设计与技术选型AlexNet 与 ResNet-18 的双基线怎么摆2.1 为什么选 AlexNet 和 ResNet-18 两条基线只跑一个网络实验报告很容易写成“调通了”的流水账。放两条基线一条浅层大卷积、一条残差网络才有对照价值。AlexNet 结构直观卷积核大、参数量级在 60M 左右训练时显存占用和收敛行为都比较“老派”适合用来观察预训练权重到底有没有起作用ResNet-18 参数量级在 11M 左右残差连接让梯度传得更顺在 CUB 这种细粒度数据上通常更容易训到稳定状态适合作为主结果。两条线放在同一套数据划分、同一套增强、同一套评估口径下读者才能判断差异来自网络结构还是来自训练策略。选型时我一般会先固定输入尺寸。AlexNet 原始论文的输入是 227×227但工程里用 torchvision 的预训练权重时统一到 224×224 更省事ResNet-18 也是 224×224这样同一个 DataLoader 可以喂两个模型不用为每个网络单独维护一套预处理。预训练权重都来自 ImageNet输出层按 CUB 的 200 类改。这里不要一上来就冻结全部卷积层只训分类头那样虽然快但浅层特征未必适配鸟类细粒度纹理报告里也少了一个“微调程度”的讨论点。模型输出层改造位置输入尺寸预训练权重在本实验中的角色AlexNetclassifier[6]改为 200 维224×224ImageNet浅层基线观察预训练收益ResNet-18fc改为 200 维224×224ImageNet主基线观察残差结构稳定性表格里不写具体准确率是因为结果要在同一份划分上跑出来才有意义。报告里最忌讳拿别人论文里的数字和自己的单次结果拼在一起口径不一致答辩时很容易被问住。2.2 输出层改造与两阶段学习率改造输出层本身只有几行代码但改完之后怎么设学习率直接决定实验是“微调”还是“把预训练权重洗掉”。我的做法是新输出层用较大的学习率从头学主干用较小的学习率慢慢跟。下面这段代码把两种模型的分类头统一改到 200 类并保留一个pretrained开关方便后面做随机初始化对照。import torch.nn as nn from torchvision import models def build_model(name, num_classes200, pretrainedTrue): 构建 AlexNet / ResNet-18并把分类头改成 CUB 的 200 类。 if name alexnet: # 新版本 torchvision 用 weights 参数老版本可换成 pretrainedTrue model models.alexnet(weightsIMAGENET1K_V1 if pretrained else None) in_features model.classifier[6].in_features model.classifier[6] nn.Linear(in_features, num_classes) elif name resnet18: model models.resnet18(weightsIMAGENET1K_V1 if pretrained else None) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) else: raise ValueError(f不支持的模型: {name}) return modelnum_classes200对应 CUB-200-2011 的类别数不要写成 1000 再靠后处理截断。pretrainedFalse时传None这样同一份脚本既能跑预训练实验也能跑随机初始化实验后面做对照不用另写一套代码。分类头的名字要记牢AlexNet 是classifier[6]ResNet-18 是fc后面分层学习率要靠名字把参数分组。分层学习率用参数组实现主干给1e-4新分类头给1e-3优化器用 SGD momentum 0.9weight decay 设5e-4。这个组合不是玄学预训练权重在 SGD 下微调通常比一开始就上大学习率的 Adam 更稳尤其是 batch 只有 32 左右的时候。如果显存吃紧把 batch 降到 16学习率按线性比例缩到一半左右不要死守一个值。import torch def make_optimizer(model, head_lr1e-3, backbone_lr1e-4, weight_decay5e-4): 按参数名把分类头和主干分到不同学习率的参数组。 head_params, backbone_params [], [] for name, param in model.named_parameters(): if not param.requires_grad: continue if name.startswith(fc.) or name.startswith(classifier.6): head_params.append(param) else: backbone_params.append(param) return torch.optim.SGD( [ {params: backbone_params, lr: backbone_lr}, {params: head_params, lr: head_lr}, ], momentum0.9, weight_decayweight_decay, )这段代码的关键在name.startswith的判断。ResNet-18 的fc.weight、fc.bias会进 head 组AlexNet 的classifier.6.weight、classifier.6.bias也会进 head 组其余卷积层和 BN 层进 backbone 组。BN 层要不要冻结通常不冻但可以把 backbone 的学习率压低让 BN 的 running mean/var 跟着数据慢慢更新。如果发现训练早期验证集掉得很厉害先把 backbone 学习率再降一个数量级而不是直接关掉预训练。2.3 实验总流程与目录约定整个实验按“数据准备 → 模型构建 → 训练 → 验证 → 记录 → 对照”走但每一步都要留下可复现的痕迹。我一般把配置写在 YAML 里把数据划分文件单独存一份训练脚本只读配置不把超参数散落在命令行里。目录按下面这种方式摆换机器或换人接手时至少知道东西在哪bird_exp/ ├── configs/ │ ├── alexnet_scratch.yaml │ ├── alexnet_pretrained.yaml │ ├── resnet18_scratch.yaml │ └── resnet18_pretrained.yaml ├── data/ │ ├── CUB_200_2011/ │ └── splits/ │ ├── train.txt │ ├── val.txt │ └── test.txt ├── outputs/ │ ├── logs/ │ ├── checkpoints/ │ └── figures/ └── src/ ├── dataset.py ├── model.py ├── train.py └── evaluate.py配置文件里至少写清模型名、是否预训练、输入尺寸、batch size、epoch 数、两个学习率、权重衰减、学习率衰减策略和随机种子。随机种子不是摆设细粒度数据上同一条命令跑两次验证集准确率波动一两个点很常见固定种子至少能让对照实验的差异不来自初始化抖动。实验编号也写进配置比如exp_alexnet_pt_lr1e3后面画图时不用靠记忆对文件名。3. 数据管线搭建CUB-200-2011 的划分、增强与标注使用3.1 训练、验证、测试三划分与防泄漏CUB-200-2011 自带官方训练集和测试集划分做实验时最稳妥的方式是保留官方测试集不动从官方训练集里再切一小部分做验证集。常见做法是切 10% 左右按类别分层采样保证 200 类在验证集里都有样本。不要从测试集里切验证集也不要把测试集图片混进训练集做增强这种泄漏在细粒度数据上特别隐蔽验证集准确率看着漂亮换到测试集直接掉一截答辩时说不清。下面这段划分逻辑假设已经把所有官方训练样本读成(image_path, label)列表用train_test_split做分层切分。random_state固定划分文件落盘后面所有模型都读同一份train.txt和val.txt。from sklearn.model_selection import train_test_split def split_train_val(samples, val_ratio0.1, seed42): samples: [(image_path, label), ...]只包含官方训练集。 labels [label for _, label in samples] train_samples, val_samples train_test_split( samples, test_sizeval_ratio, stratifylabels, random_stateseed, ) return train_samples, val_samplesstratifylabels保证每个类别在训练和验证里的比例接近避免某些稀有类别全被切到一边。切完之后把路径和标签写进文本文件训练脚本只认文件不认“临时随机切”。如果报告里要写数据规模可以写“官方训练集切出约 10% 作为验证集测试集保持官方划分”不要写一个没有出处的精确数字。划分来源用途是否做数据增强训练集官方训练集切分后的主体更新参数是验证集官方训练集切分出的 10%调超参数、早停、选 checkpoint否只做尺寸变换测试集官方测试集最终评估只跑一次否只做尺寸变换这张表的重点是“测试集只跑一次”。调参阶段反复看测试集等于把测试集当验证集用最后报出来的数字会偏乐观。我一般在训练脚本里加一个开关只有最终评估才允许读测试集划分文件。3.2 输入尺寸与增强策略训练增强不要堆太多。鸟类细粒度识别的关键是保留判别性部位比如喙形、眼纹、羽色分布RandomResizedCrop 的缩放范围太狠会把鸟裁得只剩背景。比较稳的组合是随机裁剪缩放比例控制在 0.7 到 1.0水平翻转打开颜色抖动轻度使用最后按 ImageNet 的均值和标准差归一化。验证和测试只做 Resize 到 256 再 CenterCrop 到 224保证评估口径一致。from torchvision import transforms # 训练增强保留主体轻度颜色扰动 train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) # 验证/测试只做确定性的尺寸变换 val_tf 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]), ])归一化的三个均值和三个标准差是 ImageNet 预训练权重的标配不要用 CUB 自己算的均值替代除非你同时从随机初始化开始训。用错归一化是训练不收敛的常见原因之一输入分布和预训练权重预期不一致主干输出会偏loss 前几个 epoch 看着降得很慢。水平翻转在鸟类数据上一般可以用但如果某些类别本身左右特征差异很大可以在配置里关掉做一组对照报告里多一个讨论点。3.3 边界框标注的两种用法与一个边界CUB 每张图带边界框标注这个信息不要浪费但也不要把它当成目标检测任务。分类实验里框有两种用法一是训练时按框裁剪把背景去掉再送网络观察背景对细粒度分类的干扰二是评估时用真实框裁剪后再分类和整图输入的结果做对比报告里可以写成“使用框裁剪”和“不使用框裁剪”两行。两种用法都不需要模型输出框坐标。def crop_by_bbox(image, bbox, margin0.1, size(224, 224)): 按 CUB 的 [x1, y1, x2, y2] 裁剪并留一点边距。 x1, y1, x2, y2 bbox w, h image.size bw, bh x2 - x1, y2 - y1 x1 max(0, int(x1 - margin * bw)) y1 max(0, int(y1 - margin * bh)) x2 min(w, int(x2 margin * bw)) y2 min(h, int(y2 margin * bh)) return image.crop((x1, y1, x2, y2)).resize(size)margin0.1是给框外留一点上下文避免裁得太紧把鸟的轮廓切掉。如果要做“框裁剪”对照训练和验证必须同时用同一种裁剪策略不能训练用框、验证用整图否则评估口径变了结果没法比。还有一个边界要讲清楚这套实验是分类不是检测可视化里画预测框只能作为“模型关注区域”的示意不能说成模型学会了定位。答辩时被问到“框是预测的还是标注的”要能一句话说清。4. 训练模块实现从损失曲线到可复现的微调脚本4.1 训练循环与分层学习率落地训练循环本身不复杂细粒度分类的坑主要在评估和记录。下面这个train_one_epoch返回平均 loss 和训练准确率验证函数按同样口径返回 val loss 和 val acc。每个 epoch 结束后把学习率、训练指标、验证指标一起写日志后面画曲线直接读日志不靠回忆。import torch def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) pred logits.argmax(dim1) correct (pred labels).sum().item() total labels.size(0) return running_loss / total, correct / totalloss.item() * images.size(0)是按样本数加权最后一个 batch 不满时不会把平均 loss 拉偏。logits.argmax(dim1)取的是 200 类里分数最高的那一类训练准确率只作参考真正用来选模型的是验证集准确率。设备用cuda时记得在验证阶段加torch.no_grad()否则显存会随着验证轮次慢慢涨上去跑到后半程突然 OOM这种翻车在实验报告里很常见。验证函数单独写不参与梯度更新torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() running_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) logits model(images) loss criterion(logits, labels) running_loss loss.item() * images.size(0) correct (logits.argmax(dim1) labels).sum().item() total labels.size(0) return running_loss / total, correct / totalmodel.eval()会切 BN 和 Dropout 的行为验证集结果才稳定。如果忘了写这一句验证集准确率会随 batch 组成抖动曲线看起来像“模型在训练中反复横跳”其实是评估模式没切对。4.2 学习率衰减与早停学习率衰减策略在报告里是要拿来对照的不能只写“用了衰减”。我一般先用ReduceLROnPlateau做基线验证集准确率连续 3 个 epoch 不涨就把学习率乘 0.5最低降到1e-6。这个策略不依赖总 epoch 数适合还没摸清收敛位置的时候。等基线跑稳了再换余弦退火做一组对照看曲线是更平滑还是更早过拟合。scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, # 监控验证集准确率越大越好 factor0.5, # 每次衰减一半 patience3, # 连续 3 个 epoch 不提升再降 min_lr1e-6, )scheduler.step(val_acc)要放在每个 epoch 的验证之后传的是验证集准确率不是训练 loss。早停和 checkpoint 一起做验证集准确率创新高就存一份best.pth连续 8 个 epoch 不创新高就停。早停的耐心值不要设得太小细粒度数据上验证集准确率偶尔会平台几个 epoch 再抬一下耐心太小会错过后面的提升。best_acc, bad_epochs 0.0, 0 for epoch in range(max_epochs): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step(val_acc) if val_acc best_acc: best_acc val_acc bad_epochs 0 torch.save(model.state_dict(), foutputs/checkpoints/{exp_id}_best.pth) else: bad_epochs 1 if bad_epochs 8: breakexp_id从配置里读不要写死。存 checkpoint 时只存state_dict()就够模型结构由配置决定后面评估时按同一份配置重建模型再加载权重。如果要把优化器状态也存下来做断点续训再另存一份last.pthbest.pth保持干净。4.3 日志与可视化数据落盘loss 曲线和 accuracy 曲线不是训练完再凭截图回忆而是每个 epoch 往 CSV 里追加一行。字段至少包括 epoch、train_loss、train_acc、val_loss、val_acc、head 学习率、backbone 学习率。后面画图用 pandas 读 CSV想换横轴、想放大某一段都方便。import csv, os log_path foutputs/logs/{exp_id}.csv write_header not os.path.exists(log_path) with open(log_path, a, newline) as f: writer csv.writer(f) if write_header: writer.writerow([epoch, train_loss, train_acc, val_loss, val_acc, lr_head, lr_backbone]) writer.writerow([epoch, train_loss, train_acc, val_loss, val_acc, optimizer.param_groups[1][lr], optimizer.param_groups[0][lr]])参数组顺序要和make_optimizer里一致第 0 组是 backbone第 1 组是 head。画图时把lr_backbone和lr_head画在同一张图上能直观看到衰减策略有没有按预期生效。除了 loss 和 accuracy报告里最好再加一张混淆矩阵和几张 top-k 错误样本。混淆矩阵不用自己写用验证集预测结果和标签做confusion_matrix挑出最容易被混淆的几对类别比如体型相近、羽色相近的鸟解释起来比单看准确率有说服力。5. 结果对比与常见问题排查预训练、超参数和过拟合的三条暗线5.1 预训练与随机初始化的对照实验设计预训练到底有没有用不能靠“感觉收敛快了”下结论要做成控制变量的对照。同一份train.txt/val.txt/ 测试集同一套增强同一个 batch size同一个 epoch 上限只改pretrained开关。最好再固定随机种子跑两次取平均至少跑一次完整对照。报告里用一张表记录实验编号、初始化方式、backbone 学习率、head 学习率、最佳验证集 epoch、最佳验证集准确率、测试集准确率、备注。实验编号初始化backbone lrhead lr最佳 epoch验证集准确率测试集准确率备注A1ImageNet 预训练1e-41e-3记录值记录值记录值主基线A2随机初始化1e-31e-3记录值记录值记录值与 A1 同 epoch 上限R1ImageNet 预训练1e-41e-3记录值记录值记录值主基线R2随机初始化1e-31e-3记录值记录值记录值与 R1 同 epoch 上限随机初始化那一组的学习率不能照抄预训练组。预训练主干用1e-4是微调随机初始化主干用1e-4往往学得太慢通常要回到1e-3甚至更高。这一点如果不在表里写清楚读者会误以为“预训练组学习率更低还更好”结论就站不住。对照实验的公平不是所有超参数完全一样而是每组都处在自己能正常收敛的配置下再比最终性能和收敛速度。从曲线形态看预训练组通常在更少 epoch 内把验证集准确率抬起来训练 loss 下降也更平顺随机初始化组前期 loss 降得慢验证集准确率起步低后期可能追一点但很少反超。如果随机初始化组反而更好先别急着写结论检查测试集有没有泄漏、预训练权重有没有加载成功、归一化有没有用错这三件事是血泪经验里最常见的“假反超”。5.2 超参数敏感性步数、学习率、衰减策略超参数对照不用把网格拉满选三个维度各做两到三档就够写报告训练步数epoch 上限、学习率、衰减策略。训练步数太短训练 loss 和验证集准确率都还在降属于欠拟合训练步数太长训练 loss 继续降但验证集准确率走平甚至下掉属于过拟合。报告里可以把同一组配置的 loss 曲线和 val acc 曲线叠在一张图上标出最佳 epoch 和早停 epoch解释为什么选那个 checkpoint。学习率看两件事训练初期 loss 有没有震荡验证集准确率有没有在早期卡住。学习率过大loss 曲线会上下跳甚至出现 NaN学习率过小loss 降得很慢曲线像一条缓坡。分层学习率下还要分开看 head 和 backbone如果 head 学习率过大分类头来回摆backbone 学习率再稳也没用。衰减策略的对照重点看后期ReduceLROnPlateau的曲线通常在平台期后有一次小台阶余弦退火则更平滑但可能过早把学习率压得太低。两种策略没有绝对优劣报告里说清“在我的数据划分和 epoch 预算下哪一种更稳”就够了。5.3 五类高频翻车点的现象、原因与处理第一类预训练权重加载了但分类头没换对。现象是训练一开始 loss 就在 5.3 左右200 类均匀分布下的对数值附近准确率长期在 0.5% 上下怎么调学习率都不动。原因通常是输出层还是 1000 类或者换了输出层但加载权重时把新分类头也覆盖了。处理方式是先打印model.fc.out_features或model.classifier[6].out_features确认是 200加载权重时用strictFalse只加载主干分类头单独初始化。更稳妥的做法是加载完权重后单独检查分类头参数的均值不要让它停留在预训练权重的旧分布上。第二类数据划分泄漏验证集虚高。现象是验证集准确率很快到很高测试集一跑掉一大截或者验证集 loss 比训练 loss 还低。原因多半是测试集图片混进了训练集或者验证集图片做了随机增强后又被训练用到。处理方式是固定划分文件训练脚本只读train.txt验证只读val.txt测试集在最终评估前不加载。每次跑实验前把三个文件的路径打印一遍确认没有重叠。第三类归一化用错训练不收敛。现象是 loss 下降很慢或者前几个 epoch loss 几乎不动模型输出偏向某几个类。原因是用ToTensor()之后没有做 ImageNet 归一化或者自己算了一套 CUB 均值方差却加载了 ImageNet 预训练权重。处理方式是统一用mean[0.485,0.456,0.406]、std[0.229,0.224,0.225]训练、验证、测试三套 transform 保持一致。改完归一化后先跑一个 mini-batch 过拟合测试看模型能不能把 32 张图训到接近 100% 训练准确率能过再开完整训练。第四类backbone 和 head 用同一个学习率预训练特征被洗掉。现象是训练初期验证集准确率不升反降loss 先降后震荡曲线看起来像“学崩了”。原因是主干学习率太大预训练权重被快速覆盖。处理方式是分层学习率head 用1e-3backbone 用1e-4或更低如果还是不稳先冻结主干训 2 到 3 个 epoch 分类头再解冻做小学习率微调。报告里要把这两种策略的曲线放一起说明为什么最终选了后者。第五类把分类当检测框可视化误导结论。现象是报告里画了预测框但模型本身只输出 200 类分数框其实是标注框或人工画的读者会以为模型学会了定位。原因是分类实验和检测实验的任务边界没讲清。处理方式是在图注里写明“框为数据集标注框用于裁剪或评估”如果要展示模型关注区域用 Grad-CAM 之类的特征图可视化并说明它只是注意力示意不是检测输出。答辩时主动划清这条边界比被追问后再解释更稳。6. 验收与答辩用三条曲线和一张表讲清实验结论验收这套实验不用把每个文件都打开讲抓住三条曲线和一张对照表就够。三条曲线是训练 loss、验证集 accuracy、学习率随 epoch 的变化一张表是预训练与随机初始化、AlexNet 与 ResNet-18 的对照记录。验收时先看数据划分文件是否固定、训练日志是否完整、checkpoint 是否能按配置重建后加载再看曲线和表格能不能支撑结论。下面这张检查表可以照着走一遍。验收项合格标准检查方法数据划分训练/验证/测试文件固定无重叠比对路径集合打印样本数模型改造两个模型输出层均为 200 类打印out_features预训练加载主干权重加载成功分类头重新初始化检查加载日志与参数均值训练日志每个 epoch 有 loss、acc、学习率打开 CSV 看字段是否齐全对照实验预训练与随机初始化同口径查配置表与随机种子可视化loss/acc 曲线、混淆矩阵、错误样本图注写清数据来源与口径答辩时我习惯按“问题—选型—对照—边界”四步讲。先讲 CUB 的 200 类细粒度难点再讲为什么用 AlexNet 和 ResNet-18 两条基线、为什么改输出层后要分层学习率然后拿对照表说明预训练带来的收敛速度和最终性能差异最后主动讲失败案例哪一组超参数训崩了、哪次验证集虚高是划分问题、框可视化为什么不能当成检测。评委通常不怕你犯错怕的是你把错误藏起来。能把一条震荡的 loss 曲线解释清楚比报一个漂亮但无法复现的准确率更加分。从那以后我每次跑对照实验都先把划分文件、配置文件、随机种子和日志路径写进一张实验卡片再动模型。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?