news 2026/11/5 20:07:23

用PyTorch实现DenseNet多类别鸟种分类:原理、选型与微调实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用PyTorch实现DenseNet多类别鸟种分类:原理、选型与微调实战

简介:基于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约8M
DenseNet-16932[6, 12, 32, 32]1664约14M
DenseNet-20132[6, 12, 48, 32]1920约20M
DenseNet-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(brightness=0.3, contrast=0.3, saturation=0.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,少数类的梯度信号会被多数类淹没,训练出来的模型对少数类几乎全错。这时候要用WeightedRandomSampler:

from 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_samples=len(weights), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler, num_workers=4, shuffle=False)

weights按每张样本所属类别的频率倒数计算,少数类被采样到的概率更高。replacement=True表示同一张图可以在一轮里被采样多次,这是为了凑足num_samples的样本量。需要注意:用了sampler就不能再开shuffle=True,两者语义冲突,DataLoader会直接报错。另外,少数类被反复送进网络,如果数据增强不够强,反而会对少数类过拟合。所以用了采样器之后,建议把ColorJitter和RandomResizedCrop适当加强一点,让同一个样本每次看到的样子都不一样。

如果训练集本身是CUB-200这种每类样本量均衡的数据集,就不需要采样器,直接shuffle=True即可。千万别把采样器和均衡数据集一起用,那会把训练分布强行扭曲,反而降低多数类的精度。判断需不需要采样器,直接打印类别样本数分布,如果最大值和最小值相差超过5倍,就应该处理。

4. 用PyTorch跑通DenseNet121训练流程:模型替换、优化器与学习率

4.1 加载预训练权重并替换分类头

DenseNet的backbone部分在torchvision里叫features,最后接一个Linear分类器。加载方式建议用Torchvision的weights参数,不要自己下载权重文件再传路径,weights参数会自动处理权重来源和校验逻辑。替换分类头时有一个硬编码问题:四个版本的分类器输入维度不一样,121是1024,169是1664,161是2208,201是1920,所以代码里不能写死数字:

import torch import torch.nn as nn from torchvision import models num_classes = 200 # 你的鸟类类别数 model = models.densenet121(weights=models.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", transform=train_transform) val_dataset = datasets.ImageFolder("data/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=40) 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(f"Epoch {epoch + 1}: loss={train_loss / len(train_dataset):.4f}, val_acc={val_acc:.4f}")

三个关键参数说明:lr设成1e-4而不是1e-3,迁移学习里新分类头是随机初始化的,学习率太大会让刚开始的几步梯度把backbone冲乱;AdamW相比Adam多了解耦的权重衰减,配合weight_decay=1e-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再加载,或者用strict=False跳过不匹配层。我一般先替换再加载,这样分类头从零开始,backbone继承预训练特征。如果用了strict=False,务必确认跳过的是不是只有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个epoch,loss在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_layer="features.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跑了几十张图,发现模型盯着水面和树枝看。把包含这些背景的错样本加回训练集、重新微调之后,确认模型在看鸟的头部和翅膀才敢往外推。那以后我的习惯是:任何一版模型,先可视化再谈部署。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 20:07:23

Kalibr标定D435i完整实操:从环境搭建到相机-IMU联合标定

Kalibr 标定 D435i,这套流程我前后折腾了快两周才彻底跑通。网上教程不少,但大多零散,要么环境配置一笔带过,要么关键步骤含糊其辞,照着做很容易卡在某个莫名其妙的报错上。这篇东西我把整个链路重新捋了一遍&#xff…

作者头像 李华
网站建设 2026/10/27 13:36:27

LSTM时间序列预测实战:原理、Keras实现与5个常见坑

简介:这是一套基于LSTM的时间序列预测Python程序,专为需要完成课程设计、期末大作业或入门深度学习预测任务的学生和开发者准备。项目刻意简化使用门槛,代码带有详细注释,并配有使用说明手册与README文档,即使新手也能…

作者头像 李华
网站建设 2026/11/5 6:51:23

游戏反作弊工业级技术体系:从内存保护到行为图谱建模

1. 这不是“破解游戏”的速成课,而是一套真实存在的工业级技术体系你搜“游戏逆向工程”,首页弹出来的可能是“XX游戏修改器下载”“一键解锁VIP”“免ROOT修改金币”——这些和我要讲的,根本不在一个技术维度上。真正支撑起《原神》《王者荣…

作者头像 李华
网站建设 2026/10/30 22:16:24

游戏简报从零构建指南:结构设计、数据筛选与表达技巧

1. 当一份游戏简报只剩下一个日期,我们还能聊什么看到“阿尔蒂斯游戏简报 26.09”这个标题的时候,我第一反应是:这大概率是一份周期性更新的内部参考材料,或者某个游戏项目组的周报/月报。日期“26.09”可能是2026年9月&#xff0…

作者头像 李华
网站建设 2026/11/5 1:11:08

飞书CLI如何让AI Agent直接操控多维表格与机器人

飞书官方CLI上线55天就破万星这件事,放在AI Agent井喷的今年看,其实是个非常明确的信号:工具链正在从“人手工操作”转向“Agent直接调用”。以前我们聊飞书开放平台,默认姿势是打开开发者后台、看API文档、写回调、配权限&#x…

作者头像 李华