☰
DenseNet代码深度拆解:从特征拼接到PyTorch实现,彻底理解信息复用
2026/10/20 12:38:14 网站建设 项目流程

在很长一段时间里,我对残差网络的印象停留在“加法”上——把输入绕一圈加到输出上,梯度就有了捷径。直到自己动手写DenseNet,才意识到另一种思路同样优雅:不想着“怎么把梯度传回去”,而是把每一层都直接暴露给后面的所有层,让信息流动变成一张网。这篇就来拆解DenseNet的实现细节,从设计动机到每个模块的代码,再到CIFAR-10上的完整训练流程,全程用PyTorch,适合已经写过几轮CNN、想彻底搞懂DenseNet内部构造的读者。

1. DenseNet到底在解决什么问题,和我之前理解的有什么不一样

1.1 从“加法路径”到“拼接复用”:一个思路转换

写过ResNet的人都有一个直觉:网络深了以后,梯度要穿越很多层才能回到浅层,于是残差连接相当于给梯度开了一条“高速路”。DenseNet的设计者走了另一条路:与其让每一层只和前一层的输出打交道,不如让每一层都能看到前面所有层的输出。

具体到特征图上,ResNet是输出 = 输入 + F(输入),这里的加法要求两边的通道数相同,或者至少能对齐;DenseNet则是把前面所有层的输出在通道维度上拼起来,输出 = [x_0, x_1, x_2, ..., x_{l-1}, F(...)]。这个拼接操作让每一层接收到的信息量随深度线性增长,而不是像ResNet那样被限制在一个固定通道数里。

我第一次实现时最直观的感受是:这种设计不需要担心“通道数怎么对齐”的问题,因为拼接没有任何形状限制。每一层只需要输出一个新通道数的特征图(这个数量就是growth rate),剩下的就是把已有的特征图集合传下去。整个网络像是一个不断扩充的团队,每个人都可以翻阅之前所有人的记录,然后贡献一点自己的新笔记。

1.2 Growth Rate 与 Bottleneck:两个绕不开的数值

DenseNet里有个非常重要的超参数叫growth rate,代码里通常写作growth_rate,表示每个DenseLayer新生成几通道特征图。它默认是32,处理CIFAR这类小图时常用12甚至8。

这里有个很容易踩的认知误区:growth rate不是“基础通道数”,而是“每层新增的知识量”。决定网络容量的其实是层数 × growth rate。比如一个DenseBlock里有12层,growth rate是12,那么这个Block结束后特征图的通道数就增加了144。如果把growth rate理解成类似UNet的基本通道数,那后面所有地方都会算不明白。

论文里通常会让每个3×3卷积之前先接一个1×1卷积,把输入通道压缩到bn_size × growth_rate,这个操作叫Bottleneck,bn_size默认是4。它的作用很直接:假设某层输入通道已经累积到400多,直接做3×3卷积计算量不小;先用1×1卷积把通道降到96(比如bn_size=4, growth_rate=24),再做3×3卷积,计算量低很多,还能顺便融合跨通道信息。

1.3 整体骨架:Stem、DenseBlock、Transition 各司其职

DenseNet的宏观结构不算复杂,可以概括成三部分:

  • Stem:一个7×7卷积加最大池化(标准ImageNet版),或者一个小尺寸卷积(小图版),负责把输入图像变成初始特征图。
  • DenseBlock:若干个DenseLayer串在一起,层与层之间通过拼接传递信息。特征是逐步累积的,空间尺寸不变。
  • Transition:一个1×1卷积加2×2平均池化,作用有两个:把通道压细(默认压到一半,由compression参数控制),顺便把空间尺寸减半。

设计上,DenseBlock负责“变深”,Transition负责“变细”。所以在代码层面,需要把“一个DenseLayer单独做什么”和“一个DenseLayer怎么跟整个Block配合”想清楚,否则很容易写出表面上能跑、实际维度全乱掉的网络。

在继续写代码之前,先在心里过一遍提问:DenseLayer自己要不要做拼接?答案是不做。每个DenseLayer只负责从输入特征中提取“新知识”,返回固定通道数的特征图;拼接是DenseBlock的事。如果不把这两个角色分开,后面的实现会越写越乱。

2. DenseLayer 怎么写:BN-ReLU-Conv 的顺序为什么不能乱

2.1 先归一化再激活再卷积,而不是常规的 Conv-BN-ReLU

很多刚接触DenseNet的人会疑惑:为什么这里不按“卷积→BatchNorm→ReLU”的常规套路写?因为DenseNet沿用了一种叫“pre-activation”的结构,也就是把BN和ReLU放在卷积之前。

