結局ROC AUCはなにものかPython実装して定義への理解を深める
今回はROC AUC (Receiver Operating Characteristic curve Area Under the Curve)についてです。
2値分類の機械学習モデルの予測スコア精度指標としてめちゃめちゃ使われる印象があるROC AUCですが、結構説明は難しいですよね。
説明しようとすると、ROC AUCとはこのカーブの下側の面積です、このカーブがROCカーブといって、TPr、FPrというものをプロットして、、閾値を変えて、、、閾値というのは、、、といったように一筋縄では説明できないな、と思ったことがあります。
ただ、実はROC AUCは、「正例(Pos)のサンプルの予測確率が、負例(Neg)のサンプルの予測確率に比べて高くなっているか」、つまり正しい順序で各サンプルの予測確率が並んでいるかの割合、というとても理解しやすい定義で表すことができます。加えてその定義をみると、ROC AUCはクラス比率の逆数によって、それぞれのクラスの精度を重みづけている指標ともとれることがわかります。
また、その定義にのっとって自分でROC AUCをPython実装してsklearn実装と比較してみました。
ROC AUCの図の見方
混合行列の整理
ROC AUCについて見る前に、TP, FP, FN, TNについて整理します。下図に表しました。
2値分類において、予測クラスと実際のクラスが合っている場合は、Trueで表され、間違っている場合はFalse。そして予測クラスがPositiveクラス(正例, 1)である場合にPositive、Negativeクラス(負例, 1)である場合Negativeとすると、上のようにTP, FP, FN, TNが定義されます。
ROC Curve
それでは、ROC AUCを見ていきます。ROC AUCの概要を下図で表現しました。$x$軸はFPrで、$y$軸はTPrです。このFPr, TPrの点を結んだ曲線がROC Curveであり、その下側の面積がROC AUCとなります。またROC AUCの重要な性質として必ず0から1の範囲になるという性質があります。
上図の指標の整理を見ると、$x$軸であるFPrは、実際のクラスがNegのうち間違えてPosと予測されてしまった割合。$y$軸であるTPrは、実際のクラスがPosのうち正しくPosと予測された割合 ( = Recall )で表されます。
FPr, TPrの定義を確認すると、予測値がすべてNeg(0)であった場合には分子が0になるため、$(0,0)$となることがわかります。予測値がすべてPos(1)である場合には、TN, FNが0となり、分母分子が一致するので$(1,1)$を通ることになります。また、完全にランダム予測の場合は、ROC Curveが$y = x$線になりROC AUCは0.5となります。
ROC AUCの定義
それではROC AUCをよりシンプルな形で表現をしたいと思います。
準備として、実際のクラスがPos(1)であるサンプルの集合を$S_1$とします。そのサンプル数は$|S_1| =TP + FN$となります。また、実際のクラスがNeg(0)であるサンプルの集合を$S_0$とします。そのサンプル数は$|S_0| = FP + TN$となります。学習した分類モデルを$f$として、サンプル$s$に対しての予測スコアを$f(s)$で表します。
これらを用いるとROC AUCは、
$$\text{ROCAUC} = \dfrac{ \sum_{s_0\in S_0,s_1 \in S_1}{\mathbf{1}[f(s_0) < f(s_1)]} + 0.5\times\sum_{s_0\in S_0,s_1 \in S_1}{\mathbf{1}[f(s_0) = f(s_1)]}}{|S_0| \times |S_1|}$$
と表すことができます。*1
分母は、実際のクラスがNeg(0)のサンプル数$|S_0|$、実際のクラスがPos(1)のサンプル数$|S_1|$の全組み合わせ数$|S_0| \times |S_1|$です。
分子の第1項目は$S_0$と$S_1$の全サンプルの組み合わせの予測スコア比較で、$S_1$のサンプルの方が予測スコアが高い数 ( = 正しい順序で判別できる数 )、第2項は全サンプルの組み合わせのうち予測スコアが同じになっている数に$0.5$をかけたものです。
つまり、ROC AUCは、Pos(1), Neg(0)それぞれのクラスに属するサンプルの全組み合わせの中で、どれだけ予測スコアの順序が正しく保たれているかの指標、と見なせます。
また、この定義をよく見てみると、$|S_0|, |S_1|$に精度がとても依存していることがわかります。もしクラス比率が$(|S_0| : |S_1|) = (8, 2)$の場合には、$S_1$の1サンプルに対応する$S_0$のサンプルは$8/2 = 4$倍あることになります。そこで分子の第一項をみると、Pos(1)を1サンプルを完全に分類できるようになったときの精度の上昇幅は、Neg(0)を1サンプルを完全に分類できるようになった場合の上昇幅の4倍となります。*2
以上のことからROC AUCは各サンプルに対して、クラス比率の逆数分だけ重みづけている指標だと考えられます。
Pythonで実装してみる
自作関数の定義
定義に従って関数を作ってみます。各クラスの全サンプルの組み合わせはitertools
のproduct
を用いて実装します。そして愚直に全サンプルの予測スコアを比較して最終的にサンプル数で割る(平均をとる)実装にします。コードはこちらです。
# -*- coding: utf-8 -*-
import numpy as np
from itertools import product
def my_roc_auc(true_label: np.array, pred_proba : np.array) -> float:
"""
ROCAUC = Pr (true_labelが0のサンプルの予測スコア < true_labelが1のサンプルの予測スコア) --- (a)
+ 0.5 * Pr (true_labelが0のサンプルの予測スコア = true_labelが1のサンプルの予測スコア) --- (b)
from sklearn.metrcis import roc_auc_scoreの結果と一致することを確認済み
"""
# サンプルを抽出
pred_proba_true_label_0 = pred_proba[true_label == 0]
pred_proba_true_label_1 = pred_proba[true_label == 1]
# 全組み合わせを直積から作成
pred_proba_product = product(pred_proba_true_label_0, pred_proba_true_label_1)
pred_proba_product = np.array(list(pred_proba_product))
# 正しい順序で分類できているフラグ (a)
success_classified_flg = pred_proba_product[:,0] < pred_proba_product[:,1]
# true_labelが0, 1のサンプル間で予測確率が一致しているフラグ (b)
equal_score_flg = pred_proba_product[:,0] == pred_proba_product[:,1]
# どちらもintにして, 足す
roc_auc_array = success_classified_flg.astype(int) + equal_score_flg.astype(int) * 0.5
roc_auc = np.mean(roc_auc_array)
return roc_auc
sklearn実装との比較
自作の関数とskelarn.metrics
のroc_auc_score
を比較します。比較のためにassert_almost_equal
というnumpy.testing
の関数を使って比較します。assert_almost_equal
は小数点第何位までの一致を判定できる関数で、一致していない場合はAssertionError
がでます。
from numpy.testing import assert_almost_equal # 比較用の関数
from sklearn.metrics import roc_auc_score # sklearn ROC AUC
from myfunc.my_roc_auc import my_roc_auc # 自作 ROC AUC
n_samples = 1000
np.random.seed(n_samples)
# generate sample data
true_label = np.random.choice([0,1], size=n_samples)
pred_proba = np.random.choice(np.linspace(0, 1, 100), size=n_samples)
# calc ROCAUC
roc_auc_sklearn = roc_auc_score(true_label, pred_proba)
roc_auc_mine = my_roc_auc(true_label, pred_proba)
print("ROCAUC\n\tsklearn:\t%.15f\n\tmine:\t%.15f"%(roc_auc_sklearn, roc_auc_mine))
assert_almost_equal(roc_auc_sklearn, roc_auc_mine, decimal=10)
# 出力(小数点以下15桁までの一致を確認)
ROCAUC
sklearn: 0.523924202292845
mine: 0.523924202292845
結果を見てみると、小数点15位まで一致していることがわかります。
これを一応以下のコードで1000回ほどやってもAssertionError
がでなかったので、一致していると見なします。
# 実装がsklearnのAUCと一致するかテスト, エラーがでなければ一致している
n_trials = 1000
n_samples = 200
for n in tqdm(range(n_trials)):
np.random.seed(n)
# generate sample data
true_label = np.random.choice([0,1], size=n_samples)
pred_proba = np.random.choice(np.linspace(0, 1, 100), size=n_samples)
# calc ROCAUC
roc_auc_sklearn = roc_auc_score(true_label, pred_proba)
roc_auc_mine = my_roc_auc(true_label, pred_proba)
# check almost equal
assert_almost_equal(roc_auc_sklearn, roc_auc_mine, decimal=10)
1サンプル分類成功するごとの上昇幅の確認
次にクラス比率とROC AUCの関係を確認するために10サンプルのデータで検証します。クラス比率はPos(1) : Neg(0) = 2:8とします、この場合に、Posを1サンプル完全に分類できることによってNegを1サンプル完全に分類できるよりも4倍精度が向上しやすいことを確認します。
すべて0.5と予測した場合のROC AUC
# example 1
y_true_label = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0])
y_pred_proba = np.array([0.5] * 10)
# calc ROCAUC
roc_auc_sklearn = roc_auc_score(y_true_label, y_pred_proba)
roc_auc_mine = my_roc_auc(y_true_label, y_pred_proba)
print("ROCAUC\n\tsklearn:\t%.5f\n\tmine:\t%.5f"%(roc_auc_sklearn, roc_auc_mine))
全て同じ値の予測値なので、全組み合わせが分子の第2項に該当するためROC AUCが0.5となります。
# 出力
ROCAUC
sklearn: 0.50000
mine: 0.50000
少数クラスであるPosを1サンプル完全に分類できるようになった場合のROC AUC
実際のラベルがPos(1)であるサンプルの予測値を1にしてROC AUCを計算してみます。Pos(1)は全体が10サンプルある中の2サンプルのみなのでクラス比率が低いクラスとなっています。
# example 2 (Posを1サンプル完全に分類できるようになった場合の精度)
y_true_label = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0])
y_pred_proba = np.array([0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 1.0])
# calc ROCAUC
roc_auc_sklearn = roc_auc_score(y_true_label, y_pred_proba)
roc_auc_mine = my_roc_auc(y_true_label, y_pred_proba)
print("ROCAUC\n\tsklearn:\t%.5f\n\tmine:\t%.5f"%(roc_auc_sklearn, roc_auc_mine))
結果は以下のように0.75となり、0.25上昇しました。
# 出力 (Posを1サンプル完全に分類できるようになった場合の精度)
ROCAUC
sklearn: 0.75000
mine: 0.75000
多数クラスであるNegを1サンプル完全に分類できるようになった場合のROC AUC
実際のラベルがNeg(0)であるサンプルの予測値を0にしてROC AUCを計算してみます。Neg(0)は全体が10サンプルある中の8サンプルなのでクラス比率が高いクラスとなっています
# example 3 (Negを1サンプル完全に分類できるようになった場合の精度)
y_true_label = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0])
y_pred_proba = np.array([0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.0, 0.5, 0.5])
# calc ROCAUC
roc_auc_sklearn = roc_auc_score(y_true_label, y_pred_proba)
roc_auc_mine = my_roc_auc(y_true_label, y_pred_proba)
print("ROCAUC\n\tsklearn:\t%.5f\n\tmine:\t%.5f"%(roc_auc_sklearn, roc_auc_mine))
結果は以下のように0.5625となり、0.0625の上昇となりました。Posの時の上昇幅である0.25と比べるとだいぶ小さいですね。
# 出力 (Negを1サンプル完全に分類できるようになった場合の精度)
ROCAUC
sklearn: 0.56250
mine: 0.56250
1サンプル完全に分類できるようになった場合のROC AUC上昇幅の比較
上記の結果を比較してしてみます。Posの上昇幅とNegの上昇幅は、
# 1サンプルを分類成功することによるROC AUC上昇幅の比率を確認
# (少数クラスPosの上昇幅) / (多数クラスNegの上昇幅)
(0.75000 - 0.5) / (0.56250 - 0.5)
> 4
となり、4となります。またこれは、下記のようにクラス比率の逆数と一致することがわかりました。
# 不均衡データの比率を計算する
n_major = np.sum(y_true_label == 0)
n_minor = np.sum(y_true_label == 1)
minor_ratio = n_minor / n_major
print("少数クラスの比率 :\t\t\t %.3f"%minor_ratio)
print("少数クラスの比率の逆数 :\t %.3f"%(1/minor_ratio))
少数クラスの比率 : 0.250
少数クラスの比率の逆数 : 4.000
上記から1サンプルを完全に分類できるようになった場合のROC AUCの上昇幅は、クラス比率の逆数と一致しました。上記の実験コードは全てこちらです。
まとめ
ROC AUCが各クラスのサンプルの全組み合わせで予測スコアの順序が保たれている度合いで表せるという直感的に理解しやすい定義を紹介しました。またROC AUCがクラス比率の逆数によって重み付けされているのは、驚きでした。超極端な不均衡データ(1000:1とか) に対して ROC AUCは、その逆数分だけ重みづけてしまうので、その少数クラスのスコアの変動によってROC AUCが高くも低くもブレやすいのは納得です。ただ逆数で補正している分、各クラスを公平にみているとも取れますね。
ちなみに、自作実装のROC AUCとsklearnは結果は一致したものの、おそらく計算の仕方が異なっていて、sklearnの方がめちゃめちゃ早いです。
*1:Pepe, M. S., Longton, G., & Janes, H. (2009). Estimation and comparison of receiver operating characteristic curves. The Stata Journal, 9(1), 1-16.を参考に筆者作成
*2:ここで完全に分類できるようになるとは、そのサンプルが所属するクラスの予測スコアのうち最も極端な値をとるようになる (ROC AUCの分子の第1項の不等号が完全に成り立つようになる) ことを表しています。つまり、Posである確率が全サンプルのPosである予測スコアの最大値より大きくなる、Negの場合、Posである予測確率の最小値以下となることを指しています。