FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

CNTK 2.0 のチュートリアル試行: CNTK 102 Feed Forward network with NumPy

CNTK 2.0のチュートリアルを試行する。

今回はある程度翻訳という形で実施してみた。だいぶ誤訳はあるだろうが、ザックリ概要をつかむという形でやっていきたい。

github.com

以降の文章については、CNTK 2.0の翻訳を含んでいるが、誤訳を含んでいる可能性がある。問題がある場合は指摘していただけるとありがたいです。


from IPython.display import Image

CNTK 102: フィードフォワードネットワークを使ってデータ分類を実行する

CNTK 101では回帰分析でデータの分類を行ったが、今回はフィードフォワードネットワークを用いてデータの分類を実行する。これはCNTK 101と同じ課題を解くことになる。

問題 (CNTK 101と同様)

CNTK 101チュートリアルでは、回帰分析を用いてデータの分類を行う。ここで例として取り上げられているのは、2種類の要素(年齢、腫瘍の大きさ)に対するガン腫瘍であるかどうかを識別する回帰分析だ。つまり、X軸を年齢、Y軸を腫瘍の大きさ都市、それがガン腫瘍であるかどうかを識別するものとなる。 以下のようなグラフをプロットすることができ、青色のプロットが良性腫瘍、赤色のプロットが悪性腫瘍となる。 さらに、下記のような回帰直線を引くことで、良性と悪性の境界線を引くことができるようになる。

# Figure 1
Image(url="https://www.cntk.ai/jup/cancer_data_plot.jpg", width=400, height=400)

f:id:msyksphinz:20170708232441p:plain

CNTK 101では、回帰分析を用いてデータを分類する直線を探索した。しかし実際の世界の問題では、線形関数で分類することのできるデータというのは制度にかけ、良い特性を導くための材料としては不足だ。 個のチュートリアルでは、複数の線形関数の答えを組み合わせることによって(CNTK 101の回帰分析の回答を利用する)、非線形の分類法を導くことを目的とする。

アプローチの方法

任意の学習アルゴリズムは5つの段階を踏む。データ読み込み、データ処理、モデル作成、モデルパラメータの学習、モデルの評価(テスト、予測)である。 本チュートリアルでは、3番目のモデル作成のところのみCNTK 101と異なり、今回はフィードフォワードのネットワークを利用する。

フィードフォワードネットワークモデル

回帰モデルのチュートリアルと、同様のデータセットを使用してフィードフォワードのモデルを学習させる。このモデルでは複数の回帰分析器を利用して、単体の回帰分析よりもより複雑なデータを分類する際に適切な境界値を設定できるようにする。以下は一般的なネットワークの構成である。

# Figure 2
Image(url="https://upload.wikimedia.org/wikipedia/en/5/54/Feed_forward_neural_net.gif", width=200, height=200)

f:id:msyksphinz:20170708232511p:plain

フィードフォワードニューラルネットワークは、ネットワークのノード間で「サイクルが発生しない」ネットワークである。 フィードフォワードニューラルネットワークは人工のニューあるネットワークの中で最初に登場した、最もシンプルなネットワークである。 このネットワークでは、情報は単一の方向、順方向にのみ、入力ノードから(必要とあれば)隠しノード、そして出力ノードへと伝搬していく。 ネットワーク中にサイクルは存在しない。

チュートリアルでは、上記の5つのステップと、テストデータを用いたモデルのテストを完了させるために必要なステップとしては、異なるものを利用していく。

# Import the relevant components
from __future__ import print_function # Use a function definition from future version (say 3.x from 2.7 interpreter)
import matplotlib.pyplot as plt
%matplotlib inline

import numpy as np
import sys
import os

import cntk as C

以下のブロックでは、本チュートリアルスクリプトをCNTKで実行する場合のターゲットについて、マシン内部の定義されている環境変数を探している。 適切なターゲットデバイス(GPU vs CPU)を選択して設定する。それ以外の場合には、CNTKのデフォルトのポリ氏を利用して実行可能な最適なデバイスを利用する(使用可能ならばGPU、それ以外はCPU)

# Select the right target device when this notebook is being tested:
if 'TEST_DEVICE' in os.environ:
    if os.environ['TEST_DEVICE'] == 'cpu':
        C.device.try_set_default_device(C.device.cpu())
    else:
        C.device.try_set_default_device(C.device.gpu(0))

データの生成

本節はCNTK 101を理解しているのならばスキップすることができる。次のセクション「モデルの生成」に進んでよい。

