Keras: Fashion-MNISTを使ってCNNを可視化する

Fahion-MNISTのデータを使って学習したニューラルネットワークの畳み込み層を可視化します。

Fashion-MNIST

Fashion-MNISTは衣料品の画像を10クラス (Coat, Shirtなど) に分類するデータセットです。MNISTと同じく、学習サンプル数60,000・テストサンプル数10,000で、各画像は28x28のグレースケールとなっています。Kerasに付属されていますので、簡単に利用できます。

github.com

モデルの実装と可視化

今回使うパッケージたちです。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

import keras
from keras.datasets import fashion_mnist
from keras.models import Model, Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, AveragePooling2D, MaxPooling2D
from sklearn.metrics import confusion_matrix

Fashion-MNISTのデータのロード

fashion_mnist.load_data()でFashion-MNISTの画像とラベルをロードできます。

  • 各画像は28ピクセルx28ピクセルで、各ピクセルは0〜255のグレースケール画像です
  • 0〜9のラベルとなっており、0が"T-shirt/top"、1が"Trouser"、...というようになってます
# 画像とラベルのデータのロード
(X_train_image, Y_train_label), (X_test_image, Y_test_label) = fashion_mnist.load_data()
print(X_train_image.shape, Y_train_label.shape, X_test_image.shape, Y_test_label.shape)
# ((60000, 28, 28), (60000,), (10000, 28, 28), (10000,))

# 値域を確認
print(np.min(X_train_image), np.max(X_train_image), np.min(X_test_image), np.max(X_test_image))
# (0, 255, 0, 255)

# ラベルに偏りが無いことを確認
print(np.unique(Y_train_label, return_counts=True))
# (array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=uint8), array([6000, 6000, 6000, 6000, 6000, 6000, 6000, 6000, 6000, 6000]))
print(np.unique(Y_test_label, return_counts=True))
# (array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=uint8), array([1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000]))

# ラベルデータをセット
# https://github.com/zalandoresearch/fashion-mnist#labels
label_map = pd.Series([
    'T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat',
    'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot'
])

# 36枚を表示
plt.figure(figsize=(10,10))
for i in range(36):
    plt.subplot(6, 6, i+1)
    plt.xticks([])
    plt.yticks([])
    plt.grid(False)
    plt.imshow(X_train_image[i])
    plt.xlabel(label_map[Y_train_label[i]])

学習サンプルの一部を表示してみると、こんな感じです。サンダルやバッグなども含まれていること、人が見てもコート・シャツ・セーター (pullover) の区別が難しそうなことなどがわかるかと思います。

LeNet-5で実装・学習

ベースとなるモデルとして、LeNet-5でニューラルネットワークを実装・学習します。以前の投稿も参考にしてみてください。

  • X -> Conv -> AveragePooling -> Conv -> AveragePooling -> FC -> FC -> FC -> softmax -> y という簡単な構造です
    • 活性化関数には"relu"を使っています
# 0.0〜1.0へ正規化
X_train = X_train_image.astype('float32')
X_test = X_test_image.astype('float32')
X_train /= 255
X_test /= 255

# チャネルの追加
X_train = X_train.reshape(X_train.shape + (1,))
X_test = X_test.reshape(X_test.shape + (1,))

# one-hotエンコーディング
num_labels = label_map.size
Y_train = keras.utils.to_categorical(Y_train_label, num_labels)
Y_test = keras.utils.to_categorical(Y_test_label, num_labels)

# モデルを作る
input_shape = (X_train.shape[1], X_train.shape[2], 1)

model = Sequential()

model.add(Conv2D(6, kernel_size=(5, 5), strides=(1, 1), padding='same', activation='relu', input_shape=input_shape))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))
model.add(Conv2D(16, kernel_size=(5, 5), strides=(1, 1), padding='valid', activation='relu'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))
model.add(Flatten())
model.add(Dense(120, activation='relu'))
model.add(Dense(84, activation='relu'))
model.add(Dense(num_labels, activation='softmax'))

model.compile(
    loss=keras.losses.categorical_crossentropy,
    optimizer=keras.optimizers.Adadelta(),
    metrics=['accuracy']
)

# 学習
epochs = 50
batch_size = 1000

