唐揚げとプードルを絶対に見分けたい！畳み込みニューラルネットワークで画像分類

## はじめに
https://qiita.com/katsujitakeda/items/f1842f5e831bb6475ba8

この記事は前回の記事,[唐揚げ?orトイプードル？機械学習で判別してみた](https://qiita.com/katsujitakeda/items/f1842f5e831bb6475ba8)のリベンジ記事です！

前回初投稿でお試しで書いたものが多くの方に見ていただけて嬉しい反面、雑な部分も多くあるので恥ずかしくなっているところです。（とは言え、1万viewもありがとうございます！！）

今回は畳み込みニューラルネットワーク（以下CNN）を用いて唐揚げとプードルの分類器を作成しました。
その後にGradCAMを用いてニューラルネットワークが何をみて判断しているのか可視化したので見ていただけたら嬉しいです。


目標は **この画像を正しくプードルと認識できるような分類器を機械学習で作ること！！**

<section style="text-align: center;">

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/72047efd-91ec-9bb2-6263-67629f07fb9c.png)

</section>


## 1.畳み込みニューラルネットワークとは

CNNは一般的な順伝播型のニューラルネットワークとは違い、全結合層だけでなく畳み込み層(Convolution Layer)とプーリング層(Pooling Layer)から構成されるニューラルネットワークです。画像ピクセルを交換したり改良したり柔軟な対処ができ、パラメータの数が全結合層に比べて少ないことが特徴です。

<img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/f31cc2e1-8b3b-d267-2939-648b5bf8b41d.png">

https://deepage.net/deep_learning/2016/11/07/convolutional_neural_network.html

## 1.1　畳み込み層について

畳み込み層では複数のフィルタを用いて２次元畳み込み演算を行いフィルタの数だけ特徴マップを作成しています。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/235a500b-91ce-bccd-9aed-c7f251580041.png">

## 1.2　プーリング層について
プーリング層とは物体の些細な位置変化によって認識結果が変わらないようにする手法です。具体例を図にしてみました。ここでは最大プーリングを使用。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/b2fd8e85-a402-8d4f-e5e2-f0c5530827c1.png">

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/2844fa37-c083-460d-4ab3-b822c78471b2.png">

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/803f7e3b-c382-9a84-7047-beba02a1da5e.png">

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/49ffd1ba-f8b8-7d1a-c3c3-2ba9994aa1f4.png">

## 2.tensorflowで畳み込みニューラルネットワークの実装

## 2.1　必要なモジュールのインポート

```python
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt
import cv2
import os
import glob
import pathlib
from tensorflow.keras.callbacks import EarlyStopping 
import japanize_matplotlib
```
## 2.2　データセットの作成
まず画像パスから画像を読み込み返す関数を作成します。
```python
def load_and_preprocess_image(path):
    """画像パスから画像を読み込み返す関数"""
    image = tf.io.read_file(path)
    image = tf.image.decode_jpeg(image, channels=3)
    image = tf.cast(image,tf.float32) / 255.0 #ピクセルに0〜２５５にデータがあるので0〜１の範囲にする
    return image


def preprocess(example_i,example_l,size=(64,64),mode='train'):
    """訓練時：画像をランダムに変形、テスト時：画像を整形して返す関数"""
    image = example_i
    label = example_l

    if mode == 'train':
        image_cropped = tf.image.random_crop(image,size=(150,150,3))
        image_resized = tf.image.resize(image_cropped,size = size)
        image_flip = tf.image.random_flip_left_right(image_resized)
        return (image_flip, tf.cast(label,tf.int32))
    else:
        image_cropped = tf.image.crop_to_bounding_box(
            image,offset_height=34,offset_width=14,
            target_height=150,target_width=150)
        image_resized = tf.image.resize(image_cropped,size=size)
        return (image_resized, tf.cast(label,tf.int32))
```

次に実際にtrainデータセットの作成を行います。ラベリングが難しかったので唐揚げのフォルダ、プードルのフォルダを作って別々でデータセットを作り、結合しました。


