ディープラーニングを Deeplearning4j でカジュアルに始める(その1)

f:id:Naotsugu:20210622222124g:plain


はじめに

本稿では Deeplearning4j を使った、ディープラーニングについて説明します。厳密な定義や数式には立ち入らず、意味合いと利用方法に焦点を当てていきます。

題材として、ディープラーニング界の Hello World である MNIST(Modified National Institute of Standards and Technology database)を使った手書き数字の画像認識を扱います。

ディープラーニングの考え方と、実際の実装をあわせて説明し、手書き数字の画像認識のアプリケーションを作成するまでをガイドします。今回は、全3回の内の 1 回目です。


Deeplearning4j とは

Eclipse Deeplearning4j は、Java によるディープ・ラーニングの計算フレームワークです。

数多くのディープラーニング・アルゴリズムをサポートしており、これらのアルゴリズムは Hadoop や Spark などによる分散並列処理が可能となっています。

github.com


Deeplearning4j によるニューラル・ネットワーク構築の流れ

最初に Deeplearning4j を使ったニューラル・ネットワーク構築の大まかな流れを見ておきましょう。

ニューラル・ネットワークの定義は、ビルダー NeuralNetConfiguration.Builder() により行います。

MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
    .seed(123)
    .updater(new Nesterovs(0.006, 0.9))
    .l2(1e-4)
    .list()
    .layer(new DenseLayer.Builder()
        .nIn(HEIGHT * WIDTH)
        .nOut(1000)
        .activation(Activation.RELU)
        .weightInit(WeightInit.XAVIER)
        .build())
    .layer(new OutputLayer.Builder(
            LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
        .nIn(1000)
        .nOut(10)
        .activation(Activation.SOFTMAX)
        .weightInit(WeightInit.XAVIER)
        .build())
    .build();

後ほど説明しますが、layer() でニューラル・ネットワークの層を定義しています。

各ネットワーク層を layer() にて積み重なることで複数層のニューラル・ネットワークを簡単に定義することができます。


ニューラル・ネットワークの定義が整ったら、以下のようにしてネットワークを構築・初期化します。

MultiLayerNetwork model = new MultiLayerNetwork(conf);
model.init();

DataSetIterator training = // ...
model.fit(training, epochs);

fit() メソッドに訓練データを与えることで、トレーニングデータによる学習を行います。


学習済みのモデルは、テストデータを使って以下のように評価することができます。

DataSetIterator testing = // ...
Evaluation eval = model.evaluate(testing);
System.out.println(eval.stats());

evaluate() で評価結果を入手できます。


学習済みのモデルが整えば、推論処理(forward propagation)を行うことができます。

INDArray input = // ...
INDArray output = model.output(input);

今回の例では、入力となる手書き数字の画像データ input をトレーニング済みのモデルに与え、出力結果 output として推論した数字のデータを得ることができます。

Deeplearning4j の流れを見たところで、ニューラル・ネットワークの概要から話を進めましょう。


ディープラーニングとニューラル・ネットワーク

ディープラーニング(深層学習)とは、ある問題を複数の階層構造として関連させて学習する手法です。このディープラーニングで現在広く普及しているのが、(人工)ニューラル・ネットワークを多層構成とした機械学習になります。ニューラル(神経系)という言葉は、生物の神経系の構造を単純化したモデルを使うことからきています。

生物の神経系は、神経細胞同士のネットワークにより構成されており、この神経細胞をニューロンと言います。ニューロンは、シナプスを介して入力刺激が入ってきた場合に活動電位を発生させ、他の細胞に情報を伝達するという性質を持ちます。一つの神経細胞に複数の細胞から入力したり、活動電位がおきる閾値を変化させたりすることによって情報の修飾が行われます。人間の脳全体では 1,000 億個にのぼるニューロン同士がシナプスでつながり合うことで複雑なネットワークを形成しています。

ニューロンのネットワーク構造を模倣する人工ニューラル・ネットワークでは、神経細胞であるニューロンを以下のようにモデル化して考えます。

f:id:Naotsugu:20210622220759p:plain

丸印が一つのニューロンを表し、隣接するニューロン同士が矢印で連結されています。中央のニューロンには、左のニューロンから接続があり、前段の出力に対して重み係数 w で重み付けされたものが入力となります。神経細胞の情報伝達は、シナプス間の神経伝達物質の放出と受容により行われますが、この伝達されやすさを重み係数として表します。重み係数が大きければより強く情報が伝達され、重み係数が小さければ情報の伝達はあまり行われないということになります。

神経細胞では、入力信号を受け取り、活動電位の閾値を超えた場合に、後段のニューロンに信号が伝達されます。この閾値に相当するものが 、バイアス値 b であり、ニューロンの発火のしやすさを表現しています。

ニューロンには、前段のニューロンの出力に対して重み付けされた信号と、バイアス値が入力信号として入ってきます。そして、これらの入力信号を合計値が活性関数(Activation function) φ を通して変換され、後続のニューロンに繋がっていきます。活性関数は、入力値がある値を超えたら一定の出力を行ったり、入力信号の強さに応じて出力の強さも変化するといった、色々な関数が考えられ、これがニューロンの特性を表すものとなります。

ニューロンは、入力信号に重み係数で重み付けし、バイアス値を考えつつ活性関数により出力信号を変化させるというようにモデル化されました。ここで、バイアス値についてもう少し考えてみましょう。発火のしやすさを表すバイアスは、固定値(例えば 1)が重み係数で重み付けされた入力信号の一つとして考えても差し支えなさそうです。このように考えることで、多数のニューロンに対する計算を、行列演算として簡素に扱うことができるため、バイアスは入力信号の一つとして扱われます。

ニューラル・ネットワークは、モデル化されたニューロンを多数層状に配備し、それぞれを連結させることにより構成されます。ニューロンは単純な構造ですが、これらを組み合わせて3層(入力層・中間層・出力層)以上のニューラル・ネットワークを構成することで、連続な任意関数を近似できることが証明されています。これは普遍性定理と呼び、ニューラル・ネットワークはある種の普遍性を有していることを表しています。もし理数系の方であれば、フーリエ級数展開により、どのような周期関数も三角関数の和で表現できることを思い出せば、なんとなく納得できるかと思います。理数系でない場合は、単純な動作しかしないトランジスタを組み合わせることでコンピュータが構成されていることを考えると良いかもしれません。


活性関数(Activation function)

ニューロンは、前層の出力に重み付けされ、足し合わされ、活性関数により後段のニューロンに信号を伝達します。この活性関数は問題に応じて様々な関数が提案されています。

いくつか例を見てみましょう。

単純な例として以下のようなステップ関数が考えられます。

f:id:Naotsugu:20210623205122p:plain

入力値が閾値である 0 を超えた時点で、一定の出力を生成する関数となっており、ニューロンは ON/OFF の 2 値の切り替えスイッチとして働くことになります。


ReLU(Rectified Linear Unit)は、よく使われる活性関数で、以下のようなものです。

f:id:Naotsugu:20210622221007p:plain

入力値が 0 を超えた時点で、入力信号の強さに応じた出力を生成する関数です。


線形的なものだけでなく、なだらかな曲線となる非線形な関数として、シグモイド関数と呼ばれる以下のようなもあります。

f:id:Naotsugu:20210622221048p:plain

これはステップ関数の ON/OFF の変化を、なだらかな変化量を示すようにした関数と考えることができます。活性関数は、これ以外にも各種の関数を考えることができ、問題に応じて適切なものを選択して利用することになります。


ニューラル・ネットワークのパラメータ

ここまでで、ニューラル・ネットワークの構成を見てきましたが、一つ大きな問題が残っています。それは、ニューラル・ネットワークに内在するパラメータをどのように決定すれば良いかという問題です。つまり、先程説明したニューロン間の信号伝達時の重み係数(パラメータ)をどのように獲得すればよいのか という事になります。通常、パラメータの数は膨大になるため、組み合わせは天文学的数字になり、総当りで検証するなどの手段は現実的に不可能です。そこで、如何に適切なパラメータを現実的な時間内で見つけ出すかがニューラル・ネットワークを扱う上での一番の関心事になります。

ニューラル・ネットワークでは、このパラメータを学習により決定していきます。ある学習データを与え、ニューラル・ネットワークからの出力を損失関数(loss function)とよばれる評価指標で評価し、この損失関数の値が小さくなるようにパラメータの値を機械的に調整(機械学習)していくことになります。

手書き数字の画像認識を例に考えてみましょう。ある画像を入力として、出力層の値が一番大きくなったものを、予測した推論値と考えます。これは、入力画像データを、10個のクラスのいずれかに分類する分類問題と考えることができます。この様子を図示したものが以下となります。

f:id:Naotsugu:20210622221144p:plain

中央の雲形が、何らかの変換を行う関数であり、今回はこの関数をニューラル・ネットワークで構成します。ニューラル・ネットワークでは、適切なパラメータを選択すれば任意の関数を近似できるため、パラメータをどのように最適化すれば良いのかを考えることが問題の焦点となります。

通常、分類問題に対しては教師あり学習(supervised learning)を行うことが一般的です。教師あり学習では、実際の手書き数字の画像と、その正解をセットにして多数用意し、ニューラル・ネットワークで処理した結果が正解に近づくようにパラメータを変更していきます。パラメータの変更は、最適解により早く近づくように、微分に基づいたパラメータ更新が使われます。

損失関数とパラメータ更新については後で述べるとして、入力となる学習データについて、ここで見ておきましょう。


MNIST画像データ

MNIST は機械学習の分野で最も有名なデータセットのひとつです(最近は、より画像数を増やしたQMNISTが使われることも多いです)。0 から 9 までの数字画像が、訓練画像としで 60,000 枚、テスト画像としで 10,000 枚用意されています。

f:id:Naotsugu:20210622221226p:plain

画像データは 28 × 28 のグレースケール(1 チャンネル)で、各ピクセルは 0 から 255 までの値を取ります。この画像をニューラル・ネットワークの入力データにするため、画像のピクセルを一つづつ取り出した 784(28 × 28) 個の数値データの並びとして扱うことになります。


Deeplearning4j では、入力データの準備を以下のように実装することができます。

var imageLoader = new NativeImageLoader(28, 28);
var imageScaler = new ImagePreProcessingScaler(0, 1);

INDArray img = imageLoader.asRowVector(file);
imageScaler.transform(img);

NativeImageLoader.asRowVector により画像データを行列形式で読み込んでいます。

INDArray は N-dimensional array です。つまり多次元配列を表します(I は Interface を意味)。

ニューラル・ネットワークでは行列演算を多様するため、Deeplearning4j では ND4J という行列演算ライブラリを内包しており、INDArray はこのライブラリが提供するものです。この例では、1 × 748 の一次元の行列データとして生成されます。

続くImagePreProcessingScaler.transform() は、画像データのスケールを調整する処理です。今回、各ピクセルは 0 から 255 までの値を取りますが、これを0から1の float 値に正規化します。あるピクセルが白であれば、値は 255 であり、スケール調整の結果 1.0 に変換されます。同様に黒の場合は 0.0 に変換されます。このように生成された1行のデータがニューラル・ネットワークへの1つの入力となります。

f:id:Naotsugu:20210622221303p:plain

画像データを Deeplearning4j で処理するには、作成した画像データを学習データ分の2次元行列の形にする必要があります。これは以下のような実装になります。

INDArray img = //...
final INDArray in  = Nd4j.create(60_000, 28 * 28);
in.putRow(n, img);

ここでは、学習データ60,000個分の行と、1つの画像データのピクセル分748(28 × 28)の列を持つ2次元配列を準備しています。先ほど、ニューラル・ネットワークでは行列演算を多様すると説明しました。行列形式にすることで、ニューラルネットワークを介した計算を束ねて一度に行うことができるようになります。もちろん1つの画像データを1つずつ処理することもできますが、行列計算ライブラリでは、行列計算処理の最適化や、大規模な分散処理が可能となっているため、行列として束ねた形で一度に扱うことで処理時間の短縮が期待できます。

f:id:Naotsugu:20210622221337p:plain


訓練用データセット

訓練用の入力画像の処理方法をみてきましたが、ニューラル・ネットワークの出力結果との比較はどのように行えば良いでしょうか。Deeplearning4j では、org.nd4j.linalg.dataset.DataSet という形で、入力データと正解データのペアを用意して訓練を行います。

今回の場合は、0 から 9 までの10個のいずれかに分類するため、10個のうちの正解部分を 1 に、それ以外を 0 とするデータを出力の正解データとして準備します。このような、正解ラベルとなるインデックスだけが 1 で、その他は 0 であるものを one-hot 表現 と呼びます。

MNIST の場合、それぞれの画像データが数字のファオルダにまとまっているので、フォルダ名に該当する列に 1 を設定すれば良いことになります。

f:id:Naotsugu:20210622221415p:plain

図中の一番上の行が、2の画像を入力とした場合の正解位置としてインデックス2となる位置に 1 (赤色)を設定しています。訓練データは 60,000 あるため、入力画像に合わせた正解データを60,000行のデータとして用意します。

それぞれの画像に対して以下のようにして教師データを作成することができます。

final INDArray out = Nd4j.create(60_000, 10);
int label = Integer.parseInt(file.toPath().getParent().getFileName().toString());
out.put(n, label, 1.0);

作成した入力データと教師データをあわせて、以下のようにデータセット DataSet を作成します。

final INDArray in  = // ...
final INDArray out = // ... 

List<DataSet> list = new DataSet(in, out).asList();
Collections.shuffle(list, new Random(System.currentTimeMillis()));

集めたデータセットは、学習過程でランダムに選択されるようシャッフルしておきます。

以上で、データセット DataSet は画像データと、分類の答えとなる正解データのペアが格納された訓練用のデータセットが作成できました。続いて、作成したデータセットを与えるニューラル・ネットワークの定義についてみて行きましょう。


ニューラル・ネットワークの定義

冒頭でみた Deeplearning4j におけるネットワーク定義をふたたび見てみましょう。

MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
    // ...
    .layer(new DenseLayer.Builder()
        .nIn(HEIGHT * WIDTH)
        .nOut(1000)
        .activation(Activation.RELU)
        .weightInit(WeightInit.XAVIER)
        .build())
    .layer(new OutputLayer.Builder(
            LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
        .nIn(1000)
        .nOut(10)
        .activation(Activation.SOFTMAX)
        .weightInit(WeightInit.XAVIER)
        .build())
    .build();

このネットワークの定義では、2つの層が定義されており、DenseLayer.Builder()OutputLayer.Builder() というビルダーで定義されています。これが、先程までに説明したニューラル・ネットワークの層の定義となります。一つずつ見ていきましょう。

まずは、DenseLayer の定義です。

new DenseLayer.Builder()
    .nIn(HEIGHT * WIDTH)
    .nOut(1000)
    .activation(Activation.RELU)
    .weightInit(WeightInit.XAVIER)
    .build()

DenseLayer とは「全結合層」を意味します。これは、層内の全てのニューロンが次の層の全ニューロンと接続する形態です。この層の入力は、先ほど準備した入力画像のピクセル分748(28 × 28)が入力となり nIn(HEIGHT * WIDTH) として定義しています。層の出力は nOut(1000) で、1,000 としています。 1,000個のニューロンからの出力が次層の入力となります。

activation(Activation.RELU) では、活性関数(Activation function) を定義しています。RELU

ReLU(Rectified Linear Unit) で、これは前半部分で既に見た活性関数です。

weightInit(WeightInit.XAVIER) ではパラメータ(つまり重み)の初期分布を定義します。ニューラル・ネットワークでは、ある初期値の重みを与え、損失関数による誤差が小さくなるよう、少しづつ重みを更新していきます。この重みの初期値を適切に与えることで学習効率が上がります。逆に言えば、初期値として不適切な重みを与えてしまうと適切な学習が行えません。WeightInit.XAVIER は「Xavier の初期値」と呼ばれる、正規分布に応じた確率分布の初期値を生成します。この正規分布は、前層のノードの数を n とした場合、1/√n の標準偏差を持つ分布を使います。前層のノードが多い場合には、初期値の値は、よりゼロに近い値に集中することになります(これについては、後ほど詳しくみていきます)。


次が、もう一つの層 OutputLayer でこれは出力層になります。

new OutputLayer.Builder(
        LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
    .nIn(1000)
    .nOut(10)
    .activation(Activation.SOFTMAX)
    .weightInit(WeightInit.XAVIER)
    .build()

前層の出力が 1,000 となっているため、この層の入力は nIn(1000) となります。そして、出力は nOut(10) と10個であり、手書き数字の分類結果として 0 から 9 のいずれかの推定結果を表現します。

activation(Activation.SOFTMAX) では、活性関数として softmax 活性関数を利用するよう定義しています。そして、トレーニング時の結果の良否を判定する損失関数には LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD として negative log-likelihood(NLL) 「負の対数尤度」を指定しています。

softmax 活性関数は分類問題の出力層でよく使われる関数で、10個の出力の合計値が1となるように(指数対数的に)正規化する関数です。全ての出力の合計が1となるため、入力画像が、特定のクラスに属する確率として解釈することができます。このような確率として解釈することで、80%の確率で数字の5と推定する といった具合に結果の解釈がやりやすくなります。この値を負の対数尤度を損失関数として評価します。

では、ここで出た softmax 活性関数負の対数尤度 について少し詳しくみてみましょう。


softmax関数

softmax 関数が行うことを直感的に考えると、各値の合計が1になるように、サイズのベクトルを押しつぶすこと と言えます。x を入力として、指数関数 y=e^x により変換し、変換後の y が全体に対してどの程度の割合になるかを算出することが softmax 関数の行うことです。

指数関数 y=e^x をグラフにすると以下のようになります。e はネイピア数で、2.71828… と無限に続く定数です。自然界で良く出る定数です。意味合いは異なりますが、円周率みたいなものと考えておけば良いと思います。ネイピア数を底とする指数関数 e^x は、微分しても変わらず e^x であるという重要な性質があり、微分を扱う科学技術計算で多様されます。

f:id:Naotsugu:20210731123509p:plain

softmax 関数 への入力が、例えば -2.0, 1.0, 3.0 という3つの値を例に考えてみましょう。これら3つの値を指数関数 y=e^x で変換すると、0.135, 2.718, 20.08 となります。前述のグラフで見ると、横軸の x がそれぞれ -2.0, 1.0, 4.0 であった場合の縦軸 y の値 0.135, 2.718, 20.08 を得ることになります。変換後の値で特徴的なのは、xが負数であっても、y は必ず正数となる点です。そしてxが大きくなれば、y は指数的に大きくなるということです。

次に、変換した 0.135, 2.718, 20.08 という値を合計が 1 になるように正規化することを考えます。全ての数を合計すれば、誤差を気にしなければ 22.93 となります。この合計値でそれぞれの値を割ってあげれば、0.135 ÷ 22.93 = 0.006 , 2.718 ÷ 22.93 = 0.118 , 20.08 ÷ 22.93 = 0.875 のようになります。全ての値は非負であり、総和は 1.0 となります。最後の値0.875が一番大きく、87% で確からしそうだと解釈できることになります。これが softmax 関数が行うことです。

グラフと合わせて見ると、値(x)がゼロ付近に固まっていた場合は、変換後の値にも大きな偏りは発生せず、一つでも大きな値があれば、その値が(指数関数的に)大きく扱われることが見て取れます。このような softmax関数は、今回のような複数クラスの学習問題でよく使われる関数です。実際には、softmax 関数は、損失関数として negative log-likelihood(NLL) 「負の対数尤度」と組み合わせて使用されます。


負の対数尤度

negative log-likelihood(NLL) 「負の対数尤度」は、softmax 関数と合わせて損失関数として使用されます。尤度 は「ゆうど」と読み、値の尤もらしさを意味するものになります。名前は仰々しいものですが、やっていることは単純に、softmax 関数 の結果に対して自然対数を計算し、-1 を乗じるだけです。

自然対数をグラフにすれば以下のようになります。softmax 関数の出力は、先ほど見た通り、0から1の範囲に収まります。グラフ上のx軸が 0 から 1 の範囲に該当する y軸 の値は全て負数とっています。これに -1 (negative)を乗じることで正数に変換する。これが「負の対数尤度」の計算になります(実際には softmax関数のそれぞれの出力について負の対数尤度を計算し、正解ラベルを乗じて合計しますが、今回のような分類問題では、正解ラベルが one-hot 表現(正解が1で、それ以外が0)となっているため、正解のクラスの値だけが考慮されます)。

f:id:Naotsugu:20210731123616p:plain

softmax 関数の結果が 1.0(x軸の1.0)に近ければ、y軸は0付近となり、softmax 関数の結果が小さくなれば(x軸が0に近づけば)、y軸の値は大きな値となり、グラフの傾きも大きくなります。

softmax 関数の結果から負の対数尤度を計算し、この値を小さくする方向にパラメータを更新していけば、適切なパラメータを得ることができます。そして、傾きが大きければ望む値からは離れていて、傾きが小さくなれば望む値に近づいていると判断することもできます。負の対数尤度を、望む結果からのズレを表す損失関数として使い、この損失関数の結果が小さくなる(望む結果からのズレが小さくなる)ようなパラメータを得るわけです。対数を使うことで、乗算を和として扱うことができ、科学技術計算で扱いやすい点も負の対数尤度を使う理由です。

さて、ここでの説明は、1つの画像データに対する1つの結果を評価するという形で進めてきましたが、実際には、ある程度まとまった束(バッチサイズ)に対して結果を得て、その平均値を(統計的に)評価する形を取ります。Deeplearning4j では、データセットに対して以下のようにバッチサイズを指定します。

int batchSize = 10;
DataSetIterator dataSetIterator = new ListDataSetIterator<>(list, batchSize);



次回に続きます。

blog1.mammb.com