半年ほど、古文書(正確には古典籍)のデータを用いた機械学習で遊んでいたが、どうにも行き詰った感じなので、気分転換にネタを変えてみることにした。
今回のテーマは「笑っている犬」と「怒っている犬」を判別すること。私は犬を飼っているわけではないので、犬の表情に詳しいわけではないが、笑っている犬と怒っている犬の写真はだいたい区別できる。
これは笑っているように見える。データ元はフリー写真素材ぱくたそ
これは怒っている。Photoangel - jp.freepik.com によって作成された background 写真
ぱっと見の印象は全然違うが、それぞれの特徴を言語化してみると、口が開いている、歯が見える、目を細めているなどなど、意外と似ているのかもしれない。これをCNNでうまく判別できるのかを試してみた。
###データ収集
著作権フリー画像サイトでは、笑っている犬と怒っている犬の画像が大量にあるわけではないので、画像をどこから入手するか悩んでいたところ、STORIA法律事務所のBLOGによると、ネットからダウンロードした著作権フリーではない画像を学習データに使って、機械学習のモデルを作ることは著作権違反にはならないらしい。
ということで、googleから画像を一括でダウンロードするツールgoogle_images_download を使うことにした。
まずは、Anaconda promptから、
> pip install google_images_download
でツールをインストール。(最初はcondaで試したが、condaにはライブラリがなかった)
そのまま同じプロンプトで画像をダウンロードしようとしたら、
Evaluating...
Looks like we cannot locate the path the 'chromedriver' (use the '--chromedriver' argument to specify the path to the executable.) or google chrome browser is not installed on your machine (exception: argument of type 'NoneType' is not iterable)
こんなエラーが出た。要はchromedriverがないということなので、googleのchromedriverのサイトからchromedriver_win32.zipをダウンロードして適当な場所に解凍。(ダウンロードする前に、Chromeのバージョンを確認して、同じバージョンのchromedriverを選択しないとエラーになる。)
で、キーワードを指定して画像をダウンロード。
英語と日本語でキーワードを指定して、各500枚ずつの画像をダウンロードする。
> googleimagesdownload --keywords "笑っている犬" --limit 500 --output_directory "C:\Users\User\Desktop\dog\down" --language Japanese --chromedriver "(chromedriverを解凍したフォルダ)\chromedriver.exe"
> googleimagesdownload --keywords "怒っている犬" --limit 500 --output_directory "C:\Users\User\Desktop\dog\down" --language Japanese --chromedriver "(chromedriverを解凍したフォルダ)\chromedriver.exe"
> googleimagesdownload --keywords "smile dog" --limit 500 --output_directory "C:\Users\User\Desktop\dog\down"" --language English --chromedriver "(chromedriverを解凍したフォルダ)\chromedriver.exe"
> googleimagesdownload --keywords "angry dog" --limit 500 --output_directory "C:\Users\User\Desktop\dog\down"" --language English --chromedriver "(chromedriverを解凍したフォルダ)\chromedriver.exe"
画像は C:\Users\User\Desktop\dog\down(入力したキーワード)
のフォルダにダウンロードされた。
フォルダの中を見てみると、Googleの画像検索で、同じキーワードで検索したときと同じ画像がダウンロードされているようだ。
笑っている犬、怒っている犬の写真だけでなく、イラスト、他の動物、表情が分かりにくい犬の写真、人や他の動物や物が一緒に写っている写真もたくさんある。
そのため、私の目で見て、明らかに笑っている犬と怒っている犬の写真を各120枚ずつ選択した。
キーワードの精度(各キーワードで欲しい画像がどのぐらい入っているか)は、
「笑っている犬」>「angry dog」>「怒っている犬」>「smile dog」
だった。写真をアップする飼い主さんにも、見る人にも、怖い犬より可愛い犬のほうが好まれそうだから、笑っている犬がたくさん出てくるのは分かるのだが、英語の「smile dog」ではほとんど良いデータがなかったのは、何故だろうか? 英語ネイティブの人たちは犬にはsmileを使わないのか?それとも店の名前などによく使われるからなのか?
###学習データ、テストデータ作成
選んだ120枚ずつの画像を、画像形式がjpegでないものは、Windows標準の「ペイント」でjpegに変換してから、dog_smileとdog_angryという名前のフォルダに格納した。(意外とここまでの前処理の手作業が大変だった。)
笑/怒で各120枚のデータのうち、1~100枚目までを学習データ、101~120枚目までをテストデータとした。
何となく、笑=1、怒=0のほうがイメージとしてしっくり来るので、データの読み込みは怒->笑の順番にした。
# 画像を読み込んで、行列に変換する関数を定義
from keras.preprocessing.image import load_img, img_to_array
def img_to_traindata(file, img_rows, img_cols, rgb):
if rgb == 0:
img = load_img(file, color_mode = "grayscale", target_size=(img_rows,img_cols)) # grayscaleで読み込み
else:
img = load_img(file, color_mode = "rgb", target_size=(img_rows,img_cols)) # RGBで読み込み
x = img_to_array(img)
x = x.astype('float32')
x /= 255
return x
# 学習データ、テストデータ生成
import glob, os
img_rows = 224 # 画像サイズはVGG16のデフォルトサイズとする
img_cols = 224
nb_classes = 2 # 怒っている、笑っているの2クラス
img_dirs = ["./dog_angry", "./dog_smile"] # 怒っている犬、笑っている犬の画像を格納したディレクトリ
X_train = []
Y_train = []
X_test = []
Y_test = []
for n, img_dir in enumerate(img_dirs):
img_files = glob.glob(img_dir+"/*.jpg") # ディレクトリ内の画像ファイルを全部読み込む
for i, img_file in enumerate(img_files): # ディレクトリ(文字種)内の全ファイルに対して
x = img_to_traindata(img_file, img_rows, img_cols, 1) # 各画像ファイルをRGBで読み込んで行列に変換
if i < 100: # 1~100枚目までを学習データ
X_train.append(x) # 学習用データ(入力)に画像を変換した行列を追加
Y_train.append(n) # 学習用データ(出力)にクラス(怒=0、笑=1)を追加
else: # 101~120枚目までをテストデータ
X_test.append(x) # テストデータ(入力)に画像を変換した行列を追加
Y_test.append(n) # テストデータ(出力)にクラス(怒=0、笑=1)を追加
import numpy as np
# 学習、テストデータをlistからnumpy.ndarrayに変換
X_train = np.array(X_train, dtype='float')
Y_train = np.array(Y_train, dtype='int')
X_test = np.array(X_test, dtype='float')
Y_test = np.array(Y_test, dtype='int')
# カテゴリカルデータ(ベクトル)に変換
from keras.utils import np_utils
Y_train = np_utils.to_categorical(Y_train, nb_classes)
Y_test = np_utils.to_categorical(Y_test, nb_classes)
# 作成した学習データ、テストデータをファイル保存
np.save('models/X_train_2class_120.npy', X_train)
np.save('models/X_test_2class_120.npy', X_test)
np.save('models/Y_train_2class_120.npy', Y_train)
np.save('models/Y_test_2class_120.npy', Y_test)
# 作成したデータの型を表示
print(X_train.shape)
print(Y_train.shape)
print(X_test.shape)
出力結果は、
(200, 224, 224, 3)
(200, 2)
(40, 224, 224, 3)
となったので、意図した通り、学習データ各100個、合計200個、テストデータ各20個、合計40個となっていることが分かる。
###モデル定義&学習(CNN)
次に、モデルを作って学習させてみた。モデルは日本の古文書で機械学習を試す(10)で使ったのと同じ、畳み込み3層のものをベースとして、2クラス分類なので、全結合層をsoftmax->sigmoid、損失関数をcategorical_crossentropy -> binary_crossentropyに変更した。
from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.layers import Conv2D, MaxPooling2D
# 【パラメータ設定】
batch_size = 20
epochs = 30
input_shape = (img_rows, img_cols, 3)
nb_filters = 32
# size of pooling area for max pooling
pool_size = (2, 2)
# convolution kernel size
kernel_size = (3, 3)
# 【モデル定義】
model = Sequential()
model.add(Conv2D(nb_filters, kernel_size, # 畳み込み層
padding='valid',
activation='relu',
input_shape=input_shape))
model.add(Conv2D(nb_filters, kernel_size, activation='relu')) # 畳み込み層
model.add(MaxPooling2D(pool_size=pool_size)) # プーリング層
model.add(Conv2D(nb_filters, kernel_size, activation='relu')) # 畳み込み層
model.add(MaxPooling2D(pool_size=pool_size)) # プーリング層
model.add(Dropout(0.25)) # ドロップアウト(過学習防止のため、入力と出力の間をランダムに切断)
model.add(Flatten()) # 多次元配列を1次元配列に変換
model.add(Dense(128, activation='relu')) # 全結合層
model.add(Dropout(0.2)) # ドロップアウト
model.add(Dense(nb_classes, activation='sigmoid')) # 2クラスなので全結合層をsigmoid
# モデルのコンパイル
model.compile(loss='binary_crossentropy', # 2クラスなのでbinary_crossentropy
optimizer='adam', # 最適化関数のパラメータはデフォルトを使う
metrics=['accuracy'])
# 【各エポックごとの学習結果を生成するためのコールバックを定義(前回より精度が良い時だけ保存)】
from keras.callbacks import ModelCheckpoint
import os
model_checkpoint = ModelCheckpoint(
filepath=os.path.join('models','model_2class120_{epoch:02d}_{val_acc:.3f}.h5'),
monitor='val_acc',
mode='max',
save_best_only=True,
verbose=1)
# 【学習】
result = model.fit(X_train, Y_train, batch_size=batch_size, epochs=epochs, verbose=1, validation_data=(X_test, Y_test),
callbacks=[model_checkpoint])
学習時間は1エポック当たり45~50秒、テストデータに対する最大精度は8/30エポック目の0.700だった。
学習経過を可視化してみると、10エポックぐらいで学習データに対する精度がほぼ1になり、その後は過学習気味となっている。
# 【学習データとテストデータに対する正解率をプロット】
import matplotlib.pyplot as plt
%matplotlib inline
plt.plot(range(1, epochs+1), result.history['acc'], label="Training")
plt.plot(range(1, epochs+1), result.history['val_acc'], label="Validation")
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.ylim([0,1])# y軸の最小値、最大値
plt.grid(True) # グリッドを表示
plt.xticks(np.arange(0, epochs+1, 10))
plt.legend(bbox_to_anchor=(1.8, 0), loc='lower right', borderaxespad=1, fontsize=15)
plt.show()
ImageDataGeneratorで学習データを水増し
学習データ数が少なめなので、ImageDataGeneratorを使って、データを水増ししてみた。
まず、水増しする条件を指定してImageDataGeneratorオブジェクトを作成する。バッチサイズは先ほどと同じ20にする。
from keras.preprocessing.image import ImageDataGenerator
train_datagen = ImageDataGenerator(
rotation_range=20, # 20°までランダムに回転
width_shift_range=0.1, # 水平方向にランダムでシフト
height_shift_range=0.1, # 垂直方向にランダムでシフト
shear_range=0.19, # 斜め方向(pi/16まで)にランダムに引っ張る
zoom_range=0.1 # ランダムにズーム
)
train_generator = train_datagen.flow(X_train, Y_train, batch_size=batch_size, seed = 16)
モデルは上と同じ畳み込み3層のものを定義した。後は、ModelCheckpointで保存するモデルの名前を変更し、model.fitを以下のように変えて実行するだけ。
result = model.fit_generator(train_generator,
samples_per_epoch=X_train.shape[0],
epochs=epochs,
verbose=1,
validation_data=(X_test, Y_test),
callbacks=[model_checkpoint])
samples_per_epoch(1エポック当たりの学習データ数)は、元の学習データ数と同じにする。実行すると、「samples_per_epochではなく、Keras 2 APIのsteps_per_epochを使うように」という警告が出たが、steps_per_epoch = samples_per_epoch / batch_size で自動変換してくれた。
テストデータに対する最大精度は29/30エポック目の0.77500だった。水増しなしのときより良くなっている。
試しに、samples_per_epochをX_train.shape[0]*2、X_train.shape[0]*4にして、学習データをさらに水増ししてみたところ
*2の最大精度 22/30エポック目の0.7250
*4の最大精度 11/30エポック目の0.7875
精度が *4 > *1 > *2 なのは謎だ。多すぎても少なすぎてもダメというなら分かるのだが、真ん中が一番低い。
この後、手が滑って水増しなしの学習を最初からやり直してしまったのだが、10エポック目で0.788の精度が出た。0.700~0.788ぐらいは乱数で変わるのか?水増しなしなので、乱数の影響は各バッチで使う学習データの選び方と初期値だろうか。
転移学習
次に、日本の古文書で機械学習を試す(11)と同じようにVGG16の学習済みモデルを使って転移学習をしてみる。VGG16の最後の結合層のみを新たに定義して、再学習させる。
学習データ、テストデータは最初からVGG16の使用を念頭に置いて224x224で作ってあるので、そのまま使える。
# VGG15の学習済みモデルを読み込む
from keras.applications.vgg16 import VGG16
from keras.layers import Input
# 最後の全結合層を除いたモデルを読み込むのでinclude_top=False
input_tensor = Input(shape=(img_rows, img_cols, 3))
base_model_v = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)
# 全結合層を定義(VGG15)
from keras.models import Sequential, Model
from keras.layers import Dense, Dropout, Activation, Flatten
fc_model = Sequential()
fc_model.add(Flatten(input_shape=base_model_v.output_shape[1:]))
fc_model.add(Dense(256))
fc_model.add(Activation("relu"))
fc_model.add(Dropout(0.5))
fc_model.add(Dense(nb_classes))
fc_model.add(Activation("sigmoid"))
# 読み込んだVGG16と、定義した全結合層を連結
model_t = Model(inputs=base_model_v.input, outputs=fc_model(base_model_v.output))
# base_model_vの各層の重みを固定する(VGG16)
for layer in base_model_v.layers:
layer.trainable = False
# モデルのコンパイル
model_t.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
# 【各エポックごとの学習結果を生成するためのコールバックを定義(前回より精度が良い時だけ保存)】
from keras.callbacks import ModelCheckpoint
import os
model_checkpoint_t = ModelCheckpoint(
filepath=os.path.join('models','model_2class120_transfer_{epoch:02d}_{val_acc:.3f}.h5'),
monitor='val_acc',
mode='max',
save_best_only=True,
verbose=1)
# 【パラメータ設定】
batch_size = 20
epochs = 30
# 【学習】
result_t = model_t.fit(X_train, Y_train,
batch_size=batch_size,
epochs=epochs,
verbose=1,
validation_data=(X_test, Y_test),
callbacks=[model_checkpoint_t])
学習時間は1エポック当たり200~260秒、テストデータに対する最大精度は18/30エポック目の0.8500だった。
モデルが複雑な分、学習に時間はかかるが、かなり精度が上がっている。(これだけ違えば乱数の影響だけではないと思う。たぶん。)
古文書(古典籍)のデータでは、VGG16の転移学習より、シンプルなCNNで最初から学習させたほうが精度が良かったが、今回は転移学習のほうが精度が高い。元データの種類や判別したい内容が近いためだろうか。
転移学習 + ImageDataGenerator
ImageDataGeneratorで学習データを水増しした転移学習も試してみた。先ほどと同じように、ModelCheckpointで保存するモデルの名前を変更し、model.fitを以下のように変えて実行するだけ。
result = model_t.fit_generator(train_generator,
samples_per_epoch=X_train.shape[0],
epochs=epochs,
verbose=1,
validation_data=(X_test, Y_test),
callbacks=[model_checkpoint])
テストデータに対する最大精度は8/30エポック目の0.8250だった。水増しなしの転移学習の場合よりは下がっているが、最初のシンプルなCNNよりは良い結果となった。
こちらも学習経過を可視化してみた。15エポック前後でやめても良かったかもしれない。
画像のどこに着目しているかをGradCAMで可視化
モデルが、「笑っている犬」と「怒っている犬」をどこで区別しているのかが気になったので、GradCAMで着目箇所を可視化してみた。
GradCAMのコードはkerasでGrad-CAM 自分で作ったモデルでを参考にさせていただいた。
まず、一番精度が良かった転移学習(水増しなし)の18エポック目のモデルを読み込んで、層構造を確認する。
from keras.models import load_model
model = load_model('models/model_2class120_transfer_18_0.850.h5')
model.summary()
出力はこうなった。
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_4 (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
_________________________________________________________________
sequential_29 (Sequential) (None, 2) 6423298
=================================================================
Total params: 21,137,986
Trainable params: 6,423,298
Non-trainable params: 14,714,688
_________________________________________________________________
画像を1つ読み込んで、畳み込み層のうち、最後のもの(block5_conv3)と、モデルの最終出力の勾配から、最終出力に影響している画像の部分を可視化する。ついでに、「笑」「怒」の判別結果と、それぞれのスコアも表示してみる。
import numpy as np
## 画像読み込み
filename = "freeimage/smile1.jpg"
x = img_to_traindata(filename, img_rows, img_cols, 1) # img_to_traindata関数は、学習データ生成のときに定義
x = np.expand_dims(x, axis=0)
## どのクラスかを判別する
preds = model.predict(x)
pred_class = np.argmax(preds[0])
print("識別結果:", pred_class)
print("確率:", preds[0])
from keras import backend as K
import cv2
# モデルの最終出力を取り出す
model_output = model.output[:, pred_class]
# 最後の畳込み層を取り出す
last_conv_output = model.get_layer('block5_conv3').output
# 最終畳込み層の出力の、モデル最終出力に関しての勾配
grads = K.gradients(model_output, last_conv_output)[0]
# model.inputを入力すると、last_conv_outputとgradsを出力する関数を定義
gradient_function = K.function([model.input], [last_conv_output, grads])
# 読み込んだ画像の勾配を求める
output, grads_val = gradient_function([x])
output, grads_val = output[0], grads_val[0]
# 重みを平均化して、レイヤーのアウトプットに乗じてヒートマップ作成
weights = np.mean(grads_val, axis=(0, 1))
heatmap = np.dot(output, weights)
heatmap = cv2.resize(heatmap, (img_rows, img_cols), cv2.INTER_LINEAR)
heatmap = np.maximum(heatmap, 0)
heatmap = heatmap / heatmap.max()
heatmap = cv2.applyColorMap(np.uint8(255 * heatmap), cv2.COLORMAP_JET) # ヒートマップに色をつける
heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) # 色をRGBに変換
# 元の画像と合成
superimposed_img = (np.float32(heatmap)/4 + x[0]*255/4*3)
冒頭に挙げた「笑っている犬」の画像(学習、テストには使っていない)に対する出力
識別結果: 1
確率: [0.25032192 0.7496992 ]
冒頭に挙げた「怒っている犬」の画像(学習、テストには使っていない)に対する出力
識別結果: 0
確率: [0.9978389 0.0027218]
どちらも、識別結果は正解だった。
「笑」のほうは、目のまわりと舌、「怒」のほうは口元~耳に着目しているようだ。「笑」のほうは人間の感覚と近いが、「怒」のほうは謎だ。人間が判別するときは、上の歯や牙が出ているかどうか、鼻がめくれているかどうかを見ている気がするのだが。
フリー写真素材ぱくたそ からもう少し写真をダウンロードして試してみる。
識別結果: 1
確率: [0.00342841 0.9956071 ]
識別結果は正解。舌を見ているのは先ほどと同じだが、頭のリボンと首を見ているのは謎。
識別結果: 1
確率: [4.246722e-04 9.995505e-01]
これも正解。こちらは顔全体を見ているようだ。
元画像3。ちょっと難易度を上げて横向きの笑っている犬。
識別結果: 1
確率: [0.0012683 0.99887735]
なぜ正解しているのか不明だが、結果は合っている。
元画像4。笑っても怒っていもいない。ちょっと不満そうな顔?
識別結果: 1
確率: [0.0078612 0.989988 ]
どっちに分類されても正解ではないのだが、「笑」に分類された。目に着目しているようだ。
「怒っている犬」の著作権フリー画像は最初のもの以外に見つけられなかった。著作権の関係でここには載せられないが、学習データとテストデータでもチェックしてみると、正解している画像は口周りと耳に着目しているものが多かった。耳は怒っているかどうかを判別するのに意外と重要なのだろうか?
冒頭の画像で、シンプルCNNのモデルでも試してみた。
識別結果: 0
確率: [0.8697514 0.12745507]
これは結果が間違っているし、何を見ているのかも分からない。
識別結果: 0
確率: [0.998166 0.0012412]
これは結果が正解で、歯、鼻、目を見ているのは直感とも一致する。
VGG16とシンプルCNNでは着目箇所が全然違うのは意外だった。
元画像が正方形ではないので、画像を読み込んで224x224に変換するで、画像が歪んで変になっている。学習データ、テストデータは224x224に変換した後の歪んだ画像を使っていることになるので、画像を読み込む前に手動で正方形にトリミングしておくと、精度が上がるかもしれない。
やっぱり機械学習はデータ収集と前処理が大変だ。
動作環境
今回使ったマシンの環境は以下の通り。
OS Windows10
CPU Intel(R) Core(TM) i7-5500U 2.4GHz
RAM 8.00GB
数年前に買ったノートPCで、GPUなし
(pythonとライブラリのバージョン)
python3.6.6、keras2.2.4、tensorflow1.9.0、hdf51.10.2
###おまけ:AIがサボった話
上では各クラス100枚の学習用画像を使ったが、最初は各クラス20枚、バッチサイズ4で試してみた。そうしたら、3エポック目から、学習データの精度0.500、テストデータの精度も0.500で全く変化しなくなってしまった。バッチサイズや最適化関数の学習率(lr)を変えてみると、少しは上下に振れるようになるが、最終的には0.5で落ち着いてしまう。
何が起こっているのかと思い、次のようなコードを書いて、学習データと識別データがどちらのクラスに分類されているかをチェックしてみた。
for i, x in enumerate(np.concatenate([X_train, X_test], 0):
x = np.expand_dims(x, axis=0)
preds = model.predict_classes(x)
pred = preds.tolist()
prob = model.predict_proba(x)
prlist = prob.tolist()
print(img_file)
print("No." , i, " 正解:[", np.where(Y_test[i] == 1)[0][0], "] 予測結果:" , pred , prlist)
すると、何と全てのデータが高確率(スコア0.9以上)でクラス0(怒)に分類されていた。そりゃ精度50%になるわ…
昔、テストで、2択問題なら全部同じ方を選べば、確率的に100点満点の50点は取れるなどと考えていたのを思い出して笑ってしまった。
将来、AIやロボットがもっと身近なものになったら、こんな感じでおサボりしたりするのだろうか。