```python
#データセットの作成　唐揚げ
kara_imgdir_path = pathlib.Path('../../karapu/karaage/')
kara_file_list = sorted([str(path) for path in kara_imgdir_path.glob('*.jpg')])

kara_ds_path = tf.data.Dataset.from_tensor_slices(kara_file_list)

kara_image_ds = kara_ds_path.map(load_and_preprocess_image)

kara_image_labels = [1]*377 #唐揚げはラベル１
kara_label_ds = tf.data.Dataset.from_tensor_slices(tf.cast(kara_image_labels, tf.int64))
#ラベルと画像のデータの結合
kara_image_label_ds = tf.data.Dataset.zip((image_ds, label_ds))

#データセットの作成プードル
pu_imgdir_path = pathlib.Path('../../karapu/poodle/')
pu_file_list = sorted([str(path) for path in pu_imgdir_path.glob('*.jpg')])

pu_ds_path = tf.data.Dataset.from_tensor_slices(pu_file_list)

pu_image_ds = pu_ds_path.map(load_and_preprocess_image)

pu_image_labels = [0]*402　#プードルはラベル0

pu_label_ds = tf.data.Dataset.from_tensor_slices(tf.cast(pu_image_labels, tf.int64))
pu_image_label_ds = tf.data.Dataset.zip((pu_image_ds, pu_label_ds))

#唐揚げとプードルの結合
ds = kara_image_label_ds.concatenate(pu_image_label_ds)
#シャッフル
ds_images_labels = ds.shuffle(779)

#trainとvalidにデータを分ける(train,valid)=(679, 100)
tf.random.set_seed(1)
ds_images_labels = ds_images_labels.shuffle(1000,reshuffle_each_iteration=False)
train = ds_images_labels.take(679)
valid = ds_images_labels.skip(679)

BATCH_SIZE = 32
BUFFER_SIZE = 779
IMAGE_SIZE = (128,128)
steps_per_epoch = np.ceil(779/BATCH_SIZE) #画像数/バッチ数
#dsが(img, label)のタプルのため、lambdaに二つ引数を渡す。
ds_train = ds_images_labels.map(lambda x,i:preprocess(x,i,size=IMAGE_SIZE,mode='train'))
ds_train = ds_train.shuffle(buffer_size=BUFFER_SIZE).repeat()
ds_train = ds_train.batch(BATCH_SIZE)

ds_valid = valid.map(lambda x,i:preprocess(x,i,size=IMAGE_SIZE,mode='train'))
ds_valid = ds_valid.batch(BATCH_SIZE)

```
これでやっとデータの準備ができました。次から学習に進んでいきます。

## 2.3　モデルの作成

`` from tensorflow.keras.callbacks import EarlyStopping) ``でEarlyStoppingを決めておきます。モニターはvalidのlossでいいでしょう。


```python
from tensorflow.keras.callbacks import EarlyStopping
es_cb = EarlyStopping(
                      monitor = 'val_loss', 
                      patience = 10, 
                      restore_best_weights = True)

model = tf.keras.Sequential([
    tf.keras.layers.Conv2D(32,(3,3),padding='same',activation='relu'),
    tf.keras.layers.MaxPooling2D((2,2)),
    tf.keras.layers.Dropout(rate=0.5),
    tf.keras.layers.Conv2D(64,(3,3),padding='same',activation='relu'),
    tf.keras.layers.MaxPooling2D((2,2)),
    tf.keras.layers.Dropout(rate=0.5),

    tf.keras.layers.Conv2D(128,(3,3),padding='same',activation='relu'),
    tf.keras.layers.MaxPooling2D((2,2)),

    tf.keras.layers.Conv2D(256,(3,3),padding='same',activation='relu'),

    tf.keras.layers.GlobalAveragePooling2D(),

    tf.keras.layers.Dense(1,activation=None)    
    ])

model.compile(optimizer = tf.keras.optimizers.Adam(),
                loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
                metrics=['accuracy'])
history = model.fit(ds_train,validation_data=ds_valid,
                    epochs=20,steps_per_epoch=steps_per_epoch,callbacks = es_cb,)
```
`` `model.summary()` ``でモデルの内容を確認します。
![スクリーンショット 2022-09-13 21.36.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/c4a7f503-b75b-5369-346d-d30745e82bfd.png)

