VGG16ファインチューニングモデルによる画像分類

# 概要
本記事では、既存の学習済みモデル(VGG16)をファインチューニングしたモデルを用いた、交差検証によるコップと皿の画像分類の手順と、実際に学習・検証、テストをした結果、考察、今後についてを記載しています。

今回実施するファインチューニングとは、事前学習済みのモデルの一部(入力層、出力層やその付近の層等)を変更して再学習する手法のことを言います。
画像認識などの分野では、異なる画像の分類であっても共通する特徴が存在する場合が多いため、こういった事前学習済みのモデルを活用する手法が有用であると言われています。
特に、データを多く用意できない新しいタスクに対して、既存のモデルを活用することで効果を発揮する手法になります。

<dl>
<dt>データの少ない画像分類モデルのloss例：</dt>
<dd>左グラフ: 1から作成した場合(検証データ損失:0.0786)</dd>
<dd>右グラフ: ファインチューニング(検証データ損失:0.0040)</dd>
</dl>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/c958decc-bc67-6ee8-ea24-f795698739c4.png) ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/f2b6dcb7-6a27-b762-bcb0-d6d6ae730abd.png)

:::note info
上記グラフはスケールが異なり少し見辛いですが、どちらも精度は高いものの、ファインチューニングモデルでは検証データの損失が1桁異なり、グラフの形状から高い安定性を保っている様子が分かります。
:::

今回取得できるコップと皿の元データが30枚ずつ程度と決して多くなかったため、１からモデルを作成して学習させるよりも、より正確な分類が期待できるファインチューニングを行うことにしました。

:::note info
ファインチューニングの分類について、書籍では転移学習の一部、あるいは同じ意味で記載されていることが多いようですが、web上では記事によってモデルの重みを固定する場合は転移学習、固定しない場合はファインチューニング、のように分けて考えた見方もあるようです。
今回ご紹介するモデルは、学習済みのモデルの重みをある程度固定して学習させているため、上記web記事の定義に従えば"転移学習"という見方もできるかも分かりませんが、ここでは書籍での定義に従い"ファインチューニング"としております。
:::

# 環境
- Python 3.7.9
- tensorflow 2.3.0

# 手順概要
##### 1. 分類したい画像データを収集・リサイズします。
##### 2. 画像枚数を拡張します。
##### 3. 画像を前処理し、画像と正解ラベルを結合します。その後、データを教師・検証データとテストデータに分割します。
##### 4. モデルを作成し、事前設定を行います。その後、学習データと検証データを用いて学習を行います。
##### 5. 学習したモデルを用いて、テストデータの予測と評価を行います。

# １. 画像データの収集

スマートフォンで画像を撮影した後でPCに移動、
各クラスの画像を入力画像としてフォルダに分けて保存します。

今回は基本的に自宅にある種類の異なるコップと皿を撮影し、入力画像としています。
拡張前のデータ数として30枚ずつ用意したいと思っていたため、足りない数枚は近くのショッピングセンターにて撮影をさせてもらいました。
基本的には分類対象が明確に写るように撮っていますが、影による明るさの違い等はあります。
(汎用性を持たせるため、輪郭処理や閾値処理等の処理は行っていません。)

##### 使用した画像枚数：
| クラス | 枚数 |
|:-:|:-:|
|コップ|30|
|皿|30|

##### 分類に用いるコップと皿の画像の例：
![cup1_pre_article.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/da121bc6-6ddb-f278-185a-f43ecc087a9c.jpeg)　![dish8_pre.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/81616d7b-10a9-9051-3be5-f7034ab8c5ab.jpeg)

その後、pillowで画像のサイズを一律にリサイズします。

```python:
import os
import glob
from PIL import Image

# フォルダの置いてある場所を指定
PATH = "フォルダ場所"

# 入力画像フォルダ
input_dir = "cup_pre"
#input_dir = "dish_pre"

# リサイズ後のサイズ
resize = 100

# 出力フォルダ名
out_dir = "cup"
#out_dir = "dish"

# フォルダを置く場所に移動
os.chdir(PATH)

# ファイル名
files = glob.glob(input_dir+'/*.jpg')

# 出力フォルダが存在しない場合は作成
if not os.path.exists(out_dir):
    os.mkdir(out_dir)

# フォルダを置く場所に移動
os.chdir(PATH)

# 元画像の変換
for file in files:
    img = Image.open(file)
    img_resize = img.resize((resize, resize))
    img_resize.save(os.path.join(PATH,os.path.join(out_dir,os.path.basename(file))))
```

