☰
DenseNet图像分类实战:鸟品种识别与模型评估
2026/10/1 13:19:21 网站建设 项目流程

简介:这是一份面向图像识别入门与进阶开发者的完整实战项目,实现200种鸟品种的多类别分类。项目覆盖DenseNet121、161、169、201四种主干网络,可通过pretrained与freeze_layers参数灵活控制是否加载ImageNet预训练权重或仅训练分类层;同时内置Adam与SGD两种优化器对比,结合交叉熵损失和余弦退火学习率策略。配套提供训练集与验证集上的loss、准确率曲线,并输出混淆矩阵、召回率、精确率、F1-score和特异度等评估指标。资源共2000个文件,以1995张鸟图像JPG为主,另有3个Python脚本、1个数据集标签TXT和1个Readme文档,7z压缩包约803MB,数据与标签齐备,支持一键运行。已有167人学习这一方案,适合需要快速搭建分类基线、进行消融实验或学习评价体系搭建的研究者。

1. 为什么我拿 DenseNet 做鸟品种识别:四版对照、一键跑通、指标看得全

做多类别图像分类,很多人第一反应是 ResNet 或 EfficientNet,但手里只有 200 类、约 8k 张鸟图还要做消融实验的时候,DenseNet 反而是更容易出稳定结果的选项,因为密集连接把前面每一层的特征图反复送给后续层,参数增量不多,特征复用却非常充分,小数据集上收敛明显更稳。这套项目代码把 DenseNet121/161/169/201 四个版本一次集成,通过 pretrained 和 freeze_layers 两个参数决定是否加载官方 ImageNet 预训练权重、是否只训练分类输出层;优化器内置 Adam 和 SGD,损失函数用多类别交叉熵,学习率走余弦退火,评估会同时覆盖训练集和验证集的 loss、accuracy,并输出混淆矩阵、precision、recall、F1 和特异度。适合刚上手 CNN 图像识别、想先跑通再逐步改参数的新人,也适合正准备补一组对比实验数据的从业者。

2. DenseNet 家族加深的是“复用”,不是单纯堆层数:121/161/169/201 从哪开始选

当你看过 ResNet 的残差结构再回头看 DenseNet,会发现它走的是完全相反的思路。ResNet 想的是“把上一层的输入原样抄送过来,保证梯度有近路可走”,DenseNet 想的是“把前面所有层提取出来的特征全部拼起来,让后面每一层都能直接读到前面任何一层的信息”。这两种做法在梯度传播上都有效,但 DenseNet 的特征复用更激进:你多加一层,不只是多学一种特征,而是给后面所有层都多了一份参考。

这个特性落到鸟品种分类这种任务上,价值非常直接。鸟类的区分往往靠翅膀纹理、尾羽颜色、嘴型这些局部特征,而这些特征恰恰分布在不同的卷积层级里。浅层卷积看到的是边缘和色块,深层卷积看到的是组合形状,DenseNet 让深层分类器能直接引用浅层的细粒度纹理,所以在样本量不大、类别又细分的场景下,它比同深度的普通 CNN 更容易把相似品种分开。

2.1 特征为什么会“消失”:梯度路径和细粒度信息的双重问题

先用一个简单的类比理解梯度问题。普通卷积网络从输入到输出是一条长流水线,每一层都在上一层输出的中间结果上继续加工。如果中间的某几层学到的东西重复,信息一路传递下来会越来越“稀”,梯度回传到浅层时也容易变得非常小,这就是常说的梯度消失。模型不是不想学浅层纹理,而是浅层根本没收到有效的更新信号。

DenseNet 把每一层的输出 concat 到后续每一层的输入,等于给梯度开了无数条近路。反向传播时,梯度可以跳过中间若干层直接回到浅层,浅层卷积因此能真正被训练到。我拿同一份鸟数据对比过,用 DenseNet 训练时,第一个 epoch 结束后 feature map 里已经能看到明显的羽毛边缘响应,而普通 CNN 至少要等到两三个 epoch 之后才有类似效果。这不是玄学,是特征复用带来的实际差异。

