层和块
引用翻译:《动手学深度学习》
为了实现复杂的网络,我们引入了神经网络块的概念。 块(block)可以描述单个层、由多个层组成的组件或整个模型本身。 使用块进行抽象的一个好处是可以将一些块组合成更大的组件, 这一过程通常是递归的。
对于多层感知机而言,整个模型及其组成层都是这种架构。 整个模型接受原始输入(特征),生成输出(预测), 并包含一些参数(所有组成层的参数集合)。 同样,每个单独的层接收输入(由前一层提供), 生成输出(到下一层的输入),并且具有一组可调参数, 这些参数根据从下一层反向传播的信号进行更新。
事实证明,研究讨论“比单个层大”但“比整个模型小”的组件更有价值。
帮助推动深度学习的关键组件之一是强大的软件。与半导体设计类似,工程师们从指定晶体管到逻辑电路再到编写代码,我们现在见证了深度网络设计的类似进展。在前面的章节中,我们看到了从设计单个神经元到整个神经元层的转变。
然而,当我们有152个层时,即使按层设计网络也会很繁琐,就像He等人在2016年提出的用于计算机视觉问题的ResNet-152那样。这样的网络有相当程度的规律性,它们由重复(或至少是类似设计)的层块组成。这些块然后形成了更复杂的网络设计的基础。
简而言之,块是一个或多个层的组合。这种设计得到了代码的帮助,它可以根据需要生成这种块,就像乐高工厂生成的块可以被组合成可怕的人工制品。我们从非常简单的模块开始,即多层感知器的模块。一个常见的策略是构建一个两层的网络,如下所示。
import torch
from torch import nn
from torch.autograd import Variable
import torch.nn.functional as F
net = nn.Sequential(nn.Linear(20, 256),nn.ReLU(),nn.Linear(256, 10))
x = torch.randn(2,20)
net(x)
tensor([[ 0.0259, -0.0882, 0.1119, -0.1585, -0.0911, 0.1426, -0.0533, 0.2907,
-0.2684, -0.0755],
[-0.3949, -0.0375, -0.1352, -0.5339, -0.0366, -0.2347, -0.3211, -0.2683,
-0.4857, -0.2530]], grad_fn=<AddmmBackward>)
块是一个或多个层的组合。上面的代码生成了一个具有256个单元的隐藏层的网络,然后是ReLU激活和另外10个单元支配输出。特别是,我们使用nn.Sequential构造器生成一个空的网络,然后将这两个层插入其中。

