予測確率の較正(calibration)入門
予測確率の較正(calibration)入門
LightGBMのpredict_probaが「0.2」と出力したとき、その馬は本当に20%の確率で勝つのでしょうか。実は、機械学習モデルが出力する確率は、そのままでは実際の頻度とズレていることが少なくありません。この記事では、ズレを可視化するreliability curveの描き方と、ズレを補正する「較正(calibration)」の手法を解説します。
モデルの出力する「確率」は本当の確率とは限らない
二値分類モデルは「勝つ確率」らしき数値を出力しますが、この数値が実際の勝率と一致する保証はどこにもありません。
- 勾配ブースティングは損失関数を最小化するように学習しますが、early stoppingや正則化の影響で、出力が0.5寄りに圧縮されたり、逆に極端に振れたりします
- 勝ち馬は1レースに1頭しかいないため、正例が少ない不均衡データになりやすく、確率の絶対値が歪みがちです
- 複数モデルの平均(アンサンブル)は、出力をなだらかにする方向に働きます
その結果、「予測確率0.2前後の馬を集めたら、実際には12%しか勝っていなかった」ということが普通に起こります。どの馬が強いかの順位付けには影響しなくても、確率の絶対値を使う場面では深刻な問題になります。
reliability curveでズレを可視化する
予測確率をbucketに区切り、各bucket内の「予測確率の平均」と「実際の勝率」を比較した表(やグラフ)がreliability curve(信頼度曲線)です。
import numpy as np
import pandas as pd
def reliability_table(y_true, y_pred, n_bins=10):
df = pd.DataFrame({'pred': y_pred, 'actual': y_true})
df['bucket'] = pd.cut(df['pred'], bins=np.linspace(0, 1, n_bins + 1))
return df.groupby('bucket', observed=True).agg(
mean_pred=('pred', 'mean'), # bucket内の予測確率の平均
actual_rate=('actual', 'mean'), # bucket内の実際の勝率
count=('actual', 'size'),
)
# y_test: 実際に勝ったか(0/1)、pred: モデルの予測勝率
print(reliability_table(y_test, pred))
mean_predとactual_rateが全bucketで一致していれば、グラフ上は対角線に乗る理想的な状態です。matplotlibで散布図にすると、どの確率帯でどちらにズレているかが一目で分かります。
ズレの典型パターン
| パターン | 症状 | よくある原因 |
|---|---|---|
| 過信(overconfidence) | 高確率帯で実勝率が予測を下回る | 過学習、特徴量のリーク |
| 過小信(underconfidence) | 予測が0.5寄りに圧縮され、両端のズレが大きい | 強い正則化、アンサンブル平均 |
| シグモイド状の歪み | 低確率帯と高確率帯で逆方向にズレる | 不均衡データ、損失関数の特性 |
較正手法1: Platt scaling
Platt scalingは、モデルの出力をロジット(対数オッズ)に変換し、ロジスティック回帰で「正しい確率」に写像し直す方法です。
from scipy.special import logit
from sklearn.linear_model import LogisticRegression
# 検証データ(モデルの学習に使っていないデータ)でfit
z_valid = logit(np.clip(pred_valid, 1e-6, 1 - 1e-6)).reshape(-1, 1)
platt = LogisticRegression(C=1e10) # 正則化は実質オフ
platt.fit(z_valid, y_valid)
# テストデータに適用
z_test = logit(np.clip(pred_test, 1e-6, 1 - 1e-6)).reshape(-1, 1)
pred_platt = platt.predict_proba(z_test)[:, 1]
パラメータが傾きと切片の2つだけなので、データが少なくても安定します。一方で、シグモイド状のズレしか補正できないため、複雑な歪みには対応できません。
較正手法2: isotonic regression
isotonic regression(単調回帰)は、「予測確率が大きいほど実勝率も大きい」という単調性だけを仮定して、任意の形のズレを補正するノンパラメトリックな手法です。使い方はPlattと同様で、sklearnのIsotonicRegression(out_of_bounds='clip')を検証データでfitし、テストデータにpredictを適用するだけです(具体的なコードは次節のcross-fit例で示します)。
柔軟な分、データが少ないと階段状に過適合します。目安として、fit用のデータが数千件以上あるならisotonic、少ないならPlattが無難な使い分けです。
cross-fitの重要性: 同じデータでfitすると楽観化する
較正でいちばんやりがちな失敗が、モデルの学習に使ったデータで較正器をfitしてしまうことです。学習データ上の予測は過学習で実態より「当たって見える」ため、それに合わせた較正器は本番でズレます。逆に、較正の評価まで同じデータでやると、見かけだけ綺麗なreliability curveが出てしまいます。
原則は「モデル学習・較正器のfit・評価で、それぞれ別のデータを使う」です。データを無駄にしたくない場合は、cross-fit(fold分割して、fitに使ったfold以外へ適用)が定番です。
from sklearn.isotonic import IsotonicRegression
from sklearn.model_selection import KFold
def cross_fit_isotonic(pred, y, n_splits=5):
"""fit用と適用用のfoldを分けて較正済み確率を得る"""
calibrated = np.zeros_like(pred, dtype=float)
kf = KFold(n_splits=n_splits, shuffle=False)
for fit_idx, apply_idx in kf.split(pred):
iso = IsotonicRegression(out_of_bounds='clip')
iso.fit(pred[fit_idx], y[fit_idx])
calibrated[apply_idx] = iso.predict(pred[apply_idx])
return calibrated
どの行も「自分以外のfoldでfitした較正器」で変換されるため、楽観バイアスを避けられます。競馬データは時系列なので、foldも時間順に切る(shuffleしない)のが安全です。
較正は順位を変えない
Platt scalingもisotonic regressionも単調変換です。予測確率の大小関係は保たれるため、「このレースでどの馬が1位か」という順位は較正の前後で一切変わりません。AUCも変わりません。
つまり役割分担はこうなります。
- 順位(どの馬が相対的に強いか): モデル本体の仕事。較正では改善しない
- 確率の絶対値(それが何%なのか): 較正の仕事。loglossやBrier scoreが改善する
「較正したのに的中率が上がらない」のは正常です。較正は当てる力を上げる技術ではなく、確率の数値を信用できるようにする技術です。
なぜ較正が必要か: 期待値計算の前提になる
確率の絶対値が効いてくる代表例が期待値(EV)の計算です。単勝の期待値は EV = 予測勝率 × 単勝オッズ で定義され、予測確率がそのまま掛け算に入ります。モデルが0.2と言っているのに実際の頻度が0.12なら、期待値は1.67倍も過大に見積もられ、確率帯によってズレ方が違えば「どの馬の期待値が高いか」の比較自体が崩れます。期待値ベースで予測を解釈したいなら、較正済みの確率を使うことが前提条件です。
評価指標: Brier scoreとECE
較正の良し悪しは目視だけでなく数値でも測れます。
def brier_score(y_true, y_pred):
"""確率予測の二乗誤差。小さいほど良い"""
return np.mean((y_pred - y_true) ** 2)
def ece(y_true, y_pred, n_bins=10):
"""Expected Calibration Error。bucketごとのズレの加重平均"""
bins = np.linspace(0, 1, n_bins + 1)
idx = np.digitize(y_pred, bins[1:-1])
total = len(y_pred)
err = 0.0
for b in range(n_bins):
mask = idx == b
if mask.sum() == 0:
continue
gap = abs(y_pred[mask].mean() - y_true[mask].mean())
err += (mask.sum() / total) * gap
return err
print(f'Brier: {brier_score(y_test, pred):.4f} -> {brier_score(y_test, pred_iso):.4f}')
print(f'ECE : {ece(y_test, pred):.4f} -> {ece(y_test, pred_iso):.4f}')
- Brier score: 判別力と較正の両方を含む総合指標
- ECE: 較正のズレだけを取り出した指標。reliability curveの数値版
較正の前後で両方を比較し、改善していることを確認してから使うのが基本の流れです。
まとめ
| ポイント | 内容 |
|---|---|
| 出力は鵜呑みにしない | predict_probaの数値は実際の頻度とズレうる |
| まず可視化 | reliability curveでどの確率帯がどうズレているか見る |
| 手法の使い分け | データが少なければPlatt、多ければisotonic |
| cross-fit必須 | fit用と適用用のデータを分けないと楽観化する |
| 順位は不変 | 較正は単調変換。順位はモデル、確率値は較正の仕事 |
| 動機 | 期待値計算には較正済み確率が前提 |
確率を「順位を付けるためのスコア」としてだけ使うなら較正は不要ですが、確率の数値そのものを意思決定に使うなら避けて通れない工程です。
JvLink To Importer で構築したデータベースがあれば、過去レースの結果と突き合わせてreliability curveをすぐに描けます。
この記事をシェア