一文读懂现代卷积神经网络—稠密连接网络(DenseNet)
目录
什么是 DenseNet?
稠密块(Dense Block)详解
一、稠密块的核心思想
二、稠密块的结构组成
1. 卷积单元(的结构)
2. 密集连接的具体方式
3. 关键参数:增长率(Growth Rate, k)
4. 过渡层(Transition Layer)
三、稠密块的优势
四、与 ResNet 残差块的对比
过渡层(Transition Layer)详解
一、什么是过渡层?
二、过渡层的核心作用
三、过渡层的典型结构
四、设计细节与参数
五、与 ResNet 中对应组件的对比
DenseNet 的作用
DenseNet 与 ResNet 的核心区别
ResNet和DenseNet的结构示意图
为啥卷积块中采用 “批量归一化(BN)→ 激活函数 → 卷积层” 的顺序?
1. 批量归一化(BN)放在最前:从根源稳定输入分布
2. 激活函数放在 BN 之后:让激活更 “有效”
3. 卷积层放在最后:利用稳定输入提升参数学习效率
完整代码
实验结果
什么是 DenseNet?
稠密连接网络(Dense Convolutional Network,简称 DenseNet)是 2017 年由 Huang 等人提出的一种深层卷积神经网络,其核心创新是“稠密连接(Dense Connection)”:网络中的每个层都会与前面所有层直接连接,即第l层的输入是前l-1层的输出的拼接(而非简单相加)。
核心结构:
稠密块(Dense Block):由多个卷积层组成,层间通过稠密连接融合特征。设第i层的输出为
,则第l层的输入为前所有层输出的拼接:
其中
表示通道维度上的拼接,
是第l层的卷积操作(含 BN、ReLU、卷积核)。
过渡层(Transition Layer):用于连接不同的稠密块,通过 1×1 卷积降维和 2×2 平均池化减小特征图尺寸,防止网络参数爆炸。
稠密块(dense block)和过渡层(transition layer)。 前者定义如何连接输入和输出,而后者则控制通道数量,使其不会太复杂。
稠密块(Dense Block)详解
稠密块(Dense Block)是深度学习网络 DenseNet(Densely Connected Convolutional Networks) 的核心组成单元,其设计核心是通过密集连接(Dense Connection) 实现极致的特征重用,是对传统卷积网络和 ResNet 中 “跳跃连接” 的进一步升级。
一、稠密块的核心思想
传统卷积网络中,每一层的输入仅来自上一层;ResNet 通过 “跳跃连接” 让层的输入包含上一层的输出(元素相加);而稠密块则更进一步 ——每一层的输入是前面所有层的输出的拼接(Concatenation)。
具体来说,若稠密块包含 L 层,第 l 层(记为
)的输入是第
层的输出的拼接,输出为
,其中:
是稠密块的初始输入特征图;
表示将这些特征图在通道维度上拼接;
是第 l 层的卷积操作(通常包含 BN、ReLU、卷积等子操作)。
这种 “密集连接” 使得特征在网络中能够被充分传递和复用,从根本上解决了深层网络的 “特征衰减” 问题。
二、稠密块的结构组成
一个典型的稠密块由多个 “卷积单元” 和 “密集连接” 组成,同时为了控制计算量,通常会包含瓶颈层(Bottleneck) 和过渡层(Transition Layer)(过渡层用于连接不同稠密块,非稠密块内部结构,但需结合理解)。
1. 卷积单元(
的结构)
稠密块中的每一层 \(H_l\) 通常由以下子操作组成(顺序固定):
- BN(Batch Normalization):标准化特征,加速训练;
- ReLU:非线性激活,增强表达能力;
- 1x1 卷积(可选,瓶颈层):减少输入特征的通道数(如将通道数压缩至原来的 1/4),降低计算量;
- 3x3 卷积:提取局部特征,输出固定数量的特征图(由 “增长率” 决定)。
其中,1x1 卷积作为 “瓶颈层” 是 DenseNet 的重要优化:若直接对拼接后的高维特征做 3x3 卷积,计算量会爆炸;而 1x1 卷积可先将通道数降至较低维度(如 4k,k 为增长率),再用 3x3 卷积输出 k 个特征图,既保证特征提取效率,又控制了参数规模。
2. 密集连接的具体方式
假设稠密块的初始输入特征图为
(通道数为
),每一层的输出特征图通道数为 k(即 “增长率”),则:
- 第 1 层输入:
,输出:
(通道数 k);
- 第 2 层输入:
(通道数
),输出:
(通道数 k);
- 第 3 层输入:
(通道数
),输出:
(通道数 k);
- ...
- 第 L 层输入:
(通道数
),输出:
(通道数 k)。
最终,整个稠密块的输出是所有层输出的拼接:
(通道数
)。
3. 关键参数:增长率(Growth Rate, k)
增长率 k 是稠密块的核心参数,定义为每一层输出的特征图通道数。它控制了特征图的增长速度:
- 若 k 较小(如 12、24),即使层数较多,拼接后的总通道数也不会过大,保证计算效率;
- 实验表明,较小的 k(如 k=12)即可让 DenseNet 达到优异性能,说明密集连接对特征的利用率极高。
4. 过渡层(Transition Layer)
稠密块之间通过 “过渡层” 连接,作用是压缩特征图通道数并降低尺寸,避免网络冗余:
- 过渡层通常由 “BN + 1x1 卷积 + 2x2 平均池化” 组成;
- 1x1 卷积将前一个稠密块的输出通道数压缩(如压缩至原来的 θ 倍,θ∈(0,1],称为 “压缩因子”);
- 平均池化将特征图尺寸减半(如从 32x32 变为 16x16),控制网络深度和计算量。
三、稠密块的优势
极致的特征重用 每一层都能直接访问前面所有层的特征,特征在网络中被反复利用,避免了传统网络中 “特征随层数增加而衰减” 的问题。
缓解梯度消失 反向传播时,梯度可通过密集连接直接从深层传递到浅层,大幅缓解深层网络的梯度消失问题,使训练更深的网络成为可能。
参数效率更高 由于特征重用充分,DenseNet 无需像其他网络(如 ResNet)那样通过增加通道数提升性能,小增长率 k 即可实现高准确率,参数规模通常小于 ResNet。
正则化效果 密集连接增加了层之间的相互依赖,一定程度上减少了过拟合,提升模型泛化能力。
四、与 ResNet 残差块的对比
对比维度 稠密块(Dense Block) 残差块(Residual Block) 连接方式 特征图拼接(Concatenation) 特征图元素相加(Element-wise Add) 输入来源 前面所有层的输出 仅上一层的输出(跨层相加) 特征利用效率 极高(所有历史特征直接参与当前层) 较高(仅上一层特征与当前层特征融合) 特征维度变化 随层数线性增长(由增长率 k 控制) 基本不变(相加不改变通道数) 计算量控制 依赖瓶颈层(1x1 卷积)和过渡层 依赖残差连接的 “恒等映射”
过渡层(Transition Layer)详解
过渡层(Transition Layer)是稠密连接网络(DenseNet) 中的关键组件,主要用于连接相邻的稠密块(Dense Block),并实现特征图的降维和压缩,在保证网络效率的同时控制模型复杂度。以下从定义、作用、结构细节及设计意义展开详解:
一、什么是过渡层?
过渡层是 DenseNet 中位于两个稠密块之间的连接模块,其核心功能是对前一个稠密块输出的特征图进行处理,为下一个稠密块提供合适维度的输入。由于稠密块会通过密集连接生成大量特征图(如一个包含 12 层的稠密块可能输出数百个特征图),过渡层的作用类似于 “桥梁”,通过降维减少特征数量,避免网络参数和计算量过度膨胀。
二、过渡层的核心作用
特征降维 稠密块的输出特征图数量通常较多(例如,每个稠密块会累计生成
个特征图,其中 k 是增长率,L 是块内层数)。过渡层通过卷积操作将特征图数量按比例压缩(通常压缩至原来的
倍,
称为压缩因子,一般取 0.5),减少后续计算量。
空间尺寸缩减 通过池化操作(通常是 2×2 的平均池化)将特征图的空间尺寸(高和宽)缩小一半,与传统卷积网络中 “下采样→增大感受野” 的设计思路一致,帮助网络捕捉更全局的特征。
特征融合与平滑 过渡层中的卷积操作(通常是 1×1 卷积)可以对稠密块输出的多通道特征进行融合,减少冗余信息,同时保持特征的连续性,为下一个稠密块的输入提供更精炼的特征。
三、过渡层的典型结构
过渡层的结构简洁,通常由两个操作组成,按顺序执行:
1×1 卷积
- 作用:对输入特征图进行通道压缩(降维),并融合通道间的信息。
- 细节:卷积核数量为前一个稠密块输出特征数的
倍(
),当
时不压缩),步长为 1, padding 为 0。
- 示例:若前一个稠密块输出 200 个特征图,
,则 1×1 卷积后输出 100 个特征图。
2×2 平均池化
- 作用:将特征图的空间尺寸(如
)缩减为
,实现下采样。
- 细节:池化核大小 2×2,步长 2,无 padding,确保尺寸减半。
四、设计细节与参数
- 压缩因子
:是 DenseNet 的重要超参数,控制特征压缩比例。当
时,DenseNet 称为 “压缩 DenseNet”(如 DenseNet-C),若
则不压缩。实验中
是常用设置,可在精度和效率间取得平衡。
- 激活函数与归一化:过渡层的 1×1 卷积后通常会跟随批量归一化(BN)和 ReLU 激活函数,确保特征分布稳定并引入非线性。
五、与 ResNet 中对应组件的对比
在 ResNet 中,连接不同残差块的下采样通常通过 “stride=2 的 3×3 卷积” 或 “单独的池化层 + 卷积” 实现,目的是缩减尺寸但不刻意压缩通道数。而过渡层的核心差异在于:
- 主动降维:通过 1×1 卷积和压缩因子主动减少通道数,更注重控制模型参数。
- 与稠密块的配合:由于稠密块会累计大量特征,过渡层的降维是 DenseNet 控制复杂度的关键,而 ResNet 的残差块不会累计特征,因此无需专门的压缩机制。
DenseNet 的作用
缓解梯度消失问题 稠密连接让每个层都能直接接收前面所有层的梯度(反向传播时,梯度无需经过多层累积),确保深层网络的梯度能有效传递到浅层,解决了深层网络训练困难的问题。
促进特征复用 每个层的输入包含前面所有层的特征(而非仅前一层),特征在网络中被多次复用,减少了冗余特征的学习,提升了特征利用效率。例如,浅层的边缘特征和深层的语义特征可直接融合,增强模型表达能力。
减少参数数量 由于特征复用充分,DenseNet 无需像 ResNet 那样通过增加通道数来提升性能(通道数通常远小于 ResNet),因此参数总量更少(例如 DenseNet-121 的参数约 800 万,仅为 ResNet-50 的 1/3)。
抑制过拟合 特征的多次复用相当于给网络引入了 “正则化” 效果,尤其在小数据集上,过拟合风险更低,泛化能力更强。
DenseNet 与 ResNet 的核心区别
对比维度 ResNet(残差网络) DenseNet(稠密连接网络) 连接方式 每个层仅与前一层连接(“链式”):\(x_l = H_l(x_{l-1}) + x_{l-1}\) 每个层与前面所有层连接(“稠密”):\(x_l = H_l([x_0, ..., x_{l-1}])\) 特征融合方式 残差相加(元素级加法,要求通道数相同) 特征拼接(通道级拼接,通道数随层数累积) 通道数变化 随深度翻倍(如 64→128→256→512),通过升维提升能力 单一层通道数固定(如 32),通过拼接累积总通道数(复用特征) 参数效率 较低(通道数大,参数多) 较高(通道数小,特征复用减少冗余参数) 计算复杂度 中等(加法操作轻量,但通道数大) 较高(拼接导致总通道数大,需通过过渡层控制) 梯度传播 梯度通过残差连接间接传递(需经过前一层) 梯度直接传递到所有浅层(无中间层阻碍) 适用场景 超深网络(如 ResNet-152)、大数据集(需强表达能力) 中小数据集(泛化能力强)、对参数敏感的场景
ResNet和DenseNet的结构示意图
为啥卷积块中采用 “批量归一化(BN)→ 激活函数 → 卷积层” 的顺序?
1. 批量归一化(BN)放在最前:从根源稳定输入分布
BN 的核心功能是标准化输入数据的分布(将每个通道的输入调整为均值 0、方差 1 的标准分布),从而解决 “内部协变量偏移”。
如果将 BN 放在卷积层之后(即 “卷积→BN→激活”),则 BN 只能标准化卷积层的输出;而放在卷积层之前(即 “BN→激活→卷积”),BN 可以直接标准化进入卷积层的原始输入,从更早期稳定数据分布,效果更彻底:
- 卷积层的输入分布更稳定,参数更新时输出变化更平缓,训练更稳定;
- 避免卷积层因输入分布剧烈波动而陷入 “参数震荡”(例如卷积核反复调整以适应突变的输入)。
2. 激活函数放在 BN 之后:让激活更 “有效”
激活函数(如 ReLU)的作用是引入非线性,但其效果高度依赖输入分布:
- ReLU 对负数输入会 “截断”(输出 0),若输入分布不稳定(例如均值偏移到负数区域),会导致大量神经元 “死亡”(输出恒为 0);
- BN 将输入标准化为 “均值 0、方差 1” 的分布后,ReLU 的输入会均匀分布在正负区间,既能保留足够多的正输入(激活有效神经元),又能通过负输入的截断引入非线性,避免激活函数失效。
3. 卷积层放在最后:利用稳定输入提升参数学习效率
卷积层是特征提取的核心,其参数学习(通过梯度下降更新 W 和 b)需要稳定的输入分布:
- 经过 BN 标准化和激活函数非线性变换后,输入到卷积层的数据分布已非常稳定,卷积核可以更高效地学习 “有意义的特征模式”(例如边缘、纹理);
- 若卷积层放在最前,其输出分布会因参数更新而剧烈变化,后续的 BN 和激活函数需要不断 “适配” 这种变化,降低训练效率。
先稳定分布,再引入非线性,最后高效提取特征
完整代码
"""
文件名: 7.7 稠密连接网络(DenseNet)
作者: 墨尘
日期: 2025/7/14
项目名: dl_env
备注: 实现完整的DenseNet网络,包含稠密块、过渡层及端到端训练流程,用于Fashion-MNIST分类
"""import torch
from torch import nn
from d2l import torch as d2l
# 手动显示图像相关库
import matplotlib.pyplot as plt # 绘图库
import matplotlib.text as text # 用于修改文本绘制(解决符号显示问题)# -------------------------- 核心解决方案:解决文本显示问题 --------------------------
# 定义替换函数:将Unicode减号(U+2212,可能导致显示异常)替换为普通减号(-)
def replace_minus(s):"""解决Matplotlib中Unicode减号显示异常的问题参数:s: 待处理的字符串或其他类型对象返回:处理后的字符串或原始对象(非字符串类型)"""if isinstance(s, str): # 判断输入是否为字符串return s.replace('\u2212', '-') # 替换特殊减号为普通减号return s # 非字符串直接返回# 重写matplotlib的Text类的set_text方法,解决减号显示异常
original_set_text = text.Text.set_text # 保存原始的set_text方法
def new_set_text(self, s):"""重写后的文本设置方法,在设置文本前先处理减号显示问题"""s = replace_minus(s) # 调用替换函数处理文本中的减号return original_set_text(self, s) # 调用原始方法设置文本
text.Text.set_text = new_set_text # 应用重写后的方法# -------------------------- 字体配置(确保中文和数学符号正常显示)--------------------------
plt.rcParams["font.family"] = ["SimHei"] # 设置中文字体(支持中文显示)
plt.rcParams["text.usetex"] = True # 使用LaTeX渲染文本(提升数学符号显示效果)
plt.rcParams["axes.unicode_minus"] = True # 确保负号正确显示(避免显示为方块)
plt.rcParams["mathtext.fontset"] = "cm" # 设置数学符号字体为Computer Modern(更美观)
d2l.plt.rcParams.update(plt.rcParams) # 让d2l库的绘图工具继承上述字体配置# 定义卷积块:BN + ReLU + 3x3卷积(DenseNet的基础单元)
def conv_block(input_channels, num_channels):"""构建DenseNet中的基础卷积单元,实现特征提取参数:input_channels: 输入特征图的通道数num_channels: 输出特征图的通道数(即增长率的一部分)返回:nn.Sequential: 包含批量归一化、激活函数和卷积层的序列模块"""return nn.Sequential(nn.BatchNorm2d(input_channels), # 批量归一化:稳定输入分布,加速训练nn.ReLU(), # ReLU激活:引入非线性,增强特征表达能力# 3x3卷积:提取局部空间特征,padding=1确保输出尺寸与输入相同nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1))class DenseBlock(nn.Module):"""稠密块(DenseBlock):DenseNet的核心组件,通过密集连接实现特征复用参数:num_convs: 块内包含的卷积块数量input_channels: 初始输入通道数num_channels: 每个卷积块的输出通道数(即"增长率",控制特征增长速度)"""def __init__(self, num_convs, input_channels, num_channels):super(DenseBlock, self).__init__()layer = []# 逐个添加卷积块,每个卷积块的输入通道数随层数递增for i in range(num_convs):# 第i个卷积块的输入通道数 = 初始通道数 + i×增长率# 例如:第0个卷积块输入=input_channels,第1个=input_channels+num_channels,以此类推layer.append(conv_block(input_channels + i * num_channels, # 动态计算输入通道数num_channels # 固定输出通道数(增长率)))self.net = nn.Sequential(*layer) # 将所有卷积块组合成序列def forward(self, X):"""前向传播:通过密集连接拼接所有卷积块的输出参数:X: 输入特征图,形状为(batch_size, input_channels, height, width)返回:拼接后的特征图,形状为(batch_size, final_channels, height, width)其中final_channels = input_channels + num_convs×num_channels"""for blk in self.net:Y = blk(X) # 当前卷积块的输出# 在通道维度(dim=1)上拼接原始输入X和当前输出Y(密集连接的核心)X = torch.cat((X, Y), dim=1)return X# 过渡层:连接两个稠密块,实现特征降维和尺寸缩减
def transition_block(input_channels, num_channels):"""过渡层:压缩特征通道数并减小空间尺寸,避免网络参数爆炸参数:input_channels: 输入特征图的通道数(前一个稠密块的输出)num_channels: 输出特征图的通道数(通常为输入的1/2,即压缩因子0.5)返回:nn.Sequential: 包含批量归一化、激活、卷积和池化的序列模块"""return nn.Sequential(nn.BatchNorm2d(input_channels), # 批量归一化:稳定过渡层输入分布nn.ReLU(), # 激活函数:引入非线性# 1x1卷积:减少通道数(核心降维操作,参数少且计算高效)nn.Conv2d(input_channels, num_channels, kernel_size=1),# 2x2平均池化:将空间尺寸减半(height和width各除以2)nn.AvgPool2d(kernel_size=2, stride=2))if __name__ == '__main__':# -------------------------- 测试核心组件功能 --------------------------# 测试1:稠密块(2个卷积块,输入3通道,增长率10)dense_blk = DenseBlock(num_convs=2, input_channels=3, num_channels=10)X = torch.randn(4, 3, 8, 8) # 随机输入:4个样本,3通道,8x8尺寸Y = dense_blk(X)print(f"稠密块输出形状: {Y.shape}") # 预期:(4, 3+2×10=23, 8, 8)# 测试2:过渡层(输入23通道,输出10通道)trans_blk = transition_block(input_channels=23, num_channels=10)Z = trans_blk(Y)print(f"过渡层输出形状: {Z.shape}") # 预期:(4, 10, 4, 4)(尺寸减半)# -------------------------- 构建完整DenseNet网络 --------------------------# 第一个模块:初始卷积+池化(预处理输入图像)b1 = nn.Sequential(# 7x7大卷积:初步提取全局特征,步长2压缩尺寸nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),nn.BatchNorm2d(64), # 批量归一化nn.ReLU(), # 激活# 3x3最大池化:进一步将尺寸减半(为后续稠密块做准备)nn.MaxPool2d(kernel_size=3, stride=2, padding=1))# 配置稠密块参数num_channels = 64 # 初始通道数(与b1输出通道一致)growth_rate = 32 # 增长率:每个卷积块的输出通道数(控制特征增长速度)num_convs_in_dense_blocks = [4, 4, 4, 4] # 每个稠密块包含的卷积块数量(总4个稠密块)# 构建后续模块(稠密块+过渡层)blks = []for i, num_convs in enumerate(num_convs_in_dense_blocks):# 添加稠密块blks.append(DenseBlock(num_convs, num_channels, growth_rate))# 更新当前通道数:稠密块输出通道 = 输入通道 + 卷积块数量×增长率num_channels += num_convs * growth_rate# 除最后一个稠密块外,添加过渡层(通道数减半)if i != len(num_convs_in_dense_blocks) - 1:blks.append(transition_block(num_channels, num_channels // 2))num_channels = num_channels // 2 # 更新通道数为过渡层输出# 组装完整网络net = nn.Sequential(b1, # 初始模块*blks, # 所有稠密块和过渡层nn.BatchNorm2d(num_channels), # 最终批量归一化nn.ReLU(), # 激活nn.AdaptiveAvgPool2d((1, 1)), # 全局平均池化:将特征图压缩为1x1nn.Flatten(), # 展平为一维向量nn.Linear(num_channels, 10) # 全连接层:输出10类(Fashion-MNIST))# -------------------------- 训练DenseNet模型 --------------------------# 训练参数lr = 0.1 # 学习率(DenseNet对学习率较鲁棒)num_epochs = 10 # 训练轮数batch_size = 256 # 批量大小# 加载Fashion-MNIST数据集(调整图像大小为96x96适配网络)train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)# 启动训练(使用d2l库的训练函数,自动支持GPU)print("\n开始训练DenseNet模型...")d2l.train_ch6(net, train_iter, test_iter,num_epochs, lr,device=d2l.try_gpu() # 自动选择GPU(如有))# 显示训练曲线(损失+准确率)plt.show(block=True)