EN

Neural Network Libraries Step by Step 4 前編

2019年10月15日 火曜日

チュートリアル

Posted by Takuya Yashima

nnabla.parametric_functionsとnnabla.functions

ここまではVariableやそのはたらきGradientの算出方法やそれを使ったパラメータの更新について見てきました。 これでNeural Network Librariesを用いてニューラルネットワークのモデルを構築するための基本的な準備ができたわけですが、 Neural Network LibrariesのPython APIを見てみると、functionsparametric_functionsの2種類のfunctionsがあることがわかります。 これはどのように使い分けるのでしょうか。

import nnabla as nn
import nnabla.solvers as S
import numpy as np

import nnabla.functions as F  # function
import nnabla.parametric_functions as PF  # parametric function
import nnabla.initializer as I  # 重みの初期化に使うイニシャライザ

ここでは仮に、10クラスの分類問題を解くニューラルネットワークを作るとします。 クラス数を定義し、続けて仮想的な画像データを作成します。3チャンネル、幅8 x 高さ8、1枚の画像データがあるとします。

# number of classes
num_class = 10

# input data
batch_size = 1
channels = 3
img_height = 8
img_width = 8
in_shape = (batch_size, channels, img_height, img_width)
x = nn.Variable(in_shape)  # 入力となるVariableを用意
x.d = np.random.random(in_shape)  # Variableに値を代入

次にニューラルネットワークを表現する計算グラフを定義していきます。 今回は簡単なモデルとして、 Convolution->RELU->Convolution->RELU->Affine という構造のネットワークを作成していきます。 まずは、Exampleなどで多用されているnnabla.parametric_functions(以下、PFと表記)を利用するパターンで計算グラフを作ってみましょう。

def construct_CNN_with_PF(x, init_filter, num_class):
    # 重みパラメータの初期化に用いるイニシャライザを定義
    initializer = I.UniformInitializer((-0.1, 0.1))

    with nn.parameter_scope("PF/conv1"):
        h = PF.convolution(x, init_filter, kernel=(3, 3),
                           pad=(1, 1), stride=(2, 2),
                           w_init=initializer, with_bias=False)
    h = F.relu(h)

    with nn.parameter_scope("PF/conv2"):
        h = PF.convolution(h, 2*init_filter, kernel=(3, 3),
                           pad=(1, 1), stride=(2, 2),
                           w_init=initializer, with_bias=False)
    h = F.relu(h)

    with nn.parameter_scope("PF/affine"):
        pred = PF.affine(h, num_class, w_init=initializer, with_bias=False)
    return pred

次に、PFを使わないパターンを考えてみます。

def construct_CNN_without_PF(x, init_filter, num_class):
    # PFを使う場合と同じイニシャライザを使用
    initializer = I.UniformInitializer((-0.1, 0.1))

    kernel_1 = nn.parameter.get_parameter_or_create(
               name="F/conv1/conv/W", shape=(init_filter, channels, 3, 3),
               initializer=initializer, need_grad=True)
    h = F.convolution(x, kernel_1, pad=(1, 1), stride=(2, 2))
    h = F.relu(h)

    kernel_2 = nn.parameter.get_parameter_or_create(
               name="F/conv2/conv/W", shape=(2*init_filter, init_filter, 3, 3),
               initializer=initializer, need_grad=True)

    h = F.convolution(h, kernel_2, pad=(1, 1), stride=(2, 2))
    h = F.relu(h)

    weight = nn.parameter.get_parameter_or_create(
             name="F/affine/affine/W", shape=(h.size, num_class),
             initializer=initializer, need_grad=True)
    pred = F.affine(h, weight)

    return pred

この2つの違いは、ConvolutionやAffineレイヤーを作る際の記法です。 PFを使う場合、例えばConvolutionレイヤーの作り方は、

with nn.parameter_scope("PF/conv1"):
    h = PF.convolution(x, init_filter, kernel=(3, 3),
                       pad=(1, 1), stride=(2, 2),
                       w_init=initializer, with_bias=False)

となっている一方、PFを使わない場合は

kernel_1 = nn.parameter.get_parameter_or_create(
           name="F/conv1/conv/W", shape=(init_filter, channels, 3, 3),
           initializer=initializer, need_grad=True)
h = F.convolution(x, kernel_1, pad=(1, 1), stride=(2, 2)) 

となります。どちらも(折り返さなければ)2行で書けますが、関数の引数の違いを除くと、それぞれ以下のような特徴があることがわかります。 PFを使う場合、

  • withブロックを用い、ネストされた中でConvolution演算を定義している

PFを使わない場合、

  • Convolutionに用いる重み(kernel_1)をnn.parameter.get_parameter_or_create()によって定義している
  • 重みを定義する際、その名前とshapeなどを指定している
  • Convolution演算に対し、自ら定義した重みを引数として渡している

これはすなわち、PFを使う場合は、

  • Convolutionに用いる重みパラメータを自ら定義する必要も、指定する必要もない

ということを意味します。 これは、PFを使わない場合に、演算を行う前にその都度利用する重みパラメータを定義し、関数の引数として渡している点とは対照的です。 しかし、いずれの場合にも、演算に用いる重みパラメータはきちんと作成されています。 ここで、それぞれの計算グラフを実際に作成し、nn.get_parameters()を用いてその中で用いられる重みパラメータを確認してみましょう。 ここではまず、PFを使っていない場合から見ていきましょう。

