かなり間を空けてしまいましたが第3回です。

CNTK自体が刻々とバージョンアップが続けられているため本連載の内容も陳腐化しているところがありますが、今回で終わりとなるので書ききってしまいます。

前回の記事ではCNTKで多層パーセプトロン(MLP)のモデル実装とMNISTデータセットの学習を実施しました。

今回は畳み込みニューラルネットワーク(CNN)を実装し、総括を行って終わりとしたいと思います。
入力データは前回同様MNISTデータセットを使用します。データ準備の手順は前回に記載済みのため省略しますが、不明な人は前回を参照してください。

Windows + Python 3 + CNTK + VSCode ではじめる機械学習(2)
Windows + Python 3 + CNTK + VSCode ではじめる機械学習(1)

なお、畳み込みニューラルネットワークの基礎知識についてここで述べることはしません。
私自身それほど詳しいわけではないですが、まったくわからないという方がいましたら「畳み込みニューラルネットワーク」で検索すると解説している文献はたくさん見つかりますので、調べてみると良いでしょう。

CNNの実装

さっそくCNNのモデルを実装しましょう。
前回作成したmodels.pyに以下のコードを追加します。

class CNN:
    """
    畳み込みニューラルネットワークモデル
    """
    def __init__(self, input_dim, output_dim):
        """
        コンストラクタ
        params:
            input_dim(integer): 入力パラメータ数
            output_dim(integer): 出力数
        """
        with default_options(activation=relu, pad=False):
            self.layers = Sequential([
                For(range(3), lambda i: [
                    Convolution((3, 3), [32, 48, 64][i], init=he_uniform(), pad=True),
                    MaxPooling((3, 3), strides=(2, 2)),
                ]),
                Dense(96, init=he_uniform()),
                Dropout(0.5),
                Dense(output_dim, activation=None)
            ])

見て頂ければわかると思いますが、MLPと比べても実装量はほんの少し増えているくらいで、それほど差がありません。
このようにモデルの実装が簡単に行えるのはライブラリを使用する大きな利点になります。

今回は新たな機能としてConvolutionMaxPoolingDropout が登場しています。
これらを中心に説明します。

Convolution

Convolution(rf_shape, num_filters=None,
            activation=activation_default_or_None,
            init=init_default_or_glorot_uniform,
            pad=pad_default_or_False)

Convolutionはニューラルネットワークの畳み込み層を表す機能です。
rf_shapeにフィルタの形状、num_filtersに出力フィルタ数を指定します。
initは前回のDenseと同様、heの初期値を指定しています。
padは出力のzero-paddingを行うか否かを表すフラグです。

また今回はdefault_optionsを使用しています。
これは任意で使うことが可能で、withキーワードとともに使用することでネストの内部でdefault_optionsで指定した値がデフォルト値となります。
今回はactivation=reluが記述されているため、Convolutionの活性化関数はReLU関数となっています。

注意点として、フィルタの形状rf_shapeはConvolutionの入力パラメータと次元数を合わせなければいけません。
MLPのときはMNISTデータセットの画像データを1次元に変換して全結合層で扱っていましたが、今回は画像の縦×横に合わせた2次元のデータを入力とします。
そうすることによってピクセル間の縦横の位置関係を考慮した学習が可能となります。

MaxPooling

MaxPooling(rf_shape, strides=1, pad=False)

MaxPoolingはニューラルネットワークのプーリング層を表す機能です。Maxとあるとおり、最大値プーリングを実現します。
他に平均値でプーリングする AveragePooling も存在しますので、用途に応じて使い分けましょう。

rf_shapepadはConvolutionと同じです。
stridesはプーリング処理のフィルタの移動量となります。

今回はMaxPoolingにもdefault_optionsで記述したpad=Falseの指定が適用されています。

Dropout

Dropout(prob)

Dropoutはニューラルネットワークのドロップアウト層を表す機能です。
probに0~1の範囲で有効とするパラメータの割合を指定します。
1を指定するとすべてのパラメータを有効とし、ゼロを指定した場合はすべてのパラメータが無効となります。

CNNで画像認識を実装する

CNNのモデルを使用し、画像認識の実装を行ないます。

前回のMLPと全体の流れは同じになるので、解説は変更のあるところに留めます。

if __name__ == "__main__":
    input_dim = (1, 28, 28) # 入力データ1件のデータサイズ(チャンネル数, 高さ, 幅)
    input_data = input_dim[0] * input_dim[1] * input_dim[2]
    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_data, 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_data, num_output_classes)

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

主処理の呼び出し部分になりますが、MLPではinput_dim = 784だったところをinput_dim = (1, 28, 28)に変更しています。
MLPでは1次元のデータを入力としていましたが、今回は縦×横の形状のままデータを入力するため、このように定義を変更します。
create_readerに渡す入力データのサイズは前回同様、784を指定したいので、input_dataにサイズを確保しています。

次に、モデルの作成部です。

    # CNNで実装したモデルを入力パラメータと接続
    nn = CNN(input_dim, num_output_classes)
    z = nn.layers(scaled_input)

ここは前項で作成したCNNのモデルを作るようにしているだけですね。同じインターフェースで作るようにしているので、生成部の差し替えでOKです。

