卷积神经网络 · 深度学习 06

Hailiang Zhao | 2021/08/24

Categories: math Tags: deep-learning optimization CNN


卷积神经网络(CNN)的诞生引发了本轮深度学习浪潮。CNN 在 MLP 的基础之上引入了卷积操作和池化操作,从而有效地学习到了图像的轮廓等重要特征。

二维互相关运算

互相关运算 $\textrm{corr} = \textrm{rot180} (\textrm{conv})$ 如下图所示:

图 1 二维互相关运算

对应的代码实现:

# 二维互相关运算
def corr2d(X, K):
    h, w = K.shape
    # 窄卷积(N - n + 1)
    Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            Y[i, j] = (X[i: i + h, j: j + w] * K).sum()
    return Y

填充与步长

对于单个维度而言,设输入特征大小(输入特征和卷积核的宽和高可以不相同,此时输出神经元在宽和高上的数量需要分别计算)为 $n$, 卷积核大小为 $m$,步长为 $s$,输入神经元两端各补 $p$ 个零,则输出神经元的数量为 $$ \Big\lfloor \frac{n - m + 2p}{s} \Big\rfloor + 1. $$

多输入通道与多输出通道

若输入数据的通道数为 $c_i$,则卷积核应当是一个大小为 $c_i \times k_h \times k_w$ 的 tensor。 由于输入和卷积核各有 $c_i$ 个通道,我们可以在各个通道上对输入的二维数组和卷积核的二维核数组做互相关运算, 再将这 $c_i$ 个互相关运算的二维输出按通道相加,得到一个二维数组。这就是含多个通道的输入数据与多输入通道的卷积核做二维互相关运算的输出。

图 2 从多输入通道到单输出通道

当输入通道有多个时,因为我们对各个通道的结果做了累加,所以不论输入通道数是多少,输出通道数总是为 1。 设卷积核输入通道数和输出通道数分别为 $c_i$$c_o$,高和宽分别为 $k_h$$k_w$。如果希望得到含多个通道的输出, 我们可以为每个输出通道分别创建形状为 $c_i \times k_h \times k_w$ 的 tensor 的核数组,并将它们在输出通道维上连结, 因此卷积核的形状即 $c_o \times c_i \times k_h \times k_w$ 的 tensor。在做互相关运算时, 每个输出通道上的结果由卷积核在该输出通道上的核数组与整个输入数组的互相关运算得到。

$1 \times 1$ 卷积层

输出中的每个元素来自输入中在高和宽上相同位置的元素在不同通道之间的按权重累加。假设我们将通道维当作特征维,将高和宽维度上的元素当成数据样本, 那么 $1 \times 1$ 卷积层的作用与全连接层等价(区别在于 $1 \times 1$ 卷积层允许权重参数共享,因而拥有更少的参数数量)。

$1 \times 1$ 卷积层被当作保持高和宽维度形状不变的全连接层使用,可以通过调整网络层之间的通道数来控制模型复杂度

图 3 1×1 卷积层

池化层

池化层常常跟在卷积层之后。在下图所示的 $2 \times 2$ 最大池化的例子中,只要卷积层识别的(最显著的)模式在高和宽上移动不超过一个元素, 池化层就可以将它检测出来,从而缓解卷积层对位置的过度敏感性。

图 4 池化层

池化层也可以有多通道。只不过,池化层是对每个输入通道分别池化,而不是像卷积层那样将各通道的输入按通道相加。这意味着池化层的输出通道数与输入通道数相等。

# 平均池化与最大池化
def pool2d(X, pool_size, mode='max'):
    X = X.float()
    p_h, p_w = pool_size
    Y = torch.zeros(X.shape[0] - p_h + 1, X.shape[1] - p_w + 1)
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            if mode == 'max':
                Y[i, j] = X[i: i + p_h, j: j + p_w].max()
            elif mode == 'avg':
                Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
    return Y

LeNet-5

LeNet 分为 卷积层块全连接层块 两个部分。

