参考教程

深度卷积神经网络(AlexNet)

计算机视觉流水线

  1. 在传统机器学习方法中,计算机视觉流水线是由经过人的手工精心设计的特征流水线组成的。对于这些传统方法,大部分的进展都来自于对特征有了更聪明的想法,并且学习到的算法往往归于事后的解释
  2. 与训练端到端(从像素到分类结果)系统不同,经典机器学习的流水线看起来更像下面这样:
    • 获取一个有趣的数据集。在早期,收集这些数据集需要昂贵的传感器
    • 根据光学、几何学、其他知识以及偶然的发现,手工对特征数据集进行预处理
    • 通过标准的特征提取算法(如SIFT(尺度不变特征变换、SURF(加速鲁棒特征或其他手动调整的流水线来输入数据
    • 将提取的特征放到最喜欢的分类器中(例如线性模型或其它核方法),以训练分类器

学习表征

  1. 观察图像特征的提取方法,SIFT、SURF、HOG(定向梯度直方图)、bags of visual words 和类似的特征提取方法占据了主导地位
  2. 另一组研究人员认为特征本身应该被学习,在合理地复杂性前提下,特征应该由多个共同学习的神经网络层组成,每个层都有可学习的参数。在机器视觉中,最底层可能检测边缘、颜色和纹理。

Alexnet

2012年,AlexNet横空出世。它首次证明了学习到的特征可以超越手工设计的特征。它一举打破了计算机视觉研究的现状。

  1. AlexNet和LeNet的架构非常相似:Alexnet
  2. 模型设计:
    • 在AlexNet的第一层,卷积窗口的形状是11×1111\times11 。 由于ImageNet中大多数图像的宽和高比MNIST图像的多10倍以上,因此,需要一个更大的卷积窗口来捕获目标
    • 第二层中的卷积窗口形状被缩减为5×55\times5,然后是3×33\times3
    • 在第一层、第二层和第五层卷积层之后,加入窗口形状为3×33\times3 、步幅为2的最大汇聚层。 而且AlexNet的卷积通道数目是LeNet的10倍。
    • 在最后一个卷积层后有两个全连接层,分别有4096个输出。 这两个巨大的全连接层拥有将近1GB的模型参数。
  3. 激活函数
    AlexNet将sigmoid激活函数改为更简单的ReLU激活函数:
    • ReLU激活函数的计算更简单,它不需要如sigmoid激活函数那般复杂的求幂运算
    • 当使用不同的参数初始化方法时,ReLU激活函数使训练模型更加容易
  4. 容量控制和预处理
    • AlexNet通过dropout控制全连接层的模型复杂度,而LeNet只使用了权重衰减
    • AlexNet在训练时增加了大量的图像增强数据,如翻转、裁切和变色
  5. 网络结构实现:
    net = nn.Sequential(
        # 这里,我们使用一个11*11的更大窗口来捕捉对象。
        # 同时,步幅为4,以减少输出的高度和宽度。
        # 另外,输出通道的数目远大于LeNet
        nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1), nn.ReLU(),
        nn.MaxPool2d(kernel_size=3, stride=2),
        # 减小卷积窗口,使用填充为2来使得输入与输出的高和宽一致,且增大输出通道数
        nn.Conv2d(96, 256, kernel_size=5, padding=2), nn.ReLU(),
        nn.MaxPool2d(kernel_size=3, stride=2),
        # 使用三个连续的卷积层和较小的卷积窗口。
        # 除了最后的卷积层,输出通道的数量进一步增加。
        # 在前两个卷积层之后,汇聚层不用于减少输入的高度和宽度
        nn.Conv2d(256, 384, kernel_size=3, padding=1), nn.ReLU(),
        nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(),
        nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(),
        nn.MaxPool2d(kernel_size=3, stride=2),
        nn.Flatten(),
        # 这里,全连接层的输出数量是LeNet中的好几倍。使用dropout层来减轻过度拟合
        nn.Linear(6400, 4096), nn.ReLU(),
        nn.Dropout(p=0.5),
        nn.Linear(4096, 4096), nn.ReLU(),
        nn.Dropout(p=0.5),
        # 最后是输出层。由于这里使用Fashion-MNIST,所以用类别数为10,而非论文中的1000
        nn.Linear(4096, 10))
    

使用块的网络(VGG)

  1. VGG块
    • 由一系列卷积层组成,后面再加上用于空间下采样的最大汇聚层。在最初的 VGG 论文中,作者使用了带有3×33\times3卷积核、填充为 1(保持高度和宽度)的卷积层,和带有2×22\times2池化窗口、步幅为 2(每个块后的分辨率减半)的最大汇聚层
    • 块的使用导致网络定义的非常简洁。使用块可以有效地设计复杂的网络。
    • 在VGG论文中,Simonyan和Ziserman尝试了各种架构。特别是他们发现深层且窄的卷积(即 3×33 \times 3)比较浅层且宽的卷积更有效
  2. VGG网络VGG
    • 与 AlexNet、LeNet 一样,VGG 网络可以分为两部分:第一部分主要由卷积层和汇聚层组成,第二部分由全连接层组成
    • 原始 VGG 网络有 5 个卷积块,其中前两个块各有一个卷积层,后三个块各包含两个卷积层。 第一个模块有 64 个输出通道,每个后续模块将输出通道数量翻倍,直到该数字达到 512。由于该网络使用 8 个卷积层和 3 个全连接层,因此它通常被称为 VGG-11
  3. 网络结构实现:
    def vgg_block(num_convs, in_channels, out_channels):
        layers = []
        for _ in range(num_convs):
            layers.append(nn.Conv2d(in_channels, out_channels,
                                    kernel_size=3, padding=1))
            layers.append(nn.ReLU())
            in_channels = out_channels
        layers.append(nn.MaxPool2d(kernel_size=2,stride=2))
        return nn.Sequential(*layers)
    
    def vgg(conv_arch):
        conv_blks = []
        in_channels = 1
        # 卷积层部分
        for (num_convs, out_channels) in conv_arch:
            conv_blks.append(vgg_block(num_convs, in_channels, out_channels))
            in_channels = out_channels
    
        return nn.Sequential(
            *conv_blks, nn.Flatten(),
            # 全连接层部分
            nn.Linear(out_channels * 7 * 7, 4096), nn.ReLU(), nn.Dropout(0.5),
            nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5),
            nn.Linear(4096, 10))
    
    net = vgg(conv_arch)
    

网络中的网络(NiN)

  1. NiN块
    • 在每个像素位置应用一个全连接层。 如果我们将权重连接到每个空间位置,我们可以将其视为 1×1 卷积层,或作为在每个像素位置上独立作用的全连接层。 从另一个角度看,即将空间维度中的每个像素视为单个样本,将通道维度视为不同特征
    • 与VGG块的差异nin块
  2. NiN模型
    • NiN 和 AlexNet 之间的一个显著区别是 NiN 完全取消了全连接层。 相反,NiN 使用一个 NiN块,其输出通道数等于标签类别的数量。最后放一个 全局平均汇聚层,生成一个多元逻辑向量
    • NiN 设计的一个优点是,它显著减少了模型所需参数的数量。然而,在实践中,这种设计有时会增加训练模型的时间
    • 实现:
      def nin_block(in_channels, out_channels, kernel_size, strides, padding):
          return nn.Sequential(
              nn.Conv2d(in_channels, out_channels, kernel_size, strides, 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())
      net = nn.Sequential(
          nin_block(1, 96, kernel_size=11, strides=4, padding=0),
          nn.MaxPool2d(3, stride=2),
          nin_block(96, 256, kernel_size=5, strides=1, padding=2),
          nn.MaxPool2d(3, stride=2),
          nin_block(256, 384, kernel_size=3, strides=1, padding=1),
          nn.MaxPool2d(3, stride=2),
          nn.Dropout(0.5),
          # 标签类别数是10
          nin_block(384, 10, kernel_size=3, strides=1, padding=1),
          nn.AdaptiveAvgPool2d((1, 1)),
          # 将四维的输出转成二维的输出,其形状为(批量大小, 10)
          nn.Flatten())
      

含并行连结的网络(GoogLeNet)

  1. Inception块
    • 结构图inception
  2. GoogLeNet模型
    • 结构示意:googlenet
    • GoogLeNet 一共使用 9 个Inception块和全局平均汇聚层的堆叠来生成其估计值
    • Inception块之间的最大汇聚层可降低维度
    • 第一个模块类似于 AlexNet 和 LeNet
    • Inception块的栈从VGG继承
    • 全局平均汇聚层避免了在最后使用全连接层
    • 实现:
      class Inception(nn.Module):
          # `c1`--`c4` 是每条路径的输出通道数
          def __init__(self, in_channels, c1, c2, c3, c4, **kwargs):
              super(Inception, self).__init__(**kwargs)
              # 线路1,单1 x 1卷积层
              self.p1_1 = nn.Conv2d(in_channels, c1, kernel_size=1)
              # 线路2,1 x 1卷积层后接3 x 3卷积层
              self.p2_1 = nn.Conv2d(in_channels, 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_channels, 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_channels, 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)
      
      b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
                 nn.ReLU(),
                 nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
      b2 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=1),
                 nn.ReLU(),
                 nn.Conv2d(64, 192, kernel_size=3, padding=1),
                 nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
      b3 = nn.Sequential(Inception(192, 64, (96, 128), (16, 32), 32),
                 Inception(256, 128, (128, 192), (32, 96), 64),
                 nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
      b4 = nn.Sequential(Inception(480, 192, (96, 208), (16, 48), 64),
                 Inception(512, 160, (112, 224), (24, 64), 64),
                 Inception(512, 128, (128, 256), (24, 64), 64),
                 Inception(512, 112, (144, 288), (32, 64), 64),
                 Inception(528, 256, (160, 320), (32, 128), 128),
                 nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
      b5 = nn.Sequential(Inception(832, 256, (160, 320), (32, 128), 128),
                 Inception(832, 384, (192, 384), (48, 128), 128),
                 nn.AdaptiveAvgPool2d((1,1)),
                 nn.Flatten())
      
      net = nn.Sequential(b1, b2, b3, b4, b5, nn.Linear(1024, 10))
      

批量归一化

  1. 训练神经网络时的一些挑战
    • 数据预处理的方式通常会对最终结果产生巨大影响
    • 对于典型的多层感知机或卷积神经网络。当我们训练时,中间层中的变量可能具有更广的变化范围
    • 更深层的网络很复杂,容易过拟合
  2. BN原理:
    • 表达式:

      BN(x)=γxμ^Bσ^B+β.\mathrm{BN}(\mathbf{x}) = \boldsymbol{\gamma} \odot \frac{\mathbf{x} - \hat{\boldsymbol{\mu}}_\mathcal{B}}{\hat{\boldsymbol{\sigma}}_\mathcal{B}} + \boldsymbol{\beta}.

    其中, μ^B\hat{\boldsymbol{\mu}}_\mathcal{B}是样本均值, σ^B\hat{\boldsymbol{\sigma}}_\mathcal{B}是小批量B\mathcal{B}的样本标准差。通常包含拉伸参数γ\boldsymbol{\gamma}和偏移参数β\boldsymbol{\beta},它们的形状与xx相同且是需要与其他模型参数一起学习的参数
    • μ^B\hat{\boldsymbol{\mu}}_\mathcal{B}σ^B{\hat{\boldsymbol{\sigma}}_\mathcal{B}}表达式:

      μ^B=1BxBx,σ^B2=1BxB(xμ^B)2+ϵ.\begin{aligned} \hat{\boldsymbol{\mu}}_\mathcal{B} &= \frac{1}{|\mathcal{B}|} \sum_{\mathbf{x} \in \mathcal{B}} \mathbf{x}, \\ \hat{\boldsymbol{\sigma}}_\mathcal{B}^2 &= \frac{1}{|\mathcal{B}|} \sum_{\mathbf{x} \in \mathcal{B}} (\mathbf{x} - \hat{\boldsymbol{\mu}}_{\mathcal{B}})^2 + \epsilon.\end{aligned}

      在方差估计值中添加一个小常量ϵ>0\epsilon > 0 ,以确保永远不会尝试除以零
  3. 批量归一化层
    • 全连接层:

      h=ϕ(BN(Wx+b))\mathbf{h} = \phi(\mathrm{BN}(\mathbf{W}\mathbf{x} + \mathbf{b}) )

    • 卷积层:当卷积有多个输出通道时,需要对这些通道的每个输出执行批量归一化,每个通道都有自己的拉伸和偏移参数
    • 预测过程中的批量归一化:不再需要样本均值中的噪声以及在微批次上估计每个小批次产生的样本方差;我们可能需要使用我们的模型对逐个样本进行预测。 一种常用的方法是通过移动平均估算整个训练数据集的样本均值和方差,并在预测时使用它们得到确定的输出
    • 实现:
      class BatchNorm(nn.Module):
          # `num_features`:完全连接层的输出数量或卷积层的输出通道数。
          # `num_dims`:2表示完全连接层,4表示卷积层
          def __init__(self, num_features, num_dims):
              super().__init__()
              if num_dims == 2:
                  shape = (1, num_features)
              else:
                  shape = (1, num_features, 1, 1)
              # 参与求梯度和迭代的拉伸和偏移参数,分别初始化成1和0
              self.gamma = nn.Parameter(torch.ones(shape))
              self.beta = nn.Parameter(torch.zeros(shape))
              # 非模型参数的变量初始化为0和1
              self.moving_mean = torch.zeros(shape)
              self.moving_var = torch.ones(shape)
      
          def forward(self, X):
              # 如果 `X` 不在内存上,将 `moving_mean` 和 `moving_var`
              # 复制到 `X` 所在显存上
              if self.moving_mean.device != X.device:
                  self.moving_mean = self.moving_mean.to(X.device)
                  self.moving_var = self.moving_var.to(X.device)
              # 保存更新过的 `moving_mean` 和 `moving_var`
              Y, self.moving_mean, self.moving_var = batch_norm(
                  X, self.gamma, self.beta, self.moving_mean,
                  self.moving_var, eps=1e-5, momentum=0.9)
              return Y
      
    • 简明实现:
      net = nn.Sequential(
          nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
          nn.MaxPool2d(kernel_size=2, stride=2),
          nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
          nn.MaxPool2d(kernel_size=2, stride=2), nn.Flatten(),
          nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
          nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
          nn.Linear(84, 10))
      

残差网络(ResNet)

  1. 函数类
    • 对于非嵌套函数类,较复杂的函数类并不总是向“真”函数ff^*靠拢,相反嵌套函数类可以避免上述问题:函数类
    • 因此只有当较复杂的函数类包含较小的函数类时,我们才能确保提高它们的性能
    • 若能将新添加的层训练成恒等映射f(x)=xf(\mathbf{x}) = \mathbf{x}, 新模型和原模型将同样有效,同时由于新模型可能得出更优的解来拟合训练数据集,因此添加层似乎更容易降低训练误差
  2. 残差块
    • 正常块与残差块残差块
    • 残差映射往往更容易优化
    • 当理想映射f(x)f(\mathbf{x})极接近与恒等映射时,残差映射也易于捕捉恒等映射的细微波动
  3. ResNet模型
    • ResNet-18架构:ResNet18
    • 实现:
      class Residual(nn.Module):  #@save
          def __init__(self, input_channels, num_channels,
                      use_1x1conv=False, strides=1):
              super().__init__()
              self.conv1 = nn.Conv2d(input_channels, num_channels,
                                  kernel_size=3, padding=1, stride=strides)
              self.conv2 = nn.Conv2d(num_channels, num_channels,
                                  kernel_size=3, padding=1)
              if use_1x1conv:
                  self.conv3 = nn.Conv2d(input_channels, num_channels,
                                      kernel_size=1, stride=strides)
              else:
                  self.conv3 = None
              self.bn1 = nn.BatchNorm2d(num_channels)
              self.bn2 = nn.BatchNorm2d(num_channels)
              self.relu = nn.ReLU(inplace=True)
      
          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)
      
      b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
                 nn.BatchNorm2d(64), nn.ReLU(),
                 nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
      def resnet_block(input_channels, num_channels, num_residuals,
               first_block=False):
          blk = []
          for i in range(num_residuals):
              if i == 0 and not first_block:
                  blk.append(Residual(input_channels, num_channels,
                                      use_1x1conv=True, strides=2))
              else:
                  blk.append(Residual(num_channels, num_channels))
          return blk
      b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True))
      b3 = nn.Sequential(*resnet_block(64, 128, 2))
      b4 = nn.Sequential(*resnet_block(128, 256, 2))
      b5 = nn.Sequential(*resnet_block(256, 512, 2))
      
      net = nn.Sequential(b1, b2, b3, b4, b5,
                  nn.AdaptiveAvgPool2d((1,1)),
                  nn.Flatten(), nn.Linear(512, 10))
      

