nikkie-ftnextの日記

イベントレポートや読書メモを発信

scikit-learnのf1_scoreのaverage引数に指定するmicroやmacroやsamplesって、なに?

はじめに

アカネチャン...(推しの子6話見た)、nikkieです😭😭😭

機械学習モデルの評価指標の1つ F1 score について、scikit-learnの実装を使いこなすために調べたことをまとめます。

目次

scikit-learnのf1_scoreのaverage引数

問題設定としてはマルチラベル分類です。

今回は3クラスとしましょう。
クラスのラベルは1,2,3とします。

正解が分かっているサンプルが5例あるとします。

>>> true_labels = [[1,2], [1], [1,2,3], [2,3], [3]]

評価対象の機械学習モデルは以下のように推論したとします。

>>> pred_labels = [[1,3], [2], [1,3], [3], [3]]

true_labelspred_labelsからF1 scoreを求めてモデルを性能評価したいです。
マルチラベルでは、F1 scoreの算出方法が複数通りあります。
モデルの性能評価をするために1つの値を求めるわけですが、この求め方、もっというと平均の取り方にいくつかのバリエーションがあるのです1

おことわり事項

動作環境

  • Python 3.10.9
  • scikit-learn 1.2.2
  • NumPy 1.24.3

参考書籍

『Kaggleで勝つデータ分析の技術』の2.3.5を参照しました。

この書籍のサンプルコードは公開されており、以下の例を自分の言葉で説明したものとなります。
https://github.com/ghmagazine/kagglebook/blob/3d8509d1c1b41a765e3f4744ba1fb226188e2b15/ch02/ch02-01-metrics.py#L90-L128

MultiLabelBinarizerでラベルを処理

今回は3クラスですから、マルチラベルを3つの0/1の並びで表します。
ラベルとして付与されたクラスが1、付与されなかったクラスが0です。
Multi-hot encodingと呼ぶと理解しています2

使うのはMultiLabelBinarizer

>>> from sklearn.preprocessing import MultiLabelBinarizer
>>> mlb = MultiLabelBinarizer()
>>> y_true = mlb.fit_transform(true_labels)
>>> y_pred = mlb.transform(pred_labels)

>>> y_true
array([[1, 1, 0],
       [1, 0, 0],
       [1, 1, 1],
       [0, 1, 1],
       [0, 0, 1]])
>>> y_pred
array([[1, 0, 1],
       [0, 1, 0],
       [1, 0, 1],
       [0, 0, 1],
       [0, 0, 1]])

average="micro"

scikit-learnのドキュメント(Parametersの中のaverage)より(2023/07/21 ドキュメントへのリンクを追加)

Calculate metrics globally by counting the total true positives, false negatives and false positives.

5例3クラスなのでy_truey_predshape(5, 3)ですが、これを15個の0/1の並びとして1つのF1 scoreを求める、という理解です。

>>> y_true.reshape(-1)
array([1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 1])
>>> y_pred.reshape(-1)
array([1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1])
>>> from sklearn.metrics import f1_score
>>> f1_score(y_true.reshape(-1), y_pred.reshape(-1))
0.6250000000000001

>>> f1_score(y_true, y_pred, average="micro")
0.6250000000000001

15個の数字の並びで扱うときの混同行列

>>> from sklearn.metrics import confusion_matrix
>>> confusion_matrix(y_true.reshape(-1), y_pred.reshape(-1))
array([[4, 2],
       [4, 5]])

見方は

# [予測がNの列, 予測がPの列]
array([[TN, FP],  # 正解がNの行
       [FN, TP]])  # 正解がPの行

ですから3、ここからprecisionやrecallを算出すると

>>> precision = cm[1][1] / (cm[1][1] + cm[0][1])  # tp / (tp + fp)
>>> precision
0.7142857142857143
>>> recall = cm[1][1] / (cm[1][1] + cm[1][0])  # tp / (tp + fn)
>>> recall
0.5555555555555556

よってF1 scoreは

>>> 2 * (precision * recall) / (precision + recall)
0.6250000000000001

一致🙌
つまりf1_score(..., average="micro")の指定はこれを全自動でやっていたってことですね!

average="macro"

scikit-learnのドキュメント(Parametersの中のaverage)より

Calculate metrics for each label, and find their unweighted mean.

今回は3クラスなので、クラスごとにF1 scoreを求め、3クラス分の平均としてF1 scoreを求めるという理解です。
✍️macro=クラスごとのF1 scoreの平均

>>> class1_f1 = f1_score(y_true[:, 0], y_pred[:, 0])
>>> class2_f1 = f1_score(y_true[:, 1], y_pred[:, 1])
>>> class3_f1 = f1_score(y_true[:, 2], y_pred[:, 2])
>>> class1_f1, class2_f1, class3_f1
(0.8, 0.0, 0.8571428571428571)

>>> import numpy as np
>>> np.mean([class1_f1, class2_f1, class3_f1])
0.5523809523809523

>>> f1_score(y_true, y_pred, average="macro")
0.5523809523809523

一致してますね🙌
クラス2については1サンプルも正解していないので、その分平均が低くなっていますね。

ドキュメントには次のように続いています

This does not take label imbalance into account.

意訳 ラベルの不均衡を考慮に入れない

average=None

average引数はNoneも取れます。

If None, the scores for each class are returned.

このとき、各クラスのF1 scoreが返されます。

>>> f1_score(y_true, y_pred, average=None)
array([0.8       , 0.        , 0.85714286])

上で見たclass1_f1, class2_f1, class3_f1と同じですね。

