MXNet官方文档中文版教程(3):神经网络图(Symbol)

文档英文原版参见Symbol - Neural network graphs and auto-differentiation

在前面的教程中,我们介绍了NDArray,MXNet中操作数据的基本数据结构。仅仅使用NDArray本身,我们就可以执行很多数学操作。实际上,我们也可以使用NDArray定义和更新一整个神经网络。NDArray支持命令式编程(用于科学计算),充分利用任何前端语言的本机控制。但是我们为什么没有就使用NDArray用于所有的计算呢?

MXNet提供了符号接口,用于符号编程的交互。它和一步步的解释的命令式编程不同,我们首先要定义一个计算图。这个图包括了输入的占位符和设计的输出。之后编译这个图,产生一个可以绑定到NDArray s并运行的函数。Symbol API类似于caffe中的网络配置或者Theano中的符号编程。

另一个优势就是我们可以在使用之前优化我们的函数。例如,我们要执行命令式的数学计算,但是我们并不知道运行每个操作的时间,因为这个值是在之后才计算出来的。但是使用符号化编程,我们提前声明所需的输出。这意味着我们可以通过执行操作来回收中间步骤分配的内存。Symbol API 对于同一个网络使用更少的内存。

在我们的设计文档中,我们对命令式和符号式编程的优势做了一个更深入的探讨。但是在本篇文档中,我们只是教你如何使用MXNet的Symbol API。我们可以从其他符合中组合新的符号,使用简单的矩阵操作符(例如“+”)或者整个神经网络层。操作符支持多个变量输入,多个输出,并且维持内部状态符号。

前提条件

为了完成以下教程,我们需要:

pip install jupyter
  • GPUs:教程的部分实现需要用到GPU。如果没有GPU,只用把变量gpu_device 设置为mx.cpu()即可

基本符号组成

基本操作符

以下的例子复合了一个简单的表达式“a+b”。我们首先使用mx.sym.Variable 创建占位符a和b及其名称,然后用操作符“+”构造期望的符号。在新建时如果名字字符串没有给定,MXNet会自动为符号生产一个独一无二的名字,如c的例子所示。

import mxnet as mx
a = mx.sym.Variable('a')
b = mx.sym.Variable('b')
c = a + b
(a, b, c)

大多数NDArray 操作符可以被应用于Symbol,例如:

# elemental wise multiplication
d = a * b
# matrix multiplication
e = mx.sym.dot(a, b)
# reshape
f = mx.sym.reshape(d+e, shape=(1,4))
# broadcast
g = mx.sym.broadcast_to(f, shape=(2,4))
# plot
mx.viz.plot_network(symbol=g)

上述示例中声明的计算可以使用bind 方法绑定到输入数据进行评估。我们将在符号布局部分讨论。

基本神经网络

除了基本的操作符,Symbol 拥有丰富的神经网络层集。以下代码构造了一个两层的全连接层,然后通过给定输入数据大小实例化该结构。

net = mx.sym.Variable('data')
net = mx.sym.FullyConnected(data=net, name='fc1', num_hidden=128)
net = mx.sym.Activation(data=net, name='relu1', act_type="relu")
net = mx.sym.FullyConnected(data=net, name='fc2', num_hidden=10)
net = mx.sym.SoftmaxOutput(data=net, name='out')
mx.viz.plot_network(net, shape={'data':(100,200)})

每一个符号具有唯一的字符串名称。NDArray和Symbol都表示单个的张量。操作符表示张量之间的计算。操作符接受symbol(或者NDArray)作为输入,也可以增加像隐藏神经元个数(num_hidden),激活类型(act_type)这样的超参数,并产生输出。

我们其实可以把一个symbol看做是具有多个参数的函数,可以用以下的方法遍历这些参数:

net.list_arguments()

这些参数是每一个symbol所需要的参数和输入:

  • data:变量data需要的输入数据
  • fc1_weight & fc1_bias:第一个全连接层fc1的权重和偏置
  • fc2_weight &fc2_bias:第二个全连接层fc2的权重和偏置
  • out_label:损失函数需要的标签

我们也可以明确指定名称:

net = mx.symbol.Variable('data')
w = mx.symbol.Variable('myweight')
net = mx.symbol.FullyConnected(data=net, weight=w, name='fc1', num_hidden=128)
net.list_arguments()

在上述示例中,FullyConnected 层有三个输入:数据,权值,偏置。任何,任何没有指定输入的将自动生成一个变量。

更加复杂的组成

MXNet对深度学习常用的层提供更优化的符号。我们也可以在Python中定义新的操作符。下面的例子先将两个symbol相加,再把它们送入到全连接操作符中。

lhs = mx.symbol.Variable('data1')
rhs = mx.symbol.Variable('data2')
net = mx.symbol.FullyConnected(data=lhs + rhs, name='fc1', num_hidden=128)
net.list_arguments()

我们还可以以比上述示例中描述的单个前向组合更灵活的方式构建符号:

