TensorFlow.js : チュートリアル : MNIST – 畳み込みニューラルネットで手書き数字認識 (翻訳/解説)
作成 : (株)クラスキャット セールスインフォメーション
日時 : 04/04/2018
* 本ページは、TensorFlow.js サイトの Tutorials – Training on Images: Recognizing Handwritten Digits with a Convolutional Neural Network を翻訳した上で適宜、補足説明したものです:
* サンプルコードの動作確認はしておりますが、適宜、追加改変しています。
* ご自由にリンクを張って頂いてかまいませんが、sales-info@classcat.com までご一報いただけると嬉しいです。
このチュートリアルでは、手書き数字を畳み込みニューラルネットワークで分類するために TensorFlow.js モデルを構築します。最初に、分類器をそれに何千もの手書き数字画像とそれらのラベルを「見させる」ことによって訓練します。それからそのモデルが決して見ていないテストデータを使用して分類器の精度を評価します。
データ
このチュートリアルのためには MNIST 手書きデータセット を使用します。私達が分類するために学習する手書き MNIST 数字はこのように見えます :
![]() |
![]() |
![]() |
データを処理するために、data.js を書きました、これは便宜のために私達が提供する MNIST データセットのホストされたバージョンから MNIST 画像のランダム・バッチを取得するクラス MnistData を含んでいます。
MnistData はデータセット全体を訓練データとテストデータに分割します。モデルを訓練するとき、分類器は訓練セットだけを見るでしょう。モデルを評価するとき、モデルの予測が真新しいデータにどのくらい上手く一般化されるかを見るために、モデルがまだ見ていない、テストセットのデータだけを使用します。
MnistData は2つの public メソッドを持ちます :
- nextTrainBatch(batchSize): 訓練セットから画像とそれらのラベルのランダム・バッチを返します。
- nextTestBatch(batchSize): テストセットから画像とそれらのラベルのバッチを返します。
NOTE : MNIST 分類器を訓練するとき、データをランダムにシャッフルすることは重要です、その結果モデルの予測はそれに画像を供給する順序の影響を受けません。例えば、この訓練段階の間にもしモデルに総ての数字 1 を最初に供給したならば、モデルは単純に 1 を予測するように学習するかもしれません (これは損失を最小化するからです)。そしてモデルに 2 だけを供給した場合には、それは単純に 2 だけを予測するように切り替えて決して 1 を予測しないかもしれません (何故ならば、再度、これは画像の新しいセットのために損失を最小化するでしょうから)。モデルは、数字の代表的なサンプルに渡り正確な予測を行なうような学習は決してしないでしょう。
モデルを構築する
このセクションでは、畳み込み画像分類器モデルを構築します。それを行なうために、Sequential モデル (モデルの最も単純なタイプ) を使用します、そこでは tensor は一つの層から次へと連続的に渡されます。
最初に、私達の Sequential モデルを tf.sequential でインスタンス化しましょう :
const model = tf.sequential();
モデルを作成した今、それに層を追加しましょう。
最初の層を追加する
私達が追加する最初の層は 2-次元畳み込み層です。畳み込みは空間的に不変な変換 (つまり、画像の異なる部分のパターンやオブジェクトが同じように扱われます) を学習するためにフィルタ・ウィンドウを画像に渡りスライドします 。畳み込みについての更なる情報については、この記事 を見てください。
tf.layers.conv2d を使用して 2-D 畳み込み層を作成することができます、これは層の構造を定義する configuration オブジェクトを受け取ります :
model.add(tf.layers.conv2d({ inputShape: [28, 28, 1], kernelSize: 5, filters: 8, strides: 1, activation: 'relu', kernelInitializer: 'VarianceScaling' }));
configuration オブジェクトの各引数を分解しましょう :
- inputShape. モデルの最初の層に流れ込むデータの shape です。この場合、MNIST サンプルは 28×28-ピクセルの白黒画像です。画像データについての典型的なフォーマットは [row, column, depth] ですので、ここでは [28, 28, 1] の shape を構成することを望みます — 各次元についてのピクセル数のための 28 行と列、そして私達の画像は 1 カラーチャネルだけを持ちますから 1 depth です。
- kernelSize. 入力データに適用されるスライディング畳み込みフィルタ・ウィンドウのサイズです。ここでは、5 の kernelSize を設定します、これは正方形の、5×5 畳み込みウィンドウを指定します。
- filters. 入力データに適用するサイズ kernelSize のフィルタ・ウィンドウの数。ここでは、データに 8 フィルタを適用します。
- strides. スライディング・ウィンドウの「ステップ・サイズ」です — i.e. フィルタが画像に渡り移動するたびに幾つのピクセルをシフトするかです。ここでは、1 のストライドを指定します、これはフィルタが画像に渡り 1 ピクセルのステップでスライドすることを意味します。
- activation. 畳み込みが完了した後でデータに適用される 活性化関数 です。この場合、Rectified Linear Unit (ReLU) 関数を適用しています、これは ML モデルにおいて非常に一般的な活性化関数です。
- kernelInitializer. モデル重みをランダムに初期化するために使用されるメソッドで、これは訓練ダイナミクスに非常に重要です。初期化の詳細についてはここでは深入りしませんが、VarianceScaling (ここで使用されます) は一般に良い initializer 選択です。
2番目の層を追加する
モデルに2番目の層を追加しましょう : マックスプーリング層で、これは tf.layers.maxPooling2d を使用して作成します。この層は畳み込みからの結果 (活性としても知られます) を各スライディング・ウィンドウについて最大値を計算することによりダウンサンプリングします :
model.add(tf.layers.maxPooling2d({ poolSize: [2, 2], strides: [2, 2] }));
引数を分解しましょう :
- poolSize. 入力データに適用されるスライディング・プーリング・ウィンドウのサイズです。ここでは、[2, 2] の poolSize を設定します、これはプーリング層が入力データに 2×2 ウィンドウを適用することを意味します。
- strides. スライディング・プーリング・ウィンドウの「ステップ・サイズ」です — i.e. ウィンドウが入力データに渡り移動するたびに幾つのピクセルをシフトするかです。ここでは、[2, 2] のストライドを指定します、これはフィルタが水平と垂直方向の両者で画像に渡り 2 ピクセルのステップでスライドすることを意味します。
NOTE: poolSize と strides の両者が 2×2 ですから、プーリング・ウィンドウは完全に非オーバーラッピングです。これはプーリング層が前の層からの活性のサイズを半分にカットすることを意味します。
残りの層を追加する
層構造の繰り返しはニューラルネットワークの一般的なパターンです。私達のモデルに2番目の畳み込み層、続いてもう一つのプーリング層を追加しましょう。私達の2番目の畳み込み層ではフィルタの数を 8 から 16 に 2 倍していることに注意してください。また inputShape を指定していないことにも注意しましょう、何故ならばそれは前の層からの出力の shape から推論できるからです :
model.add(tf.layers.conv2d({ kernelSize: 5, filters: 16, strides: 1, activation: 'relu', kernelInitializer: 'VarianceScaling' })); model.add(tf.layers.maxPooling2d({ poolSize: [2, 2], strides: [2, 2] }));
次に、前の層の出力をベクトルに平坦化するために flatten 層を追加しましょう :
model.add(tf.layers.flatten());
最後に、dense 層 (完全結合層としても知られています) を追加しましょう、これは最後の分類を遂行します。dense 層の前の畳み込み + プーリング層のペアの出力を平坦化することはニューラルネットワークのもう一つの一般的なパターンです :
model.add(tf.layers.dense({ units: 10, kernelInitializer: 'VarianceScaling', activation: 'softmax' }));
dense 層に渡される引数を分解してみましょう。
- units. 出力活性のサイズです。これは最終層で、そして私達は 10-クラス分類タスク (数字 0-9) を行なっていますので、ここでは 10 units を使用します (時に units はニューロン数として参照されますが、その用語は回避します)。
- kernelInitializer. dense 層のために、畳み込み層のために使用したのと同じ VarianceScaling 初期化ストラテジーを使用します。
- activation. 分類タスクのための最後の層の活性化関数は通常は softmax です。Softmax は私達の 10-次元出力ベクトルを確率分布に正規化します、その結果 10 クラスの各々のための確率を得ます。
モデルを訓練する
実際にモデルの訓練を駆動するためには、optimizer を構築して損失関数を定義する必要があります。また私達のモデルがデータ上でどのくらい上手く遂行するかを計測するために評価メトリックを定義します。
Optimizer を定義する
私達の畳み込みニューラルネットワークのためには、確率的勾配降下法 (SGD, stochastic gradient descent) optimizer を 0.15 の学習率で使用します :
const LEARNING_RATE = 0.15; const optimizer = tf.train.sgd(LEARNING_RATE);
損失を定義する
損失関数としては、交差エントロピー (categoricalCrossentropy) を使用します、これは分類タスクを最適化するために一般的に使用されます。categoricalCrossentropy は、私達のモデルの最終層により生成された確率分布と (正しいクラス・ラベルで 1 (100%) を持つ分布となる) ラベルにより与えられる確率分布の間の誤差を測定します。例えば、数字 7 のサンプルに対して次のラベルと予測値が与えられた場合 :
|class | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |----------|---|---|---|---|---|---|---|---|---|---||label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | |prediction|.1 |.01|.01|.01|.20|.01|.01|.60|.03|.02|
categoricalCrossentropy はもし予測が数字が 7 である高い確率であればより低い損失値を、予測が 7 の低い確率であればより高い損失値を与えます。訓練の間、データセット全体に渡る categoricalCrossentropy を最小化するためにモデルはその内部パラメータを更新します。
評価メトリックを定義する
評価メトリックとしては、accuracy を使用します、これは総ての予測から正しい予測のパーセンテージを測定します。
モデルをコンパイルする
モデルをコンパイルするためには、optimizer、損失関数そして評価メトリックのリスト (ここでは、単に ‘accuracy’ です) を持つ configuration オブジェクトをそれに渡します :
model.compile({ optimizer: optimizer, loss: 'categoricalCrossentropy', metrics: ['accuracy'], });
バッチサイズを configure する
訓練を始める前に、バッチサイズに関連する 2,3 の更なるパラメータを定義する必要があります :
// How many examples the model should "see" before making a parameter update. const BATCH_SIZE = 64; // How many batches to train the model for. const TRAIN_BATCHES = 100; // Every TEST_ITERATION_FREQUENCY batches, test accuracy over TEST_BATCH_SIZE examples. // Ideally, we'd compute accuracy over the whole test set, but for performance // reasons we'll use a subset. const TEST_BATCH_SIZE = 1000; const TEST_ITERATION_FREQUENCY = 5;
更にバッチ処理とバッチサイズについて
計算を並列化して GPU の能力を完全に利用するために、私達は単一の feed-forward 呼び出しを使用して複数の入力をまとめてバッチ処理してそれらをネットワークを通して供給することを望みます。
私達の計算をバッチ処理するもう一つの理由は最適化の間、幾つかのサンプルからの勾配を平均した後にはじめて内部パラメータを更新することです。これは外れ値のサンプル (e.g., 誤ってラベル付けられた数字) により間違った方向にステップを取ることを回避する助けになります。
入力データをバッチ処理するとき、rank D+1 の tensor を導入します、ここで D は単一の入力の次元です。
前に議論したように、MNIST データセットの単一の画像の次元は [28, 28, 1] です。64 の BATCH_SIZE を設定したとき、一度に 64 画像をバッチ処理しています、これは私達のデータの実際の shape は [64, 28, 28, 1] であることを意味します (バッチは常に最も外側の次元です) 。
NOTE : 最初の conv2d の config の inputShape はバッチサイズ (64) を指定していないことを思い出してください。Configs はバッチサイズ不可知論として書かれていて、その結果それらは任意のサイズのバッチを受け取ることができます。
訓練ループをコーディングする
ここに訓練ループのためのコードがあります :
for (let i = 0; i < TRAIN_BATCHES; i++) { const batch = data.nextTrainBatch(BATCH_SIZE); let testBatch; let validationData; // Every few batches test the accuracy of the mode. if (i % TEST_ITERATION_FREQUENCY === 0) { testBatch = data.nextTestBatch(TEST_BATCH_SIZE); validationData = [ testBatch.xs.reshape([TEST_BATCH_SIZE, 28, 28, 1]), testBatch.labels ]; } // The entire dataset doesn't fit into memory so we call fit repeatedly // with batches. const history = await model.fit( batch.xs.reshape([BATCH_SIZE, 28, 28, 1]), batch.labels, { batchSize: BATCH_SIZE, validationData, epochs: 1 }); const loss = history.history.loss[0]; const accuracy = history.history.acc[0]; // ... plotting code ... }
コードを分析してみましょう。最初に、訓練サンプルのバッチを取得します。GPU 並列化を活用してパラメータ更新を行なう前に多くのサンプルからのエビデンスを平均するためにサンプルをバッチ処理することを上から思い出してください :
const batch = data.nextTrainBatch(BATCH_SIZE);
5 ステップ (TEST_ITERATION_FREQUENCY) 毎に、テストセットからの MNIST 画像とそれらの対応するラベルのバッチを含む 2 つの要素の配列、validationData を構築します。このデータをモデルの精度を評価するために使用します :
if (i % TEST_ITERATION_FREQUENCY === 0) { testBatch = data.nextTestBatch(TEST_BATCH_SIZE); validationData = [ testBatch.xs.reshape([TEST_BATCH_SIZE, 28, 28, 1]), testBatch.labels ]; }
model.fit はモデルが訓練されてパラメータが実際に更新される場所です。
NOTE : データセット全体の上で一度に model.fit() を呼び出すことはデータセット全体を GPU にアップロードする結果になるでしょう、これはアプリケーションをフリーズさせかねません。GPU に多すぎるデータをアップロードすることを回避するために、下で示されるように、model.fit() を for ループ内で呼び出し、一度にデータの単一のバッチを渡すことを推奨します :
// The entire dataset doesn't fit into memory so we call fit repeatedly // with batches. const history = await model.fit( batch.xs.reshape([BATCH_SIZE, 28, 28, 1]), batch.labels, {batchSize: BATCH_SIZE, validationData: validationData, epochs: 1});
引数を再度分解してみましょう :
- x. 入力画像データです。サンプルをバッチで供給していますので、fit 関数に私達のバッチがどのくらい大きいかを知らせなければならないことを忘れないでください。MnistData.nextTrainBatch は shape [BATCH_SIZE, 784] を持つ画像を返します — 長さ 784 (28 * 28) の 1-D ベクトルにある画像のための総てのデータです。けれども、私達のモデルは shape [BATCH_SIZE, 28, 28, 1] の画像データを想定していますので、それに従って reshape します。
- y. ラベルです ; 各画像のための正しい数字分類です。
- batchSize. 各訓練バッチに幾つの画像が含まれるかです。ここで使用するために先に 64 の BATCH_SIZE を設定しています。
- validationData. TEST_ITERATION_FREQUENCY (ここでは, 5) バッチ毎に構築する検証セットです。このデータは shape [TEST_BATCH_SIZE, 28, 28, 1] にあります。先に、1000 の TEST_BATCH_SIZE を設定しています。評価メトリック (accuracy) はこのデータセットに渡り計算されます。
- epochs. バッチ上で遂行される訓練実行の数です。fit にバッチを反復的に供給していますので、それに一度にこのバッチから訓練することを望むだけです。
fit を呼び出すたびに、それはメトリクスのログを含む豊かなオブジェクトを返し、これは history にストアしています。各訓練反復のために loss と accuracy を抽出し、グラフ上にそれらをプロットすることができます :
const loss = history.history.loss[0]; const accuracy = history.history.acc[0];
結果を見ます!
完全なコードを実行する場合、貴方はこのような出力を見るはずです
(訳注 : 以下の画像は実際の実行結果のスナップショットです) :
画像の殆どについてモデルは正しい数字を予測しているようです。Great work!
以上