另一个好处是参数效率。因为是特征拼接而不是层层堆 filter,DenseNet 每一层只需要新增少量卷积核就能产生新特征,所以 121 层版本的参数总量并不比很多几十层的网络大多少。对 8k 张图这种量级的数据,参数少意味着过拟合风险低,更意味着单卡也能跑得动。这一点在你只有一块消费级显卡、又要一口气跑四个版本做对比时,体会会非常明显。

2.2 四个版本差在哪:深度、宽度和显存开销的权衡

这四个版本不是简单的“越大越好”,它们解决的是不同预算下的选型问题。DenseNet121 是最常用的基线,层数 121,特征重用最经济,显存占用最小,训练速度最快,适合第一轮跑通全流程;DenseNet161 的增长率更高,每层新产生的特征图更多,细粒度表达能力更强,但显存和计算量明显上升;DenseNet169 介于两者之间,是一个相对均衡的版本;DenseNet201 层数最深,适合数据量更大、类别更多、对精度要求更高的场景。

我把常见的选型判断整理成一张表,你可以直接按预算对号入座:

版本特点建议使用场景换数据集时的注意点
DenseNet121最轻量,训练快,显存友好第一轮基线实验、新手跑流程类别数改了要同步改分类层
DenseNet161增长率高,细粒度特征更强鸟品种这种细粒度分类任务显存吃紧时先把 batch size 减半
DenseNet169深度与特征宽度比较均衡需要折中精度和速度的实验学习率可以比 121 稍微调小
DenseNet201层数最深,表达上限高数据充足、追求最高精度迭代轮数不够时容易欠拟合

实际跑的时候,我一般先用 121 把整套数据和评估脚本跑通,确认没有路径、标签、显存问题,再换成 161 或 201 做正式对比。这样能避免在最大的模型上反复踩低级错误,也方便观察同一个优化器和学习率策略在不同深度下的稳定性差异。

2.3 预训练权重和冻结层,用代码拆开看

pretrained 和 freeze_layers 是这个项目里频率最高的两个参数,很多人第一次看到会搞混。pretrained 控制的是“模型初始权重从哪里来”,True 表示加载官方在 ImageNet 上训练好的权重,False 表示随机初始化;freeze_layers 控制的是“训练时哪些参数更新”,True 表示只训练分类输出层,False 表示所有层都参与更新。

这两个参数组合起来有四种情况,但真正有意义的组合是两种。pretrained=True 加 freeze_layers=True 是最省资源的迁移学习方式,相当于把 ImageNet 上学到的通用特征提取能力直接搬过来,只重新训练最后那层分类器,几百张图就能训出可用模型;pretrained=True 加 freeze_layers=False 是微调方式,所有层一起梯度更新,数据量中等的时候精度上限更高,但需要更小心地控制学习率,否则容易把预训练权重破坏掉。至于 pretrained=False 和 freeze_layers=True,这个组合没有意义,特征层不更新还是随机权重,等于只训了一个分类头,我不会建议任何人使用。

import torch import torch.nn as nn import torchvision.models as models model_name = 'densenet121' # 可选 densenet121 / 161 / 169 / 201 num_classes = 200 # 当前数据集的类别数 # torchvision 里的 DenseNet 构造入口,带官方预训练权重 model = getattr(models, model_name)(pretrained=True) # 替换最后的分类器,in_features 是原分类层输入维度 in_features = model.classifier.in_features model.classifier = nn.Linear(in_features, num_classes) # freeze_layers=True 时固定特征提取部分 freeze_layers = True if freeze_layers: for name, param in model.named_parameters(): # 'classifier' 开头的参数保留可训练状态 if not name.startswith('classifier'): param.requires_grad = False

代码的逻辑是先按名字构造对应版本,再把最后的全连接层换成指定类别数,最后通过参数名判断是否冻结。注意最后一步用的是分类层参数名前缀,DenseNet 的分类层在 torchvision 实现里叫 classifier,所以直接用 startswith 判断就行。如果你用的是自定义的 DenseNet 实现,分类层的命名可能是 fc 或 fc_out,需要按实际名称去改。