がんのサンプルに似せた合成データを作成する。これにはPythonnumpyライブラリを利用する。ここで生成する(2次元で表現された)2つの特徴を持っており、各データは2つのクラスのうちどちらかに属している(良性データ: 青、悪性データ:赤)。

今回の例では、トレーニングデータはラベル(青もしくは赤)を持っており、各軸に対応している(ここでは2つの特性として、年齢と腫瘍の大きさを利用する)。 本例では、2つのクラスをラベル0, 1として表現する。したがって、この問題は2値の分離問題である。

# Ensure we always get the same amount of randomness
np.random.seed(0)

# Define the data dimensions
input_dim = 2
num_output_classes = 2

入力値とラベル

チュートリアルではnumpyライブラリを利用して合成データを作成する。読者が実際の世界で扱う問題では、特性データ(年齢と腫瘍の大きさ)に対して、ひとつの観察データ(observation)が存在する。 ここでは、各観察データは(もし別の特性が存在するならば)2次元のデータではなくより高次元のデータであり、それらはCNTKではテンソルとして表現される。 より高度なチュートリアルではより高い次元のデータを扱う方法についても紹介する。

# Helper function to generate a random data sample
def generate_random_data_sample(sample_size, feature_dim, num_classes):
    # Create synthetic data using NumPy. 
    Y = np.random.randint(size=(sample_size, 1), low=0, high=num_classes)

    # Make sure that the data is separable
    X = (np.random.randn(sample_size, feature_dim)+3) * (Y+1)
    X = X.astype(np.float32)    
    # converting class 0 into the vector "1 0 0", 
    # class 1 into vector "0 1 0", ...
    class_ind = [Y==class_number for class_number in range(num_classes)]
    Y = np.asarray(np.hstack(class_ind), dtype=np.float32)
    return X, Y
# Create the input variables denoting the features and the label data. Note: the input 
# does not need additional info on number of observations (Samples) since CNTK first create only 
# the network tooplogy first 
mysamplesize = 64
features, labels = generate_random_data_sample(mysamplesize, input_dim, num_output_classes)

データを可視化してみよう。

注意 mathplotlib.pyplotのインポートに失敗した場合、conda install matplotlibを実行してpyplotのバージョン依存の問題を修復すること。

# Plot the data 
import matplotlib.pyplot as plt
%matplotlib inline

# given this is a 2 class 
colors = ['r' if l == 0 else 'b' for l in labels[:,0]]

plt.scatter(features[:,0], features[:,1], c=colors)
plt.xlabel("Scaled age (in yrs)")
plt.ylabel("Tumor size (in cm)")
plt.show()

f:id:msyksphinz:20170708232658p:plain

モデルの生成

我々のフィードフォワードネットワークは比較的単純で、2つの隠れレイヤ(num_hidden_layers)を持っており、各隠れレイヤには50個の隠れノード(hidden_layer_dim)が用意されている。

# Figure 3
Image(url="http://cntk.ai/jup/feedforward_network.jpg", width=200, height=200)

f:id:msyksphinz:20170708232722p:plain

上記の緑色のノードが隠れレイヤであり、本例では50個用意されている。また隠れレイヤの数は2である。ここで次の値はどのようになるか。

  • num_hidden_layers
  • hidden_layers_dim

注意 上記の図では、(回帰分析の際に説明した)バイアスノードについては表示していない。各隠れレイヤにはバイアスノードが存在している。

num_hidden_layers = 2
hidden_layers_dim = 50

ネットワークの入力と出力は以下である。

  • 入力値(CNTKコンセプトのキーとなるもの)

入力値は、モデル観測中(つまりトレーニング中)もモデル評価中(つまりテスト中)も異なる観測データ(データポイントもしくはサンプル、本例の青赤色で表現されたもの)のコンテナである。 つまり、inputの形状は提供される出力データの形状とマッチしていなければならない。 例えば、データが縦10ピクセルx横5ピクセルのデータであれば、入力の特徴値の次元は2である(画像データの縦、横で表現される)。 同様に、本例では入力データの次元は、年齢と腫瘍サイズの2、つまりinput_dim = 2である。 別のチュートリアルでは、より多くの次元のデータの処理についても取り扱う。

問題 今回のモデルでは、入力の次元値はいくつになるか? これはCNTKのネットワークやモデルの値を理解するためには必須のものである。

# The input variable (representing 1 observation, in our example of age and size) $\bf{x}$ which 
# in this case has a dimension of 2. 
#
# The label variable has a dimensionality equal to the number of output classes in our case 2. 

input = C.input_variable(input_dim)
label = C.input_variable(num_output_classes)

フィードフォワードネットワークのセットアップ

