dive into deep learning深度学习计算部分即卷积神经网络部分

dive into deep learning

5. 深度学习计算

除了庞大的数据集和强大的硬件, 优秀的软件工具在深度学习的快速发展中发挥了不可或缺的作用。 从2007年发布的开创性的Theano库开始, 灵活的开源工具使研究人员能够快速开发模型原型, 避免了我们使用标准组件时的重复工作, 同时仍然保持了我们进行底层修改的能力。 随着时间的推移,深度学习库已经演变成提供越来越粗糙的抽象。 就像半导体设计师从指定晶体管到逻辑电路再到编写代码一样, 神经网络研究人员已经从考虑单个人工神经元的行为转变为从层的角度构思网络, 通常在设计架构时考虑的是更粗糙的块(block)。

之前我们已经介绍了一些基本的机器学习概念, 并慢慢介绍了功能齐全的深度学习模型。 在上一章中,我们从零开始实现了多层感知机的每个组件, 然后展示了如何利用高级API轻松地实现相同的模型。 为了易于学习,我们调用了深度学习库,但是跳过了它们工作的细节。 在本章中,我们将深入探索深度学习计算的关键组件, 即模型构建、参数访问与初始化、设计自定义层和块、将模型读写到磁盘, 以及利用GPU实现显著的加速。 这些知识将使你从深度学习“基础用户”变为“高级用户”。 虽然本章不介绍任何新的模型或数据集, 但后面的高级模型章节在很大程度上依赖于本章的知识。

5.1. 层和块

之前首次介绍神经网络时,我们关注的是具有单一输出的线性模型。 在这里,整个模型只有一个输出。 注意,单个神经网络 (1)接受一些输入; (2)生成相应的标量输出; (3)具有一组相关 参数(parameters),更新这些参数可以优化某目标函数。

然后,当考虑具有多个输出的网络时, 我们利用矢量化算法来描述整层神经元。 像单个神经元一样,层(1)接受一组输入, (2)生成相应的输出, (3)由一组可调整参数描述。 当我们使用softmax回归时,一个单层本身就是模型。 然而,即使我们随后引入了多层感知机,我们仍然可以认为该模型保留了上面所说的基本架构。

对于多层感知机而言,整个模型及其组成层都是这种架构。 整个模型接受原始输入(特征),生成输出(预测), 并包含一些参数(所有组成层的参数集合)。 同样,每个单独的层接收输入(由前一层提供), 生成输出(到下一层的输入),并且具有一组可调参数, 这些参数根据从下一层反向传播的信号进行更新。

事实证明,研究讨论“比单个层大”但“比整个模型小”的组件更有价值。 例如,在计算机视觉中广泛流行的ResNet-152架构就有数百层, 这些层是由层组(groups of layers)的重复模式组成。 这个ResNet架构赢得了2015年ImageNet和COCO计算机视觉比赛 的识别和检测任务 [He et al., 2016a]。 目前ResNet架构仍然是许多视觉任务的首选架构。 在其他的领域,如自然语言处理和语音, 层组以各种重复模式排列的类似架构现在也是普遍存在。

