KerasでF1スコアをmetircsに入れる際は要注意

KerasでF1スコアをモデルのmetrics（評価関数）に入れて訓練させてたら、えらい低い値が出てきました。「なんかおかしいな」と思ってよく検証してみたら、とんでもない穴があったので書いておきます。

環境：Keras v2.2.4

## 要点
* KerasのmetricsにF1スコアを入れることはできるが、調和平均で出てくる値をバッチ間の算術平均で計算しているので正確な値ではない
* 正確な値を計算したかったらmetricsではなく、コールバックでエポックの最後に一括で求めるべき

## F1スコアとは
Precision-recallのトレードオフの最適解を求めるための尺度。特に精度が意味をなさなくなる歪んだデータに対して有効。F1スコアについて知っている方は飛ばしていいです。

### 歪んだデータとは
2クラス分類を考えるとしましょう。設定は猫と犬の分類、メールがスパムかスパムではないか、なんでもいいです。2クラス分類なので、「y=0、y=1の2種類のラベル」があります。ここで歪んだデータとは、例えば**y=0のデータは9900個ある一方で、y=1は100個しかないようなケース**です。

「不均衡データ」と呼ぶほうが一般的かもしれません。

### なぜ精度が意味をなさなくなるのか
もし、y=0が9900個、y=1が100個の場合、「**全てy=0と予想すれば、訓練をするまでもなく精度が99%になるから**」です。もしy=0が5000件、y=1が5000件のように均等なケースでは精度が意味ありますが、このように極端に偏った例で精度を評価関数とする際は注意が必要です。

### 混同行列（Confusion matrix）を見よう
精度もF1スコアもある式により求めた尺度にすぎないので、より直接的にモデルの良し悪しを図るには混同行列を見るのが一番良いです。これはSklearnを使うと簡単にできます。

```python
from sklearn.metrics import confusion_matrix
confusion_matrix(y_true, y_pred)
```

y_trueには正しいラベルを、y_predには予測されたラベルを代入します。混同行列をprintで表示すると、このように出てきます。

```:出力
[[9000   20]
 [  11   89]]
```