Conv-BN-ReLU是后激活,BN-ReLU-Conv是前激活。DenseNet里的每个DenseLayer都由两组“BN-ReLU-Conv”组成:第一组是1×1卷积做bottleneck降维,第二组是3×3卷积输出本层真正的新特征。

这种写法有一个很实际的好处:BN的归一化范围直接作用在卷积输入上,而不是作用在卷积输出后再过激活函数。对梯度流动来说,前激活结构往往更友好,而且代码实现时更像一个“先处理再加工”的流水线,逻辑上非常清爽。

import torch import torch.nn as nn import torch.nn.functional as F class DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super(DenseLayer, self).__init__() self.norm1 = 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.norm2 = 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): out = self.conv1(self.relu1(self.norm1(x))) out = self.conv2(self.relu2(self.norm2(out))) if self.drop_rate > 0: out = F.dropout(out, p=self.drop_rate, training=self.training) return out

注意Dropout的位置:我把它放在3×3卷积之后、返回之前,也就是说Dropout只作用于“本层新生成的特征”,不会随机丢弃旧特征。这是有意为之的,旧特征是前面所有层共同依赖的“公共记忆”,如果被随机丢弃,整个Block的信息会变得很不稳定。

2.2 Bottleneck 的有效性:用1×1卷积控制计算量

Bottleneck设计在我的实际测试里带来两个好处:第一,3×3卷积的输入通道被压低,计算量明显下降;第二,1×1卷积本身相当于一个可学习的通道混合器,压缩前先把所有通道的信息揉一遍,信息没有损失多少,但参数和计算量都省下来了。

这里给出一个直观的计算量对比:假设某层输入是256通道的56×56特征图,growth_rate为32,bn_size为4。如果不做bottleneck,直接3×3卷积输出32通道,计算量大约是56×56×256×32×9;如果先1×1卷积降到128通道,再3×3输出32通道,计算量是56×56×256×128×1 + 56×56×128×32×9。后者通常比前者节省30%以上。对大网络来说,这个差距会被放大得更明显。

所以说,bn_size这个参数虽然写起来只是一个小数字,实际上决定了整个DenseNet的“厚度分配”:通道在进入真正提取特征的3×3之前被压缩到什么程度。

3. DenseBlock 与 Transition:通道怎么拼接、怎么收缩

3.1 DenseBlock:一个循环里维护特征集合

DenseBlock的forward写法比DenseLayer稍微复杂一点。需要用features列表保存当前Block内所有层产生的特征图,每经过一个DenseLayer,就把新结果追加到这个列表末尾。下一轮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): current_in_channels = in_channels + i * growth_rate self.layers.append( DenseLayer( current_in_channels, growth_rate, bn_size, drop_rate ) ) def forward(self, x): features = [x] for layer in self.layers: new_feature = layer(torch.cat(features, dim=1)) features.append(new_feature) return torch.cat(features, dim=1)

这段代码里最关键的是current_in_channels = in_channels + i * growth_rate,它把“第几层”和“输入通道数”绑定在了一起。第0层输入是Block的起始通道数,第1层就多出growth_rate个通道,以此类推。

torch.cat(features, dim=1)从实现上看是把特征图在通道方向拼接。这里我遇到过一个问题:如果features里存了太多张量,内存占用会明显上升,因为每一步都要保存中间特征供后续拼接。这个问题在训练大模型时尤其突出,后面专门讲显存和效率的部分会细说。

理解了这个拼接逻辑,再回过头看设计就很清晰:DenseBlock内的第i层,输入通道数是不断增长的,但输出一直稳定在growth_rate个新通道。所以整个网络的特征通道数增长非常规律,完全可以提前算出来。

3.2 Transition:先降维再池化,还是先池化再降维?

Transition的职责是隔开两个DenseBlock,让通道数和空间尺寸都下降。标准写法是:BN-ReLU → 1×1卷积 → 2×2平均池化,其中1×1卷积输出的通道数由compression参数决定,默认是0.5,也就是输入通道数砍一半。

class Transition(nn.Module): def __init__(self, in_channels, out_channels): super(Transition, self).__init__() self.norm = 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): x = self.conv(self.relu(self.norm(x))) x = self.pool(x) return x

我推荐先做1×1卷积再池化。原因很简单:1×1卷积本身可以看作一个通道压缩器,它先把冗余通道融合掉,池化再对空间做下采样。如果反过来先池化再1×1卷积,信息已经丢失了一部分再压缩,虽然最终shape相同,但实验效果通常略差。而且从计算量角度看,先降通道能减少池化阶段需要处理的数据量。

