
简介基于DenseNet121/161/169/201四种卷积神经网络的图像识别实战项目面向深度学习初学者与计算机视觉开发者专解多类别鸟品种分类任务。资源为7z压缩包共2000个文件以1995张JPG鸟类图像为主含3个Python脚本、1个标签文件及1份readme说明整体约803MB。已有167人学习下载。项目支持ImageNet预训练权重与冻结层设置可切换Adam/SGD优化器内置交叉熵损失与余弦退火学习率评估时输出训练/验证集的Loss、Accuracy、混淆矩阵、Recall、Precision、F1等指标。数据集含200种鸟类约8000张图像标签齐全按readme指引可一键运行并支持更换自定义数据集。1. DenseNet做多类别鸟种分类先回答三个“为什么”多类别鸟品种分类看着是图像识别的入门题真做起来才知道它有多闹心两个品种的差异可能只在翅尖一圈羽色同一个品种换个姿态、换个光线特征变化比猫狗大得多。把ResNet这类卷积神经网络一味加深训练集准确率上去了验证集却开始乱抖这就是细粒度识别里典型的退化问题。DenseNet在这类任务上表现稳靠的不是单纯“更深”而是让特征图在层与层之间直接拼接复用。这篇文章用PyTorch把DenseNet的121、161、169、201四个版本串联起来从原理、选型、数据准备到训练坑位完整过一遍适合手里已经有一批鸟类图片、正准备组织成项目实战的开发者。2. DenseNet的密集连接到底改了什么从残差相加到特征拼接2.1 为什么“深度”在细粒度分类上不够用鸟种分类属于细粒度图像识别类间差异极小类内差异反而大。VGG这类模型加深到一定程度训练集loss正常下降、验证集却开始波动这是网络退化。ResNet通过残差相加把梯度传到前面但相加操作把新特征和旧特征混合在一起细粒度信息会被“稀释”。想象一下前面某一层已经提取到“翅膀边缘有一圈白羽”这种关键特征经过几十层卷积之后这个信息在加法和非线性变换里不断被扰动最后分类器可能只拿到一个模糊的影子。DenseNet的思路完全不同如果前面的特征有用就让后面每一层都能直接看到它而不是等它经过几层隐式传播。具体做法是把前面所有层的输出拼接起来作为当前层的输入。带来的第一个实际好处是梯度可以走“捷径”直达早期层梯度消失问题被结构性化解第二个好处是参数大幅下降因为网络不需要重复学习已有的特征每层只需要学一小部分新特征也就是“特征复用”。你去看DenseNet的结构图会发现每个dense block内部呈现出锯齿状的密集连接这就是它和普通卷积神经网络结构图最直观的差别。在鸟种分类这种样本量不大、类目多、相似度高的场景里特征复用意味着早期层的低级特征比如羽毛纹理、喙形边缘可以直接被末端的分类器读取不需要靠多层卷积一层层重编码。Kaggle上做过类似比赛的几乎都对比过ResNet和DenseNet在同等训练条件下DenseNet的验证集准确率往往更高尤其是200类这种多类别设定下。我在项目里的第一感受是DenseNet的验证曲线更平滑震荡更少不容易出现那种“训练集一路狂飙、验证集原地踏步”的窘境。2.2 密集连接与特征复用参数量降低的真相DenseNet的核心公式可以写成第 l 层的输入是 x0 到 x(l-1) 所有输出的拼接即 xl Hl([x0, x1, ..., x(l-1)])。这里 Hl 是一个组合操作通常是 BN ReLU 3x3 卷积的组合。每层输出固定数量的特征图这个数量叫 growth rate一般设为 32 或 48。也就是说每一层只新增 k 个通道其余输入通道全部是复用的旧特征。这里有个很容易误解的点DenseNet的通道数会涨得很夸张。以DenseNet-121为例第一个dense block有6层每层新增32个通道到第6层输入通道数已经接近 32*6 加上初始的64几百个通道。越往后累积越多最后一个dense block的输入可能有一千多个通道。听起来计算量很大但实际上大部分通道只在拼接后参与卷积卷积核本身很薄所以总参数量反而小。这也是DenseNet的“真相”参数低但FLOPs并不低内存占用更不低。项目实战里如果不理解这一点直接上DenseNet-161很容易被显存支配。DenseNet把网络分成多个dense block块内做密集连接块之间用transition层压缩。transition层的标准做法是 BN 1x1卷积 2x2平均池化1x1卷积把通道数乘一个压缩系数通常取0.5。这个压缩步骤不能省否则通道数积累到后面会失控。训练时还有一个常见技巧在transition层之后加一点dropout。我一般控制在0.1到0.3之间小数据集上超过0.3反而掉点因为鸟类的细粒度特征本身就很依赖局部通道的协同。2.3 121/161/169/201四个版本的结构差异四个版本的结构差异主要体现在growth rate和每个阶段的dense block层数上。整理成一张表版本growth rate四个dense block层数配置分类器输入维度大致参数量DenseNet-12132[6, 12, 24, 16]1024约8MDenseNet-16932[6, 12, 32, 32]1664约14MDenseNet-20132[6, 12, 48, 32]1920约20MDenseNet-16148[6, 12, 36, 24]2208约28M表格里列的是包含分类头的常见实现数据理解相对大小即可。DenseNet-121是默认首选训练时间最短类目少、每类图片几百张时足够DenseNet-169比121深一档适合样本量中等偏上的场景DenseNet-201块内层数最多适合200类全量数据、每类上千张的情况DenseNet-161是个例外它把growth rate提到了48每层学到的增量特征更多在细粒度任务里经常比121高出1到2个点但它显存占用和训练时长都明显上涨常用batch size需要从64降到32甚至16。选型时可以这样判断设备只有8GB显存直接锁死121类间差异极细、显存有余量161值得试样本量超过十万张再去考虑201。预训练权重是另一个硬性约束torchvision里这四个版本都带ImageNet预训练权重迁移学习在中小数据集上是必须的从零训练DenseNet在鸟类数据上验证集基本起不来。这也是为什么我坚持用torchvision而不是自己去复现结构——预训练权重本身就是最好的初始化。3. 四种版本怎么选121/161/169/201的选型逻辑与数据集准备3.1 选型前的三个约束显存、样本量与训练时间版本选型不是越大越好要看三个硬约束。第一个是显存DenseNet的拼接机制让中间激活值的体积非常大DenseNet-161在batch size为32、输入224x224时8GB显存基本是极限稍微激进一点就OOM。第二个是样本量每类图片少于300张时大网络只会记住训练集的噪声DenseNet-201在这种数据上验证集准确率可能反而不如121。第三个是训练时间161比121慢大约一倍如果你需要频繁调参试错先用121跑通再换161更划算。我一般按下面这个逻辑做决策每类图片少于300张设备8GB显存直接用DenseNet-121配输入尺寸224每类300到600张、类间差异极细优先试DenseNet-161但batch size要降到16必要时把输入尺寸降到192每类1000张以上、批量大且追求极限精度再上169或201。还有一个容易被忽略的点torchvision里这四个版本的预训练权重都来自ImageNet它们对224x224输入做了归一化如果你把输入改成192预训练特征的分布会有轻微错位需要多跑几个epoch让BN层重新适应。3.2 数据目录结构与增强配置PyTorch做图像识别项目最顺手的组织方式是torchvision.datasets.ImageFolder它要求数据按照“类别名作为目录名”的结构摆放。我习惯把数据拆成train和val两部分目录结构长这样data/ ├── train/ │ ├── Black_footed_Albatross/ │ │ ├── 0001.jpg │ │ └── 0002.jpg │ ├── Laysan_Albatross/ │ └── ... └── val/ ├── Black_footed_Albatross/ └── ...类别名即目录名ImageFolder会自动按目录字典序生成类别索引。注意两个坑train和val的类别集合必须完全一致否则类别索引会错位划分数据时按每个类别做stratified split不要在全部图片上随机切那样很容易让某些样本量小的类别在验证集里一张都不剩。数据增强是细粒度分类的命根子。鸟类图片里鸟在画面中的占比经常不一致有的图鸟占了大半张有的图鸟缩在角落里所以随机裁剪的尺度范围要放宽from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform 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]) ])RandomResizedCrop的scale下限设成0.6而不是默认的0.08原因是鸟类目标通常比ImageNet里的物体更集中裁剪太狠会把鸟身截断反而破坏语义。ColorJitter的强度也不能太高鸟类羽毛颜色本身是重要判别特征饱和度扰动超过0.3会让模型对颜色变化过于迟钝。Normalize的mean和std用的是ImageNet统计值使用预训练权重时不能换这是迁移学习的标准操作换了相当于把预训练特征分布的坐标系改掉了。3.3 类别不均衡采样器的正确用法很多鸟类数据集并不像CUB-200那样每类刚好上百张自己爬的数据经常是某些类几百张、某些类只有十几张。直接用普通DataLoader少数类的梯度信号会被多数类淹没训练出来的模型对少数类几乎全错。这时候要用WeightedRandomSamplerfrom torch.utils.data import WeightedRandomSampler import torch labels [sample[1] for sample in train_dataset] class_counts torch.bincount(torch.tensor(labels)) weights 1.0 / class_counts[labels].float() sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size32, samplersampler, num_workers4, shuffleFalse)weights按每张样本所属类别的频率倒数计算少数类被采样到的概率更高。replacementTrue表示同一张图可以在一轮里被采样多次这是为了凑足num_samples的样本量。需要注意用了sampler就不能再开shuffleTrue两者语义冲突DataLoader会直接报错。另外少数类被反复送进网络如果数据增强不够强反而会对少数类过拟合。所以用了采样器之后建议把ColorJitter和RandomResizedCrop适当加强一点让同一个样本每次看到的样子都不一样。如果训练集本身是CUB-200这种每类样本量均衡的数据集就不需要采样器直接shuffleTrue即可。千万别把采样器和均衡数据集一起用那会把训练分布强行扭曲反而降低多数类的精度。判断需不需要采样器直接打印类别样本数分布如果最大值和最小值相差超过5倍就应该处理。4. 用PyTorch跑通DenseNet121训练流程模型替换、优化器与学习率4.1 加载预训练权重并替换分类头DenseNet的backbone部分在torchvision里叫features最后接一个Linear分类器。加载方式建议用Torchvision的weights参数不要自己下载权重文件再传路径weights参数会自动处理权重来源和校验逻辑。替换分类头时有一个硬编码问题四个版本的分类器输入维度不一样121是1024169是1664161是2208201是1920所以代码里不能写死数字import torch import torch.nn as nn from torchvision import models num_classes 200 # 你的鸟类类别数 model models.densenet121(weightsmodels.DenseNet121_Weights.IMAGENET1K_V1) in_features model.classifier.in_features model.classifier nn.Linear(in_features, num_classes) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)从model.classifier.in_features动态读取维度这样代码以后换169或161版本时不用改这一行。替换分类头之后再调用load_state_dict来加载预训练权重顺序不能反否则分类头维度对不上会报size mismatch。Torchvision的weights参数是在内部处理预训练权重的替换分类头后模型结构已经变了只影响分类头那一层的初始化backbone部分仍然带ImageNet特征这就是迁移学习的核心价值。4.2 训练循环、优化器与学习率调度训练部分我直接给出一个最小可跑通的完整循环这个流程适合迁移学习初期的探索阶段from torch.utils.data import DataLoader from torchvision import datasets train_dataset datasets.ImageFolder(data/train, transformtrain_transform) val_dataset datasets.ImageFolder(data/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max40) best_acc 0.0 for epoch in range(40): model.train() train_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() * images.size(0) scheduler.step() model.eval() correct 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() val_acc correct / len(val_dataset) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_bird_densenet121.pth) print(fEpoch {epoch 1}: loss{train_loss / len(train_dataset):.4f}, val_acc{val_acc:.4f})三个关键参数说明lr设成1e-4而不是1e-3迁移学习里新分类头是随机初始化的学习率太大会让刚开始的几步梯度把backbone冲乱AdamW相比Adam多了解耦的权重衰减配合weight_decay1e-4对鸟种分类这种中小数据集有明显的防过拟合效果T_max设为40和epoch数保持一致CosineAnnealingLR的曲线正好走完一个完整周期学习率从1e-4平滑降到接近0。scheduler.step()放在每个epoch结束后调用一次因为一个epoch对应一次学习率调整。保存checkpoint时只存state_dict就够了但更好的习惯是把epoch和optimizer状态一起存下来方便中断后恢复训练torch.save({ model: model.state_dict(), epoch: epoch, optimizer: optimizer.state_dict(), best_acc: best_acc, }, checkpoint.pth)4.3 评估曲线与过拟合判断单看val_acc不够200类鸟类数据集的top-1准确率和top-5准确率要一起看。正常情况下top-1在85%以上时top-5应该接近95%以上。如果top-1还在涨但top-5长期不动说明模型开始在一些“难分对”的相似类上打转这时候要检查是不是某些类别的训练样本太少或者类间特征在增强后被过度抹平。训练loss和val_acc的组合可以判断过拟合训练loss持续下降、val_acc连续10个epoch不涨就是模型开始背诵训练集噪声了。此时优先做两件事一是用early stopping在val_acc连续N个epoch不涨时杀掉训练并回滚到best checkpoint二是把ColorJitter的强度调大一点让模型看到更多颜色变化。千万别直接增大weight_decay那会让所有参数一起缩小细粒度特征反而更容易被丢掉。我每次训练都会顺手把每个epoch的训练loss和val_acc打进日志文件哪怕只是print到控制台重定向保存后面分析问题时能少很多猜测。5. 鸟种分类训练中五个高频踩坑点与排查方法5.1 预训练权重加载时报size mismatch现象代码在load_state_dict时抛错提示classifier.weight尺寸不匹配。原因torchvision的预训练权重最后一层是ImageNet的1000类你的分类头只有200类。解决先替换classifier再加载或者用strictFalse跳过不匹配层。我一般先替换再加载这样分类头从零开始backbone继承预训练特征。如果用了strictFalse务必确认跳过的是不是只有classifier这一层要是把backbone的层也跳过了预训练就白加载了。5.2 验证集准确率高但换一批真实照片就翻车现象验证集acc到了92%拿手机拍的几张现场鸟图测试预测结果完全不对。原因背景泄露。验证集和训练集来自同一批数据的随机切分很多鸟类图片的背景高度一致比如某种海鸟总是出现在岩石上海岸线附近模型学会的是“这种鸟总站在海边”而不是“这种鸟长这样”。解决先看Grad-CAM热力图确认关注区域是不是鸟本体再调整数据增强。RandomResizedCrop的scale范围加大、引入RandomErasing随机擦除都会强迫模型多关注目标本身。这是细粒度项目里最容易踩的坑也是最容易让人误以为模型已经可以上线的坑。5.3 类别不均衡少数类几乎全部预测错现象训练完打印混淆矩阵200类里样本量少的类准确率接近0多数类反而很高。原因普通的CrossEntropyLoss被多数类主导少数类的梯度信号太微弱网络直接忽略了它们。解决用第3章的WeightedRandomSampler重采样或者给CrossEntropyLoss传一个类别权重向量权重按样本数倒数计算。这两条路我都试过采样器更稳因为它不改变loss的数值分布类别权重则容易让少数类的loss值跳得很大需要同时调低学习率。注意采样器会把少数类的图反复送入网络如果增强不够强反而对少数类过拟合增强强度要跟采样器一起调。5.4 显存OOM在DenseNet-161上频繁出现现象batch size设为32换DenseNet-161后直接OOM或者不OOM但训练速度极慢。原因DenseNet的拼接机制导致中间激活值体积大训练时多个dense block的中间特征都要留在显存里做反向传播。解决优先换回DenseNet-121它和161在中等数据量上的精度差距通常不到1个点其次把输入尺寸从224降到192显存占用按平方下降再不行就用梯度累积把batch拆小。注意161的BatchNorm在小batch下统计量不稳定如果batch降到8以下建议把BN的momentum从默认0.1调到0.05否则验证集BN统计量的误差会被放大。5.5 Loss震荡不降验证集一动不动现象训练了10个epochloss在1到2之间反复横跳val_acc没有爬升趋势。原因学习率偏大是首要嫌疑迁移学习里最怕的是先用大学习率把预训练特征搅乱。另一个可能原因是Normalize的mean/std填错比如用成了0到1归一化的统计值。还有一个隐蔽问题数据集里混入了损坏图片或非鸟图片损坏图片解码失败会导致整个Dataloader崩溃非鸟图片则会让loss出现周期性尖刺。解决先把lr降到3e-5跑20个epoch看曲线是否平滑下降检查transform里的mean和std是否和预训练权重匹配清洗数据集把全黑、模糊到无法辨认、带大面积文字水印的图片删掉。这三步走完基本能定位问题。6. 微调策略与Grad-CAM可视化把模型精度再推一档训练到val_acc初步收敛后不要急着结束。把backbone解冻一部分、用更低学习率微调通常还能再涨1到2个点。我的习惯是先冻结整个features只训分类头跑几十个epoch让分类器先适应鸟类特征分布然后解冻最后两个dense block和对应的transition层用分层学习率微调for name, param in model.named_parameters(): if name.startswith(features.denseblock3) or \ name.startswith(features.transition2) or \ name.startswith(features.denseblock4): param.requires_grad True else: param.requires_grad False optimizer torch.optim.AdamW([ {params: model.classifier.parameters(), lr: 1e-4}, {params: [p for n, p in model.named_parameters() if p.requires_grad and not n.startswith(model.classifier)], lr: 1e-5}, ])分类头用1e-4解冻的backbone部分用1e-5相差一个量级。解冻太多层容易破坏预训练特征解冻太少又学不到鸟类特有的细粒度模式。常用技巧是只看Grad-CAM热力图来判断该解冻哪一层热力图如果集中在鸟的头部和翅膀边缘说明特征提取已经到位如果分散在背景上说明当前层的语义抽象还不够需要再往上解冻一个dense block。Grad-CAM可视化是验证模型“到底在看什么”的最直接手段。我常用torchcam这个库target_layer指向最后一个dense block的最后一个卷积层from torchcam.methods import GradCAM cam_extractor GradCAM(model, target_layerfeatures.denseblock4.denselayer16.conv2) model.eval() with torch.no_grad(): out model(img.unsqueeze(0).to(device)) activation_map cam_extractor(0, out)注意target_layer要指向具体的卷积层不要指向整个dense block否则热力图分辨率太低。DenseNet-121的最后一个dense block有16个DenseLayer每个DenseLayer里conv2是3x3卷积换成169或201时这个层数会变要对应调整。把热力图resize到原图尺寸叠加后一眼就能看出模型有没有被背景带偏。我自己在做一个野外鸟类监测项目时一版模型在测试集上做到93%拿去河边拍了几张就翻车。最后用Grad-CAM跑了几十张图发现模型盯着水面和树枝看。把包含这些背景的错样本加回训练集、重新微调之后确认模型在看鸟的头部和翅膀才敢往外推。那以后我的习惯是任何一版模型先可视化再谈部署。希望帮到你。本文还有配套的精品资源点击获取