kerasで判別機を自作してAIの評価セットを作りたい

#序論
##AIは誰のものか
インドやシンガポールでは中学生でもAIを組むのが当たり前(*要出典)らしい。
実際、ライブラリを説明書通りコピペして書き換えればAIは出来上がるので、
全ての人がAIで開発する世の中が近づいてきているのかもしれない。

devコースで手掛けた2つのものは、いずれもMicrosoft Azure CustomVision APIを利用した。
CustomVisionはすごくお手軽で、それ自体使うのにコードを書くことは全く必要ない。
こうしたAIのAPIはAmazon、Google、NTTなども提供している他、ベンチャー企業も既に
数多くTech系の見本市に出展しており、これからも増えていくことが予想される。

AIが「全ての人のもの」になるのは近い。

##ブラックボックスに挑む
APIが増える中で開発者にとって「どれを使うか」は大きな問題になってくる。
AIの画像認識は実際に使ってみると人間が想像つかない判別基準を持っているのが分かる。
その中で、自分のデータセットや目的に合ったAPIを選べるかは
サービスのパフォーマンスに大きく影響するはずだ。

レギュレーションの面でも内閣府から日本におけるAI 7原則が発表されているが、
その中でこれからはAIサービスに判断基準の明示が求める方針が打ち出されている。
内閣府が求めなくても、もしAIの判断で損害を被った人が増えればどの道そうなるだろう。

##画像認識の過程を可視化する
最近、画像認識の分野で取り組まれていることに、認識過程の可視化がある。[^1]
[^1]: [1610.02391] Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization
判別機が画像を認識した際に、寄与率の高い入力をピックアップしてくれるアルゴリズムになっており、画像の場合は入力がピクセルと対応するため、元の画像にマッピングすると画像のどこが寄与しているのか、ヒートマップの形で視覚的に捉えられるというものだ。

##畳み込みニューラルネットワーク
次章から技術的な話に移るので、簡単に画像認識のアルゴリズムについて触れておく。

画像認識では畳み込みニューラルネットワーク(以下CNN)という手法が主に使われる。CNNはニューラルネットワーク(以下NN)の派生形で、ConvolutionやPooling（どちらもググるとすぐ出る）というプロセスで画像を縮小したり均質化しながら認識パターンを形成する。
※畳み込み = Convolutionの和訳

薄くしたクロワッサンの生地にチョコチップをバラ蒔いて、生地を畳んで重ねるとチョコチップ入りのクロワッサンが焼ける。私たちが知りたいのはバラまかれた全てのチョコチップ同士の総当たりの関係性ではなく、焼きあがった時のチョコチップの量と偏りである。

#Keras
##Kerasとは
TensorFlowをベースにして動く機械学習ライブラリ。ニューラルネットワーク(以下NN)で使われるLayerがクラスとして実装されており、好きなLayerをカスタマイズしてModelにaddしていくとNNの構築ができる。

##Layer
NNのアルゴリズムとして一番面白いところである。
それだけに説明が長いのだが、とりあえず使うものだけ覚えておけば組むことはできる。

例えばCIFAR-10を使った手書き文字判別の畳み込みニューラルネットワーク(以下CNN)の
サンプルコードは以下の通りとなっている。(公式github参照)
基本的には注釈のみ書き換えてある。

