全文链接:https://tecdat.cn/?p=38782
引言
在当今深度学习领域,卷积神经网络(CNN)架构不断发展与创新,诸多先进的架构被提出并广泛应用。像GoogleNet(ILSVRC 2014获胜者)、ResNet(ILSVRC 2015获胜者)以及DenseNet(CVPR 2017最佳论文奖)等,它们在被提出时都处于当时的顶尖水平,且其核心思想也为如今众多先进架构奠定了基础。所以,深入理解这些架构并掌握如何实现它们是十分重要的。
基础库导入与环境设置
以下是关于路径和随机种子设置等相关代码:
# 数据集下载路径(例如CIFAR10数据集)
DATASET_PATH = "../data"
预训练模型与数据下载
import urllib.request
from urllib.error import HTTPError
# 如果检查点路径不存在则创建
os.makedirs(CHECKPOINT\_PATH, exist\_ok=True)
# 对每个文件,检查是否已存在,不存在则尝试下载
for file\_name in pretrained\_files:
file\_path = os.path.join(CHECKPOINT\_PATH, file_name)
if "/" in file_name:
CIFAR10数据集处理与分析
# 计算数据集的均值
DATA\_MEANS = (train\_dataset.data / 255.0).mean(axis=(0,1,2))
# 计算数据集的标准差
DATA_STD = (traint.data / 255.0).std(axis=(0,1,2))
利用上述计算得到的均值和标准差信息来对数据进行归一化处理,同时在训练过程中使用数据增强技术,以降低过拟合风险并提升模型的泛化能力。
为验证归一化是否有效,可输出单个批次数据的均值和标准差,理想情况下各通道均值接近0,标准差接近1。
最后,可视化训练集中的部分图像以及经过随机数据增强后的样子
NUM_IMAGES = 4
images = \[train\_dataset\[idx\]\[0\] for idx in range(NUM\_IMAGES)\]
plt.imshow(img_grid)
plt.axis('off')
plt.show()
plt.close()
PyTorch Lightning框架
PyTorch Lightning是一个能简化PyTorch中模型训练、评估和测试代码的框架,它还能自动将模型训练相关信息记录到TensorBoard中,并且用很少的代码就能自动保存模型检查点,方便我们将精力集中在实现不同模型架构上,减少代码编写的额外负担。
首先导入该库:
# 尝试导入PyTorch Lightning库
try:
import pytorch_lightning as pl
except ModuleNotFoundError: # 如果Google Colab默认未安装,在此进行安装
!pip install --quiet pytorch-lightning>=1.5
import pytorch_lightning as pl
它自带了很多实用函数,比如设置种子的函数:
# 设置种子
pl.seed_everything(42)
在PyTorch Lightning中,通过定义pl.LightningModule
(继承自torch.nn.Module
)来将代码组织成5个主要部分:
- 初始化(
__init__
):创建所有必要的参数和模型。 - 优化器(
configure_optimizers
):创建优化器、学习率调度器等。 - 训练循环(
training_step
):只需定义单个批次的损失计算,优化器相关的梯度清零、反向传播和参数更新等操作以及日志记录、保存操作等都在后台自动完成。 - 验证循环(
validation_step
):与训练类似,只需定义每步要执行的操作。 - 测试循环(
test_step
):和验证类似,不过是针对测试集进行操作。
以下是一个用于训练CNN的Lightning Module示例代码:
self.loss_module = nn.CrossEntropyLoss()
# 用于在Tensorboard中可视化计算图的示例输入
self.example\_input\_array = torch.zeros((1, 3, 32, 32), dtype=torch.float32)
def forward(self, imgs):
# 前向传播函数,在可视化计算图时运行
return self.model(imgs)
def configure_optimizers(self):
# 支持Adam或SGD优化器
if self.hparams.optimizer_name == "Adam":
# AdamW是带有正确权重衰减实现的Adam(详见https://arxiv.org/pdf/1711.05101.pdf)
optimizer = optim.AdamW(
self.parameters(), **self.hparams.optimizer_hparams)
elif self.hparams.optimizer_name == "SGD":
optimizer = optim.SGD(self.parameters(), **self.hparams.optimizer_hparams)
else:
最后,就可以聚焦于我们要实现的GoogleNet、ResNet和DenseNet等卷积神经网络了。
Inception与GoogleNet
在2014年被提出的GoogleNet凭借其Inception模块的应用赢得了ImageNet挑战赛。通常在本教程中,我们主要聚焦于Inception的概念,而非GoogleNet的具体细节,因为基于Inception诞生了众多后续工作(如Inception-v2、Inception-v3、Inception-v4、Inception-ResNet等),这些后续工作主要着重于提升效率以及构建更深的Inception网络。不过,若要从根本上理解,研究原始的Inception模块就足够了。
Inception模块会在同一个特征图上分别应用四个卷积块,分别是1x1、3x3、5x5的卷积以及一个最大池化操作。这样能让网络从不同感受野去审视相同的数据。理论上,仅学习5x5卷积可能功能会更强大,但这不仅计算量和内存占用更大,而且更容易出现过拟合现象。整体的Inception模块外观如下:
在3x3和5x5卷积之前额外添加的1x1卷积用于降维。这一点尤为关键,因为后续所有分支的特征图会进行合并,我们不希望特征尺寸出现急剧膨胀。由于5x5卷积的计算量是1x1卷积的25倍,所以在大卷积之前进行降维能节省大量计算量和参数。
输入参数说明:
c_in - 来自上一层的输入特征图数量
c_red - 包含"3x3"和"5x5"键的字典,用于指定降维的1x1卷积输出
c_out - 包含"1x1"、"3x3"、"5x5"和"max"键的字典
act_fn - 激活函数类的构造器(例如nn.ReLU)
"""
super().\_\_init\_\_()
# 1x1卷积分支
self.conv_1x1 = nn.Sequential(
nn.Conv2d(c\_in, c\_out\["1x1"\], kernel_size=1),
nn.BatchNorm2d(c_out\["1x1"\]),
act_fn()
)
# 3x3卷积分支
self.conv_3x3 = nn.Sequential(
nn.Conv2d(c\_in, c\_red\["3x3"\], kernel_size=1),
nn.BatchNorm2d(c_red\["3x3"\]),
act_fn(),
nn.Conv2d(c\_red\["3x3"\], c\_out\["3x3"\], kernel_size=3, padding=1),
nn.BatchNorm2d(c_out\["3x3"\]),
act_fn()
)
# 5x5卷积分支
self.conv_5x5 = nn.Sequential(
nn.Conv2d(c\_in, c\_red\["5x5"\], kernel_size=1),
nn.BatchNorm2d(c_red\["5x5"\]),
act_fn(),
GoogleNet架构是由多个Inception模块堆叠而成,偶尔会使用最大池化来降低特征图的高度和宽度。原始的GoogleNet是为ImageNet图像尺寸(224x224像素)设计的,拥有近700万个参数。由于我们是在CIFAR10(图像尺寸为32x32)上进行训练,所以不需要如此复杂的架构,而是采用简化版本。降维和每个滤波器(1x1、3x3、5x5以及最大池化)的输出通道数量需要手动指定,如有兴趣也可进行更改。一般的思路是为3x3卷积设置最多的滤波器,因为它们功能足够强大,能考虑到上下文信息,同时所需参数仅约为5x5卷积的三分之一。
# 对原始图像进行初次卷积,以增加通道数量
self.input_net = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
self.hparams.act_fn()
)
# 堆叠Inception模块
self.inception_blocks = nn.Sequential(
InceptionBlock(64, c\_red={"3x3": 32, "5x5": 16}, c\_out={"1x1": 16, "3x3": 32, "5x5": 8, "max": 8}, act\_fn=self.hparams.act\_fn),
InceptionBlock(64, c\_red={"3x3": 32, "5x5": 16}, c\_out={"1x1": 24, "3x3": 48, "5x5": 12, "max": 12}, act\_fn=self.hparams.act\_fn),
nn.MaxPool2d(3, stride=2, padding=1), # 32x32 => 16x16
InceptionBlock(96, c\_red={"3x3": 32, "5x5": 16}, c\_out={"1x1": 24, "3x3": 48, "5x5": 12, "max": 12}, act\_fn=self.hparams.act\_fn),
现在,我们可以将模型整合到上面定义的模型字典中:
model_dict\["GoogleNet"\] = GoogleNet
Tensorboard日志
PyTorch Lightning的一个很棒的附加功能是能自动记录到TensorBoard中。为了让大家更好地了解TensorBoard的用途,我们可以查看在训练GoogleNet时PyTorch Lightning生成的面板。
TensorBoard包含多个标签页。主标签页是标量标签页,我们可以在此记录单个数值的变化情况,例如训练损失、准确率、学习率等。如果查看训练或验证准确率,就能真切看到使用学习率调度器的影响。降低学习率能使模型的训练性能得到良好提升。同样,查看训练损失时,会发现此时损失会突然下降。不过,训练集上的数值高于验证集,这表明我们的模型存在过拟合现象,对于如此大型的网络来说,这是不可避免的。
TensorBoard中另一个有趣的标签页是图标签页,它展示了从输入到输出按模块构建的网络架构,基本呈现了CIFARModule
前向传播步骤中的操作。双击模块可将其展开,大家可以从不同角度探索架构。图可视化通常有助于验证模型是否按预期运行,以及在计算图中是否遗漏了某些层。
ResNet
ResNet论文是被引用次数最多的人工智能论文之一,它为超过1000层的神经网络奠定了基础。尽管其原理简单,但残差连接的想法非常有效,因为它能支持网络中稳定的梯度传播。我们不是对 (x_{l+1}=F(x_{l})) 进行建模,而是对 (x_{l+1}=x_{l}+F(x_{l})) 建模,其中 (F) 是一个非线性映射(通常是一系列神经网络模块,如卷积、激活函数和归一化操作)。如果对此类残差连接进行反向传播,可得:
(\frac{\partial x_{l+1}}{\partial x_{l}} = \mathbf{I} + \frac{\partial F(x_{l})}{\partial x_{l}})
偏向于单位矩阵的特性保证了稳定的梯度传播,使其受 (F) 本身的影响更小。已经有许多ResNet的变体被提出,大多是关于函数 (F) 或者对求和结果所应用的操作。在本教程中,我们来看其中两种:原始的ResNet模块以及预激活ResNet模块。我们在下方直观对比这两种模块(图源 - He等人):
原始的ResNet模块在跳跃连接之后应用非线性激活函数(通常是ReLU)。相反,预激活ResNet模块在 (F) 的开头就应用非线性操作。两者各有优劣。然而,对于非常深的网络,预激活ResNet表现更好,因为如上述计算所示,其梯度流能保证有单位矩阵的特性,并且不会受到应用于其上的非线性激活的影响。为了进行对比,我们将这两种ResNet类型都实现为较浅的网络。
先来看原始的ResNet模块。上面的示意图已经展示了 (F) 中包含哪些层。我们需要处理的一种特殊情况是,当想要降低图像的宽和高维度时,基本的ResNet模块要求 (F(x_{l})) 与 (x_{l}) 形状相同,所以在与 (F(x_{l})) 相加之前,也需要改变 (x_{l}) 的维度。原始实现使用步长为2的恒等映射,并将额外的特征维度用0填充。不过,更常用的实现是使用步长为2的1x1卷积,因为它能在改变特征维度的同时,在参数和计算成本方面更高效。ResNet模块的代码相对简单,如下所示:
"""
输入参数说明:
c_in - 输入特征数量
act_fn - 激活函数类的构造器(例如nn.ReLU)
subsample - 如果为True,我们要在模块内应用步长,将输出形状在高度和宽度上缩小2倍
c\_out - 输出特征数量。注意,只有当subsample为True时,此参数才有意义,否则c\_out = c_in
"""
super().\_\_init\_\_()
if not subsample:
c\_out = c\_in
# 代表F的网络
self.net = nn.Sequential(
nn.Conv2d(c\_in, c\_out, kernel_size=3, padding=1, stride=1 if not subsample else 2, bias=False), # 不需要偏置,因为批归一化会处理它
nn.BatchNorm2d(c_out),
act_fn(),
接下来实现预激活ResNet模块。为此,我们需要改变self.net
中的层顺序,并且不在输出上应用激活函数。此外,下采样操作也需要应用非线性,因为输入 (x_l) 尚未经过非线性处理。
与模型选择类似,我们定义一个字典来创建从字符串到模块类的映射。我们将在模型中把字符串名称作为超参数值,用于在不同的ResNet模块之间进行选择。大家也可以自行实现其他类型的ResNet模块并添加进来。
整体的ResNet架构是由多个ResNet模块堆叠而成,其中一些模块会对输入进行下采样。当在整个网络中讨论ResNet模块时,我们通常按照相同的输出形状对它们进行分组。因此,如果说ResNet有[3,3,3]
个模块,意思是我们有三组,每组包含3个ResNet模块,并且在第四和第七个模块处进行下采样。在CIFAR10上具有[3,3,3]
个模块的ResNet可视化如下:
这三组分别作用于分辨率为 (32×32)、(16×16) 和 (8×8) 的情况。
"""
输入参数说明:
num_classes - 分类输出的数量(CIFAR10为10)
num_blocks - 包含ResNet模块数量的列表,每组的第一个模块(除了第一组的第一个)使用下采样
c_hidden - 不同模块中的隐藏维度列表,通常随着深度增加而翻倍
act\_fn\_name - 要使用的激活函数名称,从"act\_fn\_by_name"中查找
block\_name - ResNet模块的名称,从"resnet\_blocks\_by\_name"中查找
"""
super().\_\_init\_\_()
assert block\_name in resnet\_blocks\_by\_name
self.hparams = SimpleNamespace(num\_classes=num\_classes,
c\_hidden=c\_hidden,
num\_blocks=num\_blocks,
act\_fn\_name=act\_fn\_name,
act\_fn=act\_fn\_by\_name\[act\_fn\_name\],
block\_class=resnet\_blocks\_by\_name\[block_name\])
self.\_create\_network()
self.\_init\_params()
def \_create\_network(self):
c\_hidden = self.hparams.c\_hidden
# 对原始图像进行初次卷积,以增加通道数量
if self.hparams.block_class == PreActResNetBlock: # => 不在输出上应用非线性
self.input_net = nn.Sequential(
nn.Conv2d(3, c\_hidden\[0\], kernel\_size=3, padding=1, bias=False)
)
最后,我们可以训练ResNet模型。与GoogleNet训练的一个不同之处在于,我们明确使用带动量的SGD作为优化器,而非Adam。在普通的浅层ResNets上,Adam往往会导致准确率稍差一些。目前还不完全清楚为什么Adam在这种情况下表现更差,一种可能的解释与ResNet的损失曲面有关。已有研究表明,ResNet比没有跳跃连接的网络能产生更平滑的损失曲面。有无跳跃连接的损失曲面的一种可能可视化如下:
(x)轴和(y)轴展示了参数空间的投影,(z)轴展示了不同参数值所取得的损失值。在像右侧这样的平滑曲面上,我们可能不需要像Adam提供的那种自适应学习率。相反,Adam可能会陷入局部最优,而SGD能找到更宽泛的最小值,往往泛化能力更好。
下面我们使用SGD来训练模型:
resnet\_model, resnet\_results = train\_model(model\_name="ResNet",
model\_hparams={"num\_classes": 10,
"c_hidden": \[16,32,64\],
"num_blocks": \[3,3,3\],
"act\_fn\_name": "relu"},
optimizer_name="SGD",
optimizer_hparams={"lr": 0.1,
"momentum": 0.9,
"weight_decay": 1e-4})
Tensorboard日志
与GoogleNet模型类似,我们也有ResNet模型的TensorBoard日志。我们可以在下面打开它:
大家可以自行探索TensorBoard,包括计算图等内容。总体而言,我们可以看到,在训练的初期阶段,使用SGD的ResNet的训练损失比GoogleNet更高。不过在降低学习率之后,该模型能取得更高的验证准确率。我们会在笔记末尾对比具体的分数。
DenseNet网络架构分析
在深度学习领域,深度神经网络的架构不断演进,DenseNet 便是其中一种能够支持构建非常深层神经网络的架构。它对于残差连接有着独特的视角,与传统方式有所不同。传统做法往往聚焦于对各层之间差异进行建模,而 DenseNet 则将残差连接视为一种跨层复用特征的有效途径,如此一来,便无需再去学习那些冗余的特征图了。
当网络不断加深时,模型会学习抽象特征以识别各类模式。然而,一些复杂的模式其实是由抽象特征(比如手、脸等)以及低级特征(比如边缘、基础颜色等)共同组合而成的。在深层网络中,标准的卷积神经网络(CNN)若要找到这些低级特征,就不得不去重复学习这类特征图,这会造成大量参数资源的浪费。而 DenseNet 提供了一种高效的特征复用方式,它让每一次卷积操作都依赖于之前所有的输入特征,并且仅添加少量的滤波器,以此来避免上述的资源浪费情况,其原理可参考下图
在 DenseNet 中,最后一层被称作过渡层(TransitionLayer),它承担着降低特征图在高度、宽度以及通道尺寸方面维度的任务。尽管从技术层面来讲,这会在一定程度上打破恒等反向传播,但由于网络中此类过渡层数量较少,所以对整体的梯度流影响并不大。
DenseNet相关模块与整体架构
DenseLayer模块
在神经网络架构中,DenseLayer
模块有着重要作用,以下是其具体的代码实现及相关解释。
class DenseLayer(nn.Module):
def \_\_init\_\_(self, c\_in, bn\_size, growth\_rate, act\_fn):
"""
输入参数说明:
c_in - 输入通道数量
bn_size - 1x1卷积输出的瓶颈尺寸(增长率因子),通常取值在2到4之间。
growth_rate - 3x3卷积的输出通道数量
act_fn - 激活函数类的构造器(例如nn.ReLU)
"""
super().\_\_init\_\_()
self.net = nn.Sequential(
nn.BatchNorm2d(c_in),
act_fn(),
nn.Conv2d(c\_in, bn\_size * growth\_rate, kernel\_size=1, bias=False),
nn.BatchNorm2d(bn\_size * growth\_rate),
act_fn(),
nn.Conv2d(bn\_size * growth\_rate, growth\_rate, kernel\_size=3, padding=1, bias=False)
)
def forward(self, x):
out = self.net(x)
out = torch.cat(\[out, x\], dim=1)
return out
这个DenseLayer
模块的作用是,先对输入数据进行一系列的归一化、激活以及卷积操作,然后将处理后的结果与原始输入在通道维度上进行拼接,最后输出拼接后的结果。
DenseBlock模块
DenseBlock
模块用于汇总按顺序应用的多个密集层:
在这个模块中,通过循环创建多个DenseLayer
,并将它们按顺序组合起来,每个DenseLayer
的输入通道数量会随着层数的增加而动态变化,它是原始输入通道加上前面各层生成的特征图数量,最终输出经过这些密集层处理后的结果。
TransitionLayer模块
TransitionLayer
模块主要用于对输入(通常是一个密集块的最终输出)进行通道维度的降维处理:
该模块先对输入进行归一化和激活操作,接着通过1x1卷积来减少通道数量,然后采用核大小为2、步长为2的平均池化操作来降低高度和宽度维度,这样做相比ResNet中使用的一些方式更具参数效率,最后输出处理后的结果。
DenseNet整体架构
有了上述的基础模块,我们可以构建完整的DenseNet
架构了:
在DenseNet
的构建过程中,首先确定了隐藏通道的初始数量,然后通过对原始图像进行初次卷积得到初始特征。接着,按照设定的层数列表循环创建密集块,并根据情况添加过渡层来逐步调整特征维度。最后,通过一系列操作将处理后的特征映射到分类输出,整个网络结构就这样搭建完成了。
模型训练与TensorBoard日志
与其他模型不同,在训练DenseNet
时,它使用Adam优化器并不会出现像ResNet那样的问题,所以采用Adam来进行训练。这里选择的其他超参数使得构建出的网络在参数规模上与ResNet和GoogleNet相近。通常在设计非常深的网络时,DenseNet
在参数利用效率方面比ResNet更有优势,而且能取得相近甚至更好的性能。
densenet\_model, densenet\_results = train\_model(model\_name="DenseNet",
model\_hparams={"num\_classes": 10,
"num_layers": \[6,6,6,6\],
"bn_size": 2,
"growth_rate": 16,
"act\_fn\_name": "relu"},
optimizer_name="Adam",
optimizer_hparams={"lr": 1e-3,
"weight_decay": 1e-4})
训练完成后,我们同样可以查看DenseNet
训练的TensorBoard日志:
从验证准确率和训练损失的整体变化过程来看,它与GoogleNet的训练情况比较相似,这也和使用Adam进行网络训练有关,大家可以自行去探索训练指标情况。
结论与对比
在分别讨论了各个模型,并对它们都进行训练之后,我们终于可以对它们进行对比了。首先,我们将所有模型的结果整理到一个表格中:
首先,我们可以看到所有模型的表现都还算不错。大家自行实现的简单模型在实际应用中性能会低很多,这除了参数数量较少之外,也和架构设计的选择有关。GoogleNet在验证集和测试集上的性能是最低的,尽管它和DenseNet
的差距比较小。如果对GoogleNet中所有通道尺寸进行合适的超参数搜索,可能会将模型的准确率提升到相近水平,但鉴于超参数数量众多,这样做的成本也比较高。ResNet在验证集上的性能比DenseNet
和GoogleNet高出1%以上,而原始版本和预激活版本之间的差异较小。由此我们可以得出结论,对于浅层网络来说,激活函数的位置似乎并不是关键因素,尽管在一些论文中提到对于非常深的网络情况并非如此(例如He等人的相关研究)。
总体而言,我们可以认为ResNet是一种简单但功能强大的架构。如果将这些模型应用到更复杂的任务中,比如处理更大的图像以及网络内部层数更多的情况,我们可能会看到GoogleNet与像ResNet和DenseNet
这样具有跳跃连接的架构之间出现更大的性能差距。在CIFAR10上与更深层模型的对比可参考此处。有趣的是,DenseNet
在相关设置中性能优于原始的ResNet,但稍逊于预激活ResNet。而最佳模型,即双路径网络(Chen等人),实际上是ResNet和DenseNet
的结合,这表明二者各有优势。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。