前回はWindows上でCNTKとVSCodeによる開発環境構築を行ないました。

今回はCNTKを使用して画像認識の実装をしていきます。

なお、前回の記事はこちらです。
Windows + Python 3 + CNTK + VSCode ではじめる機械学習(1)

入力データの準備

一般的に機械学習には学習過程の教師データが不可欠(*)です。
今回の教師データには画像認識のHello WorldであるところのMNISTデータセットを使用します。

幸いCNTKにもMNISTデータセットを使用するサンプルがあり、データ読み込みのための仕組みが用意されているため、これを流用します。

(*) 機械学習の中でも特に強化学習(DQN … ドキュン?)と呼ばれる手法は教師データを必要としませんが、本連載では触れません。興味のある方は調べてみてください。

コマンドプロンプトを起動して以下のコマンドを実行してください。

%MYCNTKPATH%cntkScriptscntkpy34.bat
cd %MYCNTKPATH%cntkExamplesImageDataSetsMNIST
python install_mnist.py

install_mnist.pyはMNISTデータセットをダウンロードし、テキスト形式で保存するスクリプトです。

スクリプト終了後、dirコマンドを実行しましょう。
Train-28x28_cntk_text.txtTest-28x28_cntk_text.txt が作成されていればダウンロードは完了です。

なお、今回は解説しませんが %MYCNTKPATH%cntkExamplesImageClassification にはMNISTデータセットを使用した様々なニューラルネットワークの実装サンプルが含まれています。
本記事の内容もこれらのサンプルを参考に作成したものです。
つまづいたら見てみると良いでしょう。

プロジェクトの作成

開発用のプロジェクトを用意します。といっても、やることは多くありません。

適当なフォルダを作成します。今回は C:devcntk-mnist としました。ここは各々の環境で読み替えてください。

次にダウンロードしたMNISTデータセットを C:devcntk-mnistdatasets にコピーします。

VSCodeを起動し、メニューのファイル>フォルダを開く から作成したcntk-mnistフォルダを開きましょう。
画像のようになっていればプロジェクト作成完了です。

VSCodeでプロジェクトを開く

ネットワークモデルの実装

前置きが長くなりましたがいよいよ実装です。
機械学習においてネットワークをどのようなモデルとするかは1つの課題になりますが、今回は最も単純と思われる多層パーセプトロン(Multi Layer Perceptron … MLP)を作ります。

実装するモデルを図示します。
多層パーセプトロン

機械学習ライブラリを使ったネットワークモデルの実装は一般に非常にシンプルで小さなコードになります。
Pythonコードの全貌は以下のとおりです。

# -*- coding: utf-8 -*-
"""
ニューラルネットワークモデル定義
"""
from cntk.models import Sequential, LayerStack
from cntk.layers import Dense
from cntk.ops import relu
from cntk.initializer import he_uniform

class MLP:
    """
    多層パーセプトロンネットワークモデル
    """
    def __init__(self, input_dim, output_dim):
        """
        コンストラクタ
        params:
            input_dim(integer): 入力パラメータ数
            output_dim(integer): 出力数
        """
        self.layers = Sequential([
            For(range(3), lambda i: [
                Dense([256, 128, 64][i], init=he_uniform(), activation=relu)
            ]),
            Dense(output_dim, activation=None)
        ])

このコードをmodels.pyという名前で保存します。
登場する要素について見ていきましょう。

Dense

Dense(shape, init=init_default_or_glorot_uniform, activation=activation_default_or_None)

Denseはニューラルネットワークの全結合層を表す機能です。
shapeに出力数をintで指定します。
また今回隠れ層ではinit=he_uniform()で初期値としてheの初期値、activation=reluで活性化関数にReLU関数を指定しています。

戻り値は、全結合層の入力を受け取り、出力を返す関数です。

h = Dense(256, activation=relu)(feature)

このコードは以下の図の実装になります。
Dense層

CNTKの層を表す機能はどれもこれと同じく、層の入力を受け取り出力を返す関数を戻り値とします。
大事な性質なので覚えておきましょう。

Sequential

Sequential(layers)

Sequentialはニューラルネットワークの複数の層を1つにまとめる機能です。
引数layersに、まとめたい層の(関数の)配列を指定します。