```python
import keras
from keras.datasets import cifar10
from keras.preprocessing.image import ImageDataGenerator
from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.layers import Conv2D, MaxPooling2D
import os

# 学習に関わるパラメータや基本的な設定の部分
batch_size = 32
num_classes = 10
epochs = 100
data_augmentation = True
num_predictions = 20
save_dir = os.path.join(os.getcwd(), 'saved_models')
model_name = 'keras_cifar10_trained_model.h5'

# CIFAR-10の読み込みと結果の確認
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
print('x_train shape:', x_train.shape)
print(x_train.shape[0], 'train samples')
print(x_test.shape[0], 'test samples')

# クラスラベルのデータをValueからBinaryマトリクス形式へ変換(おまじない系のコード)
y_train = keras.utils.to_categorical(y_train, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)

# 【重要】layer構成を設定
model = Sequential()
# 2次元のmatrixのConvolution層を追加
model.add(Conv2D(32, (3, 3), padding='same',
                 input_shape=x_train.shape[1:]))
model.add(Activation('relu'))
model.add(Conv2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

model.add(Conv2D(64, (3, 3), padding='same'))
model.add(Activation('relu'))
model.add(Conv2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

model.add(Flatten())
model.add(Dense(512))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(num_classes))
model.add(Activation('softmax'))

# 最適化の手法とパラメータを設定
opt = keras.optimizers.rmsprop(lr=0.0001, decay=1e-6)

# 損失関数をカテゴリ分けで使うcategorical_crossentropyとしてModelを実際に構築
model.compile(loss='categorical_crossentropy',
              optimizer=opt,
              metrics=['accuracy'])

# 各ピクセルのR/G/Bそれぞれを0-1に収まるリニアな256段階のfloat値に変換
x_train = x_train.astype('float32')
x_test = x_test.astype('float32')
x_train /= 255
x_test /= 255

# ??? 上の方でdata_augmentation=Trueにしているので使ってないと思う
if not data_augmentation:
    print('Not using data augmentation.')
    model.fit(x_train, y_train,
              batch_size=batch_size,
              epochs=epochs,
              validation_data=(x_test, y_test),
              shuffle=True)
else:
    print('Using real-time data augmentation.')
# 画像の水増しに関する設定、一旦読み飛ばす
    datagen = ImageDataGenerator(
        featurewise_center=False,  # set input mean to 0 over the dataset
        samplewise_center=False,  # set each sample mean to 0
        featurewise_std_normalization=False,  # divide inputs by std of the dataset
        samplewise_std_normalization=False,  # divide each input by its std
        zca_whitening=False,  # apply ZCA whitening
        zca_epsilon=1e-06,  # epsilon for ZCA whitening
        rotation_range=0,  # randomly rotate images in the range (degrees, 0 to 180)
        # randomly shift images horizontally (fraction of total width)
        width_shift_range=0.1,
        # randomly shift images vertically (fraction of total height)
        height_shift_range=0.1,
        shear_range=0.,  # set range for random shear
        zoom_range=0.,  # set range for random zoom
        channel_shift_range=0.,  # set range for random channel shifts
        # set mode for filling points outside the input boundaries
        fill_mode='nearest',
        cval=0.,  # value used for fill_mode = "constant"
        horizontal_flip=True,  # randomly flip images
        vertical_flip=False,  # randomly flip images
        # set rescaling factor (applied before any other transformation)
        rescale=None,
        # set function that will be applied on each input
        preprocessing_function=None,
        # image data format, either "channels_first" or "channels_last"
        data_format=None,
        # fraction of images reserved for validation (strictly between 0 and 1)
        validation_split=0.0)

    # Compute quantities required for feature-wise normalization
    # (std, mean, and principal components if ZCA whitening is applied).
    datagen.fit(x_train)

    # Fit the model on the batches generated by datagen.flow().
    model.fit_generator(datagen.flow(x_train, y_train,
                                     batch_size=batch_size),
                        #次の1行はgithubのコードにない(が、ないと動かない)ので追加
                        steps_per_epoch=int(x_train.shape[0] / float(batch_size)), 

                        epochs=epochs,
                        validation_data=(x_test, y_test),
                        workers=4)

# 結果の出力に関するところなので特に触る必要なし
if not os.path.isdir(save_dir):
    os.makedirs(save_dir)
model_path = os.path.join(save_dir, model_name)
model.save(model_path)
print('Saved trained model at %s ' % model_path)

# Score trained model.
scores = model.evaluate(x_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])
```

このCNNで使用されているレイヤーは以下の7種類だけである。
Dense
Conv2D
MaxPooling2D

Activation('relu')
Activation('softmax'))

Dropout
Flatten

###Dense
入力値と重み値から出力を計算する最もベーシックなNNの層。
単純な数式 y = x・w +bで表すことができる。

画像認識でいきなり使うとクロワッサンのチョコチップ1個1個の関係性を見ることになる。
そのためConvolutionやPoolingでデータを抽象化した後で使われる。

###Conv2D
画像認識の主役。Convolutionを2次元データに対して行う。
model.add(Conv2D(64, (3, 3), padding='same'))とすると、
入力を3×3ずつピックアップして3×3の重みテンソルをかけた値の行列を出力とする。
これを64パターンの重みテンソルで行う(出力行列も64個できる)。
画面の外側には0値(padding)を設ける。