卷积层块里的基本单位是卷积层后接最大池化层:卷积层用来识别图像里的空间模式,如线条和物体局部,之后的 最大池化层则用来降低卷积层对位置的敏感性。卷积层块由两个这样的基本单位重复堆叠构成。在卷积层块中,每个卷积层都使用 $5×5$ 的窗口,并在输出上使用 sigmoid 激活函数。第一个卷积层输出通道数为 6,第二个卷积层输出通道数则增加到 16。这是因为第二个卷积层比第一个卷积层的输入的高和宽要小,所以增加输出通道使两个卷积层的参数尺寸类似。卷积层块的两个最大池化层的窗口形状均为 $2×2$,且步幅为 2。由于池化窗口与步幅形状相同,池化窗口在输入上每次滑动所覆盖的区域互不重叠。

图 5 LeNet-5 的模型架构

卷积层块的输出为四维数组 (batch_size, num_channels, width, height)。当卷积层块的输出传入全连接层块时, 全连接层块会将小批量中每个样本变平(flatten)。也就是说,全连接层的输入形状将变成二维,其中第一维是 batch_size,第二维是每个样本变平后的向量表示,长度为通道、高和宽的乘积。这种对 tensor 的变换可使用 X.view(X.shape[0], -1) 实现。

对应的代码实现:

class LeNet5(nn.Module):
    def __init__(self):
        # input feature: (batch_size, 1, 28, 28)
        super(LeNet5, self).__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(1, 6, 5),     # (batch_size, 6, 24, 24)
            nn.Sigmoid(),
            nn.MaxPool2d(2),        # (batch_size, 6, 12, 12)

            nn.Conv2d(6, 16, 5),    # (batch_size, 16, 8, 8)
            nn.Sigmoid(),
            nn.MaxPool2d(2)         # (batch_size, 16, 4, 4)
        )
        self.fc = nn.Sequential(
            nn.Linear(16 * 4 * 4, 120),     # (batch_size, 256)
            nn.Sigmoid(),
            nn.Linear(120, 84),             # (batch_size, 84)
            nn.Sigmoid(),
            nn.Linear(84, 10)               # (batch_size, 10)
        )

    def forward(self, img):
        feature = self.conv(img)
        return self.fc(feature.view(img.shape[0], -1))

AlexNet

AlexNet 包含 8 层变换,其中有 5 层卷积和 2 层全连接隐藏层,以及 1 个全连接输出层。

AlexNet 第一层中的卷积窗口形状是 $11 \times 11$。因为 ImageNet 中绝大多数图像的高和宽均比 MNIST 图像的高和宽大 10 倍以上,ImageNet 图像的物体占用更多的像素,所以需要更大的卷积窗口来捕获物体。第二层中的卷积窗口形状减小到 $5×5$,之后全采用 $3×3$。此外,第一、第二和第五个卷积层之后都使用了窗口形状为 $3×3$、步幅为 2 的最大池化层。而且,AlexNet 使用的卷积通道数也大于 LeNet 中的卷积通道数数十倍。

图 6 AlexNet 的模型架构

对应的代码实现:

class AlexNet(nn.Module):
    """
    The images input are resize into (1, 224, 224).
    """
    def __init__(self):
        super(AlexNet, self).__init__()
        # input feature is of size (batch_size, 1, 224, 224)
        self.conv = nn.Sequential(
            nn.Conv2d(1, 96, 11, 4),            # (batch_size, 96, 54, 54)
            nn.ReLU(),
            nn.MaxPool2d(3, 2),                 # (batch_size, 96, 26, 26)

            nn.Conv2d(96, 256, 5, 1, 2),        # (batch_size, 256, 26, 26)
            nn.ReLU(),
            nn.MaxPool2d(3, 2),                 # (batch_size, 256, 11, 11)

            nn.Conv2d(256, 384, 3, 1, 1),       # (batch_size, 384, 11, 11)
            nn.ReLU(),
            nn.Conv2d(384, 384, 3, 1, 1),       # (batch_size, 384, 11, 11)
            nn.ReLU(),
            nn.Conv2d(384, 256, 3, 1, 1),       # (batch_size, 256, 11, 11)
            nn.ReLU(),
            nn.MaxPool2d(3, 2)                  # (batch_size, 256, 5, 5)
        )
        self.fc = nn.Sequential(
            nn.Linear(256 * 5 * 5, 4096),       # (batch_size, 256 * 5 * 5)
            nn.ReLU(),
            nn.Dropout(0.5),

            nn.Linear(4096, 4096),
            nn.ReLU(),
            nn.Dropout(0.5),

            nn.Linear(4096, 10)
        )

    def forward(self, img):
        feature = self.conv(img)
        return self.fc(feature.view(img.shape[0], -1))

