简介:这是一份基于DenseNet卷积神经网络家族(densenet121、161、169、201四种版本)实现的图像识别实战资源,面向有一定深度学习基础、希望掌握图像分类完整流程的开发者与学生。项目中已内置200种鸟图像约8000张数据及标签,可一键运行;主干网络的预训练权重加载与冻结层设置可通过pretrained和freeze_layers参数灵活切换,便于开展消融对比。优化器内置Adam与SGD,损失函数采用多类别交叉熵,学习率策略使用余弦退火算法,评估环节输出训练集和验证集的loss与准确率曲线,并附混淆矩阵、召回率、精确率、F1分数等指标。压缩包共2000个文件,其中1995个jpg图像、3个py脚本、1个txt与1个readme说明,整体约803MB,目录清晰;已吸引167人学习下载,适合用于分类项目实战、课程设计或算法对比研究。
1. DenseNet 多类别鸟品种分类实战:从四个预训练版本到消融实验
图像识别项目里,多类别细粒度分类一直比普通分类更考验网络能力。拿鸟品种识别来说,200 个类别、约 8000 张图,类间差异小——比如 Brewer_Blackbird 和 Red_Winged_Blackbird,区别往往只在翅膀上那一小块颜色,网络如果只靠最后几层高层语义特征,很容易混。DenseNet 这类密集连接网络恰恰擅长这事:每一层都拿前面所有层的特征做输入,浅层的纹理、颜色细节能直接传到分类层,不会在层层传递中丢信息。这份项目以 DenseNet121/161/169/201 四个版本为主干,覆盖了从加载预训练权重、冻结特征层、切换优化器到跑通混淆矩阵和 F1-score 的全流程。适合两类人:一是要做课程设计或毕业设计、需要一份能直接跑出训练曲线和评估指标的多分类项目;二是想对比 DenseNet 不同深度在同一数据集上表现、做消融实验的入门研究者。
2. DenseNet 网络家族:密集连接机制与四个版本怎么选
2.1 密集连接的核心逻辑:为什么梯度传得深还不易过拟合
DenseNet 和 ResNet 最大的区别在连接方式。ResNet 做的是恒等映射的残差相加,也就是每层输出是输入加上本层变换结果;DenseNet 则是把前面所有层的输出在通道维度上拼接起来,作为当前层的输入。假设网络有 L 层,传统网络是 L 条连接,DenseNet 是 L(L+1)/2 条。这个密集连接设计带来最直接的好处是梯度短路:反向传播时,损失函数的梯度可以从最后一层直接传到前面任意一层,不会因为网络深了出现梯度衰减。
从特征复用的角度看,DenseNet 每一层都只新增很少的新特征,比如增长率 growth rate 为 32 时,每层只输出 32 个新通道,剩下的全靠复用前几层的特征。这让网络参数大幅减少,同样精度下 DenseNet121 的参数量约为 ResNet50 的一半左右。对于鸟品种分类这种细粒度任务,还有一个隐性好处:浅层提取的边缘、纹理、颜色斑块细节,不会被深层抽象特征覆盖掉,分类层可以直接看到这些细节信息。
2.2 四个版本的差异:深度、增长率与计算量对比
DenseNet 家族四个版本不是简单地把层数翻倍,它们在 block 配置和增长率上各有取舍。densenet121 是 4 个 DenseBlock 的层数配比 [6, 12, 24, 16],densenet161 是 [6, 12, 36, 24] 且增长率提高到 48,densenet169 是 [6, 12, 32, 32] 增长率 32,densenet201 是 [6, 12, 48, 32] 增长率 32。层数越多,网络能捕获的语义层次越丰富,但计算量也随之增加。
实际选择时,得看你的硬件和训练时间。GTX 1060 6G 这种级别的卡,densenet121 和 densenet169 跑起来没压力,batch_size 可以设到 16 或 32;densenet201 勉强能跑,但训练时间明显拉长;densenet161 因为增长率是 48,中间特征图通道数膨胀得厉害,显存占用最高,小显卡很容易 OOM。我一般会用这张表做初筛:
| 模型 | 层数配置 | 增长率 | 参数量(约) | 适用场景 |
|---|---|---|---|---|
| densenet121 | 6-12-24-16 | 32 | 7.0M | 快速验证、小数据集、CPU 可跑 |
| densenet169 | 6-12-32-32 | 32 | 14.1M | 精度与速度折中 |
| densenet201 | 6-12-48-32 | 32 | 18.3M | 追求更高精度、显存充足 |
| densenet161 | 6-12-36-24 | 48 | 26.5M | 细粒度分类上限探索、大显存 |
2.3 预训练权重的作用:迁移学习在鸟分类中的实际收益
项目里 pretrained 参数控制是否载入 ImageNet 预训练权重。对鸟品种分类这种数据量只有几千张的任务,从零训练深度网络基本不可行,一是收敛极慢,二是很容易过拟合。载入 ImageNet 权重后,网络前面的层已经学会了通用的边缘、纹理、形状特征,你要做的只是微调后面几层,让它适配鸟品种的特定分布。
值得注意的是,ImageNet 里本身就有不少鸟类类别,预训练模型对羽毛、喙、翅膀这些特征已经有很强的响应,所以迁移效果通常比迁移到医学影像等完全不同的领域要好。项目里 freeze_layers 参数用来控制是否冻结特征层,如果只训练分类输出层,训练速度飞快,但精度上限受限于预训练特征的适配程度;如果要达到最佳效果,一般建议前几十个 epoch 冻结特征层,后面解冻整个网络做全局微调。
3. 数据准备与加载:200 类鸟图像怎么组织才能让 PyTorch 高效读取
3.1 数据目录结构与标签映射逻辑
这份项目的数据集包含约 8000 张图像,覆盖 200 个鸟品种,每张图像的文件名格式是类似 Rock_Wren_0071_189213.jpg 这样——品种名在前,中间是编号。这里需要注意一个细节:文件名里的品种名直接对应标签,但 0071 不是类别 ID,而是该品种内部的样本编号。项目里已经准备好的标签文件或者目录结构,会自动完成从文件名到类别索引的映射。
如果你要换自己的数据集,目录结构需要满足 PyTorch 的 ImageFolder 约定:根目录下每个子文件夹是一个类别,子文件夹名就是类别名,里面放着该类别的所有图像。这样 DataLoader 会自动给每个类别分配从 0 开始的索引,顺序取决于文件夹名的字母排序。
3.2 DataLoader 配置:batch、shuffle 与 num_workers 的合理取值
数据加载部分我一般会关注四个参数:
from torch.utils.data import DataLoader from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder('./data/train', transform=train_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True)参数说明:RandomResizedCrop 会随机裁剪图像的一部分并缩放到 224×224,相当于做了尺度扰动,对鸟这种主体大小不固定的场景很有效。HorizontalFlip 是水平翻转,注意鸟的朝向左右翻转不影响类别判断,可以放心用。ColorJitter 调节亮度对比度,应对不同光照条件下拍摄的鸟图。Normalize 用的 mean 和 std 必须和预训练权重保持一致,因为 ImageNet 预训练模型是在这个标准化分布下收敛的。
num_workers 在 Windows 上设 4 以上偶尔会报 DataLoader worker 错误,Linux 上可以设到 CPU 核心数。batch_size 看显存,densenet121 在 8G 显存下 32 没问题,densenet161 建议降到 8 或 16,否则容易 CUDA out of memory。
3.3 数据增强策略:针对细粒度分类的增强组合
细粒度分类的数据增强有个原则:不要破坏判别性细节。像鸟品种分类,喙的形状、翅膀斑纹的颜色分布是最关键的判别特征,所以增强手段要避免过度扭曲。我见过有人直接上 RandAugment 把所有操作强度拉到最大,结果训练集精度很高、验证集精度上不去,这就是增强过度了,模型学到的特征被噪声淹没。
这个项目里的增强策略比较克制,RandomResizedCrop 加 HorizontalFlip 加 ColorJitter 是基础组合。我更推荐在此基础上加一个 RandomRotation(10),只旋转 10 度以内,模拟拍摄角度轻微偏移,同时不会把鸟的姿态扭曲到失真的程度。测试集的 transform 不要加任何随机增强,只需要 Resize 到 256、CenterCrop 到 224、ToTensor、Normalize。
4. 训练策略配置:预训练权重、优化器切换与余弦退火
4.1 pretrained 与 freeze_layers 的组合逻辑
项目代码里 pretrained 和 freeze_layers 两个参数是配合使用的,理解它们的组合关系是跑通实验的关键:
import torch import torch.nn as nn from torchvision import models def get_model(model_name, num_classes=200, pretrained=True, freeze_layers=True): if model_name == 'densenet121': model = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1 if pretrained else None) elif model_name == 'densenet161': model = models.densenet161(weights=models.DenseNet161_Weights.IMAGENET1K_V1 if pretrained else None) elif model_name == 'densenet169': model = models.densenet169(weights=models.DenseNet169_Weights.IMAGENET1K_V1 if pretrained else None) elif model_name == 'densenet201': model = models.densenet201(weights=models.DenseNet201_Weights.IMAGENET1K_V1 if pretrained else None) # 替换分类头,原来 ImageNet 是 1000 类,现在改成 200 类 in_features = model.classifier.in_features model.classifier = nn.Linear(in_features, num_classes) if freeze_layers: for param in model.features.parameters(): param.requires_grad = False # 只训练分类头 for param in model.classifier.parameters(): param.requires_grad = True return model逻辑说明:pretrained 控制权重的来源,weights 参数传入 None 时就是随机初始化;freeze_layers 为 True 时,把所有特征提取层的 requires_grad 设为 False,只有分类头在更新。注意一个容易踩的坑:替换 classifier 后,如果 freeze_layers 为 False,整个网络都在训练,需要更小的学习率;如果为 True,只训练一层 Linear,可以用较大的学习率。
4.2 Adam 与 SGD 的消融对比设计
项目中优化器通过 if 语句切换,支持 Adam 和 SGD 两种。做消融实验的时候,二者除了算法本身不同,学习率的设置逻辑也要调整。Adam 自带自适应学习率,初始学习率通常设 1e-3 到 1e-4;SGD 需要手动设置动量,学习率一般设 1e-2 到 1e-3,配合 momentum=0.9、weight_decay=1e-4。
def get_optimizer(model, optimizer_name='adam', lr=1e-3): if optimizer_name.lower() == 'adam': optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4) elif optimizer_name.lower() == 'sgd': optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=1e-4) else: raise ValueError(f"Unsupported optimizer: {optimizer_name}") return optimizer实际观察到的现象是:Adam 前期收敛快,验证集 loss 降得猛,但后期精度容易在某个值附近震荡;SGD 前期慢,但配合余弦退火能稳步爬到更高精度。如果只跑少量 epoch 做快速验证,用 Adam;如果要刷最终的准确率指标,SGD 加余弦退火更稳。
4.3 余弦退火学习率:让 loss 震荡幅度收窄
余弦退火的核心思路是让学习率按照余弦函数周期性地从最大值衰减到最小值。PyTorch 里对应的实现是: ```python import math from torch.optim.lr_scheduler import CosineAnnealingLR # T_max 是一个周期内的迭代次数,eta_min 是学习率下限 scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6) for epoch in range(epochs): train_one_epoch() validate() scheduler.step()参数说明:T_max 通常设为总 epoch 数,这样整个训练过程学习率从初始值平滑下降到 eta_min。如果把 T_max 设为总 epoch 数的一半,相当于训练过程中经历两个完整的余弦周期,每个周期结束后学习率回升一次,有利于跳出局部极小点。
4.4 分类头初始化:替换 Linear 后如果不初始化会怎样
替换分类头后,新的 Linear 层默认是 PyTorch 的 Kaiming 均匀初始化,而预训练的分类头已经被训练成适配 ImageNet 1000 类的分布。这里有个隐藏问题:如果 freeze_layers 为 True,只训练新的分类头,那么分类头的初始化质量直接决定了初始 loss 的大小。我一般会把新的 Linear 层做一次 Xavier 初始化,让输出分布更平稳,初始 loss 能下降更快。
def init_classifier(model): for m in model.modules(): if isinstance(m, nn.Linear) and m.out_features == 200: nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias)其实不做这步也能跑,只是训练初期 loss 会先有一个明显的下降过程,看起来像模型在"热身"。做了初始化后,第一个 epoch 的 loss 就会落入正常范围。对于做实验记录来说,这一步能让对比实验的初始状态更一致。
4.5 类别不平衡问题:200 类每类只有几十张图
8000 张图分到 200 类,平均每类只有 40 张,这对训练集和验证集的划分提出了很高要求。如果随机划分,很容易出现某个类别在训练集里有 35 张、验证集里只有 5 张的情况,评估指标波动很大。这类项目我一般建议按类别分层划分:每个类别内部按 8:2 或 7:3 切分,保证验证集覆盖所有类别。如果某些类别本身只有十几张图,三张五张地分可能不够,这时候就用 K 折交叉验证,或者退一步只报告 top-1 和 top-5 准确率,避免单类别的 precision 值忽高忽低。
5. 避坑指南:DenseNet 训练中三个最常翻车的环节
5.1 ImageNet 预训练权重无法下载或下载超时
现象是程序跑到一半报错,提示连接超时或者 SSL 证书错误。
原因是 torchvision 的 weights 参数会尝试从官方 URL 下载权重文件,国内网络环境下经常连接不稳定。
解决办法是先手动下载 .pth 文件放到本地,再用 load_state_dict 载入。具体做法是先用浏览器或下载工具把权重文件存到项目目录下,然后在代码里指定本地路径:
import torch from torchvision import models model = models.densenet121(weights=None) state_dict = torch.load('./weights/densenet121-a639ec97.pth') model.load_state_dict(state_dict)5.2 densenet161 在 8G 以下显卡直接 CUDA out of memory
训练一开始报错,提示 CUDA out of memory,连第一个 iteration 都跑不完。
原因是 densenet161 增长率是 48,中间特征拼接后的通道数远高于其他版本,显存占用成倍增加。
解决办法有几个方向:batch_size 从 32 降到 8,同时把图像输入从 224 缩到 192 或 160;或者改用梯度累积,每 4 个 batch 更新一次梯度。如果只是想做对比实验而硬件有限,建议跳过 densenet161,它在 200 类鸟数据上不一定比 densenet201 高多少。
5.3 验证集精度卡在某个值上不去,怀疑模型结构有问题
训练 loss 持续下降但验证集准确率连续几十个 epoch 没有提升,看起来像过拟合,但用了 weight_decay 也没用。
原因是学习率卡在了某个平台期,SGD 的动量不足以翻越当前的损失曲面瓶颈。
解决办法是检查 learning rate 的曲线,如果已经衰减到 eta_min 附近,就把 T_max 缩短,让学习率周期性回升;或者手动把学习率调回初始值的十分之一再训练几十个 epoch。
5.4 冻结特征层后 loss 下降很快但精度不高
freeze_layers=True 时,训练损失降得很快,验证集精度却只有 70% 左右,解冻后整个网络微调,精度才明显上升。
原因是冻结特征层时,分类头只能基于 ImageNet 预训练特征的原始输出做线性分类,而这些特征不是针对鸟品种细粒度差异优化的。
解决办法是在训练后期设置 freeze_layers=False,用一个更小的学习率如 1e-5 继续微调整个网络。常见做法是先用冻结模式快速训练分类头 30 个 epoch,再解冻所有层训练 50 个 epoch,这样兼顾了收敛速度和最终精度。
6. 评估指标与进阶玩法:混淆矩阵、F1-score 和换数据集训练
6.1 评估脚本的核心输出:混淆矩阵与各类别指标的可视化
项目在训练集和验证集上分别输出 loss 和 accuracy 曲线,同时生成混淆矩阵、precision、recall、F1-score 和特异度指标。混淆矩阵对细粒度分类的价值在于能直观看到哪些类别容易混淆。比如在 200 类鸟数据上,混淆矩阵的非对角线元素通常会集中在亲缘关系近的品种之间,这部分信息直接指向数据集的标注质量或类别定义的模糊性。
评估脚本里的核心逻辑是这样的:
from sklearn.metrics import confusion_matrix, classification_report import matplotlib.pyplot as plt import numpy as np def evaluate_model(model, val_loader, class_names, device): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for inputs, labels in val_loader: inputs = inputs.to(device) labels = labels.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) # 混淆矩阵 cm = confusion_matrix(all_labels, all_preds) # 每个类别的 precision、recall、F1 report = classification_report(all_labels, all_preds, target_names=class_names, output_dict=True) # 绘制混淆矩阵热力图 fig, ax = plt.subplots(figsize=(20, 20)) im = ax.imshow(cm, cmap='Blues') ax.set_xticks(np.arange(len(class_names)), class_names, rotation=90) ax.set_yticks(np.arange(len(class_names)), class_names) plt.colorbar(im) plt.tight_layout() plt.savefig('./outputs/confusion_matrix.png', dpi=150)这段代码的逻辑是先收集所有预测和真实标签,然后用 sklearn 一次性算出混淆矩阵和分类报告。需要注意一个 Bug 隐患:200 个类别的混淆矩阵是 200×200,如果直接完整画出来,每个格子太小根本看不清。我一般会做两个版本,一个是全量矩阵,另一个只提取最容易混淆的前 30 对类别,单独画一张局部混淆图,这样能快速定位误判模式。
特异度在二分类里比较直观,多分类场景下通常做 macro 平均。每个类别的特异度是把当前类别当正类、其余全部当负类来算,如果某些类别样本数太少,特异度会异常高,参考价值有限。
6.2 F1-score 在类别不平衡数据下的解读方式
项目里同样输出了 macro 和 weighted 的 F1-score,这对 200 类鸟数据非常重要。由于每类的样本数不同,如果只盯 accuracy,可能出现某个类别占比较高、模型忽略小类别也能拿到不错准确率的情况。F1-score 的使用要注意聚类和算术差异的区别——当类别不均匀时,macro 平均会把所有类别一视同仁地加权,这能放大少数类的影响;weighted 平均则按样本数加权,和大类别的表现直接影响的结果。从运行效果看,如果模型没有处理好少数类,可以观察到 macro 平均的 F1 会明显低于 weighted 平均的 F1,这种差距本身就是一个可分析的信号。
6.3 更换自己的数据集:训练脚本需要改哪些参数
项目 README 提到了更换数据集的流程,实操里最关键是三类修改:数据目录结构需要按 ImageFolder 约定整理成 train/ 和 val/ 两个根目录,每个类别一个子文件夹;num_classes 参数需要改成自己的类别数,这会同时影响模型分类头的输出维度和评估脚本的 class_names 列表;另外如果图像尺寸不是标准的方形,而模型输入固定是 224×224,RandomResizedCrop 和 CenterCrop 会自动处理,但如果图像宽高比差异非常大,建议先统一 Resize 到 256×256 再裁剪,否则形变会严重影响分类效果。
还有个容易忽略的点:标签映射。项目原本用的是文件名解析标签,换成自定义数据集后,标签完全由文件夹名字决定,需要检查文件夹名是否有空格、特殊字符,这些会在 ImageFolder 创建类别映射时引发索引错位。
6.4 进阶:把 DenseNet 换成其他模型做横向对比
核心出图逻辑是:主干网络是 DenseNet 家族的四个版本,想让项目更丰富,最简单的做法是在 get_model 里增加对 resnet50、efficientnet_b0 等模型的支持。模型结构设计时,resnet50 参数量适中、对显存要求不高,适合跑基准值,efficientnet_b0 则在计算量和精度之间平衡得好,可以作为额外的对比基线。
from torchvision import models as tv_models def get_model(model_name, num_classes=200, pretrained=True): if model_name.startswith('densenet'): # 原有 DenseNet 逻辑 ... elif model_name == 'resnet50': model = tv_models.resnet50(weights=tv_models.ResNet50_Weights.IMAGENET1K_V1 if pretrained else None) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) elif model_name == 'efficientnet_b0': model = tv_models.efficientnet_b0(weights=tv_models.EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None) in_features = model.classifier[1].in_features model.classifier = nn.Linear(in_features, num_classes) return model这样改完之后,消融实验的维度就多了一个:同一数据集、同一训练配置下的 DenseNet 家族对比,以及 DenseNet121 与 ResNet50、EfficientNet 的跨架构对比。评估脚本不用动,loss 曲线、混淆矩阵、F1-score 这些输出都是通用的。
从那以后我每次跑多分类项目都会先跑通一个 DenseNet121 加冻结特征层的快速基线,再决定要不要上更深的版本或解冻微调。这套流程看起来多花了一两天,但换来的是对数据和模型匹配度的清晰把握——希望帮到你。
本文还有配套的精品资源,点击获取