上記をそれぞれのフォルダに対して実行します。

# ２. 画像枚数の拡張

入力画像が置かれたフォルダのファイル数を確認する場面が出てくるため、
まず最初にファイル数を数える関数を設定します。

```python:フォルダ内のファイル数を数える関数
def count_file(dir):
    file_count = 0
    for file_name in os.listdir(dir):
        # 入力ファイルのパスを取得
        file_path = os.path.join(dir,file_name)            
        # 入力ファイルの有無を確認
        if os.path.isfile(file_path):  
            # ファイル数を数える
            file_count += 1
    return file_count
    print(file_count)
```

次に画像枚数を拡張する関数を用意します。

```python:画像を水増しする関数
# out_file_countに達するまでinput_dir内の画像を拡張する関数
def image_augumentation(input_dir, out_dir, out_file_count):

    # 入力フォルダ内のファイル名を取得
    files = glob.glob(input_dir + '/*.jpg')

    # 入力ファイル数確認（関数呼び出し）
    in_file_count = count_file(input_dir)

    # 出力フォルダが存在しない場合は作成
    if not os.path.exists(out_dir):
        os.mkdir(out_dir)

    # 入力フォルダの画像を出力フォルダにコピー
    copy_tree(input_dir, out_dir)
            
    for i, file in enumerate(files):

        # RGB画像(PIL形式)の読み込み
        img = load_img(file)
        
        # numpy配列へ変換
        x = img_to_array(img)
        
        # flow関数に合わせてnumpy4次元データに変換(3次元→4次元)
        x = x[np.newaxis]

        # ImageDataGeneratorで画像を増やす        

        # ※1 ImageDataGeneratorクラスへの引数をdirectory形式で変数に代入
        params = {
            'width_shift_range': 0.4, 
            'height_shift_range': 0.4,
            'horizontal_flip': True,
            'vertical_flip': True,
            'zoom_range':0.2, 
            'shear_range':0.2,
            'rotation_range': 50,                      
            'fill_mode':"nearest"
        }
        # ※2 オブジェクト作成
        datagen = ImageDataGenerator(**params)

        # ※3 flowメソッドで画像を出力する拡張画像を保存するイテレータを作成・リターン。
        # イテレータに対してnextメソッドを用いることで各画像を出力フォルダに保存する。
        # nextメソッドの実行回数だけ画像が出力される。
        gen = datagen.flow(x, batch_size=1, save_to_dir=out_dir, save_prefix=re.search("[a-zA-Z]+",input_dir).group(), save_format='jpg')
        
        # ※4 繰り返し回数 = (出力したいファイル数 / 現在の入力フォルダ内のファイル数 -1) 繰り返す。
        # 不足分は後で追加する。
        for j in range(out_file_count // in_file_count -1 ):
            batch = gen.next()
        if i+1 == in_file_count:
            # 出力ファイル数確認(関数呼び出し)
            cur_file_count = count_file(out_dir)
            if out_file_count > cur_file_count:
                # 出力枚数が計算より少ない場合があるため多めに考慮。
                for k in range((out_file_count % in_file_count)+10):
                    batch = gen.next()
                    # 現行ファイル数と出力ファイル数を確認(関数呼び出し)
                    cur_file_count = count_file(out_dir)
                    # 出力フォルダのファイル数が出力したい枚数になったら終了
                    if cur_file_count >= out_file_count:
                        break
```


上記※1にて、ImageDataGeneratorを用いて各画像を300枚ずつに拡張しています。

<dl>
<dt>今回用いるパラメータ：</dt>

<dd>width_shift_range= 横幅方向にずらす範囲(横幅に対する割合を指定)</dd>
<dd>height_shift_range= 高さ方向にずらす範囲(高さに対する割合を指定)</dd>
<dd>holizontal_flip= 左右反転</dd>
<dd>vertical_flip= 上下反転</dd>
<dd>zoom_range= 拡大・縮小範囲</dd>
<dd>shear_range= シアー強度(平行四辺形のように変形させる角度)</dd>
<dd>rotation_range=回転角度</dd>
<dd>fill_mode= 変形により空いた空間の埋め方</dd>

