4.1. 模型构造
让我们回顾一下在“多层感知机的简洁实现”一节中含单隐藏层的多层感知机的实现方法。我们首先构造Sequential
实例,然后依次添加两个全连接层。其中第一层的输出大小为256,即隐藏层单元个数是256;第二层的输出大小为10,即输出层单元个数是10。我们在上一章的其他 节中也使用了Sequential
类构造模型。这里我们介绍另外一种基于Block
类的模型构造方法:它让模型构造更加灵活。
4.1.1. 继承Block
类来构造模型
Block
类是nn
模块里提供的一个模型构造类,我们可以继承它来定义我们想要的模型。下面继承Block
类构造本节开头提到的多层感知机。这里定义的MLP
类重载了Block
类的__init__
函数和forward
函数。它们分别用于创建模型参数和定义前向计算。前向计算也即正向传播。
In [1]:
from mxnet import nd from mxnet.gluon import nn class MLP(nn.Block): # 声明带有模型参数的层,这里声明了两个全连接层 def __init__(self, **kwargs): # 调用MLP父类Block的构造函数来进行必要的初始化。这样在构造实例时还可以指定其他函数 # 参数,如“模型参数的访问、初始化和共享”一节将介绍的模型参数params super(MLP, self).__init__(**kwargs) self.hidden = nn.Dense(256, activation='relu') # 隐藏层 self.output = nn.Dense(10) # 输出层 # 定义模型的前向计算,即如何根据输入x计算返回所需要的模型输出 def forward(self, x): return self.output(self.hidden(x))
以上的MLP
类中无须定义反向传播函数。系统将通过自动求梯度而自动生成反向传播所需的backward
函数。
我们可以实例化MLP
类得到模型变量net
。下面的代码初始化net
并传入输入数据X
做一次前向计算。其中,net(X)
会调用MLP
继承自Block
类的__call__
函数,这个函数将调用MLP
类定义的forward
函数来完成前向计算。
In [2]:
X = nd.random.uniform(shape=(2, 20)) net = MLP() net.initialize() net(X)
Out[2]:
[[ 0.09543004 0.04614332 -0.00286654 -0.07790349 -0.05130243 0.02942037 0.08696642 -0.0190793 -0.04122177 0.05088576] [ 0.0769287 0.03099705 0.00856576 -0.04467199 -0.06926839 0.09132434 0.06786595 -0.06187842 -0.03436673 0.04234694]] <NDArray 2x10 @cpu(0)>
注意,这里并没有将Block
类命名为Layer
(层)或者Model
(模型)之类的名字,这是因为该类是一个可供自由组建的部件。它的子类既可以是一个层(如Gluon提供的Dense
类),又可以是一个模型(如这里定义的MLP
类),或者是模型的一个部分。我们下面通过两个例子来展示它的灵活性。
4.1.2. Sequential
类继承自Block
类
我们刚刚提到,Block
类是一个通用的部件。事实上,Sequential
类继承自Block
类。当模型的前向计算为简单串联各个层的计算时,可以通过更加简单的方式定义模型。这正是Sequential
类的目的:它提供add
函数来逐一添加串联的Block
子类实例,而模型的前向计算就是将这些实例按添加的顺序逐一计算。
下面我们实现一个与Sequential
类有相同功能的MySequential
类。这或许可以帮助读者更加清晰地理解Sequential
类的工作机制。
In [3]:
class MySequential(nn.Block): def __init__(self, **kwargs): super(MySequential, self).__init__(**kwargs) def add(self, block): # block是一个Block子类实例,假设它有一个独一无二的名字。我们将它保存在Block类的 # 成员变量_children里,其类型是OrderedDict。当MySequential实例调用 # initialize函数时,系统会自动对_children里所有成员初始化 self._children[block.name] = block def forward(self, x): # OrderedDict保证会按照成员添加时的顺序遍历成员 for block in self._children.values(): x = block(x) return x
我们用MySequential
类来实现前面描述的MLP
类,并使用随机初始化的模型做一次前向计算。
In [4]:
net = MySequential() net.add(nn.Dense(256, activation='relu')) net.add(nn.Dense(10)) net.initialize() net(X)
Out[4]:
[[ 0.00362228 0.00633332 0.03201144 -0.01369375 0.10336449 -0.03508018 -0.00032164 -0.01676023 0.06978628 0.01303309] [ 0.03871715 0.02608213 0.03544959 -0.02521311 0.11005433 -0.0143066 -0.03052466 -0.03852827 0.06321152 0.0038594 ]] <NDArray 2x10 @cpu(0)>
可以观察到这里MySequential
类的使用跟“多层感知机的简洁实现”一节中Sequential
类的使用没什么区别。
4.1.3. 构造复杂的模型
虽然Sequential
类可以使模型构造更加简单,且不需要定义forward
函数,但直接继承Block
类可以极大地拓展模型构造的灵活性。下面我们构造一个稍微复杂点的网络FancyMLP
。在这个网络中,我们通过get_constant
函数创建训练中不被迭代的参数,即常数参数。在前向计算中,除了使用创建的常数参数外,我们还使用NDArray
的函数和Python的控制流,并多次调用相同的层。
In [5]:
class FancyMLP(nn.Block): def __init__(self, **kwargs): super(FancyMLP, self).__init__(**kwargs) # 使用get_constant创建的随机权重参数不会在训练中被迭代(即常数参数) self.rand_weight = self.params.get_constant( 'rand_weight', nd.random.uniform(shape=(20, 20))) self.dense = nn.Dense(20, activation='relu') def forward(self, x): x = self.dense(x) # 使用创建的常数参数,以及NDArray的relu函数和dot函数 x = nd.relu(nd.dot(x, self.rand_weight.data()) + 1) # 复用全连接层。等价于两个全连接层共享参数 x = self.dense(x) # 控制流,这里我们需要调用asscalar函数来返回标量进行比较 while x.norm().asscalar() > 1: x /= 2 if x.norm().asscalar() < 0.8: x *= 10 return x.sum()
在这个FancyMLP
模型中,我们使用了常数权重rand_weight
(注意它不是模型参数)、做了矩阵乘法操作(nd.dot
)并重复使用了相同的Dense
层。下面我们来测试该模型的随机初始化和前向计算。
In [6]:
net = FancyMLP() net.initialize() net(X)
Out[6]:
[18.571953] <NDArray 1 @cpu(0)>
因为FancyMLP
和Sequential
类都是Block
类的子类,所以我们可以嵌套调用它们。
In [7]:
class NestMLP(nn.Block): def __init__(self, **kwargs): super(NestMLP, self).__init__(**kwargs) self.net = nn.Sequential() self.net.add(nn.Dense(64, activation='relu'), nn.Dense(32, activation='relu')) self.dense = nn.Dense(16, activation='relu') def forward(self, x): return self.dense(self.net(x)) net = nn.Sequential() net.add(NestMLP(), nn.Dense(20), FancyMLP()) net.initialize() net(X)
Out[7]:
[24.86621] <NDArray 1 @cpu(0)>
4.1.4. 小结
- 可以通过继承
Block
类来构造模型。 Sequential
类继承自Block
类。- 虽然
Sequential
类可以使模型构造更加简单,但直接继承Block
类可以极大地拓展模型构造的灵活性。
4.1.5. 练习
- 如果不在
MLP
类的__init__
函数里调用父类的__init__
函数,会出现什么样的错误信息? - 如果去掉
FancyMLP
类里面的asscalar
函数,会有什么问题? - 如果将
NestMLP
类中通过Sequential
实例定义的self.net
改为self.net = [nn.Dense(64,activation='relu'), nn.Dense(32, activation='relu')]
,会有什么问题?
4.2. 模型参数的访问、初始化和共享
在“线性回归的简洁实现”一节中,我们通过init
模块来初始化模型的全部参数。我们也介绍了访问模型参数的简单方法。本节将深入讲解如何访问和初始化模型参数,以及如何在多个层之间共享同一份模型参数。
我们先定义一个与上一节中相同的含单隐藏层的多层感知机。我们依然使用默认方式初始化它的参数,并做一次前向计算。与之前不同的是,在这里我们从MXNet中导入了init
模块,它包含了多种模型初始化方法。
In [1]:
from mxnet import init, nd from mxnet.gluon import nn net = nn.Sequential() net.add(nn.Dense(256, activation='relu')) net.add(nn.Dense(10)) net.initialize() # 使用默认初始化方式 X = nd.random.uniform(shape=(2, 20)) Y = net(X) # 前向计算
4.2.1. 访问模型参数
对于使用Sequential
类构造的神经网络,我们可以通过方括号[]
来访问网络的任一层。回忆一下上一节中提到的Sequential
类与Block
类的继承关系。对于Sequential
实例中含模型参数的层,我们可以通过Block
类的params
属性来访问该层包含的所有参数。下面,访问多层感知机net
中隐藏层的所有参数。索引0表示隐藏层为Sequential
实例最先添加的层。
In [2]:
net[0].params, type(net[0].params)
Out[2]:
(dense0_ ( Parameter dense0_weight (shape=(256, 20), dtype=float32) Parameter dense0_bias (shape=(256,), dtype=float32) ), mxnet.gluon.parameter.ParameterDict)
可以看到,我们得到了一个由参数名称映射到参数实例的字典(类型为ParameterDict
类)。其中权重参数的名称为dense0_weight
,它由net[0]
的名称(dense0_
)和自己的变量名(weight
)组成。而且可以看到,该参数的形状为(256, 20),且数据类型为32位浮点数(float32
)。为了访问特定参数,我们既可以通过名字来访问字典里的元素,也可以直接使用它的变量名。下面两种方法是等价的,但通常后者的代码可读性更好。
In [3]:
net[0].params['dense0_weight'], net[0].weight
Out[3]:
(Parameter dense0_weight (shape=(256, 20), dtype=float32), Parameter dense0_weight (shape=(256, 20), dtype=float32))
Gluon里参数类型为Parameter
类,它包含参数和梯度的数值,可以分别通过data
函数和grad
函数来访问。因为我们随机初始化了权重,所以权重参数是一个由随机数组成的形状为(256, 20)的NDArray
。
In [4]:
net[0].weight.data()
Out[4]:
[[ 0.06700657 -0.00369488 0.0418822 ... -0.05517294 -0.01194733 -0.00369594] [-0.03296221 -0.04391347 0.03839272 ... 0.05636378 0.02545484 -0.007007 ] [-0.0196689 0.01582889 -0.00881553 ... 0.01509629 -0.01908049 -0.02449339] ... [ 0.00010955 0.0439323 -0.04911506 ... 0.06975312 0.0449558 -0.03283203] [ 0.04106557 0.05671307 -0.00066976 ... 0.06387014 -0.01292654 0.00974177] [ 0.00297424 -0.0281784 -0.06881659 ... -0.04047417 0.00457048 0.05696651]] <NDArray 256x20 @cpu(0)>
权重梯度的形状和权重的形状一样。因为我们还没有进行反向传播计算,所以梯度的值全为0。
In [5]:
net[0].weight.grad()
Out[5]:
[[0. 0. 0. ... 0. 0. 0.] [0. 0. 0. ... 0. 0. 0.] [0. 0. 0. ... 0. 0. 0.] ... [0. 0. 0. ... 0. 0. 0.] [0. 0. 0. ... 0. 0. 0.] [0. 0. 0. ... 0. 0. 0.]] <NDArray 256x20 @cpu(0)>
类似地,我们可以访问其他层的参数,如输出层的偏差值。
In [6]:
net[1].bias.data()
Out[6]:
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0.] <NDArray 10 @cpu(0)>
最后,我们可以使用collect_params
函数来获取net
变量所有嵌套(例如通过add
函数嵌套)的层所包含的所有参数。它返回的同样是一个由参数名称到参数实例的字典。
In [7]:
net.collect_params()
Out[7]:
sequential0_ ( Parameter dense0_weight (shape=(256, 20), dtype=float32) Parameter dense0_bias (shape=(256,), dtype=float32) Parameter dense1_weight (shape=(10, 256), dtype=float32) Parameter dense1_bias (shape=(10,), dtype=float32) )
这个函数可以通过正则表达式来匹配参数名,从而筛选需要的参数。
In [8]:
net.collect_params('.*weight')
Out[8]:
sequential0_ ( Parameter dense0_weight (shape=(256, 20), dtype=float32) Parameter dense1_weight (shape=(10, 256), dtype=float32) )
4.2.2. 初始化模型参数
我们在“数值稳定性和模型初始化”一节中描述了模型的默认初始化方法:权重参数元素为[-0.07, 0.07]之间均匀分布的随机数,偏差参数则全为0。但我们经常需要使用其他方法来初始化权重。MXNet的init
模块里提供了多种预设的初始化方法。在下面的例子中,我们将权重参数初始化成均值为0、标准差为0.01的正态分布随机数,并依然将偏差参数清零。
In [9]:
# 非首次对模型初始化需要指定force_reinit为真 net.initialize(init=init.Normal(sigma=0.01), force_reinit=True) net[0].weight.data()[0]
Out[9]:
[ 0.01074176 0.00066428 0.00848699 -0.0080038 -0.00168822 0.00936328 0.00357444 0.00779328 -0.01010307 -0.00391573 0.01316619 -0.00432926 0.0071536 0.00925416 -0.00904951 -0.00074684 0.0082254 -0.01878511 0.00885884 0.01911872] <NDArray 20 @cpu(0)>
下面使用常数来初始化权重参数。
In [10]:
net.initialize(init=init.Constant(1), force_reinit=True) net[0].weight.data()[0]
Out[10]:
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.] <NDArray 20 @cpu(0)>
如果只想对某个特定参数进行初始化,我们可以调用Parameter
类的initialize
函数,它与Block
类提供的initialize
函数的使用方法一致。下例中我们对隐藏层的权重使用Xavier随机初始化方法。
In [11]:
net[0].weight.initialize(init=init.Xavier(), force_reinit=True) net[0].weight.data()[0]
Out[11]:
[ 0.00512482 -0.06579044 -0.10849719 -0.09586414 0.06394844 0.06029618 -0.03065033 -0.01086642 0.01929168 0.1003869 -0.09339568 -0.08703034 -0.10472868 -0.09879824 -0.00352201 -0.11063069 -0.04257748 0.06548801 0.12987629 -0.13846186] <NDArray 20 @cpu(0)>
4.2.3. 自定义初始化方法
有时候我们需要的初始化方法并没有在init
模块中提供。这时,可以实现一个Initializer
类的子类,从而能够像使用其他初始化方法那样使用它。通常,我们只需要实现_init_weight
这个函数,并将其传入的NDArray
修改成初始化的结果。在下面的例子里,我们令权重有一半概率初始化为0,有另一半概率初始化为[−10,−5][−10,−5]和[5,10][5,10]两个区间里均匀分布的随机数。
In [12]:
class MyInit(init.Initializer): def _init_weight(self, name, data): print('Init', name, data.shape) data[:] = nd.random.uniform(low=-10, high=10, shape=data.shape) data *= data.abs() >= 5 net.initialize(MyInit(), force_reinit=True) net[0].weight.data()[0]
Init dense0_weight (256, 20) Init dense1_weight (10, 256)
Out[12]:
[-5.3659673 7.5773945 8.986376 -0. 8.827555 0. 5.9840508 -0. 0. 0. 7.4857597 -0. -0. 6.8910007 6.9788704 -6.1131554 0. 5.4665203 -9.735263 9.485172 ] <NDArray 20 @cpu(0)>
此外,我们还可以通过Parameter
类的set_data
函数来直接改写模型参数。例如,在下例中我们将隐藏层参数在现有的基础上加1。
In [13]:
net[0].weight.set_data(net[0].weight.data() + 1) net[0].weight.data()[0]
Out[13]:
[-4.3659673 8.5773945 9.986376 1. 9.827555 1. 6.9840508 1. 1. 1. 8.48576 1. 1. 7.8910007 7.9788704 -5.1131554 1. 6.4665203 -8.735263 10.485172 ] <NDArray 20 @cpu(0)>
4.2.4. 共享模型参数
在有些情况下,我们希望在多个层之间共享模型参数。“模型构造”一节介绍了如何在Block
类的forward
函数里多次调用同一个层来计算。这里再介绍另外一种方法,它在构造层的时候指定使用特定的参数。如果不同层使用同一份参数,那么它们在前向计算和反向传播时都会共享相同的参数。在下面的例子里,我们让模型的第二隐藏层(shared
变量)和第三隐藏层共享模型参数。
In [14]:
net = nn.Sequential() shared = nn.Dense(8, activation='relu') net.add(nn.Dense(8, activation='relu'), shared, nn.Dense(8, activation='relu', params=shared.params), nn.Dense(10)) net.initialize() X = nd.random.uniform(shape=(2, 20)) net(X) net[1].weight.data()[0] == net[2].weight.data()[0]
Out[14]:
[1. 1. 1. 1. 1. 1. 1. 1.] <NDArray 8 @cpu(0)>
我们在构造第三隐藏层时通过params
来指定它使用第二隐藏层的参数。因为模型参数里包含了梯度,所以在反向传播计算时,第二隐藏层和第三隐藏层的梯度都会被累加在shared.params.grad()
里。
4.2.5. 小结
- 有多种方法来访问、初始化和共享模型参数。
- 可以自定义初始化方法。
4.2.6. 练习
- 查阅有关
init
模块的MXNet文档,了解不同的参数初始化方法。 - 尝试在
net.initialize()
后、net(X)
前访问模型参数,观察模型参数的形状。 - 构造一个含共享参数层的多层感知机并训练。在训练过程中,观察每一层的模型参数和梯度。
4.3. 模型参数的延后初始化
如果做了上一节练习,你会发现模型net
在调用初始化函数initialize
之后、在做前向计算net(X)
之前时,权重参数的形状中出现了0。虽然直觉上initialize
完成了所有参数初始化过程,然而这在Gluon中却是不一定的。我们在本节中详细讨论这个话题。
4.3.1. 延后初始化
也许读者早就注意到了,在之前使用Gluon创建的全连接层都没有指定输入个数。例如,在上一节使用的多层感知机net
里,我们创建的隐藏层仅仅指定了输出大小为256。当调用initialize
函数时,由于隐藏层输入个数依然未知,系统也无法得知该层权重参数的形状。只有在当我们将形状是(2, 20)的输入X
传进网络做前向计算net(X)
时,系统才推断出该层的权重参数形状为(256, 20)。因此,这时候我们才能真正开始初始化参数。
让我们使用上一节中定义的MyInit
类来演示这一过程。我们创建多层感知机,并使用MyInit
实例来初始化模型参数。
In [1]:
from mxnet import init, nd from mxnet.gluon import nn class MyInit(init.Initializer): def _init_weight(self, name, data): print('Init', name, data.shape) # 实际的初始化逻辑在此省略了 net = nn.Sequential() net.add(nn.Dense(256, activation='relu'), nn.Dense(10)) net.initialize(init=MyInit())
注意,虽然MyInit
被调用时会打印模型参数的相关信息,但上面的initialize
函数执行完并未打印任何信息。由此可见,调用initialize
函数时并没有真正初始化参数。下面我们定义输入并执行一次前向计算。
In [2]:
X = nd.random.uniform(shape=(2, 20)) Y = net(X)
Init dense0_weight (256, 20) Init dense1_weight (10, 256)
这时候,有关模型参数的信息被打印出来。在根据输入X
做前向计算时,系统能够根据输入的形状自动推断出所有层的权重参数的形状。系统在创建这些参数之后,调用MyInit
实例对它们进行初始化,然后才进行前向计算。
当然,这个初始化只会在第一次前向计算时被调用。之后我们再运行前向计算net(X)
时则不会重新初始化,因此不会再次产生MyInit
实例的输出。
In [3]:
Y = net(X)
系统将真正的参数初始化延后到获得足够信息时才执行的行为叫作延后初始化(deferred initialization)。它可以让模型的创建更加简单:只需要定义每个层的输出大小,而不用人工推测它们的输入个数。这对于之后将介绍的定义多达数十甚至数百层的网络来说尤其方便。
然而,任何事物都有两面性。正如本节开头提到的那样,延后初始化也可能会带来一定的困惑。在第一次前向计算之前,我们无法直接操作模型参数,例如无法使用data
函数和set_data
函数来获取和修改参数。因此,我们经常会额外做一次前向计算来迫使参数被真正地初始化。
4.3.2. 避免延后初始化
如果系统在调用initialize
函数时能够知道所有参数的形状,那么延后初始化就不会发生。我们在这里分别介绍两种这样的情况。
第一种情况是我们要对已初始化的模型重新初始化时。因为参数形状不会发生变化,所以系统能够立即进行重新初始化。
In [4]:
net.initialize(init=MyInit(), force_reinit=True)
Init dense0_weight (256, 20) Init dense1_weight (10, 256)
第二种情况是我们在创建层的时候指定了它的输入个数,使系统不需要额外的信息来推测参数形状。下例中我们通过in_units
来指定每个全连接层的输入个数,使初始化能够在initialize
函数被调用时立即发生。
In [5]:
net = nn.Sequential() net.add(nn.Dense(256, in_units=20, activation='relu')) net.add(nn.Dense(10, in_units=256)) net.initialize(init=MyInit())
Init dense2_weight (256, 20) Init dense3_weight (10, 256)
4.3.3. 小结
- 系统将真正的参数初始化延后到获得足够信息时才执行的行为叫作延后初始化。
- 延后初始化的主要好处是让模型构造更加简单。例如,我们无须人工推测每个层的输入个数。
- 也可以避免延后初始化。
4.3.4. 练习
- 如果在下一次前向计算
net(X)
前改变输入X
的形状,包括批量大小和输入个数,会发生什么?
4.4. 自定义层
深度学习的一个魅力在于神经网络中各式各样的层,例如全连接层和后面章节中将要介绍的卷积层、池化层与循环层。虽然Gluon提供了大量常用的层,但有时候我们依然希望自定义层。本节将介绍如何使用NDArray
来自定义一个Gluon的层,从而可以被重复调用。
4.4.1. 不含模型参数的自定义层
我们先介绍如何定义一个不含模型参数的自定义层。事实上,这和“模型构造”一节中介绍的使用Block
类构造模型类似。下面的CenteredLayer
类通过继承Block
类自定义了一个将输入减掉均值后输出的层,并将层的计算定义在了forward
函数里。这个层里不含模型参数。
In [1]:
from mxnet import gluon, nd from mxnet.gluon import nn class CenteredLayer(nn.Block): def __init__(self, **kwargs): super(CenteredLayer, self).__init__(**kwargs) def forward(self, x): return x - x.mean()
我们可以实例化这个层,然后做前向计算。
In [2]:
layer = CenteredLayer() layer(nd.array([1, 2, 3, 4, 5]))
Out[2]:
[-2. -1. 0. 1. 2.] <NDArray 5 @cpu(0)>
我们也可以用它来构造更复杂的模型。
In [3]:
net = nn.Sequential() net.add(nn.Dense(128), CenteredLayer())
下面打印自定义层各个输出的均值。因为均值是浮点数,所以它的值是一个很接近0的数。
In [4]:
net.initialize() y = net(nd.random.uniform(shape=(4, 8))) y.mean().asscalar()
Out[4]:
-7.212293e-10
4.4.2. 含模型参数的自定义层
我们还可以自定义含模型参数的自定义层。其中的模型参数可以通过训练学出。
“模型参数的访问、初始化和共享”一节分别介绍了Parameter
类和ParameterDict
类。在自定义含模型参数的层时,我们可以利用Block
类自带的ParameterDict
类型的成员变量params
。它是一个由字符串类型的参数名字映射到Parameter类型的模型参数的字典。我们可以通过get
函数从ParameterDict
创建Parameter
实例。
In [5]:
params = gluon.ParameterDict() params.get('param2', shape=(2, 3)) params
Out[5]:
( Parameter param2 (shape=(2, 3), dtype=<class 'numpy.float32'>) )
现在我们尝试实现一个含权重参数和偏差参数的全连接层。它使用ReLU函数作为激活函数。其中in_units
和units
分别代表输入个数和输出个数。
In [6]:
class MyDense(nn.Block): # units为该层的输出个数,in_units为该层的输入个数 def __init__(self, units, in_units, **kwargs): super(MyDense, self).__init__(**kwargs) self.weight = self.params.get('weight', shape=(in_units, units)) self.bias = self.params.get('bias', shape=(units,)) def forward(self, x): linear = nd.dot(x, self.weight.data()) + self.bias.data() return nd.relu(linear)
下面,我们实例化MyDense
类并访问它的模型参数。
In [7]:
dense = MyDense(units=3, in_units=5) dense.params
Out[7]:
mydense0_ ( Parameter mydense0_weight (shape=(5, 3), dtype=<class 'numpy.float32'>) Parameter mydense0_bias (shape=(3,), dtype=<class 'numpy.float32'>) )
我们可以直接使用自定义层做前向计算。
In [8]:
dense.initialize() dense(nd.random.uniform(shape=(2, 5)))
Out[8]:
[[0.06917784 0.01627153 0.01029644] [0.02602214 0.0453731 0. ]] <NDArray 2x3 @cpu(0)>
我们也可以使用自定义层构造模型。它和Gluon的其他层在使用上很类似。
In [9]:
net = nn.Sequential() net.add(MyDense(8, in_units=64), MyDense(1, in_units=8)) net.initialize() net(nd.random.uniform(shape=(2, 64)))
Out[9]:
[[0.03820474] [0.04035058]] <NDArray 2x1 @cpu(0)>
4.4.3. 小结
- 可以通过
Block
类自定义神经网络中的层,从而可以被重复调用。
4.4.4. 练习
- 自定义一个层,使用它做一次前向计算。
4.5. 读取和存储
到目前为止,我们介绍了如何处理数据以及如何构建、训练和测试深度学习模型。然而在实际中,我们有时需要把训练好的模型部署到很多不同的设备。在这种情况下,我们可以把内存中训练好的模型参数存储在硬盘上供后续读取使用。
4.5.1. 读写NDArray
我们可以直接使用save
函数和load
函数分别存储和读取NDArray
。下面的例子创建了NDArray
变量x
,并将其存在文件名同为x
的文件里。
In [1]:
from mxnet import nd from mxnet.gluon import nn x = nd.ones(3) nd.save('x', x)
然后我们将数据从存储的文件读回内存。
In [2]:
x2 = nd.load('x') x2
Out[2]:
[ [1. 1. 1.] <NDArray 3 @cpu(0)>]
我们还可以存储一列NDArray
并读回内存。
In [3]:
y = nd.zeros(4) nd.save('xy', [x, y]) x2, y2 = nd.load('xy') (x2, y2)
Out[3]:
( [1. 1. 1.] <NDArray 3 @cpu(0)>, [0. 0. 0. 0.] <NDArray 4 @cpu(0)>)
我们甚至可以存储并读取一个从字符串映射到NDArray
的字典。
In [4]:
mydict = {'x': x, 'y': y} nd.save('mydict', mydict) mydict2 = nd.load('mydict') mydict2
Out[4]:
{'x': [1. 1. 1.] <NDArray 3 @cpu(0)>, 'y': [0. 0. 0. 0.] <NDArray 4 @cpu(0)>}
4.5.2. 读写Gluon模型的参数
除NDArray
以外,我们还可以读写Gluon模型的参数。Gluon的Block
类提供了save_parameters
函数和load_parameters
函数来读写模型参数。为了演示方便,我们先创建一个多层感知机,并将其初始化。回忆“模型参数的延后初始化”一节,由于延后初始化,我们需要先运行一次前向计算才能实际初始化模型参数。
In [5]:
class MLP(nn.Block): def __init__(self, **kwargs): super(MLP, self).__init__(**kwargs) self.hidden = nn.Dense(256, activation='relu') self.output = nn.Dense(10) def forward(self, x): return self.output(self.hidden(x)) net = MLP() net.initialize() X = nd.random.uniform(shape=(2, 20)) Y = net(X)
下面把该模型的参数存成文件,文件名为mlp.params。
In [6]:
filename = 'mlp.params' net.save_parameters(filename)
接下来,我们再实例化一次定义好的多层感知机。与随机初始化模型参数不同,我们在这里直接读取保存在文件里的参数。
In [7]:
net2 = MLP() net2.load_parameters(filename)
因为这两个实例都有同样的模型参数,那么对同一个输入X
的计算结果将会是一样的。我们来验证一下。
In [8]:
Y2 = net2(X) Y2 == Y
Out[8]:
[[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.] [1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]] <NDArray 2x10 @cpu(0)>
4.5.3. 小结
- 通过
save
函数和load
函数可以很方便地读写NDArray
。 - 通过
load_parameters
函数和save_parameters
函数可以很方便地读写Gluon模型的参数。
4.5.4. 练习
- 即使无须把训练好的模型部署到不同的设备,存储模型参数在实际中还有哪些好处?
4.6. GPU计算
到目前为止,我们一直在使用CPU计算。对复杂的神经网络和大规模的数据来说,使用CPU来计算可能不够高效。在本节中,我们将介绍如何使用单块NVIDIA GPU来计算。首先,需要确保已经安装好了至少一块NVIDIA GPU。然后,下载CUDA并按照提示设置好相应的路径(可参考附录中“使用AWS运行代码”一节)。这些准备工作都完成后,下面就可以通过nvidia-smi
命令来查看显卡信息了。
In [1]:
!nvidia-smi # 对Linux/macOS用户有效
Mon Mar 4 00:26:49 2019 +-----------------------------------------------------------------------------+ | NVIDIA-SMI 396.37 Driver Version: 396.37 | |-------------------------------+----------------------+----------------------+ | GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. | |===============================+======================+======================| | 0 Tesla M60 Off | 00000000:00:1D.0 Off | 0 | | N/A 34C P0 45W / 150W | 0MiB / 7618MiB | 0% Default | +-------------------------------+----------------------+----------------------+ | 1 Tesla M60 Off | 00000000:00:1E.0 Off | 0 | | N/A 38C P0 40W / 150W | 0MiB / 7618MiB | 96% Default | +-------------------------------+----------------------+----------------------+ +-----------------------------------------------------------------------------+ | Processes: GPU Memory | | GPU PID Type Process name Usage | |=============================================================================| | No running processes found | +-----------------------------------------------------------------------------+
接下来,我们需要确认安装了MXNet的GPU版本。安装方法见“获取和运行本书的代码”一节。运行本节中的程序需要至少2块GPU。
4.6.1. 计算设备
MXNet可以指定用来存储和计算的设备,如使用内存的CPU或者使用显存的GPU。默认情况下,MXNet会将数据创建在内存,然后利用CPU来计算。在MXNet中,mx.cpu()
(或者在括号里填任意整数)表示所有的物理CPU和内存。这意味着,MXNet的计算会尽量使用所有的CPU核。但mx.gpu()
只代表一块GPU和相应的显存。如果有多块GPU,我们用mx.gpu(i)
来表示第i块GPU及相应的显存(i从0开始)且mx.gpu(0)
和mx.gpu()
等价。
In [2]:
import mxnet as mx from mxnet import nd from mxnet.gluon import nn mx.cpu(), mx.gpu(), mx.gpu(1)
Out[2]:
(cpu(0), gpu(0), gpu(1))
4.6.2. NDArray
的GPU计算
在默认情况下,NDArray
存在内存上。因此,之前我们每次打印NDArray
的时候都会看到@cpu(0)
这个标识。
In [3]:
x = nd.array([1, 2, 3]) x
Out[3]:
[1. 2. 3.] <NDArray 3 @cpu(0)>
我们可以通过NDArray
的context
属性来查看该NDArray
所在的设备。
In [4]:
x.context
Out[4]:
cpu(0)
4.6.2.1. GPU上的存储
我们有多种方法将NDArray
存储在显存上。例如,我们可以在创建NDArray
的时候通过ctx
参数指定存储设备。下面我们将NDArray
变量a
创建在gpu(0)
上。注意,在打印a
时,设备信息变成了@gpu(0)
。创建在显存上的NDArray
只消耗同一块显卡的显存。我们可以通过nvidia-smi
命令查看显存的使用情况。通常,我们需要确保不创建超过显存上限的数据。
In [5]:
a = nd.array([1, 2, 3], ctx=mx.gpu()) a
Out[5]:
[1. 2. 3.] <NDArray 3 @gpu(0)>
假设至少有2块GPU,下面代码将会在gpu(1)
上创建随机数组。
In [6]:
B = nd.random.uniform(shape=(2, 3), ctx=mx.gpu(1)) B
Out[6]:
[[0.59119 0.313164 0.76352036] [0.9731786 0.35454726 0.11677533]] <NDArray 2x3 @gpu(1)>
除了在创建时指定,我们也可以通过copyto
函数和as_in_context
函数在设备之间传输数据。下面我们将内存上的NDArray
变量x
复制到gpu(0)
上。
In [7]:
y = x.copyto(mx.gpu()) y
Out[7]:
[1. 2. 3.] <NDArray 3 @gpu(0)>
In [8]:
z = x.as_in_context(mx.gpu()) z
Out[8]:
[1. 2. 3.] <NDArray 3 @gpu(0)>
需要区分的是,如果源变量和目标变量的context
一致,as_in_context
函数使目标变量和源变量共享源变量的内存或显存。
In [9]:
y.as_in_context(mx.gpu()) is y
Out[9]:
True
而copyto
函数总是为目标变量开新的内存或显存。
In [10]:
y.copyto(mx.gpu()) is y
Out[10]:
False
4.6.2.2. GPU上的计算
MXNet的计算会在数据的context
属性所指定的设备上执行。为了使用GPU计算,我们只需要事先将数据存储在显存上。计算结果会自动保存在同一块显卡的显存上。
In [11]:
(z + 2).exp() * y
Out[11]:
[ 20.085537 109.1963 445.2395 ] <NDArray 3 @gpu(0)>
注意,MXNet要求计算的所有输入数据都在内存或同一块显卡的显存上。这样设计的原因是CPU和不同的GPU之间的数据交互通常比较耗时。因此,MXNet希望用户确切地指明计算的输入数据都在内存或同一块显卡的显存上。例如,如果将内存上的NDArray
变量x
和显存上的NDArray
变量y
做运算,会出现错误信息。当我们打印NDArray
或将NDArray
转换成NumPy格式时,如果数据不在内存里,MXNet会将它先复制到内存,从而造成额外的传输开销。
4.6.3. Gluon的GPU计算
同NDArray
类似,Gluon的模型可以在初始化时通过ctx
参数指定设备。下面的代码将模型参数初始化在显存上。
In [12]:
net = nn.Sequential() net.add(nn.Dense(1)) net.initialize(ctx=mx.gpu())
当输入是显存上的NDArray
时,Gluon会在同一块显卡的显存上计算结果。
In [13]:
net(y)
Out[13]:
[[0.0068339 ] [0.01366779] [0.02050169]] <NDArray 3x1 @gpu(0)>
下面我们确认一下模型参数存储在同一块显卡的显存上。
In [14]:
net[0].weight.data()
Out[14]:
[[0.0068339]] <NDArray 1x1 @gpu(0)>
4.6.4. 小结
- MXNet可以指定用来存储和计算的设备,如使用内存的CPU或者使用显存的GPU。在默认情况下,MXNet会将数据创建在内存,然后利用CPU来计算。
- MXNet要求计算的所有输入数据都在内存或同一块显卡的显存上。
4.6.5. 练习
- 试试大一点儿的计算任务,如大矩阵的乘法,看看使用CPU和GPU的速度区别。如果是计算量很小的任务呢?
- GPU上应如何读写模型参数?
4.6.6. 参考文献
[1] CUDA下载地址。 https://developer.nvidia.com/cuda-downloads