Transition里的池化选的是平均池化而不是最大池化,这也是个值得注意的细节。Downsampling发生在特征图已经经过1×1压缩之后,平均池化更倾向于保留整体的响应强度,而最大池化容易只保留最强烈的局部特征。DenseNet整体风格是“信息复用”,平均池化更贴合。

3.3 整体网络组装:模块之间的通道衔接

有了DenseLayer、DenseBlock和Transition,组装大网络就顺理成章了。我给出一个相对通用的DenseNet骨架,支持通过参数配置不同的深度:

class DenseNet(nn.Module): def __init__( self, num_init_features=64, growth_rate=32, block_layers=(6, 12, 24, 16), bn_size=4, drop_rate=0.0, num_classes=1000, compression=0.5 ): super(DenseNet, self).__init__() self.features = nn.Sequential() # Stem self.features.add_module( 'stem_conv', nn.Conv2d(3, num_init_features, kernel_size=7, stride=2, padding=3, bias=False) ) self.features.add_module('stem_norm', nn.BatchNorm2d(num_init_features)) self.features.add_module('stem_relu', nn.ReLU(inplace=True)) self.features.add_module( 'stem_pool', nn.MaxPool2d(kernel_size=3, stride=2, padding=1) ) num_features = num_init_features for i, num_layers in enumerate(block_layers): if i > 0: out_features = int(num_features * compression) self.features.add_module(f'transition{i}', Transition(num_features, out_features)) num_features = out_features block = DenseBlock( num_layers, num_features, growth_rate, bn_size, drop_rate ) self.features.add_module(f'denseblock{i + 1}', block) num_features = num_features + num_layers * growth_rate # Final BN + ReLU + Global AvgPool self.features.add_module('final_norm', nn.BatchNorm2d(num_features)) self.features.add_module('final_relu', nn.ReLU(inplace=True)) self.features.add_module( 'final_pool', nn.AdaptiveAvgPool2d((1, 1)) ) self.classifier = nn.Linear(num_features, num_classes) # 初始化 for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x

这里是容易出现接错筋的地方:最后一个Block之后不再接Transition,而是直接接BN、ReLU、全局平均池化。如果多接一个Transition,特征图会被无谓地缩小,精度反而下降。真正的DenseNet论文也不在最后一个Block后面做降维下采样,因为全局平均池化会把任意尺寸变成1×1,不再需要多一次Transition。

初始化方面,卷积层用kaiming_normal初始化的原因和ReLU激活相关;BN层的权重初始化为1、偏置为0,保证初始阶段每个归一化后的特征基本保持原分布。如果不初始化,训练初期loss下降会慢很多,这是常见的隐性坑。

4. 用一张表看清前向传播的维度变化

4.1 标准DenseNet-121在ImageNet输入下的形状推演

拿最经典的DenseNet-121举例,参数为num_init_features=64, growth_rate=32, block_layers=(6, 12, 24, 16), compression=0.5,输入是224×224的RGB图像。推演过程如下:

模块输入尺寸输出尺寸通道数说明
Stem卷积7×7,stride2224×224×3112×112×6464第一次降采样
Stem最大池化3×3,stride2112×112×6456×56×6464第二次降采样
DenseBlock1(6层)56×56×6456×56×25625664+6×32
Transition156×56×25628×28×128128通道减半,尺寸减半
DenseBlock2(12层)28×28×12828×28×512512128+12×32
Transition228×28×51214×14×256256通道减半,尺寸减半
DenseBlock3(24层)14×14×25614×14×10241024256+24×32
Transition314×14×10247×7×512512通道减半,尺寸减半
DenseBlock4(16层)7×7×5127×7×10241024512+16×32
全局平均池化 + 分类7×7×10241×1×10241024展平后接FC

这张表建议写代码时放旁边,边写边对。几乎所有的维度报错都来自中间某一个Block的通道数算错,而只要按照“起始通道 + 层数 × growth rate”这个公式算,每一步都能提前确定。

4.2 CIFAR小图版本的体型选择

标准DenseNet-121对小数据集来说太大了,CIFAR-10只有32×32的输入,7×7卷积加最大池化会把特征图压得太狠。实战中更适合DenseNet-BC的小型配置:num_init_features=24, growth_rate=12, block_layers=(12, 12, 12), compression=0.5,同时把Stem改成3×3卷积,不接最大池化。

推演过程:

模块输入尺寸输出尺寸通道数
Stem 3×3卷积(stride1)32×32×332×32×2424
DenseBlock1(12层)32×32×2432×32×16824+12×12
Transition132×32×16816×16×8484
DenseBlock2(12层)16×16×8416×16×22884+12×12
Transition216×16×2288×8×114114
DenseBlock3(12层)8×8×1148×8×258114+12×12
全局平均池化 + 分类8×8×2581×1×258258

这个配置算下来总层数是12+12+12=36层(每个DenseLayer包含两个卷积层),参数量大概在0.8M左右,普通显卡几小时就能训完300轮。

从推演过程能看出一个关键规律:DenseBlock内的空间尺寸始终不变,只有Transition才会让尺寸减半。如果两个Block之间忘了加Transition,要么后面输入尺寸对不上,要么网络一直以高分辨率跑,计算量爆炸。

5. CIFAR-10 实战:完整训练一个 DenseNet

5.1 数据增强与预处理:32×32小图的常规配置

CIFAR-10的分类任务,数据增强其实很简单:随机裁剪加水平翻转就够;如果想让效果更好,可以加Cutout。我习惯用如下配置:

import torchvision.transforms as transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize( (0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010) ), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize( (0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010) ), ])

这里有个细节:Normalize用的均值和方差是CIFAR-10数据集整体的统计值,不是随便填的。很多人直接写(0.5, 0.5, 0.5)也能跑,但收敛速度和最终精度会略差,因为数据没有被真正“白化”到标准分布。

Cutout如果不嫌麻烦可以加上,我常用的做法:在标准化之后,把张量里随机一块正方形区域置零。它对DenseNet这种特征复用型网络的提升还挺明显,差不多能多1个点。

5.2 模型与优化器选择:为什么我不用Adam而是SGD

模型直接复用上面写的DenseNet类,只要把参数调成小图版本:

model = DenseNet( num_init_features=24, growth_rate=12, block_layers=(12, 12, 12), bn_size=4, drop_rate=0.0, num_classes=10, compression=0.5, )

优化器方面,我推荐SGD而不是Adam。Adam虽然前期收敛快,但在CIFAR这种小数据集上,SGD配合cosine学习率衰减,最终精度通常高1到2个点。weight_decay设成1e-4,对DenseNet这种结构来说已经足够抑制过拟合。

import torch import torch.nn as nn model = model.cuda() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD( model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4, nesterov=True ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=300)

注意nesterov=True,这是一种改进动量方法,在很多CNN实战里比普通动量收敛更稳。

5.3 训练循环:完整代码和容易忽略的坑

训练的部分其实和普通CNN差不多,但有两个地方值得特别提醒:第一个是每个epoch结束都要在验证集上评估;第二个是训练完切换到验证或推理模式时,一定要model.eval(),否则BatchNorm还在用当前batch的统计量,结果会莫名其妙地抖动。

def train_one_epoch(model, train_loader, optimizer, criterion): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, targets in train_loader: inputs, targets = inputs.cuda(), targets.cuda() optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) total += targets.size(0) correct += (predicted == targets).sum().item() return running_loss / total, correct / total def evaluate(model, test_loader, criterion): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, targets in test_loader: inputs, targets = inputs.cuda(), targets.cuda() outputs = model(inputs) loss = criterion(outputs, targets) running_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) total += targets.size(0) correct += (predicted == targets).sum().item() return running_loss / total, correct / total

实际训练300个epoch,前50个epoch提升最快,后面越来越平。我观察到验证集准确率大约在第120至150个epoch之间逐渐超过90%,最终稳定在92%附近。如果加上Cutout,并适当调低weight_decay到5e-5,可以进一步到93%。对只有0.8M参数的小模型来说,这个结果已经非常能打。

6. 实测结果与效率分析:准确率、显存和训练时间的取舍

6.1 与同参数量ResNet的对比

我在相同实验条件下跑过一个参数量接近的ResNet变体。直观感受是:DenseNet的验证集准确率略高,尤其是epoch比较少的时候,优势更明显。原因在于DenseNet每一层都能直接拿到较早层的信息,小数据量下不容易过拟合。

模型参数量CIFAR-10验证集准确率训练显存占用
DenseNet-BC(growth=12, 36层)约0.8M92%左右中等
同规模ResNet约1.1M90%~91%较低

6.2 显存占用为什么比想象中高

虽然DenseNet参数量不大,但训练阶段的显存占用并不低。原因很直接:DenseBlock内部每一层都依赖前面所有层的输出做拼接,因此在前向传播时必须保存每一层的特征图,直到整个Block计算完才能释放。Block越长,显存占用越高。

