☰
PyTorch实战:从零实现DenseNet,详解稠密连接与调参技巧
2026/10/15 5:51:17 网站建设 项目流程

1. 为什么还要聊DenseNet:一个被低估的经典结构

如果你最近两年才开始接触深度学习,大概率是从ResNet、EfficientNet或者Vision Transformer入门的。DenseNet这个名字听起来像是上个时代的东西,2017年的CVPR最佳论文,到现在已经有些年头了。但我在实际项目里反复验证下来,DenseNet在很多中小规模数据集上的表现依然非常能打,尤其是在数据量不够大、又不想上太重的预训练模型时,它往往能给你惊喜。

DenseNet全称是Densely Connected Convolutional Networks,核心思想就一句话:每一层都直接和前面所有层连起来。听起来简单,但真正动手用PyTorch实现的时候,你会发现里面有不少细节值得琢磨——特征拼接怎么拼、内存怎么爆、增长率怎么选、Bottleneck层到底加不加。这些问题在论文里可能一笔带过,但落到代码上,每一个都能让你调半天。

这篇文章我打算从零把DenseNet在PyTorch里的实现讲透。不是那种贴一段官方代码就完事的教程,而是把我自己踩过的坑、调参的经验、以及在不同任务上验证过的结论都摊开来说。适合已经会用PyTorch搭基本CNN、想深入理解DenseNet结构并落地到实际项目里的朋友。如果你还在纠结要不要用DenseNet,或者用了但效果不理想,那这篇内容应该能帮你省下不少试错时间。

2. DenseNet到底解决了什么问题:从ResNet说起

2.1 残差连接和稠密连接的本质区别

要理解DenseNet,得先搞清楚它和ResNet的分歧在哪。ResNet的思路是“跳跃连接”,每一层的输出是前面某一层输出的加法叠加:x_l = H_l(x_{l-1}) + x_{l-1}。这种方式解决了深层网络的梯度消失问题,让网络可以做到上百层甚至上千层。

但ResNet有个隐含的问题:加法操作在某种程度上会“覆盖”前面的信息。虽然理论上网络可以学到恒等映射来保留信息,但实际上随着层数增加,前面层的信息在传递过程中会被逐渐稀释。你可以把它想象成传话游戏,每传一个人,原始信息就模糊一点。

DenseNet换了个思路:不用加法,用拼接。每一层的输入是前面所有层输出的拼接,x_l = H_l([x_0, x_1, ..., x_{l-1}])。这样一来,原始信息永远不会被覆盖,每一层都能直接访问到最开始的输入特征和中间所有层的特征。这个设计带来的好处是显而易见的:特征复用率极高,梯度可以直接从损失函数流到每一层,网络不需要那么宽就能学到丰富的特征表示。

2.2 参数效率为什么反而更高

第一次看到DenseNet结构图的人通常会有一个疑问:每层都拼接前面所有层的输出,那特征图数量不是爆炸式增长吗?参数量岂不是大得离谱?

实际情况恰恰相反。DenseNet的参数量通常比同精度的ResNet还要少。原因在于它的“增长率”设计。假设增长率k=32,意思是每个Dense Block里每一层只输出32个新特征图。虽然拼接之后总通道数在增加,但每一层自己只需要学习这32个新特征。相比ResNet每层动辄输出256或512个通道,DenseNet每层的卷积核数量少得多。

更重要的是,因为特征被反复复用,DenseNet不需要像ResNet那样在每一层都重新学习已经学过的特征。这就好比一个团队做项目,ResNet是每个人从头做一遍再汇总,DenseNet是每个人只做自己那块新东西然后拼到已有成果上。显然后者的冗余工作更少。

2.3 适合什么场景,不适合什么场景

DenseNet不是万能的。根据我的经验,它在以下场景表现特别好:训练数据在几千到几万张之间、图像分辨率不算太高(224×224到512×512)、对参数量和显存有一定约束、需要较强的特征复用能力(比如细粒度分类、医学图像分割)。