<dd>constant= fill_modeののパラメータ①: 一色で補完</dd>
 <dd>nearest= fill_modeののパラメータ②: 隣接ピクセルの色で補完</dd>
 <dd>reflect= fill_modeののパラメータ③: 反転画像で補完</dd>
 <dd>wrap= fill_modeののパラメータ④: 画像を繰り返す形で補完</dd>
</dl>
詳細は公式サイトをご参照いただけましたら幸いです。
https://keras.io/ja/preprocessing/image/

※2にて、for文で一枚ずつ読み込み、上記のパラメータを用いてImageDataGeneratorのオブジェクトを作成します。

※3にて、flowメソッドを用いて、オブジェクトに保存先やファイル名の接頭部、形式を指定して画像を生成するGeneratorを作成します。

※4にて、生成したGeneratorに対してnextメソッドを実行することで画像を保存します。
for文を用いて元画像から必要枚数を生成していきます。

##### 拡張画像例：
fill_mode=reflectの場合、鏡のように反射しているため、複数のコップや皿が見えます。
![cup_0_9466.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/325727a4-8a5a-1d4d-2c83-95d7ea62dd09.jpeg)　![dish_0_559.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/e984d3e9-6f73-b294-aca7-afe3f6e89241.jpeg)
fill_mode=nearestの場合、隣接した色で埋めるため、周囲が引き延ばされたように見えます。
![dish_0_2365.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/146cdb80-311f-dceb-8354-fc1788be210f.jpeg)　![cup_0_368.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/e3ac07a3-4f06-5300-0b29-12b5aa2fcbb1.jpeg)
今回はnearestを用いています。

# ３. 画像の前処理、ラベル付加、X,y分割、教師・検証データとテストデータの分割

今回、2014年に大規模画像認識競技会ILSVRで準優勝をしたVGG16というモデルを元にファインチューニングをします。小さいカーネルで畳み込みを繰り返しながらプーリング毎にサイズを半分にすることで、シンプルながら高い精度を発揮するモデルと言われています。

```python:データセットを作り、Xとyに分割
# 画像データにラベルを付けxとyのデータセットを作成。

import matplotlib.pyplot as plt
import os
import cv2
import random
import numpy as np
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.applications.vgg16 import preprocess_input

categories = ["cup","dish"]
category_size = len(categories)

# データセット用の配列を用意
def create_data_set():
    data_set = []

    # ※1 クラス毎にフォルダを読み込み
    for class_num, category in enumerate(categories):
        path = os.path.join(PATH, category)
        # 画像を1枚ずつ読み込み、numpy配列変換、前処理、ラベル追加を実施(0➡cup, 1➡dish)
        for image_name in os.listdir(path):
            img_pil = load_img(os.path.join(path, image_name)) 
            img_array = img_to_array(img_pil)
            img_preprocessed = preprocess_input(img_array) 
            # ※2 ラベル追加
            data_set.append([img_preprocessed,  class_num])

    # ※3 データの順番をシャッフルする
    # 乱数生成ジェネレータ作成
    rng = np.random.default_rng()
    # axis=0でシャッフル。
    data_set = rng.permutation(data_set, axis=0)

    # ※4 
    # 画像データ
    X = []
    
    # ラベル情報
    y = []
    
    # X,yデータ作成
    for feature, label in data_set:
        X.append(feature)
        y.append(label)
        
    # numpy配列変換
    X = np.array(X)
    y = np.array(y)

    return X,y

X_train_val_test, y_train_val_test = create_data_set()
```

・VGG16はtensorflowではなく、比較的画像解析に強いフレームワークcaffeで学習されているため、※1で各クラス毎に画像を１枚ずつ読み込み、numpy配列に変換しcaffeのフレームワークに合わせて画像に前処理を施しています。
具体的には、RGB➡BGR変換と、各チャンネルについて、学習した際の全画像の平均を引く正規化が行われます。

参考までに、前処理を行った画像をRGBの範囲で表示させると以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/2f6c58ee-3108-c44b-ec2b-3a89d2a03d67.png)
・※2にて画像に対して、皿かコップかを示すラベルを追加しています。

・この時点で画像はラベル別に並んでいますが、※3にてこれをシャッフルします。

・※4にてラベル付画像を１枚ずつ画像部をX、ラベル部をyの配列に追加し、numpy配列に変換します。