history = model.fit(
    x=X_train,y=Y_train, epochs=epochs, batch_size=batch_size, 
    validation_data=(X_test, Y_test), verbose=1
)

損失関数の値の推移を見ると、20エポックあたりから学習データとテストデータで差が広がり始めており、過学習の傾向が見られます。

f:id:ohke:20190330150613p:plain

畳み込み層の可視化

各層の出力と重みについて可視化してみます。

出力の可視化

畳み込み層のみを抽出したモデルを作り、画像データ (ここではテストサンプル) を入力させます。

# 畳み込み層のみを抽出
conv_layers = [l.output for l in model.layers[:4]]
conv_model = Model(inputs=model.inputs, outputs=conv_layers)

# 畳み込み層の出力を取得
conv_outputs = conv_model.predict(X_test)

for i in range(len(conv_outputs)):
    print(f'layer {i}:{conv_outputs[i].shape}')

# layer 0:(10000, 28, 28, 6)
# layer 1:(10000, 14, 14, 6)
# layer 2:(10000, 10, 10, 16)
# layer 3:(10000, 5, 5, 16)

例えば、以下のスニーカーの画像の各層の出力を見ます。

f:id:ohke:20190330151750p:plain

def plot_conv_outputs(outputs):
    filters = outputs.shape[2]
    for i in range(filters):
        plt.subplot(filters/6 + 1, 6, i+1)
        plt.xticks([])
        plt.yticks([])
        plt.xlabel(f'filter {i}')
        plt.imshow(outputs[:,:,i])

# 1層目 (Conv2D)
plot_conv_outputs(conv_outputs[0][0])
# 2層目 (AveragePooling2D)
plot_conv_outputs(conv_outputs[1][0])
# 3層目 (Conv2D)
plot_conv_outputs(conv_outputs[2][0])
# 4層目 (AveragePooling2D)
plot_conv_outputs(conv_outputs[3][0])
  • 1層目のConv2Dは、輪郭抽出の働きをしていると思われます
  • 3層目のConv2Dは、解釈が難しいですが、画像の各パーツ (領域) の抽出に役立っていそうです
  • 2層目・3層目でAveragePoolingが挟まり、だんだん粗く・ぼやけた出力となっていってます

f:id:ohke:20190330151951p:plain

f:id:ohke:20190330152000p:plain

f:id:ohke:20190330152007p:plain

f:id:ohke:20190330152015p:plain

重みの可視化

次に重みを可視化します。

  • 各レイヤからget_weights()メソッドを使うことで重みを得られます
    • フィルタの重みとバイアスがタプルで返されます
def plot_conv_weights(filters):
    filter_num = filters.shape[3]
    
    for i in range(filter_num):
        plt.subplot(filter_num/6 + 1, 6, i+1)
        plt.xticks([])
        plt.yticks([])
        plt.xlabel(f'filter {i}')
        plt.imshow(filters[:, :, 0, i])

# 1層目 (Conv2D)
plot_conv_weights(model.layers[0].get_weights()[0])

解釈が難しいですが、1層目の重みを見ると全体的にぼやけていることから正則化が必要かな、ということろでしょうか。

f:id:ohke:20190330154405p:plain

改善

重みが少しノイジーでしたので、各層にDropoutを加えることで正則化効果を狙ってみます。また、どちらかというとより一般的なMaxPooling2Dに切り替えてみます。

new_model = Sequential()

new_model.add(Conv2D(6, kernel_size=(5, 5), strides=(1, 1), padding='same', activation='relu', input_shape=input_shape))
new_model.add(MaxPooling2D((2, 2), strides=(2, 2)))
new_model.add(Dropout(0.2))
new_model.add(Conv2D(16, kernel_size=(5, 5), strides=(1, 1), padding='valid', activation='relu'))
new_model.add(MaxPooling2D((2, 2), strides=(2, 2)))
new_model.add(Dropout(0.2))
new_model.add(Flatten())
new_model.add(Dense(120, activation='relu'))
new_model.add(Dropout(0.3))
new_model.add(Dense(84, activation='relu'))
new_model.add(Dense(num_labels, activation='softmax'))

