8.8. 设计卷积网络架构¶ 在 SageMaker Studio Lab 中打开 Notebook
前面的章节已经带我们回顾了用于计算机视觉的现代网络设计。我们所涉及的所有工作的共同点是,它们在很大程度上都依赖于科学家的直觉。许多架构在很大程度上都受到了人类创造力的影响,而对深度网络所提供的设计空间的系统探索则少得多。尽管如此,这种“网络工程”方法已经取得了巨大的成功。
自从AlexNet(第 8.1 节)在ImageNet上击败传统计算机视觉模型以来,通过堆叠按照相同模式设计的卷积块来构建非常深的网络已经变得很流行。特别是,VGG网络(第 8.2 节)推广了\(3 \times 3\)卷积。NiN(第 8.3 节)表明,即使是\(1 \times 1\)卷积也可以通过增加局部非线性而受益。此外,NiN通过聚合所有位置的信息解决了在网络头部聚合信息的问题。GoogLeNet(第 8.4 节)增加了多个不同卷积宽度的分支,在其Inception块中结合了VGG和NiN的优点。ResNets(第 8.6 节)将归纳偏置改变为恒等映射(从\(f(x) = 0\)开始)。这使得网络可以非常深。近十年后,ResNet的设计仍然很受欢迎,这证明了其设计的成功。最后,ResNeXt(第 8.6.5 节)增加了分组卷积,在参数和计算之间提供了更好的权衡。作为视觉Transformer的前身,Squeeze-and-Excitation Networks(SENets)允许在不同位置之间进行高效的信息传递 (Hu et al., 2018)。这是通过计算每个通道的全局注意力函数来实现的。
到目前为止,我们忽略了通过“神经架构搜索”(NAS)(Liu et al., 2018, Zoph and Le, 2016)获得的。我们选择这样做是因为它们的成本通常是巨大的,依赖于暴力搜索、遗传算法、强化学习或其他形式的超参数优化。给定一个固定的搜索空间,NAS使用一种搜索策略,根据返回的性能估计自动选择一个架构。NAS的结果是一个单一的网络实例。EfficientNets是这种搜索的一个著名成果(Tan and Le, 2019)。
接下来,我们将讨论一个与寻求“单一最佳网络”截然不同的想法。它的计算成本相对较低,在此过程中能带来科学见解,并且在结果质量方面非常有效。让我们回顾一下Radosavovic et al. (2020)的策略,即“设计网络设计空间”。该策略结合了手动设计和NAS的优点。它通过对“网络分布”进行操作,并以一种方式优化分布,从而为整个网络家族获得良好的性能。其成果是“RegNets”,特别是RegNetX和RegNetY,以及一系列用于设计高性能CNN的指导原则。
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
from mxnet import init, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l
npx.set_np()
from flax import linen as nn
from d2l import jax as d2l
import tensorflow as tf
from d2l import tensorflow as d2l
8.8.1. AnyNet设计空间¶
下面的描述与Radosavovic et al. (2020)中的推理非常相似,为了适应本书的篇幅做了一些简化。首先,我们需要一个模板来探索网络家族。本章中设计的共同点之一是网络由一个“主干”(stem)、一个“主体”(body)和一个“头部”(head)组成。主干执行初始的图像处理,通常通过较大窗口尺寸的卷积。主体由多个块组成,执行从原始图像到对象表示所需的大部分转换。最后,头部将其转换为所需的输出,例如通过softmax回归器进行多类分类。主体又由多个阶段组成,在分辨率递减的图像上操作。实际上,主干和每个后续阶段都将空间分辨率减小四分之一。最后,每个阶段由一个或多个块组成。这种模式在所有网络中都很常见,从VGG到ResNeXt。事实上,为了设计通用的AnyNet网络,Radosavovic et al. (2020)使用了图 8.6.5中的ResNeXt块。
图 8.8.1 AnyNet设计空间。每个箭头旁的数字\((\mathit{c}, \mathit{r})\)表示该点的通道数\(c\)和图像分辨率\(\mathit{r} \times \mathit{r}\)。从左到右:由主干、主体和头部组成的通用网络结构;由四个阶段组成的主体;一个阶段的详细结构;块的两种替代结构,一种没有下采样,另一种在每个维度上将分辨率减半。设计选择包括每个阶段\(\mathit{i}\)的深度\(\mathit{d_i}\)、输出通道数\(\mathit{c_i}\)、分组数\(\mathit{g_i}\)和瓶颈比率\(\mathit{k_i}\)。¶
让我们详细回顾一下图 8.8.1中概述的结构。如前所述,一个AnyNet由主干、主体和头部组成。主干以RGB图像(3个通道)作为输入,使用步幅为\(2\)的\(3 \times 3\)卷积,然后进行批量归一化,将分辨率从\(r \times r\)减半到\(r/2 \times r/2\)。此外,它生成\(c_0\)个通道作为主体的输入。
由于该网络旨在与形状为\(224 \times 224 \times 3\)的ImageNet图像良好配合,主体通过4个阶段将其缩减为\(7 \times 7 \times c_4\)(回想一下\(224 / 2^{1+4} = 7\)),每个阶段最终的步幅为\(2\)。最后,头部采用完全标准的设计,通过全局平均池化,类似于NiN(第 8.3 节),然后是一个全连接层,为\(n\)类分类输出一个\(n\)维向量。
大多数相关的设计决策都内在于网络的主体部分。它按阶段进行,每个阶段都由我们在第 8.6.5 节中讨论的相同类型的ResNeXt块组成。那里的设计也完全是通用的:我们从一个通过使用步幅为\(2\)的块(图 8.8.1中最右边的那个)来将分辨率减半开始。为了匹配这一点,ResNeXt块的残差分支需要通过一个\(1 \times 1\)卷积。这个块后面跟着可变数量的额外ResNeXt块,这些块保持分辨率和通道数不变。请注意,一个常见的设计实践是在卷积块的设计中增加一个轻微的瓶颈。因此,对于瓶颈比率\(k_i \geq 1\),我们在每个阶段\(i\)的每个块内分配一定数量的通道\(c_i/k_i\)(正如实验所示,这并不是很有效,应该跳过)。最后,由于我们正在处理ResNeXt块,我们还需要选择在阶段\(i\)进行分组卷积的分组数\(g_i\)。
这个看似通用的设计空间仍然为我们提供了许多参数:我们可以设置块宽度(通道数)\(c_0, \ldots c_4\)、每个阶段的深度(块数)\(d_1, \ldots d_4\)、瓶颈比率\(k_1, \ldots k_4\)以及分组宽度(分组数)\(g_1, \ldots g_4\)。总共这加起来有17个参数,导致了数量大得不合理的配置需要探索。我们需要一些工具来有效地减少这个巨大的设计空间。这就是设计空间概念之美所在。在此之前,让我们先实现通用设计。
class AnyNet(d2l.Classifier):
def stem(self, num_channels):
return nn.Sequential(
nn.LazyConv2d(num_channels, kernel_size=3, stride=2, padding=1),
nn.LazyBatchNorm2d(), nn.ReLU())
class AnyNet(d2l.Classifier):
def stem(self, num_channels):
net = nn.Sequential()
net.add(nn.Conv2D(num_channels, kernel_size=3, padding=1, strides=2),
nn.BatchNorm(), nn.Activation('relu'))
return net
class AnyNet(d2l.Classifier):
arch: tuple
stem_channels: int
lr: float = 0.1
num_classes: int = 10
training: bool = True
def setup(self):
self.net = self.create_net()
def stem(self, num_channels):
return nn.Sequential([
nn.Conv(num_channels, kernel_size=(3, 3), strides=(2, 2),
padding=(1, 1)),
nn.BatchNorm(not self.training),
nn.relu
])
class AnyNet(d2l.Classifier):
def stem(self, num_channels):
return tf.keras.models.Sequential([
tf.keras.layers.Conv2D(num_channels, kernel_size=3, strides=2,
padding='same'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation('relu')])
每个阶段由depth
个ResNeXt块组成,其中num_channels
指定块的宽度。请注意,第一个块将输入图像的高度和宽度减半。
@d2l.add_to_class(AnyNet)
def stage(self, depth, num_channels, groups, bot_mul):
blk = []
for i in range(depth):
if i == 0:
blk.append(d2l.ResNeXtBlock(num_channels, groups, bot_mul,
use_1x1conv=True, strides=2))
else:
blk.append(d2l.ResNeXtBlock(num_channels, groups, bot_mul))
return nn.Sequential(*blk)
@d2l.add_to_class(AnyNet)
def stage(self, depth, num_channels, groups, bot_mul):
net = nn.Sequential()
for i in range(depth):
if i == 0:
net.add(d2l.ResNeXtBlock(
num_channels, groups, bot_mul, use_1x1conv=True, strides=2))
else:
net.add(d2l.ResNeXtBlock(
num_channels, num_channels, groups, bot_mul))
return net
@d2l.add_to_class(AnyNet)
def stage(self, depth, num_channels, groups, bot_mul):
blk = []
for i in range(depth):
if i == 0:
blk.append(d2l.ResNeXtBlock(num_channels, groups, bot_mul,
use_1x1conv=True, strides=(2, 2), training=self.training))
else:
blk.append(d2l.ResNeXtBlock(num_channels, groups, bot_mul,
training=self.training))
return nn.Sequential(blk)
@d2l.add_to_class(AnyNet)
def stage(self, depth, num_channels, groups, bot_mul):
net = tf.keras.models.Sequential()
for i in range(depth):
if i == 0:
net.add(d2l.ResNeXtBlock(num_channels, groups, bot_mul,
use_1x1conv=True, strides=2))
else:
net.add(d2l.ResNeXtBlock(num_channels, groups, bot_mul))
return net
将网络的主干、主体和头部组合在一起,我们完成了AnyNet的实现。
@d2l.add_to_class(AnyNet)
def __init__(self, arch, stem_channels, lr=0.1, num_classes=10):
super(AnyNet, self).__init__()
self.save_hyperparameters()
self.net = nn.Sequential(self.stem(stem_channels))
for i, s in enumerate(arch):
self.net.add_module(f'stage{i+1}', self.stage(*s))
self.net.add_module('head', nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(),
nn.LazyLinear(num_classes)))
self.net.apply(d2l.init_cnn)
@d2l.add_to_class(AnyNet)
def __init__(self, arch, stem_channels, lr=0.1, num_classes=10):
super(AnyNet, self).__init__()
self.save_hyperparameters()
self.net = nn.Sequential()
self.net.add(self.stem(stem_channels))
for i, s in enumerate(arch):
self.net.add(self.stage(*s))
self.net.add(nn.GlobalAvgPool2D(), nn.Dense(num_classes))
self.net.initialize(init.Xavier())
@d2l.add_to_class(AnyNet)
def create_net(self):
net = nn.Sequential([self.stem(self.stem_channels)])
for i, s in enumerate(self.arch):
net.layers.extend([self.stage(*s)])
net.layers.extend([nn.Sequential([
lambda x: nn.avg_pool(x, window_shape=x.shape[1:3],
strides=x.shape[1:3], padding='valid'),
lambda x: x.reshape((x.shape[0], -1)),
nn.Dense(self.num_classes)])])
return net
@d2l.add_to_class(AnyNet)
def __init__(self, arch, stem_channels, lr=0.1, num_classes=10):
super(AnyNet, self).__init__()
self.save_hyperparameters()
self.net = tf.keras.models.Sequential(self.stem(stem_channels))
for i, s in enumerate(arch):
self.net.add(self.stage(*s))
self.net.add(tf.keras.models.Sequential([
tf.keras.layers.GlobalAvgPool2D(),
tf.keras.layers.Dense(units=num_classes)]))
8.8.2. 设计空间的分布和参数¶
正如在第 8.8.1 节中刚刚讨论的,设计空间的参数是该设计空间中网络的超参数。考虑在AnyNet设计空间中识别好的参数的问题。我们可以尝试为给定的计算量(例如,FLOPs和计算时间)找到“单一最佳”参数选择。如果我们只允许每个参数有两个可能的选择,我们将不得不探索\(2^{17} = 131072\)个组合来找到最佳解决方案。由于其高昂的成本,这显然是不可行的。更糟糕的是,我们从这个练习中并没有真正学到任何关于如何设计网络的东西。下次我们增加,比如说,一个X阶段,或者一个移位操作,或者类似的东西,我们将需要从头开始。更糟糕的是,由于训练中的随机性(舍入、洗牌、位错误),没有两次运行可能产生完全相同的结果。一个更好的策略是尝试确定参数选择应该如何相关的通用准则。例如,瓶颈比率、通道数、块数、组数或它们在层与层之间的变化,理想情况下应由一组简单的规则来管理。Radosavovic et al. (2019)的方法依赖于以下四个假设
我们假设通用的设计原则确实存在,因此许多满足这些要求的网络应该提供良好的性能。因此,确定网络的“分布”可能是一个明智的策略。换句话说,我们假设大海捞针中有许多好针。
我们不需要将网络训练到收敛,然后才能评估一个网络是否好。相反,使用中间结果作为最终准确性的可靠指导就足够了。使用(近似)代理来优化目标被称为多保真度优化(Forrester et al., 2007)。因此,设计优化是基于在数据集中仅经过几次传递后达到的准确性进行的,从而显著降低了成本。
在较小规模(对于较小的网络)上获得的结果可以推广到较大规模。因此,优化是针对结构相似但块数较少、通道数较少的网络进行的。只有在最后,我们才需要验证这样找到的网络在规模扩大后也能提供良好的性能。
设计的各个方面可以近似分解,以便可以或多或少独立地推断它们对结果质量的影响。换句话说,优化问题是中等难度的。
这些假设使我们能够廉价地测试许多网络。特别是,我们可以从配置空间中均匀地“采样”并评估它们的性能。随后,我们可以通过回顾用所述网络可以实现的误差/准确度的“分布”来评估参数选择的质量。用\(F(e)\)表示给定设计空间的网络所犯错误的累积分布函数(CDF),该网络是使用概率分布\(p\)绘制的。也就是说,
我们现在的目标是找到一个网络上的分布\(p\),使得大多数网络的错误率非常低,并且\(p\)的支撑集是简洁的。当然,要准确地执行这个操作在计算上是不可行的。我们求助于一个网络样本\(\mathcal{Z} \stackrel{\textrm{def}}{=} \{\textrm{net}_1, \ldots \textrm{net}_n\}\)(分别具有错误\(e_1, \ldots, e_n\)),这些样本来自\(p\),并使用经验CDF\(\hat{F}(e, \mathcal{Z})\)来代替
当一组选择的CDF优于(或匹配)另一组CDF时,就意味着其参数选择更优(或无差异)。因此,Radosavovic et al. (2020)对网络所有阶段\(i\)共享网络瓶颈比率\(k_i = k\)进行了实验。这摆脱了四个控制瓶颈比率的参数中的三个。为了评估这是否(负面地)影响性能,可以从约束和非约束分布中抽取网络并比较相应的CDF。事实证明,这个约束根本不影响网络分布的准确性,如图 8.8.2的第一幅图所示。同样地,我们可以选择在网络的各个阶段选择相同的分组宽度\(g_i = g\)。同样,这也不影响性能,如图 8.8.2的第二幅图所示。这两个步骤结合起来减少了六个自由参数的数量。