```python:学習データ＋検証データとテストデータを分割
# データをtrain_valとtestにラベルが均等になるよう分割

from sklearn.model_selection import train_test_split

# ※5 学習データ＋検証データとテストデータに分割 (テストデータ:20%)
X_train_val, X_test, y_train_val, y_test = train_test_split(X_train_val_test, y_train_val_test, test_size=0.20, stratify = y_train_val_test)

# ※6 テストデータをone-hotエンコーディング (train_valデータはKFoldの後で実施)
y_test = to_categorical(y_test,category_size)
```


・上記コードの※5にて、train_test_splitを用いて学習・検証に用いるnumpy配列と、最終的にテストに用いるnumpy配列に分割します。
test_size = 0.20とすることで全体の20%をテストデータに割り当てます。
また、stratifyオプションを用いることでコップ画像と皿画像を均等に分割することができます。

・この時点で、テストラベルは0→コップ、1→皿となっていますが、このままですと数字の大小が意味のあるものとして学習され結果に影響が出るため、※6にてto_categoricalメソッドを用いてone-hotエンコーディングという形式に変換します。

| クラス | ラベル値 | one-hotエンコーディング |
|:-:|:-:|:-:|
|コップ|0|[1 0]|
|皿|1|[0 1]|

# ４. モデルの構築とコンパイル、学習

今回はtensorflowというフレームワークを用いて、モデルを作成します。

まずは、モデルの学習中に条件を満たすと実行されるコールバック関数を３つ設定します。

##### １つ目： チェックポイント保存
この関数は、学習・検証を繰り返す中で、監視しているステータスが最も良い時のモデルの重みだけを、指定したファイル名で保存します。

```python:チェックポイント保存
ch_pt = callbacks.ModelCheckpoint(filepath='my_model.h5',monitor='val_loss',save_best_only=True,save_weights_only=True)
```

##### ２つ目：学習率の削減

学習率とは、１回に重みを修正する値の大きさを表すパラメータです。

学習を始めた時点では高い学習率でどんどん重みを修正する方が早く最適な値に近付きますが、そのままでは最適な重みを飛び越えてしまうため、最適な値に近付くにつれて学習率を低くして修正する値を小さくすることができれば、最適な値により近づけるような微調整が可能です。

この関数では、検証データのlossが改善されなくなった際に、学習率を下げることでこの微調整ができます。

```python:学習率調整
rd_lr = callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.2, patience=5, min_lr=0.001)
```

##### ３つ目：early stopping

Deep learningでは学習を何度も繰り返しながら最適な重みを見つけていきますが、繰り返すにつれて学習データに過剰適合すると、学習データ以外のデータの正解率が低い、いわゆる汎化性[^1]の低いモデルができてしまいます。

これを防ぐために、検証データのlossが改善されなくなったタイミングで学習を停止するように設定できる関数です。

```python:early_stopping
es_cb = callbacks.EarlyStopping(monitor='val_loss', patience=20, verbose=1, mode='auto')
```

次に、モデル作成の関数を定義します。この関数は後で呼び出します。

```python:モデル構築+コンパイルを行う関数
def fine_tuning_model():
    # モデルを作成する(VGG16、変更点：①input_shapeを100x100x3に合わせる、②Flattenの代わりにGlobal average poolingを用いる、③Global average pooling以降は全結合層やバッチ正規化、ドロップアウトを繋げる)
    base_model_avg = tf.keras.applications.vgg16.VGG16(include_top=False, pooling="avg", input_shape=(X_train.shape[1],X_train.shape[2],X_train.shape[3]))

    x = base_model_avg.output
    x = Dense(256)(x)
    x = BatchNormalization(axis = 1)(x)
    x = Activation('relu')(x)
    x = Dropout(0.4)(x)
    x = Dense(128)(x)
    x = BatchNormalization(axis = 1)(x)
    x = Activation('relu')(x)
    x = Dropout(0.4)(x)
    
    # one-hotエンコーディング-> 2, ２値分類のため、0~1を出力するsigmoid関数が最適と思われる。
    x = Dense(category_size, activation="sigmoid")(x)

    # モデルを用意
    model_functional = Model(inputs = base_model_avg.input, outputs = x)
    
    # モデルの重みを15層目まで凍結
    for layer in model_functional.layers[:16]:
        layer.trainable = False
    
    # モデルのコンパイル
    model_functional.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
    
    return model_functional
```

