Probability calibration

分類モデルの確率補正

In [ ]:
# default_exp proba_calib

問題提起

スパムメールやクレジット詐欺を見分けるタスクなどを学習した 分類モデルが出力する予測値は通常 (0, 1) の範囲内に収まり、 予測確率とも呼ばれるので、 うっかり正例であるクラス確率だと 思い込みかねません。

実運用では、閾値を設けて、予測値がその閾値を超えるかどうかで判断を下したりします。 予測値がクラス確率であるかどうかによって、閾値の意味も大きく変わってきます。

スパムメール分類モデルの場合、真のクラス確率を学習したのなら、 90% という予測値が出力されたような100通のメールのうち、 90通が本当にスパムメールだろうと期待されます。 本当のスパムメールの数が90を下回ったらモデルの自信過剰、 上回ったら自信不足と言えます。

モデルの予測値が真の確率とどのくらい乖離しているのか図り、 予測値を真の確率に近づける補正方法を実験してみましょう。

In [ ]:
# exporti
import matplotlib.pyplot as plt
import japanize_matplotlib
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from sklearn.metrics import brier_score_loss, roc_auc_score

ライブラリの用意

sklearn を使って信頼性曲線を書いたり確率補正します。 補正前のベースモデルとしては LightGBM を使います。 図形は Matplotlib と Plotly で作ります。

In [ ]:
import numpy as np
import pandas as pd
import plotly.express as px
from lightgbm import LGBMClassifier
from sklearn import datasets
from sklearn.datasets import make_circles
from sklearn.model_selection import train_test_split

サンプルデータ

簡単な実験データとして、 sklearnを使って、 円を指定し、その内側を正例として、外側を負例とします。 ノイズを投入したり、正例の割合を減らしてデータを不均衡化しています。

実験データを学習用・補正用・試験用に分けました。

In [ ]:
n_samples = 1_000_000
pos_rate = 0.1
radius = 0.3

X, y = make_circles(n_samples=n_samples, factor=radius, noise=0.2)
df = pd.DataFrame(X, columns=["x1", "x2"])
df["y"] = pd.Series(y).astype("category")
df["r"] = df.x1 ** 2 + df.x2 ** 2
print("ノイズ率(ラベルが反転)", 1 - sum((df.r <= radius) == (df.y == 1)) / n_samples)

# ランダムに正例をドロップし、インバランスクラス化
df = pd.concat(
    [
        df[(df.y == 0) | (df.r >= radius)],
        df[(df.y == 1) & (df.r < radius)].sample(int(pos_rate * sum(df.y))),
    ]
)
X = df[["x1", "x2"]]
y = df.y

print("正例率", sum(y) / len(y))

# 学習・補正・テストデータ分割
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42
)
X_train_, X_calib, y_train_, y_calib = train_test_split(
    X_train,
    y_train,
    test_size=4000 / len(X_train),
    random_state=2020,
)
print("学習・補正・テスト用データ比率", np.array([len(X_train_), len(X_calib), len(X_test)]) / len(X))
ノイズ率(ラベルが反転) 0.08449600000000002
正例率 0.20687921246278282
学習・補正・テスト用データ比率 [0.74365384 0.00634497 0.25000119]
In [ ]:
# exports
def plot_calibration_curve(named_classifiers, X_test, y_test):
    fig = plt.figure(figsize=(10, 10))
    ax1 = plt.subplot2grid((3, 1), (0, 0), rowspan=2)
    ax2 = plt.subplot2grid((3, 1), (2, 0))

    ax1.plot([0, 1], [0, 1], "k:", label="完全な補正")
    for name, clf in named_classifiers.items():
        prob_pos = clf.predict_proba(X_test)[:, 1]
        auc = roc_auc_score(y_test, prob_pos)
        brier = brier_score_loss(y_test, prob_pos)
        print("%s:" % name)
        print("\tAUC  : %1.3f" % auc)
        print("\tBrier: %1.3f" % (brier))
        print()

        fraction_of_positives, mean_predicted_value = calibration_curve(
            y_test,
            prob_pos,
            n_bins=10,
        )

        ax1.plot(
            mean_predicted_value,
            fraction_of_positives,
            "s-",
            label="%s (%1.3f)" % (name, brier),
        )

        ax2.hist(prob_pos, range=(0, 1), bins=10, label=name, histtype="step", lw=2)

    ax1.set_ylabel("正例の比率")
    ax1.set_ylim([-0.05, 1.05])
    ax1.legend(loc="lower right")
    ax1.set_title("信頼性曲線")

    ax2.set_xlabel("予測値の平均")
    ax2.set_ylabel("サンプル数")
    ax2.legend(loc="upper center", ncol=2)

    plt.tight_layout()
In [ ]:
fig = px.scatter(df.sample(1_000), x="x1", y="x2", color="y", hover_data=["r"])
boundary = (1 + radius) / 2
fig.add_shape(
    type="circle",
    xref="x",
    yref="y",
    x0=-boundary,
    y0=-boundary,
    x1=boundary,
    y1=boundary,
    line_color="Yellow",
    line_width=3,
)
fig.update_layout(width=500, height=500)
fig.show()