图 8.8.2 比较设计空间的误差经验分布函数。\(\textrm{AnyNet}_\mathit{A}\)是原始设计空间;\(\textrm{AnyNet}_\mathit{B}\)绑定了瓶颈比率,\(\textrm{AnyNet}_\mathit{C}\)也绑定了分组宽度,\(\textrm{AnyNet}_\mathit{D}\)增加了跨阶段的网络深度。从左到右:(i)绑定瓶颈比率对性能没有影响;(ii)绑定分组宽度对性能没有影响;(iii)增加跨阶段的网络宽度(通道数)可以提高性能;(iv)增加跨阶段的网络深度可以提高性能。图由Radosavovic et al. (2020)提供。¶
接下来,我们寻找减少阶段宽度和深度众多潜在选择的方法。一个合理的假设是,随着我们深入,通道数应该增加,即\(c_i \geq c_{i-1}\)(根据他们在图 8.8.2中的符号是\(w_{i+1} \geq w_i\)),得到\(\textrm{AnyNetX}_D\)。同样,同样合理的假设是,随着阶段的推进,它们应该变得更深,即\(d_i \geq d_{i-1}\),得到\(\textrm{AnyNetX}_E\)。这可以在图 8.8.2的第三和第四幅图中分别进行实验验证。
8.8.3. RegNet¶
由此产生的\(\textrm{AnyNetX}_E\)设计空间由遵循易于解释的设计原则的简单网络组成:
所有阶段\(i\)共享瓶颈比率\(k_i = k\);
所有阶段\(i\)共享分组宽度\(g_i = g\);
跨阶段增加网络宽度:\(c_{i} \leq c_{i+1}\);
跨阶段增加网络深度:\(d_{i} \leq d_{i+1}\)。
这给我们留下了一组最终的选择:如何为最终的\(\textrm{AnyNetX}_E\)设计空间选择上述参数的具体值。通过研究\(\textrm{AnyNetX}_E\)分布中表现最好的网络,可以观察到以下情况:网络的宽度理想情况下应随着网络中的块索引线性增加,即\(c_j \approx c_0 + c_a j\),其中\(j\)是块索引,斜率\(c_a > 0\)。鉴于我们只能为每个阶段选择不同的块宽度,我们得到了一个分段常数函数,旨在匹配这种依赖关系。此外,实验还表明,瓶颈比率为\(k = 1\)时表现最好,即我们被建议根本不使用瓶颈。
我们建议感兴趣的读者通过细读Radosavovic et al. (2020)来了解更多关于针对不同计算量的特定网络设计的细节。例如,一个有效的32层RegNetX变体由\(k = 1\)(无瓶颈),\(g = 16\)(分组宽度为16),第一和第二阶段的通道数分别为\(c_1 = 32\)和\(c_2 = 80\),选择的深度为\(d_1=4\)和\(d_2=6\)个块。该设计的惊人见解在于,即使在研究更大规模的网络时,它仍然适用。更好的是,它甚至适用于具有全局通道激活的Squeeze-and-Excitation (SE) 网络设计(RegNetY)(Hu et al., 2018)。
class RegNetX32(AnyNet):
def __init__(self, lr=0.1, num_classes=10):
stem_channels, groups, bot_mul = 32, 16, 1
depths, channels = (4, 6), (32, 80)
super().__init__(
((depths[0], channels[0], groups, bot_mul),
(depths[1], channels[1], groups, bot_mul)),
stem_channels, lr, num_classes)
class RegNetX32(AnyNet):
def __init__(self, lr=0.1, num_classes=10):
stem_channels, groups, bot_mul = 32, 16, 1
depths, channels = (4, 6), (32, 80)
super().__init__(
((depths[0], channels[0], groups, bot_mul),
(depths[1], channels[1], groups, bot_mul)),
stem_channels, lr, num_classes)
class RegNetX32(AnyNet):
lr: float = 0.1
num_classes: int = 10
stem_channels: int = 32
arch: tuple = ((4, 32, 16, 1), (6, 80, 16, 1))
class RegNetX32(AnyNet):
def __init__(self, lr=0.1, num_classes=10):
stem_channels, groups, bot_mul = 32, 16, 1
depths, channels = (4, 6), (32, 80)
super().__init__(
((depths[0], channels[0], groups, bot_mul),
(depths[1], channels[1], groups, bot_mul)),
stem_channels, lr, num_classes)
我们可以看到,每个RegNetX阶段都逐步降低分辨率并增加输出通道数。
RegNetX32().layer_summary((1, 1, 96, 96))
Sequential output shape: torch.Size([1, 32, 48, 48])
Sequential output shape: torch.Size([1, 32, 24, 24])
Sequential output shape: torch.Size([1, 80, 12, 12])
Sequential output shape: torch.Size([1, 10])
RegNetX32().layer_summary((1, 1, 96, 96))
[22:33:30] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
Sequential output shape: (1, 32, 48, 48)
Sequential output shape: (1, 32, 24, 24)
Sequential output shape: (1, 80, 12, 12)
GlobalAvgPool2D output shape: (1, 80, 1, 1)
Dense output shape: (1, 10)
RegNetX32(training=False).layer_summary((1, 96, 96, 1))
Sequential output shape: (1, 48, 48, 32)
Sequential output shape: (1, 24, 24, 32)
Sequential output shape: (1, 12, 12, 80)
Sequential output shape: (1, 10)
RegNetX32().layer_summary((1, 96, 96, 1))
Sequential output shape: (1, 48, 48, 32)
Sequential output shape: (1, 24, 24, 32)
Sequential output shape: (1, 12, 12, 80)
Sequential output shape: (1, 10)
8.8.4. 训练¶
在Fashion-MNIST数据集上训练32层的RegNetX和之前一样。
model = RegNetX32(lr=0.05)
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128, resize=(96, 96))
trainer.fit(model, data)
model = RegNetX32(lr=0.05)
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128, resize=(96, 96))
trainer.fit(model, data)
model = RegNetX32(lr=0.05)
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128, resize=(96, 96))
trainer.fit(model, data)
trainer = d2l.Trainer(max_epochs=10)
data = d2l.FashionMNIST(batch_size=128, resize=(96, 96))
with d2l.try_gpu():
model = RegNetX32(lr=0.01)
trainer.fit(model, data)
8.8.5. 讨论¶
凭借诸如局部性和平移不变性(第 7.1 节)等理想的归纳偏置(假设或偏好),CNN一直是视觉领域的主导架构。从LeNet一直到Transformers(第 11.7 节)(Dosovitskiy et al., 2021, Touvron et al., 2021)开始在准确性上超越CNN之前,情况一直如此。虽然最近视觉Transformers的许多进展“可以”被移植回CNN(Liu et al., 2022),但这只有在更高的计算成本下才可能实现。同样重要的是,最近的硬件优化(NVIDIA Ampere和Hopper)只会进一步拉大有利于Transformers的差距。
值得注意的是,Transformers对局部性和平移不变性的归纳偏置程度明显低于CNN。学习到的结构能够胜出,很大程度上要归功于大型图像集的可用性,例如LAION-400m和LAION-5B(Schuhmann et al., 2022),它们拥有多达50亿张图像。令人惊讶的是,这方面一些更相关的研究甚至包括了MLP(Tolstikhin et al., 2021)。
总而言之,视觉Transformers(第 11.8 节)目前在大型图像分类的最新性能方面处于领先地位,这表明“可扩展性胜过归纳偏置”(Dosovitskiy et al., 2021)。这包括使用多头自注意力(第 11.5 节)预训练大型Transformers(第 11.9 节)。我们邀请读者深入这些章节进行更详细的讨论。