VGG-11

VGG 块的组成规律是:连续使用数个相同的填充为 1、窗口形状为 $3×3$ 的卷积层后接上一个步幅为 2、窗口形状为 $2×2$ 的最大池化层。卷积层保持输入的高和宽不变, 而池化层则对其减半。

对于给定的感受野(与输出有关的输入图片的局部大小),采用堆积的小卷积核优于采用大的卷积核,因为可以增加网络深度来保证学习更复杂的模式,而且代价还比较小(参数更少)。例如,在 VGG 中,使用了 3 个 $3 \times 3$ 卷积核来代替 $7 \times 7$ 卷积核,使用了 2 个 $3 \times 3$ 卷积核来代替 $5 \times 5$ 卷积核,这样做的主要目的是在保证具有相同感知野的条件下, 提升了网络的深度,在一定程度上提升了神经网络的效果。

对应的代码实现:

# VGG 块
def vgg_block(num_convs, in_channels, out_channels):
    blk = []
    for i in range(num_convs):
        if i == 0:
            blk.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
        else:
            blk.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1))
        blk.append(nn.ReLU())
    blk.append(nn.MaxPool2d(kernel_size=2, stride=2))
    return nn.Sequential(*blk)

# VGG-11
def VGG11(conv_arch, fc_features, fc_hidden_units=4096, fc_out_units=10):
    net = nn.Sequential()
    for i, (num_conv, in_channels, out_channels) in enumerate(conv_arch):
        net.add_module('vgg_block_' + str(i + 1),
                       vgg_block(num_conv, in_channels, out_channels))
    net.add_module('fc', nn.Sequential(
        metrics.FlattenLayer(),
        nn.Linear(fc_features, fc_hidden_units),
        nn.Dropout(0.5),
        nn.Linear(fc_hidden_units, fc_hidden_units),
        nn.Dropout(0.5),
        nn.Linear(fc_hidden_units, fc_out_units)
    ))
    return net

VGG 网络有 5 个 VGG 块,前 2 块使用单卷积层,而后 3 块使用双卷积层。因为这个网络使用了 8 个卷积层和 3 个全连接层,所以经常被称为 VGG-11。

Network in Network (NiN)

卷积层的输入和输出通常是四维数组 (batch_size, num_channels, width, height),而全连接层的输入和输出则通常是二维数组 (batch_size, num_features)。如果想在全连接层后再接上卷积层,则需要将全连接层的输出变换为四维。可以用 $1 \times 1$ 卷积层代替全连接层,其中 空间维度(高和宽)上的每个元素相当于样本,通道相当于特征

对应的代码实现:

# nin_block
def nin_block(in_channels, out_channels, kernel_size, stride, padding):
    blk = nn.Sequential(
        nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding),
        nn.ReLU(),
        nn.Conv2d(out_channels, out_channels, kernel_size=1),
        nn.ReLU(),
        nn.Conv2d(out_channels, out_channels, kernel_size=1),
        nn.ReLU()
    )
    return blk

NiN 使用卷积窗口形状分别为 $11×11$$5×5$$3×3$ 的卷积层,相应的输出通道数也与 AlexNet 中的一致。每个 NiN 块后接一个步幅为 2、窗口形状为 $3×3$ 的最大池化层。

