简介这是一份面向深度学习初学者与计算机视觉方向学生的实验报告文档聚焦卷积神经网络在细粒度鸟类识别任务中的完整实践。报告以AlexNet与ResNet-18两种经典架构为基线将输出层改为200类以适配CUB-200-2011数据集并对比ImageNet预训练权重微调与随机初始化从头训练的性能差异同时分析学习率、训练步数与衰减策略等超参数对结果的影响。文档还给出训练过程loss曲线与验证集accuracy的可视化解读以及边界框预测结果展示读者可据此理解细粒度分类中的数据划分、迁移学习与过拟合判断思路适合作为课程实验或练手项目的参考范本。压缩包共1个文件为docx格式实验报告体积约1.36MB内含网络结构图、数据集示例与结果对比图表便于直接阅读与引用。目前已有184人学习。1. 200 类鸟塞进 CNN一份鸟类识别实验报告能复现到什么程度把 200 类鸟塞进一个卷积神经网络里最容易翻车的不是模型不够深而是数据划分和输出层没对齐。这份鸟类识别实验报告给出的路线很直接AlexNet 和 ResNet-18 两套 CNN用 ImageNet 预训练权重初始化把输出层改成 200 类在 CUB-200-2011 数据集上做微调再用 loss、accuracy 曲线和预测框做过程可视化最后对比预训练与随机初始化、不同学习率和训练步数的差异。它适合想跑通细粒度图像识别基线的人也适合拿它当课程实验或报告底稿的技术人员如果你只想找一个能直接跑出高准确率的模型这份材料不是那种调参大全它的价值在于把“为什么这样设”和“哪一步容易翻车”讲清楚。2. 模型与数据先对齐AlexNet/ResNet-18 改 200 类输出与 CUB-200-2011 划分2.1 为什么细粒度鸟类识别优先用预训练 CNN 而不是从头训练CUB-200-2011 只有约 1.18 万张图200 类平均每类 60 张左右。这个量级对 ImageNet 预训练模型来说只够做微调不足以从随机初始化开始喂出一个稳定的 CNN。鸟类识别又属于细粒度图像识别类别之间的差异常常在喙形、翼纹、羽色分布这些局部区域浅层卷积学到的边缘和纹理仍然通用预训练权重能把这些通用特征带进来训练初期收敛会快很多。实验报告里选择 AlexNet 和 ResNet-18 作为起点思路是对的AlexNet 结构简单适合观察全连接层替换和微调流程ResNet-18 有残差连接训练更深一点时梯度更稳作为对照能看出骨架差异。两者都把输出层改成 200这不是可选项是必须项。ImageNet 原始输出是 1000 类直接拿来跑 CUB 会得到完全错位的标签空间。常见做法是把新输出层从头初始化骨干网络用较小学习率微调新层用较大学习率。这样做的好处是新层快速适应 200 类鸟类骨干网络不至于被小数据集带偏。实验报告里提到“对预训练参数使用较小学习率微调”落地时就要把参数分组而不是所有参数共用一个学习率。模型输入尺寸输出层位置输出维度预训练权重微调重点AlexNet224×224classifier 第 6 层200ImageNet替换最后一层 Linear骨干小学习率ResNet-18224×224fc 层200ImageNet替换 fcBN 统计量注意冻结策略提示AlexNet 在 torchvision 里的默认输入是 224×224不要按原论文的 227 去凑尺寸预处理和模型结构对不上时第一层卷积的期望输入会直接报错。2.2 替换输出层与冻结策略可运行的 PyTorch 改法先写模型构建和输出层替换。这里不绑定具体版本号权重枚举用当前环境可用的 DEFAULT避免硬编码某个权重字符串。代码里同时处理 AlexNet 和 ResNet-18返回模型和模型名后面训练循环复用。import torch import torch.nn as nn from torchvision import models def build_model(name, num_classes200, pretrainedTrue): 构建 AlexNet 或 ResNet-18并把输出层改成 num_classes。 if name alexnet: weights models.AlexNet_Weights.DEFAULT if pretrained else None model models.alexnet(weightsweights) in_features model.classifier[6].in_features # AlexNet 的分类头在 classifier 的第 6 个模块 model.classifier[6] nn.Linear(in_features, num_classes) elif name resnet18: weights models.ResNet18_Weights.DEFAULT if pretrained else None model models.resnet18(weightsweights) in_features model.fc.in_features # ResNet 系列分类头就叫 fc model.fc nn.Linear(in_features, num_classes) else: raise ValueError(funsupported model: {name}) return model def set_finetune(model, name, freeze_backboneTrue): 控制骨干是否冻结分类头始终可训练。 for p in model.parameters(): p.requires_grad not freeze_backbone if name alexnet: for p in model.classifier[6].parameters(): p.requires_grad True else: for p in model.fc.parameters(): p.requires_grad True return model逻辑上build_model先加载预训练骨架再把分类头换成 200 维set_finetune决定骨干是否参与反向传播。参数上num_classes200对应 CUB 的类别数pretrainedTrue表示加载 ImageNet 权重freeze_backboneTrue时只训练新分类头适合先跑一个快速基线如果显存和时间允许更稳的做法是解冻全部参数用分组学习率微调。分组学习率要按参数名或模块归属拆开。下面的写法把分类头单独放一组骨干放另一组避免新层和预训练层共用同一个 lr。def build_optimizer(model, name, backbone_lr1e-4, head_lr1e-3, momentum0.9, weight_decay5e-4): 分类头用 head_lr骨干用 backbone_lr。 if name alexnet: head_params list(model.classifier[6].parameters()) else: head_params list(model.fc.parameters()) head_ids {id(p) for p in head_params} backbone_params [ p for p in model.parameters() if p.requires_grad and id(p) not in head_ids ] return torch.optim.SGD( [ {params: backbone_params, lr: backbone_lr}, {params: head_params, lr: head_lr}, ], momentummomentum, weight_decayweight_decay, )逻辑上head_ids用id()标记分类头参数剩下的可训练参数归入骨干组。参数上backbone_lr1e-4是微调预训练层的常见起点head_lr1e-3让新初始化的 200 类分类头学得更快momentum0.9和weight_decay5e-4是 SGD 微调的常用组合。如果只冻结骨干训练分类头骨干组会是空列表这时优化器只更新分类头属于快速验证流程不是最终性能方案。2.3 CUB-200-2011 目录、划分文件与 Dataset 接法CUB 的原始标注文件里images.txt是图像 id 到相对路径的映射image_class_labels.txt是图像 id 到类别标签的映射train_test_split.txt标记每张图属于官方训练集还是测试集。实验报告里写训练集、验证集、测试集三划分但官方划分只有 train/test验证集必须自己从训练集里分层抽出来并且固定随机种子。否则每次跑出来的 val accuracy 都不可比。先看一个把官方文件转成三份 txt 的脚本。输出格式是每行“相对路径 类别标签”标签从 0 到 199。import random from collections import defaultdict def read_kv(path): d {} with open(path, r, encodingutf-8) as f: for line in f: k, v line.strip().split(maxsplit1) d[int(k)] v return d def make_splits(root, out_dir, val_ratio0.1, seed42): images read_kv(f{root}/images.txt) labels read_kv(f{root}/image_class_labels.txt) is_train read_kv(f{root}/train_test_split.txt) by_cls defaultdict(list) test_rows [] for img_id, rel in images.items(): y int(labels[img_id]) - 1 # CUB 标签 1..200 转 0..199 if is_train[img_id] 1: by_cls[y].append(rel) else: test_rows.append((rel, y)) rng random.Random(seed) train_rows, val_rows [], [] for y, rels in by_cls.items(): rels sorted(rels) rng.shuffle(rels) n_val max(1, int(len(rels) * val_ratio)) val_rows [(r, y) for r in rels[:n_val]] train_rows [(r, y) for r in rels[n_val:]] def dump(name, rows): with open(f{out_dir}/{name}, w, encodingutf-8) as f: for rel, y in rows: f.write(f{rel} {y}\n) dump(train.txt, train_rows) dump(val.txt, val_rows) dump(test.txt, test_rows) print(len(train_rows), len(val_rows), len(test_rows))逻辑上脚本先读三个官方映射文件把官方训练集按类别分组再按类别抽 10% 做验证集官方测试集原样保留。参数上val_ratio0.1控制验证集比例seed42保证划分可复现max(1, ...)保证每类至少有一张验证图如果某类样本太少验证集过小会让曲线抖动很大这时可以把val_ratio提到 0.15但要同步固定划分文件不能每轮重抽。接着写 Dataset。它只依赖上面生成的 txt不猜目录结构路径拼接清晰。from torch.utils.data import Dataset from PIL import Image class CUBTextDataset(Dataset): def __init__(self, root, split_file, transformNone): self.root root self.transform transform self.samples [] with open(split_file, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue rel, label line.rsplit( , 1) self.samples.append((rel, int(label))) def __len__(self): return len(self.samples) def __getitem__(self, idx): rel, label self.samples[idx] img Image.open(f{self.root}/{rel}).convert(RGB) if self.transform is not None: img self.transform(img) return img, label逻辑上__getitem__按相对路径读图并强制转 RGB避免灰度图或带 alpha 通道的图片在卷积层前维度不一致。参数上root指向 CUB 解压后的根目录split_file指向 train/val/test 的 txttransform由训练或验证阶段传入标签已经在生成 txt 时转成 0 到 199Dataset 不再做二次减一否则会整体错位。注意划分文件一旦确定就不要再动。训练集、验证集、测试集之间只要有同一张图泄漏最后 test accuracy 会虚高而且这种虚高在混淆矩阵里很难看出来。3. 训练管线拆解从 DataLoader 到 loss/accuracy 曲线3.1 数据增强与输入尺寸AlexNet 224、ResNet-18 224 的差异两个模型在 torchvision 里都按 224×224 输入组织预处理所以训练和验证的 transform 可以共用一套尺寸参数。鸟类细粒度识别里颜色是判别特征颜色抖动不要默认开随机裁剪的下限也别压得太狠否则鸟的主体被裁掉模型学到的更多是背景。水平翻转通常安全但如果你做的是需要方向信息的任务翻转前先确认标签语义不会被破坏。from torchvision import transforms train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(p0.5), 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], ), ])逻辑上训练阶段先随机裁剪到 224 再翻转验证阶段先缩到 256 再中心裁剪到 224保证评估时输入分布稳定。参数上scale(0.7, 1.0)表示随机裁剪保留原图 70% 到 100% 的面积下限太低会把鸟裁成局部纹理p0.5是水平翻转概率归一化参数沿用 ImageNet 统计量因为骨干权重来自 ImageNet输入分布要跟预训练时一致。DataLoader 的参数里batch_size先按显存给AlexNet 的全连接层比较吃显存ResNet-18 相对轻。num_workers不要一上来拉满先看磁盘和 CPUpin_memoryTrue在 GPU 训练时能减少拷贝等待。from torch.utils.data import DataLoader train_ds CUBTextDataset(data/CUB_200_2011, splits/train.txt, train_tf) val_ds CUBTextDataset(data/CUB_200_2011, splits/val.txt, val_tf) test_ds CUBTextDataset(data/CUB_200_2011, splits/test.txt, val_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue) test_loader DataLoader(test_ds, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue)逻辑上训练集打乱验证集和测试集不打乱保证评估顺序稳定。参数上batch_size32是起点显存不够就减半或开梯度累积验证和测试用 64 是为了减少前向次数不影响指标num_workers4是常见折中太大在小数据集上反而增加进程开销。3.2 分组学习率与训练循环训练循环要记录每个 epoch 的 train loss、train acc、val loss、val acc后面画曲线和选最佳 checkpoint 都用它。优化器用第 2 章的分组写法学习率调度器可以用余弦退火也可以用 StepLR关键是别在训练中途手动改 lr 又不记录。import torch def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total 0.0, 0, 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad(set_to_noneTrue) logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) correct (logits.argmax(1) y).sum().item() total x.size(0) return total_loss / total, correct / total torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 for x, y in loader: x, y x.to(device), y.to(device) logits model(x) loss criterion(logits, y) total_loss loss.item() * x.size(0) correct (logits.argmax(1) y).sum().item() total x.size(0) return total_loss / total, correct / total逻辑上训练函数做前向、反传、更新评估函数只做前向并关闭梯度。参数上criterion用nn.CrossEntropyLoss()它内部包含 softmax不要在外面再加 softmaxoptimizer.zero_grad(set_to_noneTrue)比清零更省一点显存logits.argmax(1)取 200 类里概率最大的类别和标签维度对齐。外层循环负责调度器和最佳模型保存。验证集 loss 下降时保存不要只看 train acc。import copy def fit(model, train_loader, val_loader, optimizer, scheduler, device, epochs30, ckpt_pathbest.pt): criterion torch.nn.CrossEntropyLoss() history {train_loss: [], train_acc: [], val_loss: [], val_acc: []} best_val_loss float(inf) best_state None for epoch in range(1, epochs 1): tr_loss, tr_acc train_one_epoch( model, train_loader, criterion, optimizer, device ) va_loss, va_acc evaluate( model, val_loader, criterion, device ) scheduler.step() history[train_loss].append(tr_loss) history[train_acc].append(tr_acc) history[val_loss].append(va_loss) history[val_acc].append(va_acc) if va_loss best_val_loss: best_val_loss va_loss best_state copy.deepcopy(model.state_dict()) torch.save(best_state, ckpt_path) print(fepoch {epoch:02d} train_loss{tr_loss:.4f} ftrain_acc{tr_acc:.4f} val_loss{va_loss:.4f} fval_acc{va_acc:.4f}) return history, best_state逻辑上每个 epoch 先训练再验证调度器在验证后推进一步最佳模型按验证集 loss 保存。参数上epochs30是跑通流程的起点报告里强调训练步数过长会过拟合、过短会欠拟合实际要看 val loss 拐点ckpt_path只存最佳权重测试时加载它不要拿最后一个 epoch 的权重直接报结果。3.3 可视化loss、accuracy 与预测框曲线不用花哨一张双轴图足够左轴 loss右轴 accuracy。train loss 和 val loss 放一起看欠拟合/过拟合val acc 看收敛平台。代码里把 history 直接传进去保存图片方便写报告。import matplotlib.pyplot as plt def plot_curve(history, out_pathcurve.png): epochs range(1, len(history[train_loss]) 1) fig, ax1 plt.subplots(figsize(7, 4)) ax1.plot(epochs, history[train_loss], labeltrain loss) ax1.plot(epochs, history[val_loss], labelval loss) ax1.set_xlabel(epoch) ax1.set_ylabel(loss) ax1.legend(locupper left) ax2 ax1.twinx() ax2.plot(epochs, history[val_acc], colortab:green, linestyle--, labelval acc) ax2.set_ylabel(accuracy) ax2.legend(locupper right) fig.tight_layout() fig.savefig(out_path, dpi160)逻辑上loss 和 accuracy 量纲不同用双轴避免 accuracy 被压成一条平线。参数上dpi160够报告插图用val_acc用虚线区分如果曲线震荡厉害先看 batch size 和学习率不要急着换模型。预测框可视化在分类实验里不是必须项但 CUB 自带bounding_boxes.txt拿它做错误样本回看很有用。注意bounding_boxes.txt的第一列是图像 id不是类别 id画框前要先通过images.txt映射到路径。代码里先读框再按图像 id 找路径。from PIL import Image, ImageDraw def load_boxes(root): boxes {} with open(f{root}/bounding_boxes.txt, r, encodingutf-8) as f: for line in f: parts line.strip().split() img_id int(parts[0]) boxes[img_id] tuple(map(float, parts[1:])) return boxes def draw_box(root, img_id, boxes, out_pathbox.png): images read_kv(f{root}/images.txt) rel images[img_id] img Image.open(f{root}/{rel}).convert(RGB) x, y, w, h boxes[img_id] draw ImageDraw.Draw(img) draw.rectangle([x, y, x w, y h], outlinered, width3) img.save(out_path)逻辑上load_boxes把图像 id 映射到(x, y, w, h)draw_box再把图像 id 映射到相对路径并画矩形。参数上x, y是左上角坐标w, h是宽高单位是像素如果框画歪了优先检查是不是把img_id当成了类别标签这是很常见的翻车点。4. 超参数与实验对比预训练权重、学习率、步数怎么定4.1 预训练 vs 随机初始化控制变量表实验报告里对比了“仅用 CUB 从随机初始化训练”和“用预训练权重微调”两组结果结论是微调在性能和收敛速度上更好。复现这个对比时控制变量比结论更重要同一份划分文件、同一套数据增强、同一个 batch size、同一个评估口径只改预训练权重和对应的学习率策略。否则你看到的差异可能来自划分或增强而不是预训练。实验编号模型预训练骨干 lr分类头 lrepochs观察重点A1AlexNet否1e-31e-330收敛速度、train loss 下降斜率A2AlexNet是1e-41e-330初期 val acc、过拟合出现时间R1ResNet-18否1e-31e-330残差骨架从头训练稳定性R2ResNet-18是1e-41e-330细粒度特征迁移效果表格里的学习率是起点不是固定答案。从头训练时骨干 lr 不能太小否则 30 个 epoch 内根本走不出初始平台微调时骨干 lr 要压小避免预训练特征被小数据集快速覆盖。实验报告里提到“新输出层从头训练预训练参数小学习率微调”落地就是 A2/R2 这两组的配置。把实验配置写成列表循环跑结果写 CSV。这样比手动改脚本可靠。import csv experiments [ {name: alexnet_scratch, model: alexnet, pretrained: False, backbone_lr: 1e-3, head_lr: 1e-3}, {name: alexnet_ft, model: alexnet, pretrained: True, backbone_lr: 1e-4, head_lr: 1e-3}, {name: resnet18_scratch, model: resnet18, pretrained: False, backbone_lr: 1e-3, head_lr: 1e-3}, {name: resnet18_ft, model: resnet18, pretrained: True, backbone_lr: 1e-4, head_lr: 1e-3}, ] def run_one(cfg, train_loader, val_loader, device, epochs30): model build_model(cfg[model], num_classes200, pretrainedcfg[pretrained]) set_finetune(model, cfg[model], freeze_backboneFalse) model model.to(device) optimizer build_optimizer( model, cfg[model], backbone_lrcfg[backbone_lr], head_lrcfg[head_lr], ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxepochs ) history, best_state fit( model, train_loader, val_loader, optimizer, scheduler, device, epochsepochs, ckpt_pathf{cfg[name]}.pt, ) return history, best_state逻辑上每组实验独立构建模型、优化器和调度器避免状态串味。参数上pretrained控制是否加载 ImageNet 权重backbone_lr和head_lr按组传入T_maxepochs让余弦退火在一个训练周期内走完如果显存不够先把 batch size 减半不要先改学习率否则对比条件又变了。4.2 学习率与衰减策略的扫参方法学习率不要一上来网格拉满。先粗扫分类头 lr再细扫骨干 lr最后看衰减策略。常见起点是分类头[3e-4, 1e-3, 3e-3]骨干[1e-5, 1e-4, 5e-4]先固定 epochs20 看趋势再对表现最好的两组延长到 40 或 50。每次只动一个维度否则结果无法归因。def lr_grid(): grid [] for head_lr in [3e-4, 1e-3, 3e-3]: for backbone_lr in [1e-5, 1e-4, 5e-4]: grid.append({ model: resnet18, pretrained: True, head_lr: head_lr, backbone_lr: backbone_lr, epochs: 20, }) return grid def write_result(path, row): with open(path, a, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnamesrow.keys()) writer.writerow(row)逻辑上lr_grid生成 9 组组合write_result每次追加一行方便中断后继续。参数上epochs20是粗扫成本控制head_lr和backbone_lr的范围覆盖了“太小收敛慢”和“太大震荡”的两端如果某组 train loss 在前 5 个 epoch 完全不降先停掉检查权重加载和标签映射不要继续烧时间。衰减策略方面StepLR 更直观CosineAnnealingLR 在后期学习率更平滑。实验报告里提到不同学习率衰减策略会影响性能落地时至少对比一次StepLR(step_size10, gamma0.1)和余弦退火。看曲线时重点看 val loss 后 10 个 epoch 是否还在降如果已经平了加 epoch 意义不大。4.3 结果表与判读欠拟合/过拟合信号结果表不要只记一个 accuracy至少记 best val loss、best epoch、test top1、test top5、训练耗时。这样别人复现时能判断你是“收敛早”还是“过拟合晚”。下面这张表是判读用不是让你填假数据每组实验按自己的日志填。现象可能原因先做什么train loss 几乎不降lr 太小、骨干冻结过多、标签错位解冻骨干打印一个 batch 的标签和 logits 维度train loss 震荡明显lr 太大、batch 太小、数据增强过猛降 head_lr检查 RandomResizedCrop 下限train acc 高、val acc 低过拟合、验证集太小、划分泄漏加早停固定 split检查同图泄漏val loss 先降后升过拟合保存 best val loss 权重减 epochsval acc 平台很早学习率衰减太慢或模型容量到顶换衰减策略对比 ResNet-18实验报告里说“过短训练步数欠拟合、过长过拟合、过大学习率震荡、过小收敛慢”这四句话在曲线上都有对应形态。不要只看最终数字先把曲线形态和表里的现象对上再决定是调 lr 还是调 epochs。5. 避坑与排查鸟类识别实验里最容易翻车的五件事5.1 数据划分泄漏与类别不均衡现象是验证集 accuracy 很漂亮测试集一跑掉一截或者某些类别在混淆矩阵里几乎全错。原因常见有两个验证集从训练集里随机抽时没有按类别分层导致少数类验证样本极少或者生成 split 时用了随机数但没固定 seed每次运行划分都变调参时等于在验证集上反复过拟合。CUB 每类约 60 张分层抽样后每类验证集只有几张指标抖动会很大必须固定划分文件。解决方式是把 split 文件当成实验输入的一部分生成一次后不再改动训练脚本只读不重抽。下面的检查代码放在训练前跑一遍确认没有交集。def read_rows(path): rows [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if line: rel, y line.rsplit( , 1) rows.append((rel, int(y))) return rows train_rows read_rows(splits/train.txt) val_rows read_rows(splits/val.txt) test_rows read_rows(splits/test.txt) train_set {rel for rel, _ in train_rows} val_set {rel for rel, _ in val_rows} test_set {rel for rel, _ in test_rows} assert not (train_set val_set), train/val 有重叠 assert not (train_set test_set), train/test 有重叠 assert not (val_set test_set), val/test 有重叠逻辑上用相对路径集合做交集判断比只比标签可靠。参数上路径必须和 Dataset 里的root拼接方式一致否则你检查本文还有配套的精品资源点击获取