(参考)クラスごとの混同行列

>>> confusion_matrix(y_true[:, 0], y_pred[:, 0])
array([[2, 0],
       [1, 2]])
>>> confusion_matrix(y_true[:, 1], y_pred[:, 1])
array([[1, 1],
       [3, 0]])
>>> confusion_matrix(y_true[:, 2], y_pred[:, 2])
array([[1, 1],
       [0, 3]])
  • クラス1(インデックスは0)
    • precision 1.0 (=2/(2+0))
    • recall 0.67 (=2/(2+1))
    • 👉 F1 0.8
  • クラス2(インデックスは1)
    • precision 0.0 (=0/(0+1))
    • recall 0.0 (=0/(0+3))
    • 👉 F1 0.0
  • クラス3(インデックスは2)
    • precision 0.75 (=3/(3+1))
    • recall 1.0 (=3/(3+0))
    • 👉 F1 0.8571428571428571

average="samples"

scikit-learnのドキュメント(Parametersの中のaverage)より

Calculate metrics for each instance, and find their average (only meaningful for multilabel classification where this differs from accuracy_score).

インスタンスごとにF1 scoreを計算し、それを平均するという理解です。
今回は5例なので、5つのF1 scoreの平均ということですね。
カッコ内によると マルチラベル分類でのみaccuracy_scoreと違うので意味がある ようです。

>>> sample1_f1 = f1_score(y_true[0, :], y_pred[0, :])
>>> sample2_f1 = f1_score(y_true[1, :], y_pred[1, :])
>>> sample3_f1 = f1_score(y_true[2, :], y_pred[2, :])
>>> sample4_f1 = f1_score(y_true[3, :], y_pred[3, :])
>>> sample5_f1 = f1_score(y_true[4, :], y_pred[4, :])

>>> sample1_f1, sample2_f1, sample3_f1, sample4_f1, sample5_f1
(0.5, 0.0, 0.8, 0.6666666666666666, 1.0)
>>> np.mean([sample1_f1, sample2_f1, sample3_f1, sample4_f1, sample5_f1])
0.5933333333333334

>>> f1_score(y_true, y_pred, average="samples")
0.5933333333333334

一致🙌
サンプル2で正例を外している分、平均も低くなっていますね。

(参考)サンプルごとの混同行列

>>> confusion_matrix(y_true[0, :], y_pred[0, :])
array([[0, 1],
       [1, 1]])
>>> confusion_matrix(y_true[1, :], y_pred[1, :])
array([[1, 1],
       [1, 0]])
>>> confusion_matrix(y_true[2, :], y_pred[2, :])
array([[0, 0],
       [1, 2]])
>>> confusion_matrix(y_true[3, :], y_pred[3, :])
array([[1, 0],
       [1, 1]])
>>> confusion_matrix(y_true[4, :], y_pred[4, :])
array([[2, 0],
       [0, 1]])
  • サンプル1
    • precision 0.5 (=1/(1+1))
    • recall 0.5 (=1/(1+1))
    • 👉 F1 0.5
  • サンプル2
    • precision 0.0 (=0/(0+1))
    • recall 0.0 (=0/(0+1))
    • 👉 F1 0.0
  • サンプル3
    • precision 1.0 (=2/(2+0))
    • recall 0.67 (=2/(2+1))
    • 👉 F1 0.8
  • サンプル4
    • precision 1.0 (=1/(1+0))
    • recall 0.5 (=1/(1+1))
    • 👉 F1 0.6666666666666666
  • サンプル5
    • precision 1.0 (=1/(1+0))
    • recall 1.0 (=1/(1+0))
    • 👉 F1 1.0

転置してaverage=Noneを指定したら、サンプルごとのF1 score!

shapeが(5, 3)のndarrayどうしなら3つのF1 score(各クラスのF1 score)が算出されるのを見ました。
気づいちゃったんですが、転置してshapeを(3, 5)にしたら、5つのF1 scoreが算出されます!(=サンプルごとのF1 score)

>>> f1_score(y_true.T, y_pred.T, average=None)
array([0.5       , 0.        , 0.8       , 0.66666667, 1.        ])

終わりに

scikit-learnのf1_scoreaverage引数に"micro", "macro", "samples"を指定したとき、どのようにF1 scoreを平均するかを見てきました。
3クラスのマルチラベル分類で、5サンプルのデータを例にすると

  • micro: y_truey_labelをそれぞれreshape(-1)1次のndarray)にして、1つのF1 scoreを算出
  • macro: クラスごとにF1 scoreを算出して、それを平均
    • 今回の例では3つのF1 scoreを平均
    • average=Noneを平均
  • samples: サンプルごとにF1 scoreを算出して、それを平均
    • 今回の例では5つのF1 scoreを平均
    • 転置してaverage=Noneで求まる値を平均

となります。

この記事で詳しく見ていないものは、average="weighted"の指定です。
ドキュメントを見たところ、クラスごとのF1 scoreですが、各ラベルの正例の数(support)分の重みを付けて平均するようで、ここがmacroとの違いになるようです。

>>> f1_score(y_true, y_pred, average="macro")
0.5523809523809523
>>> f1_score(y_true, y_pred, average="weighted")
0.5523809523809524

今回の例ではほとんど変わらないですね。

また、各値はどういう目的average引数に指定するか(例:こういうときにaverage="macro"を指定する)はモヤッとしているので、そこは『評価指標入門』にあたってみようと思います。


  1. 平均の取り方を指定する引数だからaverageという名前なんですね!
  2. One-hot encodingに対して
  3. 詳しくはこちらに