アルパカ三銃士

〜アルパカに酔いしれる獣たちへ捧げる〜

MXNet の基礎を Perl で学んでみた part 2

前回の「MXNet の基礎を Perl で学んでみた part 1」では NDArray api, Symbol api を組み合わせて計算を試した。今回は、前回使用した api も使ってデータセットをどう準備するか、ニューラルネットをどのように組み立ていくかのチュートリアルを行う。

以下のサイトを参考にしながら進めた。

medium.com

データセットの定義

これから扱うデータセットは想定としてサンプル数が 1000 個あるとする。

  • 各サンプルは 100 の特徴を持っている
  • その特徴は float で 0 ~ 1 の範囲で表現される
  • サンプルは 10 個のカテゴリで分けられる。与えられたサンプルを 10 個のカテゴリの中からどれか予測する
  • サンプル数が 1000 個あるうちの 800 個を学習に用いて、残りの 200 個を検証用に利用する
  • 学習と検証に使うバッチサイズは 10 とする

以下を前回も利用した Reply 上に打ち込む。

0> use PDL;
1> use AI::MXNet qw(mx);
2> use Log::Minimal;
3> my $sample_count = 1000;
$res[0] = 1000

4> my $train_count = 800;
$res[1] = 800

5> my $valid_count = $sample_count - $train_count;
$res[2] = 200

6> my $feature_count = 100;
$res[3] = 100

7> my $category_count = 10;
$res[4] = 10

8> my $batch = 10;
$res[5] = 10

データセットの作成

一様分布(uniform distribution)を利用して 1000 個のサンプルを作成する。それらを 1000 行 100 列のデータとして $X という変数に NDArray オブジェクトとして格納する。

9> my $X = mx->nd->uniform(0.0, 1.0, [$sample_count, $feature_count]);
$res[6] = bless( {
         'handle' => bless( do{\(my $o = '140584369140112')}, 'NDArrayHandle' )
       }, 'AI::MXNet::NDArray' )

10> $X->shape
$res[7] = [
  1000,
  100
]

11> print $X->aspdl
[
 [   0.548814    0.592845    0.715189    0.844266    0.602763    0.857946    0.544883    0.847252    0.423655    0.623564    0.645894    0.384382    0.437587    0.297535    0.891773    0.056713    0.963663    0.272656    0.383442    0.477665    0.791725    0.812169    0.528895    0.479977    0.568045    0.392785    0.925597    0.836079   0.0710361    0.337396   0.0871293    0.648172   0.0202184    0.368242     0.83262    0.957155    0.778157    0.140351    0.870012    0.870087    0.978618    0.473608    0.799159    0.800911    0.461479    0.520477    0.780529     0.67888    0.118274    0.720633    0.639921     0.58202    0.143353    0.537373    0.944669    0.758616    0.521848    0.105908    0.414662      0.4736    0.264556    0.186332    0.774234    0.736918     0.45615     0.21655    0.568434    0.135218   0.0187898    0.324141    0.617635    0.149675    0.612096    0.222321    0.616934    0.386489    0.943748    0.902599     0.68182     0.44995    0.359508    0.613063    0.437032    0.902349    0.697631   0.0992803   0.0602255    0.969809    0.666767     0.65314    0.670638     0.17091    0.210383    0.358152    0.128926    0.750686    0.315428    0.607831    0.363711    0.325047],
 ...,
 [   0.822414    0.721977    0.851162    0.533715    0.833357    0.611384    0.194662    0.120122    0.277528   0.0768438    0.318004    0.249329    0.971834    0.688225    0.897387    0.210838    0.101945   0.0457902    0.134996    0.566007    0.393416    0.323128    0.536272    0.425514    0.678741    0.367624     0.40234    0.113791  0.00340211    0.840583    0.432358    0.704648    0.410175    0.380955    0.856335    0.647605    0.392824   0.0770666    0.460948    0.965236    0.919514     0.79029    0.637864    0.144381    0.277751    0.938149    0.253583    0.307909    0.566944    0.782187     0.35406    0.879679    0.656584    0.857085    0.538141    0.868193    0.397419    0.779172    0.861492    0.345305    0.107033    0.133855   0.0391257    0.905164    0.687082    0.695042    0.402869    0.819127   0.0834093    0.878308    0.255888    0.148227    0.191853    0.436425    0.318892    0.207528    0.970987     0.84975    0.458267    0.280815    0.650715    0.989984    0.877337    0.661115    0.602423     0.98605    0.426482    0.356773    0.655736   0.0203642    0.546329    0.803301     0.69331    0.759888    0.233675    0.135931    0.830924    0.243514    0.551318    0.125452]
]

