雑記: AUC の話とその scikit-learn での計算手順の話

AUC というものを算出しろといわれることがあると思います。でも幸いなことに scikit-learn で算出できます。

例えば以下のような正解ラベルが付いたデータのそれぞれに対して、あるモデルが以下のようなスコアを出しているとします。正解ラベルを「スパムメールか否か」、モデルのスコアを「モデルが推定したスパムメールらしさ」などと考えてもいいです。

真の正解ラベルモデルの出力スコア
02
01
02
14
12
01
13
15
このモデルのスコアの AUC を得るには、上のデータを sklearn.metrics.roc_auc_score に渡すだけです。あるいは sklearn.metrics.roc_curve に渡してからその出力を sklearn.metrics.auc に渡しても同じです( AUC とはその名の通り「Area Under Curve=曲線の下の面積」なので、後者は一旦曲線を出していることになります )。

from sklearn import metrics

list_label = [0, 0, 0, 1, 1, 0, 1, 1]
list_score = [2, 1, 2, 4, 2, 1, 3, 5]

auc = metrics.roc_auc_score(list_label, list_score)
print(auc)

fpr, tpr, thresholds = metrics.roc_curve(list_label, list_score)
auc = metrics.auc(fpr, tpr)
print(auc)
0.9375
0.9375

AUC は 0.9375 となります。この AUC がどんな曲線の下の面積だったのかを知るには、metrics.roc_curve の出力をプロットしてみるとわかります。

%matplotlib inline
import matplotlib.pyplot as plt
from pylab import rcParams
rcParams['figure.figsize'] = 6, 4
rcParams['font.family'] = 'Ume Gothic O5'
rcParams['font.size'] = 16

print("fpr: ", fpr)
print("tpr: ", tpr)
print("thresholds: ", thresholds)

plt.plot(fpr, tpr, marker="o")
plt.xlabel("fpr")
plt.ylabel("tpr")
plt.show()
fpr:  [0.  0.  0.5 1. ]
tpr:  [0.25 0.75 1.   1.  ]
thresholds:  [5 3 2 1]

f:id:cookie-box:20190209172457p:plain

fpr、tpr なるものをプロットし、各点を直線で結ぶとこのような折れ線が描けます。そして、この折れ線の下の面積は確かに 0.9375 になっていることがわかります(※ 上図では補っていませんが、折れ線は本当は原点から始まっていると考えてください)。
しかし、fpr、tpr、thresholds というのは何なのでしょうか。それは thresholds というリストに入っている値のそれぞれを閾値としたときのモデルの判定を下のように書き出してみるとみえてきます(下の表では、8つのデータをスコア降順に並べ直しています)。
正解ラベルスコア▼判定(閾値=5)判定(閾値=3)判定(閾値=2)判定(閾値=1)
1:スパム5スパム(正)スパム(正)スパム(正)スパム(正)
1:スパム4非スパム(誤)スパム(正)スパム(正)スパム(正)
1:スパム3非スパム(誤)スパム(正)スパム(正)スパム(正)
0:非スパム2非スパム(正)非スパム(正)スパム(誤)スパム(誤)
0:非スパム2非スパム(正)非スパム(正)スパム(誤)スパム(誤)
1:スパム2非スパム(誤)非スパム(誤)スパム(正)スパム(正)
0:非スパム1非スパム(正)非スパム(正)非スパム(正)スパム(誤)
0:非スパム1非スパム(正)非スパム(正)非スパム(正)スパム(誤)
真のスパムメールのうち
スパムと判定されている割合
25%75%100%100%
真の非スパムメールのうち
スパムと判定されている割合
0%0%50%100%
上の表の下から2番目の行が tpr 、1番下の行が fpr に相当していることがわかります。しかし、この下の面積にどんな意味があるのでしょうか。
上の表の下から2番目の行は閾値をどんどん下げていくとどれだけスパムメールがカバーされていくか」と解釈できます。なので、0% から始まり、やがて 100% に至ります(本当は上の表の左端に 0% が補われます)。他方、一番下の行は閾値をどんどん下げていくとどれだけ非スパムメールまで巻き込んでカバーされてしまっていくか」と解釈できます。これも 0% から始まり 100% に至ります(やはり上の表の左端に 0% が補われます。今回のケースはたまたま補わなくても一番左が 0% ですが、いつもそうではありません)スパムメール判定モデルに求められることは、閾値をどんどん下げていくと、先にスパムメールだけがカバーされていく(非スパムメールを巻き込まずに)」、そんなスコアを出力することです。
  • 例えば完璧なモデルなら、閾値を下げていったときに先に完全にスパムメールをカバーし切って、その後非スパムメールがカバーされ始めます。つまり、fpr=0% のまま(非スパムメールを1件も巻き込まないまま)tpr=100% に到達するので(全てのスパムメールをカバーするので)、このときの fpr-tpr 曲線は (0%、0%)→(0%、100%)→(100%、100%)の形を描きます。この曲線の下の面積は 1 です。
  • 逆にスパムメールと非スパムメールを全然識別できないモデルなら、閾値を下げていったときにスパムメールも非スパムメールも同じ割合ずつカバーしていってしまいそうです。このときの fpr-tpr 曲線は (0%、0%)→(100%、100%)を直線に結びます。この曲線の下の面積は 0.5 です。
