MXNet チュートリアル : Symbol – GoogLeNet Inception

MXNet チュートリアル : Symbol – GoogLeNet Inception(翻訳/解説)
翻訳 : (株)クラスキャット セールスインフォメーション
日時 : 02/16/2017

* 本ページは、MXNet 本家サイトの Symbol Tutorial を翻訳した上で適宜、補足説明したものです:
    http://mxnet.io/tutorials/python/symbol.html

Neural Network Graphs – ニューラルネットワーク・グラフを構築するためにどのように mxnet.symbol を使用するか。基本演算子(層)を紹介して新しいものをどのように構築するかを示します。

* サンプルコードの動作確認はしておりますが、適宜、追加改変しています。
* このページのグラフ画像はすべて自作しています。

 

テンソル計算インターフェイス NDArray の他にも、MXNet の他の主要なオブジェクトとして mxnet.symbol、あるいは短く mxnet.sym によって提供される Symbol があります。symbol はマルチ出力記号式 (multi-output symbolic expression) を表します。それらは、単純な行列演算子 (e.g. “+”)、あるいはニューラルネットワーク層 (e.g. 畳込み層) のような、演算子 (operators) で合成 (composited) されます。演算子は幾つかの入力変数を取り、一つ以上の出力変数を生成し、そして内部的な状態変数を持ちます。変数は、(後でバインドできる)自由であるか、または他の symbol の出力です。

 

Symbol 合成 (Composition)

基本演算子

次の例は単純な式 $a+b$ を合成します。最初にプレースホルダーを $mx.sym.Variable$ を使用して $a$ と $b$ という名前で作成して、そして希望する symbol を演算子 $+$ を使用して構築します。作成の際に文字列名が与えられない時には、MXNet は symbol のために自動的に一意な名前を生成します。これは 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 times
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))  
mx.viz.plot_network(symbol=g)

基本ニューラルネットワーク

基本演算子以外に、Symbol はニューラルネットワーク層のリッチセットを持ちます。次のコードは 2 層完全結合ニューラルネットワークを構築してそして与えられた入力データ shape で構造を視覚化します。

# Output may vary
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)})

深層ネットワークのためのモジューラ化された構築

Google Inception のような深層ネットワークのためには、多くの層が与えられた時 layer by layer で構築することは苦痛です。それらのネットワークのために、しばしば構築をモジュラー化します。Google Inception を例に取れば、最初に畳込み層、バッチ正規化層、そして ReLu 活性化層を一緒に連鎖する factory 関数を定義することができます :

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

そして ConvFactory をベースに Inception モジュールを構築する関数を定義します。

# @@@ AUTOTEST_OUTPUT_IGNORED_CELL
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.symbol.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.symbol.Concat(*[c1x1, c3x3, cd3x3, cproj], name='ch_concat_%s_chconcat' % name)
    return concat
prev = mx.symbol.Variable(name="Previos Output")
in3a = InceptionFactoryA(prev, 64, 64, 64, 64, 96, "avg", 32, name="in3a")
mx.viz.plot_network(symbol=in3a, shape=shape)

最終的に複数の inception モジュールを連鎖することによりネットワーク全体を得ることができます。完全な例は mxnet/example/image-classification/symbols/inception-bn.py で公開されています。

複数 Symbol のグループ化

複数の損失層 (loss layers) でニューラルネットワークを構成するために、複数の symbols を一緒にグループ化するために $mxnet.sym.Group$ が利用できます。次の例は2つの出力をグループ化します :

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$ のように多次元配列演算を提供します。時々ユーザはどちの方法を使うべきか混乱します。ここでは違いを簡潔に明示しますが、より詳細な説明は MXNet Architecture : 深層学習のためのプログラミング・モデル を参照してください。

NDArray は命令型プログラミング的なインターフェイスを提供し、計算は行毎に評価されます。一方で Symbol は命令型プログラミングに近く、最初に計算を宣言し、そしてそれからデータで評価します。このカテゴリの例は正規表現と SQL を含みます。

NDArray の利点 :

  • 実直である (straightforward)
  • 他の言語の特徴 (for ループ、if-else 条件、…) とライブラリと共同作業するのが簡単。
  • ステップ毎のデバッグが簡単。

Symbol の利点 :

  • +, *, sin, そして reshape のような、NDArray の殆ど全ての機能を提供する。
  • Convolution, Activation, そして BatchNorm のような数多くのニューラルネットワーク関連演算子を提供する。
  • 自動微分を提供する。
  • 深層ニューラルネットワークのような複雑な計算を簡単に構築して操作できます。
  • セーブ、ロード、そして可視化が簡単。
  • バックエンドが計算とメモリ使用の最適化をすることが簡単です。

MXNet Tutorial : Mixed Programing で、完全なトレーニング・プログラムを開発するためにこれら2つのインターフェイスがどのように一緒に使用されるかを示します。このチュートリアルでは Symbol の使用に集中します。

 

Symbol 操作 *

NDArray と比較した Symbol の一つの重要な違いは、計算を最初に宣言して、それからデータをバインドして実行することです。

このセクションでは symbol を直接操作する関数を紹介します。しかし注意してください、それらの殆どは $mx.module$ で素晴らしくラップされています。このセクションは安全にスキップ可能です。

Shape 推論

