シリーズで、NNablaのPython-like C++APIをご紹介したいと思います。
第三回目は、LeNetのサンプルを元にして、C++APIの基本的な使い方をご紹介いたします。
ここでは、まず、深層学習による分類モデル用ニューラルネットワークの代表例であるLeNetを説明します。
LeNetは、畳み込みニューラルネットワーク(CNN)を提唱したYann LeCunらによって1998年に提案された手書き文書認識用の識別モデルです。
以下はその論文になります。
GradientBased Learning Applied to Document Recognition, Yann LeCun, etal.
http://vision.stanford.edu/cs598_spring07/papers/Lecun98.pdf
LeNetは、画像の入力層から二層の畳み込み層と二層の全結合層、および、これらの層の間を結ぶ非線形層を介して、クラス別確率ベクトルの出力層へと接続されるフィードフォワードネットワークです。
入力層から最初の二層の畳み込み(コンボリューション)層にかけてのネットワークでは、画像から画像の特徴を抽出するのに適したフィルタが学習されます。
続く二層の全結合層では、オブジェクトクラスの確率(その前段のロジット)を算出するための画像特徴の組合せ方が学習されます。
LeNetの実行ファイルを作るプログラムは、以下の3つのソースコードファイルで構成されます。
以下では、これらの3つのソースコードファイルを順に説明します。
メインプログラム:train_lenet_classifier.cpp
このソースコードファイルでは、実行ファイルが用いる計算環境の指定と、学習、実行プログラム本体の呼び出しを行っています。
大まかな構成を以下に紹介します。
- NNablaの計算環境を記述するためのヘッダを追加する。
#include <nbla/context.hpp>
- 計算環境(コンテキスト)の指定を行う。
以下は、CPUの計算環境のもとで、変数をfloat型で計算するコンテキストを指定するための変数です。
nbla::Context ctx{{"cpu:float"}, "CpuCachedArray", "0"};
- 計算環境(ctx)を入力して、トレーニング本体の関数を呼び出す。
mnist_training(ctx);
Lenet学習プログラム:lenet_training.hpp
このソースコードファイルでは、LeNetによる分類モデルの記述と、モデルのビルド、学習、評価の記述を行っています。
大まかな構成を以下に紹介します。
1. NNablaヘッダの追加とエリアシング
ここでは、NNablaの計算グラフ、計算環境、レイヤ関数、ソルバのヘッダを追加し、さらに、名前スペースの別名付け(alias)を行います。
以下は、その記述を抜粋したコードです。
#include <nbla/computation_graph/computation_graph.hpp>
#include <nbla/solver/adam.hpp>
using namespace nbla;
using std::make_shared;
#include <nbla/functions.hpp>
#include <nbla/global_context.hpp>
#include <nbla/parametric_functions.hpp>
namespace f = nbla::functions;
namespace pf = nbla::parametric_functions;
Python-like C++APIでは、学習パラメータなしレイヤ関数をf、学習パラメータありレイヤ関数をpfに別名付け(alias)します。
このように別名付け(alias)を設定することで、PythonAPIを使って、パラメータなしレイヤ関数をF、パラメータありレイヤ関数をPFで記述している場合と比べて、そっくりの記述を可能にしています。
2. LeNetモデルの記述
LeNetは、二層の学習可能な畳み込み(コンボリューション)層(pf::convolution)と二層の学習可能な全結合層(pf::affine)で構成されます。
以下は、LeNetのネットワーク構成を記述した部分です。
CgVariablePrt model(CgVariablePtr x, ParameterDirectory parameters){
auto h = pf::convolution(x, 1, 16, {5, 5}, parameters["conv1"]);
h = f::max_pooling(h, {2, 2}, {2, 2}, true, {0, 0}); h = f::relu(h, false);
h = pf::convolution(h, 1, 16, {5, 5}, parameters["conv2"]);
h = f::max_pooling(h, {2, 2}, {2, 2}, true, {0, 0});
h = f::relu(h, false);
h = pf::affine(h, 1, 50, parameters["affine3"]);
h = f::relu(h, false);
h = pf::affine(h, 1, 10, parameters["affine4"]);
return h;
}
ネットワークの入出力
ネットワークの構成を使い回せるように関数化しています。
関数化せず直書きしても動作します。
関数名は、modelとしています。
関数の入力xはCgVariablePtr型で、出力hもCgVariablePtr型です。
CgVariablePtr型は、深層学習に必要となる値や勾配値を多次元配列で保持できるNNablaのクラスです。
入力xは、バッチサイズ、MNISTデータセットのグレースケール、28×28ピクセルの多次元配列です。
出力hは、10クラス分類のロジット(ソフトマックス関数で確率にする前の値)ベクトルになります。
関数
Python-like C++APIでは、PythonAPIと同じように、学習パラメータなし層をf、学習パラメータあり層をpfで別名付けしています。
各レイヤ関数は、入力と出力の間の機能を表し、一行で記述できます。
なお、PythonAPIとなり、パラメータあり層(pf::convolutionやpf::affine)では、引数axisの指定が必要です。
この引数は、入力(xやh)の次に来る二番目の引数です。
この引数は、多次元配列中の配列サイズ(Shape_t)を表す次元のうち、バッチサイズの指定が終わり、変数サイズが始まる最初の次元です。
バッチサイズの次元は通常1次元なので、大抵の場合、この値は1(0からの通し番号で)になります。
バッチサイズの次元が一次元ではない(グループ単位や系列になっている)場合、バッチサイズの次元にあわせて、引数の値を変更します。
学習パラメータ
学習パラメータは、parametersの引数名で宣言されたパラメータ保持クラス(ParameterDirectoryクラス)で管理されます。
この変数は、階層的に変数名を管理しています。
このparametersは、パラメータ名のルートとなる名前が階層的に保持されています。
ここで、parameters[“パラメータ名”]としてルート以下のパラメータ名を指定すると、パラメータ名が”ルート/パラメータ名”であるパラメータが呼び出されたり、あるいは、新たに追加されたりします。
関数の引数
コンボリューション層は、Python-like C++APIでは、入力xのほかに、少なくともカーネルサイズ(引数名kernel)を指定する必要があります。
カーネルサイズはベクター初期化を用いて{5, 5}のように記述します。
コンボリューションには様々なバリエーションが提唱されています。
これらのバリエーションを使い分けるためには、カーネルサイズ(kernel)のほかにも、パッディングサイズ(pad)やストライド幅(stride)、ダイレーション幅(dilation)、グループ数(group)などを引数として指定できることが求められます。
しかし、これらの引数の値は、多くの場合、pad={0,0}、stride={1,1}、dilation={1,1}、group=1を用います。
Python-like C++APIでは、これら値をデフォルト値としているため、省略することもできます。
一方、マックスプーリング層は、カーネルサイズ(kernel)のほかに、パッディングサイズ(pad)やストライド幅(stride)も指定する必要があります。
他のプーリングを用いる場合でも同様です。
3. 学習、評価用ネットワークのビルド
学習用ネットワークは、前述のニューラルネットワークにロス関数を接続したネットワークです。
また、評価用ネットワークは、ここでは、エラーレートを算出するレイヤを接続したネットワークとしています。
以下に、学習、評価用のネットワークをビルドするコードを抜粋しました。
SingletonManager::get()->set_current_context(ctx);
ParameterDirectory params;
int batch_size = 128;
auto x = make_shared(Shape_t({batch_size, 1, 28, 28}), false);
auto t = make_shared(Shape_t({batch_size, 1}), false);
auto h = model(x, params);
auto loss = f::mean(f::softmax_cross_entropy(h, t, 1), {0, 1}, false);
auto err = f::mean(f::top_n_error(h, t, 1, 1), {0, 1}, false);
計算環境を指定
計算環境は、グローバルなコンテキスト(GlobalContext)に、計算を行いたい環境を表すコンテキスト(ctx)を、引き渡すことで指定します。
このプログラムでは、計算環境のコンテキスト(ctx)は、メインプログラム(train_lenet_classifier.cpp)で定義しています。
SingletonManagerは、指定した変数をアプリケーションでは一つしか扱わないようにするクラスです。
グローバルなコンテキストがプログラム上で複数定義されないようにする役割を担っています。
パラメータ保持クラスを宣言
学習パラメータありのモデルを扱う場合は、学習パラメータを保持するクラス(ParameterDirectory)を宣言します。
学習、評価ネットワークへの入力の定義
このサンプルプログラムでは、学習、評価ネットワークへの入力変数として、説明変数x、目的変数tの2つがあります。
これらの入力変数は、CgVariablePtrクラスでインスタンスを作ります。
インスタンスは、make_shared<CgVariable>関数を用いて作ります。
この関数では、インスタンスを作る際に、配列サイズをクラスShape_tを用いて指定します。
説明変数xは、バッチサイズ(batch_size)×イメージサイズの多次元配列です。
イメージサイズは、MNISTデータのイメージサイズに合わせて(1, 28, 28)としています。
一方、目的変数tは、バッチサイズ(batch_size)×1の多次元配列です。
ここでは、目的変数は、0から始まる整数値で表されたクラスラベルを想定しています。
説明変数から予測変数を算出
前述のモデルをビルドする関数(model)に説明変数xを入力して、クラス確率を予測するソフトマックス関数に入力するロジットベクトルhを出力します。
予測変数と目的変数を比較する評価関数を定義
分類問題の場合、学習用ネットワークの場合は平均損失、評価用ネットワークでは平均誤分類率を評価することが多くあります。
学習用ネットワークでは平均損失を、ロジットベクトルと、目的変数(ラベル)を、ソフトマックスクロスエントロピー関数に接続することで算出できるようにします。
また、評価用ネットワークでは平均誤分類率を、ロジットベクトルと、目的変数(ラベル)を、トップnエラー関数に接続することで算出できるようにします。
このプログラムでは、平均損失をloss、平均誤分類率をerrの変数名でCgVariablePtrクラスに保持できるように記述しました。
4. ソルバーの設定
ソルバーは、学習パラメータの更新時に利用するもので、ソルバーの種類と、設定パラメータを指定してインスタンスを作り、学習パラメータをセットして使用します。
以下は、ソルバーとしてADAMを利用する場合の例になります。
auto adam = create_AdamSolver(ctx, 0.001, 0.9, 0.999, 1.0e-8);
adam->set_parameters(params.get_parameters());
5. 学習、評価データ供給の設定
Python-like C++APIでは、まだ、学習、評価データ供給のための汎用のAPIは用意していません。
これは、C++はPythonと比べて、学習したいデータのドメインやフォーマットのライブラリの準備が整備されていないからです。
このサンプルプログラムのデータ供給に関する技術も、MNISTデータセットに特化した記述を行っています。
しかし、処理の流れなどは、ドメインやフォーマットによらず共通なので、参考になれば幸いです。
MNISTデータセットでの学習、評価データ供給は、mnist_data.hppのファイルに記述しました。
学習本体(lenet_training.hpp)では、このファイルに記載されたデータ供給のためのクラスのインスタンスを以下のように記述しています。
#include "mnist_data.hpp"
MnistDataIterator train_data_provider("train");
MnistDataIterator test_data_provider("test");
6. 学習ループ
学習ループは、ミニバッチをサンプリングしてニューラルネットの入力変数にコピーするパートと、与えられたミニバッチのもとで学習(パラメータ更新)、評価するパートで構成されています。
6-1. ミニバッチ
このサンプルプログラムでは、ミニバッチをサンプリングして、入力変数にコピーするパートは記述されていません。
実際の記述は、mnist_data.hppの中にあります。
このプログラムには、MNISTデータの解凍読み出しから、ミニバッチの生成、シャッフル、入力変数へのコピーなどが記述されています。
このうち、入力変数へのコピーは、入力変数のポインタの読み出しと、ポインタが指す実体へのミニバッチデータのコピーで構成されます。
以下は、説明変数x、入力変数tのそれぞれのポインタの読み出し部分の抜粋です。
float_t *x_d = x->variable()->cast_data_and_get_pointer(cpu_ctx, true);
uint8_t *t_d = t->variable()->cast_data_and_get_pointer(cpu_ctx, true);
ここで、呼び出し関数の第一引数cpu_ctxは、計算環境をCPUにしたコンテキストです。
このプログラムでは、cpu_ctxとctxは同一のものでも動作します。
第二引数は、呼び出したポインタを、write_onlyで用いるかどうかを指定するフラグです。
ここでは、trueに設定します。
このパートを記述するには、学習ループの中で、ポインタをその都度呼び出す上述の記述が必要です。
つまり、ポインタをどこかにおいて使い回さないようにしたいと考えています。
ここは、最もバグが生じやすかった箇所であり、今後、改善していく予定です。
6-2. 学習
ミニバッチの学習は、パラメータ初期化、順方向、逆方向、パラメータ更新の処理でなされます。
この手順は、Python-like C++APIとPythonAPIでほぼ同じです。
異なる点は、逆伝搬の前に、lossの勾配値(逆伝搬の先頭における勾配値)をすべて1に初期化する処理が必要な点です。
adam->zero_grad();
loss->forward(/*clear_buffer=*/false, /*clear_no_need_grad=*/false);
loss->variable()->grad()->fill(1);
loss->backward(/*NdArrayPtr grad =*/nullptr, /*bool clear_buffer = */false);
adam->update();
6-3. モニタ
モニタでは、学習時の平均損失、テスト評価時の平均誤り率をモニタすることが多くあります。
平均損失lossや誤分類率errの値をモニタするには、これらの変数からポインタを呼び出します。
float_t *t_loss_d = loss->variable()->cast_data_and_get_pointer(cpu_ctx, false);
float_t *v_err_d = err->variable()->cast_data_and_get_pointer(cpu_ctx, false);
ポインタの呼び出しは、ミニバッチのデータを入力変数にコピーする場合と同じ関数で行います。
呼び出し関数の第一引数は、cpu_ctxを用います。
第二引数のwrite_onlyフラグはfalseと指定します。