做图像分类的人,应该都见过DenseNet这个名字。作为CNN架构中的常青树,它在很多任务里都是默认的反超基线。第一次看DenseNet的结构图时,我脑子里冒出的第一个问题是:把每一层都和之前所有层连起来,参数不会爆炸吗?直到亲手实现过、训练过、在几个项目里把它和小数据集搭配在一起之后,才慢慢搞明白它到底为什么有效,又有什么让人头疼的代价。
这篇文章不聊复杂的论文推导,只讲三件事:DenseNet的发明动机和结构细节;从零实现时需要的代码和参数直觉;以及我在实际使用中踩过的坑,比如显存爆炸、BN失效、微调翻车。如果你是第一次接触CNN架构,看完可以自己写一个能跑的DenseNet;如果你已经用过它,那第5节的显存优化和超参设置应该值得一看。
1. 为什么需要DenseNet:梯度消失问题的另类解法
1.1 网络越深,第一层背后的梯度越"稀薄"
训练深层神经网络,最头疼的问题不是网络不够聪明,而是梯度传不回去。反向传播的时候,梯度要逐层乘以这一层权重矩阵的导数。正常情况下这些数值都小于1,一旦网络堆到几十层,前面几层的梯度就会被反复相乘消磨到几乎为0。这时候网络虽然在深度上加了很多层,但浅层几乎学不到东西,真正在干活的只有最后那几层。
这个现象在卷积网络上尤其明显。VGG那种纯堆卷积的网络,做到19层就已经非常难训练,必须精心调初始化、调学习率,否则训练曲线要么直接发散,要么前几层的卷积核永远保持随机噪声状态。大家天天说"深度学习",但深层网络前面的层根本深不下去,这本质上是个优化问题,不是表达力问题。
1.2 ResNet的解决思路与它的"保留缺陷"
ResNet给出的方案是跳连接:把输入x直接加到这一层输出上,形成残差结构。梯度可以从最后的损失函数一路沿着shortcut传回前面,避免了每一层都要乘一遍小权重导数的困境。这个手段极其有效,ResNet之后百层网络成了标配。
但我后来在某个项目里做激活图可视化,发现ResNet有个不太被注意到的"冗余":不同block学到的特征有不少是重复的。因为残差结构是"旧特征+新特征",后面的层依然会重新提取一些和前面层差不多的边缘、纹理性特征。你可以把ResNet理解成每个人都在记笔记,后面的人虽然也会参考前面的笔记,但还是要重新抄一遍重点。
这个现象不代表ResNet不行,只是说明它解决梯度问题的思路是"绕路",而不是让信息本身被充分利用。那么问题来了:能不能让每一层都直接看到前面所有层的信息,而不是只看到上一层的输出?
1.3 DenseNet的回答:让信息在所有层之间直接流动
DenseNet的核心逻辑只有一句话:在通道维度上,把这一层之前的所有特征图全部拼接起来,作为这一层的输入。也就是说,第L层看到的不是"上一层传下来的特征",而是"第1层到第L-1层的完整特征集合"。
这样做的好处有两个方面。第一,梯度可以通过无数条短路直接到达浅层,前面的层再也不会因为梯度稀释而学不到东西。第二,特征被反复利用,后面的层不需要重新发明一遍前面已经检测过的边缘和纹理,只需要在已有特征基础上做出自己的新判断,从而大大减少参数冗余。
这里的"密集连接"经常被误解成所有层之间做加权求和,其实不是。DenseNet做的是torch.cat([x, out], dim=1),纯粹把新旧特征图拼在一起,然后交给卷积去自动提取。因为每一层新增的特征图数量很小,整体参数量反而比同样深度的ResNet更少。
2. DenseNet的内部结构拆解:Dense Block与Transition层
2.1 一次典型的Dense Layer操作流程
DenseNet不会真的让120层网络里的每一层都和前面119层直接相连,而是把网络切成几个Dense Block,在Block内部做密集连接,Block之间用Transition层做下采样和通道压缩。理解这个结构是理解DenseNet的起点。
一个标准的Dense Layer流程是这样的:先过BatchNorm,再过ReLU,然后过一个1x1卷积,再过BatchNorm和ReLU,最后过3x3卷积。1x1卷积的输出通道通常是growth rate的4倍,所以它也被叫做bottleneck层。3x3卷积输出growth rate个新特征图。做完这些之后,把输入和输出在通道维上拼接起来,得到下一层输入。
为什么非要在3x3卷积之前加一个1x1卷积?因为Block内部通道数会随层数线性增长,如果不先压缩一下,3x3卷积的输入通道数会很快变成几百甚至上千,计算量会彻底失控。1x1卷积在数学上等价于对每个像素做一次全连接变换,计算量很小,却能大幅减少后续3x3卷积的输入维度。
2.2 Growth Rate参数:通道增长的步长
Growth Rate,通常用k表示,是DenseNet里最重要的超参数。每个Dense Layer只输出k个新特征图,而不是像普通卷积那样动辄64、128个。论文里常用的k是32,也见过k=48的设置。你可以把它理解为每一层给整个网络贡献的"新知识量"。
k的大小直接影响一个Block内通道增长速度。假设Block的初始输入通道是c0,Block内共有L个Dense Layer,那么最后一层的输入通道数就是c0 + k*(L-1)。举个例子:c0=64,k=32,L=24,那么最后一层输入的通道数是64 + 32*23 = 800。这个数字还不算bottleneck里的4倍放大,所以没有1x1卷积,这个Block根本没法跑。
在ImageNet上常见的DenseNet变体都是k=32,因为k越大,参数和计算量增长越快,但精度提升会迅速饱和。我做过的实验也验证了这一点:把k从32改成48,在CIFAR-10上的精度只涨了不到0.3%,参数量却涨了接近一倍,性价比很低。
2.3 Transition层的压缩系数
每个Dense Block之间不是直接拼接的,中间会插一个Transition层,作用有两个:降低特征图的分辨率,同时压缩通道数。标准Transition是"BN -> 1x1卷积 -> 2x2平均池化",1x1卷积把通道数乘以一个压缩系数θ后输出。
论文里θ默认取0.5,也就是通道数减半,这种加上了bottleneck和compression的版本叫DenseNet-BC。实际使用中,几乎所有人都会用DenseNet-BC,因为压缩带来的参数下降很明显,而精度基本不掉。原因是DenseNet本身存在大量冗余的连接,压缩一部分过渡特征其实起到了类似正则化的作用。
下表是我常用的几个DenseNet变体配置,层数分配是每个Dense Block包含的Dense Layer数量:
| 网络变体 | Block层数配置 | Growth Rate | 参数量(约) |
|---|---|---|---|
| DenseNet-121 | 6, 12, 24, 16 | 32 | 8M |
| DenseNet-169 | 6, 12, 32, 32 | 32 | 14M |
| DenseNet-201 | 6, 12, 48, 32 | 32 | 20M |
| DenseNet-264 | 6, 12, 64, 48 | 32 | 33M |
3. 从零手写DenseNet:结构与代码逐行对应
3.1 先写DenseLayer:经典配方的组合
学习DenseNet最好的方式就是自己写一遍。下面这段代码是我在PyTorch里常用的DenseNet骨架,不是论文实现的完整复刻,但足以说明问题。
先写一个通用的"BN+ReLU+Conv"组合模块:
import torch import torch.nn as nn class BN_ReLU_Conv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0): super().__init__() self.bn = nn.BatchNorm2d(in_channels) self.relu = nn.ReLU(inplace=True) self.conv = nn.Conv2d( in_channels, out_channels, kernel_size, stride=stride, padding=padding, bias=False ) def forward(self, x): return self.conv(self.relu(self.bn(x)))然后是这个模块的组装产物DenseLayer:
class DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate, bottleneck_ratio=4, dropout=0.0): super().__init__() self.bottleneck = BN_ReLU_Conv( in_channels, bottleneck_ratio * growth_rate, 1 ) self.conv3x3 = BN_ReLU_Conv( bottleneck_ratio * growth_rate, growth_rate, 3, padding=1 ) self.dropout = nn.Dropout2d(dropout) if dropout > 0 else nn.Identity() def forward(self, x): out = self.conv3x3(self.bottleneck(x)) out = self.dropout(out) return torch.cat([x, out], dim=1)注意torch.cat这行,它就是DenseNet的"密集"所在。每次forward,都把输入x和新增的out拼起来,下一层看到的通道数就会越来越多。
3.2 堆叠DenseBlock与Transition
有了DenseLayer之后,DenseBlock的实现很直白:维护一个ModuleList,每新建一个Layer时,它的输入通道数要等于in_channels + i * growth_rate。
class DenseBlock(nn.Module): def __init__(self, num_layers, in_channels, growth_rate, dropout=0.0): super().__init__() self.layers = nn.ModuleList() for i in range(num_layers): layer = DenseLayer( in_channels + i * growth_rate, growth_rate, dropout=dropout ) self.layers.append(layer) def forward(self, x): for layer in self.layers: x = layer(x) return xTransition层更简单,就是BN+ReLU+1x1卷积把通道数从in_channels压缩到out_channels,再用平均池化把空间尺寸减半。
class Transition(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv = BN_ReLU_Conv(in_channels, out_channels, 1) self.pool = nn.AvgPool2d(2) def forward(self, x): return self.pool(self.conv(x))3.3 组装DenseNet-121
用上面这些组件就可以拼出DenseNet-121。初始stem部分是一层7x7卷积接最大池化,然后按6, 12, 24, 16四个Block排列。Block之间Transiton的压缩系数设为0.5,也就是DenseNet-BC。
class DenseNet(nn.Module): def __init__( self, init_channels=64, growth_rate=32, block_config=(6, 12, 24, 16), compression=0.5, num_classes=1000, ): super().__init__() self.features = nn.Sequential() self.features.add_module("stem_conv", nn.Conv2d(3, init_channels, 7, stride=2, padding=3, bias=False)) self.features.add_module("stem_bn", nn.BatchNorm2d(init_channels)) self.features.add_module("stem_relu", nn.ReLU(inplace=True)) self.features.add_module("stem_pool", nn.MaxPool2d(3, stride=2, padding=1)) in_channels = init_channels for i, num_layers in enumerate(block_config): block = DenseBlock(num_layers, in_channels, growth_rate) self.features.add_module(f"dense_block_{i+1}", block) in_channels += num_layers * growth_rate if i != len(block_config) - 1: out_channels = int(in_channels * compression) self.features.add_module(f"transition_{i+1}", Transition(in_channels, out_channels)) in_channels = out_channels self.features.add_module("final_bn", nn.BatchNorm2d(in_channels)) self.features.add_module("final_relu", nn.ReLU(inplace=True)) self.pool = nn.AdaptiveAvgPool2d(1) self.classifier = nn.Linear(in_channels, num_classes) def forward(self, x): x = self.features(x) x = self.pool(x) x = torch.flatten(x, 1) return self.classifier(x)这样写出来的是一个能直接跑起来的DenseNet-BC模型。你可以在自己的数据集上改num_classes,或者只取features当作一个特征提取器。
3.4 前向传播和反向传播的直觉
前向传播的过程很直观:特征图在通道维度上不断变宽,但空间尺寸在Transition层逐级减半。模型最后输出的特征图通道数非常大,可是由于全局平均池化的存在,送入分类头的只是一个很扁的向量,不会因为通道数多而撑爆全连接层。
反向传播的时候,DenseNet的优势就体现出来了。因为torch.cat把输入x和输出out拼在一起,梯度在反向穿过cat节点时,会分裂成两条路径:一条继续回传给当前层的2个卷积,另一条直接传给更早的所有层。这样梯度就有无数条到达浅层的路径,哪怕网络到200层,前面的卷积也能拿到足够强的更新信号。
换句话说,DenseNet不是在"物理上"解决梯度消失,而是让梯度能够通过几十条近路绕过中间层,直接到达需要的地方。
4. 与ResNet对比:DenseNet赢在哪里,又输在哪里
4.1 特征重用 vs 特征残差:两种特征学习哲学
ResNet和DenseNet都有一个共同点:它们都在传统卷积之外增加了跨层连接。但连接方式有本质差异。ResNet用的是加法,output = F(x) + x,新特征和旧特征混合在一起,共享同一个通道。DenseNet用的是拼接,output = cat(x, F(x)),旧特征不改变,新特征单独占据新通道。
这个差异带来的影响是:ResNet每一层都可以修改旧特征,旧信息会被后续层的加法"污染";DenseNet则把历史信息原封不动地保存下来,后面每一层都看到的是"完整的历史档案"。所以DenseNet里面的特征重用程度远高于ResNet。视觉任务里很多低层边缘、颜色、纹理等通用特征,经过ResNet的几十层加法之后早已面目全非,而DenseNet则把它们原样保留到分类器之前。
4.2 参数效率:更少的参数,相近的精度
单看ImageNet分类结果,DenseNet-121只有约8M参数,却能达到和ResNet-50(25M参数)几乎持平的精度。DenseNet-201参数约20M,精度反而超过ResNet-50。这种参数效率主要来自特征共享:同样的边缘检测器,ResNet可能在30个block里各自学一遍,DenseNet只需要有一层学出来,后面所有层都能直接引用。
但这里必须说清楚:参数少不等于计算量小。DenseNet的FLOPs并不低,因为即使每个block只增加32个通道,累积到后面,3x3卷积的输入通道数也会很大,导致乘加运算量居高不下。所以你会发现DenseNet在GPU上的训练速度和ResNet差不多甚至更慢,显存反而更高。参数效率和计算效率是两个维度,别混淆。
4.3 显存消耗与推理速度:被忽视的成本
我实际用下来,DenseNet最大的坑就是显存。训练时反向传播需要保存每一层卷积的输入特征图。DenseNet因为几乎每一层都在cat,保存的中间变量非常多,所以显存占用比同精度的ResNet高30%到50%。如果你只有8G显存的卡,DenseNet-121的batch size可能只能开到32,而ResNet-50能开到64以上。
推理速度也一样,DenseNet由于通道膨胀导致的卷积计算量增大,单张图片的推理时间通常比ResNet更长。在工业界做高并发、低延迟的在线服务时,这不是一个可以忽略的问题。所以很多实际落地的项目依然用ResNet作为backbone,而不是DenseNet。
5. 训练DenseNet的实战经验与常见坑
5.1 小数据集上的正则化与数据增强
DenseNet在小数据集上表现不错,因为参数少、特征重用本身就有一定的正则化效果,但该过拟合照样过拟合。我在一个只有几百张医疗影像样本的细分任务上测试过,直接训练DenseNet-121比直接训练ResNet-50更稳,但仍然需要配合Mixup或者简单随机裁剪翻转来抑制过拟合。
第一版跑了50个epoch之后,训练集准确率已经接近100%,验证集却只有75%,这明显是过拟合了。我把权重衰减从1e-4降到5e-5,再给每个DenseLayer加0.2的dropout,最后验证集涨到81%。对于小数据集,我的经验是:dropout比调大模型更有效,而且运行成本低得多。你也可以试着把growth rate从32降到24,相当于手动给模型"减负"。
5.2 BN、Dropout和激活函数的先后顺序
DenseNet属于pre-activation设计,也就是BN和ReLU放在卷积前面。代码里BN_ReLU_Conv模块清楚地体现了这一点。这个顺序不是随便定的,它可以保证梯度在跨层传播时不受BN的影响,训练更稳定。如果你改成post-activation,模型不是不能train,只是收敛速度会变慢,尤其是在训练初期。
Dropout的位置则更讲究。我建议把Dropout放在DenseLayer最后一个3x3卷积之后、torch.cat之前。这样只会随机丢弃当前层新生成的特征,不会影响历史特征。如果放在cat之后,Dropout会把之前所有层共享的特征整体置零,特征重用直接被破坏,效果很差。这个小细节值得记一下,很多人在这里翻过车。
5.3 显存优化的三个实用手段
训练DenseNet最有效的显存优化方法是激活检查点(activation checkpointing)。PyTorch的torch.utils.checkpoint可以在前向计算时不保存中间激活,反向传播时临时重新算一次。代价是训练时间增加约20%-30%,显存却能省一半以上。我在实际项目里对DenseNet的四个DenseBlock逐块包上checkpoint,8G显存下batch size从24涨到48,效果显著。
第二招是梯度累积。如果卡上只能跑batch size=8,那就跑8个step再更新一次,等效batch size=64。这会延长训练时间,但能让你在有限的硬件上尝试更大的有效batch。
第三招是混合精度训练。现在主流PyTorch版本里直接用torch.autocast包住前向就基本完成,BN层会自动保持FP32,不会出现数值稳定性问题。混合精度对DenseNet这种计算量大的模型尤其值得开启,提速效果比其他模型更明显。
5.4 从零训练还是在预训练模型上微调
迁移学习时,直接加载ImageNet预训练的DenseNet是省事的做法。但要注意:DenseNet的BN层在预训练阶段积累了running mean和running variance,微调时的batch size如果很小,这些统计量会更新得不稳定,导致loss突然乱跳。我在一个目标检测项目里用batch size=4微调DenseNet-121,训练曲线抖到根本看不下去,果断冻结所有BN层,只让卷积和分类头参与训练,loss马上就稳了。
如果你的任务输入分辨率不是224x224,而是128x128,建议直接用预训练模型把前几层冻结,只训练后面几个Block,或者干脆从零训练一个growth rate更小的变体。很多医学影像patch的输入尺寸较小,强行resize到224可能引入不必要的插值噪声,精度不升反降。
6. DenseNet的变体、后续影响与今天的选型建议
6.1 DenseNet-BC与其他改进变体
论文中重点推荐的DenseNet-BC(Bottleneck + Compression)现在基本就是DenseNet的代名词。在此基础上,后来的研究者还尝试过不少变体:有人把3x3卷积换成group convolution,减少计算量;有人把拼接换成注意力加权,让网络自己决定哪个历史特征更重要;也有人把DenseNet思想扩展到3D卷积网络,用于视频理解。整体上看,这些变体很少有全面超越原版的,但都验证了密集连接的通用性。
我偶尔会手动魔改DenseNet:把第一个7x7大卷积替换成3x3卷积叠加,因为小输入分辨率下大卷积核感受野没优势;或是把最后一个Block的growth rate降低,让靠近分类器的特征不要过于冗余。这些改动在特定数据集上会有小幅提升,但不稳定,最稳的还是直接用原版DenseNet-BC。
6.2 从DenseNet走出的思想:对后续网络架构的启发
DenseNet真正推动的是"特征重用"这个设计范式。后来的VoVNet专门批判了DenseNet"逐层密集连接"的效率问题,提出只做一次聚合聚合,速度和精度平衡更好。EfficientNet在进行神经架构搜索时,网络设计中也大量参考了跨层特征复用的思路。即使到了Transformer时代,层间残差连接和特征重用背后的动机,和DenseNet本质上是一脉相承的。
所以我觉得不必纠结DenseNet还够不够"新",它的价值更多在于提供了一种思考网络结构的模板:当梯度传不过去时,不一定只能加shortcut;当参数冗余时,也不一定要砍层数。试试把信息直接广播给后面的所有层,可能就有惊喜。
6.3 今天做项目,DenseNet还值不值得用?
直接给出我的选型参考:
| 使用场景 | 建议 |
|---|---|
| 数据集几百到几千张、精度优先、显存较充裕 | DenseNet-BC-121/169 |
| 大规模数据集、推理速度敏感 | ResNet或EfficientNet |
| 移动端、低功耗设备 | MobileNetV2/V3 |
| 目标检测/分割backbone | 优先ResNet,DenseNet用于特征复用调试 |
我个人的经验是:DenseNet并不会退出历史舞台,但它已经从"主角"变成了"特种兵"。遇到小数据、细粒度分类、特征复用需求强烈的任务时,把之前跑通的ResNet换成DenseNet试试,经常能收获意想不到的精度提升。当然,前提是你能接受它更高的显存开销和更慢的推理速度。
最后再分享一个实用技巧:不管你是做分类还是分割,如果要在大模型上尝试DenseNet,第一个改动应该是开启激活检查点,第二个改动是确认BN的batch size不要太小,第三个改动是准备好一份"小growth rate"的备选配置。这三件事做好,DenseNet带来的收益远大于它给你添的麻烦。