次に 1000 個のサンプルを 0 ~ 9 の integer の型で表現する。これを $Y という変数に NDArray オブジェクトとして格納する。

12> my $ary_ref = []
$res[9] = []

13> $ary_ref->[$_] = int rand $category_count for 0 .. $sample_count - 1;
$res[10] = ''

14> my $Y = mx->nd->array($ary_ref, dtype => 'float32')
$res[12] = bless( {
         'handle' => bless( do{\(my $o = '140525750453648')}, 'NDArrayHandle' ),
         'writable' => 1
       }, 'AI::MXNet::NDArray' )

15> $Y->shape
$res[13] = [
  1000
]

16> say $Y->_slice(0, 10)->aspdl
[3 6 8 0 2 6 7 9 3 6]
$res[14] = 1

データセットの分割

次に先ほどランダムで生成したデータセットを、学習用と検証用にそれぞれ 80%, 20% の割合で分割する。ここで crop という NDArray の機能を使う。実際にデータセットを用いる場合、偏りを避けるためにデータセットの順序をシャッフルして利用するべきとのこと。

17> my $X_train = mx->nd->crop($X, [0,0], [$train_count, $feature_count-1])
$res[15] = bless( {
         'handle' => bless( do{\(my $o = '140525748810864')}, 'NDArrayHandle' )
       }, 'AI::MXNet::NDArray' )

18> my $X_valid = mx->nd->crop($X, [$train_count,0], [$sample_count,$feature_count-1])
$res[16] = bless( {
         'handle' => bless( do{\(my $o = '140525749083744')}, 'NDArrayHandle' )
       }, 'AI::MXNet::NDArray' )

19> my $Y_train = mx->nd->crop($Y, [0], [$train_count])
$res[17] = bless( {
         'handle' => bless( do{\(my $o = '140525744749424')}, 'NDArrayHandle' )
       }, 'AI::MXNet::NDArray' )

20> my $Y_valid = mx->nd->crop($Y, [$train_count], [$sample_count])
$res[18] = bless( {
         'handle' => bless( do{\(my $o = '140525723673424')}, 'NDArrayHandle' )
       }, 'AI::MXNet::NDArray' )

これでデータセットの準備完了

ネットワークの構築

今回利用するネットワークはシンプルとのこと。そこで、それぞれのレイヤーを確認していく。 - 入力層(input layer)。Symbol api を用いて data という名前で定義されている。

21> my $data = mx->sym->Variable('data')
$res[19] = bless( {
         'handle' => bless( do{\(my $o = '140525750482368')}, 'SymbolHandle' )
       }, 'AI::MXNet::Symbol' )
  • fc1、これは最初の隠れ層(hidden layer)で 64 のニューロンを持つ全結合層(fully-connected)から組み立てられている。即ち、入力層は 64 全てのニューロンと接続されている。このような層の組み立てを MXNet では以下のように簡単に行うことができる。
22> my $fc1 = mx->sym->FullyConnected($data, name => 'fc1', num_hidden => 64)
$res[20] = bless( {
         'handle' => bless( do{\(my $o = '140525750539600')}, 'SymbolHandle' )
       }, 'AI::MXNet::Symbol' )
  • fc1 からそれぞれ出力された結果は活性化関数を通る。ここで用いる活性化関数は ‘ReLU’ とも呼ばれているランプ関数(Rectified Linear Unit)である。活性化関数はニューロンが「発火する」か否か、つまり、「その入力が正しい結果を予測するのに十分な意味を持つか否か」を決定するために用いられる。
23> my $relu1 = mx->sym->Activation($fc1, name => 'relu1', act_type => "relu")
$res[21] = bless( {
         'handle' => bless( do{\(my $o = '140525723860224')}, 'SymbolHandle' )
       }, 'AI::MXNet::Symbol' )
  • fc2 は 2 つ目の隠れ層である。これは 10 のニューロンを持つ全結合層(fully-connected)から組み立てられている。この 10 は最初に定義したカテゴリ 10 個から来ている。それぞれのニューロンは float な値を出力する。この出力結果で最も大きな値が「カテゴリの中で最も適したもの」になる。