为了实现这些复杂的网络,我们引入了神经网络的概念。 (block)可以描述单个层、由多个层组成的组件或整个模型本身。 使用块进行抽象的一个好处是可以将一些块组合成更大的组件, 这一过程通常是递归的,如 图5.1.1所示。 通过定义代码来按需生成任意复杂度的块, 我们可以通过简洁的代码实现复杂的神经网络。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oWxWNLTy-1647607362476)(https://zh.d2l.ai/_images/blocks.svg)]

图5.1.1 多个层被组合成块,形成更大的模型

从编程的角度来看,块由(class)表示。 它的任何子类都必须定义一个将其输入转换为输出的前向传播函数, 并且必须存储任何必需的参数。 注意,有些块不需要任何参数。 最后,为了计算梯度,块必须具有反向传播函数。 在定义我们自己的块时,由于自动微分(在 2.5节 中引入) 提供了一些后端实现,我们只需要考虑前向传播函数和必需的参数。

在构造自定义块之前,我们先回顾一下多层感知机 ( 4.3节 )的代码。 下面的代码生成一个网络,其中包含一个具有256个单元和ReLU激活函数的全连接隐藏层, 然后是一个具有10个隐藏单元且不带激活函数的全连接输出层。

5.1.1. 自定义块

要想直观地了解块是如何工作的,最简单的方法就是自己实现一个。 在实现我们自定义块之前,我们简要总结一下每个块必须提供的基本功能:

  1. 将输入数据作为其前向传播函数的参数。
  2. 通过前向传播函数来生成输出。请注意,输出的形状可能与输入的形状不同。例如,我们上面模型中的第一个全连接的层接收一个20维的输入,但是返回一个维度为256的输出。
  3. 计算其输出关于输入的梯度,可通过其反向传播函数进行访问。通常这是自动发生的。
  4. 存储和访问前向传播计算所需的参数。
  5. 根据需要初始化模型参数。

在下面的代码片段中,我们从零开始编写一个块。 它包含一个多层感知机,其具有256个隐藏单元的隐藏层和一个10维输出层。 注意,下面的MLP类继承了表示块的类。 我们的实现只需要提供我们自己的构造函数(Python中的__init__函数)和前向传播函数。

class MLP(nn.Module):
    # 用模型参数声明层。这里,我们声明两个全连接的层
    def __init__(self):
        # 调用MLP的父类Module的构造函数来执行必要的初始化。
        # 这样,在类实例化时也可以指定其他函数参数,例如模型参数params(稍后将介绍)
        super().__init__()
        self.hidden = nn.Linear(20, 256)  # 隐藏层
        self.out = nn.Linear(256, 10)  # 输出层

    # 定义模型的前向传播,即如何根据输入X返回所需的模型输出
    def forward(self, X):
        # 注意,这里我们使用ReLU的函数版本,其在nn.functional模块中定义。
        return self.out(F.relu(self.hidden(X)))

我们首先看一下前向传播函数,它以X作为输入, 计算带有激活函数的隐藏表示,并输出其未规范化的输出值。 在这个MLP实现中,两个层都是实例变量。 要了解这为什么是合理的,可以想象实例化两个多层感知机(net1net2), 并根据不同的数据对它们进行训练。 当然,我们希望它们学到两种不同的模型。

接着我们实例化多层感知机的层,然后在每次调用前向传播函数时调用这些层。 注意一些关键细节: 首先,我们定制的__init__函数通过super().__init__() 调用父类的__init__函数, 省去了重复编写模版代码的痛苦。 然后,我们实例化两个全连接层, 分别为self.hiddenself.out。 注意,除非我们实现一个新的运算符, 否则我们不必担心反向传播函数或参数初始化, 系统将自动生成这些。

我们来试一下这个函数:

net = MLP()
net(X)
tensor([[ 0.2511,  0.1825,  0.0906, -0.1014, -0.0818, -0.2257,  0.0343, -0.0502,
         -0.1205,  0.1808],
        [ 0.0538,  0.2270, -0.0294, -0.0254,  0.0274, -0.1483, -0.1254,  0.0424,
         -0.1205,  0.1796]], grad_fn=<AddmmBackward>)

块的一个主要优点是它的多功能性。 我们可以子类化块以创建层(如全连接层的类)、 整个模型(如上面的MLP类)或具有中等复杂度的各种组件。 我们在接下来的章节中充分利用了这种多功能性, 比如在处理卷积神经网络时。

5.1.2. 顺序块

现在我们可以更仔细地看看Sequential类是如何工作的, 回想一下Sequential的设计是为了把其他模块串起来。 为了构建我们自己的简化的MySequential, 我们只需要定义两个关键函数:

  1. 一种将块逐个追加到列表中的函数。
  2. 一种前向传播函数,用于将输入按追加块的顺序传递给块组成的“链条”。

下面的MySequential类提供了与默认Sequential类相同的功能。

class MySequential(nn.Module):
    def __init__(self, *args):
        super().__init__()
        for idx, module in enumerate(args):
            # 这里,module是Module子类的一个实例。我们把它保存在'Module'类的成员
            # 变量_modules中。module的类型是OrderedDict
            self._modules[str(idx)] = module

    def forward(self, X):
        # OrderedDict保证了按照成员添加的顺序遍历它们
        for block in self._modules.values():
            X = block(X)
        return X

__init__函数将每个模块逐个添加到有序字典_modules中。 你可能会好奇为什么每个Module都有一个_modules属性? 以及为什么我们使用它而不是自己定义一个Python列表? 简而言之,_modules的主要优点是: 在模块的参数初始化过程中, 系统知道在_modules字典中查找需要初始化参数的子块。

MySequential的前向传播函数被调用时, 每个添加的块都按照它们被添加的顺序执行。 现在可以使用我们的MySequential类重新实现多层感知机。

net = MySequential(nn.Linear(20, 256), nn.ReLU(), nn.Linear(256, 10))
net(X)
tensor([[-0.0345,  0.0057,  0.0227, -0.1984, -0.2237, -0.0464, -0.0189, -0.2631,
         -0.2752, -0.1415],
        [-0.1851,  0.0243, -0.0286, -0.1372, -0.1288, -0.1205,  0.1659, -0.3036,
         -0.2170, -0.0545]], grad_fn=<AddmmBackward>)

5.1.3. 在前向传播函数中执行代码

Sequential类使模型构造变得简单, 允许我们组合新的架构,而不必定义自己的类。 然而,并不是所有的架构都是简单的顺序架构。 当需要更强的灵活性时,我们需要定义自己的块。 例如,我们可能希望在前向传播函数中执行Python的控制流。 此外,我们可能希望执行任意的数学运算, 而不是简单地依赖预定义的神经网络层。

到目前为止, 我们网络中的所有操作都对网络的激活值及网络的参数起作用。 然而,有时我们可能希望合并既不是上一层的结果也不是可更新参数的项, 我们称之为常数参数(constant parameter)。 例如,我们需要一个计算函数
f ( x , w ) = c ⋅ w ⊤ x f(\mathbf{x},\mathbf{w}) = c \cdot \mathbf{w}^\top \mathbf{x} f(x,w)=cwx
的层, 其中x是输入, w是参数, c是某个在优化过程中没有更新的指定常量。 因此我们实现了一个FixedHiddenMLP类,如下所示:

注:relu的作用

https://www.zhihu.com/question/29021768

**1.为什么引入非线性激励函数?**

如果不适用激励函数,那么在这种情况下每一层的输出都是上层输入的线性函数,很容易验证,无论你神经网络有多少层,输出都是输入的线性组合,与没有隐藏层效果相当,这种情况就是最原始的感知机(perceptron)了

正因为上面的原因,我们决定引入非线性函数作为激励函数,这样深层神经网络就有意义了,不再是输入的线性组合,可以逼近任意函数,最早的想法是用sigmoid函数或者tanh函数,输出有界,很容易充当下一层的输入

 

**2.为什么引入Relu?**

第一,采用sigmoid等函数,算激活函数时候(指数运算),计算量大,反向传播求误差梯度时,求导涉及除法,计算量相当大,而采用Relu激活函数,整个过程的计算量节省很多

第二,对于深层网络,sigmoid函数反向传播时,很容易就出现梯度消失的情况(在sigmoid函数接近饱和区时,变换太缓慢,导数趋于0,这种情况会造成信息丢失),从而无法完成深层网络的训练

第三,Relu会使一部分神经元的输出为0,这样就造成了网络的稀疏性,并且减少了参数的相互依存关系,缓解了过拟合问题的发生

当然,现在也有一些对relu的改进,比如,prelu,random relu等,在不同的数据集上会有一些训练速度上或者准确率上的改进

多加一句,现在主流的做法,会多做一步batch normalization,尽可能保证每一层网络的输入具有相同的分布

***\*一言以蔽之\****,其实,relu函数的作用就是增加了[神经网络](https://so.csdn.net/so/search?q=神经网络&spm=1001.2101.3001.7020)各层之间的非线性关系,否则,如果没有激活函数,层与层之间是简单的线性关系,每层都相当于矩阵相乘,这样怎么能够完成我们需要神经网络完成的复杂任务,

我们利用神经网络去解决图像分割,边界探测,超分辨等问题时候,我们的输入(假设为x),与期望的输出(假设为y)之间的关系究竟是什么?也就是***\*y=f(x)\****中,**f是什么**,我们也不清楚,但是我们对一点很确信,那就是***\*f不是一个简单的线性函数\****,应该是一个抽象的复杂的关系,那么利用神经网络就是去学习这个关系,存放在model中,利用得到的model去推测训练集之外的数据,得到期望的结果


class FixedHiddenMLP(nn.Module):
    def __init__(self):
        super().__init__()
        # 不计算梯度的随机权重参数。因此其在训练期间保持不变
        self.rand_weight = torch.rand((20, 20), requires_grad=False)
        self.linear = nn.Linear(20, 20)

    def forward(self, X):
        X = self.linear(X)
        # 使用创建的常量参数以及relu和mm函数
        X = F.relu(torch.mm(X, self.rand_weight) + 1)
        # 复用全连接层。这相当于两个全连接层共享参数
        X = self.linear(X)
        # 控制流
        while X.abs().sum() > 1:
            X /= 2
        return X.sum()

在这个FixedHiddenMLP模型中,我们实现了一个隐藏层, 其权重(self.rand_weight)在实例化时被随机初始化,之后为常量。 这个权重不是一个模型参数,因此它永远不会被反向传播更新。 然后,神经网络将这个固定层的输出通过一个全连接层。

注意,在返回输出之前,模型做了一些不寻常的事情: 它运行了一个while循环,在L1L1范数大于11的条件下, 将输出向量除以22,直到它满足条件为止。 最后,模型返回了X中所有项的和。 注意,此操作可能不会常用于在任何实际任务中, 我们只是向你展示如何将任意代码集成到神经网络计算的流程中。

net = FixedHiddenMLP()
net(X)
tensor(0.0362, grad_fn=<SumBackward0>)

class NestMLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(nn.Linear(20, 64), nn.ReLU(),
                                 nn.Linear(64, 32), nn.ReLU())
        self.linear = nn.Linear(32, 16)

    def forward(self, X):
        return self.linear(self.net(X))

chimera = nn.Sequential(NestMLP(), nn.Linear(16, 20), FixedHiddenMLP())
chimera(X)

你可能会开始担心操作效率的问题。 毕竟,我们在一个高性能的深度学习库中进行了大量的字典查找、 代码执行和许多其他的Python代码。 Python的问题全局解释器锁 是众所周知的。 在深度学习环境中,我们担心速度极快的GPU可能要等到CPU运行Python代码后才能运行另一个工作。

5.1.5. 小结

  • 一个块可以由许多层组成;一个块可以由许多块组成。
  • 块可以包含代码。
  • 块负责大量的内部处理,包括参数初始化和反向传播。
  • 层和块的顺序连接由Sequential块处理。

5.2. 参数管理

在选择了架构并设置了超参数后,我们就进入了训练阶段。 此时,我们的目标是找到使损失函数最小化的模型参数值。 经过训练后,我们将需要使用这些参数来做出未来的预测。 此外,有时我们希望提取参数,以便在其他环境中复用它们, 将模型保存下来,以便它可以在其他软件中执行, 或者为了获得科学的理解而进行检查。

之前的介绍中,我们只依靠深度学习框架来完成训练的工作, 而忽略了操作参数的具体细节。 本节,我们将介绍以下内容:

  • 访问参数,用于调试、诊断和可视化。
  • 参数初始化。
  • 在不同模型组件间共享参数。

我们首先看一下具有单隐藏层的多层感知机。

import torch
from torch import nn

net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
X = torch.rand(size=(2, 4))
net(X)
tensor([[-0.1909],
        [-0.2025]], grad_fn=<AddmmBackward>)

5.2.1. 参数访问

我们从已有模型中访问参数。 当通过Sequential类定义模型时, 我们可以通过索引来访问模型的任意层。 这就像模型是一个列表一样,每层的参数都在其属性中。 如下所示,我们可以检查第二个全连接层的参数。

print(net[2].state_dict())
OrderedDict([('weight', tensor([[-0.2358, -0.2256, -0.1930, -0.0475, -0.0732, -0.3483,  0.0520,  0.1466]])), ('bias', tensor([-0.0579]))])

输出的结果告诉我们一些重要的事情: 首先,这个全连接层包含两个参数,分别是该层的权重和偏置。 两者都存储为单精度浮点数(float32)。 注意,参数名称允许唯一标识每个参数,即使在包含数百个层的网络中也是如此。

5.2.1.1. 目标参数

注意,每个参数都表示为参数类的一个实例。 要对参数执行任何操作,首先我们需要访问底层的数值。 有几种方法可以做到这一点。有些比较简单,而另一些则比较通用。 下面的代码从第二个全连接层(即第三个神经网络层)提取偏置, 提取后返回的是一个参数类实例,并进一步访问该参数的值。

print(type(net[2].bias))
print(net[2].bias)
print(net[2].bias.data)
<class 'torch.nn.parameter.Parameter'>
Parameter containing:
tensor([-0.0579], requires_grad=True)
tensor([-0.0579])

5.2.1.2. 一次性访问所有参数

当我们需要对所有参数执行操作时,逐个访问它们可能会很麻烦。 当我们处理更复杂的块(例如,嵌套块)时,情况可能会变得特别复杂, 因为我们需要递归整个树来提取每个子块的参数。 下面,我们将通过演示来比较访问第一个全连接层的参数和访问所有层。

print(*[(name, param.shape) for name, param in net[0].named_parameters()])
print(*[(name, param.shape) for name, param in net.named_parameters()])
('weight', torch.Size([8, 4])) ('bias', torch.Size([8]))
('0.weight', torch.Size([8, 4])) ('0.bias', torch.Size([8])) ('2.weight', torch.Size([1, 8])) ('2.bias', torch.Size([1]))

这为我们提供了另一种访问网络参数的方式,如下所示。

net.state_dict()['2.bias'].data
tensor([-0.0579])

5.2.1.3. 从嵌套块收集参数

让我们看看,如果我们将多个块相互嵌套,参数命名约定是如何工作的。 我们首先定义一个生成块的函数(可以说是“块工厂”),然后将这些块组合到更大的块中。

def block1():
    return nn.Sequential(nn.Linear(4, 8), nn.ReLU(),
                         nn.Linear(8, 4), nn.ReLU())

def block2():
    net = nn.Sequential()
    for i in range(4):
        # 在这里嵌套
        net.add_module(f'block {i}', block1())
    return net

rgnet = nn.Sequential(block2(), nn.Linear(4, 1))
rgnet(X)
tensor([[0.3983],
        [0.3983]], grad_fn=<AddmmBackward>)
Sequential(
  (0): Sequential(
    (block 0): Sequential(
      (0): Linear(in_features=4, out_features=8, bias=True)
      (1): ReLU()
      (2): Linear(in_features=8, out_features=4, bias=True)
      (3): ReLU()
    )
    (block 1): Sequential(
      (0): Linear(in_features=4, out_features=8, bias=True)
      (1): ReLU()
      (2): Linear(in_features=8, out_features=4, bias=True)
      (3): ReLU()
    )
    (block 2): Sequential(
      (0): Linear(in_features=4, out_features=8, bias=True)
      (1): ReLU()
      (2): Linear(in_features=8, out_features=4, bias=True)
      (3): ReLU()
    )
    (block 3): Sequential(
      (0): Linear(in_features=4, out_features=8, bias=True)
      (1): ReLU()
      (2): Linear(in_features=8, out_features=4, bias=True)
      (3): ReLU()
    )
  )
  (1): Linear(in_features=4, out_features=1, bias=True)
)

因为层是分层嵌套的,所以我们也可以像通过嵌套列表索引一样访问它们。 下面,我们访问第一个主要的块中、第二个子块的第一层的偏置项。

rgnet[0][1][0].bias.data
tensor([-0.3155,  0.0512,  0.3313,  0.2001, -0.2423, -0.2325, -0.4816, -0.0248])

5.2.2. 参数初始化

知道了如何访问参数后,现在我们看看如何正确地初始化参数。 我们在 4.8节中讨论了良好初始化的必要性。 深度学习框架提供默认随机初始化, 也允许我们创建自定义初始化方法, 满足我们通过其他规则实现初始化权重。

默认情况下,PyTorch会根据一个范围均匀地初始化权重和偏置矩阵, 这个范围是根据输入和输出维度计算出的。 PyTorch的nn.init模块提供了多种预置初始化方法。

5.2.2.1. 内置初始化

让我们首先调用内置的初始化器。 下面的代码将所有权重参数初始化为标准差为0.01的高斯随机变量, 且将偏置参数设置为0。

5.3. 延后初始化

到目前为止,我们忽略了建立网络时需要做的以下这些事情:

  • 我们定义了网络架构,但没有指定输入维度。
  • 我们添加层时没有指定前一层的输出维度。
  • 我们在初始化参数时,甚至没有足够的信息来确定模型应该包含多少参数。

你可能会对我们的代码能运行感到惊讶。 毕竟,深度学习框架无法判断网络的输入维度是什么。 这里的诀窍是框架的延后初始化(defers initialization), 即直到数据第一次通过模型传递时,框架才会动态地推断出每个层的大小。

在以后,当使用卷积神经网络时, 由于输入维度(即图像的分辨率)将影响每个后续层的维数, 有了该技术将更加方便。 现在我们在编写代码时无须知道维度是什么就可以设置参数, 这种能力可以大大简化定义和修改模型的任务。 接下来,我们将更深入地研究初始化机制。

5.4. 自定义层

深度学习成功背后的一个因素是神经网络的灵活性: 我们可以用创造性的方式组合不同的层,从而设计出适用于各种任务的架构。 例如,研究人员发明了专门用于处理图像、文本、序列数据和执行动态规划的层。 未来,你会遇到或要自己发明一个现在在深度学习框架中还不存在的层。 在这些情况下,你必须构建自定义层。在本节中,我们将向你展示如何构建。

5.4.1. 不带参数的层

首先,我们构造一个没有任何参数的自定义层。 如果你还记得我们在 5.1节对块的介绍, 这应该看起来很眼熟。 下面的CenteredLayer类要从其输入中减去均值。 要构建它,我们只需继承基础层类并实现前向传播功能。

首先,我们构造一个没有任何参数的自定义层。 如果你还记得我们在 5.1节对块的介绍, 这应该看起来很眼熟。 下面的CenteredLayer类要从其输入中减去均值。 要构建它,我们只需继承基础层类并实现前向传播功能。

import torch
import torch.nn.functional as F
from torch import nn


class CenteredLayer(nn.Module):
    def __init__(self):
        super().__init__()

    def forward(self, X):
        return X - X.mean()

让我们向该层提供一些数据,验证它是否能按预期工作。

layer = CenteredLayer()
layer(torch.FloatTensor([1, 2, 3, 4, 5]))

Output:
tensor([-2., -1.,  0.,  1.,  2.])

注:很重要的一点是 网络结构必须包含的两个函数

init(self) 和 forward(self, X):

现在,我们可以将层作为组件合并到更复杂的模型中。

net = nn.Sequential(nn.Linear(8, 128), CenteredLayer())

作为额外的健全性检查,我们可以在向该网络发送随机数据后,检查均值是否为0。 由于我们处理的是浮点数,因为存储精度的原因,我们仍然可能会看到一个非常小的非零数。

Y = net(torch.rand(4, 8))
Y.mean()

tensor(2.6776e-09, grad_fn=<MeanBackward0>)

5.4.2. 带参数的层

以上我们知道了如何定义简单的层,下面我们继续定义具有参数的层, 这些参数可以通过训练进行调整。 我们可以使用内置函数来创建参数,这些函数提供一些基本的管理功能。 比如管理访问、初始化、共享、保存和加载模型参数。 这样做的好处之一是:我们不需要为每个自定义层编写自定义的序列化程序。

现在,让我们实现自定义版本的全连接层。 回想一下,该层需要两个参数,一个用于表示权重,另一个用于表示偏置项。 在此实现中,我们使用修正线性单元作为激活函数。 该层需要输入参数:in_unitsunits,分别表示输入数和输出数。

class MyLinear(nn.Module):
    def __init__(self, in_units, units):
        super().__init__()
        self.weight = nn.Parameter(torch.randn(in_units, units))
        self.bias = nn.Parameter(torch.randn(units,))
    def forward(self, X):
        linear = torch.matmul(X, self.weight.data) + self.bias.data
        return F.relu(linear)

接下来,我们实例化MyLinear类并访问其模型参数。

linear = MyLinear(5, 3)
linear.weight
Parameter containing:
tensor([[-0.5454,  1.2766, -0.3547],
        [-0.4969, -0.2906, -0.9240],
        [ 0.2956, -0.8858,  1.3960],
        [-0.3093,  1.2917,  1.4760],
        [ 0.3728,  1.4528,  0.7151]], requires_grad=True)

我们可以使用自定义层直接执行前向传播计算。

我们还可以使用自定义层构建模型,就像使用内置的全连接层一样使用自定义层。

net = nn.Sequential(MyLinear(64, 8), MyLinear(8, 1))
net(torch.rand(2, 64))

5.4.3. 小结

  • 我们可以通过基本层类设计自定义层。这允许我们定义灵活的新层,其行为与深度学习框架中的任何现有层不同。
  • 在自定义层定义完成后,我们就可以在任意环境和网络架构中调用该自定义层。
  • 层可以有局部参数,这些参数可以通过内置函数创建。

5.5. 读写文件

到目前为止,我们讨论了如何处理数据, 以及如何构建、训练和测试深度学习模型。 然而,有时我们希望保存训练的模型, 以备将来在各种环境中使用(比如在部署中进行预测)。 此外,当运行一个耗时较长的训练过程时, 最佳的做法是定期保存中间结果, 以确保在服务器电源被不小心断掉时,我们不会损失几天的计算结果。 因此,现在是时候学习如何加载和存储权重向量和整个模型了。

5.5.1. 加载和保存张量

对于单个张量,我们可以直接调用loadsave函数分别读写它们。 这两个函数都要求我们提供一个名称,save要求将要保存的变量作为输入。

import torch
from torch import nn
from torch.nn import functional as F

x = torch.arange(4)
torch.save(x, 'x-file')

我们现在可以将存储在文件中的数据读回内存。

x2 = torch.load('x-file')
x2


tensor([0, 1, 2, 3])

我们可以存储一个张量列表,然后把它们读回内存。

y = torch.zeros(4)
torch.save([x, y],'x-files')
x2, y2 = torch.load('x-files')
(x2, y2)



(tensor([0, 1, 2, 3]), tensor([0., 0., 0., 0.]))

我们甚至可以写入或读取从字符串映射到张量的字典。 当我们要读取或写入模型中的所有权重时,这很方便。

mydict = {'x': x, 'y': y}
torch.save(mydict, 'mydict')
mydict2 = torch.load('mydict')
mydict2

5.5.2. 加载和保存模型参数

保存单个权重向量(或其他张量)确实有用, 但是如果我们想保存整个模型,并在以后加载它们, 单独保存每个向量则会变得很麻烦。 毕竟,我们可能有数百个参数散布在各处。 因此,深度学习框架提供了内置函数来保存和加载整个网络。 需要注意的一个重要细节是,这将保存模型的参数而不是保存整个模型。 例如,如果我们有一个3层多层感知机,我们需要单独指定架构。 因为模型本身可以包含任意代码,所以模型本身难以序列化。 因此,为了恢复模型,我们需要用代码生成架构, 然后从磁盘加载参数。 让我们从熟悉的多层感知机开始尝试一下。

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden = nn.Linear(20, 256)
        self.output = nn.Linear(256, 10)

    def forward(self, x):
        return self.output(F.relu(self.hidden(x)))

net = MLP()
X = torch.randn(size=(2, 20))
Y = net(X)

接下来,我们将模型的参数存储在一个叫做“mlp.params”的文件中。

torch.save(net.state_dict(), 'mlp.params')

为了恢复模型,我们实例化了原始多层感知机模型的一个备份。 这里我们不需要随机初始化模型参数,而是直接读取文件中存储的参数。

clone = MLP()
clone.load_state_dict(torch.load('mlp.params'))
clone.eval()

5.6. GPU

表1.5.1中, 我们回顾了过去20年计算能力的快速增长。 简而言之,自2000年以来,GPU性能每十年增长1000倍。

本节,我们将讨论如何利用这种计算性能进行研究。 首先是如何使用单个GPU,然后是如何使用多个GPU和多个服务器(具有多个GPU)。

我们先看看如何使用单个NVIDIA GPU进行计算。 首先,确保你至少安装了一个NVIDIA GPU。 然后,下载NVIDIA驱动和CUDA 并按照提示设置适当的路径。 当这些准备工作完成,就可以使用nvidia-smi命令来查看显卡信息。

在PyTorch中,每个数组都有一个设备(device), 我们通常将其称为上下文(context)。 默认情况下,所有变量和相关的计算都分配给CPU。 有时上下文可能是GPU。 当我们跨多个服务器部署作业时,事情会变得更加棘手。 通过智能地将数组分配给上下文, 我们可以最大限度地减少在设备之间传输数据的时间。 例如,当在带有GPU的服务器上训练神经网络时, 我们通常希望模型的参数在GPU上。

要运行此部分中的程序,至少需要两个GPU。 注意,对于大多数桌面计算机来说,这可能是奢侈的,但在云中很容易获得。 例如,你可以使用AWS EC2的多GPU实例。 本书的其他章节大都不需要多个GPU, 而本节只是为了展示数据如何在不同的设备之间传递。

5.6.1. 计算设备

我们可以指定用于存储和计算的设备,如CPU和GPU。 默认情况下,张量是在内存中创建的,然后使用CPU计算它。

在PyTorch中,CPU和GPU可以用torch.device('cpu')torch.device('cuda')表示。 应该注意的是,cpu设备意味着所有物理CPU和内存, 这意味着PyTorch的计算将尝试使用所有CPU核心。 然而,gpu设备只代表一个卡和相应的显存。 如果有多个GPU,我们使用torch.device(f'cuda:{i}') 来表示第ii块GPU(ii从0开始)。 另外,cuda:0cuda是等价的。

import torch
from torch import nn

torch.device('cpu'), torch.device('cuda'), torch.device('cuda:1')
(device(type='cpu'), device(type='cuda'), device(type='cuda', index=1))

我们可以查询可用gpu的数量。

torch.cuda.device_count()
def try_gpu(i=0):  #@save
    """如果存在,则返回gpu(i),否则返回cpu()"""
    if torch.cuda.device_count() >= i + 1:
        return torch.device(f'cuda:{i}')
    return torch.device('cpu')

def try_all_gpus():  #@save
    """返回所有可用的GPU,如果没有GPU,则返回[cpu(),]"""
    devices = [torch.device(f'cuda:{i}')
             for i in range(torch.cuda.device_count())]
    return devices if devices else [torch.device('cpu')]

try_gpu(), try_gpu(10), try_all_gpus()
(device(type='cuda', index=0),
 device(type='cpu'),
 [device(type='cuda', index=0), device(type='cuda', index=1)])

5.6.2. 张量与GPU

我们可以查询张量所在的设备。 默认情况下,张量是在CPU上创建的。

x = torch.tensor([1, 2, 3])
x.device
device(type='cpu')

需要注意的是,无论何时我们要对多个项进行操作, 它们都必须在同一个设备上。 例如,如果我们对两个张量求和, 我们需要确保两个张量都位于同一个设备上, 否则框架将不知道在哪里存储结果,甚至不知道在哪里执行计算。

5.6.2.1. 存储在GPU上

有几种方法可以在GPU上存储张量。 例如,我们可以在创建张量时指定存储设备。接 下来,我们在第一个gpu上创建张量变量X。 在GPU上创建的张量只消耗这个GPU的显存。 我们可以使用nvidia-smi命令查看显存使用情况。 一般来说,我们需要确保不创建超过GPU显存限制的数据。

X = torch.ones(2, 3, device=try_gpu())
X

假设你至少有两个GPU,下面的代码将在第二个GPU上创建一个随机张量。

Y = torch.rand(2, 3, device=try_gpu(1))
Y
tensor([[0.5473, 0.1942, 0.2213],
        [0.5998, 0.5565, 0.0372]], device='cuda:1')

5.6.2.2. 复制

如果我们要计算X + Y,我们需要决定在哪里执行这个操作。 例如,如 图5.6.1所示, 我们可以将X传输到第二个GPU并在那里执行操作。 不要简单地X加上Y,因为这会导致异常, 运行时引擎不知道该怎么做:它在同一设备上找不到数据会导致失败。 由于Y位于第二个GPU上,所以我们需要将X移到那里, 然后才能执行相加运算。pic

Z = X.cuda(1)
print(X)
print(Z)
tensor([[1., 1., 1.],
        [1., 1., 1.]], device='cuda:0')
tensor([[1., 1., 1.],
        [1., 1., 1.]], device='cuda:1')
Y + Z
tensor([[1.5473, 1.1942, 1.2213],
        [1.5998, 1.5565, 1.0372]], device='cuda:1')

5.6.2.3. 旁注

人们使用GPU来进行机器学习,因为单个GPU相对运行速度快。 但是在设备(CPU、GPU和其他机器)之间传输数据比计算慢得多。 这也使得并行化变得更加困难,因为我们必须等待数据被发送(或者接收), 然后才能继续进行更多的操作。 这就是为什么拷贝操作要格外小心。 根据经验,多个小操作比一个大操作糟糕得多。 此外,一次执行几个操作比代码中散布的许多单个操作要好得多(除非你确信自己在做什么)。 如果一个设备必须等待另一个设备才能执行其他操作, 那么这样的操作可能会阻塞。 这有点像排队订购咖啡,而不像通过电话预先订购: 当你到店的时候,咖啡已经准备好了。

最后,当我们打印张量或将张量转换为NumPy格式时, 如果数据不在内存中,框架会首先将其复制到内存中, 这会导致额外的传输开销。 更糟糕的是,它现在受制于全局解释器锁,使得一切都得等待Python完成。

5.6.3. 神经网络与GPU

类似地,神经网络模型可以指定设备。 下面的代码将模型参数放在GPU上。

net = nn.Sequential(nn.Linear(3, 1))
net = net.to(device=try_gpu())

在接下来的几章中, 我们将看到更多关于如何在GPU上运行模型的例子, 因为它们将变得更加计算密集。

当输入为GPU上的张量时,模型将在同一GPU上计算结果。

net(X)
tensor([[1.2194],
        [1.2194]], device='cuda:0', grad_fn=<AddmmBackward>)

让我们确认模型参数存储在同一个GPU上。

net[0].weight.data.device

总之,只要所有的数据和参数都在同一个设备上, 我们就可以有效地学习模型。 在下面的章节中,我们将看到几个这样的例子。

5.6.4. 小结

  • 我们可以指定用于存储和计算的设备,例如CPU或GPU。默认情况下,数据在主内存中创建,然后使用CPU进行计算。
  • 深度学习框架要求计算的所有输入数据都在同一设备上,无论是CPU还是GPU。
  • 不经意地移动数据可能会显著降低性能。一个典型的错误如下:计算GPU上每个小批量的损失,并在命令行中将其报告给用户(或将其记录在NumPy ndarray中)时,将触发全局解释器锁,从而使所有GPU阻塞。最好是为GPU内部的日志分配内存,并且只移动较大的日志。

6. 卷积神经网络

在前面的章节中,我们遇到过图像数据。 这种数据的每个样本都由一个二维像素网格组成, 每个像素可能是一个或者多个数值,取决于是黑白还是彩色图像。 到目前为止,我们处理这类结构丰富的数据的方式还不够有效。 我们仅仅通过将图像数据展平成一维向量而忽略了每个图像的空间结构信息,再将数据送入一个全连接的多层感知机中。 因为这些网络特征元素的顺序是不变的,因此最优的结果是利用先验知识,即利用相近像素之间的相互关联性,从图像数据中学习得到有效的模型。

本章介绍的卷积神经网络(convolutional neural network,CNN)是一类强大的、为处理图像数据而设计的神经网络。 基于卷积神经网络架构的模型在计算机视觉领域中已经占主导地位,当今几乎所有的图像识别、目标检测或语义分割相关的学术竞赛和商业应用都以这种方法为基础。

现代卷积神经网络的设计得益于生物学、群论和一系列的补充实验。 卷积神经网络需要的参数少于全连接架构的网络,而且卷积也很容易用GPU并行计算。 因此卷积神经网络除了能够高效地采样从而获得精确的模型,还能够高效地计算。 久而久之,从业人员越来越多地使用卷积神经网络。即使在通常使用循环神经网络的一维序列结构任务上(例如音频、文本和时间序列分析),卷积神经网络也越来越受欢迎。 通过对卷积神经网络一些巧妙的调整,也使它们在图结构数据和推荐系统中发挥作用。

在本章的开始,我们将介绍构成所有卷积网络主干的基本元素。 这包括卷积层本身、填充(padding)和步幅(stride)的基本细节、用于在相邻区域汇聚信息的汇聚层(pooling)、在每一层中多通道(channel)的使用,以及有关现代卷积网络架构的仔细讨论。 在本章的最后,我们将介绍一个完整的、可运行的LeNet模型:这是第一个成功应用的卷积神经网络,比现代深度学习兴起时间还要早。 在下一章中,我们将深入研究一些流行的、相对较新的卷积神经网络架构的完整实现,这些网络架构涵盖了现代从业者通常使用的大多数经典技术。

6.1. 从全连接层到卷积

我们之前讨论的多层感知机十分适合处理表格数据,其中行对应样本,列对应特征。 对于表格数据,我们寻找的模式可能涉及特征之间的交互,但是我们不能预先假设任何与特征交互相关的先验结构。 此时,多层感知机可能是最好的选择,然而对于高维感知数据,这种缺少结构的网络可能会变得不实用。

例如,在之前猫狗分类的例子中:假设我们有一个足够充分的照片数据集,数据集中是拥有标注的照片,每张照片具有百万级像素,这意味着网络的每次输入都有一百万个维度。 即使将隐藏层维度降低到1000,这个全连接层也将有106×103=109106×103=109个参数。 想要训练这个模型将不可实现,因为需要有大量的GPU、分布式优化训练的经验和超乎常人的耐心。

有些读者可能会反对这个观点,认为要求百万像素的分辨率可能不是必要的。 然而,即使分辨率减小为十万像素,使用1000个隐藏单元的隐藏层也可能不足以学习到良好的图像特征,在真实的系统中我们仍然需要数十亿个参数。 此外,拟合如此多的参数还需要收集大量的数据。 然而,如今人类和机器都能很好地区分猫和狗:这是因为图像中本就拥有丰富的结构,而这些结构可以被人类和机器学习模型使用。 卷积神经网络(convolutional neural networks,CNN)是机器学习利用自然图像中一些已知结构的创造性方法。

6.1.1. 不变性

想象一下,假设你想从一张图片中找到某个物体。 合理的假设是:无论哪种方法找到这个物体,都应该和物体的位置无关。 理想情况下,我们的系统应该能够利用常识:猪通常不在天上飞,飞机通常不在水里游泳。 但是,如果一只猪出现在图片顶部,我们还是应该认出它。 我们可以从儿童游戏”沃尔多在哪里”( 图6.1.1)中得到灵感: 在这个游戏中包含了许多充斥着活动的混乱场景,而沃尔多通常潜伏在一些不太可能的位置,读者的目标就是找出他。 尽管沃尔多的装扮很有特点,但是在眼花缭乱的场景中找到他也如大海捞针。 然而沃尔多的样子并不取决于他潜藏的地方,因此我们可以使用一个“沃尔多检测器”扫描图像。 该检测器将图像分割成多个区域,并为每个区域包含沃尔多的可能性打分。 卷积神经网络正是将空间不变性(spatial invariance)的这一概念系统化,从而基于这个模型使用较少的参数来学习有用的表示。

../_images/where-wally-walker-books.jpg

​ 图6.1.1 沃尔多游戏示例图。

现在,我们将上述想法总结一下,从而帮助我们设计适合于计算机视觉的神经网络架构:

  1. 平移不变性(translation invariance):不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应,即为“平移不变性”。
  2. 局部性(locality):神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系,这就是“局部性”原则。最终,可以聚合这些局部特征,以在整个图像级别进行预测。

让我们看看这些原则是如何转化为数学表示的。

6.1.2. 多层感知机的限制

首先,多层感知机的输入是二维图像X,其隐藏表示H在数学上是一个矩阵,在代码中表示为二维张量。 其中X和H具有相同的形状。 为了方便理解,我们可以认为,无论是输入还是隐藏表示都拥有空间结构。

首先,多层感知机的输入是二维图像XX,其隐藏表示HH在数学上是一个矩阵,在代码中表示为二维张量。 其中XX和HH具有相同的形状。 为了方便理解,我们可以认为,无论是输入还是隐藏表示都拥有空间结构。

使用[X]i,j和[H]i,j分别表示输入图像和隐藏表示中位置(i,j)处的像素。 为了使每个隐藏神经元都能接收到每个输入像素的信息,我们将参数从权重矩阵(如同我们先前在多层感知机中所做的那样)替换为四阶权重张量W。假设U包含偏置参数,我们可以将全连接层形式化地表示为
KaTeX parse error: No such environment: split at position 8: \begin{̲s̲p̲l̲i̲t̲}̲\begin{aligned}…
其中,从W到V的转换只是形式上的转换,因为在这两个四阶张量的元素之间存在一一对应的关系。 我们只需重新索引下标(k,l),使k=i+a、l=j+b,由此可得[V]i,j,a,b=[W]i,j,i+a,j+b。 索引a和bb通过在正偏移和负偏移之间移动覆盖了整个图像。 对于隐藏表示中任意给定位置(i,j)处的像素值[H]i,j,可以通过在x中以(i,j)为中心对像素进行加权求和得到,加权使用的权重为[V]i,j,a,b

6.1.2.1. 平移不变性

现在引用上述的第一个原则:平移不变性。 这意味着检测对象在输入XX中的平移,应该仅导致隐藏表示HH中的平移。也就是说,VV和UU实际上不依赖于(i,j)(i,j)的值,即[V]i,j,a,b=[V]a,b[V]i,j,a,b=[V]a,b。并且UU是一个常数,比如uu。因此,我们可以简化HH定义为:

(6.1.2)

[H]i,j=u+∑a∑b[V]a,b[X]i+a,j+b.[H]i,j=u+∑a∑b[V]a,b[X]i+a,j+b.

这就是卷积(convolution)。我们是在使用系数[V]a,b[V]a,b对位置(i,j)(i,j)附近的像素(i+a,j+b)(i+a,j+b)进行加权得到[H]i,j[H]i,j。 注意,[V]a,b[V]a,b的系数比[V]i,j,a,b[V]i,j,a,b少很多,因为前者不再依赖于图像中的位置。这就是显著的进步!

6.1.2.2. 局部性

现在引用上述的第二个原则:局部性。如上所述,为了收集用来训练参数[H]i,j[H]i,j的相关信息,我们不应偏离到距(i,j)(i,j)很远的地方。这意味着在|a|>Δ|a|>Δ或|b|>Δ|b|>Δ的范围之外,我们可以设置[V]a,b=0[V]a,b=0。因此,我们可以将[H]i,j[H]i,j重写为

(6.1.3)

[H]i,j=u+∑a=−ΔΔ∑b=−ΔΔ[V]a,b[X]i+a,j+b.[H]i,j=u+∑a=−ΔΔ∑b=−ΔΔ[V]a,b[X]i+a,j+b.

简而言之, (6.1.3)是一个卷积层(convolutional layer),而卷积神经网络是包含卷积层的一类特殊的神经网络。 在深度学习研究社区中,VV被称为卷积核(convolution kernel)或者滤波器(filter),它仅仅是可学习的一个层的权重。 当图像处理的局部区域很小时,卷积神经网络与多层感知机的训练差异可能是巨大的:以前,多层感知机可能需要数十亿个参数来表示网络中的一层,而现在卷积神经网络通常只需要几百个参数,而且不需要改变输入或隐藏表示的维数。 参数大幅减少的代价是,我们的特征现在是平移不变的,并且当确定每个隐藏活性值时,每一层只包含局部的信息。 以上所有的权重学习都将依赖于归纳偏置。当这种偏置与现实相符时,我们就能得到样本有效的模型,并且这些模型能很好地泛化到未知数据中。 但如果这偏置与现实不符时,比如当图像不满足平移不变时,我们的模型可能难以拟合我们的训练数据。

6.1.3. 卷积

在进一步讨论之前,我们先简要回顾一下为什么上面的操作被称为卷积。在数学中,两个函数(比如f,g:Rd→Rf,g:Rd→R)之间的“卷积”被定义为

(6.1.4)

(f∗g)(x)=∫f(z)g(x−z)dz.(f∗g)(x)=∫f(z)g(x−z)dz.

也就是说,卷积是当把一个函数“翻转”并移位xx时,测量ff和gg之间的重叠。 当为离散对象时,积分就变成求和。例如:对于由索引为ZZ的、平方可和的、无限维向量集合中抽取的向量,我们得到以下定义:

(6.1.5)

(f∗g)(i)=∑af(a)g(i−a).(f∗g)(i)=∑af(a)g(i−a).

对于二维张量,则为ff的索引(a,b)(a,b)和gg的索引(i−a,j−b)(i−a,j−b)上的对应加和:

(6.1.6)

(f∗g)(i,j)=∑a∑bf(a,b)g(i−a,j−b).(f∗g)(i,j)=∑a∑bf(a,b)g(i−a,j−b).

这看起来类似于 (6.1.3),但有一个主要区别:这里不是使用(i+a,j+b)(i+a,j+b),而是使用差值。然而,这种区别是表面的,因为我们总是可以匹配 (6.1.3)(6.1.6)之间的符号。我们在 (6.1.3)中的原始定义更正确地描述了互相关(cross-correlation),这个问题将在下一节中讨论。

6.1.5. 小结

  • 图像的平移不变性使我们以相同的方式处理局部图像,而不在乎它的位置。
  • 局部性意味着计算相应的隐藏表示只需一小部分局部图像像素。
  • 在图像处理中,卷积层通常比全连接层需要更少的参数,但依旧获得高效用的模型。
  • 卷积神经网络(CNN)是一类特殊的神经网络,它可以包含多个卷积层。
  • 多个输入和输出通道使模型在每个空间位置可以获取图像的多方面特征。

6.2. 图像卷积

上节我们解析了卷积层的原理,现在我们看看它的实际应用。由于卷积神经网络的设计是用于探索图像数据,本节我们将以图像为例。

6.2.1. 互相关运算

严格来说,卷积层是个错误的叫法,因为它所表达的运算其实是互相关运算(cross-correlation),而不是卷积运算。 根据 6.1节中的描述,在卷积层中,输入张量和核张量通过互相关运算产生输出张量。

首先,我们暂时忽略通道(第三维)这一情况,看看如何处理二维图像数据和隐藏表示。在 图6.2.1中,输入是高度为3、宽度为3的二维张量(即形状为3×3)。卷积核的高度和宽度都是2,而卷积核窗口(或卷积窗口)的形状由内核的高度和宽度决定(即2×2)。pic

图6.2.1 二维互相关运算。阴影部分是第一个输出元素,以及用于计算输出的输入张量元素和核张量元素:0×0+1×1+3×2+4×3=19

在二维互相关运算中,卷积窗口从输入张量的左上角开始,从左到右、从上到下滑动。 当卷积窗口滑动到新一个位置时,包含在该窗口中的部分张量与卷积核张量进行按元素相乘,得到的张量再求和得到一个单一的标量值,由此我们得出了这一位置的输出张量值。 在如上例子中,输出张量的四个元素由二维互相关运算得到,这个输出高度为2、宽度为2,如下所示:
KaTeX parse error: No such environment: split at position 8: \begin{̲s̲p̲l̲i̲t̲}̲0\times0+1\time…
注意,输出大小略小于输入大小。这是因为卷积核的宽度和高度大于1, 而卷积核只与图像中每个大小完全适合的位置进行互相关运算。 所以,输出大小等于输入大小nh×nw减去卷积核大小kh×kw,即:
( n h − k h + 1 ) × ( n w − k w + 1 ) . (n_h-k_h+1) \times (n_w-k_w+1). (nhkh+1)×(nwkw+1).
这是因为我们需要足够的空间在图像上“移动”卷积核。稍后,我们将看到如何通过在图像边界周围填充零来保证有足够的空间移动卷积核,从而保持输出大小不变。 接下来,我们在corr2d函数中实现如上过程,该函数接受输入张量X和卷积核张量K,并返回输出张量Y

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值