JvLink To Importer

レース内で勝率の和が1にならない問題

レース内で勝率の和が1にならない問題

馬単位の二値分類(この馬は勝つか)で予測モデルを作ると、必ずぶつかる問題があります。1レース16頭の予測勝率を足しても1.0にならないのです。あるレースでは合計1.4、別のレースでは0.6になったりします。この記事では、なぜこうなるのか、なぜ放置するとまずいのか、そしてレース内正規化の3つの対処法を解説します。


なぜ和が1にならないのか

二値分類モデルは、1頭1頭を独立したサンプルとして学習します。モデルは「このレースには16頭出ていて、勝つのはちょうど1頭」というレース構造をまったく知りません

そのため、

  • 強い馬が揃ったレースでは各馬の予測勝率が軒並み高くなり、合計が1を大きく超える
  • 抜けた馬のいない混戦では全頭の予測が低く、合計が1を下回る
  • 18頭立てと8頭立てでは、頭数が多いほど合計が大きくなりやすい

という系統的な偏りが生まれます。1頭ずつ見れば妥当でも、レース単位で見ると確率として整合していない状態です。


なぜ問題なのか

「順位はどうせレース内で付けるから合計はどうでもいい」と思いがちですが、確率の数値を使う場面で次の歪みが出ます。

  • レース間で確率の意味が揃わない: 合計1.4のレースでの予測0.2と、合計0.7のレースでの予測0.2は、実質的な意味が違います。前者は相対的に「並みの評価」、後者は「抜けた評価」です
  • 期待値計算が歪む: 期待値(予測勝率×オッズ)は確率の絶対値をそのまま使うため、合計が1を超えるレースでは全頭の期待値が一律に過大評価されます
  • レースをまたいだ比較ができない: 「予測勝率0.25以上の馬」のような条件で全レースから抽出すると、頭数の少ないレースや混戦レースが不当に除外されたり過剰に拾われたりします

「ちょうど1頭が勝つ」という制約をモデル出力に反映させる工程が、レース内正規化です。


対処1: 単純正規化

最も簡単なのは、レース内の合計で割る方法です。

# race_codeごとに予測値の合計で割り、Σ=1を保証する
df['win_prob'] = df.groupby('race_code')['pred'].transform(
lambda s: s / s.sum()
)

# 確認: 全レースで合計が1になっている
print(df.groupby('race_code')['win_prob'].sum().describe())

1行で書けて、合計1は構造的に保証されます。ただし限界もあります。

  • レース内の全馬を同じ倍率で割るため、合計が1.4のレースでは全頭が一律に0.71倍されます。本来は「過大評価されている度合い」が馬によって違うかもしれません
  • 元の予測が較正済みでも、割り算の倍率がレースごとに違うため、正規化後の確率の較正は崩れえます
  • 予測の「鋭さ」(本命に確率が集中しているか、割れているか)は元のモデル出力のまま固定されます

それでも「何もしない」よりは大幅にマシで、最初の一手として十分実用的です。


対処2: 温度付きsoftmax

単純正規化の「鋭さを調整できない」問題に対処するのが温度付きsoftmaxです。予測確率をロジットに変換し、温度Tで割ってからレース内でsoftmaxを取ります。

import numpy as np
from scipy.special import logit

def temperature_softmax(df, T):
"""レース内で温度付きsoftmaxを取り、Σ=1の勝率に変換する"""
z = logit(df['pred'].clip(1e-6, 1 - 1e-6)) / T
# レース内最大値を引いてオーバーフローを防ぐ
z = z - z.groupby(df['race_code']).transform('max')
e = np.exp(z)
return e / e.groupby(df['race_code']).transform('sum')

Tの意味は直感的です。

  • T > 1: 分布が平らになる(本命の確率を下げ、人気薄に分配)。過信気味のモデル向け
  • T < 1: 分布が尖る(本命に確率を集中)。過小信気味のモデル向け
  • T = 1: 通常のsoftmax

Tはハイパーパラメータなので、検証データのloglossが最小になる値を選びます

from sklearn.metrics import log_loss

best_T, best_ll = None, np.inf
for T in np.arange(0.5, 3.01, 0.1):
p = temperature_softmax(valid_df, T)
ll = log_loss(valid_df['won'], p, labels=[0, 1])
if ll < best_ll:
best_T, best_ll = T, ll

print(f'best T = {best_T:.1f} (logloss = {best_ll:.4f})')

ここでも、Tを選ぶデータと最終評価のデータは分けてください。テストデータでTを選ぶと評価が楽観化します。


対処3(発展): Plackett-Luce型のレース内モデル

単純正規化も温度付きsoftmaxも「馬単位で学習したモデルの出力を後処理で整える」アプローチです。これに対して、最初からレースを単位としてモデル化する考え方があります。

代表がPlackett-Luceモデルです。発想はこうです。

  • 各馬に「強さスコア」を割り当てる
  • レースを「出走馬の中から1着が選ばれる」多項選択としてモデル化する
  • 1着確率は、レース内の全馬のスコアに対するsoftmaxで定義される

この定式化では、Σ=1が後処理ではなく構造として保証されます。学習時の損失も「レース内で実際の勝ち馬に高い確率を割り当てたか」というレース単位の尤度になるため、二値分類が知らなかった「同じレースのライバルとの相対関係」が学習に直接入ります。さらに2着、3着と順に選択を繰り返す形に拡張すると、着順全体の尤度も扱えます。

実装はランキング学習用のライブラリや勾配ブースティングのランキング目的関数などいくつか選択肢がありますが、まずは「そういう構造のモデルがある」と知っておくだけでも、後処理アプローチとの違いを意識できます。


検証の型: 正規化前後でreliability curveを比較する

正規化が改善になっているかは、reliability curve(予測確率bucketごとの実勝率)で確認するのが定番です。

def reliability_table(y_true, y_pred, n_bins=10):
tmp = pd.DataFrame({'pred': y_pred, 'actual': y_true})
tmp['bucket'] = pd.cut(tmp['pred'], bins=np.linspace(0, 1, n_bins + 1))
return tmp.groupby('bucket', observed=True).agg(
mean_pred=('pred', 'mean'),
actual_rate=('actual', 'mean'),
count=('actual', 'size'),
)

print(reliability_table(test_df['won'], test_df['pred']))      # 正規化前
print(reliability_table(test_df['won'], test_df['win_prob']))  # 正規化後

正規化前は「予測0.3の帯の実勝率が0.2」のようなズレが見えるはずです。正規化後にmean_predとactual_rateが近づいていれば改善、bucketによっては悪化する場合もあるので、loglossやBrier scoreと合わせて総合的に判断します。


まとめ

対処法 Σ=1保証 特徴
単純正規化 あり 1行で書ける。鋭さは調整できず歪みが残る
温度付きsoftmax あり Tで分布の鋭さを較正できる。Tは検証データで選ぶ
Plackett-Luce型 構造的に保証 レースを選択問題としてモデル化する発展形

二値分類で確率を出すだけだと、レースという構造が抜け落ちます。まず単純正規化でΣ=1を作り、reliability curveで確認しながら温度付きsoftmaxに進む、という順番がおすすめです。

JvLink To Importer で構築したデータベースなら、race_code単位のgroupby処理だけでこれらの正規化をすぐに試せます。

この記事をシェア

Post