下面我们将解释从定义层到定义块(一个或多个层)所需的各种步骤。为了开始,我们需要对软件进行一些推理。对于大多数意图和目的来说,一个块的行为非常像一个花哨的层。也就是说,它提供以下功能。
1、它需要摄取数据(输入)。
2、它需要产生一个有意义的输出。这通常被编码在我们称之为前向函数的地方。它允许我们通过net(X)调用一个块获得所需的输出。幕后发生的事情是,它调用forward来进行前向传播。
4、在调用后向时,它需要产生一个与其输入有关的梯度。一般来说,这是自动的。
5、它需要存储区块所固有的参数。例如,上面的块包含两个隐藏层,我们需要一个地方来存储它的参数。
6、很明显,它还需要根据需要初始化这些参数。
一、一个自定义块
nn.Module类提供了我们所需的大部分功能。它是 nn 模块中提供的一个模型构造器,我们可以继承它来定义我们想要的模型。下面继承Block类来构造本节开头提到的多层感知器。这里定义的MLP类重写了Block类的init和forward函数。它们分别用于创建模型参数和定义前向计算。前向计算也就是前向传播。
class MLP(nn.Module):
# 声明一个带有模型参数的层。在这里,我们声明两个全连接层
def __init__(self, **kwargs):
# 调用MLP父类Module的构造函数来进行必要的初始化。通过这种方式,
# 在构造实例时也可以指定其他的函数参数,比如模型参数params,在下面的章节中会有所描述
super(MLP, self).__init__(**kwargs)
self.hidden = nn.Sequential(nn.Linear(20,256),nn.ReLU()) # Hidden layer
self.output = nn.Linear(256,10) # Output layer
# 定义模型的正向计算,也就是如何根据输入的x返回所需的模型输出
def forward(self, x):
return self.output(self.hidden(x))
让我们再仔细看一下。前向方法通过评估隐藏层self.hidden(x)和随后评估输出层self.output( …)来调用一个网络。这就是我们在这个块的前向传递中所期望的。
为了让该块知道它需要评估什么,我们首先需要定义层。这就是init方法的作用。它首先初始化所有与块相关的参数,然后构建必要的层。这就把相应的层和所需的参数附加到类中。注意,不需要在类中定义一个反向传播方法。系统会通过自动寻找梯度来自动生成反向传播所需的反向方法。这同样适用于初始化方法,它是自动生成的。
net1 = MLP()
x = torch.randn(2,20)
net1(x)
tensor([[ 0.0105, 0.1501, 0.0600, 0.1323, 0.1560, -0.0089, -0.2808, -0.1873,
-0.0897, -0.0632],
[-0.0975, 0.0499, -0.0119, 0.1543, 0.1187, -0.0591, -0.1541, -0.1315,
-0.1765, -0.0034]], grad_fn=<AddmmBackward>)
二、一个连续的块
Block类是一个描述数据流的通用组件。事实上,Sequential类是从Block类派生出来的:当模型的前向计算是每层计算的简单串联时,我们可以用更简单的方式定义模型。
Sequential类的目的是提供一些有用的便利函数。特别是,添加方法允许我们一个一个地添加串联的Block子类实例,而模型的前向计算是按照添加的顺序一个一个地计算这些实例。
下面,实现一个MySequential类,它具有与Sequential类相同的功能。这可能有助于你更清楚地理解序列类的工作原理。
class MySequential(nn.Sequential):
def __init__(self, **kwargs):
super(MySequential, self).__init__(**kwargs)
def add_module(self, block):
# 这里,block是一个Block子类的实例,我们假设它有一个唯一的名字。
# 我们将其保存在Block类的成员变量_children中,其类型为OrderedDict。
# 当MySequential实例调用初始化函数时,系统会自动初始化_children的所有成员
self._modules[block] = block
def forward(self, x):
# OrderedDict保证了成员将按照它们被添加的顺序被遍历。
# 通过对字典遍历进行串联
for block in self._modules.values():
x = block(x)
return x
其核心是add方法。它将任何块添加到有序的子字典中。然后,当前向传播被调用时,这些块被依次执行。让我们看看MLP现在是什么样子。
net = MySequential()
net.add_module(nn.Linear(20,256))
net.add_module(nn.ReLU())
net.add_module(nn.Linear(256,10)) # 添加到有序的子字典
x = torch.randn(2,20)
net(x)
tensor([[-0.1179, 0.1587, -0.1411, -0.1516, 0.3740, -0.1992, -0.0926, 0.0747,
-0.1095, -0.1455],
[-0.2971, 0.1281, -0.1680, -0.1337, 0.6045, -0.1480, 0.1353, 0.0316,
-0.0474, -0.0564]], grad_fn=<AddmmBackward>)
type(net) # 此时net的类型,不可print
__main__.MySequential
三、代码块
尽管 Sequential 类可以使模型的构建更容易,而且你不需要定义前进方法,但直接继承 Block 类可以大大扩展模型构建的灵活性。特别是,我们将在前进方法中使用 Python 的控制流。在这时,我们需要引入另一个概念,即常量参数。这些是在调用 backprop 时不使用的参数。这听起来很抽象,但实际上是这样的。假设我们有一些函数
f ( x , w ) = 3 ⋅ w ⊤ x . f(x,w)=3⋅w⊤x. f(x,w)=3⋅w⊤x.
在这种情况下,3是一个常数参数。我们可以把3改成其他的东西,比如说c,通过
f ( x , w ) = c ⋅ w ⊤ x . f(x,w)=c⋅w⊤x. f(x,w)=c⋅w⊤x.
其实没有什么变化,只是我们可以调整c的值。就w和x而言,它仍然是一个常数。然而,由于Pytorch事先不知道这一点,所以值得帮它一把(这也使代码更快,因为我们没有让Pytorch引擎在一个没有变化的参数上进行疯狂的追逐)。让我们看看这在实践中是什么样子的。
class FancyMLP(nn.Sequential):
def __init__(self, **kwargs):
super(FancyMLP, self).__init__(**kwargs)
# 用get_constant创建的随机权重参数在训练中不会被迭代(即常量参数)
# self.rand_weight = self.parameters.get_constant('rand_weight',nd.random.uniform(shape=(20, 20)))
# 在pytorch中,如果传入的输入形状与定义的模块块相同,你可以使用任何模块
self.dense1=nn.Sequential(nn.Linear(20,20),nn.ReLU())
self.rand_weight=nn.Parameter(torch.empty(20,20).uniform_(0, 1) )
self.dense = nn.Sequential(nn.Linear(20,256),nn.ReLU())
self.register_buffer('random_weights', self.rand_weight)
def forward(self, x):
x = self.dense1(x)
# 使用创建的常数参数,以及NDArray的relu和dot函数
print(x.shape)
# 在pytorch中,点积dot适用于1d张量,2d张量使用torch.mm或torch.bmm。
x = F.relu(torch.mm(x, Variable(self.random_weights).data) + 1)
# 重复使用全连接层。这相当于与两个全连接层共享参数
x = self.dense(x)
# 在控制流中,我们需要调用asscalar来返回标量进行比较。
while x.norm().item() > 1:
print('x.norm().item()',x.norm().item())
print('x.norm()',x.norm())
x /= 2
if x.norm().item() < 0.8:
print('x.norm().item()',x.norm().item())
print('x.norm()',x.norm())
x *= 10
return x.sum()
在这个FancyMLP模型中,我们使用了恒定的权重Rand_weight(注意,它不是一个模型参数),进行了一个矩阵乘法运算(nd.dot<),并重新使用了同一个密集层。
请注意,这与使用两套不同参数的密集层有很大不同。
相反,我们两次使用了同一个网络。在深度网络中,当人们想表达一个网络的多个部分共享相同的参数时,往往也会说参数是绑定的。让我们看看,如果我们构建它并通过它输入数据会发生什么。
net = FancyMLP()
print(net)
x = torch.randn(2,20)
FancyMLP(
(dense1): Sequential(
(0): Linear(in_features=20, out_features=20, bias=True)
(1): ReLU()
)
(dense): Sequential(
(0): Linear(in_features=20, out_features=256, bias=True)
(1): ReLU()
)
)
torch.Size([2, 20])
x.norm().item() 32.54374694824219
x.norm() tensor(32.5437, grad_fn=<CopyBackwards>)
x.norm().item() 16.271873474121094
x.norm() tensor(16.2719, grad_fn=<CopyBackwards>)
x.norm().item() 8.135936737060547
x.norm() tensor(8.1359, grad_fn=<CopyBackwards>)
x.norm().item() 4.067968368530273
x.norm() tensor(4.0680, grad_fn=<CopyBackwards>)
x.norm().item() 2.0339841842651367
x.norm() tensor(2.0340, grad_fn=<CopyBackwards>)
x.norm().item() 1.0169920921325684
x.norm() tensor(1.0170, grad_fn=<CopyBackwards>)
x.norm().item() 0.5084960460662842
x.norm() tensor(0.5085, grad_fn=<CopyBackwards>)
net(x)结果输出:
tensor(64.3372, grad_fn=<SumBackward0>)
我们没有理由不把这些建立网络的方式混合起来。
显然,下面的例子更像是一个嵌合体,或者说是一个Rube Goldberg机器。也就是说,它结合了从单个区块构建一个区块的例子,而这些区块又可能是区块本身。
此外,我们甚至可以在同一个正向函数中结合多种策略。为了证明这一点,这里有一个网络。
class NestMLP(nn.Sequential):
def __init__(self, **kwargs):
super(NestMLP, self).__init__(**kwargs)
self.net = nn.Sequential(nn.Linear(20,64),nn.ReLU(),nn.Linear(64, 32),nn.ReLU())
self.dense = nn.Sequential(nn.Linear(32,20),nn.ReLU())
def forward(self, x):
return self.dense(self.net(x))
chimera = nn.Sequential()
chimera.add_module("Linear1",nn.Linear(20,20))
chimera.add_module("NestNlp",NestMLP())
chimera.add_module("FancyMLP",FancyMLP())
print(chimera)
Sequential(
(Linear1): Linear(in_features=20, out_features=20, bias=True)
(NestNlp): NestMLP(
(net): Sequential(
(0): Linear(in_features=20, out_features=64, bias=True)
(1): ReLU()
(2): Linear(in_features=64, out_features=32, bias=True)
(3): ReLU()
)
(dense): Sequential(
(0): Linear(in_features=32, out_features=20, bias=True)
(1): ReLU()
)
)
(FancyMLP): FancyMLP(
(dense1): Sequential(
(0): Linear(in_features=20, out_features=20, bias=True)
(1): ReLU()
)
(dense): Sequential(
(0): Linear(in_features=20, out_features=256, bias=True)
(1): ReLU()
)
)
)
torch.Size([2, 20])
chimera(x)结果输出:
tensor(12.6640, grad_fn=<SumBackward0>)
四、汇编
热心的读者可能开始担心这个的效率了。毕竟,在这个应该是高性能的深度学习库中,我们有很多字典查询、代码执行和很多其他Pythonic的事情在进行。
Python的全局解释器锁的问题是众所周知的。在深度学习的背景下,这意味着我们有一个超级快的GPU(或多个GPU),它可能不得不等待,直到运行Python的一个微不足道的CPU核心有机会告诉它下一步该怎么做。
这显然是可怕的,有很多方法可以解决这个问题。提高Python速度的最好方法是完全避免它。
Pytorch通过允许混合化来做到这一点。在它里面,Python 解释器在第一次调用时就执行了这个块。Pytorch运行时记录正在发生的事情,并在下一次调用Python时将其短路。
这在某些情况下可以大大加快事情的进展,但需要注意控制流的问题。我们建议有兴趣的读者在读完本章后跳到涵盖杂交和编译的章节。
五、摘要
-
层是块
-
许多层可以是一个块
-
许多块可以是一个块
-
代码可以是一个块
-
块负责大量的内务工作,如参数初始化、反推和相关问题。
-
层和块的顺序连接由同名的顺序块来处理。
六、练习
1、当调用一个其父类不在父类的init函数中的init方法时,你会得到什么样的错误信息?
2、如果你删除FancyMLP类中的item函数,会出现什么样的问题?
3、如果你将NestMLP类中Sequential实例定义的self.net改为self.net = [nn.Sequential(nn.Linear(64,256),nn.ReLU()), nn.Sequential(nn.Linear(32,256),nn.ReLU()]会出现什么样的问题?]
4、实现一个块,该块将两个块作为参数,例如net1和net2,并在前向传递中返回两个网络的串联输出(这也被称为并行块)。
5、假设你想串联同一个网络的多个实例。实现一个工厂函数,生成同一块的多个实例,并从中建立一个更大的网络。