この記事の抜粋したコードの完全版はGitHubでご覧いただけます。
また、この記事で作成したモデルはTwitterのスタバ警察botで実際に試せるので、ご興味があれば適当な画像を「スタバなう」という文字列と一緒にリプライしてみてください。
こういうtweetが機械学習界隈からの怒りを買ってます(笑) https://t.co/COV1IHyh03
— Yuki Suga (@ysuga) July 26, 2019
というツイートからも分かるように、現在のスタバなうツイートは完全に関係ない画像で蹂躙されており、実際にスタバで撮影された画像は全体の24%しかありません。
逆にここまで来ると、残り76%の画像に着目した方が良いのではという気すらしてきます。
というわけで、「スタバなうと言いながら投稿される関係ない画像」の筆頭であるラーメンの分類器を、スタバなうツイートだけで作れるかどうか試してみます。
手順やコードについてはGPUを使ってVGG16をFine Tuningして、顔認識AIを作って見た - Qiitaを大変参考にさせて頂きました。
学習データは2019/07/21-2019/08/03の間に「スタバなう」とツイートされた4064枚の画像です。
あらかじめ、ラーメンかそうでないか、ついでにスタバかどうかの3つのラベルを振っておきます。枚数は以下の通りでした。
分類 | 枚数 |
---|---|
全体 | 4064 |
ラーメン画像 | 617 |
スタバ画像 | 973 |
その他画像 | 2474 |
(画像の収集手順についてご興味があれば、こちらの記事もご覧ください→「スタバなう」でアップロードされたtwitterの画像をGoで収集してS/N比を調べる - Qiita)
これらをラベルごとのサブディレクトリに切っておきます。(この後、前処理を行うので、originalというsuffixを付けておきます)
$ pwd
/path/to/images/train
$ tree .
.
├── other-original
├── ramen-original
└── sutaba-original
例えばramen-original
ディレクトリにはこんな感じの画像が入っています。
これらの画像のうち、数十枚はtest用の画像として別に保存しておきます。(今回は各クラス70枚程度をtestとしています)
$ pwd
/path/to/images/test
$ tree .
.
├── other-original
├── ramen-original
└── sutaba-original
前処理
今回はVGG16モデルを使って分類器を作ります。
画像サイズは224x224にしておくのが一般的?のようですが、当然ツイートから収集した画像はサイズも違いますし、そもそも正方形でないことが多いです。なので、あらかじめ正方形画像へ切り出し+リサイズを行っておきます。
numpyやpillowでも簡単に書けると思いますが、今回はmogrifyを利用して切り出しました。mogirifyは、画像のリサイズやクロップを行うためのImageMagickラッパーです。
ラーメン画像のリサイズを行う場合は以下を実行します。(パスは環境に合わせて変更してください。)
mogrify \
-path /path/to/images/train/ramen \
-define jpeg:size=224x224 \
-thumbnail 224x224^ \
-gravity center \
-extent 224x224 \
/path/to/images/train/ramen-original/*.jpg
これで、ramen
ディレクトリに整形後の画像が置かれます。
その他画像、スタバ画像に対してや、testディレクトリについても同様に実行してください。
データの読み込み
まずtrainとvalidation用のgeneratorを作成します。
ImageDataGenerator
のvalidation_split
にvalidation_rate
を指定することで、その割合で学習データを分割してくれます。
データオーギュメンテーションについては、画像のズームと左右反転だけを入れています。
逆立ちしてラーメンを撮影する人はいないだろうということで、画像の回転はしていません。
# 分類するクラス
classes = ["sutaba","ramen", "other"]
nb_classes = len(classes)
# 画像の大きさを設定
img_width, img_height = 224, 224
# バッチサイズ
batch_size = 300
# エポック数
nb_epoch = 100
# バリデーションにつかうデータの割合
validation_rate = 0.1
train_datagen = ImageDataGenerator(
rescale=1.0 / 255,
zoom_range=0.2,
horizontal_flip=True,
validation_split=validation_rate
)
train_generator = train_datagen.flow_from_directory(
train_data_dir,
target_size=(img_width, img_height),
color_mode='rgb',
classes=classes,
class_mode='categorical',
batch_size=batch_size,
# save_to_dir=aug_train_data_dir,
shuffle=True,
subset='training'
)
validation_generator = train_datagen.flow_from_directory(
train_data_dir,
target_size=(img_width, img_height),
color_mode='rgb',
classes=classes,
class_mode='categorical',
batch_size=batch_size,
# save_to_dir=aug_train_data_dir,
shuffle=True,
subset='validation'
)
モデルの準備
今回はImageNetで学習されたVGG16の全結合層だけを再学習していきます。
GPUを使ってVGG16をFine Tuningして、顔認識AIを作って見た - Qiitaのほぼそのままコピペです。
(書いてて気づきましたが、optimizerはAdamでも良かったかもしれません)
def create_vgg16():
input_tensor = Input(shape=(img_width, img_height, 3))
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)
top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(nb_classes, activation='softmax'))
vgg_model = Model(input=vgg16.input, output=top_model(vgg16.output))
for layer in vgg_model.layers[:15]:
layer.trainable = False
vgg_model.compile(loss='categorical_crossentropy',
optimizer=optimizers.SGD(lr=1e-3, momentum=0.9),
metrics=['accuracy'])
return vgg_model
これで準備ができたので、fit_generator
で実際に学習を開始します。
vgg_model = create_vgg16(model_dir)
n = datetime.datetime.now()
nstr = f'{n.year}-{n.month:02}-{n.day:02}_{n.hour:02}-{n.minute:02}-{n.second:02}'
fpath = f'/path/to/models/{nstr}' + 'weights.{epoch:02d}-{loss:.2f}-{acc:.2f}-{val_loss:.2f}-{val_acc:.2f}.hdf5'
cp_cb = keras.callbacks.ModelCheckpoint(filepath=fpath, monitor='val_loss', verbose=1, save_best_only=True, mode='auto')
class_weight = {0: other_train_num/sutaba_train_num, 1: other_train_num/ramen_train_num, 2: 1}
history = vgg_model.fit_generator(
train_generator,
validation_data=validation_generator,
samples_per_epoch=len(train_generator.classes),
nb_epoch=nb_epoch,
callbacks=[cp_cb],
nb_val_samples=len(validation_generator.classes),
class_weight=class_weight
)
-
class_weight
は各クラスの重みを決定します。今回は画像枚数がクラス間で最大4倍程度違うため、その比率で重みを調整して、枚数の多いその他画像が過度に重要視されないようにします。 -
keras.callbacks.ModelCheckpoint
をcallbacks
に設定することで、エポック毎にモデルを保存することができます。save_best_only=True
にしておくと、指定した値(ここではval_loss
)が改善した時にだけ保存するようになります。
評価
test用generatorの生成はtrain/validationの時とだいたい同じですが、batch_size
は1、 shuffle
はFalse
にしておきます。
test_datagen = ImageDataGenerator(rescale=1.0 / 255)
test_generator = test_datagen.flow_from_directory(
test_data_dir,
target_size=(img_width, img_height),
color_mode='rgb',
classes=classes,
class_mode='categorical',
batch_size=1,
shuffle=False)
vgg_model.load_weights('/path/to/models/2019-08-07_13-54-18weights.01-0.38-0.90-0.40-0.85.hdf5')
loss = vgg_model.predict_generator(test_generator, steps=len(test_generator.classes), verbose=1)
prob = pd.DataFrame(loss, columns=classes)
prob['predict'] = prob.idxmax(axis=1)
prob['actual'] = [classes[c] for c in test_generator.classes]
prob['path'] = test_generator.filenames
prob.to_csv('results.csv')
results.csv
は以下のようになります。
i | sutaba | ramen | other | predict | actual | path |
---|---|---|---|---|---|---|
0 | 0.9999993 | 9.351573e-09 | 7.663224e-07 | sutaba | sutaba | sutaba/1152864499935805440-0-D__MJnoUcAEiYc0.jpg |
1 | 0.9680856 | 2.6841928e-05 | 0.03188745 | sutaba | sutaba | sutaba/1152903458401292288-0-D__vlSZU4AA22-W.jpg |
2 | 0.999997 | 6.129743e-08 | 2.8856034e-06 | sutaba | sutaba | sutaba/1153073093935390720-0-EACJ3MVUYAAyBcA.jpg |
3 | 0.92388695 | 5.6549048e-05 | 0.07605657 | sutaba | sutaba | sutaba/1153104671583526912-0-EACmlFmUcAAXgEF.jpg |
4 | 0.06483261 | 0.01827882 | 0.9168886 | other | sutaba | sutaba/1153118856325414912-0-EACzez2UEAE7jDv.jpg |
(以下省略) |
左から2~4列目のsutaba/ramen/otherが、それぞれスタバ/ラーメン/その他クラスの確信度を表します。
predictが推定したクラス(最大の確信度を持つクラス),actualが実際のクラスです。
predictとactualが同じクラスになっていれば、推定に成功していると言えます。
このデータを集計してモデルの精度を算出します。
まずは混同行列を計算してみます。
from sklearn.metrics import confusion_matrix
mat = confusion_matrix(prob['actual'], prob['predict'], labels=classes)
print(mat)
# Output
# array([[66, 1, 6],
# [ 0, 56, 11],
# [ 2, 4, 64]])
| | スタバと判定 | ラーメンと判定 | その他と判定 |
|--:|--:|--:|--:|:--|:--|:--|
| 実際にスタバの画像 | 66 | 1 | 6 |
| 実際にラーメンの画像 | 0 | 56 | 11 |
| 実際にその他の画像 | 2 | 4 | 64 |
ラーメン画像をスタバと間違えることは一度もありませんでしたが、16%ぐらいはその他と判定してしまっているようです。
逆に、スタバ画像なのにラーメンと評価してしまっている画像が1枚あります。対象の画像を含むツイートは以下です。
彼氏とスタバなう pic.twitter.com/mgJKYPELLA
— Amelia (@kk66172001) July 22, 2019
今回は、つけ麺もラーメン画像として含めていたので、「器が2つあって片方に暗い色の液体が入っている」写真は、つけ麺と判定されているのかもしれません。
最後に、ラーメンクラスに着目した精度を評価します。
t = prob['actual'] == 'ramen'
p = prob['predict'] == 'ramen'
print(confusion_matrix(t, p, labels=[True, False]))
print('accuracy:', accuracy_score(t, p))
print('precision:', precision_score(t, p))
print('recall':, recall_score(t, p))
print('f1:', f1_score(t, p))
# Output
# [[ 56 11]
# [ 5 138]]
# accuracy 0.9238095238095239
# precision 0.9180327868852459
# recall 0.835820895522388
# f1 0.875
というわけで「スタバなう」ツイートだけで、F値87.5%でラーメンを判定することができました。🍜
このモデルはTwitterのスタバ警察botで実際に使っています。どれぐらいちゃんとラーメン(やスタバ)を判定できるのかご興味があれば試してみてください!
ピピーッ❗️🔔⚡️スタバ警察です❗️👊👮❗️ オオッこのツイート🤔🤔🤔...間違いなくスタバ❗️❗️😂😂😂
— スタバ警察 (@sutaba_police) August 19, 2019
市民の協力に感謝するッッッ👮👮❗❗ https://t.co/nXsfuHtdxx