参数上要特别说明的是学习率。freeze_layers=True 时,模型里大部分参数不更新,只有分类头在学,这种情况下学习率可以适当放宽,因为要学的参数少、类间干扰也小;freeze_layers=False 时,所有层都在更新,预训练权重很容易被大学习率一把打乱,常见做法是把学习率调到原来的一半甚至三分之一,比如原来 1e-3,微调时降到 5e-4 或 3e-4。我自己的习惯是先把冻结版跑出来,确认分类头没问题,再解冻微调,这样即使后面指标异常,也能快速定位是特征层的问题还是分类头的问题。

3. 训练配置:Adam/SGD 双优化器、交叉熵和余弦退火各自的角色

一次成功的训练,网络结构只占一部分,优化器和学习率策略往往决定了最后指标能不能看。这套项目把这两块都做成了可切换配置,训练脚本里通过 if 语句判断优化器类型,损失函数固定用多类别交叉熵,学习率调度用余弦退火。下面逐个拆开讲,顺便说明每个参数在鸟分类任务里该怎么设、改了之后会有什么影响。

3.1 为什么同时内置 Adam 和 SGD:消融对照不是玄学,是基线

很多初学者觉得优化器选一个顺手用就行,但如果你是来做消融实验的,Adam 和 SGD 必须同时出现。理由很简单:不同优化器的收敛路径和最终落点都不同,单独用 Adam 跑出的结果不能代表模型上限,SGD 加合理动量往往在细粒度分类上能拿到更平滑的决策边界。

Adam 的优势在于自适应调节每个参数的学习率,免去了手动挑选学习率的麻烦,训练初期收敛非常快,尤其适合数据量不大、不想花太多时间调参的场景。SGD 没有自适应机制,需要自己配好学习率和动量,收敛慢,但最终精度通常更稳,很多图像分类竞赛的冠军方案最终选择的都是带动量的 SGD。所以正确用法不是“哪个好用用哪个”,而是两组都跑:先用 Adam 快速看数据和代码有没有问题、指标是否正常,再切到 SGD 把完整训练流程跑完,最后在实验记录里把两组对比都放上去。

优化器切换在代码里通常是这样实现的:

if optimizer_name == 'adam': optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) elif optimizer_name == 'sgd': optimizer = torch.optim.SGD(model.parameters(), lr=1e-2, momentum=0.9, weight_decay=1e-4) else: raise ValueError('optimizer_name must be adam or sgd')

逻辑上并不复杂,关键是参数差异。SGD 的学习率开头要给得比 Adam 大一到两个数量级,因为 SGD 每一步只沿着当前梯度走一步,lr 太小会寸步难行;而 Adam 自带近似二阶动量修正,lr=1e-3 对它已经是常见默认值。momentum=0.9 是图像分类里最常用的动量设置,它让参数更新方向带上历史梯度信息,减少来回震荡;weight_decay 两个优化器都建议加上,配合数据增强能有效防止过拟合。如果之后想加别的优化器,比如 AdamW,在这个 if 结构里再补一个分支就行,项目预留了扩展位,不用动整体框架。

3.2 多类别交叉熵:标签是整数索引,不是 one-hot

损失函数提到交叉熵,就绕不开标签形式这个坑。鸟品种分类是 200 个互斥类别,标签应该组织成整数索引,比如 0 到 199,对应每个品种在类别列表里的位置。放在 PyTorch 里,交叉熵损失的输入是模型输出的 200 维 logits 和真实标签的整数索引,不需要手动做 one-hot 编码。这个细节能避免一大批新手翻车。

另一个常见误区是把输出端最后一个线性层当成概率分布。多类别交叉熵内部会先对 logits 做 softmax 归一化,再按标签索引取值计算负对数似然,所以你传进去的应该是最后一个全连接层的原始输出,而不是转成概率后的结果。如果代码里手写了 softmax 再送入交叉熵,精度反而会受损,因为数值稳定性不如内置实现。训练脚本里一般会这样组织:

import torch.nn as nn criterion = nn.CrossEntropyLoss() # forward 阶段:model 输出的是原始 logits,shape 为 [batch, 200] logits = model(images) loss = criterion(logits, labels) # labels 是整数索引,shape 为 [batch]