稠密连接网络(DenseNet)

  1. 从ResNet到DenseNet
    • ResNet将函数ff分解为两部分:一个简单的线性项和一个更复杂的非线性项,若想将ff拓展成超过两部分的信息就可以用到DenseNet
    • ResNet与DenseNet主要区别:densenet块
    • DenseNet输出是连接而不是如ResNet的简单相加,即

      x[x,f1(x),f2([x,f1(x)]),f3([x,f1(x),f2([x,f1(x)])]),].\mathbf{x} \to \left[ \mathbf{x}, f_1(\mathbf{x}), f_2([\mathbf{x}, f_1(\mathbf{x})]), f_3([\mathbf{x}, f_1(\mathbf{x}), f_2([\mathbf{x}, f_1(\mathbf{x})])]), \ldots\right].

    • 稠密网络主要由两部分构成:稠密块和过渡层
  2. 稠密快体
    • 一个稠密块由多个卷积块组成,每个卷积块使用相同数量的输出信道。 然而,在前向传播中,我们将每个卷积块的输入和输出在通道维上连结
    • 实现:
      def conv_block(input_channels, num_channels):
          return nn.Sequential(
                  nn.BatchNorm2d(input_channels), nn.ReLU(),
                  nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1))
      class DenseBlock(nn.Module):
          def __init__(self, num_convs, input_channels, num_channels):
              super(DenseBlock, self).__init__()
              layer = []
              for i in range(num_convs):
                  layer.append(conv_block(
                      num_channels * i + input_channels, num_channels))
              self.net = nn.Sequential(*layer)
      
          def forward(self, X):
              for blk in self.net:
                  Y = blk(X)
                  # 连接通道维度上每个块的输入和输出
                  X = torch.cat((X, Y), dim=1)
              return X
      
  3. 过度层
    • 由于每个稠密块都会带来通道数的增加,使用过多则会过于复杂化模型。 而过渡层可以用来控制模型复杂度。 它通过1×11\times 1卷积层来减小通道数,并使用步幅为 2 的平均汇聚层减半高和宽,从而进一步降低模型复杂度
    • 实现:
      def transition_block(input_channels, num_channels):
          return nn.Sequential(
                  nn.BatchNorm2d(input_channels), nn.ReLU(),
                  nn.Conv2d(input_channels, num_channels, kernel_size=1),
                  nn.AvgPool2d(kernel_size=2, stride=2))
      
  4. DenseNet模型
    • DenseNet 首先使用同 ResNet 一样的单卷积层和最大汇聚层
    • 接下来,类似于 ResNet 使用的 4 个残差块,DenseNet 使用的是 4 个稠密块
    • 在每个模块之间,ResNet 通过步幅为 2 的残差块减小高和宽,DenseNet 则使用过渡层来减半高和宽,并减半通道数
    • 与 ResNet 类似,最后接上全局汇聚层和全连接层来输出结果
    • 实现
      b1 = nn.Sequential(
          nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
          nn.BatchNorm2d(64), nn.ReLU(),
          nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
      # `num_channels`为当前的通道数
      num_channels, growth_rate = 64, 32
      num_convs_in_dense_blocks = [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.AdaptiveMaxPool2d((1, 1)),
          nn.Flatten(),
          nn.Linear(num_channels, 10))