NiN 去掉了 AlexNet 最后的 3 个全连接层,取而代之地,NiN 使用了输出通道数等于标签类别数的 NiN 块,然后使用 全局平均池化层 对每个通道中所有元素求平均并直接用于分类。这里的全局平均池化层即窗口形状等于输入空间维形状的平均池化层。NiN 的这个设计的好处是可以显著减小模型参数尺寸,从而缓解过拟合。然而,该设计有时会造成获得有效模型的训练时间的增加。

图 7 左图是 AlexNet 和 VGG 的网络结构局部,右图是 NiN 的网络结构局部

对应的代码实现:

class GlobalAvgPool2d(nn.Module):
    # 全局平均池化层可通过将池化窗口形状设置成输入的高和宽实现
    def __init__(self):
        super(GlobalAvgPool2d, self).__init__()
    def forward(self, x):
        # 将单个通道上(宽 * 高个元素的平均值计算出来)
        return F.avg_pool2d(x, kernel_size=x.size()[2:])

# NiN
def NiN():
    # input feature is of size (batch_size, 1, 224, 224)
    return nn.Sequential(
        nin_block(1, 96, kernel_size=11, stride=4, padding=0),
        nn.MaxPool2d(3, 2),

        nin_block(96, 256, kernel_size=5, stride=1, padding=2),
        nn.MaxPool2d(3, 2),

        nin_block(256, 384, kernel_size=3, stride=1, padding=1),
        nn.MaxPool2d(3, 2),

        nn.Dropout(0.5),
        nin_block(384, 10, kernel_size=3, stride=1, padding=1), # (batch_size, 10, 5, 5)
        GlobalAvgPool2d(),                                      # (batch_size, 10, 1, 1)
        FlattenLayer()                                          # (batch_size, 10)
    )

GoogLeNet

GoogLeNet 吸收了 NiN 中网络串联网络的思想,并在此基础上做了很大改进。

图 8 GoogLeNet 的模型架构

GoogLeNet 中的基础卷积块叫作 Inception 块,有 4 条并行的线路。前 3 条线路使用窗口大小分别是 $1×1$$3×3$$5×5$ 的卷积层来抽取不同空间尺寸下的信息,其中中间 2 个线路会对输入先做 $1×1$ 卷积来减少输入通道数,以降低模型复杂度。第四条线路则使用 $3×3$ 最大池化层,后接 $1×1$ 卷积层来改变通道数。4 条线路都使用了合适的填充来使输入与输出的高和宽一致。最后将每条线路的输出在通道维上连结,并输入接下来的层中去。

对应的代码实现:

class Inception(nn.Module):
    # c1 - c4 为每条线路里的层的输出通道数
    def __init__(self, in_c, c1, c2, c3, c4):
        super(Inception, self).__init__()
        # 线路 1,单 1 x 1 卷积层
        self.p1_1 = nn.Conv2d(in_c, c1, kernel_size=1)
        # 线路 2,1 x 1 卷积层后接 3 x 3 卷积层
        self.p2_1 = nn.Conv2d(in_c, c2[0], kernel_size=1)
        self.p2_2 = nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1)
        # 线路 3,1 x 1 卷积层后接 5 x 5 卷积层
        self.p3_1 = nn.Conv2d(in_c, c3[0], kernel_size=1)
        self.p3_2 = nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2)
        # 线路 4,3 x 3 最大池化层后接 1 x 1 卷积层
        self.p4_1 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
        self.p4_2 = nn.Conv2d(in_c, c4, kernel_size=1)

    def forward(self, x):
        p1 = F.relu(self.p1_1(x))
        p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
        p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
        p4 = F.relu(self.p4_2(self.p4_1(x)))
        return torch.cat((p1, p2, p3, p4), dim=1)  # 在通道维上连结输出

