はじめに
理解の整理のためにROC曲線についてまとめます。今やエクセルでもポチッとすれば自動で描いてくれますが、実際どんな手順で書かれるのか追っていくことにします。ネットで調べると勘違いして書いている記事がgoogle上位にヒットして驚きました。(TPRを (そこまでカウントしたTP)/(全体のTP)としてたり、Thresholdの概念がなかったり…)
前提知識
まず確率分布からROC曲線のアイデアを理解することが必要となります。こちらの記事が非常にわかりやすいかと思います。
【統計学】ROC曲線とは何か、アニメーションで理解する。 (以下"ROC曲線の記事"と呼称します)
また、Confusion Matrixについては定義される評価指標が多くて文字通り、confuse you(me)なので、 混同しやすい混同行列 : baru-san.net を横に開きながら見るといいと思います。
一瞬でわかりたい人向け
通常と行と列が逆になっていますが、わかる方はこの図で一発でわかると思います。
ROC曲線を手で書くには?
メインアイデア
"ROC曲線の記事"でやられていることを離散的に行う。つまり、二値判別の確率において、閾値を1から0まで変化させて、TPRとFPRを書いていくということになります。何を言っているかわからないと思うので、以下に具体例をしめします。
具体例
ロジスティック回帰なり使って、二値判別した結果、こんなデータが得られたとしましょう。
##import from IPython.core.display import display import pandas as pd import numpy as np from sklearn.metrics import confusion_matrix, roc_curve, roc_auc_score
df = pd.DataFrame({ 'prob': [0.98, 0.9, 0.8, 0.7, 0.3, 0.2, 0.1], 'actual_label': [1, 1, 1, 1, 0, 0, 0], }) df = df[['prob', 'actual_label']] df
prob | actual_label | |
---|---|---|
0 | 0.98 | 1 |
1 | 0.90 | 1 |
2 | 0.80 | 1 |
3 | 0.70 | 1 |
4 | 0.30 | 0 |
5 | 0.20 | 0 |
6 | 0.10 | 0 |
probはlabelが1である確率を示しています。actual_labelを見ると理想的なまでに綺麗に分離できています。ここでprobを使って予測をしたいと思います。多くの人はprob>0.5で1のラベルをつければいいと判断するでしょう。ちょっとやってみます。
df['pred_label'] = (df.prob > 0.5).astype(int) df
prob | actual_label | pred_label | |
---|---|---|---|
0 | 0.98 | 1 | 1 |
1 | 0.90 | 1 | 1 |
2 | 0.80 | 1 | 1 |
3 | 0.70 | 1 | 1 |
4 | 0.30 | 0 | 0 |
5 | 0.20 | 0 | 0 |
6 | 0.10 | 0 | 0 |
当たり前ですが綺麗に正解のlabelをつけることができました。
tn, fp, fn, tp = confusion_matrix(df.actual_label, df.pred_label, ).ravel() ##tnから返すっていうトラップすぎる順番で泣いたおまえおまえおまえ cnf_matrix = pd.DataFrame([ [tp, fn], [fp, tn] ]) cnf_matrix.columns = ['PredictedAs1', 'PredictedAs0'] cnf_matrix.index = ['RealIs1', 'RealIs0'] display(cnf_matrix) print('TPR=',tp/(tp+fn),'\tFPR=',fp/(fp+tn))
PredictedAs1 | PredictedAs0 | |
---|---|---|
RealIs1 | 4 | 0 |
RealIs0 | 0 | 3 |
TPR= 1.0 FPR= 0.0
Confusion Matrixもこんな感じとなっています。ROC曲線を書くためのTPRもFPRも計算しておきました。 では"ROC曲線の記事"のように閾値を変えることをやってみます。まずはprob>0.99で1と予測するとしたとき。
df['pred_label'] = (df.prob > 0.99).astype(int) display(df) tn, fp, fn, tp = confusion_matrix(df.actual_label, df.pred_label, ).ravel() cnf_matrix = pd.DataFrame([ [tp, fn], [fp, tn] ]) cnf_matrix.columns = ['PredictedAs1', 'PredictedAs0'] cnf_matrix.index = ['RealIs1', 'RealIs0'] display(cnf_matrix) print('TPR=',tp/(tp+fn),'\tFPR=',fp/(fp+tn))
prob | actual_label | pred_label | |
---|---|---|---|
0 | 0.98 | 1 | 0 |
1 | 0.90 | 1 | 0 |
2 | 0.80 | 1 | 0 |
3 | 0.70 | 1 | 0 |
4 | 0.30 | 0 | 0 |
5 | 0.20 | 0 | 0 |
6 | 0.10 | 0 | 0 |
PredictedAs1 | PredictedAs0 | |
---|---|---|
RealIs1 | 0 | 4 |
RealIs0 | 0 | 3 |
TPR= 0.0 FPR= 0.0
これもあたりまえですが1と判断する行がなくなりました。結果としてTPがなくなりFNが4つになりました。次に同じようにprob>0.95で1と予測するとしたとき。
df['pred_label'] = (df.prob > 0.95).astype(int) display(df) tn, fp, fn, tp = confusion_matrix(df.actual_label, df.pred_label, ).ravel() cnf_matrix = pd.DataFrame([ [tp, fn], [fp, tn] ]) cnf_matrix.columns = ['PredictedAs1', 'PredictedAs0'] cnf_matrix.index = ['RealIs1', 'RealIs0'] display(cnf_matrix) print('TPR=',tp/(tp+fn),'\tFPR=',fp/(fp+tn))
prob | actual_label | pred_label | |
---|---|---|---|
0 | 0.98 | 1 | 1 |
1 | 0.90 | 1 | 0 |
2 | 0.80 | 1 | 0 |
3 | 0.70 | 1 | 0 |
4 | 0.30 | 0 | 0 |
5 | 0.20 | 0 | 0 |
6 | 0.10 | 0 | 0 |
PredictedAs1 | PredictedAs0 | |
---|---|---|
RealIs1 | 1 | 3 |
RealIs0 | 0 | 3 |
TPR= 0.25 FPR= 0.0
TPが一個増えFNが一つ減りましたね。このように閾値を変化させることでTPとFNのバランスが変化していきます。その結果TPRも変化します。このことはFPRにおいても同じことが言えます。次に閾値をどんどん変化させていったときのTPRとFPRの変化を見ていきましょう。
for i in [0.01*x for x in range(100, 0 , -5)]: df['pred_label'] = (df.prob > i).astype(int) tn, fp, fn, tp = confusion_matrix(df.actual_label, df.pred_label, ).ravel() print('Threshold=',f"{i:.2f}",'\tTPR=',f"{tp/(tp+fn):.2f}",'\tFPR=',f"{fp/(fp+tn):.2f}")
Threshold= 1.00 TPR= 0.00 FPR= 0.00
Threshold= 0.95 TPR= 0.25 FPR= 0.00
Threshold= 0.90 TPR= 0.25 FPR= 0.00
Threshold= 0.85 TPR= 0.50 FPR= 0.00
Threshold= 0.80 TPR= 0.50 FPR= 0.00
Threshold= 0.75 TPR= 0.75 FPR= 0.00
Threshold= 0.70 TPR= 0.75 FPR= 0.00
Threshold= 0.65 TPR= 1.00 FPR= 0.00
Threshold= 0.60 TPR= 1.00 FPR= 0.00
Threshold= 0.55 TPR= 1.00 FPR= 0.00
Threshold= 0.50 TPR= 1.00 FPR= 0.00
Threshold= 0.45 TPR= 1.00 FPR= 0.00
Threshold= 0.40 TPR= 1.00 FPR= 0.00
Threshold= 0.35 TPR= 1.00 FPR= 0.00
Threshold= 0.30 TPR= 1.00 FPR= 0.00
Threshold= 0.25 TPR= 1.00 FPR= 0.33
Threshold= 0.20 TPR= 1.00 FPR= 0.33
Threshold= 0.15 TPR= 1.00 FPR= 0.67
Threshold= 0.10 TPR= 1.00 FPR= 0.67
Threshold= 0.05 TPR= 1.00 FPR= 1.00
閾値(Threshold)を媒介変数としたTPRとFPRの軌跡(トラジェクトリ)がROC曲線なのです。実際に描いて見るとこんな感じです。お気づきだと思いますがThresholdをデータのprobに合わせて動かせば、行数分だけの操作で曲線を描くのに必要なTPRとFPRが充分求まります。
##visualization from bokeh.plotting import figure from bokeh.io import output_notebook, show output_notebook()
fpr, tpr, thresholds = roc_curve(df.actual_label, df.prob, pos_label=1) ##make figure p = figure( title = "理想的なROC曲線", plot_width=400, plot_height=400, x_range=(-0.01,1), y_range=(0,1.02) ) ##add line p.line( fpr, tpr, ) p.xaxis.axis_label = 'FPR' p.yaxis.axis_label = 'TPR' show(p) print('AUCスコア:',roc_auc_score(df.actual_label, df.prob))
AUCスコア: 1.0
今回の仮定した予測なら綺麗に1と0を分離できるので、ご存知の通りの綺麗なROC曲線が書けます。AUC(エリアアンダーザカーブ)の面積も1となっています。
別の具体例1
AUCがモデルの性能を測る指標になるのを示すために、別の例を示します。先程と違って
- probが0.7であるにもかかわらずactual_labelが0
- probが0.3であるにもかかわらずactual_labelが1
というデータがあります。これは構築したモデルの性能がいまいちだったためにうまく判別できないデータが現れたと思ってください。
df = pd.DataFrame({ 'prob': [0.98, 0.9, 0.8, 0.7, 0.3, 0.2, 0.1], 'actual_label': [1, 1, 1, 0, 1, 0, 0], }) df = df[['prob', 'actual_label']] df
prob | actual_label | |
---|---|---|
0 | 0.98 | 1 |
1 | 0.90 | 1 |
2 | 0.80 | 1 |
3 | 0.70 | 0 |
4 | 0.30 | 1 |
5 | 0.20 | 0 |
6 | 0.10 | 0 |
これを使ってROCとAUCを示します。
fpr, tpr, thresholds = roc_curve(df.actual_label, df.prob, pos_label=1) ##make figure p = figure( title = "性能がちょっと悪いモデルのROC曲線", plot_width=400, plot_height=400, x_range=(-0.01,1), y_range=(0,1.02) ) ##add line p.line( fpr, tpr, ) p.xaxis.axis_label = 'FPR' p.yaxis.axis_label = 'TPR' show(p) print('AUCスコア:',roc_auc_score(df.actual_label, df.prob))
AUCスコア: 0.916666666667
このように左上が凹んだ形になりました。またAUCも減りました。
別の具体例2
さらに別の例を示します。こんどはprobにかかわらずactual_labelには0,1,0,1...と並べました。つまりモデル化した判別は全くのデタラメである状況です。
df = pd.DataFrame({ 'prob': [0.98, 0.9, 0.8, 0.7, 0.3, 0.2, 0.1], 'actual_label': [0, 1, 0, 1, 0, 1, 0], }) df = df[['prob', 'actual_label']] df
prob | actual_label | |
---|---|---|
0 | 0.98 | 0 |
1 | 0.90 | 1 |
2 | 0.80 | 0 |
3 | 0.70 | 1 |
4 | 0.30 | 0 |
5 | 0.20 | 1 |
6 | 0.10 | 0 |
これを使ってROCとAUCを示します。
fpr, tpr, thresholds = roc_curve(df.actual_label, df.prob, pos_label=1) ##make figure p = figure( title = "極めて悪いモデルのROC曲線", plot_width=400, plot_height=400, x_range=(-0.01,1), y_range=(0,1.02) ) ##add line p.line( fpr, tpr, ) p.xaxis.axis_label = 'FPR' p.yaxis.axis_label = 'TPR' show(p) print('AUCスコア:',roc_auc_score(df.actual_label, df.prob))
AUCスコア: 0.5
上図のような形のため、AUCが0.5まで減りました。モデルが悪いとAUCが0.5に近づいていくのがわかると思います。
注意すべき例
一番はじめの具体例を少し書き換えて
- probが0.7であるにもかかわらずactual_labelが0
というデータがあるものをつくりました。
df = pd.DataFrame({ 'prob': [0.98, 0.9, 0.8, 0.7, 0.3, 0.2, 0.1], 'actual_label': [1, 1, 1, 0, 0, 0, 0], }) df = df[['prob', 'actual_label']] df
prob | actual_label | |
---|---|---|
0 | 0.98 | 1 |
1 | 0.90 | 1 |
2 | 0.80 | 1 |
3 | 0.70 | 0 |
4 | 0.30 | 0 |
5 | 0.20 | 0 |
6 | 0.10 | 0 |
このときAUCは1となります。ほんとは0なのに1である確率を0.7も出してるなんてなんとなく悪そうと思う方がいるかも知れませんが、これは問題ないのです。閾値を0.75に設定すれば、きれいに分離できるからです。これが本当に問題ないかはここでは議論しませんが少なくともROC曲線ではわかりません。実際に描いてみると以下になります。
fpr, tpr, thresholds = roc_curve(df.actual_label, df.prob, pos_label=1) ##make figure p = figure( title = "理想的なROC曲線", plot_width=400, plot_height=400, x_range=(-0.01,1), y_range=(0,1.02) ) ##add line p.line( fpr, tpr, ) p.xaxis.axis_label = 'FPR' p.yaxis.axis_label = 'TPR' show(p) print('AUCスコア:',roc_auc_score(df.actual_label, df.prob))
AUCスコア: 1.0
まとめ
- ROC曲線が実際にどう書かれるのかstep by stepで確認しました。
- モデルが悪くなった場合にAUCの減少を確かめました。
- 勘違いを防ぐために注意すべき具体例を示しました。
まとめがまとめになってない!!文章を書くのって難しいですね
次回は、今回のROC曲線に関連してPR曲線をまとめたいと思います。
追記
jupyter notebookをなんかの形式に変換してそのままブログに投稿できる方法を知っている方がいたら教えてください。
bokehで図を作っているのですが、htmlに保存すると違うパソコンでもちゃんと図が機能するのにはてなブログにhtmlをコピペすると図が機能しなくなるんですよね…notebookのレイアウトも崩れるし…
JavaScriptベースの動的な図を埋め込む方法も知ってる方がいたら教えてください。