labels 的数据类型要特别注意,PyTorch 的交叉熵要求它必须是 torch.long,如果数据集里标签是 str 或者 float,需要先做映射和类型转换。类别映射表建议单独存成一个 dict,训练和评估共用同一份,不然会出现训练时用第 57 类、评估时却按第 55 类算指标的错位问题。

3.3 余弦退火:让学习率先大步跑,再逐步落稳

训练早期模型离最优解远,需要大学习率快速探索;训练后期已经接近最优区域,学习率必须降下来才能精细收敛。余弦退火做的就是这件事:按剩余训练轮数控制学习率,使它沿着余弦曲线平滑下降,而不是像阶梯式下降那样突然跳变。对于 DenseNet 这种深模型,学习率如果中途突降,会把模型从一个局部最优硬生生推到另一个区域,前面的训练基本白费;余弦退火的平滑曲线则温和得多。

实际使用时,我会把初始学习率设为和优化器配套的值,并保证最大迭代次数和总 epoch 数匹配。如果 epoch 数设得很少,余弦曲线还没走完就截断了,学习率会一直停在偏大的区间,模型后期震荡降不下来,这会直接影响 loss 曲线的尾部形态。

3.4 一键运行:先改参数还是先看数据集目录

整个项目按 readme 操作可以一键训练,流程上先确认数据集路径和标签文件,再执行训练脚本。以我常用的启动方式为例:

# 训练 densenet121,使用 ImageNet 预训练权重且冻结特征层 python train.py --version densenet121 --pretrained True --freeze_layers True --batch_size 32 # 切换到 SGD,并把学习率改为 1e-2 python train.py --version densenet161 --optimizer sgd --lr 1e-2 --batch_size 16

batch_size 是显存管控的第一道闸门,8G 显存的卡跑 densenet121 可以设 32,densenet161 建议直接降到 16。如果中途显存溢出,优先减 batch_size,不要去动模型结构。数据集目录结构、图片命名规则和标签对应关系都在 readme 里写得比较清楚,头一次跑的时候建议先把数据路径打印出来看一眼,确认图像能正常解码、标签数量是 200 个类别,再开始训练,能省掉后面很多排查时间。

4. 评估不只看 accuracy:混淆矩阵、Precision、Recall、F1 和特异度怎么落地

训练结束之后,项目会在训练集和验证集上分别评估,输出 loss 和 accuracy 曲线,同时计算混淆矩阵、precision、recall、F1 score 和特异度。很多人在看结果时只盯着最后一个 accuracy,这是最亏的用法。多类别分类的 accuracy 会被大头类别主导,如果某些品种样本特别多,模型全猜它们也能拿到一个看似不错的分数,但实际对稀有品种几乎没分辨能力。这一章把每个指标到底在说什么、怎么从代码里把它们算出来、以及怎么用它们判断模型真实水平讲清楚。

4.1 训练集和验证集的 loss/accuracy 曲线,怎么读才不算白画

项目会同时输出训练集和验证集的 loss 曲线和 accuracy 曲线,两条曲线要对照着看。训练 loss 一直下降、验证 loss 也跟着下降,说明模型还在正常学习;训练 loss 下降但验证 loss 在某个点开始反弹,这是过拟合信号,说明模型开始把训练集里的细节背下来,泛化能力没有提升。这时候该做的是加数据增强、增大 weight_decay,或者提前停止。

验证准确率和训练准确率的差距也是一个重要观察点。两者差距很小,说明模型欠拟合,容量不够或者训练轮数不够;两者差距过大,说明过拟合,模型把训练样本记得太死。要命的是,很多项目跑完只留一张最好的验证 accuracy 就交差,完全没有中间过程记录。这套项目把曲线图像自动保存下来,就是让你在写实验结论时能说清楚“第几个 epoch 之后开始过拟合”,而不是只给一个孤零零的数字。

4.2 混淆矩阵和多类别指标的计算细节