各 symbol に対して、その入力 (or 引数) と出力を問い合わせることができます。与えられた入力 shape で出力 shape もまた推論できます、これはメモリ割り当てを容易にします。

arg_name = c.list_arguments()  # get the names of the inputs
out_name = c.list_outputs()    # get the names of the outputs
arg_shape, out_shape, _ = c.infer_shape(a=(2,3), b=(2,3))  
{'input' : dict(zip(arg_name, arg_shape)), 
 'output' : dict(zip(out_name, out_shape))}
{'input': {'a': (2L, 3L), 'b': (2L, 3L)},
 'output': {'_plus0_output': (2L, 3L)}}

データでバインドして評価する

作成した symbol $c$ はどのような計算が実行されるべきかを宣言します。それを評価するためには、最初に自由変数 (free variables) という名前の引数をデータで供給する必要があります。bind メソッドを使用してそれを行うことができます。それはデバイス・コンテキストと自由変数名を NDArrays にマップする辞書を引数として受け取り、executor を返します。

executor は、評価のためのメソッド 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 上で同じ symbol を評価することもできます。

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

load と save

NDArray と同様に、Symbol オブジェクトを pickle を使ってシリアライズすることもできますし、あるいは直接 save と load を使うこともできます。NDArray に選ばれたバイナリ・フォーマットとは異なり、シリアライゼーションのために Symbol はより readable な json フォーマットを使用します。tojson メソッドは json 文字列を返します。

print(c.tojson())
c.save('symbol-c.json')
c2 = mx.symbol.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", 901]}
}

True

カスタマイズされた Symbol *

$mx.sym.Convolution$ と $mx.sym.Reshape$ のような殆どの演算子はより良いパフォーマンスのために C++ で実装されています。MXNet はまた Python のようなフロントエンド言語を使用して新しい演算子を書くことをユーザに許しています。それは開発とデバッグを良く非常に簡単にします。

Python で演算子を実装するためには、list_arguments と infer_shape のようにプロパティを問い合わせるための幾つかのメソッドとともに2つの計算メソッド forward と backward を定義する必要があるだけです。

foward と backward の両者で引数のデフォルトタイプは NDArray です。そのためしばしば NDArray 演算子で計算もまた実装します。けれども、MXNet の柔軟性を示すために、NumPy を使って softmax 層の実装をデモします。Numpy ベースの演算子は CPU 上でのみ走りまた NDArray 上で適用可能な幾つかの最適化を失いますが、NumPy で提供される豊富な機能を楽しむことができます。

最初に $mx.opeator.CustomOp$ を作成してそれから foward と backward を定義します。

class Softmax(mx.operator.CustomOp):
    def forward(self, is_train, req, in_data, out_data, aux):
        x = in_data[0].asnumpy()
        y = np.exp(x - x.max(axis=1).reshape((x.shape[0], 1)))
        y /= y.sum(axis=1).reshape((x.shape[0], 1))
        self.assign(out_data[0], req[0], mx.nd.array(y))

    def backward(self, req, out_grad, in_data, out_data, in_grad, aux):
        l = in_data[1].asnumpy().ravel().astype(np.int)
        y = out_data[0].asnumpy()
        y[np.arange(l.shape[0]), l] -= 1.0
        self.assign(in_grad[0], req[0], mx.nd.array(y))

ここで NDArray 入力から numpy.ndarray に変換するために $asnumpy$ を使用します。そして $CustomOp.assign$ を使って結果を(”overwrite” または “add to” を取れる) req の値をベースに mxnet.NDArray に割り当て直します。

次にプロパティを query するために $mx.operator.CustomOpProp$ のサブクラスを作成します。

# register this operator into MXNet by name "softmax"
@mx.operator.register("softmax")
class SoftmaxProp(mx.operator.CustomOpProp):
    def __init__(self):
        # softmax is a loss layer so we don’t need gradient input
        # from layers above. 
        super(SoftmaxProp, self).__init__(need_top_grad=False)
    
    def list_arguments(self):
        return ['data', 'label']

    def list_outputs(self):
        return ['output']

    def infer_shape(self, in_shape):
        data_shape = in_shape[0]
        label_shape = (in_shape[0][0],)
        output_shape = in_shape[0]
        return [data_shape, label_shape], [output_shape], []

    def create_operator(self, ctx, shapes, dtypes):
        return Softmax()

最終的に、$mx.sym.Custom$ を(この演算子を使うための)登録名とともに使用できます。

net = mx.symbol.Custom(data=prev_input, op_type='softmax')

 

上級の使用法 *

型キャスト

MXNet はデフォルトで 32-bit float を使用します。時々より良い精度-性能のトレードオフのためにより低い精度のデータ型を使用することを望みます。例えば、Nvidia Tesla Pascal GPUs (e.g. P100) は 16-bit float 性能を改善しましたが、一方で GTX Pascal GPUs (e.g. GTX 1080) は 8-bit 整数上で速いです。

データ型を変換するためには $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})
{'input': [], 'output': [<type 'numpy.float16'>]}
{'input': [], 'output': [<type 'numpy.uint8'>]}

変数共有

時々幾つかの symbol 間でコンテンツを共有することを望みます。これは単に同じ配列でこれらの symbol を bind することでなされます。

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

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

 

Further Readings

 

Next Steps

 

以上