モデルを構築する手法はSequential API、Functional API、Subclassing APIの３種類あると言われていますが、今回はFunctional APIを用いています。

それぞれの手法と、それらを用いたモデルの構築方法については、以下のURLが分かりやすく参考になるかと思います。

https://note.nkmk.me/python-tensorflow-keras-basics/

上記のコードにて、まずはVGG16のモデルを読み込み、以下を変更しています。

#### １．今回の入力画像に合わせて、入力サイズを変更
#### ２．出力側を以下のように変更

##### ①出力側で、畳み込み層から全結合層に繋げる際に、パラメータ数を抑制し計算速度を上げながら正則化の機能を果たし精度を高めるGlobal Average Poolingを実施(各チャンネルの値を平均して全結合層へ)

##### ②Global Average Poolingの後に全結合層を２層追加し、それぞれネットワークの一部を不活性化することで過剰適合を抑制するDropout、データを平均0、分散1の形に正規化することで微分による勾配消失や過学習に対応するバッチ正規化を行い、活性化関数にはrelu関数を使用

##### ③出力層はコップと皿の二値に分類できるように0~1の値を取るsigmoid関数を使用(VGG16は多値分類[1,000種類])

次に、モデルの重みをどの層まで固定するかを指定します。
VGG16は既に多くの画像を学習しているので、この重みを活用するため
今回は既存モデル部分を16層中15層まで固定し、1層＋追加した全結合層で学習を行います。

```python:重みの固定
    # 重みを15層目まで固定
    for layer in model_functional.layers[:16]:
        layer.trainable = False
```

モデルのコンパイルを実施します。

コンパイルでは、モデルの評価に用いる損失、評価関数と、最適化アルゴリズムの指定を行います。

損失関数は二値分類に適したバイナリークロスエントロピー誤差を用います。
これは、真の値(コップなら[1 0]、皿なら[0 1])に対して、モデルが推定した確率分布(例えば[0.985 0.015]等)に対数を取った値を掛けて、更に-1を掛けたものの総和です。
式で表すと以下の通りになります。
![図a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/6847c61e-55dd-e5c1-5516-382b8b6dfb44.png)
pは真の確率分布、qはモデルが推定した確率分布になります。
以下の記事を参考にしておりますので、詳細はこちらの記事が分かりやすいかと思います。

https://qiita.com/kenta1984/items/59a9ef1788e6934fd962

正解に近ければ近いほど計算結果は0に近くなります。

評価関数は正解率を用います。

最適化アルゴリズムとしては、adamを用います。
最適化アルゴリズムは、重みを傾きがゼロになるように学習させていくための手法です。
adamは、2015年に提唱されたアルゴリズムで、重みの更新に移動平均を用いることで振動を抑制するモーメンタムというアルゴリズムと、傾きの大きさに応じて学習率を変化させるRMSPropというアルゴリズムを取り入れた形になっており良く使われるアルゴリズムの１つと言われています。

上記の関数は以下のように交差検証を実行する中で呼び出す形を取ります。

ディープラーニングのモデルでは、
学習データで重みを学習しながら、検証データで汎化性[^1]を検証します。
学習データと検証データを単純に２分割するホールドアウト法という手法がありますが、
今回は全体としてのデータの偏りに対応できるK-Fold交差検証を行います。

K-Fold交差検証では画像データをK個のブロックに分けて、

|検証用|学習用|
|:-:|:-:|
|１つ目のブロック(1個)|残りのブロック(K-1個)|

と分けて学習させます。ここまではホールドアウト法に似ています。

交差検証では次に、以下のように学習を進め、合計K回学習を実施します。

|検証用|学習用|
|:-:|:-:|
|２つ目のブロック(1個)|残りのブロック(K-1個)|
|３つ目のブロック(1個)|残りのブロック(K-1個)|
|…|…|
|K個目のブロック(1個)|残りのブロック(K-1個)|

今回は5個のブロックに分けて、5回学習します。
それぞれの学習は50epoch繰り返します。
なお、ここではStratified K-Foldという手法を用います。
Stratified K-Foldではクラスの配分が均等になるようにK-Fold交差検証を行うことができます。
つまり、今回の場合正解ラベルがコップと皿のデータが各ブロックに均等に分かれるように分割できます。

Stratified K-Foldは下図のような形になります。
![stratified_KFold.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/f77abcf5-9e71-8498-2d24-d4e5e059c5f3.png)