new_model.compile(
    loss=keras.losses.categorical_crossentropy,
    optimizer=keras.optimizers.Adadelta(),
    metrics=['accuracy']
)

損失関数の値は0.25を下回ってきました (精度は90%程度) が、こんどは適合不足の傾向が見られました。

f:id:ohke:20190330223504p:plain

1層目 (Conv2D) の重みですが、もう少し白黒をはっきりすることを期待したのですが、今ひとつ違いがわからないというところです。

f:id:ohke:20190330223828p:plain

2層目と5層目 (いずれもMaxPooling2D) の出力ですが、輪郭をはっきり捉えられていそうです。

f:id:ohke:20190330223922p:plain

f:id:ohke:20190330224106p:plain

更に改善を進めてみます。

適合不足の傾向があり、また、各層の出力でも意味のある (真っ黒や真っ白が無い) になっていそうでしたので、今度は各層のフィルタの数を増やして、表現力を強めてみます。

  • 第1層と第4層の畳み込み層のフィルタをそれぞれ2倍にします
new_model = Sequential()

new_model.add(Conv2D(12, kernel_size=(5, 5), strides=(1, 1), padding='same', activation='relu', input_shape=input_shape))
new_model.add(MaxPooling2D((2, 2), strides=(2, 2)))
new_model.add(Dropout(0.2))
new_model.add(Conv2D(32, kernel_size=(5, 5), strides=(1, 1), padding='valid', activation='relu'))
new_model.add(MaxPooling2D((2, 2), strides=(2, 2)))
new_model.add(Dropout(0.2))
new_model.add(Flatten())
new_model.add(Dense(120, activation='relu'))
new_model.add(Dropout(0.3))
new_model.add(Dense(84, activation='relu'))
new_model.add(Dense(num_labels, activation='softmax'))

new_model.compile(
    loss=keras.losses.categorical_crossentropy,
    optimizer=keras.optimizers.Adadelta(),
    metrics=['accuracy']
)

テストサンプルの損失関数の値は0.21 (精度は92%) を切ってきています。

f:id:ohke:20190330224437p:plain

第5層 (2つ目のMaxPooling2D) の出力を見ると、いずれのフィルタの出力も強く反応している (白色) 箇所があるため、意味のあるものになっているようです。

f:id:ohke:20190330225246p:plain

まとめ

Fashion-MNISTを例に、CNNの可視化と、それを使った改善をやってみました。精度で90%から92%への小さな改善となりました。

Keras: ImageNetで学習済みのVGG16をPlaces365へ転移学習する

Kerasを使って、ImageNetで学習済みモデル (VGG16) をPlaces365の分類タスクへ転移学習する、ということに取り組みます。

今回使用するパッケージたちです。

import numpy as np
import pandas as pd
import os
import shutil
from keras.applications.vgg16 import VGG16, preprocess_input, decode_predictions
from keras.models import Sequential
from keras.layers import Flatten, Dense
from keras.preprocessing import image
from sklearn.model_selection import train_test_split

VGG16

VGG16は23層 (内 学習が必要な層は16層) からなる画像クラス分類タスクに特化したネットワークです (提案論文) 。下図は原文Table 1.の抜粋で、VGG16は右から2番目の構成です。

KerasにはImageNetで事前学習 (1000クラスの分類タスク) されたモデルが組み込まれております。このモデルを転移学習させる元 (ソースドメイン) とします。

Places365

Places365も画像の分類タスクなのですが、その名の通り365種類の場所を分類します。このタスクを転移学習させる先 (ターゲットドメイン) とします。

Places: A 10 million Image Database for Scene Recognition

学習済みモデルで分類

まずはVGG16の学習済みモデルを使って、任意の画像を分類します。

  • VGG16をインスタンスを生成
    • include_top=Trueで、全結合層も含めます (このあとの転移学習ではFalseにして全結合層を削除します)
    • weights='imagenet'で、ImageNetで学習したモデルになります
# 学習済みモデルをロード
model = VGG16(include_top=True, weights='imagenet')
model.summary()

