【MXNet官方教程3】Symbol -神经网络图和自动区分

在上一篇教程里,我们介绍了NDArray,MXNet的基本数据操作结构。仅通过NDArray,我们可以计算大部分数学运算。实际上,仅通过NDArray,我们可以定义且训练一个完整的神经网络。NDArray可以在任何前端语言上执行高效的指令式科学计算,所以你可能会疑惑:为什么我们不直接使用NDArray呢?

MXNet提供了Symbol API,一个用于符号编程的接口。所谓符号编程,相比于传统的一步步计算,我们首先会定义一个计算图。计算图中包含规定的输入和输出的占位符。然后编译它,并与NDArray绑定起来进行计算。MXNet的Symbol API类似于Caffe的网络配置和Theano的符号编程。

符号编程的另一个优点是我们可以在使用它之前就优化。例如,当以指令式编程计算一个表达式时,我们不知道下一步需要什么数据。但是在符号编程里,我们已经预先定义好了需要的输出。这意味着在一边计算的时候,可以一边将中间步骤的内存回收掉。并且相同的网络下,Symbol API占用更少的内存。参见How To以及Architecture 部分。

在我们的设计笔记里,对指令式编程和符号式编程进行了更深入的比较。但在这篇教程里,我们只关注MXNet Symbol API的使用。在MXNet里,我们可以通过简单的矩阵运算符或者复杂的神经网络层,将一个Symbol转为另一个Symbol。运算符支持多维输入以及多维输出,还能保存中间状态的symbol。

为了对这些概念有一个可视化的理解,参看 Symbolic Configuration and Execution in Pictures

更具体一点,我们来手把手操作Symbol API。Symbol的构成有不同的方式。

先决条件

我们需要:

  • MXNet
  • Jupyter
pip install jupyter
  • GPUs:如果没有GPU,把gpu_device设置为mx.cpu()。

Symbol基本组成

基础运算符

这个例子创建了一个简单的表达式:a+b。首先,使用mx.sym.Variable创建两个占位符,并命名为ab。然后,用+运算符得到期望的Symbol。我们并不一定需要为变量命名,MXNet会自动给它生成一个独一无二的名字。比如例子中的c

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

大部分支持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)

http://7xt4i9.com2.z0.glb.qiniucdn.com/18-3-16/39138168.jpg

上面的计算可以使用bind方法绑定输入数据来执行运算。我们会在Symbol操作的部分详细介绍。

基础神经网络

Symbol支持大量的神经网络层,这个例子构造了一个2层全连接网络,在给定输入数据格式后将其可视化。

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)})

http://7xt4i9.com2.z0.glb.qiniucdn.com/18-3-16/71201217.jpg

每一个symbol有一个唯一的名字。NDArray和Symbol都表示一个单独的张量。运算符表示张量之间的计算。运算符把Symbol(或NDArray)作为输入,并且接受其他超参数比如隐藏神经元个数或者激活函数,最后产生输出。

我们可以把Symbol视为带有参数的函数,通过下面的方法获得这些参数:

net.list_arguments()
['data', 'fc1_weight', 'fc1_bias', 'fc2_weight', 'fc2_bias', 'out_label']

这些参数有:

  • data:变量data表示的输入数据
  • fc1_weight和fc1_bias:第1个全连接网络层的权重和偏置
  • fc2_weight和fc2_bias:第2个全连接网络层的权重和偏置
  • out_label:变量loss表示的标签

我们也可以单独给每个变量命名:

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()
['data', 'myweight', 'fc1_bias']

上面的例子中,FullyConnected层包括3个输入:data,weight,bias。当任何一个输入没有被指定时,都会得到一个自动生成的变量。

更复杂的组成

MXNet提供优化好的深度学习中常用的网络Symbol。我们还可以用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()
['data1', 'data2', 'fc1_weight', 'fc1_bias']

对比上面提到的单向组合,我们可以使用更灵活的方式生成Symbol。

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()
['data', 'fc1_weight', 'fc1_bias', 'fc2_weight', 'fc2_bias']

这个例子中,net2作为一个函数,接收net1作为输入,得到一个组合 Symbol,这个Symbol包含net1和net2的所有属性。

当你准备构建比较大的网络时,你可能需要用一个统一的前缀命名symbols,来更好的描述网络结构。你可以使用前缀命名管理器

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()
['data',
 'layer1_fc_weight',
 'layer1_fc_bias',
 'layer2_fc_weight',
 'layer2_fc_bias']

深度神经网络的模块化构造

一层一层地构建一个深度神经网络(比如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)

http://7xt4i9.com2.z0.glb.qiniucdn.com/18-3-16/31585331.jpg

然后我们再利用刚才的工厂方法定义一个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)

http://7xt4i9.com2.z0.glb.qiniucdn.com/18-3-16/76754737.jpg

最后,我们组合不同的inception模块构成整个网络。完整代码在这里

