简介:本资源是一份基于卷积神经网络的鸟类识别实验报告,面向对机器学习、深度学习感兴趣的科研人员、学生及相关技术人员,旨在帮助读者理解如何构建和优化基于CNN的细粒度物体分类模型。报告以AlexNet和ResNet-18为起点,将输出层调整为200类以匹配CUB-200-2011数据集,并采用ImageNet预训练权重进行微调,系统展示了网络结构修改、数据集划分、训练过程Loss与Accuracy可视化,以及预训练权重、学习率、训练步数等超参数对性能影响的对比分析。资源包内含1个docx文档,大小约1.36MB,结构完整、图文并茂,适合作为细粒度分类的入门实践项目。目前已有180人学习,读者可从中掌握网络选型、数据预处理、参数调整与结果评估的完整思路,并借助图表解释深入理解模型训练规律与过拟合判断方法。
1. 从 88% 准确率说起:这套 CNN 鸟类识别实验到底值不值得跑一遍
如果你手头正好有一份 CUB-200-2011 数据集,想拿它练细粒度分类,又不想从零搭训练框架,那这套基于 AlexNet 和 ResNet-18 的鸟类识别实验代码包值得你花一个下午跑通。它做的事情很具体:把 ImageNet 预训练权重迁移到 200 类鸟类分类任务上,替换输出层、分层设置学习率、对比从头训练与微调的差异,最后给出 loss 曲线和验证集 accuracy 的可视化。整套流程覆盖了数据划分、模型改造、训练监控、超参对比四个环节,不是那种跑完就完的 demo。适合刚接触迁移学习的学生、需要快速验证细粒度分类方案的技术人员,以及想拿一个完整实验报告当模板的从业者。我拆完之后的判断是:代码结构清晰,但有几个参数设置上的坑,不提前说清楚,你大概率会在验证集准确率卡在 60% 左右上不去。
2. 预训练权重怎么迁到 200 类:AlexNet 与 ResNet-18 的改造逻辑
2.1 为什么选这两个骨干而不是自己搭
AlexNet 和 ResNet-18 在这个任务里的角色不一样。AlexNet 结构简单,卷积层只有 5 层,参数量约 6000 万,其中全连接层占了大头。它的优势是训练快、显存占用低,适合先跑通流程、验证数据管道有没有问题。ResNet-18 有残差连接,参数量约 1100 万,但特征提取能力明显更强,尤其在细粒度任务上,残差结构能缓解深层网络的梯度消失,让模型学到更细粒度的纹理差异——比如不同种类鸟的喙形、羽色渐变。
我一般会先用 AlexNet 跑一轮小 epoch,确认数据加载、标签映射、损失计算都没问题,再切到 ResNet-18 做正式训练。这样排错成本最低,不会因为骨干网络太复杂而把数据层面的问题掩盖掉。
2.2 替换输出层的具体操作与参数含义
两个网络的原始输出层都是 1000 类(ImageNet),要改成 200 类。AlexNet 的 classifier 是一个 Sequential,最后一层是 Linear(4096, 1000);ResNet-18 的 fc 层是 Linear(512, 1000)。替换时只改最后一层的 out_features,其余结构不动。
import torch import torch.nn as nn from torchvision import models def build_model(arch='resnet18', num_classes=200, pretrained=True): if arch == 'alexnet': model = models.alexnet(pretrained=pretrained) # 替换 classifier 最后一层 in_features = model.classifier[6].in_features model.classifier[6] = nn.Linear(in_features, num_classes) elif arch == 'resnet18': model = models.resnet18(pretrained=pretrained) # 替换 fc 层 in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) else: raise ValueError(f"Unsupported arch: {arch}") return model这段代码的关键在pretrained=True时,torchvision 会自动下载 ImageNet 权重并加载到除最后一层外的所有层。替换后的新层默认随机初始化,需要从头训练。注意一点:如果你用的是较新版本的 torchvision,pretrained参数可能被弃用,改成weights=models.AlexNet_Weights.IMAGENET1K_V1这种写法,具体看你环境里的版本。
2.3 分层学习率:新层大、旧层小的实现方式
预训练参数已经学到了通用视觉特征,不需要大改,所以要用小学习率微调;新替换的输出层是随机初始化的,需要大学习率快速收敛。常见做法是给参数分组:
def get_param_groups(model, base_lr=1e-4, new_layer_lr=1e-3): new_params = [] old_params = [] for name, param in model.named_parameters(): if 'classifier.6' in name or 'fc' in name: new_params.append(param) else: old_params.append(param) return [ {'params': old_params, 'lr': base_lr}, {'params': new_params, 'lr': new_layer_lr} ]base_lr控制预训练层的微调幅度,一般设在 1e-5 到 1e-4 之间;new_layer_lr控制新输出层的学习速度,通常比 base_lr 大一个数量级。这个比例不是固定的,如果你的数据集很小(CUB 每类只有约 60 张),base_lr 可以再小一点,防止预训练特征被破坏。
2.4 数据增强与输入尺寸的匹配
CUB-200-2011 的图片尺寸不一,训练前要统一 resize。AlexNet 原始输入是 224×224,ResNet-18 也是 224×224。常见做法是训练时 RandomResizedCrop(224) 加 RandomHorizontalFlip,验证和测试时 Resize(256) 后 CenterCrop(224)。
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), 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]) ])Normalize 的均值和标准差是 ImageNet 的统计值,因为用了预训练权重,输入分布要跟预训练时一致,否则微调效果会打折扣。scale=(0.7, 1.0)是随机裁剪的面积比例,太小会丢细节,太大会退化成固定裁剪,0.7 到 1.0 是比较稳的范围。
3. CUB-200-2011 数据划分与训练监控:从 loss 曲线读出过拟合
3.1 数据集划分的三种方式与选择依据
CUB-200-2011 官方提供的是 train/test 划分,train 包含 5994 张,test 包含 5794 张,共 11788 张。但实验里需要验证集来调超参,所以要从 train 里再切一部分出来。常见做法有两种:按 8:2 从 train 里分,或者按类别分层抽样,保证每类在验证集里都有样本。
from torch.utils.data import DataLoader, Subset from sklearn.model_selection import StratifiedShuffleSplit import numpy as np # 假设 train_dataset 的 targets 是类别标签列表 targets = [train_dataset[i][1] for i in range(len(train_dataset))] sss = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(sss.split(np.zeros(len(targets)), targets)) train_sub = Subset(train_dataset, train_idx) val_sub = Subset(train_dataset, val_idx) train_loader = DataLoader(train_sub, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_sub, batch_size=32, shuffle=False, num_workers=4)分层抽样的好处是验证集的类别分布跟训练集一致,accuracy 指标不会因为某类样本过多而虚高。random_state=42保证每次划分结果可复现,调参时不会因为数据划分不同而误判模型优劣。
3.2 训练循环里的 loss 与 accuracy 记录
训练过程中要同时记录训练 loss、验证 loss、验证 accuracy。训练 loss 看模型是否在学,验证 loss 看是否过拟合,验证 accuracy 看最终性能。
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * imgs.size(0) _, preds = outputs.max(1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / totalloss.item() * imgs.size(0)是为了按样本数加权平均,避免最后一个 batch 不满时拉低平均值。outputs.max(1)返回每行的最大值和对应索引,索引就是预测类别。
3.3 从 loss 曲线判断训练状态
训练初期 loss 快速下降,说明模型在学基本特征;后期下降变缓,说明模型在优化细节。如果训练 loss 持续下降但验证 loss 开始上升,就是过拟合的信号。这时候可以加 dropout、weight decay,或者提前停止。
验证 accuracy 在后期趋于稳定,可能是模型到了性能上限,也可能是过拟合导致泛化能力不再提升。区分方法是看验证 loss:如果验证 loss 也在上升,就是过拟合;如果验证 loss 平稳,就是模型容量或数据量限制了上限。
3.4 预训练与非预训练的对比实验设计
要验证预训练权重的效果,需要控制变量:同样的数据划分、同样的超参、同样的训练步数,只改pretrained参数。从头训练时,所有层都用同一个学习率,比如 1e-3;微调时用分层学习率。
# 从头训练 model_scratch = build_model(arch='resnet18', pretrained=False) optimizer_scratch = torch.optim.SGD(model_scratch.parameters(), lr=1e-3, momentum=0.9) # 微调 model_finetune = build_model(arch='resnet18', pretrained=True) param_groups = get_param_groups(model_finetune, base_lr=1e-4, new_layer_lr=1e-3) optimizer_finetune = torch.optim.SGD(param_groups, momentum=0.9)对比结果通常显示:微调在第一个 epoch 就能达到从头训练十几个 epoch 的 accuracy,收敛速度快很多。但要注意,如果 base_lr 设得太大,预训练特征会被破坏,微调效果可能反而不如从头训练。我一般会先用 1e-4 跑一轮,看验证 accuracy 是否稳定上升,再决定要不要调小。
4. 避坑与排查:那些让准确率卡在 60% 的细节
4.1 现象:验证 accuracy 一直不涨,loss 也不降
原因:最常见的是标签映射错了。CUB 的类别标签是 1 到 200,但 PyTorch 的 CrossEntropyLoss 要求标签是 0 到 199。如果直接拿原始标签训练,模型会学偏。
解决:在 Dataset 的__getitem__里把标签减 1,或者用LabelEncoder重新映射。检查方法是打印几个 batch 的标签,看最小值是不是 0。
4.2 现象:训练 loss 正常下降,但验证 accuracy 波动很大
原因:验证集太小,或者 batch size 太小导致 BatchNorm 的统计量不稳定。ResNet-18 里有 BatchNorm,如果验证 batch size 只有 8 或 16,统计量跟训练时差异大,accuracy 会跳。
解决:验证和测试时把 batch size 设大一点,至少 32;如果显存不够,可以在验证前调model.eval()并固定 BatchNorm 的 running stats。另外,验证集至少每类留 5 到 10 张,否则 accuracy 的方差会很大。
4.3 现象:微调后 accuracy 比从头训练还低
原因:base_lr 太大,预训练权重被覆盖了。ImageNet 学到的边缘、纹理特征对鸟类识别也有用,如果微调时学习率过大,这些特征会被破坏。
解决:把 base_lr 降到 1e-5 或 1e-6,新层学习率保持 1e-3。也可以先冻结预训练层,只训练新层几个 epoch,再解冻全部层做小学习率微调。这种分阶段策略在小数据集上很稳。
4.4 现象:训练到后期验证 loss 上升,accuracy 不再提升
原因:过拟合。CUB 每类只有约 60 张,ResNet-18 参数量 1100 万,很容易记住训练样本。
解决:加 weight decay(1e-4 到 1e-3),加 dropout(AlexNet 的 classifier 里本来就有,ResNet 可以在 fc 前加),或者用早停。早停的判据是验证 loss 连续 5 个 epoch 不下降就停,恢复验证 loss 最低时的权重。
4.5 现象:GPU 显存不够,batch size 只能设 8
原因:ResNet-18 在 224×224 输入下,batch size 32 大约占 4GB 显存,如果显存只有 2GB,就得降 batch size。
解决:用梯度累积。每 4 个 batch 做一次 optimizer.step(),等效 batch size 32。注意 BatchNorm 的统计量还是按实际 batch 算的,所以如果实际 batch 太小,可以考虑换成 GroupNorm 或冻结 BatchNorm 层。
accum_steps = 4 optimizer.zero_grad() for i, (imgs, labels) in enumerate(loader): outputs = model(imgs) loss = criterion(outputs, labels) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()5. 进阶技巧:用 bounding box 做注意力裁剪与结果验证
5.1 用标注框裁剪鸟类区域再训练
CUB 提供了每张图的 bounding box 标注,常见做法是训练时先用检测框把鸟裁出来,再 resize 到 224×224。这样能去掉背景干扰,细粒度特征更集中。实现上可以在 Dataset 里读标注文件,按框裁剪后再做数据增强。
import os from PIL import Image class CUBDataset(Dataset): def __init__(self, root, split='train', transform=None): self.root = root self.transform = transform self.images = [] self.labels = [] self.boxes = {} # 读取 images.txt 和 train_test_split.txt with open(os.path.join(root, 'images.txt')) as f: for line in f: idx, path = line.strip().split() self.images.append(path) with open(os.path.join(root, 'image_class_labels.txt')) as f: for line in f: idx, label = line.strip().split() self.labels.append(int(label) - 1) # 转 0-based with open(os.path.join(root, 'bounding_boxes.txt')) as f: for line in f: parts = line.strip().split() idx = int(parts[0]) self.boxes[idx] = list(map(float, parts[1:])) def __getitem__(self, idx): img_path = os.path.join(self.root, 'images', self.images[idx]) img = Image.open(img_path).convert('RGB') x, y, w, h = self.boxes[idx + 1] img = img.crop((x, y, x + w, y + h)) if self.transform: img = self.transform(img) return img, self.labels[idx]bounding_boxes.txt的索引从 1 开始,所以取框时用idx + 1。裁剪后再做 RandomResizedCrop 和翻转,增强后的样本仍然只包含鸟类区域,背景噪声被去掉。这个改动通常能带来 3 到 5 个百分点的 accuracy 提升,但要注意框的坐标是浮点数,crop 时要转成整数。
5.2 验证模型是否真的学到了细粒度特征
光看 accuracy 不够,还要看模型关注的是不是鸟类区域。常见做法是用 Grad-CAM 生成热力图,叠加在原图上,看高响应区域是否落在鸟的身体上。如果热力图集中在背景,说明模型学的是背景捷径,不是鸟类特征。
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image target_layers = [model.layer4[-1]] cam = GradCAM(model=model, target_layers=target_layers) grayscale_cam = cam(input_tensor=img_tensor.unsqueeze(0)) visualization = show_cam_on_image(img_np, grayscale_cam[0], use_rgb=True)target_layers选最后一个残差块,因为那里的特征图分辨率最低但语义最强。如果热力图分散在多个区域,说明模型还没聚焦到关键部位,可以尝试加空间注意力模块,或者用更小的学习率多训几个 epoch。
5.3 一个我踩过的坑:验证集 accuracy 虚高
有一次我跑完实验,验证 accuracy 到了 92%,但测试集只有 78%。排查后发现验证集和训练集用了同一批图片的不同增强版本,相当于验证集泄漏了。后来我强制在划分数据前先按官方 train/test 分开,再从 train 里切验证集,测试集完全不参与任何调参。从那以后我每次跑实验都先检查三件事:标签是否从 0 开始、验证集是否跟训练集有重叠、测试集是否只在最后用一次。希望这些经验能帮你少走几个弯路。
本文还有配套的精品资源,点击获取