では1ステップずつフィードフォワードネットワークを構築していこう。 最初のレイヤは次元(input_dim,  m と表記)の入力特徴ベクトル( \bf x)を入力し、いわゆるevidence((hidden_layer_dim)次元(ここでは nと表記)を受け取る隠しレイヤに向けて渡す。 入力レイヤの各特徴データは、行列 \bf W  (m \times n)で表現される重みノードに接続される。 最初のステップでは、すべての特徴データに対してevidenceを計算することである。 注意 ここでは行列、ベクトルを表現するために太字を利用している。

 \bf z_1 = W \cdot x + b

ここで bは次元 nのバイアスベクトルである。

liner_layer関数では、以下の2つの処理を行う。

  1. 特徴データ( x)と重み( W)を乗算する。
  2. バイアス項 bを加算する。
def linear_layer(input_var, output_dim):
    input_dim = input_var.shape[0]
    
    weight = C.parameter(shape=(input_dim, output_dim))
    bias = C.parameter(shape=(output_dim))

    return bias + C.times(input_var, weight)

次のステップはevidence(線形レイヤの出力)を非線形関数(いわゆる「活性化関数」)に変形することである。 活性化関数を選択して、evidenceを活性化関数に変換することになる(活性化関数は、ここから選択する)。 Sigmoid関数やTanh関数が歴史的に利用されてきている。 本チュートリアルではSigmoid関数を利用する。Sigmoid関数の出力は、しばしば次のレイヤの入力として利用され、また最終レイヤの出力として利用される。

問題 そのほかの非線形関数を使用してみよう。これらの非線形関数の使用方法について慣れてみよう。

def dense_layer(input_var, output_dim, nonlinearity):
    l = linear_layer(input_var, output_dim)
    
    return nonlinearity(l)

完全接続された分類器を作成するためには、一つ隠れレイヤを作成するとそれを次々に接続しなければならない。 最初のレイヤの出力 \bt h_1 は次のレイヤの入力となる。

本チュートリルあるのネットワークでは2層のレイヤしか使用しないので、以下のようなコードとなる。

h1 = dense_layer(input_var, hidden_layer_dim, sigmoid)
h2 = dense_layer(h1, hidden_layer_dim, sigmoid)

より多層のネットワークについて素早く評価するためには、以下のような記述でもよいだろう。

h = dense_layer(input_var, hidden_layer_dim, sigmoid)
for i in range(1, num_hidden_layers):
    h = dense_layer(h, hidden_layer_dim, sigmoid)
# Define a multilayer feedforward classification model
def fully_connected_classifier_net(input_var, num_output_classes, hidden_layer_dim, 
                                   num_hidden_layers, nonlinearity):
    
    h = dense_layer(input_var, hidden_layer_dim, nonlinearity)
    for i in range(1, num_hidden_layers):
        h = dense_layer(h, hidden_layer_dim, nonlinearity)
    
    return linear_layer(h, num_output_classes)

ネットワークの出力zはネットワーク全体の出力として使用される。

# Create the fully connected classfier
z = fully_connected_classifier_net(input, num_output_classes, hidden_layers_dim, 
                                   num_hidden_layers, C.sigmoid)

前述のネットワークの記述がCNTKのプリミティブ関数群を使用してネットワークを構築する方法を理解するのを助ける一方で、より手早く高速なネットワークを構築するためにライブラリを使用する方法もある。 CNTKは(レゴブロックのような)共通部品の「レイヤ」を用意しており、それぞれ標準的なレイヤで構成されるネットワークの設計を簡単にする。 例えば、dense_layerDenseレイヤ関数によって簡単にディープモデルとして操作できるようになっている。 入力値(Input)をモデルに渡すと、ネットワークの出力が返される。

提案されるタスク 定義されたモデルをチェックし、create_model関数の出力と下記で上記のコードをカプセル化したものを比較せよ。

def create_model(features):
    with C.layers.default_options(init=C.layers.glorot_uniform(), activation=C.sigmoid):
        h = features
        for _ in range(num_hidden_layers):
            h = C.layers.Dense(hidden_layers_dim)(h)
        last_layer = C.layers.Dense(num_output_classes, activation = None)
        
        return last_layer(h)
        
z = create_model(input)

モデルパラメータのトレーニング

ネットワークが完成したら、次にモデルパラメータ \bt W \bt bのトレーニングを行う。 まずはネットワークを通って得られた出力[tex: \bt z{final_layer}] を確率 \bt pとして変換する。 [tex: \bt p = \text{softmax}\left( \bt z\text{final_layer} \right)]

トレーニング

トレーニングを実行するためには、ネットワークを通じて得られた値と実際の答えがどれくらい異なっているかを計算する必要がある。 つまり、実際のラベルに近づくように、ネットワークを通じて得られた確率を近づけていく必要がある。 この関数は「損失関数」と呼ばれ、学習したモデルと実際のトレーニングセットの誤差を示している。

  H(p) = -\sum_{j=1}^{C} y_j\log\left( p_j \right)

 pはsoftmax関数により生成された確率であり、  yはラベルを示している。 データにより提供されたラベルは「ground-truth label」とも呼ばれる。 このチュートリアルではラベルのクラスは2だが、ラベルの値も2つのじげんを持っている(num_output_classesの値、 Cと同一である)。 一般的には、手元にあるタスクが C個の異なるクラスに分類される場合、ラベルの値は C個の要素を持ち、 Cの要素が0のところにあれば、データポイントで表されるクラスを除いて1になる。 クロスエントロピー関数については、理解することを強く推奨する。

loss = C.cross_entropy_with_softmax(z, label)

評価

分類結果を評価するためには、evidenceのベクトルとして表現されているネットワークの出力値(softmax関数により確率に変換されている)はクラスの数と同一なので、それらを比較することである。

eval_error = C.classification_error(z, label)

誤差を最小にするためには様々な手法があるが、「確率的勾配効果法(Stochastic Gradient Descent: SGD)」という手法が最も有名である。 典型的に、この方法はランダムな初期値でモデルのパラメータを設定する。 SGDの最適化器は、予測されたラベルの値とground-truthラベルとの誤差を計算し、勾配効果法を使用して次のイタレーションのモデルパラメータを生成する。

上記のモデルパラメータの更新には、メモリ中にロードされているすべてのデータセットを使用して更新するわけではないという点で魅力的である。 この手法ではより少ないデータポイントを使って重みの更新を行う。 従ってより多くのデータセットを使用してトレーニングを行うことが可能となる。 しかし、単一のサンプルによって値を更新すると、各イタレーションにて結果が大きく異なる場合がある。 ここでは、複数の小さなデータセットをロードして、誤差と損失はその平均値とし、モデルパラメータを更新する。 これをミニバッチ法と呼ぶ。

ミニバッチでは、より多くのトレーニングデータセットを使用する。 異なるトレーニングサンプルを使用してモデルパラメータの更新を繰り返し、処理時間と損失を減らしていく。 エラー率が小さくなり、大きな更新がなくなった場合に、そのモデルはトレーニングが完了したということである。

最適化の一つのカギとなるのが、learning_rateと呼ばれる値である。 この値は各イタレーションにおいて計算した値がモデルの更新にどれだけ影響を与えるのかを決める値である。 この値については、今後のチュートリアルで詳細を述べる。 これらの情報に基づいて、トレーニング器を構成していく。

# Instantiate the trainer object to drive the model training
learning_rate = 0.5
lr_schedule = C.learning_rate_schedule(learning_rate, C.UnitType.minibatch) 
learner = C.sgd(z.parameters, lr_schedule)
trainer = C.Trainer(z, (loss, eval_error), [learner])

まずは、いくつかのサポート関数を作成して、学習した結果を可視化できるようにする。

# Define a utility function to compute the moving average sum.
# A more efficient implementation is possible with np.cumsum() function
def moving_average(a, w=10):    
    if len(a) < w: 
        return a[:]    # Need to send a copy of the array
    return [val if idx < w else sum(a[(idx-w):idx])/w for idx, val in enumerate(a)]


# Defines a utility that prints the training progress
def print_training_progress(trainer, mb, frequency, verbose=1):    
    training_loss = "NA"
    eval_error = "NA"

    if mb%frequency == 0:
        training_loss = trainer.previous_minibatch_loss_average
        eval_error = trainer.previous_minibatch_evaluation_average
        if verbose: 
            print ("Minibatch: {}, Train Loss: {}, Train Error: {}".format(mb, training_loss, eval_error))
        
    return mb, training_loss, eval_error

トレーニングの実行

トレーニングを実行する。各イタレーションでは25個のサンプルを入力する。これはミニバッチのサイズと同一である。 ここでは20000個のデータを学習させる。 ちなみに実際の場合には、これと同様のラベルデータを提供する。 ここでは全体のデータセットのうち70%をトレーニングで使用し、残りはモデルの評価のために取っておくものとする。

# Initialize the parameters for the trainer
minibatch_size = 25
num_samples = 20000
num_minibatches_to_train = num_samples / minibatch_size
# Run the trainer and perform model training
training_progress_output_freq = 20

plotdata = {"batchsize":[], "loss":[], "error":[]}

for i in range(0, int(num_minibatches_to_train)):
    features, labels = generate_random_data_sample(minibatch_size, input_dim, num_output_classes)
    
    # Specify the input variables mapping in the model to actual minibatch data for training
    trainer.train_minibatch({input : features, label : labels})
    batchsize, loss, error = print_training_progress(trainer, i, 
                                                     training_progress_output_freq, verbose=0)
    
    if not (loss == "NA" or error =="NA"):
        plotdata["batchsize"].append(batchsize)
        plotdata["loss"].append(loss)
        plotdata["error"].append(error)

ミニバッチのサイズを変えたことによるトレーニングのエラーの違いについてみていく。 学習途中の値をチェックすることにより、損失関数が小さくなっていることはわかる。 この値はイタレーション中にデータが間違って予測されていることを示す。 これはトレーニング中に新しい値が入力されたことにより発生する可能性がある。

このような現象を小さくするためには、ミニバッチのサイズを大きくすることがあげられる。 各イタレーションですべてのデータセットを使うことにより、理想的にはこのような現象を無くすことができる。 これにより、イタレーション中に損失関数がぶれることなく減少することを示す。 しかし、この手法ではすべてのデータセットに対して勾配を計算する必要があり、モデルパラメータの更新を大量に実行しなければならない。 この簡単な例では値自体はそこまで大きくないが、実際の事象においては全体のデータセットを学習させるために複数のパスを作成してパラメータのアップデートの計算が法外なものにならないように配慮する必要がある。

従って、私たちはミニバッチサイズを小さな値に設定し、大きなデータセットの処理中にSGDに負担がかからないようにしている。 これ以降のチュートリアルでは、CNTKの最適化器を利用して実際のデータセットで計算効率を高める手法についても紹介する。

# Compute the moving average loss to smooth out the noise in SGD
plotdata["avgloss"] = moving_average(plotdata["loss"])
plotdata["avgerror"] = moving_average(plotdata["error"])

# Plot the training loss and the training error
import matplotlib.pyplot as plt

plt.figure(1)
plt.subplot(211)
plt.plot(plotdata["batchsize"], plotdata["avgloss"], 'b--')
plt.xlabel('Minibatch number')
plt.ylabel('Loss')
plt.title('Minibatch run vs. Training loss')

plt.show()

plt.subplot(212)
plt.plot(plotdata["batchsize"], plotdata["avgerror"], 'r--')
plt.xlabel('Minibatch number')
plt.ylabel('Label Prediction Error')
plt.title('Minibatch run vs. Label Prediction Error')
plt.show()

f:id:msyksphinz:20170708233131p:plain

f:id:msyksphinz:20170708233139p:plain

評価・テスト

さて、ネットワークのトレーニングが完了した。トレーニングされたネットワークに、トレーニングで使用してこなかったデータを利用して評価を行ってみる。 これはしばしばテストと呼ばれる。 新しいデータセットを使用してネットワークの評価を行い、平均エラーと損失を評価する。 これには、trainer.test_minibatchを使用する。

# Generate new data
test_minibatch_size = 25
features, labels = generate_random_data_sample(test_minibatch_size, input_dim, num_output_classes)

trainer.test_minibatch({input : features, label : labels})
0.04

このエラー率は、トレーニング中のエラー率と比較可能であり、このネットワークはこれまでに観測したことのないデータに対しても非常に効果的に動作するということが分かった。 これはオーバフィッティングという現象を避けるためのカギとなるものである。

これまで私たちはエラーの測定を行い集計してきた。 全ての観測データにおいて、評価関数はすべてのクラスにおいてその確率を返すものになっている。 本チュートリアルでデフォルトのパラメータを使用する場合、各観測データについて2要素のベクトルを返すようになっている。 ネットワークの出力をsoftmax関数に渡すことによりこれを計算している。

何故ネットワークの出力をsoftmax関数に渡す必要があるのだろうか?

ネットワークを構成する方法には、活性化ノードの出力を含んでいる(例えば、図4の緑色のレイヤ)。 出力ノード(図4のオレンジ色のノード)は活性化データを確率に変換している。 簡単で効果的な方法は、活性化データをsoftmax関数に渡すことである。

# Figure 4
Image(url="http://cntk.ai/jup/feedforward_network.jpg", width=200, height=200)

f:id:msyksphinz:20170708233232p:plain

out = C.softmax(z)
predicted_label_probs = out.eval({input : features})
print("Label    :", [np.argmax(label) for label in labels])
print("Predicted:", [np.argmax(row) for row in predicted_label_probs])
Label    : [1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0]
Predicted: [1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0]