Sequentialの戻り値もDense同様に層の入力を受け取り出力を返す関数になりますが、Sequentialは最終的な出力を返してくれます。
例を挙げると、以下の2つのコードは等価になります。

h = Dense(1024, activation=relu)(features)
p = Dense(9000, activation=softmax)(h)
p = Sequential ([
        Dense(1024, activation=relu),
        Dense(9000, activation=softmax)
    ])(features)

For

For(range(N), constructor)

Forは少々変わり種の機能です。
ニューラルネットワークのモデルでは、同じような構造の層を繰り返すことがたびたびあります。
そのような構造をForを使用することで簡潔に表現できます。

Nに層の集合の繰り返し数、constructorに層の集合のファクトリー関数を指定します。
ファクトリー関数の第一引数には現在の繰り返し数が渡されてくるので、層の深さによってパラメータのみを切り替えた同一構造の層を見通しよく記述できます。
例を挙げると、以下の2つのコードは等価になります。

h0 = Dense(256, init=he_uniform(), activation=relu)(feature)
h1 = Dense(128, init=he_uniform(), activation=relu)(h0)
p = Dense(64, init=he_uniform(), activation=relu)(h1)
p = For(range(3), lambda i: [
        Dense([256, 128, 64][i], init=he_uniform(), activation=relu)
    ])(feature)

この他にも、ニューラルネットワークで使用するレイヤーや初期値、活性化関数などをCNTKは提供してくれています。
これによって実装者は内部でどのような数式が使用されているのかさほど意識せずにネットワークモデルを構築することが可能です。
ここに挙げたものも含めてより詳細な情報は公式のAPIリファレンスを参照してください。

画像認識を実装する

前項で作成したMLPのモデルを使用し、画像認識の実装を行ないます。

プロジェクトに配置済みのMNISTデータセットファイルが認識の対象とする入力データです。
このファイルはCNTKで読み込みやすいように加工されており、以下のような関数(*)で読み込むことができます。

(*) CNTKのサンプルより流用

def create_reader(path, is_training, input_dim, label_dim):
    """
    データセットを読み込みます
    """
    return MinibatchSource(CTFDeserializer(path, StreamDefs(
        features=StreamDef(field='features', shape=input_dim, is_sparse=False),
        labels=StreamDef(field='labels', shape=label_dim, is_sparse=False)
    )), randomize=is_training, epoch_size=INFINITELY_REPEAT if is_training else FULL_DATA_SWEEP)

ここについてはデータ形式を扱いやすいようにパースするのが主な目的で、本記事の主旨とずれるため各機能についての詳細は割愛します。
公式のリファレンスを参照してください。

次に、読み込んだデータをネットワークモデルに流し込んで行く実装は以下のとおりです。

    # 入力パラメータ数と出力ラベル数をCNTKの型でラップする
    input_var = input_variable(input_dim, np.float32) # input_dim は入力パラメータ数
    label_var = input_variable(num_output_classes, np.float32) # num_output_classes は出力ラベル数
    # 入力パラメータを作成
    scaled_input = element_times(constant(0.00390625), input_var)
    # MLPで実装したモデルを入力パラメータと接続
    nn = MLP(input_dim, num_output_classes)
    z = nn.layers(scaled_input)

見慣れない要素がたくさん登場しましたが input_variableelement_times, constant はいずれもモデルに与えるパラメータをCNTKの機能でラップするものです。
詳細はやはり公式ドキュメントに任せますが、

  • input_variable … 変数の値
  • element_times … 繰り返し要素(配列)
  • constant … 定数

をそれぞれラップします。

最後の2行で前項で実装したMLPモデルのインスタンスを生成し、入力パラメータと接続しています。

Trainer

ここまでの段階ではまだネットワークモデルに値は流れていません。
学習を実行するにはTrainerと呼ばれる、ネットワークモデルの学習を担当するものが必要です。
Trainerの生成は以下のコードで行ないます。

    # Trainerを生成 (層の出力, 損失関数, 評価関数, 学習関数)
    lr_per_minibatch = learning_rate_schedule(1e-1, UnitType.minibatch)
    trainer = Trainer(z,
                      cross_entropy_with_softmax(z, label_var),
                      classification_error(z, label_var),
                      sgd(z.parameters, lr=lr_per_minibatch))