混淆矩阵是理解错误类型的核心工具。它的每一行代表真实类别,每一列代表预测类别,对角线上的值就是分类正确的样本数。对多类别分类来说,直接把 200 类画在一张图里会看不清细节,所以常见的做法是先挑出混淆最严重的几十个类别单独看。如果你的鸟品种数据里存在外观极像的两类,比如两种都带黑色羽毛的椋鸟类,混淆矩阵一眼就能告诉你模型到底把它们分到了哪里,这比调参本身更能帮你改进数据质量。

precision、recall、F1 这些指标在多类别场景下有两种计算方式:宏平均和微平均。宏平均是每个类别先算出自己的指标再取平均,对稀有类别更敏感;微平均是把所有类别的预测结果汇总后再统一计算,它们加权方式不同。常见实现里会两者都输出,我一般重点看宏平均的 F1,因为鸟品种数据集天然存在样本不均衡,只靠 accuracy 很容易被骗。

from sklearn.metrics import confusion_matrix, classification_report # predictions 是模型在验证集上 argmax 后的整数结果 # true_labels 是对应的真实标签 cm = confusion_matrix(true_labels, predictions) # 输出按类别组织的 precision / recall / f1 report = classification_report(true_labels, predictions, digits=4) print(report) # 特异度可以基于混淆矩阵按类计算: # specificity = TN / (TN + FP),多类别场景下通常按每个类别的二分类视角来算

计算逻辑里要提醒一点:predictions 在送入 sklearn 之前一定要做 argmax 和类型对齐,很多指标异常都源于这里。比如 logits 没有去掉 batch 维度就去做 argmax,或者标签和预测的类别顺序不一致,都会让混淆矩阵看起来完全是乱的。另外特异度在多类别分类里并不像二分类那样直观,它本质上是在问“对每一个鸟品种,模型能不能正确排除其他品种”,实际做的时候按每个类别分别计算,再取平均输出。

5. 避坑现场:预训练组合、显存、类别不平衡和换数据集的五个问题

写代码跑模型,真正花时间的不是写训练循环,而是处理各种“看起来合理但结果不对”的状况。下面这五个问题是我在同类 DenseNet 分类项目里反复遇到的,全部按现象、原因、解决的顺序整理,你照着排查能省不少时间。

5.1 训练 loss 正常下降,验证 accuracy 一直卡在低位

现象:训练集 loss 一路走低,验证集 loss 也跟着降,但验证 accuracy 始终在一个很低的值附近波动,比如 200 类却只有不到 10% 的准确率,像是随机猜。

原因:最常见的是数据集目录里验证集和训练集的标签顺序不一致,或者标签文件读取时用了不同的索引映射。模型确实学到了特征,但预测结果和真实标签对不上号。

解决:把验证集里前几十张图的真实标签和预测结果逐条打印出来,直接用肉眼对一遍。检查类别映射 dict 是不是全局唯一,训练和评估是否各写了一份,两份一旦有偏移就会造成这种诡异现象。我一般会把映射表序列化保存,训练完评估时重新加载同一份,杜绝不一致。

5.2 densenet161 刚启动就报 CUDA out of memory

现象:换用 densenet161 或 densenet201 后,训练脚本还没跑完一个 batch 就报显存溢出,退回到 densenet121 又正常。

原因:DenseNet 的特征拼接机制会把中间特征图不断累积,深度版本的内存开销增长比参数数量增长更快,显存瓶颈主要在激活值存储,而不是权重。

解决:先按 5.1 的办法把 batch_size 降一半,如果一个 batch 都跑不动,就把图片输入尺寸从 256 缩到 224,或者开启梯度累积,用多个小 batch 的梯度叠加代替大 batch。不要一上来就换显卡,先把 batch_size 和图片尺寸这两项压到能跑,再逐步往上加。

5.3 pretrained 和 freeze_layers 组合不对,结果和随机初始化差不多

现象:明明用了预训练权重,最终精度却比随机初始化高不了多少,而且训练过程震荡严重。

原因:大概率是 pretrained=False 搭配 freeze_layers=True,特征提取层被冻结但权重是随机值,整个模型只学了一个分类头,最终效果自然很差。另一个可能性是直接加载了带 1000 类分类层的官方权重,但替换分类头时把前层的参数修改了,预训练权重实际被重置。