224x224x3 -> (Conv2Dx2 -> MaxPooling)x2 -> (Conv2Dx3 -> MaxPooling)x3 -> FC -> FC -> sigmoid -> 1000 というネットワークになってます (上の表のとおりです) 。

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         (None, 224, 224, 3)       0         
_________________________________________________________________
block1_conv1 (Conv2D)        (None, 224, 224, 64)      1792      
_________________________________________________________________
block1_conv2 (Conv2D)        (None, 224, 224, 64)      36928     
_________________________________________________________________
block1_pool (MaxPooling2D)   (None, 112, 112, 64)      0         
_________________________________________________________________
block2_conv1 (Conv2D)        (None, 112, 112, 128)     73856     
_________________________________________________________________
block2_conv2 (Conv2D)        (None, 112, 112, 128)     147584    
_________________________________________________________________
block2_pool (MaxPooling2D)   (None, 56, 56, 128)       0         
_________________________________________________________________
block3_conv1 (Conv2D)        (None, 56, 56, 256)       295168    
_________________________________________________________________
block3_conv2 (Conv2D)        (None, 56, 56, 256)       590080    
_________________________________________________________________
block3_conv3 (Conv2D)        (None, 56, 56, 256)       590080    
_________________________________________________________________
block3_pool (MaxPooling2D)   (None, 28, 28, 256)       0         
_________________________________________________________________
block4_conv1 (Conv2D)        (None, 28, 28, 512)       1180160   
_________________________________________________________________
block4_conv2 (Conv2D)        (None, 28, 28, 512)       2359808   
_________________________________________________________________
block4_conv3 (Conv2D)        (None, 28, 28, 512)       2359808   
_________________________________________________________________
block4_pool (MaxPooling2D)   (None, 14, 14, 512)       0         
_________________________________________________________________
block5_conv1 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_conv2 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_conv3 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 7, 7, 512)         0         
_________________________________________________________________
flatten (Flatten)            (None, 25088)             0         
_________________________________________________________________
fc1 (Dense)                  (None, 4096)              102764544 
_________________________________________________________________
fc2 (Dense)                  (None, 4096)              16781312  
_________________________________________________________________
predictions (Dense)          (None, 1000)              4097000   
=================================================================
Total params: 138,357,544
Trainable params: 138,357,544
Non-trainable params: 0
_________________________________________________________________

こんな感じで予測します。

# 画像を224x224にリサイズしてnumpy形式でロード
target_size = (224, 224)

def load_input_image(path):
    x = image.img_to_array(image.load_img(path, target_size=target_size))
    x = np.expand_dims(x, axis=0)
    return x

# 予測値を算出
x = load_input_image('./images/yagi.jpg')
x_predicts = model.predict(preprocess_input(x))
results = decode_predictions(x_predicts, top=5)
for result in results:
    print(result)

まずはヤギの写真。四足の哺乳類という点は抑えてますが、ヒツジと間違えられてます。顔だけというのもあり、やむなしですね。

[('n02412080', 'ram', 0.29453668), 
 ('n02437616', 'llama', 0.19823727), 
 ('n01883070', 'wombat', 0.0629531), 
 ('n01882714', 'koala', 0.062317774), 
 ('n01877812', 'wallaby', 0.05154209)]

次は公園の写真。こちらは違和感の無い結果になってます。特定の物体ではなく、風景というニュアンスをきっちり汲み取っており、全体的な情景も表現可能なモデルとなっていることがわかります。

[('n09332890', 'lakeside', 0.71276414), 
 ('n02859443', 'boathouse', 0.08407786),
 ('n09468604', 'valley', 0.05165147),
 ('n02951358', 'canoe', 0.049545184),
 ('n02980441', 'castle', 0.039445978)]

最後にビルの写真。特徴的なカーブがベンチと勘違いさせた、ように見えますね (ベンチの画像をオーグメンテーションしてみたら類似の画像を作られるかもしれません) 。それ以外にも窓の枠や反射に引きずられて、良い感じの答えになってないです。

[('n03891251', 'park_bench', 0.14174516),
 ('n04258138', 'solar_dish', 0.13436465),
 ('n04435653', 'tile_roof', 0.12912594),
 ('n03028079', 'church', 0.10859317),
 ('n02825657', 'bell_cote', 0.06655419)]

転移学習

Places365 Standardのダウンロード

予めカレントディレクトにファイルをダウンロード・展開しておきます。1つ目には正解ラベル、2つ目には画像データがそれぞれ入ってます。