24> my $fc2 = mx->sym->FullyConnected($relu1, name => 'fc2', num_hidden => $category_count)
$res[22] = bless( {
         'handle' => bless( do{\(my $o = '140525748743920')}, 'SymbolHandle' )
       }, 'AI::MXNet::Symbol' )
  • 出力層では fc2 層から来る 10 個の値に対してソフトマックス関数を適用する。これらは 0 ~ 1 の値に 1 を足した 10 この値へ変換される。これらの値を用いて、入力されたデータはどのカテゴリに適しているか推測できる。
25> my $out = mx->sym->SoftmaxOutput($fc2, name => 'softmax')
$res[23] = bless( {
         'handle' => bless( do{\(my $o = '140525748832400')}, 'SymbolHandle' )
       }, 'AI::MXNet::Symbol' )

26> my $mod = mx->mod->Module($out)
$res[24] = bless( {
         '_p' => bless( {
                          '_aux_names' => [],
                          '_context' => [
                                          bless( {
                                                   'device_id' => 0,
                                                   'device_type' => 'cpu'
                                                 }, 'AI::MXNet::Context' )
                                        ],
                          '_data_names' => [
                                             'data'
                                           ],
                          '_fixed_param_names' => [],
                          '_label_names' => [
                                              'softmax_label'
                                            ],
                          '_output_names' => [
                                               'softmax_output'
                                             ],
                          '_param_names' => [
                                              'fc1_weight',
                                              'fc1_bias',
                                              'fc2_weight',
                                              'fc2_bias'
                                            ],
                          '_params_dirty' => 0,
                          '_state_names' => [],
                          '_work_load_list' => [
                                                 1
                                               ]
                        }, 'AI::MXNet::Module::Private' ),
         '_symbol' => bless( {
                               'handle' => bless( do{\(my $o = '140525748832400')}, 'SymbolHandle' )
                             }, 'AI::MXNet::Symbol' ),
         'binded' => 0,
         'context' => $VAR1->{'_p'}{'_context'}[0],
         'for_training' => 0,
         'inputs_need_grad' => 0,
         'logger' => bless( {}, 'AI::MXNet::Logging' ),
         'optimizer_initialized' => 0,
         'params_initialized' => 0
       }, 'AI::MXNet::Module' )

データイテレータの作成

ニューラルネットワークが一度に 1 つのサンプルができない。これは、パフォーマンスの観点からは非常に非効率的であるかららしい。なのでバッチ、すなわち固定数のサンプルを使用する。

バッチをネットワークへ送るために、NDArrayIter という機能を用いる。パラメータにはトレーニングデータ、カテゴリ、バッチサイズを渡す。

ここでは作成したイテレータが本当に正しく実行されるのか確認するため、while で試してみる。終了した後 reset を用いることで元の状態へ戻すことが可能である。

27> my $train_iter = mx->io->NDArrayIter(data => $X_train, label => $Y_train, batch_size => $batch)
$res[25] = bless( {
         '_shuffle' => 0,
         'batch_size' => 10,
         'cursor' => -10,
         'data' => [
                     [
                       'data',
                       bless( {
                                'handle' => bless( do{\(my $o = '140525748810864')}, 'NDArrayHandle' )
                              }, 'AI::MXNet::NDArray' )
                     ]
                   ],
         'data_list' => [
                          $VAR1->{'data'}[0][1],
                          bless( {
                                   'handle' => bless( do{\(my $o = '140525744749424')}, 'NDArrayHandle' )
                                 }, 'AI::MXNet::NDArray' )
                        ],
         'label' => [
                      [
                        'softmax_label',
                        $VAR1->{'data_list'}[1]
                      ]
                    ],
         'label_name' => 'softmax_label',
         'last_batch_handle' => 'pad',
         'num_data' => 800,
         'num_source' => 2
       }, 'AI::MXNet::NDArrayIter' )