解决:先确认 pretrained=True,再检查模型加载后第一层卷积的权重是否和官方权重一致,最简单的办法是加载后打印模型参数量,或者保存几个参数值做对比,确保前向路径没有被意外重建。

5.4 accuracy 分数很高,但某几个鸟品种一个都没分对

现象:整体验证 accuracy 达到 85% 以上,但打开按类别的召回率报告,发现某些品种的 recall 是 0,模型一次都没正确识别过它们。

原因:典型的类别不平衡问题。少数品种在训练集里可能只有 5-10 张图,模型见都见得少,自然学不到稳定特征;在计算整体 accuracy 时它们又被大头类别掩盖。

解决:按类别统计样本数,对样本少的品种做复制增强,或者给交叉熵损失按类别频率加权重。实际项目中我建议把类别样本数打印成直方图,一眼就能看出哪些品种数据不足;如果这个品种确实是数据收集困难,至少要在报告里标出 recall 为 0 的类别,别让整体 accuracy 把问题糊弄过去。

5.5 换数据集之后训练 loss 变成 NaN

现象:按照 readme 换了自定义数据集,训练到第二三个 epoch 时 loss 突然变成 NaN,准确率直接归零。

原因:换了新数据集之后,标签里可能存在缺失类别,或者图片解码异常产生全黑图,导致模型输出极端 logits,再配合较大初始学习率,数值溢出。另一个常见原因是类别数改小了,但旧权重分类层错误地保留了原有尺寸,前向传播时维度不匹配却被某个包装层自动处理了。

解决:先检查标签是否从 0 连续编号到 N-1,中间有没有空洞;再确认分类层输出维度和 num_classes 一致;最后把初始学习率临时调小一个数量级,如果不再 NaN,再逐步恢复原来的学习率。跑新数据前强制走一遍这三项检查,能过滤掉大部分问题。

6. 训练完先别急着写结论,拿这四步验证模型可信度

模型训练完,loss 曲线平滑、accuracy 也到了预期值,这时候最忌直接开始写报告。我自己的习惯是抽十几分钟做一轮落地验证,确保数字不是偶然跑出来的,也确保换到新图片上模型真的能用。

第一步是检查重复性。固定随机种子,用同一份数据再跑一次,如果两次的验证 accuracy 差距超过 2 个百分点,说明结果有运气成分,需要加多几次重复取平均。DenseNet 本身是确定性的网络结构,差距主要来自数据加载顺序、随机增强和 dropout,固定 seed 之后变化会小很多。我一般会在脚本里固定三个 seed:Python 的 random、NumPy 的 random、PyTorch 的 CUDA seed,三处都固定才能保证训练过程基本可复现。

第二步是回到混淆矩阵里找错例。挑出验证集里被分错最多的十几个样本,把图片、真实标签、预测标签和置信度一起打印出来。如果是外观相近的品种互相混淆,可以接受;如果模型把一只明显特征完全不同的鸟分错了,而且置信度还很高,说明模型学到了某种和品种不直接相关的捷径,最常见的是背景颜色一致造成的误判。这一步看的是模型是不是在真正学鸟,而不是学场景。

第三步是手动喂几张训练时完全没见过的图,最好是下载的实拍照片,确认图像预处理方式一致。很多项目在训练时做了 crop、翻转和颜色抖动,推理时必须走同一套预处理管道,否则模型看到的是一个与训练分布完全不同的输入,结果自然没法看。代码里最好把预处理逻辑抽成一个函数,训练和推理共用,避免两边各写一份、写歪了还不知道。

第四步是导出模型前检查输入张量的维度约定。如果是单张图片预测,记得扩一个 batch 维度,shape 是 [1, 3, H, W] 而不是 [3, H, W];如果原图是 256 宽,不要为了省事用 224 的尺寸直接推理,图像缩放对细粒度分类的影响比很多人想象的大。从那以后,我每次做完一个 DenseNet 分类项目都会强制走一遍这四步,尤其是混淆矩阵里的错例和手动喂图这两步,基本每次都能揪出一两个数据问题,也让我少写了几次“效果很好”的空话。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询