今回は少数のデータでも学習できることを確認するため、validation用のデータ (ラベルごとに100枚で、計36,500枚) を使いました。また各画像は256 x 256のsmall imageです。

$ wget http://data.csail.mit.edu/places/places365/filelist_places365-standard.tar
$ tar -xvf ./filelist_places365-standard.tar
$ wget http://data.csail.mit.edu/places/places365/val_256.tar
$ tar -xvf ./val_256.tar

2つほど画像データを見てみます。左は"/r/rock_arch"、右は"/g/golf_course"というラベルになってます。右は解釈しづらいですが、placeというドメインなのでゴルフコースというのは正しそうです。

f:id:ohke:20190323215358p:plainf:id:ohke:20190323215349p:plain

ラベルデータのロードと整形

各ファイルの正解ラベルをDataFrameの形式にします。

# 画像と正解ラベル (ID) の対応付け
labels = pd.read_csv('./places365_val.txt', sep=' ', header=None)
labels = labels.rename({0: 'file_name', 1: 'category_id'}, axis=1)
labels['file_name'] = labels['file_name'].astype(str)

# ラベルIDとラベル名の対応付け
categories = pd.read_csv('./categories_places365.txt', sep=' ', header=None)
categories = categories.rename({0: 'category_name', 1: 'category_id'}, axis=1)

labels = pd.merge(labels, categories).sort_values('file_name').reset_index(drop=True)

f:id:ohke:20190323215928p:plain

そして学習データとテストデータへ80:20で分割します。したがって学習データ数は29,200、テストデータ数は7,300となります。
このあと用いるgeneratorのための準備のため、学習用とテスト用の画像ファイルを格納するディレクトリを作成します (train_imagesとtest_images) 。さらに、正解ラベル (ここではcategory_id) と同じ名前のディレクトリをその下に作成し、ファイルをコピーします。例えば、category_id=1のテストデータはtest_images/1/の下、category_id=2の学習データはtrain_images/2/の下に、それぞれ配置します。

# 学習用データとテストデータで80:20で分割
train_labels, test_labels = train_test_split(labels, test_size=0.2, stratify=labels['category_id'], random_state=1)
train_size = train_labels.shape[0]
test_size = test_labels.shape[0]

# (test|train)_images/{category_id}/*.jpg にコピー
labels = 365
for parent in ['test_images', 'train_images']:
    os.mkdir(parent)
    for child in range(labels):
        os.mkdir(parent + '/' + str(child))

for i, f in zip(test_labels['category_id'], test_labels['file_name']):
    shutil.copy('./val_256/' + f, './test_images/' + str(i) + '/' + f)
    
for i, f in zip(train_labels['category_id'], train_labels['file_name']):
    shutil.copy('./val_256/' + f, './train_images/' + str(i) + '/' + f)

モデルの生成と学習

VGG16から全結合層を除いたモデルをロードし、代わりにオリジナルの全結合層2層 (FC -> sigmoid) を追加してます。

  • 学習済みモデルでtrainable=Falseとすることで、その層は重みが更新されなくなります
input_shape = (224, 224, 3)
classes = 365

# VGG16から全結合層を除く
conv_layers = VGG16(include_top=False, weights='imagenet', input_shape=input_shape, classes=classes)
conv_layers.trainable = False  # 学習させない (学習済みの重みを使う)

# VGG16に全結合層を追加
model = Sequential()
model.add(conv_layers)
model.add(Flatten())
model.add(Dense(1024, activation='relu'))
model.add(Dense(365, activation='sigmoid'))

model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

model.summary()

VGG16のパラメータはNon-trainableであることに着目してください。

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
vgg16 (Model)                (None, 7, 7, 512)         14714688  
_________________________________________________________________
flatten_2 (Flatten)          (None, 25088)             0         
_________________________________________________________________
dense_3 (Dense)              (None, 1024)              25691136  
_________________________________________________________________
dense_4 (Dense)              (None, 365)               374125    
=================================================================
Total params: 40,779,949
Trainable params: 26,065,261
Non-trainable params: 14,714,688
_________________________________________________________________

