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%への小さな改善となりました。