詳しくは[公式ドキュメント](http://scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html)に譲りますが、**左上はTrue Negative（TN:予測も0真の値も0）、右上はFalse Positive（FP：予測は1だったが、真の値は0）、左下はFalse Negative（FN：予測は0だったが、真の値も1）、右下はTrue Positive（TP：予測も真の値も1）**です。表にしてみましょう。

| 真(下)/予測(右) | 　　☓　　 | 　　○　　 |
|:---------------:|:---------:|:---------:|
|        ☓        |     TN    |     FP    |
|        ○        |     FN    |     TP    |

一般的な混同行列とちょっと違う（普通は一番上がTPになる）のですが、sklearnのconfusion_matrixではこのようになります。y_true, y_predの順で与えている点、そして多クラスへの拡張を考えるとこのような形になってしまうのは仕方がないでしょう。

この混合行列をもとに、いくつか評価尺度の定義があります。

* **Precision（適合率）**：  
$$\rm{Precision}=\frac{TP}{TP+FP}$$
* **Recall（再現率）**：  
$$\rm{Recall}=\frac{TP}{TP+FN} $$
* **F1score（F1スコア）**  
$$\rm{F1}=\frac{2\rm{Recall}\cdot\rm{Precision}}{\rm{Recall}+\rm{Precision}} $$

参考：http://ibisforest.org/index.php?F%E5%80%A4

尺度の定義なのでそういうものだと思ってください。F1値の式の意味は**PrecisionとRecallの[調和平均](https://ja.wikipedia.org/wiki/%E8%AA%BF%E5%92%8C%E5%B9%B3%E5%9D%87)**で、「この尺度を使うと歪んだデータにも対応できるんだな」ぐらいに思っておけばOKです。ただこの**F1スコアの平均が、足して母数で割る一般的な算術平均ではない**ということは忘れないでください。

### Scikit-learnで確認してみる
これらのPrecision, Recall, F1スコアは全てSklearnで計算することができます。このような混合行列の例を想定します。

| 真(下)/予測(右) | 　　☓　　 | 　　○　　 |
|:---------------:|:---------:|:---------:|
|        ☓        |    1000   |     30    |
|        ○        |     40    |    150    |

まずはデータを作ります。予測値（data_pred）と真の値（data_true）を擬似的に作ります。

```python
import numpy as np

data_true = np.r_[np.repeat(0, 1030), np.repeat(1, 190)]
data_pred = np.r_[np.repeat(0, 1000), np.repeat(1, 30), np.repeat(0, 40), np.repeat(1, 150)]
# Kerasで使うことを想定してランク2にする
data_true = data_true.reshape(-1, 1)
data_pred = data_pred.reshape(-1, 1)
```

Kerasで使うことを想定してランク2のテンソルにしています。各値を計算してみましょう。

```python
from sklearn.metrics import precision_score, recall_score, f1_score
precision = precision_score(data_true, data_pred)
recall = recall_score(data_true, data_pred)
f1 = f1_score(data_true, data_pred)
print("Precision", precision)
print("Recall", recall)
print("F1Score", f1)
```

```:出力
Precision 0.8333333333333334
Recall 0.7894736842105263
F1Score 0.8108108108108109
```

手動で検算すると、Precisionが150÷(150+30)=0.833…となるので良いですね。残りの値も同様です。

このままSklearnの関数を使って、混同行列を表示してみましょう。

```python
from sklearn.metrics import confusion_matrix
print(confusion_matrix(data_true, data_pred))
```

```:出力
[[1000   30]
 [  40  150]]
```

はじめの設定どおりになったのが確認できたでしょうか。混同行列だけに「どこがどの値だっけ？」と混同しやすくなるので、簡単な例を作って確認するとわかりやすいですね。

## KerasでのF1スコア
ここからが本題。KerasでのF1スコアはmetricsとして独自に定義すればエポック中に計算することができます。[StackOverFlow](https://stackoverflow.com/questions/43547402/how-to-calculate-f1-macro-in-keras)にあるのでこのコードを使ってみましょう。

```python
import keras.backend as K

def f1(y_true, y_pred):
    def recall(y_true, y_pred):
        true_positives = K.sum(K.round(K.clip(y_true * y_pred, 0, 1)))
        possible_positives = K.sum(K.round(K.clip(y_true, 0, 1)))
        recall = true_positives / (possible_positives + K.epsilon())
        return recall

    def precision(y_true, y_pred):
        true_positives = K.sum(K.round(K.clip(y_true * y_pred, 0, 1)))
        predicted_positives = K.sum(K.round(K.clip(y_pred, 0, 1)))
        precision = true_positives / (predicted_positives + K.epsilon())
        return precision
    precision = precision(y_true, y_pred)
    recall = recall(y_true, y_pred)
    #print(K.get_value(precision))
    #print(K.get_value(recall))
    return 2*((precision*recall)/(precision+recall+K.epsilon()))
```

sumとかroundとか入っていますが、シグモイド関数の出力に足して0.5未満ならラベル0、0.5以上ならラベル1と推定するためのちょっとかっこいい書き方です。大事なのは、**このコード自体は間違ってはいません**。

先程の例で確認してみましょう。SklearnのF1スコアとKeras用に定義したF1スコアの値が等しくなるか見てみます。F1スコアの説明を飛ばした方は、最後の「Scikit-learnで確認してみる」のコードを見てください。

PrecisionとRecallの値を表示するコードは、このまま使うとKerasのモデルのコンパイルでコケるのでコメントアウトしています。今確認用にコメントアウトを外してみます。

```python
tensor_true, tensor_pred = K.variable(data_true), K.variable(data_pred)
f1 = K.get_value(f1(tensor_true, tensor_pred))
print(f1)
```

変数周りがごちゃごちゃしていますが、Kerasでの内部の計算を再現するためにTensorFlowのテンソルに置き換えているだけです。上から、Precision、Recall、F1の値が表示されます。

```:出力
0.8333333
0.7894737
0.81081074
```

このようにSklearnで計算した場合と同じなので、**このコードのF1スコアの計算は間違ってはいないのです**。まずはこれを抑えておきましょう。

## Kerasのmetricsで使うとおかしいことになる
ただ、この**F1スコアのモデルの評価関数に入れるとおかしなことになります。エラーは出ませんが、ログで出てくる値が間違っているのです**。モデルの評価関数とはcompileのmetricsに入れるケースです。

```python
model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["acc", f1],)
```

MNIST＋多層パーセプトロンで検証してみました。MNIST自体は歪みの少ない綺麗なデータですが、0のサンプルだけ訓練600、テスト100と極端に減らし、歪んだデータを再現しています。その上で「0かそうでないかを分類する」問題です。検証用コードは[こちら](https://gist.github.com/koshian2/f7ed8d9187f123aef299a939f67262ef)にあります。CPUで実行できます。

metricsにF1スコアを入れながら訓練させ、最後にmodel.predictで本来のF1スコアを計算させます。出力の最後のほうを取ってみました。

```:出力
803/854 [===========================>..] - ETA: 0s - loss: 5.9667e-04 - acc: 1.0
835/854 [============================>.] - ETA: 0s - loss: 5.8444e-04 - acc: 1.0
854/854 [==============================] - 2s 2ms/step - loss: 5.7325e-04 - acc:
 1.0000 - f1: 0.5160 - val_loss: 0.0065 - val_acc: 0.9982 - val_f1: 0.4728
f1score_train 0.997502
f1score_test 0.93532336

confusion matrix train
[[54075     2]
 [    1   599]]
confusion matrix test
[[9013    7]
 [   6   94]]
```

**うわっ…Epochの最後に出てくるF1スコア、低すぎ…？**

ログに出てくるF1スコアが0.516でも、データ全体で計算すると（これはmodel.predictからf1関数に代入して計算しました）F1スコアが0.998もあるのです。**metricsのF1スコアを基準にモデルの良し悪しを判断するのは大変危険**でしょう。

### 原因はバッチ間の平均計算？
F1スコアの解説で見たように、F1スコアというのは調和平均です。なので、**データ間、バッチ間でF1スコアを計算して、それの算術平均をデータ全体の値とするというのは間違っています**。ちなみに精度は算術平均なのでこれがOKです。この操作がそのまま適用されてしまったのかもしれません。

ちなみに先程の例で、validationをフルバッチ（バッチサイズ＝テストデータ数）とするとほぼ正しい値になります。

```python
model.fit_generator(gen.flow(X_train, y_train, batch_size=64), steps_per_epoch=X_train.shape[0]//64,
                    validation_data=gen.flow(X_test, y_test, batch_size=X_test.shape[0]), validation_steps=1,
                    epochs=10)
```

```:出力
854/854 [==============================] - 2s 2ms/step - loss: 6.3929e-04 - acc:
 0.9999 - f1: 0.4966 - val_loss: 0.0080 - val_acc: 0.9981 - val_f1: 0.9110
f1score_train 0.9908256
f1score_test 0.9246231

confusion matrix train
[[54072     5]
 [    6   594]]
confusion matrix test
[[9013    7]
 [   8   92]]
```

1.3%ぐらいズレはあるものの、ValidationのほうのF1スコアのほうはだいぶマシな値になっていますね。ミニバッチのままのTrainのほうは全然違う値が出てきます。つまり、**バッチ間の平均計算が悪さをしている**というのは正しそうです。

## コールバックで計算するのが無難
metricsにF1スコアをいれると全然違う値が出てくる→KerasでF1スコアは使えないというわけではなくて、ちゃんとデータ全体を一括で計算してくれるようにすればいいのです。一つの例ですが、コールバックを使って**エポック終了時（on_epoch_end）にF1スコアを計算するのが無難**です。


```python
# コールバック
from sklearn.metrics import f1_score
from keras.callbacks import Callback

class F1Callback(Callback):
    def __init__(self, model, X_val, y_val):
        self.model = model
        self.X_val = X_val
        self.y_val = y_val

    def on_epoch_end(self, epoch, logs):
        pred = self.model.predict(self.X_val)
        f1_val = f1_score(self.y_val, np.round(pred))
        print("f1_val =", f1_val)
        # 以下チェックポイントなど必要なら書く

# 訓練
model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["acc"])

model.fit_generator(gen.flow(X_train, y_train, batch_size=64), steps_per_epoch=X_train.shape[0]//64,
                    validation_data=gen.flow(X_test, y_test, batch_size=64), validation_steps=X_test.shape[0]//64,
                    epochs=10, callbacks=[F1Callback(model, X_test, y_test)])
```

on_epoch_endでF1スコアを計算させます。こうすればバッチ間の平均計算の影響を受けません。いちいちF1スコアの関数定義するの面倒なので、Sklearnの関数使うのがいいと思います。もし必要なら、F1スコアを計算したあと、モデルのチェックポイント（係数の保存）を定義します。

全体のコードはこちらにあります。
https://gist.github.com/koshian2/39785b81309337875c8e49b7ff2fc4b2

コールバックを使う場合はfitでもfit_generatorでもどちらでもOK（なはず）です。出力は以下のようになります。

```:出力
827/854 [============================>.] - ETA: 0s - loss: 6.8249e-04 - acc: 0.9
854/854 [==============================] - 2s 2ms/step - loss: 6.9995e-04 - acc:
 0.9999 - val_loss: 0.0052 - val_acc: 0.9983
f1_val = 0.9411764705882353
confusion matrix train
[[54071     6]
 [    1   599]]
confusion matrix test
[[9012    8]
 [   4   96]]
```

ちゃんとした値が出てくるのは当たり前ということで。

データをGenerator経由でファイルから読ませる場合、正しいラベルを取得するのが大変かもしれません。この場合、コールバック側でGeneratorからのバッチを再転送するようなジェネレーター関数を作り、predict_generatorに食わせつつ、正しいラベルをキャッシュしていくといいかもしれませんね。

ちなみにどうしてもmetricsで使いたい場合、バッチサイズを極端に大きくすると多少マシになると思います。なぜなら大数の法則が働いて、バッチ間のF1スコアがデータ全体のF1スコアに収束していくからです。

追記：もう少し賢い方法ありました。TP,FP,FN,TNをmetricsで計算させて、Callback側ではそれらの値をlogから取得し、F1スコアを計算する方法です。これなら大規模なデータでもいけそうですね。
→書きました：https://blog.shikoan.com/keras-f1score/

## おさらい
KerasでF1スコアを評価関数にしたいときは、metricsではなくCallbackに入れよう。metricsにダイレクトにF1スコアを入れるとバッチ間の平均計算の影響で、間違った値が出てきて死ぬぞ。