上のモデルを学習すると、20エポックでaccuracyで0.75となりました。まだサチってはいないので、エポック数を増やせば更に改善しそうです。

  • ImageDataGeneratorを使うことで、0〜255 -> 0.0〜1.0にスケールされた画像が生成されます
    • flow_from_directory()で、学習用のディレクトリを指定 (カテゴリ名などもパスから推定されます)
  • GPUを使って約100分かかりました
batch_size = 40
target_size = (224, 224)

# 学習データのジェネレータ
train_generator = image.ImageDataGenerator(rescale=1.0/255).flow_from_directory(
    './train_images/', target_size=target_size, class_mode='categorical', batch_size=batch_size
)
# Found 29200 images belonging to 365 classes.

# 学習
history = model.fit_generator(
    generator=train_generator, 
    steps_per_epoch=train_size/batch_size,
    epochs=20,
    verbose=2
)
# Epoch 1/20
#  - 309s - loss: 5.0425 - acc: 0.0372
# Epoch 2/20
#  - 308s - loss: 4.0309 - acc: 0.1147
# ...
# Epoch 20/20
#  - 304s - loss: 0.9108 - acc: 0.7510

テストデータでの評価

上で得られたモデルを使って、テストデータで評価しました。ところがaccuracyは0.10程度と学習データとかけ離れており、完全に過学習していました。

# テストデータのジェネレータ
test_generator = image.ImageDataGenerator(rescale=1.0/255).flow_from_directory(
    './test_images/', target_size=target_size, class_mode='categorical', batch_size=batch_size, shuffle=False
)
# Found 7300 images belonging to 365 classes.

# 評価
results = model.evaluate_generator(test_generator, steps=test_size/batch_size, verbose=1)
# [6.848537857891762, 0.1019178093194145]

まとめ

ImageNetで学習済みのVGG16を、Places365の分類タスクへ転移学習してみました。

学習データに対する精度はまあまあ良かったのですが、完全に過学習していました。オーグメンテーションしたり、全結合層をチューニングしたりなどはやはり必要だなあ、というところですね。

KerasでLeNet-5を実装してKuzushiji-MNISTを分類する

仕事でそろそろコンピュータビジョン系の力が必要になるかも、となってきましたので、チクタク勉強を始めてます。

今回はKerasを使ってKuzushiji-MNISTの文字を分類するネットワークをLeNet-5で実装する、ということに取り組みます。

Kuzushiji-MNIST

日本の古典籍のくずし字の画像とラベルからなるデータセット (doi:10.20676/00000341) で、人文学オープンデータ共同利用センターによって作成されました。

codh.rois.ac.jp

3つのデータセットがありますが、今回は一番手頃なKuzushiji-MNISTを使います。

  • Kuzushiji-MNIST
    • ひらがな10クラスの分類タスク (28x28のグレースケール画像とラベルを70,000セットを含む)
  • Kuzushiji-49
    • ひらがな49クラスの分類タスク (28x28のグレースケール画像とラベルを270,912セットを含む)
  • Kuzushiji-Kanji
    • 漢字3832クラスの分類タスク (64x64のグレースケール画像とラベルを140,426セットを含む)

GitHub上で公開されてます。

github.com

@online{clanuwat2018deep,
  author       = {Tarin Clanuwat and Mikel Bober-Irizar and Asanobu Kitamoto and Alex Lamb and Kazuaki Yamamoto and David Ha},
  title        = {Deep Learning for Classical Japanese Literature},
  date         = {2018-12-03},
  year         = {2018},
  eprintclass  = {cs.CV},
  eprinttype   = {arXiv},
  eprint       = {cs.CV/1812.01718},
}

KerasとTensorflowをインストールしておきます。

pip install keras tensorflow

Kerasで実装・分類

データセットの取得・概観

こちらのURLからデータセットをダウンロードしておきます。今回はNumPyフォーマットを使います。

wget http://codh.rois.ac.jp/kmnist/dataset/kmnist/kmnist-train-imgs.npz
wget http://codh.rois.ac.jp/kmnist/dataset/kmnist/kmnist-train-labels.npz
wget http://codh.rois.ac.jp/kmnist/dataset/kmnist/kmnist-test-imgs.npz
wget http://codh.rois.ac.jp/kmnist/dataset/kmnist/kmnist-test-labels.npz