Trainer(model, loss_function, eval_function, parameter_learners)

Trainerの引数には以下の内容を指定します。
– model … ネットワークモデルと入力パラメータを接続した戻り値。(公式によると「root node of the function to train.」らしいですがこれが何者なのか理解しきれていない……)
– loss_function … 損失関数。今回はcross_entropy_with_softmaxを使います。
– eval_function … 評価関数。最終的な出力と正解ラベルの比較を行うもので、今回はclassification_errorを使います。
– parameter_learners … 学習関数。他のライブラリではOptimizer(最適化関数)などと呼ばれるもの。

学習関数にはsgdを使用しています。他にmomentum_sgdやadagrad、adam_sgdなどもありますが、それぞれ固有のパラメータが必要となるので使用の際は公式のリファレンスを参照してください。

このようにTrainerを作成した後、教師データを対象に学習を実行します。

    # 教師データを入力として 20週 学習する
    # 教師データとラベルを対応付けるハッシュ配列。 reader_train は create_reader で読み込んだ教師データ
    train_t = {
        input_var: reader_train.streams.features,
        label_var: reader_train.streams.labels
    }
    epoch_size = 60000 # epoch はデータの周回単位 epoch_size は1週の大きさ
    minibatch_size = 128 # 一度に処理するデータ数
    max_epochs = 20 # 学習期間
    for epoch in range(max_epochs):
        sample_count = 0
        while sample_count < epoch_size:
            mb_data = reader_train.next_minibatch(min(minibatch_size, epoch_size - sample_count), input_map=train_t)
            trainer.train_minibatch(mb_data)
            sample_count += mb[label_var].num_samples

        # Debug print
        training_loss = get_train_loss(trainer)
        eval_crit = get_train_eval_criterion(trainer)
        print("Epoch: {}, Train Loss: {}, Train Evaluation Criterion: {}".format(
            epoch, training_loss, eval_crit))

機械学習では入力データをおおよそ1週する学習の単位をepochと言います。
教師データは60000件あるため1epochを60000件として、今回は20週分の学習を行ないます。

reader_train.next_minibatch()で一括で処理するデータを取り出し、trainer.train_minibatch()の引数に渡すことで、取り出した教師データを対象に学習が実行されます。
今回は1epochごとに学習の結果をコンソールに出力しています。グラフ表示も含めてログ関連の機能もCNTKに用意されているため、慣れてきたら使用してみるのも良いと思います。

ここまででモデルと学習の実装が完了しました。
最後に全体のコードを記載します。
なお、学習が終わったモデルを使用してテストデータの推論を行う実装の説明は教師データの実行とさほど変化が無いため割愛します。(こればっかり……)

# -*- coding: utf-8 -*-
"""
MNISTデータセットを多層パーセプトロンで学習します
"""
import os
import numpy as np
from cntk import Trainer
from cntk.io import MinibatchSource, CTFDeserializer, StreamDef, StreamDefs, 
                    INFINITELY_REPEAT, FULL_DATA_SWEEP
from cntk.ops import input_variable, constant, element_times, cross_entropy_with_softmax, classification_error
from cntk.learner import learning_rate_schedule, UnitType, sgd
from cntk.utils import get_train_loss, get_train_eval_criterion

from model import MLP

abs_path = os.path.dirname(os.path.abspath(__file__))

def create_reader(path, is_training, input_dim, label_dim):
    """
    データセットを読み込みます
    """
    return MinibatchSource(CTFDeserializer(path, StreamDefs(
        features=StreamDef(field='features', shape=input_dim, is_sparse=False),
        labels=StreamDef(field='labels', shape=label_dim, is_sparse=False)
    )), randomize=is_training, epoch_size=INFINITELY_REPEAT if is_training else FULL_DATA_SWEEP)