data = mx.symbol.Variable('data')
net1 = mx.symbol.FullyConnected(data=data, name='fc1', num_hidden=10)
net1.list_arguments()
net2 = mx.symbol.Variable('data2')
net2 = mx.symbol.FullyConnected(data=net2, name='fc2', num_hidden=10)
composed = net2(data2=net1, name='composed')
composed.list_arguments()

在此示例中,net2 作为一个函数应用于现有符号net1 上,所得到的组合符号将具有net1net2 的所有属性。

一旦开始构建更大的网络,就可能需要使用公共前缀命名一些符号来统筹网络的结构。你可以像下面的示例一样使用前缀名称管理:

data = mx.sym.Variable("data")
net = data
n_layer = 2
for i in range(n_layer):
    with mx.name.Prefix("layer%d_" % (i + 1)):
        net = mx.sym.FullyConnected(data=net, name="fc", num_hidden=100)
net.list_arguments()

深度网络的模块化构建

对于深度网络,例如Google Inception,当有大量的层时,一个一个地构建层会十分痛苦。对于这些网络,我们通常模块化其构建。以Google Inception为例,我们首先定义一个制造函数来将卷积层,批标准化层和Relu激活层捆绑在一起:

def ConvFactory(data, num_filter, kernel, stride=(1,1), pad=(0, 0),name=None, suffix=''):
    conv = mx.sym.Convolution(data=data, num_filter=num_filter, kernel=kernel,
                  stride=stride, pad=pad, name='conv_%s%s' %(name, suffix))
    bn = mx.sym.BatchNorm(data=conv, name='bn_%s%s' %(name, suffix))
    act = mx.sym.Activation(data=bn, act_type='relu', name='relu_%s%s'
                  %(name, suffix))
    return act
prev = mx.sym.Variable(name="Previous Output")
conv_comp = ConvFactory(data=prev, num_filter=64, kernel=(7,7), stride=(2, 2))
shape = {"Previous Output" : (128, 3, 28, 28)}
mx.viz.plot_network(symbol=conv_comp, shape=shape)

然后我们定义一个构建基于ConvFactory 的Inception模型的函数:

def InceptionFactoryA(data, num_1x1, num_3x3red, num_3x3, num_d3x3red, num_d3x3,
                      pool, proj, name):
    # 1x1
    c1x1 = ConvFactory(data=data, num_filter=num_1x1, kernel=(1, 1), name=('%s_1x1' % name))
    # 3x3 reduce + 3x3
    c3x3r = ConvFactory(data=data, num_filter=num_3x3red, kernel=(1, 1), name=('%s_3x3' % name), suffix='_reduce')
    c3x3 = ConvFactory(data=c3x3r, num_filter=num_3x3, kernel=(3, 3), pad=(1, 1), name=('%s_3x3' % name))
    # double 3x3 reduce + double 3x3
    cd3x3r = ConvFactory(data=data, num_filter=num_d3x3red, kernel=(1, 1), name=('%s_double_3x3' % name), suffix='_reduce')
    cd3x3 = ConvFactory(data=cd3x3r, num_filter=num_d3x3, kernel=(3, 3), pad=(1, 1), name=('%s_double_3x3_0' % name))
    cd3x3 = ConvFactory(data=cd3x3, num_filter=num_d3x3, kernel=(3, 3), pad=(1, 1), name=('%s_double_3x3_1' % name))
    # pool + proj
    pooling = mx.sym.Pooling(data=data, kernel=(3, 3), stride=(1, 1), pad=(1, 1), pool_type=pool, name=('%s_pool_%s_pool' % (pool, name)))
    cproj = ConvFactory(data=pooling, num_filter=proj, kernel=(1, 1), name=('%s_proj' %  name))
    # concat
    concat = mx.sym.Concat(*[c1x1, c3x3, cd3x3, cproj], name='ch_concat_%s_chconcat' % name)
    return concat
prev = mx.sym.Variable(name="Previous Output")
in3a = InceptionFactoryA(prev, 64, 64, 64, 64, 96, "avg", 32, name="in3a")
mx.viz.plot_network(symbol=in3a, shape=shape)

最终我们可以通过改变多inception模型获得整个网络。

多符号组合

为了使用多损失层构建网络,我们可以使用mxnet.sym.Group 来将多个符号组合在一起。如下示例将组合了两个输出层:

net = mx.sym.Variable('data')
fc1 = mx.sym.FullyConnected(data=net, name='fc1', num_hidden=128)
net = mx.sym.Activation(data=fc1, name='relu1', act_type="relu")
out1 = mx.sym.SoftmaxOutput(data=net, name='softmax')
out2 = mx.sym.LinearRegressionOutput(data=net, name='regression')
group = mx.sym.Group([out1, out2])
group.list_outputs()

与NDArray关系

如我们目前所见,在MXNet中,SymbolNDArray 都提供多维数组操作符,例如c = a + b。在此我们简短地阐明两者不同。NDArray 提供是类似交互的命令式编程,其中计算是逐条语句执行的。而Symbol 更接近声明式编程,我们先声明计算方式,之后用数据评估。此类中的示例包括正则表达式和SQL。