ダウンロードしたファイルをarrayにロードします。あわせて、ラベルデータも読み込みます。

  • 0ならば'お'、1ならば'き'、... というようにひらがな10クラスとなってます
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# データのロード
X_train_images = np.load('kmnist-train-imgs.npz')['arr_0']
Y_train_labels = np.load('kmnist-train-labels.npz')['arr_0']
X_test_images = np.load('kmnist-test-imgs.npz')['arr_0']
Y_test_labels = np.load('kmnist-test-labels.npz')['arr_0']

# ラベルデータのロード
label_map = pd.read_csv('http://codh.rois.ac.jp/kmnist/dataset/kmnist/kmnist_classmap.csv')['char']
print(label_map)
# 0    お
# 1    き
# 2    す
# 3    つ
# 4    な
# 5    は
# 6    ま
# 7    や
# 8    れ
# 9    を
# Name: char, dtype: object
  • 学習データは60,000セット、テストデータは10,000セット
    • 学習データ・テストデータともに偏りはなく、10等分されてます
  • 画像は28x28で0〜255のグレースケール
# 画像の数・サイズを確認
print(X_train_images.shape, Y_train_labels.shape, X_test_images.shape, Y_test_labels.shape)
# ((60000, 28, 28, 1), (60000, 10), (10000, 28, 28, 1), (10000, 10))

# 値域を確認
np.min(X_train_images), np.max(X_train_images), np.min(X_test_images), np.max(X_test_images)
# (0, 255, 0, 255)

# ラベルの偏りを確認
print(np.unique(Y_train_labels, return_counts=True))
# (array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=uint8), array([6000, 6000, 6000, 6000, 6000, 6000, 6000, 6000, 6000, 6000]))
print(np.unique(Y_test_labels, return_counts=True))
# (array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=uint8), array([1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000]))

# 学習データ36枚を表示
plt.figure(figsize=(10,10))
for i in range(36):
    plt.subplot(6, 6, i+1)
    plt.xticks([])
    plt.yticks([])
    plt.grid(False)
    plt.imshow(X_train_images[i])
    plt.xlabel(Y_train_labels[i])
plt.show()

学習データを見てみますと、現代日本人ではなかなか理解できない文字となっていることがわかります。特に'を'は今とだいぶ違いますね。
公開されてから日も浅く、データの不備もいくつか指摘されています (https://github.com/rois-codh/kmnist/issues) ので、今後アノテーションが変わる可能性もあります。

KerasでLeNetを実装

それではKerasでモデルを実装していきます。

データの前処理

Kerasに入力できるように前処理を行います。

  • 各ピクセル (256階調の整数) を0.0〜1.0の小数へ正規化
  • チャネルの次元を追加 (RGBカラーなら3チャネルとなります)
  • keras.utils.to_categoricalでラベルをone-hotエンコーディング
import keras
from keras.models import Sequential
from keras.layers import Dense, Flatten
from keras.layers import Conv2D, AveragePooling2D

# 正規化
X_train = X_train_images.astype('float32')
X_test = X_test_images.astype('float32')
X_train /= 255
X_test /= 255

print(X_train.min(), X_train.max(), X_test.min(), X_test.max())
# (0.0, 1.0, 0.0, 1.0)

# チャネルの次元を加える
X_train = X_train.reshape(X_train.shape + (1,))
X_test = X_test.reshape(X_test.shape + (1,))

print(X_train.shape, X_test.shape)
# ((60000, 28, 28, 1), (10000, 28, 28, 1))

# one-hotエンコーディング
num_labels = label_map.size

Y_train = keras.utils.to_categorical(Y_train_labels, num_labels)
Y_test = keras.utils.to_categorical(Y_test_labels, num_labels)

print(Y_train.shape, Y_test.shape)
# ((60000, 10), (10000, 10))

モデルの作成

次にモデルを作ります。今回は元祖MNISTのLeNet-5を実装してみます (提案論文PDF、下図はFig 2.から抜粋) 。

  • X -> Conv -> Pooling -> Conv -> Pooling -> FC -> FC -> FC -> softmax -> y という簡単な構造です

input_shape = (X_train.shape[1], X_train.shape[2], 1)

model = Sequential()

model.add(Conv2D(6, kernel_size=(5, 5), strides=(1, 1), padding='same', activation='tanh', input_shape=input_shape))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))
model.add(Conv2D(16, kernel_size=(5, 5), strides=(1, 1), padding='valid', activation='tanh'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))
model.add(Flatten())
model.add(Dense(120, activation='tanh'))
model.add(Dense(84, activation='tanh'))
model.add(Dense(num_labels, activation='softmax'))

