简介:基于Pytorch实现DenseNet的完整项目源码,面向希望系统掌握经典卷积神经网络结构与Pytorch工程实现的开发者、研究人员及深度学习初学者。项目围绕DenseNet的核心机制展开,覆盖稠密块、过渡层、增长率与瓶颈层等关键设计,并配有CIFAR-10均值计算、模型结构可视化、训练曲线绘制等脚本,可帮助读者从零完成模型搭建、训练与评估。压缩包共14个文件,以7个Python源码文件为主,同时含6张模型结构、误差收敛及训练日志相关图片和1份Markdown说明文档,整体大小约2.87MB,结构紧凑,便于直接参考运行。目前已有336人学习下载,实战导向明确。通过阅读源码并运行训练脚本,既能深入理解DenseNet密集连接如何缓解梯度消失、促进特征复用,也能掌握Pytorch中数据预处理、损失函数与优化器配置、模型保存加载及训练可视化等完整流程,适合作为课程设计、科研复现或项目实战的参考资料。
1. 第一次跑通DenseNet的人,大概率会盯着参数量发呆
同样是CIFAR-10分类,ResNet-110要用170万参数,而DenseNet-BC-100只用不到80万参数,准确率反而更高。这就是DenseNet最反直觉的地方:它不靠加深加宽,而是靠“把每一层都接到所有后续层的输入上”这种密集连接,让特征被反复利用。2017年Gao Huang等人提出这个结构时,直接刷新了多个图像识别榜单。这个基于Pytorch的DenseNet项目实战源码,把densenet.py、train.py、compute-cifar10-mean.py、plot.py甚至训练失败时的loss曲线图都整理好了,适合两类人:一类是想搞懂DenseNet内部连接机制、准备在论文或项目里拿它当backbone的学生和算法工程师;另一类是已经会搭ResNet、想对比“残差连接”和“密集连接”在梯度流、特征复用上到底差在哪的Pytorch用户。下面从结构原理开始,一步步把这份源码拆开跑通。
2. 从Dense Block到Transition:动手前先吃透DenseNet的构造块与增长率
2.1 Dense Block里的“密集”到底是怎么连的
DenseNet的基本单元不是单个卷积层,而是Dense Block。在一个Dense Block内部,第l层的输入不是上一层输出,而是前面所有层输出的拼接(concat)。用公式表示就是x_l = H_l([x_0, x_1, ..., x_{l-1}]),这里的方括号是通道维度的拼接。
这个设计直接改变了梯度传播路径。以反向传播为例,损失对某一层权重的梯度,不再像普通CNN那样要逐层回传经过很多中间节点,而是存在从loss直达该层的“短路”。这就是为什么DenseNet可以有效缓解梯度消失。项目里的densenet.py把这种连接实现得很干净,核心就是用一个nn.ModuleList保存所有层,然后前向时把每一层输出都torch.cat进一个列表,再传给下一层。
# densenet.py 中 DenseBlock 的核心逻辑(简化) class DenseBlock(nn.Module): def __init__(self, num_layers, in_channels, growth_rate, bn_size): super().__init__() self.layers = nn.ModuleList() for i in range(num_layers): # 每个子层是一个 Bottleneck:1x1 + 3x3 卷积 self.layers.append( Bottleneck(in_channels + i * growth_rate, growth_rate, bn_size) ) def forward(self, x): features = [x] for layer in self.layers: # 把前面所有层的输出拼接后作为当前层输入 new_features = layer(torch.cat(features, dim=1)) features.append(new_features) return torch.cat(features, dim=1)这里的in_channels + i * growth_rate是通道数推导:每经过一个子层,通道数增加一个growth_rate,所以第i个子层输入通道就是初始通道数加上前i个子层贡献的通道数。torch.cat(features, dim=1)表示在通道维上拼接。需要注意,features列表里保留的是每一层的输出张量,而不是中间结果,这正是“密集”的代价:显存占用会随层数增长,后面会讲怎么用DenseNet-BC缓解。
2.2 Transition Layer为什么必须用1x1卷积压缩通道
Dense Block之间靠Transition Layer连接。它的作用有两个:一是用1x1卷积把通道数降下来,二是用平均池化缩小特征图尺寸。如果不用1x1做通道压缩,密集连接会让通道数爆炸式增长,网络的参数和计算量会失去控制。
项目里的TransitionLayer实现如下:
# densenet.py 中 Transition 的实现 class Transition(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.norm = nn.BatchNorm2d(in_channels) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.pool = nn.AvgPool2d(kernel_size=2, stride=2) def forward(self, x): x = self.norm(x) x = F.relu(x) x = self.conv(x) x = self.pool(x) return xout_channels通常是输入通道数乘一个压缩系数theta,原始论文取0.5,也就是压缩到一半。在DenseNet构造函数里,你可以看到类似out_channels = int(in_channels * theta)的写法。这里的AvgPool2d(kernel_size=2, stride=2)会把特征图高宽各缩小一半,配合通道压缩,整个网络的参数规模就在可控范围内。
从工程角度说,Transition里的顺序是BN -> ReLU -> Conv1x1 -> AvgPool,这个顺序和普通CNN的Conv->BN->ReLU不一样,原因在于Transition的前置操作。每个Dense Block内部子层已经做过BN和ReLU,Transition接收的是已经激活过的特征,所以需要再做一次BN和ReLU后再压缩。
2.3 Growth Rate和Bottleneck:两个参数决定模型容量
Growth Rate(增长率)是DenseNet最敏感的旋钮。它表示每个子层新增的通道数,源码里通常叫growth_rate。这个值越大,每一层输出的特征越多,网络表达能力越强,但参数和显存也涨得越快。项目默认的DenseNet-BC-100用的就是12,这是一个被反复验证的保守值。
Bottleneck层是另一个关键。原始DenseNet每个子层是BN-ReLU-Conv(3x3),而DenseNet-BC版本把子层改成BN-ReLU-Conv(1x1)-BN-ReLU-Conv(3x3),其中1x1卷积把通道数压缩到bn_size * growth_rate。这里的bn_size论文里取4,意味着1x1卷积输出的特征通道数是增长率的4倍,然后3x3卷积再在这个较小的通道数上做运算。
# densenet.py 中 Bottleneck 的结构 class Bottleneck(nn.Module): def __init__(self, in_channels, growth_rate, bn_size): super().__init__() # 1x1 卷积先降通道,减少 3x3 卷积的计算量 self.bn1 = nn.BatchNorm2d(in_channels) self.conv1 = nn.Conv2d(in_channels, bn_size * growth_rate, kernel_size=1, stride=1, bias=False) self.bn2 = nn.BatchNorm2d(bn_size * growth_rate) self.conv2 = nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, stride=1, padding=1, bias=False) def forward(self, x): out = F.relu(self.bn1(x)) out = self.conv1(out) out = F.relu(self.bn2(out)) out = self.conv2(out) return out从计算量角度理解bn_size:如果没有1x1卷积,3x3卷积的输入通道就是前面所有层通道之和,比如第8层可能已经积累了96个通道,直接在96个通道上做3x3卷积计算量很大。加了1x1后,先把96维压到48维(bn_size * growth_rate = 4 * 12),再做3x3,参数和FLOPs都能省下一截。这也是DenseNet-BC(Bottleneck + Compression)比普通DenseNet更省参数的原因。
这一节的选型逻辑放在Pytorch里同样适用:如果显存紧张,优先调小growth_rate而不是删层数;如果想提精度,先加num_layers(Dense Block数量)再考虑加大growth_rate。这个顺序在后续训练时能看到明显差异。
3. Pytorch实现DenseNet核心模块:从零写densenet.py
3.1 先搭Bottleneck和DenseBlock的两个基础类
现在直接看项目里的densenet.py,你会发现整个文件不到200行,核心就是上面拆开的三个类:Bottleneck、DenseBlock、Transition,外加一个组装用的DenseNet主类。写的时候注意Pytorch的nn.ModuleList和nn.Sequential的区别:DenseBlock里必须用ModuleList,因为每一层输入依赖前面所有层输出,不能用Sequential线性堆叠;而Transition之间可用Sequential。
实际编码时容易踩一个坑:Bottleneck和Transition里的bias=False是必须的,因为后面紧跟BatchNorm2d,BN层自带可学习的gamma和beta,卷积层的偏置会被BN吸收,留着只会增加参数且影响数值稳定性。在Pytorch中,如果你在Conv2d里忘了设bias=False,模型仍然能跑,但参数量会虚高,打印模型结构时对比官方实现也能发现差异。
3.2 Transition层与整个网络的组装
DenseNet主类负责把多个DenseBlock和Transition串起来,并控制每个Block之前的通道数变化。下面这段是项目构造函数的关键部分:
# densenet.py 中 DenseNet 主类的构造(局部) class DenseNet(nn.Module): def __init__(self, growth_rate=12, block_config=(6, 12, 24, 16), num_init_features=64, bn_size=4, num_classes=10): super().__init__() # 初始卷积层,CIFAR-10 输入 32x32,这里不做下采样 self.conv1 = nn.Conv2d(3, num_init_features, kernel_size=3, stride=1, padding=1, bias=False) self.features = nn.ModuleList() num_features = num_init_features for i, num_layers in enumerate(block_config): # 每次进入 DenseBlock 前先做一次 BN-ReLU self.features.append(DenseBlock(num_layers, num_features, growth_rate, bn_size)) num_features = num_features + num_layers * growth_rate if i != len(block_config) - 1: # 最后一个 Block 后不加 Transition trans = Transition(num_features, int(num_features * 0.5)) self.features.append(trans) num_features = int(num_features * 0.5) self.bn_final = nn.BatchNorm2d(num_features) self.classifier = nn.Linear(num_features, num_classes) def forward(self, x): x = self.conv1(x) for layer in self.features: x = layer(x) x = F.relu(self.bn_final(x)) x = F.adaptive_avg_pool2d(x, (1, 1)) x = torch.flatten(x, 1) x = self.classifier(x) return xblock_config默认是(6, 12, 24, 16),对应DenseNet-121在ImageNet上的配置,但项目里跑CIFAR-10会改成更小的(6, 12, 24, 16)或(6, 12, 32, 32),需要自己在训练前调整。注意i != len(block_config) - 1这个条件:最后一个DenseBlock后面不加Transition,直接进最终BN和全局池化。这是DenseNet的标准做法,因为最后一个Block输出的特征图要尽可能保留空间信息,再下采样会丢掉太多细节。
组装完之后,用densenet.py附带的测试代码可以打印出每一层输出张量的形状。推荐顺手跑一下python densenet.py,如果看到类似[1, 64, 32, 32] -> [1, 136, 32, 32]的层级输出,说明Block之间的通道递增逻辑是对的。如果通道数没有按growth_rate递增,多半是num_features在循环里忘了更新。
3.3 用项目里的make_graph.py可视化网络结构
这份资源里有个make_graph.py,它的作用是借助torchviz或graphviz把模型结构画成图。在Pytorch中可视化模型结构,一般用torchviz.make_dot:
# make_graph.py 的典型用法(项目源码内) from torchviz import make_dot from densenet import DenseNet model = DenseNet(growth_rate=12, block_config=(6, 12, 24, 16), num_classes=10) dummy_input = torch.randn(1, 3, 32, 32) output = model(dummy_input) graph = make_dot(output, params=dict(model.named_parameters())) graph.render("densenet_graph", format="png")运行后生成的densenet_graph.png会非常大,因为DenseNet的密集连接在计算图里会表现为大量连线。一个实用建议是只可视化单个DenseBlock而不是整个网络,否则生成的图片打开会卡。你可以把block_config改成(2,)这样的小配置再看。这个图能看到梯度的传播路径,尤其适合解释为什么DenseNet不会梯度消失:图中从输出到早期层会有很多接近直线的路径。
4. 训练实战:CIFAR-10从数据预处理到SGD/Adam收敛对比
4.1 数据预处理和compute-cifar10-mean.py的使用
CIFAR-10是这份源码默认的数据集。训练前要先做两件事:下载数据、计算每个通道的均值标准差。项目里的compute-cifar10-mean.py就是干这个的:
python compute-cifar10-mean.py这个脚本会加载CIFAR-10训练集,然后输出类似(0.4914, 0.4822, 0.4465)和(0.2470, 0.2435, 0.2616)这样的均值和标准差。Pytorch里做数据归一化时,transforms.Normalize的参数必须来自训练集统计量,而不能直接用ImageNet的mean=[0.485, 0.456, 0.406],否则模型收敛会变慢,甚至出现bad-convergence.png里那种loss曲线反复横跳的情况。
拿到均值后,train.py里的数据增强流程一般是:
# train.py 中的数据预处理与增强 transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ])RandomCrop(32, padding=4)先在32x32图像周围补4像素的0,再随机裁剪回32x32,本质上是一种平移增强。这种增强对DenseNet尤其重要,因为密集连接会让网络对“特征出现在哪个位置”更敏感,做点平移能让模型学到更鲁棒的空间特征。随机水平翻转也是CIFAR-10的标准增强手段,不能省。
4.2 train.py里的损失函数、优化器与学习率调度
项目train.py里的训练循环结构很标准,但有两个细节值得注意。第一,损失函数用交叉熵,Pytorch里nn.CrossEntropyLoss已经自带softmax,所以模型最后一层不需要再手动加softmax。第二,优化器选SGD还是Adam会对DenseNet收敛产生明显影响,这一点项目里用两组loss曲线专门做了对比。
# train.py 中训练循环的关键片段 criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.MultiStepLR( optimizer, milestones=[150, 225], gamma=0.1) for epoch in range(300): model.train() for inputs, targets in trainloader: outputs = model(inputs) loss = criterion(outputs, targets) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()这里lr=0.1配合momentum=0.9是DenseNet论文里的配置,但如果你直接拿Adam跑同样的300轮,效果会很差。项目里的adam-loss-error.png和sgd-loss-error.png清楚地展示了这一点:SGD在前150轮稳定下降,而Adam在初始阶段下降快,后期loss会在一个高位震荡。原因是DenseNet的密集连接导致不同层梯度尺度差异大,Adam的自适应学习率在后期容易产生不稳定的更新。所以如果你要用Adam,建议把初始学习率降到0.001并且配合weight_decay调大,但即便如此,SGD在这个结构上依然是更稳妥的选择。
MultiStepLR的milestones=[150, 225]意思是第150轮和第225轮把学习率乘以0.1。这几乎是CIFAR-10上所有ResNet/DenseNet训练的标准节奏。学习率调度对DenseNet特别重要,因为密集连接让网络后期基本上是在微调特征组合,如果学习率一直很高,loss曲线就会像bad-convergence.png那样出现周期性抖动。
4.3 项目自带的adam-loss-error和sgd-loss-error曲线说明什么
打开项目里的adam-loss-error.png和sgd-loss-error.png,能看到两条完全不同风格的曲线。SGD那张图,训练loss和验证error在前100轮平滑下降,中间有小幅波动,150轮学习率降低后验证error会有一个明显的断崖式下降。Adam那张图,头10轮下降极快,验证error一度优于SGD,但30轮后开始停滞,验证error在某个区间反复震荡。
这个现象的技术解释是:Adam的每个参数都维护了一阶和二阶矩估计,对于DenseNet这种共享特征较多的结构,某些层的梯度矩估计会被频繁更新的特征通道“污染”,导致后期更新方向偏离真实梯度。而SGD配合momentum,只保留一个全局动量,更新方向更稳定。
实操上的建议是:如果你只是想快速看DenseNet在某个数据集上跑不跑得通,用Adam跑50轮就够了;但要追求论文级的精度,必须换SGD+动量+分段学习率衰减。项目里这两种曲线都画出来了,就是提醒你不要拿着默认的Adam配置直接训300轮。
4.4 训练中bad-convergence.png暴露的常见踩坑
bad-convergence.png是项目里一张“反面教材”图,它展示的典型症状是loss在前10轮正常下降,之后突然变成nan,或者准确率一直徘徊在10%左右。对应到代码里,最可能的原因是学习率过大加上没有做梯度裁剪。DenseNet的初始卷积层conv1直接在3通道输入上提取特征,如果lr=0.1而数据没有归一化,那么初始层的权重更新步长可能让激活值爆炸。
另一个常见坑是BatchNorm的track_running_stats在训练和评估模式下的行为差异。model.train()时BN用当前batch的统计量,model.eval()时用累计的running_mean和running_var。如果你的验证循环忘了写model.eval(),验证loss会像bad-convergence.png那样忽高忽低,但训练loss正常。
还有一种情况是类别不平衡,但CIFAR-10是均衡数据集,所以重点还是检查优化器和学习率。如果你复现时遇到loss不降,先跑一遍项目里的plot.py看梯度范数:
# 在训练循环里手动打印梯度范数 total_norm = 0.0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.data.norm(2) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 print(f"Epoch {epoch} grad_norm: {total_norm:.3f}")如果grad_norm在某个epoch后超过100,基本就是梯度爆炸了。这时先确认输入数据是否归一化,再看学习率,最后才考虑模型结构。DenseNet本身有稠密短路,理论上比普通VGG更不容易梯度消失,但梯度爆炸问题依然存在,尤其是网络深到100层以上时。
5. 进阶:加载预训练权重、特征图可视化与模型参数量验证
5.1 用load_state_dict加载训练好的模型
训练完train.py会自动保存model.pth,但Pytorch保存的方式会影响加载方式。推荐在训练脚本结尾加:
torch.save({'state_dict': model.state_dict(), 'config': {'growth_rate': growth_rate, 'block_config': block_config}}, 'densenet_cifar10.pth')加载时一定要先重建出结构完全相同的模型,再load_state_dict。由于state_dict里的key对应每个层的名字,哪怕你只改了一个growth_rate,加载都会报unexpected key错误。所以保存配置信息是最省事的做法:
checkpoint = torch.load('densenet_cifar10.pth') model = DenseNet(**checkpoint['config']) model.load_state_dict(checkpoint['state_dict']) model.eval()这里model.eval()必须放在推理之前,否则BN层会用batch统计量,测试结果会比你训练时候的验证准确率低好几个百分点。
5.2 借助hooks提取任意层的特征图
DenseNet的特征复用是说,不同Dense Block提取的特征有明显的层次差异:靠前的Block学到边缘和颜色,靠后的Block学到物体部件。为了验证这一点,可以用Pytorch的register_forward_hook把某一层输出抓出来,项目plot.py里就做了类似的事。
# plot.py 中特征图可视化片段 activation = {} def hook_fn(name): def forward_hook(module, input, output): activation[name] = output.detach() return forward_hook model.features[0].register_forward_hook(hook_fn('block1')) model.features[0].layers[2].register_forward_hook(hook_fn('block1_layer3')) dummy = torch.randn(1, 3, 32, 32) model(dummy) feat = activation['block1_layer3'] # shape: [1, C, 32, 32]可视化时通常取feat[0, :8]也就是前8个通道,用torchvision.utils.make_grid拼接成一张图。DenseNet有个特别之处:由于每个子层都拼接到后续层,所以前几个通道往往携带了很原始的边缘信息,而后面几个通道是当前层新提取的复合特征。这种“新旧特征同框”的现象在ResNet里是看不到的,ResNet的残差连接虽然也做了相加,但通道之间没有这种显式的复用关系。
5.3 用densenet-err-table核对模型表现
项目里的densenet-err-table.png是一张错误率对照表,你训练完后可以把自己的结果填进去对比。以DenseNet-BC-100(block_config=(12, 24, 16),growth_rate=12)为例,在CIFAR-10上不做额外数据增强的预期错误率大约是5.2%到5.9%。下表是常见配置的参考值:
| 模型配置 | Growth Rate | 参数量 | CIFAR-10测试错误率(参考) |
|---|---|---|---|
| DenseNet-BC-100 | 12 | 0.8M | 5.2% - 5.9% |
| DenseNet-BC-190 | 40 | 25.6M | 3.7% - 4.5% |
| 项目默认配置(6,12,24,16) | 12 | 7.0M | 5.5% - 6.5% |
如果训练完错误率在10%以上,优先检查是否忘了用compute-cifar10-mean.py的统计量做归一化。另外可以打印一下模型参数量:
total_params = sum(p.numel() for p in model.parameters()) trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)你会发现DenseNet-BC-100的参数量比ResNet-56还要少,但错误率更低。这个数值对比是你在面试或写报告时最有力的论据。项目源码里还给了densenet-err-table.png,你可以把自己的结果标注上去,顺便记录训练时长和GPU型号,这样一份可复现的实验记录就完成了。
本文还有配套的精品资源,点击获取