GoogLeNet 跟 VGG 一样,在主体卷积部分中使用 5 个模块,每个模块之间使用步幅为 2 的 $3×3$ 最大池化层来减小输出高宽。第一模块使用一个 64 通道的 $7×7$ 卷积层。第二模块使用 2 个卷积层:首先是 64 通道的 $1×1$ 卷积层,然后是将通道增大 3 倍的 $3×3$ 卷积层(和 Inception 模块中的线路 2 一致)。第三模块串联 2 个完整的 Inception 块。第一个 Inception 块的输出通道数为 $64+128+32+32=256$。第二个 Inception 块输出通道数增至 $128+192+96+64=480$。第四模块串联了 5 个 Inception 块。第五模块串联了 2 个 Inception 块并使用全局平均池化层直接得到分类结果。

批量归一化

通常来说,数据标准化预处理对于浅层模型就足够有效了。随着模型训练的进行,当每层中参数更新时,靠近输出层的输出较难出现剧烈变化。但对深层神经网络来说,即使输入数据已做标准化,训练中模型参数的更新依然很容易造成靠近输出层输出的剧烈变化。这种计算数值的不稳定性通常令我们难以训练出有效的深度模型。

批量归一化的提出正是为了应对深度模型训练的挑战。在模型训练时,批量归一化利用小批量上的均值和标准差,不断调整神经网络中间输出,从而使整个神经网络在各层的中间输出的数值更稳定。实际上,批量归一化残差网络 为训练和设计深度模型提供了两类重要思路。

对全连接层批量归一化

使用批量归一化的全连接层的输出为 $$ \phi \left(\textrm{BN} \left(\vec{x} \right) \right) = \phi \left(\textrm{BN} \left(W\vec{u} + \vec{b} \right) \right), $$ 其中 $\vec{u}$ 为全连接层的输入,$\textrm{BN}$ 为批量归一化运算符。

对于小批量的 仿射变换的输出 $\mathcal{B} = \left\{ \vec{x}^{(1)}, ..., \vec{x}^{(m)} \right\}$,其中 $\vec{x}^{(i)} \in \mathbb{R}^d$,则批量归一化的输出为 $$ \vec{y}^{(i)} = \textrm{BN} \left(\vec{x}^{(i)} \right) \in \mathbb{R}^d. $$

$\textrm{BN}(\cdot)$ 的具体步骤如下:

首先对小批量 $\mathcal{B}$ 求均值和方差: $$ \vec{\mu}_{\mathcal{B}} \leftarrow \frac{1}{m} \sum_{i=1}^m \vec{x}^{(i)},\\ \vec{\sigma}^2_{\mathcal{B}} \leftarrow \frac{1}{m-1} \sum_{i=1}^m \left(\vec{x}^{(i)} - \vec{\mu}_{\mathcal{B}} \right)^2, $$

其中的平方计算是按元素求平方。接下来,使用按元素开方和按元素除法对 $\vec{x}^{(i)}$ 标准化: $$ \hat{\vec{x}}^{(i)} \leftarrow \frac{\vec{x}^{(i)} - \vec{\mu}_{\mathcal{B}}}{\sqrt{\vec{\sigma}^2_{\mathcal{B}} + \epsilon}}, $$ 这里 $\epsilon > 0$ 是一个很小的常数,保证分母大于 $0$

批量归一化层引入了两个可以学习的模型参数,拉伸(scale)参数 $\vec{\gamma}$ 和偏移(shift)参数 $\vec{\beta}$。这两个参数和 $\vec{x}^{(i)}$ 形状相同,皆为 $d$ 维向量。它们与分别做按元素乘法和加法计算: $$ \vec{y}^{(i)} \leftarrow \vec{\gamma} \odot \hat{\vec{x}}^{(i)} + \vec{\beta}. $$

可学习的拉伸和偏移参数保留了不对 $\vec{x}^{(i)}$ 做批量归一化的可能: 此时只需学出 $$ \vec{\gamma} = \sqrt{\vec{\sigma}^2_{\mathcal{B}} + \epsilon}, \vec{\beta} = \vec{\mu}_{\mathcal{B}}. $$ 我们可以对此这样理解:如果批量归一化无益,理论上,学出的模型可以不使用批量归一化。

