8.6. 残差网络(ResNet)和 ResNeXt¶ 在 SageMaker Studio Lab 中打开 Notebook
在设计越来越深的网络时,我们必须了解增加网络层数如何能增加网络的复杂度和表达能力。更重要的是,能够设计出这样的网络:增加层数可以使网络变得更具表达能力,而不仅仅是不同。为了取得一些进展,我们需要一点数学知识。
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()
import jax
from flax import linen as nn
from jax import numpy as jnp
from d2l import jax as d2l
import tensorflow as tf
from d2l import tensorflow as d2l
8.6.1. 函数类¶
考虑一个特定的网络架构(以及学习率和其他超参数设置)可以达到的函数类 \(\mathcal{F}\)。也就是说,对于所有 \(f \in \mathcal{F}\),存在一组参数(例如,权重和偏置),可以通过在合适的数据集上训练来获得。让我们假设 \(f^*\) 是我们真正想要找到的“真实”函数。如果它在 \(\mathcal{F}\) 中,我们就很幸运了,但通常我们不会这么幸运。相反,我们会尝试在 \(\mathcal{F}\) 中找到一个最好的选择 \(f^*_\mathcal{F}\)。例如,给定一个带有特征 \(\mathbf{X}\) 和标签 \(\mathbf{y}\) 的数据集,我们可能会通过解决以下优化问题来找到它
我们知道,正则化 (Morozov, 1984, Tikhonov and Arsenin, 1977) 可以控制 \(\mathcal{F}\) 的复杂性并实现一致性,因此更大规模的训练数据通常会带来更好的 \(f^*_\mathcal{F}\)。如果我们设计一个不同且更强大的架构 \(\mathcal{F}'\),我们理应期望能得到更好的结果。换句话说,我们会期望 \(f^*_{\mathcal{F}'}\) 比 \(f^*_{\mathcal{F}}\)“更好”。但是,如果 \(\mathcal{F} \not\subseteq \mathcal{F}'\),就不能保证这种情况会发生。事实上,\(f^*_{\mathcal{F}'}\) 很可能更差。正如 图 8.6.1 所示,对于非嵌套的函数类,一个更大的函数类并不总是能更接近“真实”函数 \(f^*\)。例如,在 图 8.6.1 的左侧,尽管 \(\mathcal{F}_3\) 比 \(\mathcal{F}_1\) 更接近 \(f^*\),但 \(\mathcal{F}_6\) 却偏离了,而且不能保证进一步增加复杂性就能减少与 \(f^*\) 的距离。而在 图 8.6.1 右侧的嵌套函数类中(即 \(\mathcal{F}_1 \subseteq \cdots \subseteq \mathcal{F}_6\)),我们可以避免非嵌套函数类中提到的问题。
图 8.6.1 对于非嵌套的函数类,一个更大的(由面积表示)函数类并不能保证我们能更接近“真实”函数(\(\mathit{f}^*\))。这在嵌套函数类中不会发生。¶
因此,只有当更大的函数类包含较小的函数类时,我们才能保证增加它们会严格增加网络的表达能力。对于深度神经网络,如果我们能将新添加的层训练成一个恒等函数 \(f(\mathbf{x}) = \mathbf{x}\),那么新模型将和原始模型一样有效。由于新模型可能会找到更好的解来拟合训练数据集,所以添加的层可能会更容易减少训练误差。
这正是 He 等人 (2016) 在研究非常深的计算机视觉模型时所考虑的问题。他们提出的*残差网络*(*ResNet*)的核心思想是,每个附加层都应该更容易地将恒等函数作为其元素之一。这些考虑相当深刻,但它们却导向了一个出人意料的简单解决方案,即*残差块*。凭借它,ResNet 在 2015 年赢得了 ImageNet 大规模视觉识别挑战赛。这一设计对如何构建深度神经网络产生了深远的影响。例如,残差块被添加到了循环网络中 (Kim 等人, 2017, Prakash 等人, 2016)。同样,Transformer (Vaswani 等人, 2017) 使用它们来有效地堆叠多层网络。它还用于图神经网络 (Kipf and Welling, 2016),并且作为一个基本概念,它在计算机视觉中被广泛使用 (Redmon and Farhadi, 2018, Ren 等人, 2015)。需要注意的是,残差网络之前有高速公路网络 (Srivastava 等人, 2015),它分享了一些动机,但没有围绕恒等函数的优雅参数化。
8.6.2. 残差块¶
让我们关注神经网络的一个局部,如 图 8.6.2 所示。用 \(\mathbf{x}\) 表示输入。我们假设我们希望通过学习得到的目标底层映射为 \(f(\mathbf{x})\),它将作为顶层激活函数的输入。在左侧,虚线框内的部分必须直接学习 \(f(\mathbf{x})\)。在右侧,虚线框内的部分需要学习*残差映射* \(g(\mathbf{x}) = f(\mathbf{x}) - \mathbf{x}\),这也是残差块得名的原因。如果恒等映射 \(f(\mathbf{x}) = \mathbf{x}\) 是期望的底层映射,那么残差映射就等于 \(g(\mathbf{x}) = 0\),这样就更容易学习:我们只需要将虚线框内上层权重层(例如,全连接层和卷积层)的权重和偏置推向零。右图展示了 ResNet 的*残差块*,其中将层输入 \(\mathbf{x}\) 连接到加法运算符的实线被称为*残差连接*(或*快捷连接*)。有了残差块,输入可以更快地通过残差连接跨层向前传播。实际上,残差块可以被认为是多分支 Inception 块的一个特例:它有两个分支,其中一个是恒等映射。
图 8.6.2 在一个常规块中(左),虚线框内的部分必须直接学习映射 \(\mathit{f}(\mathbf{x})\)。在一个残差块中(右),虚线框内的部分需要学习残差映射 \(\mathit{g}(\mathbf{x}) = \mathit{f}(\mathbf{x}) - \mathbf{x}\),使得恒等映射 \(\mathit{f}(\mathbf{x}) = \mathbf{x}\) 更容易学习。¶
ResNet 沿用了 VGG 的完整 \(3\times 3\) 卷积层设计。残差块有两个 \(3\times 3\) 卷积层,输出通道数相同。每个卷积层后跟一个批量归一化层和一个 ReLU 激活函数。然后,我们跳过这两个卷积操作,在最终的 ReLU 激活函数之前直接将输入加上去。这种设计要求两个卷积层的输出必须与输入具有相同的形状,这样它们才能相加。如果我们想要改变通道数,就需要引入一个额外的 \(1\times 1\) 卷积层,将输入转换为加法操作所需的形状。让我们看一下下面的代码。
class Residual(nn.Module): #@save
"""The Residual block of ResNet models."""
def __init__(self, num_channels, use_1x1conv=False, strides=1):
super().__init__()
self.conv1 = nn.LazyConv2d(num_channels, kernel_size=3, padding=1,
stride=strides)
self.conv2 = nn.LazyConv2d(num_channels, kernel_size=3, padding=1)
if use_1x1conv:
self.conv3 = nn.LazyConv2d(num_channels, kernel_size=1,
stride=strides)
else:
self.conv3 = None
self.bn1 = nn.LazyBatchNorm2d()
self.bn2 = nn.LazyBatchNorm2d()
def forward(self, X):
Y = F.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
Y += X
return F.relu(Y)
class Residual(nn.Block): #@save
"""The Residual block of ResNet models."""
def __init__(self, num_channels, use_1x1conv=False, strides=1, **kwargs):
super().__init__(**kwargs)
self.conv1 = nn.Conv2D(num_channels, kernel_size=3, padding=1,
strides=strides)
self.conv2 = nn.Conv2D(num_channels, kernel_size=3, padding=1)
if use_1x1conv:
self.conv3 = nn.Conv2D(num_channels, kernel_size=1,
strides=strides)
else:
self.conv3 = None
self.bn1 = nn.BatchNorm()
self.bn2 = nn.BatchNorm()
def forward(self, X):
Y = npx.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
return npx.relu(Y + X)
class Residual(nn.Module): #@save
"""The Residual block of ResNet models."""
num_channels: int
use_1x1conv: bool = False
strides: tuple = (1, 1)
training: bool = True
def setup(self):
self.conv1 = nn.Conv(self.num_channels, kernel_size=(3, 3),
padding='same', strides=self.strides)
self.conv2 = nn.Conv(self.num_channels, kernel_size=(3, 3),
padding='same')
if self.use_1x1conv:
self.conv3 = nn.Conv(self.num_channels, kernel_size=(1, 1),
strides=self.strides)
else:
self.conv3 = None
self.bn1 = nn.BatchNorm(not self.training)
self.bn2 = nn.BatchNorm(not self.training)
def __call__(self, X):
Y = nn.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
Y += X
return nn.relu(Y)
class Residual(tf.keras.Model): #@save
"""The Residual block of ResNet models."""
def __init__(self, num_channels, use_1x1conv=False, strides=1):
super().__init__()
self.conv1 = tf.keras.layers.Conv2D(num_channels, padding='same',
kernel_size=3, strides=strides)
self.conv2 = tf.keras.layers.Conv2D(num_channels, kernel_size=3,
padding='same')
self.conv3 = None
if use_1x1conv:
self.conv3 = tf.keras.layers.Conv2D(num_channels, kernel_size=1,
strides=strides)
self.bn1 = tf.keras.layers.BatchNormalization()
self.bn2 = tf.keras.layers.BatchNormalization()
def call(self, X):
Y = tf.keras.activations.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3 is not None:
X = self.conv3(X)
Y += X
return tf.keras.activations.relu(Y)
这段代码生成两种类型的网络:一种是当 use_1x1conv=False
时,在应用 ReLU 非线性之前将输入加到输出上;另一种是通过 \(1 \times 1\) 卷积调整通道和分辨率,然后再进行相加。图 8.6.3 对此进行了说明。
图 8.6.3 带有和不带有 \(1 \times 1\) 卷积的 ResNet 块,该卷积将输入转换为加法操作所需的形状。¶
现在让我们看一种输入和输出形状相同的情况,此时不需要 \(1 \times 1\) 卷积。
blk = Residual(3)
X = torch.randn(4, 3, 6, 6)
blk(X).shape
torch.Size([4, 3, 6, 6])
blk = Residual(3)
blk.initialize()
X = np.random.randn(4, 3, 6, 6)
blk(X).shape
[22:49:23] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
(4, 3, 6, 6)
blk = Residual(3)
X = jax.random.normal(d2l.get_key(), (4, 6, 6, 3))
blk.init_with_output(d2l.get_key(), X)[0].shape
(4, 6, 6, 3)
blk = Residual(3)
X = tf.random.normal((4, 6, 6, 3))
Y = blk(X)
Y.shape
TensorShape([4, 6, 6, 3])
我们还可以选择将输出的高度和宽度减半,同时增加输出通道的数量。在这种情况下,我们通过 use_1x1conv=True
使用 \(1 \times 1\) 卷积。这在每个 ResNet 块的开始处非常方便,可以通过 strides=2
来减小空间维度。
blk = Residual(6, use_1x1conv=True, strides=2)
blk(X).shape
torch.Size([4, 6, 3, 3])
blk = Residual(6, use_1x1conv=True, strides=2)
blk.initialize()
blk(X).shape
(4, 6, 3, 3)
blk = Residual(6, use_1x1conv=True, strides=(2, 2))
blk.init_with_output(d2l.get_key(), X)[0].shape
(4, 3, 3, 6)
blk = Residual(6, use_1x1conv=True, strides=2)
blk(X).shape
TensorShape([4, 3, 3, 6])
8.6.3. ResNet 模型¶
ResNet 的前两层与我们之前描述的 GoogLeNet 相同:一个 \(7\times 7\) 卷积层,有 64 个输出通道和步幅为 2,后跟一个 \(3\times 3\) 最大池化层,步幅为 2。不同之处在于 ResNet 在每个卷积层之后添加了批量归一化层。
class ResNet(d2l.Classifier):
def b1(self):
return nn.Sequential(
nn.LazyConv2d(64, kernel_size=7, stride=2, padding=3),
nn.LazyBatchNorm2d(), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
class ResNet(d2l.Classifier):
def b1(self):
net = nn.Sequential()
net.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3),
nn.BatchNorm(), nn.Activation('relu'),
nn.MaxPool2D(pool_size=3, strides=2, padding=1))
return net
class ResNet(d2l.Classifier):
arch: tuple
lr: float = 0.1
num_classes: int = 10
training: bool = True
def setup(self):
self.net = self.create_net()
def b1(self):
return nn.Sequential([
nn.Conv(64, kernel_size=(7, 7), strides=(2, 2), padding='same'),
nn.BatchNorm(not self.training), nn.relu,
lambda x: nn.max_pool(x, window_shape=(3, 3), strides=(2, 2),
padding='same')])
class ResNet(d2l.Classifier):
def b1(self):
return tf.keras.models.Sequential([
tf.keras.layers.Conv2D(64, kernel_size=7, strides=2,
padding='same'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation('relu'),
tf.keras.layers.MaxPool2D(pool_size=3, strides=2,
padding='same')])
GoogLeNet 使用四个由 Inception 块组成的模块。然而,ResNet 使用四个由残差块组成的模块,每个模块都使用几个具有相同输出通道数的残差块。第一个模块的通道数与输入通道数相同。由于已经使用了步幅为 2 的最大池化层,因此无需减小高度和宽度。在后续每个模块的第一个残差块中,通道数是前一个模块的两倍,并且高度和宽度减半。
@d2l.add_to_class(ResNet)
def block(self, num_residuals, num_channels, first_block=False):
blk = []
for i in range(num_residuals):
if i == 0 and not first_block:
blk.append(Residual(num_channels, use_1x1conv=True, strides=2))
else:
blk.append(Residual(num_channels))
return nn.Sequential(*blk)
@d2l.add_to_class(ResNet)
def block(self, num_residuals, num_channels, first_block=False):
blk = nn.Sequential()
for i in range(num_residuals):
if i == 0 and not first_block:
blk.add(Residual(num_channels, use_1x1conv=True, strides=2))
else:
blk.add(Residual(num_channels))
return blk
@d2l.add_to_class(ResNet)
def block(self, num_residuals, num_channels, first_block=False):
blk = []
for i in range(num_residuals):
if i == 0 and not first_block:
blk.append(Residual(num_channels, use_1x1conv=True,
strides=(2, 2), training=self.training))
else:
blk.append(Residual(num_channels, training=self.training))
return nn.Sequential(blk)
@d2l.add_to_class(ResNet)
def block(self, num_residuals, num_channels, first_block=False):
blk = tf.keras.models.Sequential()
for i in range(num_residuals):
if i == 0 and not first_block:
blk.add(Residual(num_channels, use_1x1conv=True, strides=2))
else:
blk.add(Residual(num_channels))
return blk
然后,我们将所有模块添加到 ResNet 中。这里,每个模块使用两个残差块。最后,就像 GoogLeNet 一样,我们添加一个全局平均池化层,后跟全连接层输出。
@d2l.add_to_class(ResNet)
def __init__(self, arch, lr=0.1, num_classes=10):
super(ResNet, self).__init__()
self.save_hyperparameters()
self.net = nn.Sequential(self.b1())
for i, b in enumerate(arch):
self.net.add_module(f'b{i+2}', self.block(*b, first_block=(i==0)))
self.net.add_module('last', nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(),
nn.LazyLinear(num_classes)))
self.net.apply(d2l.init_cnn)
@d2l.add_to_class(ResNet)
def __init__(self, arch, lr=0.1, num_classes=10):
super(ResNet, self).__init__()
self.save_hyperparameters()
self.net = nn.Sequential()
self.net.add(self.b1())
for i, b in enumerate(arch):
self.net.add(self.block(*b, first_block=(i==0)))
self.net.add(nn.GlobalAvgPool2D(), nn.Dense(num_classes))
self.net.initialize(init.Xavier())
@d2l.add_to_class(ResNet)
def create_net(self):
net = nn.Sequential([self.b1()])
for i, b in enumerate(self.arch):
net.layers.extend([self.block(*b, first_block=(i==0))])
net.layers.extend([nn.Sequential([
# Flax does not provide a GlobalAvg2D layer
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(ResNet)
def __init__(self, arch, lr=0.1, num_classes=10):
super(ResNet, self).__init__()
self.save_hyperparameters()
self.net = tf.keras.models.Sequential(self.b1())
for i, b in enumerate(arch):
self.net.add(self.block(*b, first_block=(i==0)))
self.net.add(tf.keras.models.Sequential([
tf.keras.layers.GlobalAvgPool2D(),
tf.keras.layers.Dense(units=num_classes)]))
每个模块中有四个卷积层(不包括 \(1\times 1\) 卷积层)。加上第一个 \(7\times 7\) 卷积层和最后一个全连接层,总共有 18 层。因此,这个模型通常被称为 ResNet-18。通过在模块中配置不同的通道数和残差块数,我们可以创建不同的 ResNet 模型,例如更深的 152 层 ResNet-152。虽然 ResNet 的主要架构与 GoogLeNet 相似,但 ResNet 的结构更简单,更容易修改。所有这些因素促成了 ResNet 的快速和广泛使用。图 8.6.4 描绘了完整的 ResNet-18。
图 8.6.4 ResNet-18 架构。¶
在训练 ResNet 之前,让我们观察一下输入形状在 ResNet 的不同模块之间是如何变化的。与之前的所有架构一样,分辨率不断减小,而通道数不断增加,直到全局平均池化层聚合所有特征为止。
class ResNet18(ResNet):
def __init__(self, lr=0.1, num_classes=10):
super().__init__(((2, 64), (2, 128), (2, 256), (2, 512)),
lr, num_classes)
ResNet18().layer_summary((1, 1, 96, 96))
Sequential output shape: torch.Size([1, 64, 24, 24])
Sequential output shape: torch.Size([1, 64, 24, 24])
Sequential output shape: torch.Size([1, 128, 12, 12])
Sequential output shape: torch.Size([1, 256, 6, 6])
Sequential output shape: torch.Size([1, 512, 3, 3])
Sequential output shape: torch.Size([1, 10])
class ResNet18(ResNet):
def __init__(self, lr=0.1, num_classes=10):
super().__init__(((2, 64), (2, 128), (2, 256), (2, 512)),
lr, num_classes)
ResNet18().layer_summary((1, 1, 96, 96))
Sequential output shape: (1, 64, 24, 24)
Sequential output shape: (1, 64, 24, 24)
Sequential output shape: (1, 128, 12, 12)
Sequential output shape: (1, 256, 6, 6)
Sequential output shape: (1, 512, 3, 3)
GlobalAvgPool2D output shape: (1, 512, 1, 1)
Dense output shape: (1, 10)
class ResNet18(ResNet):
arch: tuple = ((2, 64), (2, 128), (2, 256), (2, 512))
lr: float = 0.1
num_classes: int = 10
ResNet18(training=False).layer_summary((1, 96, 96, 1))
Sequential output shape: (1, 24, 24, 64)
Sequential output shape: (1, 24, 24, 64)
Sequential output shape: (1, 12, 12, 128)
Sequential output shape: (1, 6, 6, 256)
Sequential output shape: (1, 3, 3, 512)
Sequential output shape: (1, 10)
class ResNet18(ResNet):
def __init__(self, lr=0.1, num_classes=10):
super().__init__(((2, 64), (2, 128), (2, 256), (2, 512)),
lr, num_classes)
ResNet18().layer_summary((1, 96, 96, 1))
Sequential output shape: (1, 24, 24, 64)
Sequential output shape: (1, 24, 24, 64)
Sequential output shape: (1, 12, 12, 128)
Sequential output shape: (1, 6, 6, 256)
Sequential output shape: (1, 3, 3, 512)
Sequential output shape: (1, 10)
8.6.4. 训练¶
我们像之前一样,在 Fashion-MNIST 数据集上训练 ResNet。ResNet 是一个相当强大和灵活的架构。描绘训练和验证损失的图表显示了两条曲线之间存在显著差距,训练损失要低得多。对于这种灵活性的网络,更多的训练数据将有助于缩小差距并提高准确性。
model = ResNet18(lr=0.01)
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128, resize=(96, 96))
model.apply_init([next(iter(data.get_dataloader(True)))[0]], d2l.init_cnn)
trainer.fit(model, data)
model = ResNet18(lr=0.01)
trainer = d2l.Trainer(max_epochs=10, num_gpus=1)
data = d2l.FashionMNIST(batch_size=128, resize=(96, 96))
trainer.fit(model, data)
model = ResNet18(lr=0.01)
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 = ResNet18(lr=0.01)
trainer.fit(model, data)
8.6.5. ResNeXt¶
在 ResNet 设计中遇到的一个挑战是在给定块内非线性度和维度之间的权衡。也就是说,我们可以通过增加层数或增加卷积的宽度来增加非线性度。另一种策略是增加可以在块之间传递信息的通道数量。不幸的是,后者会带来二次方的惩罚,因为处理 \(c_\textrm{i}\) 个输入通道并输出 \(c_\textrm{o}\) 个通道的计算成本与 \(\mathcal{O}(c_\textrm{i} \cdot c_\textrm{o})\) 成正比(参见我们在 第 7.4 节 中的讨论)。
我们可以从 图 8.4.1 的 Inception 块中获得一些灵感,该块中的信息通过不同的组流过。将多个独立组的思想应用于 图 8.6.3 的 ResNet 块,催生了 ResNeXt 的设计 (Xie 等人, 2017)。与 Inception 中各种各样的变换不同,ResNeXt 在所有分支中采用*相同*的变换,从而最大限度地减少了对每个分支进行手动调整的需求。
图 8.6.5 ResNeXt 块。使用 \(\mathit{g}\) 组的分组卷积比密集卷积快 \(\mathit{g}\) 倍。当中间通道数 \(\mathit{b}\) 小于 \(\mathit{c}\) 时,它是一个瓶颈残差块。¶
将一个从 \(c_\textrm{i}\) 到 \(c_\textrm{o}\) 通道的卷积分解为 \(g\) 个组,每组大小为 \(c_\textrm{i}/g\),生成 \(g\) 个大小为 \(c_\textrm{o}/g\) 的输出,这种操作被恰如其分地称为*分组卷积*。计算成本(成比例地)从 \(\mathcal{O}(c_\textrm{i} \cdot c_\textrm{o})\) 降低到 \(\mathcal{O}(g \cdot (c_\textrm{i}/g) \cdot (c_\textrm{o}/g)) = \mathcal{O}(c_\textrm{i} \cdot c_\textrm{o} / g)\),也就是说,速度快了 \(g\) 倍。更好的是,生成输出所需的参数数量也从一个 \(c_\textrm{i} \times c_\textrm{o}\) 的矩阵减少到 \(g\) 个大小为 \((c_\textrm{i}/g) \times (c_\textrm{o}/g)\) 的小矩阵,同样减少了 \(g\) 倍。在下文中,我们假设 \(c_\textrm{i}\) 和 \(c_\textrm{o}\) 都能被 \(g\) 整除。
这种设计中唯一的挑战是,\(g\) 个组之间没有信息交换。图 8.6.5 的 ResNeXt 块通过两种方式对此进行了修正:将带有 \(3 \times 3\) 核的分组卷积夹在两个 \(1 \times 1\) 卷积之间。第二个卷积层还兼具将通道数改回来的双重作用。好处是,我们只需要为 \(1 \times 1\) 核支付 \(\mathcal{O}(c \cdot b)\) 的成本,而对于 \(3 \times 3\) 核,只需要 \(\mathcal{O}(b^2 / g)\) 的成本。与 第 8.6.2 节 中残差块的实现类似,残差连接被一个 \(1 \times 1\) 卷积所取代(从而被泛化)。
图 8.6.5 中的右图对所产生的网络块给出了一个更为简洁的总结。它也将在 第 8.8 节 中通用现代 CNN 的设计中扮演重要角色。请注意,分组卷积的思想可以追溯到 AlexNet 的实现 (Krizhevsky 等人, 2012)。在将网络分布到两个内存有限的 GPU 上时,该实现将每个 GPU 视为自己的通道,并且没有产生任何不良影响。
以下 ResNeXtBlock
类的实现接受参数 groups
(\(g\)),以及中间(瓶颈)通道数 bot_channels
(\(b\))。最后,当我们需要减小表示的高度和宽度时,我们通过设置 use_1x1conv=True, strides=2
来添加一个步幅为 \(2\)。
class ResNeXtBlock(nn.Module): #@save
"""The ResNeXt block."""
def __init__(self, num_channels, groups, bot_mul, use_1x1conv=False,
strides=1):
super().__init__()
bot_channels = int(round(num_channels * bot_mul))
self.conv1 = nn.LazyConv2d(bot_channels, kernel_size=1, stride=1)
self.conv2 = nn.LazyConv2d(bot_channels, kernel_size=3,
stride=strides, padding=1,
groups=bot_channels//groups)
self.conv3 = nn.LazyConv2d(num_channels, kernel_size=1, stride=1)
self.bn1 = nn.LazyBatchNorm2d()
self.bn2 = nn.LazyBatchNorm2d()
self.bn3 = nn.LazyBatchNorm2d()
if use_1x1conv:
self.conv4 = nn.LazyConv2d(num_channels, kernel_size=1,
stride=strides)
self.bn4 = nn.LazyBatchNorm2d()
else:
self.conv4 = None
def forward(self, X):
Y = F.relu(self.bn1(self.conv1(X)))
Y = F.relu(self.bn2(self.conv2(Y)))
Y = self.bn3(self.conv3(Y))
if self.conv4:
X = self.bn4(self.conv4(X))
return F.relu(Y + X)
class ResNeXtBlock(nn.Block): #@save
"""The ResNeXt block."""
def __init__(self, num_channels, groups, bot_mul,
use_1x1conv=False, strides=1, **kwargs):
super().__init__(**kwargs)
bot_channels = int(round(num_channels * bot_mul))
self.conv1 = nn.Conv2D(bot_channels, kernel_size=1, padding=0,
strides=1)
self.conv2 = nn.Conv2D(bot_channels, kernel_size=3, padding=1,
strides=strides, groups=bot_channels//groups)
self.conv3 = nn.Conv2D(num_channels, kernel_size=1, padding=0,
strides=1)
self.bn1 = nn.BatchNorm()
self.bn2 = nn.BatchNorm()
self.bn3 = nn.BatchNorm()
if use_1x1conv:
self.conv4 = nn.Conv2D(num_channels, kernel_size=1,
strides=strides)
self.bn4 = nn.BatchNorm()
else:
self.conv4 = None
def forward(self, X):
Y = npx.relu(self.bn1(self.conv1(X)))
Y = npx.relu(self.bn2(self.conv2(Y)))
Y = self.bn3(self.conv3(Y))
if self.conv4:
X = self.bn4(self.conv4(X))
return npx.relu(Y + X)
class ResNeXtBlock(nn.Module): #@save
"""The ResNeXt block."""
num_channels: int
groups: int
bot_mul: int
use_1x1conv: bool = False
strides: tuple = (1, 1)
training: bool = True
def setup(self):
bot_channels = int(round(self.num_channels * self.bot_mul))
self.conv1 = nn.Conv(bot_channels, kernel_size=(1, 1),
strides=(1, 1))
self.conv2 = nn.Conv(bot_channels, kernel_size=(3, 3),
strides=self.strides, padding='same',
feature_group_count=bot_channels//self.groups)
self.conv3 = nn.Conv(self.num_channels, kernel_size=(1, 1),
strides=(1, 1))
self.bn1 = nn.BatchNorm(not self.training)
self.bn2 = nn.BatchNorm(not self.training)
self.bn3 = nn.BatchNorm(not self.training)
if self.use_1x1conv:
self.conv4 = nn.Conv(self.num_channels, kernel_size=(1, 1),
strides=self.strides)
self.bn4 = nn.BatchNorm(not self.training)
else:
self.conv4 = None
def __call__(self, X):
Y = nn.relu(self.bn1(self.conv1(X)))
Y = nn.relu(self.bn2(self.conv2(Y)))
Y = self.bn3(self.conv3(Y))
if self.conv4:
X = self.bn4(self.conv4(X))
return nn.relu(Y + X)
class ResNeXtBlock(tf.keras.Model): #@save
"""The ResNeXt block."""
def __init__(self, num_channels, groups, bot_mul, use_1x1conv=False,
strides=1):
super().__init__()
bot_channels = int(round(num_channels * bot_mul))
self.conv1 = tf.keras.layers.Conv2D(bot_channels, 1, strides=1)
self.conv2 = tf.keras.layers.Conv2D(bot_channels, 3, strides=strides,
padding="same",
groups=bot_channels//groups)
self.conv3 = tf.keras.layers.Conv2D(num_channels, 1, strides=1)
self.bn1 = tf.keras.layers.BatchNormalization()
self.bn2 = tf.keras.layers.BatchNormalization()
self.bn3 = tf.keras.layers.BatchNormalization()
if use_1x1conv:
self.conv4 = tf.keras.layers.Conv2D(num_channels, 1,
strides=strides)
self.bn4 = tf.keras.layers.BatchNormalization()
else:
self.conv4 = None
def call(self, X):
Y = tf.keras.activations.relu(self.bn1(self.conv1(X)))
Y = tf.keras.activations.relu(self.bn2(self.conv2(Y)))
Y = self.bn3(self.conv3(Y))
if self.conv4:
X = self.bn4(self.conv4(X))
return tf.keras.activations.relu(Y + X)
它的用法与之前讨论的 ResNetBlock
完全类似。例如,当使用(use_1x1conv=False, strides=1
)时,输入和输出的形状相同。或者,设置 use_1x1conv=True, strides=2
会将输出的高度和宽度减半。
blk = ResNeXtBlock(32, 16, 1)
X = torch.randn(4, 32, 96, 96)
blk(X).shape
torch.Size([4, 32, 96, 96])
blk = ResNeXtBlock(32, 16, 1)
blk.initialize()
X = np.random.randn(4, 32, 96, 96)
blk(X).shape
(4, 32, 96, 96)
blk = ResNeXtBlock(32, 16, 1)
X = jnp.zeros((4, 96, 96, 32))
blk.init_with_output(d2l.get_key(), X)[0].shape
(4, 96, 96, 32)
blk = ResNeXtBlock(32, 16, 1)
X = tf.random.normal((4, 96, 96, 32))
Y = blk(X)
Y.shape
TensorShape([4, 96, 96, 32])
8.6.6. 总结与讨论¶
嵌套函数类是可取的,因为它们允许我们在增加容量时获得严格*更强大*的函数类,而不是仅仅是微妙*不同*的函数类。实现这一点的一种方法是让额外的层简单地将输入传递给输出。残差连接允许这样做。因此,这将归纳偏置从简单函数的形式 \(f(\mathbf{x}) = 0\) 变为简单函数的形式 \(f(\mathbf{x}) = \mathbf{x}\)。
残差映射可以更容易地学习恒等函数,例如将权重层中的参数推向零。我们可以通过残差块训练一个有效的*深度*神经网络。输入可以通过残差连接更快地在层间向前传播。因此,我们可以训练更深的网络。例如,原始的 ResNet 论文 (He 等人, 2016) 允许高达 152 层。残差网络的另一个好处是,它允许我们在训练过程中添加初始化为恒等函数的层。毕竟,一个层的默认行为是让数据保持不变地通过。这在某些情况下可以加速非常大的网络的训练。
在残差连接之前,带有门控单元的旁路路径被引入,以有效训练超过 100 层的高速公路网络 (Srivastava 等人, 2015)。使用恒等函数作为旁路路径,ResNet 在多个计算机视觉任务上表现出色。残差连接对后续深度神经网络(无论是卷积还是序列性质的)的设计产生了重大影响。正如我们稍后将介绍的,Transformer 架构 (Vaswani 等人, 2017) 采用了残差连接(以及其他设计选择),并在语言、视觉、语音和强化学习等领域无处不在。
ResNeXt 是卷积神经网络设计如何随时间演变的一个例子:通过更节约地使用计算,并将其与激活的大小(通道数)进行权衡,它允许以更低的成本获得更快、更准确的网络。另一种看待分组卷积的方式是将其看作卷积权重的块对角矩阵。请注意,有不少这样的“技巧”可以带来更高效的网络。例如,ShiftNet (Wu 等人, 2018) 模仿了 \(3 \times 3\) 卷积的效果,只需将移位的激活添加到通道中,即可提供增加的函数复杂性,而这次没有任何计算成本。
到目前为止我们讨论的设计的一个共同特点是,网络设计是相当手动的,主要依赖于设计者的聪明才智来找到“正确”的网络超参数。虽然这显然是可行的,但它在人力时间上也是非常昂贵的,并且不能保证结果在任何意义上都是最优的。在 第 8.8 节 中,我们将讨论一些以更自动化的方式获得高质量网络的策略。特别是,我们将回顾*网络设计空间*的概念,这导致了 RegNetX/Y 模型 (Radosavovic 等人, 2020)。