ふむふむ、パラメータたくさんあるなー、でも畳み込んだ結果なので全て全結合層だったらすごいことに...


最終的な出力はひとつで1に近ければ唐揚げに、０に近ければプードルに分類されます。

## 2.4　モデルの評価

lossと正解率、epochsのグラフを見てみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/bd7760e9-8fed-7023-3669-708070ae5423.png)


なかなかいい感じですね！ValidationがTrainにしっかりついていってます。これは期待できる。

テストデータ（２７枚）正解率も　96.30%でした！

```python
hist = history.history
x_arr = np.arange(len(hist['loss'])) + 1
fig = plt.figure(figsize=(12,4))
ax = fig.add_subplot(1,2,1)
ax.plot(x_arr, hist['loss'], '-o', label='Train loss')
ax.plot(x_arr, hist['val_loss'],'--<', label='Validation loss')
ax.legend(fontsize=15)
ax.set_xlabel('Epoch', size=15)
ax.set_ylabel('Loss', size=15)
ax = fig.add_subplot(1,2,2)
ax.plot(x_arr,hist['accuracy'],'-o',label='Train acc.')
ax.plot(x_arr,hist['val_accuracy'],'--<',label='Validation acc.')
ax.legend(fontsize=15)
ax.set_xlabel('Epoch',size=15)
ax.set_ylabel('accuracy',size=15)
plt.show()

#正解率の表示
ds_test = ds_test.map(lambda x,i:preprocess(x,i,size=IMAGE_SIZE,mode='eval')).batch(1)
test_results = model.evaluate(ds_test,verbose=1)
print('Test Acc: {:.2f}%'.format(test_results[1]*100))
```
それではテストデータの予測出力を見てみましょう！！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/61b01f22-ff46-bea4-4208-1c919dae7ef4.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/18756012-4439-7d0f-a327-c2a036c2f427.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/052208b8-8757-ef9d-9943-9892dac93bf3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/34be6eb9-146c-3747-87de-3f2def0667f6.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/f0521980-f54a-6bd7-1bfa-4c8db37e4313.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/593e0ec0-6370-c0de-eefb-856f6c5184fb.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/6cf9913e-f720-6cfd-c965-640411c3eee8.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/dd1d6ae1-32a7-7029-d728-3f7b187b1219.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/95400711-b7b5-d929-cee7-d5834da8e0c7.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/a0059c80-6c43-a08c-04d8-d0527e4ba7d7.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/835e419f-31a8-c29b-82b2-25d1e810358d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/20061d3b-4e23-f0db-4a9d-dfd9068865b2.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/8f43ea72-f508-092f-fc0d-b5e8316395ed.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/28fc40bf-1413-06ed-6c1f-9d3be4e2ff8a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/3991ec5f-3e45-7121-d60a-3d404142f04e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/fae2a9b7-27b2-d749-0062-4c7d1e4206e8.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/494380b0-a413-fd35-dd67-af41c034fa57.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/d3746801-3168-5511-5d8c-d818acc3a710.png)

**そして目標の画像は！？**



![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/d00138b8-57e5-d942-06b6-80143986e062.png)

はい、唐揚げでした...
やはり同じような訓練画像が足りないのでしょうか、もっと集めたいと思います。ただ悔しいので最後にCNNが何をみてこの子達を唐揚げだと判断しているのかGradCAMで可視化して行きます！


## 3. GradCAMによる可視化

Grad-CAM (Gradient-weighted Class Activation Mapping)
はCNNベースの画像認識モデルに対してある入力とその予測に対して局所的な説明を与える手法です。
以下のようにヒートマップを与えてCNNがどこを重要視しているか可視化することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/d15d6cd6-843c-3f69-7e75-7877ab1e6fba.png)


GradCAMの説明の前にGlobal Average PoolingとCAMの説明をして行きます。

## 3.1 Global Average Pooling（GAP）
Global Average Poolingの特徴は普通の平坦化に比べて大幅にパラメータの数を削減できることにあります。
GAPを行うと特徴map各成分の平均がユニットの値になります。
次の例でも平坦化のパラメータ40個に対して、GAPは１０個でかなりの削減です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/3aa05886-904a-c3b7-2591-d6d8e11eca5a.png)