这和ResNet有本质差别:ResNet的残差相加不需要保存那么多中间特征图,只要把输入和输出各留一份即可。实际训练大图时,DenseNet的显存占用甚至可能超过同参数量ResNet的1.5倍。

如果显存不够,可以考虑论文里提到的memory-efficient实现思路:把每个Block内部的特征图共享一块存储,存储空间只按Block内最大单层特征图来分配,而不是每层都独立保存。这种做法能显著降低峰值显存,但代码复杂度高不少。实战里我建议先用配置1(growth=12)跑通,之后再考虑优化。

6.3 训练时间与收敛曲线

DenseNet的训练速度不算快,原因也在于拼接操作带来的额外显存读写。用小配置、batch size 128、单卡训练时,每个epoch大约需要一分钟左右,300个epoch差不多是4到5小时。

如果时间有限,可以只跑150个epoch,配合cosine衰减,大概能到90%上下。我实际观察到的收敛曲线基本是:前20个epoch快速上升到70%,50个epoch到85%,之后增速放缓,150个epoch后进入90%以上的平台期。这个趋势说明DenseNet的“信息复用”特性在小模型上收敛并不慢,但不能指望它像ResNet那样子网络越高效果越好,因为显存和计算开销会拖后腿。

7. 踩坑记录:实现DenseNet时最常见的四个问题

7.1 忘掉 eval(),导致推理结果飘忽不定

这个问题其实所有带BatchNorm的网络都会遇到,但DenseNet的BN层特别多(每个DenseLayer里就有两个),一旦忘记切到eval模式,每一层的统计量都会变成当前batch的实时均值,结果就是:同一张图,单独跑和放到batch里跑,输出都不一样。

排查方法很简单,训练结束保存模型权重之后,推理前一定要调用model.eval()。如果是用PyTorch Lightning这类框架,框架本身会处理;手写训循环时就要格外留意。

7.2 通道数手算错误,维度对不上的瞬间报错

最常见的错误发生在写DenseBlock构造函数时。很多人会直接把in_channels当作每个DenseLayer的输入通道,但在第2层之后,真实输入通道已经是in_channels + growth_rate了。

# 错误写法 for i in range(num_layers): self.layers.append(DenseLayer(in_channels, growth_rate)) # 正确写法 for i in range(num_layers): current_in_channels = in_channels + i * growth_rate self.layers.append(DenseLayer(current_in_channels, growth_rate))

这类错误PyTorch会在前向传播时直接抛size mismatch,定位起来还算容易,但如果你在网络内部包了好几层,报错信息不一定精准指向DenseBlock的某一行。我建议在每个Block前后打印一下feature shape,能省很多排查时间。

7.3 Transition 里用了 int() 取整,通道数忽多忽少

compression=0.5时,过渡层的输出通道数是int(num_features * compression)。如果输入通道是奇数,比如在某个小配置里得到85,乘0.5会得到42而不是42.5——这没问题。但如果你图省事在别处用了round或者ceil,前后两个模块的维度就可能对不上。

所以统一规则很关键:所有Transition的通道计算都用int()向下取整,并在代码注释里写明。不要有的地方向下取整,有的地方四舍五入,否则后面的DenseBlock在拼接时会维度错乱,而且这种错误非常隐蔽,因为只在某些特定配置下才出现。

7.4 最后一个Block之后画蛇添足加Transition

这个错我在自己实现早期版本时踩过一次,因为直觉上“每个Block后面都该接一个Transition”。但最后一个Block接完Transition后,特征图尺寸被进一步缩小,然后全局平均池化把剩下的信息全抹平,相当于在网络末端白白丢掉了空间信息。

正确做法是最后一个Block后只做BN、ReLU、全局平均池化,然后直接进分类层。对照第4节的维度表,每一步都能确认该不该有Transition。

写在最后的个人经验

实现DenseNet最大的收获不是背会了一个网络结构,而是理解了“特征复用”和“梯度路径短”这两个概念在代码层面的体现。拼接操作、growth rate设计、Transition的压缩逻辑,每一样都能在代码里找到对应位置。如果以后面试被问模型结构,DenseNet是最适合手推的网络之一:它没有花哨的注意力机制,也没有复杂的矩阵运算,只要把循环拼接和通道公式想清楚,白板推演完全可以写下来。最后分享一个调试心得:写天气热的时候不要硬肝,每写完一个模块先跑一个假数据打印shape,比自己盲试要高效得多。

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

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

立即咨询