機械学習　F1Score完全攻略

## この記事の目的
　SignateやKaggleの分類問題では、モデルの優劣をF1Scoreで測るものが多くあります。
　この記事では以下の解説をします。

* PrecisionとRecallの説明
* F1Scoreとは何か
* ノーマルF1ScoreとMacro F1Score(=Mean F1Score)は何が違うか
* コンペでの指標がF1Scoreである場合、目的変数の値わからないtestデータセットについて正例・負例の割合を特定するテクニックの紹介

　この記事では二値分類の場合に話を限定して解説します。３値以上の場合にも適用できるように拡張できるはずですが、けっこう数式は複雑になります。

## 話の発端とこの記事を書いた動機
　2024年1月～2月、Signateで[第2回 金融データ活用チャレンジ](https://signate.jp/competitions/1325)なるコンペが開催されました。（この記事執筆時点では開催中です）
　知らない人向けにざっくり説明します。企業向けローンの属性をまとめた訓練用(train)とテスト(test)データセットがあり、「そのローンが債務不履行に陥ったかどうか」のフラグがあります。このフラグは訓練用データについては参加者が見れますが、テスト側は主催者だけが知っています。その状態で、未知のtest側の債務不履行フラグを機械学習を使って推定し、それがどれくらい真の値に近いかを競う競技です。
 データは実際ものではなく、何かのアルゴリズムで作為的に作られたものなので、実務とはかけ離れていますが、まあコンペというのはえてしてそうなるのでその点は特に問題ではありません。

　さて、コンペ序盤、そのフラグが運営側のミスで間違っていた、ということがある参加者からの指摘で発覚するという事件がありました。

　結果的に運営側の不手際だったわけですが、**データサイエンティストたるもの、主催者側の発表とか仕様書の記載とかを鵜呑みにせず、検証可能なデータのみを信用すべし**、というのが正しい姿勢だと思います。データを憎んで人を憎まず。

　また、ローカルでの検証結果とLeaderBoardの結果が整合していることをチェックするのも重要です。整合しなければ、自分の想定している検証方法の理解が誤っているか、運営側に何か重大な問題があるかのどちらかです。いずれにしてもコンペにとっては大きな問題です。

　そしてもう一つ重要なのは、testデータでの陽性・陰性の割合を評価スコアから推定する手法についてです。たいていのコンペでは、train/testでその割合は乱数によるゆらぎの要素を除けば等しいですが、たまたま乱数が偏っていたり、あるいは主催者の意図で、その割合は変化するかもしれません。よい機会なので、F1Scoreの定義から始まって、関連した自分の前から持っていた知識と今回の騒動に関する実験結果を整理したうえでこの記事にまとめ、広く共有しようというのが動機です。

## True Positive / True Negative / False Positive / False Negative
　まずここから出発します。
　詳しい解説はよそのWeb上の記事なり書籍なりにいろいろあるので譲りますが、簡単にまとめればこの表です。

<table>
<tr><td rowspan='2'>モデルの予測が陽性</td><td>正解(本当に陽性)</td><td>True Positive(TP)</td></tr>
<tr><td>不正解(本当は陰性)</td><td>False Positive(FP)</td></tr>
<tr><td rowspan='2'>モデルの予測が陰性</td><td>正解(本当に陰性)</td><td>True Negative(TN)</td></tr>
<tr><td>不正解(本当は陽性)</td><td>False Negative(FN)</td></tr>
</table>

 正解・不正解がTrue/False、モデルの予測の陽性・陰性がPositive/Negativeと対応しています。

## Precision / Recall

　表を見ると、間違い方にはFalse PositiveとFalse Negative２種類あることがわかります。そして、**一方を減らそうとすればもう一方は増える傾向**にあります。
　False Positiveを減らそうと思ったら、本当に自信のあるときだけ陽性と出力し、少しでも疑問があれば陰性と出力するようにモデルを修正することになるでしょう。そうすると、本当は陽性であるのに陰性と出力するFalse Negativeが増えてしまいます。

　どちらの誤りを重視するかは問題の種類によります。メールがスパムかどうかを判定するなら、重要なメールをスパムと判定して葬ってしまうよりは、スパムを普通のメールと判定するほうがマシです。重大な病気の診断の場合、病気を見過ごすのも問題ですが、見逃しをなくそうとすると精密検査をするコストが上がるのでバランスが重要です。

　このバランスを評価するための指標が`Precision`と`Recall`です。

||日本語名|意味|式|
|:-----|:-----|:-----|:-----:|
|`Precision`|適合率|モデルが陽性と判定し、実際に陽性だった割合|`TP/(TP+FP)`|
|`Recall`|再現率|真に陽性なもののうち、検出できた割合|`TP/(TP+FN)`|

　歴史的には、第二次世界大戦当時のイギリス空軍において、レーダー上の反応をドイツ軍機と判断して迎撃出動するか、それとも誤検知とみなして無視するかの判断を合理的に行うために発達した研究がバックグラウンドにあるそうです。（[WikipediaのROC英語版](https://en.wikipedia.org/wiki/Receiver_operating_characteristic)より）。うーむ、ロマンがありますね。当時のレーダーは精度は低かったはずです。

　なお、これの日本語名が適合率/再現率となっている理由はまったくわかりません...

　また、極端なケースではこの計算で`0/0`になってしまうことがありますが、そのときはエラーにせず0として扱うようです。(後述のテクニックで出ます！)

## F1 Score
　ここまでの話で、あるモデルと正解のわかっているデータがあるとき、`Precision` / `Recall` が計算できるようになりました。モデルに食わせた結果と真の値を比べることでTP/TN/FP/FNの個数を数え、割り算をすればいいです。
　しかし、２つのモデルのどちらが優秀か、という話になると`Precision` / `Recall`の２つの数値があると不便です。モデルAはPrecisionが優れ、モデルBはRecallが優れているとき、トータルではどちらが優秀なのか、という問題です。そのためには、１つの数値で比較できたほうが簡単です。そこで両者の**調和平均**を取ったものを`F1 Score`と定義します。

```math
\frac{1}{f1 score}=\frac{1}{2}(\frac{1}{Precision}+\frac{1}{Recall})
```
別の書き方をすると
```math
f1 score = 2 * \frac{Precision * Recall}{Precision + Recall}
```

# Macro F1 Score ( = Mean F1 Score )
　このF1 Scoreを拡張し、**陽性・陰性を逆に考えたときのスコアと平均したものがMacro F1 Score**です。陽性をどれだけうまく予測できたかの`Precision` / `Recall`と、陰性をどれだけうまく予測できたかの`Precision` / `Recall` を計算し、それぞれの`F1Score`を経て平均（こちらはF1Score間の単純平均）をとったものです。

　ちょっと話がややこしくなりますが、こうすると３値分類以上の場合にもスコアが計算できるようになるのが特徴です。`scikit-learn`なら計算は簡単にできます。スコアの意味を知っている必要はありますが、計算するコードを書く必要はありません。

```python
from sklearn.metrics import f1_score
score = f1_score(<正解>, <モデルの予測>, average='macro')
```

averageを省略すると通常のF1 Scoreになってしまうので注意が必要です。
今回問題にしているコンペではこの指標を使うので、以下はそれに絞って話を進めます。

# コンペ用テクニック　testデータ中の陽性の割合を測定する

　コンペでは、正解値の不明なtestデータについて自作のモデルで正解を予測し、その結果だけを(CSVファイル等で)提出します。運営側は正解を知っているので、それに基づいて**結果のF1Score**だけを通知してくれます。正確には、testデータのPublic Leaderboard用の一部についてだけを使って評価し、残りはコンペ終了時の最終判定で使用します。

　その性質を使い、**意図的にすべて陽性、すべて陰性の２つの予測を提出する**ことでtestデータ中の陽性の割合がわかるのです。以下はMacro F1の場合についての説明ですが、単純F1や単純accuracyである場合はもっと簡単なので、必要な場合は各自考えてみてください。

　なお一応、すべて陽性・すべて陰性のうち片方だけを提出してもtestデータ中の陽性の割合は計算可能ですが、自分の計算ミスまたは運営側に意図しないバグがあるかもしれないので、検算目的で２つともやってみるのがオススメです。

　全部陽性のファイルはIDだけそろえて値は全部１にすればいいので簡単です。
![d5df6f37434d19f0a0f7fde015cc245f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59073/a4d53bb8-fe01-9fc1-fc3e-6c4dee65eb10.png)

　Signateの本コンペで、そういう２件(All1とAll0)の提出データを作成し、それぞれでF1 Scoreを暫定評価値として手に入れることができます。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59073/7e81e260-f349-ad73-bacd-dd72ba7899ca.png)
　
　さて、Public Leaderboard用に評価するデータのうち、陽性である割合を t とします。すべて陽性(All1)で提出したとすると、次のようになります。（不慣れな人は上記の定義と突き合わせて確認しましょう）

||TP|FN|FP|TN|Precision|Recall|F1 Score|
|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|
|正解が陽性であるもの|`t`|`0`|`1-t`|`0`|`t`|`1`|`2*t/(1+t)`|
|正解が陰性であるもの|`0`|`t`|`0`|`1-t`|`0`|`0`|`0`|

Macro F1 Scoreは両方のF1Scoreを平均したものなので、これをmとおくと、

```math
m = (2 * \frac{t}{1+t} + 0) / 2 = \frac{t}{1+t}
```

これを t について解くとすなわち

```math
t = \frac{m}{1-m} (**)
```

All1のデータを提出して得られた評価値の `0.4716519` を m に代入してこの式(**)にあてはめると、

`t=0.89269158 `

が得られます。これがPublic leaderboard用データの陽性率になります。

同様に、もう一方の全件陰性データ(All0)の評価値 `0.0969092` でt'を計算すると 

`t'=0.107310847 `

となります。

この t と t' を足すときわめて高い精度で１になっているので、この計算が正しいことの裏付けが取れました。もし１にならなければどこかで間違えていることになります。

ここまでで、以下の事実が確定したことになります。

* 評価側はMacroF1を使って期待通りにスコアを計算している
* public leaderboard用評価データのうち陽性率はおおむね0.10731である（別途計算するtrainデータ上の陽性率を計算するとほぼ一致しているのでさらに安心材料です）

# Public Leaderboard用の評価データの件数を予測する

　これだけ高精度でF1Scoreがわかっていると、別の情報も得られます。全件陽性での評価値が `0.4716519` であったということは、その１つ下の桁を四捨五入していることから、真のMacro F1Scoreは `0.471651`**85** と `0.471651`**95** の間にあることになります。

　すると、Public Leaderboard上の陽性率の範囲も高精度で特定でき、`0.89269157` と `0.89269169` の間にあることがわかります。さらに、**(この陽性率 * Public Leaderboardの全件数)　はぴったり整数にならないといけない**のと、Public Leaderboardの件数はtestデータの件数より小さくなければならない制限から、(ちょっとしたプログラムを書く必要がありますし算数の知識が必要ですが)Public Leaderboardの件数は

`10577, 21154(10577 * 2), 31731(10577 * 3)`

のどれかであることが特定できました。testデータの件数42308のぴったり1/4, 2/4, 3/4になっています。たぶん2/4でしょう。

　この３つの数のどれが正しいのかは、「１つだけ陰性、他は全部陽性」というsubmitデータを出して評価値が変化すれば特定可能、ということは手元の計算でわかりました(これは今回の件に合わせて自分で計算して発見した事実です)が、その計算は結構面倒な上に、Public Leaderboardのサイズがわかってもコンペ上特に有利になることはないのでやめておきます。

# おまけ　クラス比は合っているがランダムな予測をした場合

　**MacroF1は0.5になる**という定理があるので紹介しておきます。
　testデータ中の陽性率が t であるとき、全件からランダムに割合 t の件数を選んでそれが陽性であるという提出をしたとすると、

|TP|FN|FP|TN|Precision|Recall|F1 Score|
|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|
|`t^2`|`t(1-t)`|`t(1-t)`|`(1-t)^2`|`t`|`t`|`t`|

となります。
陰性サイドでは上記の値について`t`->`1-t`の変換したものなので、MacroF1は `( t + (1-t) ) / 2 = 0.5` となります。(乱数によるゆらぎのためにぴったり0.5にはならないでしょう）
今回のコンペでは、いくら工夫を凝らしても評価値が`0.5`からほとんど変わらないことから、何かがおかしいと（他の参加者が）気づいたのが発端でした。 背後にはこういう理由があるのでした。