深層学習の出力をロジスティック回帰やSVMの入力に使う

# 本記事の目的
深層学習モデルの出力をロジスティック回帰などの軽い機械学習モデルの入力とする場合（スタッキング）の精度を確認します．
また，転移学習や fine-tuning との比較も行います．

データセットには，CIFAR-10を用います．
マシンはM1 MacBookPro (macOS 11.2.3)で，主に使用するライブラリのバージョンは以下です．
```text:バージョン情報
Python 3.10.8
tensorflow 2.10.0
keras 2.10.0
```

notebookでの実行を想定しています．
[コードはgistに公開しています．](https://gist.github.com/y629/78b1037dc10d0d837628897f6cc6cc3d)

# 比較するモデル
深層学習モデルとして **MobileNetV2** を用います．
MobileNetV2 の学習済みモデルは，keras.applications を用いてロードします（参考:[tf.keras.applications.mobilenet_v2.MobileNetV2](https://www.tensorflow.org/api_docs/python/tf/keras/applications/mobilenet_v2/MobileNetV2)）．
このモデルを前段として，全結合層やロジスティック回帰，SVMを後段とします．
つまり，MobileNetV2 の出力（1280次元）を後段の入力として，予測を行います．
具体的には，以下の6通りのモデルを作ってみます．

1. MobileNetV2 + 全結合層 (転移学習=全結合層のみ訓練)
2. MobileNetV2 + 全結合層 (fine-tuning=全ての重みを訓練)
3. MobileNetV2 (fine-tuningなし) + ロジスティック回帰
4. MobileNetV2 (fine-tuningなし) + SVM
5. MobileNetV2 (fine-tuningあり) + ロジスティック回帰
6. MobileNetV2 (fine-tuningあり) + SVM

ここで，1.は後段に付与する全結合層（1層のみ）の重みだけを学習します．
一方で，2.は後段に付与する全結合層だけでなく，元の MobileNetV2 の重みも含めて学習します．
本記事では1.の方を「転移学習」，2.の方を「fine-tuning」と呼んでいます．

初めに結果だけを以下の表に載せます．

|  モデル  | test accuracy  |
| ---- | ---- |
|  1. MobileNetV2 + 全結合層 (転移学習)  |  0.28  |
|  2. MobileNetV2 + 全結合層 (fine-tuning)  |  0.60  |
|  3.MobileNetV2 (fine-tuningなし) + ロジスティック回帰  |  0.29  |
|  4.MobileNetV2 (fine-tuningなし) + SVM  |  0.29  |
|  5.MobileNetV2 (fine-tuningあり) + ロジスティック回帰  |  0.72  |
|  4.MobileNetV2 (fine-tuningあり) + SVM  |  0.72  |

各モデルの実装を解説します（コードの全部は載せれないかもしれませんが，重要な部分は載せます）．
その前に共通して必要な準備段階について解説します．

# モデルやデータセットの準備
## 必要なライブラリのインポート
モジュールのインポート部分は以下のようになっています．
```python
import numpy as np
import pandas as pd
from pathlib import Path
import os.path

import tensorflow as tf
from tensorflow.keras.preprocessing.image import ImageDataGenerator
from tensorflow.keras.datasets import cifar10
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.models import Model, Sequential, load_model
from tensorflow.keras.layers import Input, Dense

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import LinearSVC
from sklearn.metrics import classification_report, confusion_matrix

import matplotlib.pyplot as plt
%matplotlib inline
import seaborn as sns
sns.set()
```
## データセットのロード
CIAFR-10データセットをtensorflowのメソッドを使って読み込みます．
```python
(ori_train_images, ori_train_labels), (ori_test_images, ori_test_labels) = cifar10.load_data()
# ラベルのインデックスのリストを, one-hot表現に変換する
ori_train_labels, ori_test_labels = to_categorical(ori_train_labels.reshape(-1)), to_categorical(ori_test_labels.reshape(-1))
```

## 学習済みモデルのロード
学習済みの MobileNetV2 のロードを行います（参考:[tf.keras.applications.mobilenet_v2.MobileNetV2](https://www.tensorflow.org/api_docs/python/tf/keras/applications/mobilenet_v2/MobileNetV2)）．
一旦保存している理由は，後段が異なる複数のモデルを作るため，オリジナルの`pretrained_model` を保存しておくためです（fine-tuningすると重みが変わってしまう）．
```python
pretrained_model = tf.keras.applications.MobileNetV2(
  input_shape=(32, 32, 3), # CIFAR-10の形状に合わせる
  include_top=False,
  weights='imagenet',
  pooling='avg'
)
# 一応保存しておく
pretrained_model.save("MobileNetV2.h5")
# 読み込んだ学習済みモデルの重みを固定
pretrained_model.trainable = False
```

## 前処理
今回は特別な前処理などを行わず，MobileNetV2 で定義されている前処理を用います．具体的には入力を -1 から 1 の範囲にスケーリングしているようです（参考: [tf.keras.applications.mobilenet_v2.preprocess_input](https://www.tensorflow.org/api_docs/python/tf/keras/applications/mobilenet_v2/preprocess_input)）．

```python:
image_size = 32
batch_size = 64
# 訓練とテストそれぞれのためのgeneratorを作成
train_generator = ImageDataGenerator(
  preprocessing_function=tf.keras.applications.mobilenet_v2.preprocess_input
)
test_generator = ImageDataGenerator(
 preprocessing_function=tf.keras.applications.mobilenet_v2.preprocess_input
)

# バッチのイテレータを生成
train_images = train_generator.flow(
  x=ori_train_images,
  y=ori_train_labels,
  batch_size=batch_size,
  shuffle=True,
)
test_images = train_generator.flow(
  x=ori_test_images,
  y=ori_test_labels,
  batch_size=batch_size,
  shuffle=False,
)
```

バッチの形状を確認してみると，以下のように，データに関しては `(バッチサイズ(=64), 縦(=32), 横(=32), チャンネル(=3))` となっていることが確認できます．ラベルに関しては，分類クラス数が 10 なので `(64, 10)` になります．

```python
# 形状を確認
for x, y in train_images:
  print(x.shape) # => (64, 32, 32, 3)
  print(y.shape) # => (64, 10)
  break
```

# 各モデルの実装
順番に解説します．
1., 2.に関しては Keras の Functional API の仕組みを利用します．

## 1.MobileNetV2 + 全結合層 (転移学習=全結合層のみ訓練)

```python
# 読み込んだ学習済みモデルの重みを固定
pretrained_model = load_model('MobileNetV2.h5')
pretrained_model.trainable = False

# モデルを定義
inputs = Input(shape=pretrained_model.output_shape[1:])
preds = Dense(10, activation='softmax')(inputs)
top_model = Model(inputs, preds)
tl_model = Model(inputs=pretrained_model.input,
              outputs=top_model(pretrained_model.output))
```

`top_model` は `pretrained_model` (MobileNetV2) の出力を受け取って予測確率 (10次元のベクトル) を返すモデルです．
`tl_model` はこの `top_model` と `pretrained_model` が組み合わさったモデルになります．

以下でモデルをコンパイルして，サマリを表示します．
```python:
tl_model.compile(optimizer='rmsprop',
              loss='categorical_crossentropy',
              metrics=['accuracy'])
tl_model.summary()
```

10エポックだけ学習し，テストデータで評価します．
```python:
tl_history = tl_model.fit(train_images, batch_size=64, epochs=10).history
tl_model.evaluate(test_images, batch_size=batch_size)
# => [1.9897980690002441, 0.2849000096321106]
```
`[1.9897980690002441, 0.2849000096321106]`と出力されたため，accuracy は 28%程度と低いことがわかります（全結合層1層のみの追加学習ではこの程度ということでしょうか）．

## 2. MobileNetV2 + 全結合層 (fine-tuning=全ての重みを訓練)
1.とは大きく変わりませんが，`pretrained_model.trainable = True` とすることで MobileNetV2 の重みも訓練可能にしている点が重要です．
実際，`.summary()`の出力結果の最後の方の `Trainable params:` を見ると，1.は 12,810 なのに対して，2.は 2,236,682 と多くなっていることがわかります．

```python
# fine-tuningする
# 読み込んだ学習済みモデルの重みを学習可能にする
pretrained_model = load_model('MobileNetV2.h5')
pretrained_model.trainable = True
# モデルの定義，コンパイル，サマリ表示
ft_model = Model(inputs=pretrained_model.input,
              outputs=top_model(pretrained_model.output))
ft_model.compile(optimizer='rmsprop',
              loss='categorical_crossentropy',
              metrics=['accuracy'])
ft_model.summary()
# 学習と評価
ft_history = ft_model.fit(train_images, batch_size=64, epochs=6).history
ft_model.evaluate(test_images, batch_size=batch_size)
# => [4.463766098022461, 0.5992000102996826]
```
出力結果から，accuracy は59%程度と少し良くなりました．

fine-tuning した MobileNetV2 のモデルは 5., 6.で使うので保存しておきます．
```python:
ft_model.save('MobileNetV2_ft.h5')
```

# 3.以降の実装
3.以降のロジスティック回帰や SVM の実装には scikit-learn を使うため，Keras の Functional API が利用できません．
そのため，以下の流れで実装します．

(1) MobileNetV2 の出力を得る
(2) 出力をひとつの array にまとめる
(3) まとめた array を使って後段のモデルの学習や評価を行う

(1)(2)の処理は以下の関数として定義します．
```python:(1) MobileNetV2 の出力を得るための関数
def extract_features(data_size, target_images, pretrained_model, batch_size):
  # バッチ数(バッチサイズではなく，いくつのバッチがあるか)
  n_batches = data_size // batch_size + 1
  print(f'n_batches={n_batches}')

  # 各バッチを学習済みモデルに入れた時の出力を格納
  batch_preds_list = []
  batch_labels_list = []
  for b in range(n_batches):
    print(b, end=' ')
    # バッチの画像データのnumpy配列の部分を抽出
    batch_images = target_images[b][0]
    batch_labels = target_images[b][1]
    # バッチ単位で学習済みモデルに入力
    batch_preds = pretrained_model.predict(batch_images, verbose=0)
    batch_preds_list.append(batch_preds)
    batch_labels_list.append(batch_labels)
  return batch_preds_list, batch_labels_list
```

```python:(2) MobileNetV2 の出力をひとつの array にまとめるための関数
def make_features_array(batch_preds_list, batch_labels_list):
  batch_preds_list, batch_preds_tail = batch_preds_list[:-1], batch_preds_list[-1]
  # 抽出した特徴を格納する
  features = np.stack(batch_preds_list).reshape(-1, 1280)
  # 最後のバッチとも結合
  features = np.concatenate((features, batch_preds_tail), axis=0)
  
  # ラベルについても同様に
  # 最後のバッチだけ分ける
  batch_labels_list, batch_labels_tail = batch_labels_list[:-1], batch_labels_list[-1]
  labels = np.stack(batch_labels_list).reshape(-1, 10)
  # 最後のバッチとも結合
  labels = np.concatenate((labels, batch_labels_tail), axis=0)
  return features, labels
```

上記の関数を使って，以下のように MobileNetV2 の出力を特徴量として得ることができます．
ここで，`pretrained_model=load_model('MobileNetV2.h5')` としているために，fine-tuning していない MobileNetV2 の重みを使って出力を得ることになります．
```python:訓練・テストデータに対する MobileNetV2 の出力を特徴量として取得
train_features, train_labels = make_features_array( \
	*extract_features(data_size=len(ori_train_images), target_images=train_images, pretrained_model=load_model('MobileNetV2.h5'), batch_size=batch_size) \
)
test_features, test_labels = make_features_array( \
	*extract_features(data_size=len(ori_test_images), target_images=test_images, pretrained_model=load_model('MobileNetV2.h5'), batch_size=batch_size) \
)
```

形状を確認してみます．
1280 = MobileNetV2 の出力ベクトルの次元です．
```python
print(train_features.shape, train_labels.shape) # => (50000, 1280) (50000, 10)
print(test_features.shape, test_labels.shape) # => (10000, 1280) (10000, 10)
```

## 3. MobileNetV2 (fine-tuningなし) + ロジスティック回帰
あとは以下のようにして簡単にモデルを作成できます．
```python
# データやラベルの配列
X_train = train_features
y_train = labels2cls(train_labels)
X_test = test_features
y_test = labels2cls(test_labels)
# lrの定義と学習実行
lr = LogisticRegression()
lr.fit(X_train, y_train)
# 予測結果の確認
y_train_pred = lr.predict(X_train)
print(classification_report(y_train, y_train_pred))
y_test_pred = lr.predict(X_test)
print(classification_report(y_test, y_test_pred))
```

結果を一部以下に抜粋します．accuracyも転移学習のものとそこまで変わっていないことがわかります．
```
    accuracy                           0.29     10000
   macro avg       0.33      0.29      0.28     10000
weighted avg       0.33      0.29      0.28     10000
```

## 4. MobileNetV2 (fine-tuningなし) + SVM
上のコードの
```python
lr = LogisticRegression()
lr.fit(X_train, y_train)
```
の部分を，
```python
svc = LinearSVC()
svc.fit(X_train, y_train)
```
に変えるだけです．
結果は同様に良くないです（以下一部抜粋）．
```
    accuracy                           0.29     10000
   macro avg       0.33      0.29      0.27     10000
weighted avg       0.33      0.29      0.27     10000
```

# 5., 6.（fine-tuning した MobileNetV2 の重みを使う）の実装
3., 4.のモデルも精度が良くありませんでした．
3., 4.ではオリジナルの MobileNetV2 の重みを使っていました．
それを「CIFAR-10 のデータを使って fine-tuning した MobileNetV2 の重み」に置き換えればより精度が上がると仮定して，5., 6.のモデルを作ります．
2.で最後に追加した全結合層は使いません．
その全結合層の代わりがロジスティック回帰やSVMになるイメージです．

## 5. MobileNetV2 (fine-tuningあり) + ロジスティック回帰
上で作った，MobileNetV2 にデータを渡して特徴量を得るための関数 `extract_features()` の引数である `pretrained_model` のところを変えてあげればOKです．
具体的には，2.で保存した fine-tuning 後のモデルを読み込んで，最後から2番目の層を出力するようなモデルを定義します（以下のコードでの `sub_model`）．
最終層だと2.で追加した全結合層になってしまうので，最後から2番目の層にします（この層が元々の MobileNetV2 の最終層になるため）．
この `sub_model` を `extract_features()` の引数である `pretrained_model` に指定すればOKです．

```python
ft_model = load_model('MobileNetV2_ft.h5')
# MobileNetV2_ft.h5のモデルから，MobileNetV2の出力を取り出すモデルを作成
# 最終層は全結合層なので，最後から2番目を出力するモデルを作る
sub_model = Model(inputs=ft_model.input, outputs=ft_model.layers[-2].output)
# 渡すモデルだけ変えて特徴抽出
train_features, train_labels = make_features_array( \
	*extract_features(data_size=len(ori_train_images), target_images=train_images, pretrained_model=sub_model, batch_size=batch_size) \
)
test_features, test_labels = make_features_array( \
	*extract_features(data_size=len(ori_test_images), target_images=test_images, pretrained_model=sub_model, batch_size=batch_size) \
)
```

あとは3.同様に学習を行って評価します．
`sub_model` という変数を 引数 `pretrained_model` に渡す以外は3.と同じです．
結果は以下のように，2.の全結合層を使うよりもよくなっています．
```
   accuracy                           0.72     10000
   macro avg       0.72      0.72      0.72     10000
weighted avg       0.72      0.72      0.72     10000
```

## 6. MobileNetV2 (fine-tuningあり) + SVM
こちらもコードは割愛します．
関数 `extrac_features()` の引数を `pretrained_model=sub_model` と指定する以外は，4.と全く同じコードです．
```
   accuracy                           0.72     10000
   macro avg       0.72      0.72      0.72     10000
weighted avg       0.72      0.72      0.72     10000
```
ロジスティック回帰と同様の結果になりました．

参考
- https://qiita.com/koshian2/items/f9f20f3e0eee711b1505
- https://qiita.com/sasayabaku/items/75496bafff597fd5fe79