28> while (my $batch = $train_iter->next) { say ddf($batch->data); say ddf($batch->label); }
[bless( {'handle' => bless( do{\(my $o = '140525750741360')}, 'NDArrayHandle' ),'writable' => 1}, 'AI::MXNet::NDArray' )]
[bless( {'handle' => bless( do{\(my $o = '140525750646512')}, 'NDArrayHandle' ),'writable' => 1}, 'AI::MXNet::NDArray' )]
[bless( {'handle' => bless( do{\(my $o = '140525750476944')}, 'NDArrayHandle' ),'writable' => 1}, 'AI::MXNet::NDArray' )]
...
[bless( {'handle' => bless( do{\(my $o = '140525751013360')}, 'NDArrayHandle' ),'writable' => 1}, 'AI::MXNet::NDArray' )]
$res[26] = undef

29> $train_iter->reset
$res[27] = -10

これでネットワークの準備が完了。

学習用モデル

実際にデータセット(サンプルやラベル)と入力 symbol との紐付けを行っていく。ここで先ほど作成したイテレータを用いる。

30> $mod->bind(data_shapes => $train_iter->provide_data, label_shapes => $train_iter->provide_label)
$res[28] = undef

次にニューロンの重みを初期化していく。ここはかなり重要なステップらしい。正しい初期化のテクニックを用いることで学習速度が速くなるとのこと。その正しい初期化の方法の一つとして Xavier initializer(発明した人の名前が Xavier Glorot — PDF)がある。

31> # $mod->init_params でも可能だが、やるべきではない。
32> $mod->init_params(initializer => mx->init->Xavier(magnitude => 2))
$res[29] = ''

次に最適化されたパラメータを定義する必要がある:

33> $mod->init_optimizer(optimizer => 'sgd', optimizer_params => {learning_rate => 0.1})
$res[30] = undef

そしてついに学習を行う。ここで epoch 数を 50 とする。つまり完全なデータセットがネットワークを通じて 50 回流れることになる。(10 個のサンプルのバッチに対して)

34> $mod->fit($train_iter, num_epoch => 50)
Already binded, ignoring bind()
Parameters already initialized and force_init=0. init_params call ignored.
optimizer already initialized, ignoring...
Epoch[0] Train-accuracy=0.113750
Epoch[0] Time cost=0.101
Epoch[1] Train-accuracy=0.130000
Epoch[1] Time cost=0.087
...
Epoch[49] Train-accuracy=0.977500
Epoch[49] Time cost=0.095
$res[31] = ''

結果から分かるように、トレーニングの精度は急速に上昇し、50 エポック後に 99+% に達するように思える。ネットワークはトレーニングセットを習得できたようで、いい感じ。

検証モデル

検証用のデータには新しいものを用いる。ここでは学習に用いていないデータの 20% のことを指す。
ここで検証用のデータとラベルを使用してイテレータを作成する。

35> my $pred_iter = mx->io->NDArrayIter(data => $X_valid, label => $Y_valid, batch_size => $batch)
$res[32] = bless( {
         '_shuffle' => 0,
         'batch_size' => 10,
         'cursor' => -10,
         'data' => [
                     [
                       'data',
                       bless( {
                                'handle' => bless( do{\(my $o = '140525749083744')}, 'NDArrayHandle' )
                              }, 'AI::MXNet::NDArray' )
                     ]
                   ],
         'data_list' => [
                          $VAR1->{'data'}[0][1],
                          bless( {
                                   'handle' => bless( do{\(my $o = '140525723673424')}, 'NDArrayHandle' )
                                 }, 'AI::MXNet::NDArray' )
                        ],
         'label' => [
                      [
                        'softmax_label',
                        $VAR1->{'data_list'}[1]
                      ]
                    ],
         'label_name' => 'softmax_label',
         'last_batch_handle' => 'pad',
         'num_data' => 200,
         'num_source' => 2
       }, 'AI::MXNet::NDArrayIter' )

次に iter_predict という機能を用いる。これは予測のラベルと実際のラベルを比較し、スコアを追跡して検証の正確さ、つまりネットワークが検証セット上でどの程度効果があったかを表示する。

my $pred_count = mx->nd->array([$valid_count], dtype => 'float32');
my $total_correct_preds = mx->nd->array([0], dtype => 'float32');

for my $params ($mod->iter_predict($pred_iter)) {
    my ($preds, $i_batch, $batch) = @$params;
    my $label = $batch->label->[0]->astype('int32');
    my $pred_label = $preds->[0]->argmax({axis => 1})->astype('int32');
    my $filtered = $pred_label == $label;
    my $correct_preds = mx->nd->sum($filtered)->astype('float32');
    $total_correct_preds = $total_correct_preds + $correct_preds;
}
my $accuracy = $total_correct_preds / $pred_count;
printf "Validation accuracy: %2.2f\n", $accuracy->aspdl->index(0);