聚合多个Symbol

为了构造含有多个损失层的神经网络,我们可以使用mxnet.sym.Group把多个Symbol聚合在一起,下面的例子聚合两个输出:

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()
['softmax_output', 'regression_output']

和NDArray的关系

就如你所见,Symbol和NDArray都提供了多维数组运算,比如MXNet里c = a + b。我们在这里简单陈述一下区别。

NDArray提供的是类似于指令式编程的接口,计算是一步一步执行的。而Symbol更接近宣告式编程,是先定义计算,然后再提供数据执行。宣告式编程的例子有正则表达式和SQL。

NDArray的优点:

  • 直观
  • 在原生语言特性(循环,条件选择等)和第三方库(numpy等)上容易执行
  • 容易debug

Symbol的优点:

  • 提供几乎所有NDArray支持的操作,比如+,*,sin,reshape等。
  • 容易保存、加载和可视化
  • 后端可以很容易的优化计算和内存使用

Symbol操作

Symbol和NDArray一个重要的区别是是要先定义计算图然后绑定数据最后运算。

这部分,我们介绍直接操作Symbol的方法。注意的是,大部分方法都位于module包。

shape和type推断

我们可以查询每一个symbol的参数、辅助状态和输出。在给定输入的shape或type时,我们还可以推断输出的shape或type,有利于内存分配。

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))}
{'input': {'a': (2, 3), 'b': (2, 3)}, 'output': {'_plus0_output': (2, 3)}}
{'input': {'a': numpy.float32, 'b': numpy.float32},
 'output': {'_plus0_output': numpy.float32}}

绑定数据并执行

上面的Symbol 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()))
number of outputs = 1
the first output = 
[[ 2.  2.  2.]
 [ 2.  2.  2.]]

当然也可以在gpu上执行。

注意:如果没有gpu,把gpu_device设为mx.cpu()。

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()
array([[ 5.,  5.,  5.,  5.],
       [ 5.,  5.,  5.,  5.],
       [ 5.,  5.,  5.,  5.]], dtype=float32)

我们也可以用eval方法来计算symbol,相当于同时调用bind和forward方法。

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()))
number of outputs = 1
the first output = 
[[ 2.  2.  2.]
 [ 2.  2.  2.]]

对于神经网络,更常见的方式是simple_bind,它会创建所有的参数列表。然后可以调用forwardbackward(如果需要计算梯度的话)。

加载和保存

本质上symbol和ndarray是一样的,它们都是张量,也都是运算符的输入/输出。我们可以用pickle序列化一个Symbol,还可以像之前的NDArray教程里说的直接调用saveload

当序列化NDArray时,我们序列化里面的张量数据并且直接以二进制格式存储在硬盘上。但是symbol用的是图的概念,图由一系列运算符组成,且隐式地由输出symbol表示。所以,序列化symbol的时候,我们序列化输出Symbol。symbol还需要可读的json格式来序列化。可以用tojson方法转换symbol为json字符串。

print(c.tojson())
c.save('symbol-c.json')
c2 = mx.sym.load('symbol-c.json')
c.tojson() == c2.tojson()
{
  "nodes": [
    {
      "op": "null", 
      "name": "a", 
      "inputs": []
    }, 
    {
      "op": "null", 
      "name": "b", 
      "inputs": []
    }, 
    {
      "op": "elemwise_add", 
      "name": "_plus0", 
      "inputs": [[0, 0, 0], [1, 0, 0]]
    }
  ], 
  "arg_nodes": [0, 1], 
  "node_row_ptr": [0, 1, 2, 3], 
  "heads": [[2, 0, 0]], 
  "attrs": {"mxnet_version": ["int", 1100]}
}
True

自定义Symbol

大多数运算符比如mx.sym.Convolutionmx.sym.Reshape都是用C++实现的,但MXNet支持用任何前端语言比如python来实现自定义运算符。使用自定义symbol通常可以更容易的开发和debug。详见【MXNet常见问题1】怎么创建新运算符(网络层)

高级用法

类型转换

MXNet默认使用32位浮点数,为了更高准确率,我们可以使用更低精度的数据类型。比如,Nvidia Tesla Pascal GPUs (e.g. P100)在16位浮点数上有更好的表现,GTX Pascal GPUs (e.g. GTX 1080)在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})
{'output': [<class 'numpy.float16'>], 'input': [<class 'numpy.float32'>]}
{'output': [<class 'numpy.uint8'>], 'input': [<class 'numpy.int32'>]}

变量共享

为了在不同symbol间共享数据,我们可以把这些symbol绑定同样的数据。

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()
array([[ 6.,  6.,  6.],
       [ 6.,  6.,  6.]], dtype=float32)

原文地址:Symbol - Neural network graphs and auto-differentiation

猜你喜欢

转载自blog.csdn.net/xiang_freedom/article/details/79574123