def mlp_mnist(input_dim, num_output_classes, reader_train, reader_test):
    """
    多層パーセプトロンでMNISTデータセットを学習します
    """
    # 入力パラメータ数と出力ラベル数をCNTKの型でラップする
    input_var = input_variable(input_dim, np.float32) # input_dim は入力パラメータ数
    label_var = input_variable(num_output_classes, np.float32) # num_output_classes は出力ラベル数
    # 入力パラメータを作成
    scaled_input = element_times(constant(0.00390625), input_var)
    # MLPで実装したモデルを入力パラメータと接続
    nn = MLP(input_dim, num_output_classes)
    z = nn.layers(scaled_input)

    # Trainerを生成 (層の出力, 損失関数, 評価関数, 学習関数)
    lr_per_minibatch = learning_rate_schedule(1e-1, UnitType.minibatch)
    trainer = Trainer(z,
                      cross_entropy_with_softmax(z, label_var),
                      classification_error(z, label_var),
                      sgd(z.parameters, lr=lr_per_minibatch))

    # 教師データを入力として 20週 学習する
    # 教師データとラベルを対応付けるハッシュ配列。 reader_train は create_reader で読み込んだ教師データ
    train_t = {
        input_var: reader_train.streams.features,
        label_var: reader_train.streams.labels
    }
    epoch_size = 60000 # epoch はデータの周回単位 epoch_size は1週の大きさ
    minibatch_size = 128 # 一度に処理するデータ数
    max_epochs = 20 # 学習期間
    for epoch in range(max_epochs):
        sample_count = 0
        while sample_count < epoch_size:
            mb_data = reader_train.next_minibatch(min(minibatch_size, epoch_size - sample_count), input_map=train_t)
            trainer.train_minibatch(mb_data)
            sample_count += mb_data[label_var].num_samples

        # Debug print
        training_loss = get_train_loss(trainer)
        eval_crit = get_train_eval_criterion(trainer)
        print("Epoch: {}, Train Loss: {}, Train Evaluation Criterion: {}".format(
            epoch, training_loss, eval_crit))

    # 学習が終わったモデルを使用してテストデータを対象に推論を行ない、誤回答の確率を求める
    # テストデータとラベルを対応付けるハッシュ配列。 test_train は create_reader で読み込んだテストデータ
    test_t = {
        input_var: reader_test.streams.features,
        label_var: reader_test.streams.labels
    }
    test_minibatch_size = 1024
    num_samples = 10000
    num_minibatches_to_test = num_samples / test_minibatch_size
    test_result = 0.0
    for i in range(0, int(num_minibatches_to_test)):
        mb_data = reader_test.next_minibatch(test_minibatch_size, input_map=test_t)
        eval_error = trainer.test_minibatch(mb_data)
        test_result = test_result + eval_error

    return test_result / num_minibatches_to_test

if __name__ == "__main__":
    input_dim = 784 # 入力データ1件のデータサイズ
    num_output_classes = 10 # 出力数(分類するクラス数 0~9)

    # 教師付きデータの読み込み
    train_path = os.path.normpath(os.path.join(abs_path, "..", "datasets", "Train-28x28_cntk_text.txt"))
    reader_train = create_reader(train_path, True, input_dim, num_output_classes)

    # テストデータの読み込み
    test_path = os.path.normpath(os.path.join(abs_path, "..", "datasets", "Test-28x28_cntk_text.txt"))
    reader_test = create_reader(test_path, False, input_dim, num_output_classes)

    error = mlp_mnist(input_dim, num_output_classes, reader_train, reader_test)
    print("Error: %f" % error)

いかがでしょうか?私自身の不慣れも大いにありますが、あまり読みやすいコードとは言えないかもしれませんね。
もう少しこなれてくるとライブラリの使用者も増えるのかなと思っていたりします。

それではこのスクリプトを実行してみましょう。
コマンドプロンプトで実行してもよいですし、VSCode上でPythonスクリプトを右クリックし、「Run Python File in Terminal」を選択してもよいです。
実行するとおそらく最終的に教師データの正解率は99%を越え、テストデータについても98%以上の正解率となると思います。

おわり

やや駆け足の内容となりましたが、今回はMLPのモデルを実装し、MNISTデータセットの学習を行ないました。
正解率98%はその数値だけ見れば十分実用的といえるものです。MNISTデータセットは1つの画像サイズが幅28×高さ28と小さく、シンプルなデータのため今回の実装でも十分な精度が得られます。

しかし現実的には入力データ、分類のパターンがもっと複雑なものがほとんどで、単純で層の浅いモデルで認識精度を上げることは難しいでしょう。
次回は畳み込みニューラルネットワーク(CNN)の実装を行ない、締めとしたいと思います。