但它也有明显的短板。首先是显存占用高,因为每层都要保存前面所有层的输出用于拼接,反向传播时这些中间结果都得留着。其次是推理速度偏慢,拼接操作和大量的通道数导致计算效率不如ResNet。所以如果你追求极致推理速度,或者部署在边缘设备上,DenseNet可能不是首选。

3. PyTorch实现DenseNet的核心组件拆解

3.1 DenseLayer:最基础的稠密层

DenseLayer是DenseNet的最小单元。在PyTorch里实现它,核心就是两步:先做BatchNorm+ReLU+Conv,然后把输出和输入拼接起来。

import torch import torch.nn as nn class DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super(DenseLayer, self).__init__() self.bn1 = nn.BatchNorm2d(in_channels) self.relu1 = nn.ReLU(inplace=True) 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.relu2 = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, stride=1, padding=1, bias=False) self.drop_rate = drop_rate def forward(self, x): # x是一个list,包含前面所有层的输出 concated = torch.cat(x, 1) out = self.conv1(self.relu1(self.bn1(concated))) out = self.conv2(self.relu2(self.bn2(out))) if self.drop_rate > 0: out = nn.functional.dropout(out, p=self.drop_rate, training=self.training) return out

这里有几个关键点值得展开说。第一,bn_size这个参数控制的是Bottleneck层的宽度。当bn_size=4、growth_rate=32时,1×1卷积输出128个通道,然后3×3卷积再压回32个通道。这个设计是为了减少计算量,因为直接对拼接后的高通道特征做3×3卷积代价太大。第二,dropout的位置在3×3卷积之后,这个和原始论文一致,但实际用的时候如果数据集够大,drop_rate设0就行。第三,注意forward接收的是一个list,这是DenseNet实现里比较特殊的地方,需要Block层面来组织这个list的传递。

3.2 DenseBlock:管理特征拼接的容器

DenseBlock要做的就是维护一个特征列表,每经过一个DenseLayer就把新输出追加进去。

class DenseBlock(nn.Module): def __init__(self, num_layers, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super(DenseBlock, self).__init__() self.layers = nn.ModuleList() for i in range(num_layers): self.layers.append( DenseLayer(in_channels + i * growth_rate, growth_rate, bn_size=bn_size, drop_rate=drop_rate) ) def forward(self, x): features = [x] for layer in self.layers: out = layer(features) features.append(out) return torch.cat(features, 1)

注意每个DenseLayer的输入通道数是in_channels + i * growth_rate,因为每加一层,特征列表就多一个growth_rate大小的张量。最后整个Block的输出是所有特征拼接后的结果,通道数等于in_channels + num_layers * growth_rate。

这里有个实操中容易忽略的点:features列表里的张量在拼接之前一直保留在显存里。如果num_layers比较大(比如24或32),显存占用会明显上升。我在跑512×512的医学图像时,num_layers=24的Block在batch size=8的情况下就快把12G显存吃满了。解决办法要么减小batch size,要么用梯度检查点技术,要么把输入分辨率降下来。

3.3 Transition层:降维和压缩的关键

两个DenseBlock之间需要Transition层来降低特征图的空间尺寸和通道数。

class Transition(nn.Module): def __init__(self, in_channels, out_channels): super(Transition, self).__init__() self.bn = nn.BatchNorm2d(in_channels) self.relu = nn.ReLU(inplace=True) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False) self.pool = nn.AvgPool2d(kernel_size=2, stride=2) def forward(self, x): out = self.conv(self.relu(self.bn(x))) out = self.pool(out) return out

Transition层的压缩系数通常设为0.5,也就是输出通道数减半。这个压缩操作对控制模型大小很关键。我试过不压缩(系数1.0),模型参数量直接翻倍,精度提升却非常有限,性价比很低。另外池化用的是平均池化而不是最大池化,这一点和原始论文一致,实测下来平均池化在DenseNet里确实更稳。

3.4 整体网络组装:从DenseNet-121到自定义配置

把上面三个组件串起来就是完整的DenseNet。以经典的DenseNet-121为例:

class DenseNet(nn.Module): def __init__(self, growth_rate=32, block_config=(6, 12, 24, 16), num_init_features=64, bn_size=4, drop_rate=0.0, num_classes=1000): super(DenseNet, self).__init__() self.features = nn.Sequential() self.features.add_module('conv0', nn.Conv2d(3, num_init_features, kernel_size=7, stride=2, padding=3, bias=False)) self.features.add_module('bn0', nn.BatchNorm2d(num_init_features)) self.features.add_module('relu0', nn.ReLU(inplace=True)) self.features.add_module('pool0', nn.MaxPool2d(kernel_size=3, stride=2, padding=1)) num_features = num_init_features for i, num_layers in enumerate(block_config): block = DenseBlock(num_layers, num_features, growth_rate, bn_size, drop_rate) self.features.add_module('denseblock%d' % (i + 1), block) num_features = num_features + num_layers * growth_rate if i != len(block_config) - 1: trans = Transition(num_features, num_features // 2) self.features.add_module('transition%d' % (i + 1), trans) num_features = num_features // 2 self.features.add_module('norm5', nn.BatchNorm2d(num_features)) self.classifier = nn.Linear(num_features, num_classes) def forward(self, x): features = self.features(x) out = nn.functional.relu(features, inplace=True) out = nn.functional.adaptive_avg_pool2d(out, (1, 1)) out = torch.flatten(out, 1) out = self.classifier(out) return out

block_config=(6, 12, 24, 16)对应的是DenseNet-121,四个Block的层数分别是6、12、24、16。如果你想搭DenseNet-169就是(6, 12, 32, 32),DenseNet-201是(6, 12, 48, 32)。但我不建议无脑上大的,后面会讲为什么。

4. 训练DenseNet的实操细节与调参经验

4.1 数据增强策略怎么选

DenseNet因为参数量相对少、特征复用强,对数据增强的依赖比ResNet更高。我在几个图像分类任务上对比过,同样的数据增强组合,DenseNet比ResNet的精度波动更大。也就是说,增强做得好,DenseNet能超ResNet一截;增强做得差,DenseNet可能还不如ResNet稳。

基础组合我一般用RandomResizedCrop+RandomHorizontalFlip+ColorJitter。如果是医学图像或者工业缺陷检测这类对颜色不敏感的任务,ColorJitter可以去掉,换成RandomRotation和RandomAffine。注意DenseNet对Cutout和Mixup的响应比较好,尤其是数据量少的时候,Mixup能明显抑制过拟合。但Mixup的alpha别设太大,0.2到0.4之间比较合适,太大了训练会变慢。

4.2 学习率调度和优化器选择

DenseNet对学习率比较敏感。我用SGD+CosineAnnealing的组合最多,初始学习率设0.1(batch size=256时),warmup 5个epoch。如果用Adam,初始学习率要降到1e-3甚至更低,否则前期loss容易震荡。

有个细节值得注意:DenseNet的BatchNorm层比较多,weight decay的设置要小心。我一般对BN参数和bias不做weight decay,只对卷积和全连接层的权重做,系数设1e-4。这个在PyTorch里可以通过参数分组实现:

decay_params = [] no_decay_params = [] for name, param in model.named_parameters(): if 'bn' in name or 'bias' in name: no_decay_params.append(param) else: decay_params.append(param) optimizer = torch.optim.SGD([ {'params': decay_params, 'weight_decay': 1e-4}, {'params': no_decay_params, 'weight_decay': 0.0} ], lr=0.1, momentum=0.9)

这个操作看起来小,但在DenseNet上效果挺明显,尤其是训练后期loss下降会更平滑。

4.3 显存不够时的几种应对方案

显存问题是DenseNet落地时最大的拦路虎。我整理了几种实际用过的方案,按推荐程度排序:

方案显存节省训练速度影响精度影响适用场景
减小batch size高小小首选,配合梯度累积
梯度检查点高中等无显存极度紧张时
混合精度训练中等提升极小支持AMP的显卡
减小growth rate中等提升中等数据量充足时
减小输入分辨率高提升较大最后考虑

梯度累积是个很实用的技巧,比如你想用batch size=32但显存只够8,那就累积4次梯度再更新一次参数。效果和直接上32差不多,只是BN的统计量会有些差异,但实测影响不大。

混合精度训练在DenseNet上收益很明显,因为DenseNet的计算瓶颈主要在拼接和卷积,FP16能加速不少。但要注意BN层最好保持FP32,PyTorch的AMP会自动处理这个。

4.4 迁移学习的正确打开方式

如果你不想从零训练,用预训练权重是个好选择。但DenseNet的迁移学习和ResNet有些不同。ResNet通常冻结前面的层只训练后面的,DenseNet因为特征复用太强,冻结前面层反而会限制后面的层获取有用特征。

我的做法是:先全部解冻,用较小的学习率(比如1e-3)跑几个epoch让网络适应新数据,然后再分组设置学习率——前面的Block用更小的学习率,后面的Block和分类头用正常学习率。这样比直接冻结效果好不少。

另外分类头的替换要注意,DenseNet最后的特征维度是num_init_features + sum(num_layers * growth_rate for num_layers in block_config) // 2(因为最后一个Block后面没有Transition)。以DenseNet-121为例,最后是1024维。换分类头的时候直接nn.Linear(1024, num_classes)就行。

5. 常见问题排查与性能优化实录

5.1 训练loss不下降或者震荡怎么办

这是最常见的问题。按我的排查顺序,先看数据管道有没有问题——标签对不对、图像有没有归一化、shuffle有没有开。然后看学习率是不是太大,DenseNet对学习率比ResNet敏感,可以先把学习率降10倍试试。

如果数据和学习率都没问题,检查BatchNorm的momentum。PyTorch默认是0.1,但在小batch size下这个值可能太大,导致BN统计量不稳定。可以试着降到0.01。另外DenseNet的BN层特别多,如果用了预训练权重,确保BN层的running_mean和running_var被正确加载。

还有一个容易被忽略的点:DenseNet的初始化。PyTorch默认用Kaiming初始化,但DenseNet的卷积层通道数变化很大,默认初始化在某些配置下会导致前期梯度爆炸。我一般会对卷积层手动做Kaiming正态初始化,BN层的weight初始化为1、bias为0。

5.2 过拟合了怎么处理

DenseNet因为参数量少,理论上不容易过拟合,但在小数据集上还是会出现。除了常规的加数据增强、加dropout、加weight decay之外,DenseNet有两个特有的手段。

一是调小growth rate。growth rate从32降到24或16,模型容量会明显下降,过拟合会缓解。但注意别降太多,太小了模型欠拟合反而更麻烦。二是增加Transition层的压缩系数,从0.5降到0.3甚至0.25,相当于在Block之间做了更强的信息瓶颈,能起到正则化作用。

另外stochastic depth在DenseNet上效果也不错,就是训练时随机跳过一些DenseLayer。实现起来就是在DenseBlock的forward里加一个随机mask。这个技巧在原始论文里没提,但后来很多复现工作都验证了它的有效性。

5.3 推理速度太慢怎么优化

DenseNet推理慢的主要原因是通道数多、拼接操作频繁。几个实用的优化方向:

第一,用TorchScript或者ONNX导出后做图优化,能 fuse 掉一些BN和ReLU,提升10%到20%的速度。第二,把模型转成FP16或者INT8,在支持TensorRT的平台上能快不少。第三,如果对精度要求没那么高,可以把growth rate和block_config都调小,比如用DenseNet-121的一半配置,精度掉1到2个点,速度能快一倍。

还有一个偏门但有效的做法:把Transition层的平均池化换成步长为2的3×3最大池化,实测在分类任务上精度几乎不变,但推理速度会快一些,因为最大池化的计算更简单。

5.4 常见报错速查表

报错信息可能原因解决方法
CUDA out of memory显存不足减小batch size、用梯度检查点、混合精度
RuntimeError: size mismatch通道数计算错误检查block_config和growth_rate的乘积
loss变成NaN学习率太大或梯度爆炸降低学习率、加梯度裁剪
BN层报错running_mean维度不对预训练权重和模型结构不匹配检查num_init_features和block_config
训练速度异常慢数据加载瓶颈增加num_workers、用pin_memory

梯度裁剪在DenseNet上我建议默认开着,max_norm设5.0或者10.0,能有效防止偶发的梯度爆炸。尤其是用Adam的时候,前期loss spike的概率不低。

6. 一个完整的训练脚本长什么样

把前面的东西串起来,一个可运行的训练流程大概是这样:

import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import transforms, datasets # 数据准备 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder('path/to/train', train_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=8, pin_memory=True) # 模型 model = DenseNet(growth_rate=32, block_config=(6, 12, 24, 16), num_init_features=64, num_classes=10) model = model.cuda() # 优化器参数分组 decay_params, no_decay_params = [], [] for name, param in model.named_parameters(): if 'bn' in name or 'bias' in name: no_decay_params.append(param) else: decay_params.append(param) optimizer = optim.SGD([ {'params': decay_params, 'weight_decay': 1e-4}, {'params': no_decay_params, 'weight_decay': 0.0} ], lr=0.1, momentum=0.9, nesterov=True) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) criterion = nn.CrossEntropyLoss() # 训练循环 for epoch in range(100): model.train() for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step()

这个脚本里我特意保留了梯度裁剪和参数分组,这两个在实际训练中很关键。warmup没有加进去,如果训练不稳定可以自己补一个简单的warmup逻辑,前5个epoch线性增加学习率。

7. 几个我踩过的坑和对应的心得

第一个坑是growth_rate和bn_size的搭配。一开始我按论文默认的growth_rate=32、bn_size=4来,结果在小数据集上过拟合严重。后来把growth_rate降到24、bn_size降到2,效果好很多。bn_size不是越大越好,它本质上是Bottleneck的膨胀系数,太大了反而增加计算量且容易过拟合。

第二个坑是Transition层的放置位置。我试过在第一个Block之前就加Transition,想着先降分辨率再进Block能省显存。结果精度掉了3个多点。原因是初始卷积层输出的特征还不够丰富,过早降维会丢失信息。Transition还是老老实实放在Block之间。

第三个坑是预训练权重的加载。DenseNet的预训练权重在不同框架之间迁移时,BN层的参数命名可能不一样。我有一次从别的框架转权重,没注意BN的num_batches_tracked这个buffer,导致加载后BN统计量全是初始值,训练了好几个epoch才发现问题。加载权重后一定要打印一下BN层的running_mean看看是不是正常值。

第四个坑是关于数据归一化的。DenseNet对输入分布的敏感度比ResNet高,我用ImageNet的mean和std做归一化,但在自己的数据集上如果分布差异大,最好重新算一下数据集的均值和方差。这个操作很简单,但能带来1到2个点的精度提升。

8. 后续可以怎么扩展

如果你已经把基础版DenseNet跑通了,有几个方向可以继续深入。一是把DenseNet用到分割任务上,配合U-Net的 decoder结构,也就是DenseUNet,在医学图像分割上表现很好。二是尝试DenseNet和注意力机制的融合,比如在DenseLayer里加SE模块或者CBAM,能进一步提升特征表达能力。三是把DenseNet作为backbone接到目标检测框架里,虽然速度不如ResNet,但在小目标检测上因为特征复用强,召回率会高一些。

另外如果你对模型压缩感兴趣,DenseNet的通道剪枝是个不错的研究点。因为每层只输出growth_rate个通道,冗余度相对低,剪枝的时候需要更精细的策略。我试过基于BN缩放因子的剪枝,在DenseNet-121上能剪掉30%的通道,精度只掉0.5个点,推理速度提升明显。

这些扩展方向每一个都够写一篇长文了,后面有机会再单独展开。先把基础结构吃透,后面的组合和创新才有根基。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询