NDArray 的优点:

  • 直截了当
  • 易于使用本地语言功能(for循环,if-else条件,..)和库(numpy,..)
  • 易于分步代码调试

Symbol 的优点:

  • 提供NDArray 的几乎所有功能,如+,*,sin,reshape等
  • 易于保存,加载和可视化
  • 方便后台优化计算和内存使用

符号操作

Symbol与NDArray的一大不同就是,我们首先声明计算,然后绑定数据来运行。

在此部分,我们介绍直接操作符号的函数。但注意,它们大部分都被module 完美地包装了起来。所以,即便跳过本节也无伤大雅。

形状和类型接口

对于每个符号,我们可以询问其输入(或者参数)和输出。我们也可以通过给定输入大小来获得输出大小,这有易于存储空间申请。

arg_name = c.list_arguments()  # get the names of the inputs
out_name = c.list_outputs()    # get the names of the outputs
# infers output shape given the shape of input arguments
arg_shape, out_shape, _ = c.infer_shape(a=(2,3), b=(2,3))
# infers output type given the type of input arguments
arg_type, out_type, _ = c.infer_type(a='float32', b='float32')
{'input' : dict(zip(arg_name, arg_shape)),
 'output' : dict(zip(out_name, out_shape))}
{'input' : dict(zip(arg_name, arg_type)),
 'output' : dict(zip(out_name, out_type))}

绑定数据与评估

我们构建的符号c声明了应该运行的计算。为了为其定值,我们需要首先用数据确定参数,也就是自由变量。我们可以使用bind 方法来完成。该方法接受设备上下文和一个将自由变量名称映射到NDArray的字典作为参数,然后返回一个执行器。执行器为提供forward 方法来定值和归属outputs 以获取所有结果。

ex = c.bind(ctx=mx.cpu(), args={'a' : mx.nd.ones([2,3]),
                                'b' : mx.nd.ones([2,3])})
ex.forward()
print('number of outputs = %d\nthe first output = \n%s' % (
           len(ex.outputs), ex.outputs[0].asnumpy()))

我们在GPU上用不同的的数据计算同一个符号:

gpu_device=mx.gpu() # Change this to mx.cpu() in absence of GPUs.

ex_gpu = c.bind(ctx=gpu_device, args={'a' : mx.nd.ones([3,4], gpu_device)*2,
                                      'b' : mx.nd.ones([3,4], gpu_device)*3})
ex_gpu.forward()
ex_gpu.outputs[0].asnumpy()

也可以用eval 方法来评估符号,它结合了bindforward 的方法。

ex = c.eval(ctx = mx.cpu(), a = mx.nd.ones([2,3]), b = mx.nd.ones([2,3]))
print('number of outputs = %d\nthe first output = \n%s' % (
            len(ex), ex[0].asnumpy()))

对于神经网络,更常用的模式是simple_bind ,它为你提供了所有的参数数组。之后可以调用forwardbackward 来获得梯度。

保存和载入

类似于NDArray,它们都表示一个张量,都是操作符的输入/输出。我们可以使用pickle 模块序列号Symbol 或者直接使用saveload 。当序列化NDArray时,我们序列化其中的张量数据,并以二进制格式直接转储到磁盘。 但符号使用图的概念。 图通过链接操作符组合起来。 它们由输出符号隐含地表示。所以,当序列化符号时,我们序列化输出是符号的图。虽然序列化,Symbol使用可读性更强的json 格式进行序列化。如果要将符号转换为json 字符串,要使用tojson 方法。

print(c.tojson())
c.save('symbol-c.json')
c2 = mx.sym.load('symbol-c.json')
c.tojson() == c2.tojson()

自定义符号

大部分操作符例如mx.sym.Convolutionmx.symReshape 使用C++实现可以得到更好的性能。MXNet也支持用户用任何前端语言例如Python撰写新的操作符。这使得开发和调试更加简便。用Python实现请参考如何创建新的操作符

高级用法

类型转换

MXNet默认使用32位float类型。有时我们为了更好的正确率—性能权衡,想要使用低精度的数据类型。例如,英伟达Tesla Pascal GPUs(如P100)使用16位浮点的性能提升,以及GTX Pascal GPUs(如GTX1080)使用8位整型的速度更快。

我们可以使用mx.sym.cast 操作符转换数据类型。

a = mx.sym.Variable('data')
b = mx.sym.cast(data=a, dtype='float16')
arg, out, _ = b.infer_type(data='float32')
print({'input':arg, 'output':out})

c = mx.sym.cast(data=a, dtype='uint8')
arg, out, _ = c.infer_type(data='int32')
print({'input':arg, 'output':out})

变量共享

有时我们想要共享多个不同符号中的内容。这可以直接通过用同一个数组绑定这些符号来实现。

a = mx.sym.Variable('a')
b = mx.sym.Variable('b')
b = a + a * a

data = mx.nd.ones((2,3))*2
ex = b.bind(ctx=mx.cpu(), args={'a':data, 'b':data})
ex.forward()
ex.outputs[0].asnumpy()

猜你喜欢

转载自blog.csdn.net/qq_36165459/article/details/78394259