对卷积层批量归一化

对卷积层来说,批量归一化发生在卷积计算之后、应用激活函数之前。如果卷积计算输出多个通道,我们需要对这些通道的输出分别做批量归一化, 且每个通道都拥有独立的拉伸和偏移参数,并均为 标量

设小批量中有 $m$ 个样本。在单个通道上,假设卷积计算输出的高和宽分别为 $p$$q$。我们需要对该通道中 $m×p×q$ 个元素同时做批量归一化。对这些元素做标准化计算时,我们使用相同的均值和方差,即该通道中 $m×p×q$ 个元素的均值和方差。

预测时的批量归一化

使用批量归一化训练时,我们可以将批量大小设得大一点,从而使批量内样本的均值和方差的计算都较为准确。将训练好的模型用于预测时,我们希望模型对于任意输入都有确定的输出。因此,单个样本的输出不应取决于批量归一化所需要的随机小批量中的均值和方差。一种常用的方法是通过移动平均估算整个训练数据集的样本均值和方差,并在预测时使用它们得到确定的输出。可见,和 dropout 一样,批量归一化层在训练模式和预测模式下的计算结果也是不一样的。

对应的代码实现:

# 对输入的 minibatch 进行批量归一化
def batch_norm(is_training, X, gamma, beta, moving_mean, moving_var, eps, momentum):
    # 判断当前模式是训练模式还是预测模式
    if not is_training:
        # 如果是在预测模式下,直接使用传入的移动平均所得的均值和方差
        X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
    else:
        assert len(X.shape) in (2, 4)
        if len(X.shape) == 2:
            # 使用全连接层的情况,计算特征维上的均值和方差
            mean = X.mean(dim=0)
            var = ((X - mean) ** 2).mean(dim=0)
        else:
            # 使用二维卷积层的情况,计算通道维上(axis=1)的均值和方差。
            # 这里我们需要保持 X 的形状以便后面可以做广播运算
            mean = X.mean(dim=0, keepdim=True).mean(dim=2, keepdim=True).mean(dim=3, keepdim=True)
            var = ((X - mean) ** 2).mean(dim=0, keepdim=True).mean(dim=2, keepdim=True).mean(dim=3, keepdim=True)
        # 训练模式下用当前的均值和方差做标准化
        X_hat = (X - mean) / torch.sqrt(var + eps)
        # 更新移动平均的均值和方差
        moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
        moving_var = momentum * moving_var + (1.0 - momentum) * var
    Y = gamma * X_hat + beta             # 拉伸和偏移
    return Y, moving_mean, moving_var

ResNet-18

在下图的右半部分所示的残差块中,虚线框内要学习的是残差映射 $f(\vec{x}) - \vec{x}$,当理想映射接近恒等映射时(即 $f(\vec{x}) = \vec{x}$), 虚线框内上方的加权运算的权重和偏差参数会被学习为 $\vec{0}$。此时的残差映射可以捕捉恒等映射的细微波动。

图 9 ResNet 的模型架构

对应的代码实现:

# 实现上图(右)所示的残差块
class Residual(nn.Module):
    # ResNet 沿用了 VGG 全 3×3 卷积层的设计。残差块里首先有 2 个有相同输出通道数的 3×3 卷积层
    # 每个卷积层后接一个批量归一化层
    def __init__(self, in_channels, out_channels, use_1x1conv=False, stride=1):
        super(Residual, self).__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, stride=stride)
        self.\textrm{BN}1 = nn.BatchNorm2d(out_channels)

        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
        self.\textrm{BN}2 = nn.BatchNorm2d(out_channels)

        if use_1x1conv:
            # 想要改变通道数
            self.conv3 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride)
        else:
            self.conv3 = None

    def forward(self, X):
        Y = F.relu(self.\textrm{BN}1(self.conv1(X)))
        Y = self.\textrm{BN}2(self.conv2(Y))

        # 将输入跳过这两个卷积运算后直接加在最后的 ReLU 激活函数前
        if self.conv3:
            X = self.conv3(X)
        return F.relu(Y + X)