データの偏りを避けるため、こちらの方法が最適であると考えました。

他には同じグループのデータが学習データと検証データに跨って存在しないように分割するGroup K-Foldというものもありますが、今回はコップのクラスと皿のクラスを均等に分けて学習させることが重要なため、Stratified K-Fold法を用います。

コードは以下の形になります。

```python:交差検証を用いたモデル構築～学習・検証
# モデル構築～コンパイル～学習
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import accuracy_score
from sklearn.metrics import f1_score
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense, Activation, Flatten
from tensorflow.keras.layers import Dropout
from tensorflow.keras.layers import BatchNormalization
from tensorflow.keras import initializers
from tensorflow.keras import regularizers
import tensorflow as tf
from tensorflow.keras.initializers import TruncatedNormal

n_splits = 5
epochs = 50
result_t = []
test_f1 = []

y_train_val_pred = np.zeros(len(y_train_val))
cv = StratifiedKFold(n_splits=n_splits,random_state=42,shuffle=True)

for i,(trn_index, val_index) in enumerate(cv.split(X_train_val, y_train_val)):
    X_train, X_val = X_train_val[trn_index], X_train_val[val_index]
    y_train, y_val = y_train_val[trn_index], y_train_val[val_index]

    # 学習用にOne-Hotエンコーディング
    y_train = to_categorical(y_train, category_size)
    y_val = to_categorical(y_val, category_size)

    # モデルを読み込み
    model_functional = fine_tuning_model()
    
    # 作成されたモデルについて、val_lossが最も低いepochのデータを保存
    result = model_functional.fit(x=X_train, y=y_train, epochs=epochs, batch_size=64, validation_data=(X_val, y_val),callbacks=[ch_pt,rd_lr,es_cb])
    result_t.append(result)
    
    # y_train_val_predにブロックごとに評価データの予測結果を埋めていく。
    # この時、argmaxにより、One-Hotエンコーディング形式からラベル形式に変換される。
    # [0.9 0.1] ➡ 0.
    y_train_val_pred[val_index] = np.argmax(model_functional.predict(X_val),axis=1)

    # ※8 作成したモデルでテストファイルを予測する。
    y_pred = model_functional.predict(X_test)
    
    # 予測値を丸める
    y_pred = np.round(y_pred)
    
    # F値を計算
    f1 = f1_score(y_test, y_pred, average="micro")

    # test_f1配列に結果を追加(KFoldのブロック数だけ追加される。今回は5個)
    test_f1.append(f1)
    
    # モデル全体の保存
    model_functional.save(f'vgg16_ft_trained\\my_model-{i}')

# oofデータの予測結果の正解率を計算。この時y_train_valはOne-Hotエンコーディングを行う前の
# ラベル形式であり、y_train_val_predもラベル形式なので、正しく正解率を計算できる。
score = accuracy_score(y_train_val, y_train_val_pred)
```
##### Layer：
Layerは以下のようになります。
Functional APIでは、VGG16部分も個別に層が表示されます。
![layer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/f0f220ed-b8c5-6314-03f5-54d0d02394f1.png)
##### ※7に示してあるout of fold(oof)について：
oofはK個に分割した各データの検証データの部分を表します。
各oofについて、K個に分かれたデータの各モデルで予測を行い、予測結果をK個分足し合わせて、学習・検証データ全体(X_val × K個)の正解ラベル(y_train_val)に対する正解率を算出することで、K個作成したモデルの全体的な性能を評価することができます。

今回はまず、上記の※7 で長さが学習・検証データ(y_train_val)で、要素が0のnumpy配列を「y_train_val_pred」用意します。
次に※8で、作成した各モデルを用いて各検証データの予測値をy_train_val_predに入れていき、ラベル形式に変換します。[0.1 0.9] ➡ 1.
最後に※9で、正解ラベル(y_train_val)とy_train_val_predを比較し正解率を出力します。

##### 学習したモデルの損失と正解率について：
epochを繰り返す毎にどれだけ改善されていったのかをグラフで確認します。
今回は交差検証で5回学習していますが、5回目の学習結果を表示します。