model.compile(
    loss=keras.losses.categorical_crossentropy,
    optimizer=keras.optimizers.Adadelta(),
    metrics=['accuracy']
)

print(model.summary())

作成したモデルはsummaryメソッドで概観できます。

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_8 (Conv2D)            (None, 28, 28, 6)         156       
_________________________________________________________________
average_pooling2d_7 (Average (None, 14, 14, 6)         0         
_________________________________________________________________
conv2d_9 (Conv2D)            (None, 10, 10, 16)        2416      
_________________________________________________________________
average_pooling2d_8 (Average (None, 5, 5, 16)          0         
_________________________________________________________________
flatten_4 (Flatten)          (None, 400)               0         
_________________________________________________________________
dense_9 (Dense)              (None, 120)               48120     
_________________________________________________________________
dense_10 (Dense)             (None, 84)                10164     
_________________________________________________________________
dense_11 (Dense)             (None, 10)                850       
=================================================================
Total params: 61,706
Trainable params: 61,706
Non-trainable params: 0
_________________________________________________________________

モデルの学習と検証

作成したモデルはfitメソッドで学習します。

  • エポック数とバッチサイズはハイパパラメータとして渡します

最終的には、学習データでは精度99%となってますが、テストデータで93%と、過学習の傾向が見られてます。

epochs = 30
batch_size = 1000

history = model.fit(x=X_train,y=Y_train, epochs=epochs, batch_size=batch_size, validation_data=(X_test, Y_test), verbose=1)
# Train on 60000 samples, validate on 10000 samples
# Epoch 1/30
# 60000/60000 [==============================] - 25s 423us/step - loss: 1.0245 - acc: 0.6948 - val_loss: 1.0961 - val_acc: 0.6553
# Epoch 2/30
# 60000/60000 [==============================] - 26s 431us/step - loss: 0.5329 - acc: 0.8411 - val_loss: 0.7834 - val_acc: 0.7537
# ...
# Epoch 30/30
# 60000/60000 [==============================] - 26s 431us/step - loss: 0.0280 - acc: 0.9933 - val_loss: 0.2718 - val_acc: 0.9298

# 損失関数の値の推移
epoch_array = np.array(range(30))
plt.plot(epoch_array, history.history['loss'], history.history['val_loss'])
plt.legend(['loss', 'val_loss'])
plt.show()

fitの返り値 (History) から学習過程の精度と損失関数の値を取得できます。

f:id:ohke:20190316140733p:plain

モデルを使った予測

最後に予測を行います。

予測はpredictメソッドで行います。10000の内、724の分類に失敗してました。

  • 間違ってラベル付けされた文字としては、1位が'す'、2位が'き'でした
Y_predict = model.predict(X_test)

# 誤ったテストデータを取得
labels_predict = np.argmax(Y_predict, axis=1)
labels_test = np.argmax(Y_test, axis=1)

miss_indexes = np.where(labels_predict != labels_test)[0]
print(len(miss_indexes)) # 724

miss_predict = labels_predict[miss_indexes]
miss_test = labels_test[miss_indexes]

print(np.unique(miss_test, return_counts=True))
# (array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
#  array([ 62, 102, 134,  45,  83,  80,  35,  69,  50,  64]))

# 誤った36枚を表示
plt.figure(figsize=(10,10))
for i in range(36):
    plt.subplot(6, 6, i+1)
    plt.xticks([])
    plt.yticks([])
    plt.grid(False)
    plt.imshow(X_test_images[miss_indexes[i]])
    plt.xlabel(f'{miss_test[i]} -> {miss_predict[i]}')

plt.show()

誤った画像を見てみると、うーん、確かに解釈が難しそうですね...