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

*この記事の抜粋したコードの完全版は[GitHub](https://github.com/mpppk/colaboratory/blob/master/sutaba.ipynb)でご覧いただけます。*
*また、この記事で作成したモデルは[Twitterのスタバ警察bot](https://twitter.com/sutaba_police)で実際に試せるので、ご興味があれば適当な画像を「スタバなう」という文字列と一緒にリプライしてみてください。*

------

<blockquote class="twitter-tweet" data-partner="tweetdeck"><p lang="ja" dir="ltr">こういうtweetが機械学習界隈からの怒りを買ってます(笑) <a href="https://t.co/COV1IHyh03">https://t.co/COV1IHyh03</a></p>&mdash; Yuki Suga (@ysuga) <a href="https://twitter.com/ysuga/status/1154689461503942662?ref_src=twsrc%5Etfw">July 26, 2019</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

というツイートからも分かるように、現在のスタバなうツイートは完全に関係ない画像で蹂躙されており、実際にスタバで撮影された画像は[全体の24%しかありません。](https://qiita.com/mpppk/items/dc1743fc8b3aa3116e70#結果)
逆にここまで来ると、残り76%の画像に着目した方が良いのではという気すらしてきます。
というわけで、「スタバなうと言いながら投稿される関係ない画像」の筆頭であるラーメンの分類器を、スタバなうツイートだけで作れるかどうか試してみます。
手順やコードについては[GPUを使ってVGG16をFine Tuningして、顔認識AIを作って見た \- Qiita](https://qiita.com/yottyann1221/items/20a9c8a7a02edc7cd3d1)を大変参考にさせて頂きました。

学習データは2019/07/21-2019/08/03の間に「スタバなう」とツイートされた4064枚の画像です。
あらかじめ、ラーメンかそうでないか、ついでにスタバかどうかの3つのラベルを振っておきます。枚数は以下の通りでした。

|分類|枚数|
|---|---|
|全体|4064|
|ラーメン画像|617|
|スタバ画像|973|
|その他画像|2474|

(画像の収集手順についてご興味があれば、こちらの記事もご覧ください→[「スタバなう」でアップロードされたtwitterの画像をGoで収集してS/N比を調べる \- Qiita](https://qiita.com/mpppk/items/dc1743fc8b3aa3116e70))

これらをラベルごとのサブディレクトリに切っておきます。(この後、前処理を行うので、originalというsuffixを付けておきます)

```bash
$ pwd
/path/to/images/train
$ tree .
.
├── other-original
├── ramen-original
└── sutaba-original
```

例えば`ramen-original`ディレクトリにはこんな感じの画像が入っています。

![ramen.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28464/4eee4421-5d84-4d07-304a-c8b4fab4e1a0.jpeg)

これらの画像のうち、数十枚はtest用の画像として別に保存しておきます。(今回は各クラス70枚程度をtestとしています)

```bash
$ pwd
/path/to/images/test
$ tree .
.
├── other-original
├── ramen-original
└── sutaba-original
```

## 前処理
今回はVGG16モデルを使って分類器を作ります。
画像サイズは224x224にしておくのが一般的?のようですが、当然ツイートから収集した画像はサイズも違いますし、そもそも正方形でないことが多いです。なので、あらかじめ正方形画像へ切り出し+リサイズを行っておきます。
numpyやpillowでも簡単に書けると思いますが、今回は[mogrify](https://imagemagick.org/script/mogrify.php)を利用して切り出しました。mogirifyは、画像のリサイズやクロップを行うためのImageMagickラッパーです。
ラーメン画像のリサイズを行う場合は以下を実行します。(パスは環境に合わせて変更してください。)

```shell
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`を指定することで、その割合で学習データを分割してくれます。
データオーギュメンテーションについては、画像のズームと左右反転だけを入れています。
逆立ちしてラーメンを撮影する人はいないだろうということで、画像の回転はしていません。

```python
# 分類するクラス
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](https://qiita.com/yottyann1221/items/20a9c8a7a02edc7cd3d1)のほぼそのままコピペです。
(書いてて気づきましたが、optimizerはAdamでも良かったかもしれません)

```python
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`で実際に学習を開始します。

```python
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`にしておきます。

```python
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が同じクラスになっていれば、推定に成功していると言えます。
このデータを集計してモデルの精度を算出します。

まずは混同行列を計算してみます。

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">彼氏とスタバなう <a href="https://t.co/mgJKYPELLA">pic.twitter.com/mgJKYPELLA</a></p>&mdash; Amelia (@kk66172001) <a href="https://twitter.com/kk66172001/status/1153209331015274498?ref_src=twsrc%5Etfw">July 22, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

今回は、つけ麺もラーメン画像として含めていたので、「器が2つあって片方に暗い色の液体が入っている」写真は、つけ麺と判定されているのかもしれません。

最後に、ラーメンクラスに着目した精度を評価します。

```python
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](https://twitter.com/sutaba_police)で実際に使っています。どれぐらいちゃんとラーメン(やスタバ)を判定できるのかご興味があれば試してみてください！

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ピピーッ❗️🔔⚡️スタバ警察です❗️👊👮❗️ オオッこのツイート🤔🤔🤔...間違いなくスタバ❗️❗️😂😂😂<br>市民の協力に感謝するッッッ👮‍👮‍❗❗ <a href="https://t.co/nXsfuHtdxx">https://t.co/nXsfuHtdxx</a></p>&mdash; スタバ警察 (@sutaba_police) <a href="https://twitter.com/sutaba_police/status/1163447361848217600?ref_src=twsrc%5Etfw">August 19, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>