```python:損失グラフ
# 損失グラフ
from matplotlib import pyplot as plt

# 最後に学習したモデルの損失グラフ
KFold_number = 4

# 折れ線グラフによる学習データの損失の描画
plt.plot(result_t[KFold_number].history['loss'])

# 折れ線グラフによる検証データの損失の描画
plt.plot(result_t[KFold_number].history['val_loss'])

# 凡例の指定
plt.legend(['Train', 'Val'])

# グラフの軸タイトルの指定
plt.xlabel('Epoch')
plt.ylabel('Loss')

# 描画の実行
plt.show()
```
##### 損失グラフ出力結果：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/e1d64fc0-f86c-53aa-e080-e4a697441b8e.png)

```python:正解率グラフ
# 正解率グラフ

# 折れ線グラフによる学習データの正解率の描画
plt.plot(result_t[KFold_number].history['accuracy'])

# 折れ線グラフによる検証データの正解率の描画
plt.plot(result_t[KFold_number].history['val_accuracy'])

# 凡例の指定
plt.legend(['Train', 'Val'])

# グラフの軸タイトルの指定
plt.xlabel('Epoch')
plt.ylabel('Accuracy')

# 描画の実行
plt.show()
```
##### 正解率グラフ出力結果：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/b47c37f6-ece1-0daa-a2d9-e08c034ab3c5.png)

#### oofデータ正解率:　0.998

# ５. テストデータの予測・評価

モデルを用いてテストデータを予測・評価した結果を確認します。
該当コードは４．の※8部分に記載してありますが、テストデータの予測部分だけを抜き出すと以下のようになります。

```python:テストデータ予測・評価部分の抜粋
test_f1 = []
################  省略  #######################
    # 作成したモデルでテストファイルを予測する。
    y_pred = model_functional_l.predict(X_test)
    
    # 予測値を丸める
    y_pred = np.round(y_pred)
    
    # F値を計算
    f1 = f1_score(y_test, y_pred, average="micro")

    # test_f1配列に結果を追加(KFoldのブロック数だけ追加される。今回は5個)
    testf1.append(f1)
```

##### [結果]
##### 各ブロックf値：　[0.983, 0.983, 0.975, 0.992, 0.983]

<dl>
<dt>f値</dt>
<dd>再現率と適合率の調和平均</dd>
<dd>0～1の値を取り、1に近い程良いと言える。</dd>
</dl>
<dl>
<dt>再現率</dt>
<dd>実際に正であるデータの中で正と予測できた割合</dd>
<dd>(例：癌に罹患している患者の内、癌であると予測できた割合)</dd>
</dl>
<dl>
<dt>適合率</dt>
<dd>正であると予測したものの中で実際に正であるデータの割合</dd>
<dd>(例：癌であると予測している内、実際に癌に罹っている割合)</dd>
</dl>

再現率と適合率はトレードオフの関係にあるため、その両方を考慮に入れられるf値が用いられることが多いです。

#考察

・今回使用した画像データの中には、以下のように透明なコップや影がかかって暗くなった皿等がいくつかあったので、正しく分類されるかどうか若干の心配がありました。
検証データにおいて若干正解率に不安定な部分も見られましたが、元データの少なさの他にそういったところも現れているのかも分かりません。
![cup23_pre_article.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/da9f08ef-49b4-4cef-41af-75f6d4a93fc3.jpeg)　![dish26_pre_article.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/bd6ba859-9dbd-7f0a-6500-7fbd8933bc1d.jpeg)
しかし、結果としては学習データ・検証データからテストデータに至るまで高い精度での分類ができていました。

・今回、oofの予測においてもテストデータの予測においても高い精度が得られました。このことから比較的汎化性[^1]の高いモデルが構築できていたと考えられます。
基本的にはVGG16の重みを使用することで一定以上の精度が得られたものと考えていますが、今回のように学習データが少なくパラメータの多いモデルは、特に学習データに過剰適合し未知のデータが正しく予測できない過学習も起こしやすいと言われているため、精度の向上と過学習の抑制のために、モデル作成において以下の工夫を行いました。

[^1]: 汎化性：学習時に用いたデータだけでなく、未知のデータに対しても正しく予測できる能力のこと

##### ① Dropout層の活用

Dropoutとは、ネットワークの中で一部のノードをランダムに不活性化させる手法のことで、モデルの過学習を避ける方法の１つです。
学習の繰り返しの中で毎回同じネットワークが使われる場合、学習データにのみ適合した汎化性[^1]の低いモデルとなる恐れがありますが、ランダムに使われないノードがあることで、異なる形状のネットワークを学習しているようになり、汎化性[^1]が向上します。
Dropoutは2014年にNitish SrivastavaやGeoffrey Hintonらにより提案されました。
tensorflowでは指定した割合のインプットをランダムに0にするdropoutメソッドが提供されています。
公式サイト：https://www.tensorflow.org/api_docs/python/tf/keras/layers/Dropout