次にTrainerの生成です。

    epoch_size = 60000 # epoch はデータの周回単位 epoch_size は1週の大きさ
    minibatch_size = 128 # 一度に処理するデータ数
    max_epochs = 40 # 学習期間
    # 学習率が実行時期によって切り替わるように指定する
    lr_per_sample = [0.001]*10 + [0.0005]*10 + [0.0001]
    lr_per_minibatch = learning_rate_schedule(lr_per_sample, UnitType.sample, epoch_size)
    # 運動量曲線 (adam_sgdを使用するため必要)
    mm_time_constant = [0]*5 + [1024]
    mm_schedule = momentum_as_time_constant_schedule(momentum=mm_time_constant, epoch_size=epoch_size)
    # Trainerを生成 (層の出力, 損失関数, 評価関数, 学習関数)
    trainer = Trainer(z,
                      cross_entropy_with_softmax(z, label_var),
                      classification_error(z, label_var),
                      adam_sgd(z.parameters, lr=lr_per_minibatch, momentum=mm_schedule))

少し変更量が多いですが、これは学習関数をsgdからadam_sgdへ変更したことが原因です。
sgdのままであればMLPのときと全く同じコードでOKです。
adam_sgdを使用せずともそれほど問題無いと思いますが、学習関数を変更する例として記載しておきます。

また学習期間max_epochsも40週に変更しています。これはモデルの複雑度が向上したことから学習に必要な期間が伸びた結果を考慮しています。

変更点は以上です。最後に全体のコードを記載します。
convnet_mnist.py

# -*- 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, adam_sgd, momentum_as_time_constant_schedule
from cntk.utils import get_train_loss, get_train_eval_criterion

from models import CNN

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 convnet_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)
    # CNNで実装したモデルを入力パラメータと接続
    nn = CNN(input_dim, num_output_classes)
    z = nn.layers(scaled_input)

    epoch_size = 60000 # epoch はデータの周回単位 epoch_size は1週の大きさ
    minibatch_size = 128 # 一度に処理するデータ数
    max_epochs = 40 # 学習期間
    # 学習率が実行時期によって切り替わるように指定する
    lr_per_sample = [0.001]*10 + [0.0005]*10 + [0.0001]
    lr_per_minibatch = learning_rate_schedule(lr_per_sample, UnitType.sample, epoch_size)
    # 運動量曲線 (adam_sgdを使用するため必要)
    mm_time_constant = [0]*5 + [1024]
    mm_schedule = momentum_as_time_constant_schedule(momentum=mm_time_constant, epoch_size=epoch_size)
    # Trainerを生成 (層の出力, 損失関数, 評価関数, 学習関数)
    trainer = Trainer(z,
                      cross_entropy_with_softmax(z, label_var),
                      classification_error(z, label_var),
                      adam_sgd(z.parameters, lr=lr_per_minibatch, momentum=mm_schedule))

    # 教師データを入力として 40週 学習する
    # 教師データとラベルを対応付けるハッシュ配列。 reader_train は create_reader で読み込んだ教師データ
    train_t = {
        input_var: reader_train.streams.features,
        label_var: reader_train.streams.labels
    }
    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

    # Average of evaluation errors of all test minibatches
    return test_result / num_minibatches_to_test

if __name__ == "__main__":
    input_dim = (1, 28, 28) # 入力データ1件のデータサイズ(チャンネル数, 高さ, 幅)
    input_data = input_dim[0] * input_dim[1] * input_dim[2]
    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_data, 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_data, num_output_classes)

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

スクリプトを実行してみましょう、と言いたいところですが前回と異なりかなりの処理時間が必要です。
PCの処理性能によりますが数時間~もしかすると1日ほどかかるかもしれません。
当然、その間はPCが使い物にならなくなるため、実行するときは注意してください。
誤って実行してしまったなどの理由で中断したいときは、コンソールでCtrl+Cを押せばキャンセルできると思います。

処理時間について

なぜ今回のモデルがこれほど遅くなるのか疑問に思われるかもしれませんが、全結合層(Dense)と畳み込み層(Convolution)では計算量が違いますし、単純に層の深さも増加しています。
この例を見てもわかるように機会学習には十分な処理性能を持ったマシンと時間が必須です。
28×28という非常に小さなサイズの画像データでさえそうなのですから、データの大きさや量、モデルの複雑さが向上すればその時間は加速度的に増加していきます。

このような処理時間の問題についてCNTKは特に力を入れて取り組んでおり、GPUコンピューティングのサポートや複数PCによる並列処理、また1bit-SGDといった高速化のための機能を利用するように呼びかけています。

機械学習ライブラリの速度比較図

上記のグラフはあくまでMicrosoftが公表したデータですが、GPUおよび複数PC使用時の処理性能は他のライブラリを大きく引き離しています。
私自身は試していませんが、クラウドサービスのAzureにCNTK構築済みの環境も用意されているそうです。
性能向上を図るときはこういった機能を活用していきましょう。

おわり

3回に渡ってCNTKのインストールからMLP、CNNのモデルおよびMNISTデータセットの学習について記載してきました。
私自身まだまだ勉強しているところですが、近年の機械学習分野の発展は目覚ましいものがあります。

おそらく自分で実装してみると実感できると思うのですが、利用する側から見ると機械学習は難しい数式やネットワークモデルの理論よりも、むしろ学習データの準備やどのようにネットワークモデルにデータを入力するかという方法が大きな問題となります。(改善を続けてきた研究者の方たちの地道な努力があるからこそ言えるのですが)

インターネット上にはMNISTデータセット以外にも学習用途に使うためのデータを提供してくれているものや、ネットワークモデルの学習パラメータを公開しているものもあります。
こういったものを上手く利用して機械学習による開発を行っていきたいですね。

長くなりましたが、ここまでお付き合いいただいた方はありがとうございました。

株式会社 羅針では一緒に働いてくれる技術者を募集しています。
機械学習…の仕事はまだ無いですが、われこそは!という方やともに技術を高めあっていきたいという方がいらっしゃれば、応募お待ちしております。