CNTKでCNNを実装する

今回は、CNTK v2.1を使った簡単なCNNの実装例を紹介します。
計算環境は以下になります。

- Ubuntu16.04
- Python3.5
- CNTK v2.1

## データセットの準備
お馴染みの[MNISTデータセット](http://yann.lecun.com/exdb/mnist/)を使用します。
MNISTデータのダウンロード方法は色々ありますが、（個人的に）一番手軽な方法を取ります。

```python
# データセットの準備
mnist = datasets.fetch_mldata('MNIST original', data_home=".")
x = mnist.data
y = mnist.target
```
- sklearnのfetch_mldata()関数を使用することで、WebからMNISTデータを取得することができます。
- fetch_mldata()関数からは辞書型オブジェクトが返されるので、辞書型オブジェクトから画像データとラベルデータを抽出します。

## データの加工
### 画像データの加工
画像データに対しては、以下の3つの前処理を行っています。

```python
# 画像データの加工
x = x/255.0
x = x.reshape(-1, 1, 28, 28)
x = x.astype(np.float32)
```
- 規格化
    - MNIST画像の輝度値が0-1になるように255で割っています。
- 行列変換
    - CNTKの慣例として、行列の形を**［サンプル数, チャネル, 高さ, 幅］**に変換します。
- データ型の変換
    - CNTKでは、モデルに入力するデータの型をfloat32にします[^1]。

[^1]: データの型がintかfloat64だった場合、warningが出て[内部](https://github.com/Microsoft/CNTK/blob/master/bindings/python/cntk/core.py)でfloat32に変換される

### ラベルデータの加工
CNTKでは、ラベルデータをone-hot形式に変換する必要があります。

```python
# ラベルデータの加工
enc = OneHotEncoder(n_values=10)
y = enc.fit_transform(y.reshape(-1,1)).toarray().astype(np.float32)
```
- sklearnのOneHotEncoderを使用して、ラベルデータをone-hot形式に変換しています。
- 画像データと同じく、データ型をfloat32に変換します。


### 訓練データと検証データの分割

```python
# 加工したデータセットを、訓練データと検証データに分割する
x_train, x_test, y_train, y_test = train_test_split(
     x, y, test_size=1/7, random_state=72)
```
MNISTデータセットは7万枚あるため、訓練に6万枚、検証に1万枚使うように分割します。

## モデルの設定
### CNN関数の定義
ここでは、2つの畳み込み層（各畳み込み層の直後にpooling層を挿入）と1つの全結合層から成るCNNを
定義します。CNTKでは、以下のようにCNN関数を記述します。

```python
def CNN(feature):
    """CNN Structure with 2 convolution layers, 2 pooling layers, and 1 fully-connected layer"""
    with C.layers.default_options(init=C.initializer.glorot_uniform(), activation=C.ops.relu):
        x = feature
        h1 = C.layers.Convolution2D(filter_shape=(5,5), num_filters=16, pad=True)(x)
        h2 = C.layers.MaxPooling(filter_shape=(2,2), strides=(2,2))(h1)
        h3 = C.layers.Convolution2D(filter_shape=(5,5), num_filters=32, pad=True)(h2)
        h4 = C.layers.MaxPooling(filter_shape=(2,2), strides=(2,2))(h3)
        h5 = C.layers.Dense(10, activation=None)(h4)
        model = C.layers.Dropout(0.5)(h5)

    return model
```
- 引数のfeatureには、入力データのプレースホルダを与えます。

- default_options()は、引数のデフォルト値を一括して変更するために用いられます。
上記のコードでは、パラメータの初期値分布のデフォルトを"glorotの一様分布"、活性化関数のデフォルトを"ReLU"となるよう、with構文内に存在する全ての関数に対して引数デフォルトの変更を適用させます。

- Convolution2D()は畳み込み層を意味します。
第1引数にはintまたはタプルで指定したフィルタサイズ、第2引数にはフィルタ数（チャネル）、
第3引数にはbool値(パディングを行うか否か)を与えます。

- MaxPooling()はプーリング層を意味します。
第1引数にはintまたはタプルで指定したフィルタサイズ、
第2引数にはintまたはタプルで指定したストライドサイズを与えます。

- Dense()は全結合層を意味しており、第1引数には出力の次元を与えます。
MNISTデータセットのラベルの種類は10であるため、出力次元も10に指定します。

- Dropout()はドロップアウト層を意味します（おまけでつけました）。
第1引数にはドロップアウト率を与えます（今回は50%に設定）。

### モデルオブジェクトの作成
CNN関数を定義した後は、モデルオブジェクトを作成します。

```python
# モデルオブジェクトの作成
feature = C.input((1, 28, 28))
target = C.input(10)
model = CNN(feature)
```
- featureは入力データのプレースホルダになります。先ほど前処理を行った画像データの形に合わせる必要がありますが、サンプル数の次元を抜いた**［チャネル, 高さ, 幅］**の形で作成して下さい。

- targetはラベルデータのプレースホルダになります。これもone-hot形式に変換したラベルデータの次元に合わせる必要があります（後の学習設定で使用します）。

- 入力データのプレースホルダをCNN関数の引数に与えて、モデルオブジェクトを作成します。


## 学習設定
### 最適化手法の設定
CNTKでは多くの最適化手法が実装されています。[CNTKで提供されている最適化手法一覧](https://cntk.ai/pythondocs/cntk.learners.html)
今回は、基本的な勾配降下法であるSGDを採用します。また、学習中の学習率を0.01に固定します。

```python
# 最適化手法の設定
lr_schedule = C.learners.learning_rate_schedule(0.01, unit=C.UnitType.sample)
optimizer = C.learners.sgd(model.parameters, lr=lr_schedule)
```
- 始めに、学習中における、SGDの学習率を管理するオブジェクトを作成する必要があります。
leraning_rate_schedule()の第1引数には学習率を与えます。今回は学習中の学習率を固定するため、float型の数値を入力していますが、もし学習中に学習率を変動させたい場合は、リストを与える必要があります[^2]。
第2引数には、C.UnitType.sample か C.UnitType.minibatch を与えます。これは、学習率を"サンプル単位"で変化させるか、"ミニバッチ単位"で変化させるか、を指定するものです。今回は学習率を固定しているため、どちらを与えても構いません[^3]。

- 次にoptimizerオブジェクトを作成します。sgd()の第1引数にはネットワークパラメータ、第2引数には作成した学習率管理のオブジェクトを与えます。

[^2]: 例えば、1000サンプル毎に学習率を変化させる場合は、以下のように記述する
      
      ```python
      lr = learning_rate_schedule([0.1, 0.01, 0.001], UnitType.sample, 1000)
      ```

[^3]: 第2引数にデフォルト値が設定されていないため、どちらかを与えないといけない

### 損失関数と評価指標の設定
訓練に必要となる損失関数を設定します。今回は(Softmax付きの)クロスエントロピー関数を採用します。
また、学習中のモデルの分類精度を確認するために、エラー率も設定します。

```python
# 損失とエラー率を計算するためのプレースホルダの作成
loss = C.losses.cross_entropy_with_softmax(model, target)
error = C.metrics.classification_error(model, target)
```
これらも、プレースホルダとして作成します。

### Trainerオブジェクトの設定
CNTKでは、モデルの訓練を行うために、Trainerオブジェクトを設定する必要があります。

```python
# Trainerオブジェクトを作成
trainer = C.Trainer(model, (loss, error), optimizer)
```
第1引数には訓練を行うモデルのオブジェクト、第2引数には損失とエラー率のプレースホルダで構成される
タプル(loss, error)[^4]、第3引数にはoptimizerオブジェクトを与えます。

[^4]: タプルは(loss, error)の順にしなければならない

## 学習の実行
後は訓練データを与えて学習を実行するだけです。
今回は、エポック数20、バッチサイズ100で計算を行います。

先ほど作成したtrainerオブジェクトにミニバッチを与えることで学習が行われますが、trainerオブジェクトへの入力形式に注意してください。CNTKでは、ミニバッチを辞書型オブジェクトに変換して、trainerオブジェクトに与える必要があります。

```python
# 訓練データを用いて、モデルパラメータを更新
input_map = {feature: x_batch, target: y_batch}
trainer.train_minibatch(input_map)
```
辞書型オブジェクトのkeyがプレースホルダ、valueがデータに対応しており、
{feature_placeholder: feature_data, label_placeholder: label_data}の形でオブジェクトを作成します[^6]。

[^6]: 入力データのプレースホルダは model.arguments[0] とも表すことができるため、input_mapは以下の記述でも問題ない

      ```python
      input_map = {model.arguments[0]: x_batch, target: y_batch}
      ```

実装したコードでは、100バッチ毎に、訓練データ1万件と検証データ1万件に対して、
損失値と正解率[^5]を算出しています。

```python
>>> ipython cnn_minst.py
Selected GPU[1] Tesla K80 as the process wide default device.
Epoch: 1,   Step:100,   Train Accuracy: 0.6610,   Test Accuracy: 0.6479,   Train Loss: 1.9625,   Test Loss: 1.9625,
Epoch: 1,   Step:200,   Train Accuracy: 0.8109,   Test Accuracy: 0.8021,   Train Loss: 1.1317,   Test Loss: 1.1303,
Epoch: 1,   Step:300,   Train Accuracy: 0.8647,   Test Accuracy: 0.8529,   Train Loss: 0.7551,   Test Loss: 0.7594,
Epoch: 1,   Step:400,   Train Accuracy: 0.8841,   Test Accuracy: 0.8759,   Train Loss: 0.5951,   Test Loss: 0.6015,
Epoch: 1,   Step:500,   Train Accuracy: 0.8951,   Test Accuracy: 0.8906,   Train Loss: 0.5195,   Test Loss: 0.5251,
Epoch: 1,   Step:600,   Train Accuracy: 0.8977,   Test Accuracy: 0.8987,   Train Loss: 0.4800,   Test Loss: 0.4814,
.
.
.
Epoch: 20,   Step:11500,   Train Accuracy: 0.9871,   Test Accuracy: 0.9846,   Train Loss: 0.0572,   Test Loss: 0.0655,
Epoch: 20,   Step:11600,   Train Accuracy: 0.9845,   Test Accuracy: 0.9844,   Train Loss: 0.0579,   Test Loss: 0.0646,
Epoch: 20,   Step:11700,   Train Accuracy: 0.9862,   Test Accuracy: 0.9841,   Train Loss: 0.0600,   Test Loss: 0.0664,
Epoch: 20,   Step:11800,   Train Accuracy: 0.9857,   Test Accuracy: 0.9832,   Train Loss: 0.0581,   Test Loss: 0.0643,
Epoch: 20,   Step:11900,   Train Accuracy: 0.9852,   Test Accuracy: 0.9848,   Train Loss: 0.0575,   Test Loss: 0.0641,
Epoch: 20,   Step:12000,   Train Accuracy: 0.9845,   Test Accuracy: 0.9828,   Train Loss: 0.0597,   Test Loss: 0.0658,
```

訓練終了時のモデルの性能は以下の通りであり、モデルが過学習状態でないことが確認できました。

```
Epoch: 20,   Step:12000,   Train Accuracy: 0.9845,   Test Accuracy: 0.9828,   Train Loss: 0.0597,   Test Loss: 0.0658,
```

**以上**

[^5]: (正解率) = 1 - (エラー率)


## 実装コード

```cnn_mnist.py
import numpy as np
import cntk as C
from sklearn import datasets
from sklearn.utils import shuffle
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder


def CNN(feature):
    """CNN Structure with 2 convolution layers, 2 pooling layers, and 1 fully-connected layer"""
    with C.layers.default_options(init=C.initializer.glorot_uniform(), activation=C.ops.relu):
        x = feature
        h1 = C.layers.Convolution2D(filter_shape=(5,5), num_filters=16, pad=True)(x)
        h2 = C.layers.MaxPooling(filter_shape=(2,2), strides=(2,2))(h1)
        h3 = C.layers.Convolution2D(filter_shape=(5,5), num_filters=32, pad=True)(h2)
        h4 = C.layers.MaxPooling(filter_shape=(2,2), strides=(2,2))(h3)
        h5 = C.layers.Dense(10, activation=None)(h4)
        model = C.layers.Dropout(0.5)(h5)

    return model


if __name__ == '__main__':
    # データセットの準備
    mnist = datasets.fetch_mldata('MNIST original', data_home=".")
    x = mnist.data
    y = mnist.target

    # 画像データの加工
    x = x/255.0
    x = x.reshape(-1, 1, 28, 28)
    x = x.astype(np.float32)

    # ラベルデータの加工
    enc = OneHotEncoder(n_values=10)
    y = enc.fit_transform(y.reshape(-1,1)).toarray().astype(np.float32)

    # 加工したデータセットを、訓練データと検証データに分割する
    x_train, x_test, y_train, y_test = train_test_split(
        x, y, test_size=1/7, random_state=72)

    # モデルオブジェクトの作成
    feature = C.input((1, 28, 28))
    target = C.input(10)
    model = CNN(feature)

    # 最適化手法の設定
    lr_schedule = C.learners.learning_rate_schedule(0.01, unit=C.UnitType.minibatch)
    optimizer = C.learners.sgd(model.parameters, lr=lr_schedule)

    # 損失と評価指標を計算するためのプレースホルダの作成
    loss = C.losses.cross_entropy_with_softmax(model, target)
    error = C.metrics.classification_error(model, target)

    # Trainerオブジェクトを作成
    trainer = C.Trainer(model, (loss, error), optimizer)

    # 学習を実行する
    n_epoch = 20
    batch_size = 100
    n_step = 1

    for epoch in range(n_epoch):
        # エポック毎に訓練データをシャッフル
        x_train, y_train = shuffle(x_train, y_train)

        for i in range(0, len(y_train), batch_size):
            # バッチ毎に訓練データを抽出
            x_batch = x_train[i:i+batch_size]
            y_batch = y_train[i:i+batch_size]

            # 訓練データを用いて、モデルパラメータを更新
            input_map = {feature: x_batch, target: y_batch}
            trainer.train_minibatch(input_map)

            # 100ステップ毎に精度を出力
            if n_step % 100 == 0:

                # 訓練データに対して損失と正解率を算出
                input_map = {feature: x_train[:10000], target: y_train[:10000]}
                train_loss = loss.eval(input_map).mean(axis=0)
                train_accuracy = 1.0 - error.eval(input_map).mean(axis=0)

                # 検証データに対して損失と正解率を算出
                input_map = {feature: x_test[:10000], target: y_test[:10000]}
                test_loss = loss.eval(input_map).mean(axis=0)
                test_accuracy = 1.0 - error.eval(input_map).mean(axis=0)

                print("Epoch: %s,   Step:%s,   Train Accuracy: %.4f,   Test Accuracy: %.4f,   Train Loss: %.4f,   Test Loss: %.4f,"
                      % (epoch+1, n_step, train_accuracy, test_accuracy, train_loss, test_loss))
            n_step += 1


```

## 脚注