ResNet 第一层与 GooLeNet 第一层一样,在输出通道数为 64、步幅为 2 的 $7×7$ 卷积层后接步幅为 2 的 $3×3$ 的最大池化层。不同之处在于 ResNet 在卷积层后增加了批量归一化层。GoogLeNet 在后面接了 4 个由 Inception 块组成的模块。ResNet 则使用 4 个 由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块,第一个模块的通道数同输入通道数一致。每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。最后,使用 全局平均池化层 对每个通道中所有元素求平均并输入给全连接层用于分类。

这里每个模块里有 4 个卷积层(不计算 $1×1$ 卷积层),加上最开始的卷积层和最后的全连接层,共计 18 层。这个模型通常也被称为 ResNet-18。

对应的代码实现:

# 由四个残差块组成的模块
def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
    if first_block:
        assert in_channels == out_channels
    blk = []
    for i in range(num_residuals):
        if i == 0 and not first_block:
            blk.append(Residual(in_channels, out_channels, use_1x1conv=True, stride=2))
        else:
            blk.append(Residual(out_channels, out_channels))
    return nn.Sequential(*blk)

DenseNet

DenseNet 里模块 B 的输出不是像 ResNet 那样和模块 A 的输出相加,而是在通道维上连结。这样模块 A 的输出可以直接传入模块 B 后面的层。

图 10 DenseNet 的模型架构

DenseNet 的主要构建模块是稠密块(dense block)和过渡层(transition layer)。前者定义了输入和输出是如何连结的,后者则用来控制通道数,使之不过大。

对应的代码实现:

# 稠密块
def conv_block(in_channels, out_channels):
    blk = nn.Sequential(
        nn.BatchNorm2d(in_channels),
        nn.ReLU(),
        nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1)
    )
    return blk

# 稠密块由多个 conv_block 组成,每块使用相同的输出通道数
class DenseBlock(nn.Module):
    def __init__(self, num_convs, in_channels, out_channels):
        super(DenseBlock, self).__init__()
        net = []
        for i in range(num_convs):
            in_c = in_channels + i * out_channels
            net.append(conv_block(in_c, out_channels))
        self.net = nn.ModuleList(net)
        self.out_channels = in_channels + num_convs * out_channels

    def forward(self, X):
        for blk in self.net:
            Y = blk(X)
            X = torch.cat((X, Y), dim=1)
        return X

# 过渡层
def transition_block(in_channels, out_channels):
    return nn.Sequential(
        nn.BatchNorm2d(in_channels),
        nn.ReLU(),
        nn.Conv2d(in_channels, out_channels, kernel_size=1),
        nn.AvgPool2d(kernel_size=2, stride=2)
    )

DenseNet 首先使用同 ResNet 一样的单卷积层和最大池化层。随后,类似于 ResNet 接下来使用的 4 个残差块,DenseNet 使用的是 4 个稠密块。 同 ResNet 一样,我们可以设置每个稠密块使用多少个卷积层。这里我们设成 4,从而与上一节的 ResNet 保持一致。稠密块里的卷积层通道数(即增长率)设为 32,所以每个稠密块将增加 128 个通道。

ResNet 里通过步幅为 2 的残差块在每个模块之间减小高和宽。这里我们则使用过渡层来减半高和宽,并减半通道数。 同样地,最后接上全局池化层和全连接层来输出。

最后

本文所使用的图片来自《动手学深度学习》(PyTorch 版)章节 5。本文的部分文字摘自此书,如果需要获得更详尽的解释,请前往本链接阅读原文。

本文提及的各种 CNN 的 PyTorch 实现见本链接

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接。您必须给出适当的署名,并标明是否对本文作了修改。