y_F = construct_CNN_without_PF(x, 16, num_class)
nn.get_parameters()  # 重みパラメータを表示
OrderedDict([('F/conv1/conv/W',
              <Variable((16, 3, 3, 3), need_grad=True) at 0x789aaefc2908>),
             ('F/conv2/conv/W',
              <Variable((32, 16, 3, 3), need_grad=True) at 0x789aaefc2ae8>),
             ('F/affine/affine/W',
              <Variable((128, 10), need_grad=True) at 0x789aaefc2548>)])

自ら作成した重みパラメータが作成されているのが確認できます。 また、それぞれの重みパラメータの名前は、nn.parameter.get_parameter_or_create()に渡した文字列になっているのが分かります。

ここではあえて後ほどの例に合わせていますが、名前は自由につけることができます(必ずしも/を用いた階層構造にする必要はありません)。 では、次にPFを使った場合を見てみましょう。

y_PF = construct_CNN_with_PF(x, 16, num_class)

nn.get_parameters()  # 重みパラメータを表示
OrderedDict([('F/conv1/conv/W',
              <Variable((16, 3, 3, 3), need_grad=True) at 0x789aaefc2908>),
             ('F/conv2/conv/W',
              <Variable((32, 16, 3, 3), need_grad=True) at 0x789aaefc2ae8>),
             ('F/affine/affine/W',
              <Variable((128, 10), need_grad=True) at 0x789aaefc2548>),
             ('PF/conv1/conv/W',
              <Variable((16, 3, 3, 3), need_grad=True) at 0x789cd9ffd1d8>),
             ('PF/conv2/conv/W',
              <Variable((32, 16, 3, 3), need_grad=True) at 0x789cd9ffd228>),
             ('PF/affine/affine/W',
              <Variable((128, 10), need_grad=True) at 0x789cd9ffd368>)])

再度重みパラメータが表示されました。今回構築した計算グラフにおいて使用されているパラメータが追加されているのがわかります。
このことから、PFを使った場合、自分で定義せずとも、グラフ内で用いられている演算に必要な重みパラメータは自動的に作成されているのがわかります。 また、各重みパラメータの名前に注目してみましょう。 計算グラフを定義した際に、withブロックを作るときに用いた文字列が含まれていることがわかるでしょうか。

例えばConvolutionに使われる重みパラメータの名前は、'PF/conv1/conv/W'となっていますが、これはwithブロックで自分で指定した名前空間である'PF/conv1'に'conv/W'が組み合わさったものになっています。 すなわち、PFを使うと、自動的に重みパラメータを作成してくれる上、その名前も自動で付けてくれるのです。

ここで'conv/W'はPF.convolution()で用いられる重みパラメータのデフォルトの名前です。 PFに属する関数はすべて、その演算に用いられる重みパラメータに対しデフォルトの名前が付いています。 例えば、PF.batch_normalization()を使うと、'bn/beta', 'bn/gamma', 'bn/mean', 'bn/var'の4つの名前をもつ重みパラメータが作成されます(ただし、nn.get_parameters()で表示されるのは学習対象となる重みパラメータのみなので、'bn/mean''bn/var'は表示されません。nn.get_parameters(grad_only=False)とすればすべてが表示されます)。各重みパラメータのデフォルト名に関してはPython API ReferenceのParameters to be registeredを参照してください。

また、ここまでで2つの方法で計算グラフを構築しました。これらの計算グラフの内部は同一構造であり、かつ重みパラメータの初期化にはすべてinitializer = I.UniformInitializer((-0.1, 0.1))を利用しているため、計算結果は一致します。

y_F.forward()
y_PF.forward()
np.all(y_F.d==y_PF.d)  # Trueを返す

パラメータの名前についてですが、以下のようにwithブロック内で名前空間を指定することで一部のみを選択的に抽出することも可能です。

with nn.parameter_scope("F"):
    print(nn.get_parameters())
OrderedDict([('conv1/conv/W', <Variable((16, 3, 3, 3), need_grad=True) at 0x789aaefc2908>), ('conv2/conv/W', <Variable((32, 16, 3, 3), need_grad=True) at 0x7
89aaefc2ae8>), ('affine/affine/W', <Variable((128, 10), need_grad=True) at 0x789aaefc2548>)])

ここで表示されているパラメータは実際には'F/conv1/conv/W', 'F/conv2/conv/W', 'F/affine/affine/W'の名前をもつパラメータです。
また、上記のような、名前空間を指定したwithブロック内で計算グラフを構築すると、その計算グラフ内で用いられるパラメータは指定した名前空間に含有されます。

with nn.parameter_scope("namescope"):
    _ = construct_CNN_with_PF(x, 16, num_class)
nn.get_parameters()
OrderedDict(['namescope/PF/conv1/conv/W',
              <Variable((16, 3, 3, 3), need_grad=True) at 0x789cd9cc3db8>),
             ('namescope/PF/conv2/conv/W',
              <Variable((32, 16, 3, 3), need_grad=True) at 0x789cd9cc3b88>),
             ('namescope/PF/affine/affine/W',
              <Variable((128, 10), need_grad=True) at 0x789cd9deac28>)])

※実はPFを使う場合、レイヤーの定義を1行で行うことも可能です。引数の1つのnameに重みの名前を与えることで、withブロックにその名前を渡すのと同じ動作になります。

h = PF.convolution(x, 16, kernel=(3, 3), pad=(1, 1),
                   stride=(2, 2), with_bias=False, name="PF/conv1")
nn.get_parameters()
OrderedDict([('PF/conv1/conv/W',
              <Variable((16, 3, 3, 3), need_grad=True) at 0x7f2831060868>)])

PFを使うメリットとしては、重みパラメータ(とその名前)を自分で定義する必要がないので、計算グラフに新しい演算レイヤーを追加する際に簡単に書けるという点が挙げられますが、注意すべき点もあります。これについては後編で述べていきたいと思います。