MXNet 深度学习实战 (30):MXNet 基础 3.2

阅读数:20 2019 年 12 月 28 日 22:58

MXNet深度学习实战(30):MXNet基础 3.2

(Symbol)

内容简介
本书分为四大部分:
第一部分为准备篇(第 1~2 章),简单介绍深度学习相关的基础背景知识、深度学习框架 MXNet 的发展过程和优缺点,同时介绍基础开发环境的构建和 docker 的使用,帮助读者构建必要的基础知识背景。
第二部分为基础篇(第 3~7 章),介绍 MXNet 的几个主要模块,介绍 MXNet 的数据读取、数据增强操作,同时介绍了常用网络层的含义及使用方法、常见网络结构的设计思想,以及介绍模型训练相关的参数配置。
第三部分为实战篇(第 8~10 章),以图像分类、目标检测和图像分割这三个常用领域为例介绍如何通过 MXNet 实现算法训练和模型测试,同时还将结合 MXNet 的接口详细介绍算法细节内容。
第四部分为扩展篇(第 11~12 章),主要介绍 Gluon 和 GluonCV。Gluon 接口是 MXNet 推出的用于动态构建网络结构的重要接口,GluonCV 则是一个专门为计算机视觉任务服务的深度学习库。

Symbol 是 MXNet 框架中用于构建网络层的模块,Symbol 的官方文档地址是: https://mxnet.apache.org/api/python/symbol/symbol.html ,与 Symbol 相关的接口都可以在该文档中查询。与 NDArray 不同的是,Symbol 采用的是符号式编程(symbolic programming),其是 MXNet 框架实现快速训练和节省显存的关键模块。之前我们介绍过符号式编程的含义,简单来说就是,符号式编程需要先用 Symbol 接口定义好计算图,这个计算图同时包含定义好的输入和输出格式,然后将准备好的数据输入该计算图完成计算。而 3.1 节介绍的 NDArray 采用的是命令式编程(imperative programming),计算过程可以逐步来步实现。其实在你了解了 NDArray 之后,你完全可以仅仅通过 NDArray 来定义和使用网络,那么为什么还要提供 Symbol 呢?主要是为了提高效率。在定义好计算图之后,就可以对整个计算图的显存占用做优化处理,这样就能大大降低训练模型时候的显存占用。

在 MXNet 中,Symbol 接口主要用来构建网络结构层,其次是用来定义输入数据。接下来我们再来列举一个例子,首先定义一个网络结构,具体如下。

1)用 mxnet.symbol.Variable() 接口定义输入数据,用该接口定义的输入数据类似于一个占位符。

2)用 mxnet.symbol.Convolution() 接口定义一个卷积核尺寸为 3*3,卷积核数量为 128 的卷积层,卷积层是深度学习算法提取特征的主要网络层,该层将是你在深度学习算法(尤其是图像领域)中使用最为频繁的网络层。

3)用 mxnet.symbol.BatchNorm() 接口定义一个批标准化(batch normalization,常用缩写 BN 表示)层,该层有助于训练算法收敛。

4)用 mxnet.symbol.Activation() 接口定义一个 ReLU 激活层,激活层主要用来增加网络层之间的非线性,激活层包含多种类型,其中以 ReLU 激活层最为常用。

5)用 mxnet.symbol.Pooling() 接口定义一个最大池化层(pooling),池化层的主要作用在于通过缩减维度去除特征图噪声和减少后续计算量,池化层包含多种形式,常用形式有均值池化和最大池化。

6)用 mxnet.symbol.FullyConnected() 接口定义一个全连接层,全连接层是深度学习算法中经常用到的层,一般是位于网络的最后几层。需要注意的是,该接口的 num_hidden 参数表示分类的类别数。

7)用 mxnet.symbol.SoftmaxOutput() 接口定义一个损失函数层,该接口定义的损失函数是图像分类算法中常用的交叉熵损失函数(cross entropy loss),该损失函数的输入是通过 softmax 函数得到的,softmax 函数是一个变换函数,表示将一个向量变换成另一个维度相同,但是每个元素范围在 [0,1] 之间的向量,因此该层用 mxnet.symbol.SoftmaxOutput() 来命名。这样就得到了一个完整的网络结构了。

网络结构定义代码如下:

复制代码
import mxnet as mx
data = 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 是通过计算 55128 得到的,其中 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 mx
data_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 mx
data_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 其特点是高效,主要用来定义计算图。

MXNet深度学习实战(30):MXNet基础 3.2

购书地址 https://item.jd.com/12620056.html?dist=jd

评论

发布