##### dropout層のイメージ
![dropout.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/55b8ce64-c74b-dfdb-19ba-1a0cec32e5ff.png)

##### ② Batchnormalization層の活用

Batchnormalizationは、活性化関数の後の出力データの分布を平均0分散1にし、固有のスケール(×γ)とシフト(+β)を行うことで、学習の繰り返しによるデータ分布の偏り(内部共変量シフト)を強制的に調整します。
出力データが偏ると、どのニューロンも同じような値をとることで表現性が足りなくなってしまうと言われています。(複雑なネットワークを用いても、全てのネットワークから同じような値が出力されていると、複雑である利点を得にくくなる)
また、Batchnormalizationは学習を早く進行させることができる、初期値にそれほど依存しない、過学習を抑制するなどの効果があると言われています。

Batchnormalizationは2015年にSergey loffeとChristian Szegedyにより提案されました。

tensorflowではBatchNormalizationメソッドを用いることで、入力に対して標準化が行われます。

公式サイト：
https://www.tensorflow.org/api_docs/python/tf/keras/layers/BatchNormalization

##### ③ Global Average Poolingの利用

Global Average Poolingは、以下のように各チャンネルで平均値を用いることで2次元から1次元に変換する際に全結合させるよりもパラメータが少なく、計算量を削減することができ、全結合で起こりやすい過学習も抑制する手法です。
##### 例：5 x 5 x 512の場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/e9867fd3-f3af-a512-11e8-dd7fe1927ab7.png)
モデルのsummaryでの表示
![gap.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/f9971e28-f8a2-cd53-87f0-072cbdecbe24.png)
Global Average Poolingは2014年にMin Lin、Qiang Chen、Shuicheng Yanらにより提案されました。

ちなみに、今回のモデルで上記３点のみを行わなかった場合、以下のような結果となりました。

#####今回のモデルでDrop out、Batch Normalization、Global Average Poolingを行わなかった場合
Layer：
![deteriorated_layer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/3047af36-04c2-258a-36e7-901b7f88a1b8.png)
結果：
損失グラフ[without Dropout,Batchnormalization,GAP]：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/a707e939-8aa0-2c99-5f09-015471e80e71.png)

正解率グラフ[without Dropout,Batchnormalization,GAP]：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1872129/2e776ea3-37da-25fd-6b9f-2c53f99d3c2d.png)

out of foldの予測正解率[without Dropout,Batchnormalization,GAP]： 
0.973

テストデータのf値(各モデル分)[without Dropout,Batchnormalization,GAP]:
[0.975, 0.954, 0.975, 0.967, 0.946]

今回はVGG16を用いて重みを15層目まで固定していたこともあり、5つ中5つ目のモデルを除いて明らかな過学習は見られませんでしたが、dropout等を使用していたモデルと比較して、全体的に若干精度が落ちていることがf値やoofの予測正解率からも分かります。
重みを固定する層を減らしたり、複雑な分類を行ったりすると、差がより明確になる可能性が考えられます。

また、5つ中4つのモデルにおいて、epoch 29-35でearly stoppingが呼び出されており、early stoppingがなければ更に過学習をしていた可能性が考えられます。

記載の損失グラフは5つ目のモデルの結果ですが、epoch10を超えた辺りから検証データのLossの上昇傾向が見られ、過学習が発生していると思われます。

以上の結果から、Dropout, Batchnormalization, GAPの使用により、精度が向上し過学習も抑えられていたと考えられます。

#今後について

今後は、コップと皿ような明確な分類だけでなく、より複雑でクラスの多い植物の種類の分類や、衛星画像を用いた画像分類についてもテストを行えたらと考えています。

モデルについては、別のモデル(画像処理向けBERT, transformer等)も活用できればと思います。

コードはこちらを参考にしていただけたらと思います。

https://github.com/Akihiko0123/programs/blob/main/VGG16_binary_classification.ipynb

(21'10.5 追記)
flaskでこちらのモデルを使用したWebアプリを作成しましたので、良ければぜひ試してみていただけましたら幸いです！

https://dishcupclassifier.herokuapp.com/