导读:相信很多程序员在学习一门新的编程语言或者框架时,都会先了解下该语言或者该框架涉及的数据结构,毕竟当你清晰地了解了数据结构之后才能更加优雅地编写代码,MXNet同样也是如此。
在MXNet框架中你至少需要了解这三驾马车:NDArray、Symbol和Module。这三者将会是你今后在使用MXNet框架时经常用到的接口。那么在搭建或者训练一个深度学习算法时,这三者到底扮演了一个什么样的角色呢?
这里可以做一个简单的比喻,假如将从搭建到训练一个算法的过程比作是一栋房子从建造到装修的过程,那么NDArray就相当于是钢筋水泥这样的零部件,Symbol就相当于是房子每一层的设计,Module就相当于是房子整体框架的搭建。
在本文中你将实际感受命令式编程(imperative programming)和符号式编程(symbolic programming)的区别,因为NDArray接口采用的是命令式编程的方式,而Symbol接口采用的是符号式编程的方式。
作者:迈克尔·贝耶勒(Michael Beyeler)
如需转载请联系华章科技
01 NDArrayNDArray是MXNet框架中数据流的基础结构,NDArray的官方文档地址是:
与NDArray相关的接口都可以在该文档中查询到。在了解NDArray之前,希望你先了解下Python中的NumPy库:
/
因为一方面在大部分深度学习框架的Python接口中,NumPy库的使用频率都非常高;另一方面大部分深度学习框架的基础数据结构设计都借鉴了NumPy。
在NumPy库中,一个最基本的数据结构是array,array表示多维数组,NDArray与NumPy库中的array数据结构的用法非常相似,可以简单地认为NDArray是可以运行在GPU上的NumPy array。
接下来,我会介绍在NDArray中的一些常用操作,并提供其与NumPy array的对比,方便读者了解二者之间的关系。
首先,导入MXNet和NumPy,然后通过NDArray初始化一个二维矩阵,代码如下:
import mxnet as mximport numpy as npa = mx.nd.array([[1,2],[3,4]])print(a)输出结果如下:
[[1. 2.] [3. 4.]]<NDArray 2x2 @cpu(0)>接着,通过NumPy array初始化一个相同的二维矩阵,代码如下:
b = np.array([[1,2],[3,4]])print(b)输出结果如下:
[[1 2] [3 4]]实际使用中常用缩写mx代替mxnet,mx.nd代替mxnet.ndarray,np代替numpy,本书后续篇章所涉及的代码默认都采取这样的缩写。
再来看看NumPy array和NDArray常用的几个方法对比,比如打印NDArray的维度信息:
print(a.shape)输出结果如下:
(2, 2)打印NumPy array的维度信息:
print(b.shape)输出结果如下:
(2, 2)打印NDArray的数值类型:
print(a.dtype)输出结果如下:
<class 'numpy.float32'>打印Numpy array的数值类型:
print(b.dtype)输出结果如下:
int64在使用大部分深度学习框架训练模型时默认采用的都是float32数值类型,因此初始化一个NDArray对象时默认的数值类型是float32。
如果你想要初始化指定数值类型的NDArray,那么可以通过dtype参数来指定,代码如下:
c=mx.nd.array([[1,2],[3,4]], dtype=np.int8)print(c.dtype)输出结果如下:
<class 'numpy.int8'>如果你想要初始化指定数值类型的NumPy array,则可以像如下这样输入代码:
d = np.array([[1,2],[3,4]], dtype=np.int8)print(d.dtype)输出结果如下:
int8在NumPy的array结构中有一个非常常用的操作是切片(slice),这种操作在NDArray中同样也可以实现,具体代码如下:
c = mx.nd.array([[1,2,3,4],[5,6,7,8]])print(c[0,1:3])输出结果如下:
[2. 3.]<NDArray 2 @cpu(0)>在NumPy array中可以这样实现:
d = np.array([[1,2,3,4],[5,6,7,8]])print(d[0,1:3])输出结果如下:
[2 3]在对已有的NumPy array或NDArray进行复制并修改时,为了避免影响到原有的数组,可以采用copy()方法进行数组复制,而不是直接复制,这一点非常重要。下面以NDArray为例来看看采用copy()方法进行数组复制的情况,首先打印出c的内容:
print(c)输出结果如下:
[[1. 2. 3. 4.] [5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>然后调用c的copy()方法将c的内容复制到f,并打印f的内容:
f = c.copy()print(f)输出结果如下:
[[1. 2. 3. 4.] [5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>修改f中的一个值,并打印f的内容:
f[0,0] = -1print(f)输出结果如下,可以看到此时对应位置的值已经被修改了:
[[-1. 2. 3. 4.] [ 5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>那么c中对应位置的值有没有被修改呢?可以打印此时c的内容:
print(c)输出结果如下,可以看到此时c中对应位置的值并没有被修改:
[[1. 2. 3. 4.] [5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>接下来看看如果直接将c复制给e,会有什么样的情况发生:
e = cprint(e)输出结果如下:
[[1. 2. 3. 4.] [5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>修改e中的一个值,并打印e的内容:
e[0,0] = -1print(e)输出内容如下:
[[-1. 2. 3. 4.] [ 5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>此时再打印c的内容:
print(c)输出结果如下,可以看到对应位置的值也发生了改变:
[[-1. 2. 3. 4.] [ 5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>实际上,NumPy array和NDArray之间的转换也非常方便,NDArray转NumPy array可以通过调用NDArray对象的asnumpy()方法来实现:
g=e.asnumpy()print(g)输出结果如下:
[[-1. 2. 3. 4.] [ 5. 6. 7. 8.]]NumPy array转NDArray可以通过mxnet.ndarray.array()接口来实现:
print(mx.nd.array(g))输出结果如下:
[[-1. 2. 3. 4.] [ 5. 6. 7. 8.]]<NDArray 2x4 @cpu(0)>?前面曾提到过NDArray和NumPy array最大的区别在于NDArray可以运行在GPU上,从前面打印出来的NDArray对象的内容可以看到,最后都有一个@cpu,这说明该NDArray对象是初始化在CPU上的,那么如何才能将NDArray对象初始化在GPU上呢?首先,调用NDArray对象的context属性可以得到变量所在的环境:
print(e.context)输出结果如下:
cpu(0)然后,调用NDArray对象的as_in_context()方法指定变量的环境,例如这里将环境指定为第0块GPU:
e = e.as_in_context(mx.gpu(0))print(e.context)输出结果如下:
gpu(0)环境(context)是深度学习算法中比较重要的内容,目前常用的环境是CPU或GPU,在深度学习算法中,数据和模型都要在同一个环境中才能正常进行训练和测试。
MXNet框架中NDArray对象的默认初始化环境是CPU,在不同的环境中,变量初始化其实就是变量的存储位置不同,而且存储在不同环境中的变量是不能进行计算的,比如一个初始化在CPU中的NDArray对象和一个初始化在GPU中的NDArray对象在执行计算时会报错:
f = mx.nd.array([[2,3,4,5],[6,7,8,9]])print(e+f)显示结果如下,从报错信息可以看出是2个对象的初始化环境不一致导致的:
mxnet.base.MXNetError: [11:14:13] src/imperative/./imperative_utils.h:56: Check failed: inputs[i]->ctx().dev_mask() == ctx.dev_mask() (1 vs. 2) Operator broadcast_add require all inputs live on the same context. But the first argument is on gpu(0) while the 2-th argument is on cpu(0)下面将f的环境也修改成GPU,再执行相加计算:
f = f.as_in_context(mx.gpu(0))print(e+f)输出结果如下:
[[ 1. 5. 7. 9.] [ 11. 13. 15. 17.]]<NDArray 2x4 @gpu(0)>NDArray是MXNet框架中使用最频繁也是最基础的数据结构,是可以在CPU或GPU上执行命令式操作(imperative operation)的多维矩阵,这种命令式操作直观且灵活,是MXNet框架的特色之一。因为在使用MXNet框架训练模型时,几乎所有的数据流都是通过NDArray数据结构实现的,因此熟悉该数据结构非常重要。
02 SymbolSymbol是MXNet框架中用于构建网络层的模块,Symbol的官方文档地址是:
与Symbol相关的接口都可以在该文档中查询。与NDArray不同的是,Symbol采用的是符号式编程(symbolic programming),其是MXNet框架实现快速训练和节省显存的关键模块。
符号式编程的含义,简单来说就是,符号式编程需要先用Symbol接口定义好计算图,这个计算图同时包含定义好的输入和输出格式,然后将准备好的数据输入该计算图完成计算。
而NDArray采用的是命令式编程(imperative programming),计算过程可以逐步来步实现。其实在你了解了NDArray之后,你完全可以仅仅通过NDArray来定义和使用网络,那么为什么还要提供Symbol呢?主要是为了提高效率。在定义好计算图之后,就可以对整个计算图的显存占用做优化处理,这样就能大大降低训练模型时候的显存占用。
在MXNet中,Symbol接口主要用来构建网络结构层,其次是用来定义输入数据。接下来我们再来列举一个例子,首先定义一个网络结构,具体如下。
用mxnet.symbol.Variable()接口定义输入数据,用该接口定义的输入数据类似于一个占位符。用mxnet.symbol.Convolution()接口定义一个卷积核尺寸为3*3,卷积核数量为128的卷积层,卷积层是深度学习算法提取特征的主要网络层,该层将是你在深度学习算法(尤其是图像领域)中使用最为频繁的网络层。用 mxnet.symbol.BatchNorm()接口定义一个批标准化(batch normalization,常用缩写BN表示)层,该层有助于训练算法收敛。用mxnet.symbol.Activation()接口定义一个ReLU激活层,激活层主要用来增加网络层之间的非线性,激活层包含多种类型,其中以ReLU激活层最为常用。用mxnet.symbol.Pooling()接口定义一个最大池化层(pooling),池化层的主要作用在于通过缩减维度去除特征图噪声和减少后续计算量,池化层包含多种形式,常用形式有均值池化和最大池化。用mxnet.symbol.FullyConnected()接口定义一个全连接层,全连接层是深度学习算法中经常用到的层,一般是位于网络的最后几层。需要注意的是,该接口的num_hidden参数表示分类的类别数。用mxnet.symbol.SoftmaxOutput()接口定义一个损失函数层,该接口定义的损失函数是图像分类算法中常用的交叉熵损失函数(cross entropy loss),该损失函数的输入是通过softmax函数得到的,softmax函数是一个变换函数,表示将一个向量变换成另一个维度相同,但是每个元素范围在[0,1]之间的向量,因此该层用mxnet.symbol.SoftmaxOutput()来命名。这样就得到了一个完整的网络结构了。网络结构定义代码如下:
import mxnet as mxdata = mx.sym.Variable('data')conv = mx.sym.Convolution(data=data, num_filter=128, kernel=(3,3), pad=(1,1), name='conv1')bn = mx.sym.BatchNorm(data=conv, name='bn1')relu = mx.sym.Activation(data=bn, act_type='relu', name='relu1')pool = mx.sym.Pooling(data=relu, kernel=(2,2), stride=(2,2), pool_type='max', name='pool1')fc = mx.sym.FullyConnected (data=pool, num_hidden=2, name='fc1')sym = mx.sym.SoftmaxOutput (data=fc, name='softmax')mx.sym是mxnet.symbol常用的缩写形式,后续篇章默认采用这种缩写形式。另外在定义每一个网络层的时候最好都能指定名称(name)参数,这样代码看起来会更加清晰。
定义好网络结构之后,你肯定还想看看这个网络结构到底包含哪些参数,毕竟训练模型的过程就是模型参数更新的过程,在MXNet中,list_arguments()方法可用于查看一个Symbol对象的参数,命令如下:
print(sym.list_arguments())由下面的输出结果可以看出,第一个和最后一个分别是'data'和'softmax_label',这二者分别代表输入数据和标签;'conv1_weight'和'conv1_bias'是卷积层的参数,具体而言前者是卷积核的权重参数,后者是偏置参数;'bn1_gamma'和'bn1_beta'是BN层的参数;'fc1_weight'和'fc1_bias'是全连接层的参数。
['data', 'conv1_weight', 'conv1_bias', 'bn1_gamma', 'bn1_beta', 'fc1_weight', 'fc1_bias', 'softmax_label']除了查看网络的参数层名称之外,有时候我们还需要查看网络层参数的维度、网络输出维度等信息,这一点对于代码调试而言尤其有帮助。
在MXNet中,可以用infer_shape()方法查看一个Symbol对象的层参数维度、输出维度、辅助层参数维度信息,在调用该方法时需要指定输入数据的维度,这样网络结构就会基于指定的输入维度计算层参数、网络输出等维度信息:
arg_shape,out_shape,aux_shape = sym.infer_shape(data=(1,3,10,10))print(arg_shape)print(out_shape)print(aux_shape)由下面的输出结果可知,第一行表示网络层参数的维度,与前面list_arguments()方法列出来的层参数名一一对应,例如:
输入数据'data'的维度是(1, 3, 10, 10);卷积层的权重参数'conv1_weight'的维度是(128, 3, 3, 3);卷积层的偏置参数'conv1_bias'的维度是(128,),因为每个卷积核对应于一个偏置参数;全连接层的权重参数'fc1_weight'的维度是(2, 3200),这里的3000是通过计算5*5*128得到的,其中5*5表示全连接层的输入特征图的宽和高。第二行表示网络输出的维度,因为网络的最后一层是输出节点为2的全连接层,且输入数据的批次维度是1,所以输出维度是[(1, 2)]。
第三行是辅助参数的维度,目前常见的主要是BN层的参数维度。
[(1, 3, 10, 10), (128, 3, 3, 3), (128,), (128,), (128,), (2, 3200), (2,), (1,)] [(1, 2)][(128,), (128,)]如果要截取通过Symbol模块定义的网络结构中的某一部分也非常方便,在MXNet中可以通过get_internals()方法得到Symbol对象的所有层信息,然后选择要截取的层即可,比如将sym截取成从输入到池化层为止:
sym_mini = sym.get_internals()['pool1_output']print(sym_mini.list_arguments())输出结果如下,可以看到层参数中没有sym原有的全连接层和标签层信息了:
['data', 'conv1_weight', 'conv1_bias', 'bn1_gamma', 'bn1_beta']截取之后还可以在截取得到的Symbol对象后继续添加网络层,比如增加一个输出节点为5的全连接层和一个softmax层:
fc_new = mx.sym.FullyConnected (data=sym_mini, num_hidden=5, name='fc_new')sym_new = mx.sym.SoftmaxOutput (data=fc_new, name='softmax')print(sym_new.list_arguments())输出结果如下,可以看到全连接层已经被替换了:
['data', 'conv1_weight', 'conv1_bias', 'bn1_gamma', 'bn1_beta', 'fc_new_weight', 'fc_new_bias', 'softmax_label']除了定义神经网络层之外,Symbol模块还可以实现NDArray的大部分操作,接下来以数组相加和相乘为例介绍通过Symbol模块实现上述操作的方法。首先通过 mxnet.symbol.Variable()接口定义两个输入data_a和data_b;然后定义data_a和data_b相加并与data_c相乘的操作以得到结果s,通过打印s的类型可以看出s的类型是Symbol,代码如下:
import mxnet as mxdata_a = mx.sym.Variable ('data_a') data_b = mx.sym.Variable ('data_b')data_c = mx.sym.Variable ('data_c')s = data_c*(data_a+data_b)print(type(s))输出结果如下:
<class 'mxnet.symbol.symbol.Symbol'>接下来,调用s的bind()方法将具体输入和定义的操作绑定到执行器,同时还需要为bind()方法指定计算是在CPU还是GPU上进行,执行bind操作后就得到了执行器e,最后打印e的类型进行查看,代码如下:
e = s.bind(mx.cpu(), {'data_a':mx.nd.array([1,2,3]), 'data_b':mx.nd.array([4,5,6]), 'data_c':mx.nd.array([2,3,4])})print(type(e))输出结果如下:
<class 'mxnet.executor.Executor'>这个执行器就是一个完整的计算图了,因此可以调用执行器的forward()方法进行计算以得到结果:
output=e.forward()print(output[0])输出结果如下:
[ 10. 21. 36.]<NDArray 3 @cpu(0)>相比之下,通过NDArray模块实现这些操作则要简洁和直观得多,代码如下:
import mxnet as mxdata_a = mx.nd.array([1,2,3])data_b = mx.nd.array([4,5,6])data_c = mx.nd.array([2,3,4])result = data_c*(data_a+data_b)print(result)输出结果如下:
[ 10. 21. 36.]<NDArray 3 @cpu(0)>虽然使用Symbol接口的实现看起来有些复杂,但是当你定义好计算图之后,很多显存是可以重复利用或共享的,比如在Symbol模块实现版本中,底层计算得到的data_a+data_b的结果会存储在data_a或data_b所在的空间,因为在该计算图中,data_a和data_b在执行完相加计算后就不会再用到了。
前面介绍的是Symbol模块中Variable接口定义的操作和NDArray模块中对应实现的相似性,除此之外,Symbol模块中关于网络层的操作在NDArray模块中基本上也有对应的操作,这对于静态图的调试来说非常有帮助。
之前提到过,Symbol模块采用的是符号式编程(或者称为静态图),即首先需要定义一个计算图,定义好计算图之后再执行计算,这种方式虽然高效,但是对代码调试其实是不大友好的,因为你很难获取中间变量的值。
现在因为采用命令式编程的NDArray模块中基本上包含了Symbol模块中同名的操作,因此可以在一定程度上帮助调试代码。接下来以卷积层为例看看如何用NDArray模块实现一个卷积层操作,首先用mxnet.ndarray.arange()接口初始化输入数据,这里定义了一个4维数据data,之所以定义为4维是因为模型中的数据流基本上都是4维的。具体代码如下:
data = mx.nd.arange(0,28).reshape((1,1,4,7))print(data)输出结果如下:
[[[[ 0. 1. 2. 3. 4. 5. 6.] [ 7. 8. 9. 10. 11. 12. 13.] [14. 15. 16. 17. 18. 19. 20.] [21. 22. 23. 24. 25. 26. 27.]]]]<NDArray 1x1x4x7 @cpu(0)>然后,通过mxnet.ndarray.Convolution()接口定义卷积层操作,该接口的输入除了与mxnet.symbol.Convolution()接口相同的data、num_filter、kernel和name之外,还需要直接指定weight和bias。
weight和bias就是卷积层的参数值,为了简单起见,这里将weight初始化成值全为1的4维变量,bias初始化成值全为0的1维变量,这样就能得到最后的卷积结果。具体代码如下:
conv1 = mx.nd.Convolution(data=data, weight=mx.nd.ones((10,1,3,3)), bias=mx.nd.zeros((10)), num_filter=10, kernel=(3,3), name='conv1')print(conv1)输出结果如下:
[[[[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]] [[ 72. 81. 90. 99. 108.] [135. 144. 153. 162. 171.]]]]<NDArray 1x10x2x5 @cpu(0)>总体来看,Symbol和NDArray有很多相似的地方,同时,二者在MXNet中都扮演着重要的角色。采用命令式编程的NDArray其特点是直观,常用来实现底层的计算;采用符号式编程的Symbol其特点是高效,主要用来定义计算图。
03 Module在MXNet框架中,Module是一个高级的封装模块,可用来执行通过Symbol模块定义的网络模型的训练,与Module相关的接口介绍都可以参考Module的官方文档地址:
Module接口提供了许多非常方便的方法用于模型训练,只需要将准备好的数据、超参数等传给对应的方法就能启动训练。
上午中,我们用Symbol接口定义了一个网络结构sym,接下来我们将基于这个网络结构介绍Module模块,首先来看看如何通过Module模块执行模型的预测操作。
通过mxnet.module.Module()接口初始化一个Module对象,在初始化时需要传入定义好的网络结构sym并指定运行环境,这里设置为GPU环境。
然后执行Module对象的bind操作,这个bind操作与Symbol模块中的bind操作类似,目的也是将网络结构添加到执行器,使得定义的静态图能够真正运行起来,因为这个过程涉及显存分配,因此需要提供输入数据和标签的维度信息才能执行bind操作,读者可以在命令行通过“$ watch nvidia-smi”命令查看执行bind前后,显存的变化情况。
bind操作中还存在一个重要的参数是for_training,这个参数默认是True,表示接下来要进行的是训练过程,因为我们这里只需要进行网络的前向计算操作,因此将该参数设置为False。
最后调用Module对象的init_params()方法初始化网络结构的参数,初始化的方式是可以选择的,这里采用默认方式,至此,一个可用的网络结构执行器就初始化完成了。初始化网络结构执行器的代码具体如下:
mod = mx.mod.Module(symbol=sym, context=mx.gpu(0))mod.bind(data_shapes=[('data',(8,3,28,28))], label_shapes=[('softmax_label',(8,))], for_training=False)mod.init_params()接下来随机初始化一个4维的输入数据,该数据的维度需要与初始化Module对象时设定的数据维度相同,然后通过mxnet.io.DataBatch()接口封装成一个批次数据,之后就可以作为Module对象的forward()方法的输入了,执行完前向计算后,调用Module对象的get_outputs()方法就能得到模型的输出结果,具体代码如下:
data = mx.nd.random.uniform(0,1,shape=(8,3,28,28))mod.forward(mx.io.DataBatch([data]))print(mod.get_outputs()[0])输出结果如下,因为输入数据的批次大小是8,网络的全连接层输出节点数是2,因此输出的维度是8*2:
[[ 0.50080067 0.4991993 ] [ 0.50148612 0.49851385] [ 0.50103837 0.4989616 ] [ 0.50171131 0.49828872] [ 0.50254387 0.4974561 ] [ 0.50104254 0.49895743] [ 0.50223148 0.49776852] [ 0.49780959 0.50219035]]<NDArray 8x2 @gpu(0)>接下来介绍如何通过Module模块执行模型的训练操作,代码部分与预测操作有较多地方是相似的,具体代码见下文代码清单3-1,接下来详细介绍代码内容。
本文中的代码清单都可以在本书的项目代码地址中找到:
使用mxnet.io.NDArrayIter()接口初始化得到训练和验证数据迭代器,这里为了演示采用随机初始化的数据,实际应用中要读取有效的数据,不论读取的是什么样的数据,最后都需要封装成数据迭代器才能提供给模型训练。用mxnet.module.Module()接口初始化得到一个Module对象,这一步至少要输入一个Symbol对象,另外这一步还可以指定训练环境是CPU还是GPU,这里采用GPU。调用Module对象的bind()方法将准备好的数据和网络结构连接到执行器构成一个完整的计算图。调用Module对象的init_params()方法初始化网络的参数,因为前面定义的网络结构只是一个架子,里面没有参数,因此需要执行参数初始化。调用Module对象的init_optimizer()方法初始化优化器,默认采用随机梯度下降法(stochastic gradient descent,SGD)进行优化。调用mxnet.metric.create()接口创建评价函数,这里采用的是准确率(accuracy)。执行5次循环训练,每次循环都会将所有数据过一遍模型,因此在循环开始处需要执行评价函数的重置操作、数据的初始读取等操作。此处的while循环只有在读取完训练数据之后才会退出,该循环首先会调用Module对象的forward()方法执行模型的前向计算,这一步就是输入数据通过每一个网络层的参数进行计算并得到最后结果。调用Module对象的backward()方法执行模型的反向传播计算,这一步将涉及损失函数的计算和梯度的回传。调用Module对象的update()方法执行参数更新操作,参数更新的依据就是第9步计算得到的梯度,这样就完成了一个批次(batch)数据对网络参数的更新。调用Module对象的update_metric()方法更新评价函数的计算结果。读取下一个批次的数据,这里采用了Python中的try和except语句,表示如果try包含的语句执行出错,则执行except包含的语句,这里用来标识是否读取到了数据集的最后一个批次。调用评价对象的get_name_value()方法并打印此次计算的结果。调用Module对象的get_params()方法读取网络参数,并利用这些参数初始化Module对象了。调用数据对象的reset()方法进行重置,这样在下一次循环中就可以从数据的最初始位置开始读取了。代码清单3-1 通过Module模块训练模型import mxnet as mximport loggingdata = mx.sym.Variable('data')conv = mx.sym.Convolution(data=data, num_filter=128, kernel=(3,3), pad=(1,1), name='conv1')bn = mx.sym.BatchNorm(data=conv, name='bn1')relu = mx.sym.Activation(data=bn, act_type='relu', name='relu1')pool = mx.sym.Pooling(data=relu, kernel=(2,2), stride=(2,2), pool_type='max', name='pool1')fc = mx.sym.FullyConnected(data=pool, num_hidden=2, name='fc1')sym = mx.sym.SoftmaxOutput(data=fc, name='softmax')data = mx.nd.random.uniform(0,1,shape=(1000,3,224,224))label = mx.nd.round(mx.nd.random.uniform(0,1,shape=(1000)))train_data = mx.io.NDArrayIter(data={'data':data}, label={'softmax_label':label}, batch_size=8, shuffle=True)print(train_data.provide_data)print(train_data.provide_label)mod = mx.mod.Module(symbol=sym,context=mx.gpu(0))mod.bind(data_shapes=train_data.provide_data, label_shapes=train_data.provide_label)mod.init_params()mod.init_optimizer()eval_metric = mx.metric.create('acc')for epoch in range(5): end_of_batch = False eval_metric.reset() data_iter = iter(train_data) next_data_batch = next(data_iter) while not end_of_batch: data_batch = next_data_batch mod.forward(data_batch) mod.backward() mod.update() mod.update_metric(eval_metric, labels=data_batch.label) try: next_data_batch = next(data_iter) mod.prepare(next_data_batch) except StopIteration: end_of_batch = True eval_name_vals = eval_metric.get_name_value() print("Epoch:{} Train_Acc:{:.4f}".format(epoch, eval_name_vals[0][1])) arg_params, aux_params = mod.get_params() mod.set_params(arg_params, aux_params) train_data.reset()代码清单3-1中的代码其实从mod.bind()方法这一行到最后都可以用Module模块中的fit()方法来实现。fit()方法不仅封装了上述的bind操作、参数初始化、优化器初始化、模型的前向计算、反向传播、参数更新和计算评价指标等操作,还提供了保存训练结果等其他操作,因此fit()方法将是今后使用MXNet训练模型时经常调用的方法。
下面这段代码就演示了fit()方法的调用,前面两行设置命令行打印训练信息,这三行代码可以直接替换代码清单3-1中从mod.bind()那一行到最后的所有代码。
在fit()方法的输入参数中,train_data参数是训练数据,num_epoch参数是训练时整个训练集的迭代次数(也称epoch数量)。需要注意的是,将所有train_data过一遍模型才算完成一个epoch,因此这里设定为将这个训练集数据过5次模型才完成训练。
logger = logging.getLogger()logger.setLevel(logging.INFO)mod.fit(train_data=train_data, num_epoch=5)简化版的代码如代码清单3-2所示。
代码清单3-2 通过Module模块训练模型(简化版)import mxnet as mximport loggingdata = mx.sym.Variable('data')conv = mx.sym.Convolution(data=data, num_filter=128, kernel=(3,3), pad=(1,1), name='conv1')bn = mx.sym.BatchNorm(data=conv, name='bn1')relu = mx.sym.Activation(data=bn, act_type='relu', name='relu1')pool = mx.sym.Pooling(data=relu, kernel=(2,2), stride=(2,2), pool_type='max', name='pool1')fc = mx.sym.FullyConnected(data=pool, num_hidden=2, name='fc1')sym = mx.sym.SoftmaxOutput(data=fc, name='softmax')data = mx.nd.random.uniform(0,1,shape=(1000,3,224,224))label = mx.nd.round(mx.nd.random.uniform(0,1,shape=(1000)))train_data = mx.io.NDArrayIter(data={'data':data}, label={'softmax_label':label}, batch_size=8, shuffle=True)print(train_data.provide_data)print(train_data.provide_label)mod = mx.mod.Module(symbol=sym,context=mx.gpu(0))logger = logging.getLogger()logger.setLevel(logging.INFO)mod.fit(train_data=train_data, num_epoch=5)从下面打印出来的训练结果可以看到,输出结果与代码清单3-1的输出结果基本吻合:
INFO:root:Epoch[0] Train-accuracy=0.515000INFO:root:Epoch[0] Time cost=4.618INFO:root:Epoch[1] Train-accuracy=0.700000INFO:root:Epoch[1] Time cost=4.425INFO:root:Epoch[2] Train-accuracy=0.969000INFO:root:Epoch[2] Time cost=4.428INFO:root:Epoch[3] Train-accuracy=0.988000INFO:root:Epoch[3] Time cost=4.410INFO:root:Epoch[4] Train-accuracy=0.999000INFO:root:Epoch[4] Time cost=4.425上面的演示代码中只设定了fit()方法的几个输入,其实fit()方法的输入还有很多,实际使用中可根据具体要求设定不同的输入参数,本书后面的章节还会进行详细介绍。
得益于MXNet的静态图设计和对计算过程的优化,你会发现MXNet的训练速度相较于大部分深度学习框架要快,而且显存占用非常少!这使得你能够在单卡或单机多卡上使用更大的batch size训练相同的模型,这对于复杂模型的训练非常有利,有时候甚至还会影响训练结果。
04 小结本文主要介绍了MXNet框架中最常用到的三个模块:NDArray、Symbol和Module,对比了三者之间的联系并通过简单的代码对这三个模块的使用有了大致的认识。
NDArray是MXNet框架中最基础的数据结构,借鉴了NumPy中array的思想且能在GPU上运行,同时采取命令式编程的NDArray在代码调试上非常灵活。NDArray提供了与NumPy array相似的方法及属性,因此熟悉NumPy array的用户应该能够很快上手NDArray的操作,而且二者之间的转换也非常方便。
Symbol是MXNet框架中定义网络结构层的接口,采取符号式编程的Symbol通过构建静态计算图可以大大提高模型训练的效率。Symbol中提供了多种方法用于查看Symbol对象的信息,包括参数层、参数维度等,同时也便于用户在设计网络结构的过程中查漏补缺。
此外,Symbol中的大部分网络层接口在NDArray中都有对应的实现,因此可以通过NDArray中对应名称的网络层查看具体的计算过程。
Module是MXNet框架中封装了训练模型所需的大部分操作的高级接口,用户可以通过Module模块执行bind操作、参数初始化、优化器初始化、模型的前向计算、损失函数的反向传播、网络参数更新、评价指标计算等,同时,Module模块还将常用的训练操作封装在了fit()方法中,通过该方法,用户可以更加方便地训练模型,可以说是既灵活又简便。
关于作者:魏凯峰,资深AI算法工程师和计算机视觉工程师,在MXNet、Pytorch、深度学习相关算法等方面有深入的研究和丰富的实践经验,从事计算机视觉算法相关的工作,主要研究方向包括目标检测、图像分类、图像对抗算法、模型加速和压缩。
本文摘编自《MXNet深度学习实战:计算机视觉算法实现》,经出版方授权发布。
延伸阅读《MXNet深度学习实战》
推荐语:网易资深计算机视觉算法工程师撰写,从算法实现和框架原理2个维度详细讲解计算机视觉算法的实现和MXNet框架的使用与原理。