初回のConv2dだけinput_shape=x_train.shape[1:]が入っているのは、
最初だけ入力サイズを明示する必要がある為で、ほぼおまじないコード。

###MaxPooling2D
データサイズを圧縮して特徴量の切り出しを行う。Conv2Dで出力した行列の
隣接の値のうち大きいほうを取るという手順で行列サイズを圧縮する。
model.add(MaxPooling2D(pool_size=(2, 2)))
であれば縦に1/2、横に1/2のサイズまでうまいこと落としてくれる。

これ、単純に(0,0)から1つ飛びで最大値取っているわけではないよね…?
それだと結果が安定しない気がする。よくわからないので深く考えず信用しよう。

###Activation('relu')
ニューラルネットワークは数値の集合をYesなのかNoなのかに演算するAIである。
そこで使われるのがActivation:活性化関数。一定以下なら0、一定以上なら1とするのが役割。
ただし、完全に0か1かにしてしまうと学習に使えなくなってしまうので、
一定以下なら0に近い値、一定以上なら1に近い値、中間の場合は中途半端な値も取りうる。

っというのが当初使われていたsigmoidなどの関数だが、実際に機械学習をやってみると
一定以上で1に近い値というのは学習の妨げになるということで、一定以下なら0に近い値で、
それ以上は大きくなるという関数が使われている。

reluは非常に簡単な関数で、y=max(0,x)。

###Activation('softmax')

softmaxも役割は同じで数式が異なる。
exp(x)を取り一定以下の入力は0に近い値に変換、exp(x)同士の合計で割る。
影響力のありそうな値だけ残して、それらが効いている確率を割り振るものと解釈できる。

###Dropout
入力のいくらかを全て0に変換し、次の層で無効化。
学習用データに対するオーバーフィッティング対策になるらしい。
Dropout(0.25)で25%のデータを無効化。

###Flatten
データを1次元に落とす。平滑化と訳されているが別の意味になるので誤訳だと思いたい。
[[1,2,3],[4,5,6],[7,8,9]]をFlattenにかけると[1,2,3,4,5,6,7,8,9]になる。
画像データが抽象化された後は、次元を持たせておくことに特に意味がない為。

#画像認識APIの評価手法を開発する
やっと本題。
今回の目論見は画像認識APIの評価データセットを作れないか？というものである。
手順を以下の通り考えた。
##ランダムでデータセットを生成するコードを書く
汎用性の高いデータセットCIFARから、毎回ランダムにミニデータセットを作る。
ここで選んだデータをCIFAR自体の代わりにデータセットとして用いる。
##恣意的に特定のパターンで判別能の高い判別機群を作る
Convolutionのkernelの設定をいじくったりFilterを駆使すれば、全体的に精度は出ないにしても
特定パターンに対してのみ判別力が高い判別機を作ることは可能であろう。
このような判別機をいくつか作る。
##判別機の特性に影響を受けやすいセットを見つける
ミニデータセットを判別機群にかけ、accuracyが判別機によって大きく異なるものを探す。
そのセットは判別機の評価に使えるポテンシャルがあるはずである。
このようなセットがあれば、評価データセット候補としておく。
##ポテンシャルが説明可能か検証する
いくら判別機によってaccuracyに差が出るからといっても、なぜ違うのか説明できなければ
判別基準を明らかにする評価方法とは言えない。

ここはGrad-CAMのようなヒートマップを使うことで視覚的に検証できる。

…が、多分ここまでは手が届かない。

とりあえずそれっぽいセットが出てきたら今回はOKとする。


#開発の経過と評価データセット
##動作テスト
###環境
Anaconda Navigator 1.9.4
Jupyter Notebook 5.7.2
Tensor Flow 1.12.0
Keras 2.2.4

###実行
先のサンプルコードを上記の環境で動かす。ただし、元の設定では時間がかかりすぎたので
batch_sizeは512、epochsは10とした。

###結果
Test loss: 1.4108820346832276
Test accuracy: 0.4974

6層でepochs=10だとこんなものなんだろうか、ちょっと心配…?

##ミニデータセット生成コード

