LoginSignup
66
34

More than 3 years have passed since last update.

「スタバなう」ツイートをニューラルネットで学習してラーメン判定器を作る(keras+Tensorflow+VGG16)

Posted at

この記事の抜粋したコードの完全版はGitHubでご覧いただけます。
また、この記事で作成したモデルはTwitterのスタバ警察botで実際に試せるので、ご興味があれば適当な画像を「スタバなう」という文字列と一緒にリプライしてみてください。


というツイートからも分かるように、現在のスタバなうツイートは完全に関係ない画像で蹂躙されており、実際にスタバで撮影された画像は全体の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ディレクトリにはこんな感じの画像が入っています。

ramen.jpg

これらの画像のうち、数十枚は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を作成します。
ImageDataGeneratorvalidation_splitvalidation_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.ModelCheckpointcallbacksに設定することで、エポック毎にモデルを保存することができます。save_best_only=Trueにしておくと、指定した値(ここではval_loss)が改善した時にだけ保存するようになります。

評価

test用generatorの生成はtrain/validationの時とだいたい同じですが、batch_sizeは1、 shuffleFalseにしておきます。

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枚あります。対象の画像を含むツイートは以下です。

今回は、つけ麺もラーメン画像として含めていたので、「器が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で実際に使っています。どれぐらいちゃんとラーメン(やスタバ)を判定できるのかご興味があれば試してみてください!

66
34
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
66
34