オッズを確率に変換するときの favorite-longshot bias
オッズを確率に変換するときの favorite-longshot bias
「1/オッズを和で割って正規化すれば、市場が予測する勝率になる」— オッズを確率として扱うときの基本ですが、こうして作った implied probability には系統的なズレがあることが知られています。世界中の賭け市場で繰り返し報告されてきた favorite-longshot bias です。この記事では、バイアスの中身、自分のデータベースで確認する方法、そして補正の選択肢を紹介します。
favorite-longshot bias とは
favorite-longshot bias は、競馬に限らず多くの賭け市場で観測されてきた現象で、ざっくり言うと次の傾向です。
- 大穴 (longshot): implied probability が実際の勝率より高めに出る(市場が過大評価)
- 本命 (favorite): implied probability が実際の勝率より低めに出る(市場が過小評価)
たとえば単勝 100 倍の馬の implied probability が 1% 弱でも、実際の勝率はそれよりずっと低い、という形で現れます。原因については「人は小さい確率を過大に感じる」「夢を買う票が人気薄に集まる」など諸説ありますが、ここで重要なのは1/オッズをそのまま確率として使うと、オッズ帯によって較正がズレているという事実です。
自分の DB で確認する
JV-Link で構築したデータベースがあれば、この現象は自分で確認できます。odds1_tansho と umagoto_race_joho を race_code + 馬番で join し、オッズ帯ごとに implied と実勝率を比べます。
SELECT
o.race_code,
o.umaban,
o.odds::numeric / 10 AS odds, -- オッズは 10 倍値で格納
(u.kakutei_chakujun = '01')::int AS won
FROM odds1_tansho o
JOIN umagoto_race_joho u
ON u.race_code = o.race_code
AND u.umaban = o.umaban
WHERE u.kakutei_chakujun BETWEEN '01' AND '18' -- 完走馬のみ
AND o.odds::numeric > 0;
オッズが 10 倍値で格納されている点(5.2 倍なら 52)と、取消・除外を除くフィルタを忘れないでください(カラム名は取込スキーマに合わせて読み替えてください。確定オッズなら umagoto_race_joho.tansho_odds でも同じ分析ができます)。
取得したら pandas で、レースごとに正規化した implied probability を作り、オッズ帯 bucket で実勝率と比較します。
import pandas as pd
df = pd.read_sql(sql, conn)
# レース内で 1/odds を正規化 (比例配分の de-vig)
df['inv'] = 1.0 / df['odds']
df['implied'] = df['inv'] / df.groupby('race_code')['inv'].transform('sum')
# オッズ帯で bucket を切って比較
bins = [1, 2, 3, 5, 10, 20, 50, 100, 1000]
df['band'] = pd.cut(df['odds'], bins)
summary = df.groupby('band', observed=True).agg(
n=('won', 'size'),
implied=('implied', 'mean'),
actual=('won', 'mean'),
)
summary['ratio'] = summary['actual'] / summary['implied']
print(summary)
ratio が 1 より小さいオッズ帯は「implied が実勝率より高い = 過大評価」です。十分なレース数(数年分)で集計すると、高オッズ帯ほど ratio が 1 を割り、低オッズ帯では 1 を超える傾向が見えるはずです。これが favorite-longshot bias の実物です。
較正カーブを可視化する
表の数字でも判りますが、implied と実勝率を散布図にすると一目瞭然です。対角線(implied = 実勝率)からのズレがバイアスの大きさです。
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 6))
ax.plot([1e-3, 1], [1e-3, 1], 'k--', alpha=0.5, label='完全に較正')
ax.scatter(summary['implied'], summary['actual'], s=40)
ax.set_xscale('log') # 大穴ゾーンは対数軸が見やすい
ax.set_yscale('log')
ax.set_xlabel('implied probability (1/odds 正規化)')
ax.set_ylabel('実勝率')
ax.legend()
plt.tight_layout()
plt.savefig('fl_bias.png')
両軸を対数にするのがポイントです。線形軸だと本命ゾーンに点が固まってしまい、肝心の大穴ゾーンのズレ(点が対角線の下に沈む)が見えません。年代別や芝・ダート別に色分けして重ねると、バイアスの安定性も確認できます。
補正方法: 比例 de-vig と power 法
1/オッズの和は控除率のぶん 1 を超えているので、確率にするには何らかの方法で「余剰分」を取り除く必要があります。代表的な 2 つの方法は、余剰の配り方が違います。
比例 de-vig: inv / inv.sum()。全馬から同じ比率で削るだけなので、オッズ帯ごとの較正ズレ(FL bias)はそのまま残ります。
power 法: 各馬の 1/オッズを k 乗してから和が 1 になるように k を解きます。
import numpy as np
from scipy.optimize import brentq
def power_devig(odds: np.ndarray) -> np.ndarray:
"""Σ (1/odds)^k = 1 となる k を解いて確率化する"""
inv = 1.0 / odds
k = brentq(lambda k: (inv ** k).sum() - 1.0, 0.5, 3.0)
return inv ** k
# レースごとに適用
df['p_power'] = (
df.groupby('race_code')['odds']
.transform(lambda s: power_devig(s.to_numpy()))
)
1/オッズの和が 1 を超えている通常のレースでは k > 1 に解が出ます。k 乗すると小さい値ほど相対的に大きく縮むため、大穴の確率が削られ、本命の確率が押し上げられます。つまり power 法は、余剰を取り除くと同時に FL bias と同じ方向の補正が自動的にかかる手法です。
どちらが良いかは目的次第ですが、前節の bucket 比較を p_power でやり直して、ratio が 1 に近づくかどうかで効果を確認できます。さらに踏み込むなら、オッズ帯ごとの実勝率に回帰(ロジスティック回帰や isotonic regression)を当てて較正カーブ自体を推定する方法もあります。
用途: モデルの確率と市場の確率を比べるとき
この補正が効いてくる典型的な場面は、自作モデルの予測確率と市場の確率を比較・ブレンドするときです。
- 「モデルと市場で評価が大きく食い違う馬」を探す分析では、市場側の確率が較正されていないと、食い違いの大きさ自体が歪みます。大穴ゾーンでは比例 de-vig の implied が実態より高めなので、「モデルが市場より弱気」に見える方向のアーティファクトが出ます
- モデル確率と市場確率の加重平均(ブレンド)を作る場合も、片方だけ較正ズレを抱えたまま混ぜると、ブレンド比の最適化が引きずられます
比較や合成の前に、市場側を bucket 検証 → 必要なら power 法などで較正という順序を踏んでおくと、分析の解釈がクリーンになります。
なお「大穴が過大評価されているなら逆を張れば良いのでは」と考えたくなりますが、オッズには控除率が乗っているため、バイアスの存在がそのまま単純な戦略に直結するわけではありません。ここではあくまで確率としての較正の問題として扱うのが安全です。
まとめ
1/オッズの正規化で作った市場の確率には、大穴を高めに・本命を低めに見積もる favorite-longshot bias が乗っています。自分の DB でオッズ帯 bucket の implied と実勝率を突き合わせれば現象を確認でき、power 法のような補正で較正を改善できます。モデルと市場を比較・ブレンドする分析では、まず市場側の較正を整えることが前提作業になります。
この記事をシェア