玉子焼きとだし巻き玉子を分類してみた

みなさん、こんちわ！都内で機械学習エンジニアとして働いており、MLOps をメインに、NLP やレコメンドの開発をしています。あと、玉子焼きが好きです。
この記事は、[ただの集団のアドベントカレンダー](https://adventar.org/calendars/3447)の21日目の記事になります。初めてアドベントカレンダーの記事を書きましたので、どうぞあたたかい目で読んで頂ければ幸いです。

## はじめに

ネットで玉子焼きを調べていると、「玉子焼きとだし巻き玉子の違って、味以外おんなじだよねー」、「玉子焼きとだし巻き玉子って見た目変わんないよねー」というコメントをちらほら見る。

ここでふと思う。

**人が「玉子焼き」と「だし巻き玉子」を分類するのは難しいけど、機械学習なら分類できるのでは！？**

そこで、機械学習で分類できることを確かめるために、実際に玉子焼きとだし巻き玉子の分類器を作ってみることにした！！

ということで、「玉子焼き」と「だし巻き玉子」の画像を分類するモデルを作ってみます。機能を実現するために、以下のようなフローを作る。

画像収集 -> 前処理 -> 実験

## 画像を収集する

まずはデータを集める。自分でも玉子焼きとだし巻き玉子のアルバムを持っているがラベリングされていないので、すぐには使えない。そこで Web から玉子焼きとだし巻き玉子の画像を収集することにする。

Web から画像を取得するにあたって、画像を取得する API を調査した。調査したところ、[Bing Image Search API](https://azure.microsoft.com/ja-jp/services/cognitive-services/bing-image-search-api/) の評判が良さそうだったので、これを使うことにする。[クイックスタート](https://docs.microsoft.com/en-us/azure/cognitive-services/bing-image-search/quickstarts/python)と [API 仕様](https://docs.microsoft.com/en-us/rest/api/cognitiveservices/bing-images-api-v7-reference#query-parameters)を参考に実装してみる。


```python
import requests

search_url = "https://api.cognitive.microsoft.com/bing/v7.0/images/search"
search_term = "玉子焼き"
headers = {"Ocp-Apim-Subscription-Key" : subscription_key}

def get_bing_images(url, headers, term):
    params  = {"q": term,  "mkt": "ja-JP", "count": 150}
    response = requests.get(url, headers=headers, params=params)
    return response.json()

search_results = get_bing_images(search_url, headers, search_term)
```
ここでいくつか注意点

* subscription key は、Image Search API に登録すると key が発行されるので、それを使う。
* リクエストのパラメータで mkt でリージョンの指定する
* count で取得する数（最大150件）を定義する。デフォルトは 35 件。

これでデータを取得できる。

**しかし、無事データ収集ができたかと思ったら、１つ問題が発生した！**

Bing の API で「玉子焼き」の画像を集めることはできたが、「だし巻き玉子」の画像が十分収集できなかった。（Bing の画像検索で「だし巻き玉子」「だし巻き卵」「だし巻きたまご」を検索確認したところ、ヒットなし。「出汁巻玉子」は５件ヒットした。）

このままでは学習できないので、だし巻き玉子の画像を追加で収集するために、Google の [Custom Search Engine](https://cse.google.com/cse/all) で収集する仕組みも作った。

```python
import requests

search_url = "https://www.googleapis.com/customsearch/v1"
search_term = "出汁巻玉子"

def get_google_images(url, term, key, cx, start):
    params  = {"q": term,  "key": key, "cx": cx, "start": start}
    response = requests.get(url, params=params)
    return response.json()

search_results = get_google_images(search_url, search_term, subscription_key, cx_key, start_index)
```

ここでも注意点

* API 利用のために、Custom Search API のキー（subscription_key）と Search Engine Id（cx_key）を発行する必要がある。
* １回の検索で最大 10 件までしか取得できないので、10件以上のデータを取得する場合は、オフセット（start）を使って、次ページのデータを取得する仕組みを用意する必要がある。

Google の API も利用して、やっと玉子焼きとだし巻き玉子のデータを用意することができた！
データが用意できたので、次は前処理を行う。

## 前処理

前処理でやったことは主に２つ。

* 画像のリサイズ
* train と test の分割

画像のリサイズは opencv を使って、全画像のサイズを統一した。
以下、実装の一部。

```python
import glob
import os
import cv2 as cv
import tqdm

def resize_images(base_dir, output_dir):
    for img_path in tqdm.tqdm(glob.glob(f'{base_dir}/*')):
        if '.gitkeep' in img_path:
            continue
        img = cv.imread(img_path)
        try:
            fix_img = cv.resize(img, (128, 128))
        except:
            continue
        cv.imwrite(f'{output_dir}/{os.path.basename(img_path)}', fix_img)
```

* 画像は 128x128 に設定。

データのtrain と test の分割は、csv で train と test のデータのパスとラベルを管理した。
Pytorch の [data loading tutorial](https://pytorch.org/tutorials/beginner/data_loading_tutorial.html#dataset-class) を参考に実装したので、train と test で扱うデータを csv で管理しています。作成する csv は以下のようなかんじ。

```csv
image,label
tamagoyaki_0067.jpeg,1
tamagoyaki_0101.jpeg,1
tamagoyaki_0113.jpeg,1
tamagoyaki_0042.jpeg,1
tamagoyaki_0119.jpeg,1
tamagoyaki_0022.jpeg,1
```

これでモデリングの準備が整った！

## train&testの実装

[こちら](https://github.com/creafz/pytorch-cnn-finetune/blob/master/examples/cifar10.py)のコードを参考に Pytorch でモデルを作成する。

１からモデルを作ると手間なので、ここは偉大な先人たちが作った学習済みモデルを使う。（Fine-tuning 大事ネ。）

学習済みのモデルを取得するために、[cnn-finetune](https://github.com/creafz/pytorch-cnn-finetune)というライブラリを使って、学習済みのモデルを取得する。使い方は以下の通り。

```python
import torch
from cnn_finetune import make_model

model = make_model('vgg16', num_classes=2, pretrained=True, input_size=(128, 128))
device = torch.device('cuda')
model = model.to(device)
```

vgg16 以外にも resnet などがあるので、いろんなモデルが試せて便利！

データを準備する。データを扱うクラスは以下の通り。

```python
import pandas as pd
from skimage import io
from torch.utils.data import Dataset
from torchvision import transforms

class MyDataSet(Dataset):
    def __init__(self, csv_path, root_dir, transform=None):
        self.data_df = pd.read_csv(csv_path)
        self.root_dir = root_dir
        
        if transform is None:
            self.transform = transforms.Compose([
                transforms.ToTensor(),
                transforms.Normalize(
                mean=model.original_model_info.mean,
                std=model.original_model_info.std),
            ])
        else:
            self.transform = transform
        
    def __len__(self):
        return self.data_df.shape[0]
    
    def __getitem__(self, idx):
        image_name = os.path.join(self.root_dir, self.data_df.iloc[idx, 0])
        image = io.imread(image_name)
        label = self.data_df.iloc[idx, 1]
        return self.transform(image), int(label)
```

[こちらのチュートリアル](https://pytorch.org/tutorials/beginner/data_loading_tutorial.html#dataset-class)を参考に用意した。

各データを読み込む処理を実装。

```python
import torch

# load train
train_set = MyDataSet('../images/train_data.csv', '../images/resize/data/')
train_loader = torch.utils.data.DataLoader(train_set, shuffle=True)
# load test
test_set = MyDataSet('../images/test_data.csv', '../images/resize/data/')
test_loader = torch.utils.data.DataLoader(test_set, shuffle=False)
```

criterion と optimizer の設定する。

```python
import torch.nn as nn
import torch.optim as optim

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-5)
```

train の実装

```python
def train(epoch):
    total_loss = 0
    total_size = 0
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        total_loss += loss.item()
        total_size += data.size(0)
        loss.backward()
        optimizer.step()
        if (batch_idx+1) % 20 == 0 or (batch_idx+1)==len(train_loader):
            progress = 100. * (batch_idx+1) / len(train_loader)
            average_loss = total_loss / total_size
            print(f'Train Epoch: {epoch} [{(batch_idx+1) * len(data)}/{len(train_loader.dataset)} ({progress:.0f}%)]\tAverage loss: {average_loss:.6f}')
```

testの実装

```python
def test():
    model.eval()
    test_loss = 0
    correct = 0
    outputs = []
    targets = []
    with torch.no_grad():
        for data, target in test_loader:
            targets.append(target)
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += criterion(output, target).item()
            pred = output.data.max(1, keepdim=True)[1]
            correct += pred.eq(target.data.view_as(pred)).long().cpu().sum().item()
            outputs.append(output.cpu().detach().numpy())

    test_loss /= len(test_loader.dataset)
    accuracy = 100. * correct / len(test_loader.dataset)
    print(f'\nTest set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.0f}%)\n')
    return outputs, targets
```

実験する！

```python
for epoch in range(10):
    train(epoch)
    test()
```

## 実験

いろんなモデルで比較実験してみる。

実験で使うデータ。

| label | train data | test data |
|:-----------|------------:|:------------:|
| 玉子焼き | 103 | 45 |
| 出汁巻玉子 | 70 | 31 |


実験するモデル。

* NN系
  * Alexnet
  * Resnet18
  * Squeezenet
  * Vgg11
  * Vgg16
* Logistic Regression
* Gradient Boosting Machine

Logistic Regression は scikit-learn のものを、GBM　は LightGBM を使う。
epoch 数は 10 で回してみる。

実験した結果は以下のようになった。

| アルゴリズム | Average Loss | Accuracy |
|:-----------|------------:|:------------:|
| Alexnet | 0.2580 | 87% |
| Resnet18 | 0.8520 | 47% |
| Squeezenet | 0.1777 | 95% |
| Vgg11 | 0.2620 | 93% |
| Vgg16 | 0.2407 | 95% |
| Logistic Regression | 0.5482 | 76% |
| GBM | 0.6318 | 73% |

比較したところ、Squeezenet が一番良かった。というか、NN系は Resnet を除いて全体的に良い。
実際に判定結果をみても、微妙な判定が少ないので、２つをきっちりと分けることができるみたい。

<img width="585" alt="スクリーンショット 2018-12-21 16.24.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/88216/a4a8c56f-ee4f-0b79-e36d-6e46111cb8ec.png">

また、ベースラインとして用意した Logistic Regression も悪くない。人間の目で分類するのが難しい課題でも機械学習を使えばある程度分けれることがわかった。DNNすごい。。。

## まとめ

玉子焼き分類モデルを作って、玉子焼きとだし巻き玉子を分類してみた。結果は驚きの accuracy 90% オーバー！！うれしいね。画像系の機械学習を初めてきちんとやったので良い経験になった。モデルの特性など理解できていないところが多々あるので、時間を作って勉強したい。一方で、精度は良かったものの、元データにノイズ（フライパンの画像や某レシピサービスのロゴなど）が混ざっているので、これを除去する仕組みを作るのもやってみたいと思った。玉子焼き関連でいろんなエンジンが作れそうなので、このあたりは継続的に続けていきたい。
今回作成したものは下記URLにおいてますので、興味があれば触れてみてください。実験結果もそのまま残してあるので、参考にしていただければ幸いです。

https://github.com/wararaki/tamagoyaki

また今回の記事作成に当たり、モデルのチューニング等で助けていただいた [t-ohtsuki](https://github.com/t-ohtsuki-biz) 氏に感謝！

以上です。
コメントやアドバイスをいただけると嬉しいので、どうぞよろしくお願いします。m(_ _)m