```python
from __future__ import print_function
import numpy as np
import keras
import keras.backend as K
#通常はimport cifar10するが、cifar10の代わりに自作関数を使うためcifar10はimportしない
from keras.datasets import cifar
from keras.preprocessing.image import ImageDataGenerator
from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.layers import Conv2D, MaxPooling2D
import os

#データのデシリアライズ(cifarの内部モジュールとほぼ一緒)
def unpickle(f):
    import pickle
    fo = open(f, 'rb')
    d = pickle.load(fo, encoding='bytes')
    fo.close()
    return d

#CIFAR10のbatch1をロードして1,000このデータをランダムにピックアップ
def load_dim_batch():
    label_names = unpickle("cifar-10-batches-py/batches.meta")[b'label_names']
    d = unpickle("cifar-10-batches-py/data_batch_1")
    data = np.array(d[b"data"])
    labels = np.array(d[b"labels"])

    nsamples = len(data)

    labels = np.reshape(labels,(1,data.shape[0]))
    dim = 1000
    classes = 10

    mask = np.zeros(int(nsamples/classes), dtype=np.int)

    i = 0
    while i< int(dim/classes):
        rand = np.random.randint(0, int(nsamples/classes)-1)
        if mask[rand] !=1:
            mask[rand] = 1
            i = i+1

    mask_all = mask
    for i in range(1,classes):
        mask_all = np.concatenate((mask_all,mask))

    pick = (mask_all == 1)
    picked_data = data[pick,:]
    picked_labels = labels[:,pick]

    picked_data = picked_data.reshape(picked_data.shape[0], 3, 32, 32)
    print(picked_data.shape)
    return picked_data, picked_labels

#CNN本体から呼び出される、本来cifar10のモジュールを改変したもの
def load_data():

    num_train_samples = 1000
    x_train = np.empty((num_train_samples, 3, 32, 32), dtype='uint8')
    y_train = np.empty((num_train_samples,), dtype='uint8')

    (x_train[0:num_train_samples, :, :, :],
     y_train[0:num_train_samples]) = load_dim_batch()
    
    x_test, y_test = cifar.load_batch("cifar-10-batches-py/test_batch")

    y_train = np.reshape(y_train, (len(y_train), 1))
    y_test = np.reshape(y_test, (len(y_test), 1))

    if K.image_data_format() == 'channels_last':
        x_train = x_train.transpose(0, 2, 3, 1)
        x_test = x_test.transpose(0, 2, 3, 1)

    return (x_train, y_train), (x_test, y_test)
```

学習データの数を50,000→1,000に落としたためか、accuracyは0.2603に。
うわっ…私の精度、低すぎ…？

##修正
いくら精度自体を求めていないとはいえ、あまりに低いと差が出ても信憑性に欠けるので
次のように修正
###Layerの構成
Befor : conv→conv→Pooling→conv→conv→Pooling→Dense→Dense
After : conv→conv→Pooling→conv→conv→conv→Pooling→Dense→Dense
###画像の水増し処理
rotation:15
###画像の抽出枚数
1,000→5,000
###epoch数
10→20

##モデルの追加
###model_type1
特に変更なし。いわゆるコントロール。
###model_type2
スタートから2回のconv2Dでkernelのサイズを(3×3)から(5×5)に変更。
画像をやや広い視点で見るようなイメージ。
###model_type3
画像水増し処理でzca_whitenning=True, channel_shift_range=100.
色の影響を小さくする狙い。

##10回テストデータ抽出を行い実行…

と思ったのだが。
Jupyterのカーネルが頑張っている間に12日が終わりそうな気がしてきた。
計算が無事に終わったら更新ということで。

grad-mapは到達できず。

#所感

keras直感的に使えて素敵。コード自体も結構読みやすい。
AIの評価技法はそのうち誰かが開発するだろうけど、キャッチアップできるように
Grad-CAMやバックエンドの中身(CNTKとか)はもうちょっと調べたい。

お付き合いありがとうございました。ヘタクソ選手権の猫でも眺めて一息つきましょう。
![yaba-e1508892278704.jpg](https://qiita-image-store.s3.amazonaws.com/0/271466/807db0b1-120b-3959-795a-48ed0fdfc67f.jpeg)