ここで iter_predict が返す値は

  • 3 つの情報が格納された配列リファレンス
  • $i_batch: バッチナンバー
  • $batch: NDArray オブジェクト。これは現在のバッチの情報を持っていて、10 個のデータサンプルのラベルを探すためにこれを利用する。
  • $preds: NDArray オブジェクト。 これは現在のバッチに対して予測したラベルの情報を持っている。即ち 10x10 の行列になっていて 10 個のカテゴリ中、それぞれのカテゴリがどれくらい適しているかの情報が格納されている。そこで argmax を用いることによって行列の中から最もスコアの高い行(ふさわしいカテゴリ)を取得している。それゆえ、$pred_label は 10 個の要素を持った NDArray となる。

そして $label$pred_label を比較して、幾つ正しく予測できているかを知るために sum を用いる。ループが終了した後、ついに正確さを図ることができる。今回はこのようになった。

Validation accuracy: 0.09

9% という結果は非常に悪い結果である。
確かに学習データセットを用意して、学習を行うことが出来たが、今回用いたデータはランダムな数字であったため、意味のある特徴が少なかった。このように意味のある特徴が少ない場合は正確な予測を行うことができないらしい。

以上で記述した全てのコードを若干修正し、まとめたのが以下になる。
参考元のサイトで書かれているコードは全て Python である。息を吸うように numpy と組み合わせて結果もスルッと出していたが、Perl で扱う場合全ての計算を NDArray を用いて行った方がいいことがわかってきた。(PDL との連携がまだ上手くいってない気がする)

use PDL;
use AI::MXNet qw(mx);
use feature 'say';

my $sample_count = 1000;
my $train_count = 800;

my $valid_count = $sample_count - $train_count;
my $feature_count = 100;
my $category_count = 10;
my $batch = 10;

my $X = mx->nd->uniform(0.0, 1.0, [$sample_count, $feature_count]);
my $Y = mx->nd->array([map { int rand $category_count } 0 .. $sample_count - 1], dtype => 'float32');

my $X_train = mx->nd->crop($X, [0,0], [$train_count, $feature_count-1]);
my $X_valid = mx->nd->crop($X, [$train_count,0], [$sample_count,$feature_count-1]);
my $Y_train = mx->nd->crop($Y, [0], [$train_count]);
my $Y_valid = mx->nd->crop($Y, [$train_count], [$sample_count]);

my $data = mx->sym->Variable('data');
my $fc1 = mx->sym->FullyConnected($data, name => 'fc1', num_hidden => 64);
my $relu1 = mx->sym->Activation($fc1, name => 'relu1', act_type => "relu");
my $fc2 = mx->sym->FullyConnected($relu1, name => 'fc2', num_hidden => $category_count);
my $out = mx->sym->SoftmaxOutput($fc2, name => 'softmax');

my $mod = mx->mod->Module($out);
my $train_iter = mx->io->NDArrayIter(data => $X_train, label => $Y_train, batch_size => $batch);
$mod->bind(data_shapes => $train_iter->provide_data, label_shapes => $train_iter->provide_label);
$mod->init_params(initializer => mx->init->Xavier(magnitude => 2));
$mod->init_optimizer(optimizer => 'sgd', optimizer_params => {learning_rate => 0.1});
$mod->fit($train_iter, num_epoch => 50);

my $pred_iter = mx->io->NDArrayIter(data => $X_valid, label => $Y_valid, batch_size => $batch);

my $pred_count = mx->nd->array([$valid_count], dtype => 'float32');
my $total_correct_preds = mx->nd->array([0], dtype => 'float32');

for my $params ($mod->iter_predict($pred_iter)) {
    my ($preds, $i_batch, $batch) = @$params;
    my $label = $batch->label->[0]->astype('int32');
    my $pred_label = $preds->[0]->argmax({axis => 1})->astype('int32');
    my $filtered = $pred_label == $label;
    my $correct_preds = mx->nd->sum($filtered)->astype('float32');
    $total_correct_preds = $total_correct_preds + $correct_preds;
}
my $accuracy = $total_correct_preds / $pred_count;
printf "Validation accuracy: %2.2f\n", $accuracy->aspdl->index(0);