なので、AUC が 1 に近いスコアほどよいスコアであるといえそうです(AUC の値は非スパムメールよりスパムメールに大きいスコアが付いている期待値などと解釈もできます。参考: 昔自分が書いた非常に読みづらい記事(※)
AUC が計算できたのはいいんですが、時には scikit-learn の力を借りることができないこともあると思います。でも scikit-learn と値が合わないと気持ち悪いと思います。上の流れをみると自分でも適当に実装できそうな気がしますが雰囲気でやるのはよくないと思います。ちゃんとコードをみます。sklearn.metrics.roc_auc_score は以下です。

これをみると結局 sklearn.metrics.roc_curve と sklearn.metrics.auc がよばれていることがわかります。roc_curve は以下です。

この引数をみて1つ解決した疑問があります。上の例では roc_curve の戻り値の thresholds は [5 3 2 1] となっており 4 が含まれていませんでした。しかし 4 というスコアのデータもあるので、「閾値=4」での fpr、tpr も計算するべきではないかという気もします。それは roc_curve の drop_intermediate という引数がデフォルトで True だったために、ROCカーブの形状を変えない座標の fpr、tpr が省かれていたということがここにきてわかります。試しに、drop_intermediate=False としてもう一度実行してみます。

fpr, tpr, thresholds = metrics.roc_curve(list_label, list_score, drop_intermediate=False)
print("fpr: ", fpr)
print("tpr: ", tpr)
print("thresholds: ", thresholds)

plt.plot(fpr, tpr, marker="o")
plt.xlabel("fpr")
plt.ylabel("tpr")
plt.show()
fpr:  [0.  0.  0.  0.5 1. ]
tpr:  [0.25 0.5  0.75 1.   1.  ]
thresholds:  [5 4 3 2 1]

f:id:cookie-box:20190210162832p:plain

thresholds に 4 が含まれ、ROCカーブの座標が先ほどより1点増えました。しかし、この座標はあってもなくてもROCカーブの下の面積を出す上で影響がない点であったこともわかります。ROCカーブを軽くするためにデフォルトではこのような点は省かれているようです。
それで roc_curve ではまずサブ関数の _binary_clf_curve がよばれて、閾値をどんどん下げていったときに検出されるスパムメールと非スパムメールの個数を出しています。下のコードは _binary_clf_curve からいま最低限必要な動作のみ抜き出し、コメントをアレンジしたものです。

import numpy as np
from sklearn.utils.extmath import stable_cumsum

def my_binary_clf_curve(y_true, y_score, pos_label=None):
    y_true = np.array(y_true)
    y_score = np.array(y_score)

    # y_true および y_score を y_score の降順にソートする
    desc_score_indices = np.argsort(y_score, kind="mergesort")[::-1]
    y_score = y_score[desc_score_indices]
    y_true = y_true[desc_score_indices]
    print("y_score(ソート済み): ", y_score)
    print("y_true(ソート済み): ", y_true)

    # ほしいのはこの y_score のそれぞれを閾値としたときに、検出されるスパムメールと非スパムメールの個数のベクトル
    # ただ y_score はしばしば同値データを多く含む
    # 次の要素との差がゼロでないインデックスでのみスパムメールと非スパムメールの個数がほしいのでそのようなインデックスを取得する
    distinct_value_indices = np.where(np.diff(y_score))[0]
    print("次の要素との差がゼロでないインデックス: ", distinct_value_indices)
    threshold_idxs = np.r_[distinct_value_indices, y_true.size - 1] # 最後の fp、tp が取れないので最後のインデックスは補う

    # y_true を cumsum すればよい    
    tps = stable_cumsum(y_true)[threshold_idxs] # その閾値で検出されるスパムメールの個数
    fps = 1 + threshold_idxs - tps # その閾値で検出される非スパムメールの個数(インデックス+1 が検出されるデータ総数なので tps を引けばよい)
    return fps, tps, y_score[threshold_idxs]    

print("y_score: ", list_score)
print("y_true: ", list_label)
fp, tp, thresholds = my_binary_clf_curve(list_label, list_score)
print("fp: ", fp)
print("tp: ", tp)
print("thresholds: ", thresholds)
y_score:  [2, 1, 2, 4, 2, 1, 3, 5]
y_true:  [0, 0, 0, 1, 1, 0, 1, 1]
y_score(ソート済み):  [5 4 3 2 2 2 1 1]
y_true(ソート済み):  [1 1 1 1 0 0 0 0]
次の要素との差がゼロでないインデックス:  [0 1 2 5]
fp:  [0. 0. 0. 2. 4.]
tp:  [1. 2. 3. 4. 4.]
thresholds:  [5 4 3 2 1]

fp、tp は閾値を thresholds にしたがって変化させていったときに検出される非スパムメールスパムメールの個数です。呼び出し元の roc_curve ではこの fp と tp をそれぞれ最後のインデックスの値で割って割合(fpr、tpr)に直しています。drop_intermediate=True の場合は先に不要な座標の除去が行われます。この fpr、tpr が sklearn.metrics.auc に渡されます。

そしてこの auc から必要な動作を抜き出すと以下です。というか numpy.trapz に面積を求めさせているだけです。つまり、ROCカーブの下の面積は台形則で求められている(各座標を直線で結んだ折れ線の下の面積が求められている)ことがわかります。

def my_auc(x, y):
    area = np.trapz(y, x)
    return area

fpr = fp / fp[-1]
tpr = tp / tp[-1]
auc = my_auc(fpr, tpr)
print(auc)
0.9375

いかがでしたか? AUCの計算方法はとても簡単でしたね。やはり自分で適当に実装できそうです。
何がいいたいのかというと自分は適当にやって失敗したんですか、適当だからといってやるべきではないのは、スコア降順にソートした上でROCカーブの座標を1点ずつ拾っていくことだと思います。これをやるとROCカーブはスコア同値のデータの中でラベル1とラベル0がどう並んでいるかに依存します。「スコア同値ならばラベル昇順」というソートにすれば台形則から三角形を削った長方形則(というのか?)のAUCは出るといえば出ると思います(ただAUCの定義はやはり「閾値を下げていったときの fpr-tpr 曲線」の面積なので、定義に沿っていないかなり粗い下からの評価になってしまうと思います)。この方式だと三角形が削れているので scikit-learn より小さいAUCになります。じゃあこれに三角形を補えばいい気もしますが、カーブの座標からはどう三角形を補うべきかの情報が失われています(長方形の短冊を埋めるような三角形を補う、だと誤りです。同じスコアによって描かれた辺だけを三角形で埋める必要があります)。最初から素直に scikit-learn と同じ方法でやればいいと思います。
スパムメールを学習するモデルというのは普通は未知のメールに対してスパムメールか否か(1 or 0)を判定することを目指すものだと思います。なのに、ここでのモデルはスパムメールらしさのスコアというものを出力していて、そのスコアが AUC で評価されているのは不思議さがあると思います。まず、スパムメールか否かを判定するモデルでは、モデルはメールの色々な特徴からスパムらしさを説明しようとすると思います。モデルが最終的に実際に利用されるときは、色々な特徴から推測されるスパムらしさの合計値をどこかしらの閾値で切ってモデルの出力を "1 or 0" に丸めると思いますが、モデルの内部では "1" と判定したデータの中にも「スパムらしい特徴をとても強くもつなあ」「スパムらしい特徴をまあまあもつなあ」という違いはあると思います。AUC はその 1 or 0 に丸められる前のモデルのスパムらしさのスコア付けを評価するものです。なぜ 「(閾値をどこかしらに決めた)1 or 0 の最終判定における精度や感度」などではなく「(閾値を決める前の)スコアの AUC」をみたいのかというと、「そもそも実は最終判定ではなくスコアがほしい」というケースがあると思います。閾値は後から適当に調整するので、スコアをよこせということです。あるいは、元より最終判定のみがほしい場合でも、精度・感度は同程度のモデルが2つ出来上がってきたとして、2つとも検出漏れはないが誤検出はあるとして、スパムと誤判定された非スパムメールに対する内部スコアが「閾値は超えてしまったが少し超えてしまっただけで多くのスパムメールのスコアよりは小さい」というモデルと、「多くのスパムメールのスコアよりも大きくなってしまっている」というモデルだったら後者の方が判断の根拠が心配な気はします。この場合、後者の方がAUCが小さくなります。