## 3.2 Class Activation Map (CAM)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/0d94c373-0ed3-e5d7-81a4-5d8930d97a2c.png)

## 3.3 GradCAM

さてGradCAMとはGradient-weighted Class Activation Mappingの略でCAMを一般化したものです。
具体的には特徴mapの勾配からヒートマップを与えて可視化して行きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/a33876ba-3336-8570-2ba1-1c9dc6883e5f.png)

## 3.4 GradCAMの実装と可視化

```python
def show_cam(i):
    grad_model = tf.keras.models.Model([model.inputs], [model.get_layer('conv2d_15').output, model.output])

    with tf.GradientTape() as tape:
        conv_outputs, predictions = grad_model(tensor_list[i][0])
        class_idx = np.argmax(predictions[0])
        loss = predictions[:, class_idx]

    output = conv_outputs[0]
    grads = tape.gradient(loss, conv_outputs)[0]

    gate_f = tf.cast(output > 0, 'float32')
    gate_r = tf.cast(grads > 0, 'float32')

    guided_grads = gate_f * gate_r * grads

    # 重みを平均化して、レイヤーの出力に乗じる
    weights = np.mean(guided_grads, axis=(0, 1))
    cam = np.dot(output, weights)


    # 画像を元画像と同じ大きさにスケーリング
    cam = cv2.resize(cam, (500, 500), cv2.INTER_LINEAR)
    image = cv2.resize(image_list[i][0],(500, 500),cv2.INTER_LINEAR)

    # ReLUの代わり
    cam  = np.maximum(cam, 0)
    # ヒートマップを計算
    heatmap = cam / cam.max()

    # モノクロ画像に疑似的に色をつける
    jet_cam = cv2.applyColorMap(np.uint8(255.0*heatmap), cv2.COLORMAP_JET)
    jet_cam = 0.7 * jet_cam / np.max(jet_cam)
    jet_cam = np.float32(jet_cam) + np.float32(image)


    # RGBに変換
    rgb_cam = np.float32(cv2.cvtColor(jet_cam, cv2.COLOR_BGR2RGB))

    # もとの画像に合成
    output_arr = cv2.addWeighted(src1=image, alpha=1, src2=rgb_cam, beta=0.3, gamma=0)

    fig = plt.figure(figsize=(6,10))
    ax = fig.add_subplot(1,2,1)
    ax.set_title('Grad CAM')
    ax.imshow(rgb_cam)

    ax = fig.add_subplot(1,2,2)
    if preds[i] == 1:
        ax.set_title('予測結果→唐揚げ')
    else:
        ax.set_title('予測結果→プードル')
    ax.imshow(image)
```
**唐揚げの可視化**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/ffe8a168-933d-cdbd-db1b-1e9e21cb0f43.png)

**プードルの可視化**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/a220c3a5-7f84-7886-63b5-01662b4f3286.png)

GraadCAMで可視化した結果、唐揚げは全体的に、目と鼻に注目していることがわかりました！！
そして問題の画像は、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730459/d3db43c4-091e-ab39-f8dd-00d6c04ae841.png)
唐揚げの特徴である全体に注目していることがわかります。


## 4.最後に
CNNやGradCAMを学習して、画像認識の機械学習にも大変興味がわきました！！GradCAMを通してCNNが何を重要視しているか可視化することによって私の唐揚げプードルデータセットの改善方法も見えてきました。今後は訓練データで顔が見えていないプードルや複数匹いる画像なども学習させてみたいです。

**プードルは絶対に食べさせません!!!**

## 5.参考文献

Grad CAMの論文
[Grad-CAM:Visual Explanations from Deep Networks via Gradient-based Localization](https://openaccess.thecvf.com/content_ICCV_2017/papers/Selvaraju_Grad-CAM_Visual_Explanations_ICCV_2017_paper.pdf)

https://qiita.com/shinmura0/items/ba45b7b9da02a44ed1b1

https://qiita.com/ToppaD/items/4756f2eaa7dff82d6434