
简介基于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): # 每个子层是一个 Bottleneck1x1 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, dim1)) features.append(new_features) return torch.cat(features, dim1)这里的in_channels i * growth_rate是通道数推导每经过一个子层通道数增加一个growth_rate所以第i个子层输入通道就是初始通道数加上前i个子层贡献的通道数。torch.cat(features, dim1)表示在通道维上拼接。需要注意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_size1, biasFalse) self.pool nn.AvgPool2d(kernel_size2, stride2) 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_size2, stride2)会把特征图高宽各缩小一半配合通道压缩整个网络的参数规模就在可控范围内。从工程角度说Transition里的顺序是BN - ReLU - Conv1x1 - AvgPool这个顺序和普通CNN的Conv-BN-ReLU不一样原因在于Transition的前置操作。每个Dense Block内部子层已经做过BN和ReLUTransition接收的是已经激活过的特征所以需要再做一次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_size1, stride1, biasFalse) self.bn2 nn.BatchNorm2d(bn_size * growth_rate) self.conv2 nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size3, stride1, padding1, biasFalse) 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-BCBottleneck Compression比普通DenseNet更省参数的原因。这一节的选型逻辑放在Pytorch里同样适用如果显存紧张优先调小growth_rate而不是删层数如果想提精度先加num_layersDense Block数量再考虑加大growth_rate。这个顺序在后续训练时能看到明显差异。3. Pytorch实现DenseNet核心模块从零写densenet.py3.1 先搭Bottleneck和DenseBlock的两个基础类现在直接看项目里的densenet.py你会发现整个文件不到200行核心就是上面拆开的三个类Bottleneck、DenseBlock、Transition外加一个组装用的DenseNet主类。写的时候注意Pytorch的nn.ModuleList和nn.Sequential的区别DenseBlock里必须用ModuleList因为每一层输入依赖前面所有层输出不能用Sequential线性堆叠而Transition之间可用Sequential。实际编码时容易踩一个坑Bottleneck和Transition里的biasFalse是必须的因为后面紧跟BatchNorm2dBN层自带可学习的gamma和beta卷积层的偏置会被BN吸收留着只会增加参数且影响数值稳定性。在Pytorch中如果你在Conv2d里忘了设biasFalse模型仍然能跑但参数量会虚高打印模型结构时对比官方实现也能发现差异。3.2 Transition层与整个网络的组装DenseNet主类负责把多个DenseBlock和Transition串起来并控制每个Block之前的通道数变化。下面这段是项目构造函数的关键部分# densenet.py 中 DenseNet 主类的构造局部 class DenseNet(nn.Module): def __init__(self, growth_rate12, block_config(6, 12, 24, 16), num_init_features64, bn_size4, num_classes10): super().__init__() # 初始卷积层CIFAR-10 输入 32x32这里不做下采样 self.conv1 nn.Conv2d(3, num_init_features, kernel_size3, stride1, padding1, biasFalse) 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_rate12, block_config(6, 12, 24, 16), num_classes10) dummy_input torch.randn(1, 3, 32, 32) output model(dummy_input) graph make_dot(output, paramsdict(model.named_parameters())) graph.render(densenet_graph, formatpng)运行后生成的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, padding4), 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, padding4)先在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(), lr0.1, momentum0.9, weight_decay1e-4) scheduler optim.lr_scheduler.MultiStepLR( optimizer, milestones[150, 225], gamma0.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()这里lr0.1配合momentum0.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通道输入上提取特征如果lr0.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(fEpoch {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-100block_config(12, 24, 16)growth_rate12为例在CIFAR-10上不做额外数据增强的预期错误率大约是5.2%到5.9%。下表是常见配置的参考值模型配置Growth Rate参数量CIFAR-10测试错误率参考DenseNet-BC-100120.8M5.2% - 5.9%DenseNet-BC-1904025.6M3.7% - 4.5%项目默认配置6,12,24,16127.0M5.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型号这样一份可复现的实验记录就完成了。本文还有配套的精品资源点击获取