1 . モチベーション
競馬好きの友人から競馬AIの話がでた。競馬などの公営ギャンブルは、順序を当てる組み合わせでオッズが決まる。競馬はレース毎に出走馬頭数も変わるが、オッズは3位以内の馬の組み合わせ(単体の順位も含む)によって異なるが、投票するのはこの組み合わせの中から選ぶことになる。
最大18頭で、少ないと10頭程度になることもあり、それでも3位以内の馬の組み合わせを当てるというところは同じなので、随分と当たりやすさも変わる。
さてAIとなる機械学習の方法だが、既存の手法は目的変数を3位以内を1、それ以外を0にする2値分類によるlightGBMなどを用いた予測確率を求めて、高い順に並べる手法でされている事例が多い。
加えて、広告やレコメンドなどの分野で使われるランク学習も使われているらしい。ランク学習は今まであまり馴染みがなく謎であったので、この機会に学んで見よう![]()
2. DATAの準備
競馬のデータには、シミュレーションデータを作成して用いる。
以下はシミュレーションの条件
- 馬の個々の能力値は、正規分布平均0、標準偏差0.5で決定する
- 馬のレース毎のばらつきを示す勝負ムラは、ガンマ分布から決定する
- レースごとに、馬の能力値を平均、個々の馬に設定した勝負ムラを標準偏差に従う正規分布の関数から、パフォーマンス・ファクター(pf値)を算出する
- レースは個々の馬のpf値の大きい順に順位が決まる
環境
Ubuntu 24.04.3 LTS
AMD Ryzen™ 5 5625U with Radeon™ Graphics × 12
memory 16GiB
python3.12.4
必要なライブラリの準備
import numpy as np
import pandas as pd
from scipy.stats import poisson
from scipy.stats import norm
from scipy.stats import gamma
from catboost import CatBoostRanker, Pool
import lightgbm as lgb
import random
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix
from sklearn.metrics import accuracy_score
from sklearn.metrics import precision_score
from sklearn.metrics import ndcg_score
シミュレーションの条件
馬数:300
レース数:1000
1レース当たりの出頭馬数:10〜18頭
n_horse = 300
n_race = 1000
# 馬の強さを作る
sd_mu = 0.5
mu_str = norm.rvs(size=n_horse, loc=0, scale=sd_mu)
# 勝負ムラを作る
sd_str = gamma.rvs(a=10, scale=1/10, size=n_horse)/2
1レース当たりの出頭馬数は平均14のポアソン分布に従う
# raceに出場する馬数を作成 10から18までの乱数を生成
horce_total=[]
for i in range(n_race):
n=poisson.rvs(size=1,mu=14)[0]
# 10以下または18より大きい場合は再設定
while n <= 10 or n > 18:
n = n=poisson.rvs(size=1,mu=14)[0]
horce_total.append(n)
出走する馬IDの組み合わせを作成する
race_results = []
for race_id, n_horses in enumerate(horce_total):
# 非復元抽出で馬IDを選択
selected_horses = random.sample(range(1, n_horse + 1), n_horses)
# 馬id毎にデータを再構築
for horse_id in selected_horses:
race_results.append([race_id + 1, horse_id, n_horses])
df = pd.DataFrame(race_results, columns=['race_id', 'horse_id', 'n_horse'])
300頭の馬が全レースで出馬しているか?
len(df['horse_id'].unique())
#> 300
すべての馬が出走しているのを確認。
馬の強さと勝負ムラの能力値を馬IDと連結し、パフォーマンスファクター値を作成する。
# 馬の強さの列を初期化
hstr=[]
hpf=[]
hsd=[]
for i, row in df.iterrows():
horse_index = int(row['horse_id'])-1 #馬IDは1から始まるため、調整する
hstr.append(mu_str[horse_index])
#パフォーマンス・ファクター(pf値)の作成
pf=norm.rvs(size=1,loc=mu_str[horse_index],scale=sd_str[horse_index])[0]
hpf.append(pf)
hsd.append(sd_str[horse_index])
df['horse_str']=hstr
df['horse_sd']=hsd
df['horse_pf']=hpf
pf値によるレース毎の順位の作成。
降順でランク付け(1位が最高パフォーマンス)。
df_result= pd.DataFrame()
for i in range(n_race+1):
df1 = df[df['race_id']==i].copy()
rank = df1['horse_pf'].rank(ascending=False)
df1['rank'] = rank
df_result = pd.concat([df_result,df1])
レース結果と馬能力値との関係を見てみる。
plt.figure(figsize=(10,6))
plt.scatter(df_result['rank'], df_result['horse_str'], alpha=0.2)
plt.ylabel('Horse Strength (mu_str)')
plt.xlabel('Rank')
plt.title('Relationship between Race Rank and Horse Strength')
# rankが3位以内の時は赤色でハイライト
plt.scatter(df_result[df_result['rank']<4]['rank'],
df_result[df_result['rank']<4]['horse_str'],
color='red', alpha=0.2, label='1st-3st Place')
plt.legend()
plt.grid(True)

馬の強さは概ね大きくなるほど、順位をあげる傾向があるが、勝負ムラによるばらつきが発生している。
3. 目的及び訓練・検証・テストデータの分割
3.1 目的
レース結果を3位以内を1、それ以外を0の2値分類問題とする。
df_result['rank2']=df_result['rank'].apply(lambda x: 1 if x < 4 else 0)
最終的にレースごとに3位以内の2値による予測順位の混同行列を作成し、目的関数として、Accuracy(精度)とPrecision(適合率)とする。
3.2 データの分割
レース単位でデータを分割する。
- 訓練データ:1から700レースまで
- 検証データ:701から850レースまで
- テストデータ:851から1000レースまで
trainid = range(1, 701)
validid = range(701, 851)
testid = range(851, 1001)
4 Catboostによるランク学習
ランク学習では各データがどのグループに属するかを指定する必要がある。CatBoostでは各データで、どのグループに属するかを示すgroup_idをリストの形式で与える必要がある。
そのgroup_id のデータはgrouu_idでソートされていることが必須であり、本データの場合は、race_idがそのままgroup_idになる。
4.1 訓練データ
# 元のデータから該当するrace_idを持つ行をフィルタリング
X_train = df_result[df_result['race_id'].isin(trainid)]
y_train = X_train['rank']
y2_train = X_train['rank2']
X_train1 = X_train[['race_id','horse_id']] #grop_id(race_id)を含む必要がある。
queries_train1 =X_train1['race_id'].values
4.2 検証データ
X_valid = df_result[df_result['race_id'].isin(validid)]
y_valid = X_valid['rank']
y2_valid = X_valid['rank2']
X_valid1 = X_valid[['race_id','horse_id']]
queries_valid1 =X_valid['race_id'].values
4.3 テストデータ
X_test = df_result[df_result['race_id'].isin(testid)]
y_test = X_test['rank']
y2_test = X_test['rank2']
X_test1 = X_test[['race_id','horse_id']]
queries_test1 =X_test['race_id'].values
4.4 ランク学習に用いる予測精度を評価する指標
- NDCG (Normalized Documented Cumulative Gain)
データのラベルが、4,3,2,1などの多値を取る場合の評価指標である。
NDCGは、生成したランキングが真の並び順にどれだけ適合しているかを評価する。0から1の範囲でスケーリングされ、1に近いほど良いランキングとなる。
4.5 ランク学習に用いる損失関数
- YetiRank(イエティランク)
これは、Googleの検索ランキングシステムの中核をなす重要なアルゴリズムの一つであり、従来の単純なリンクベースの評価(PageRankなど)を超えて、機械学習とユーザー行動のデータを組み合わせて、検索結果の順位を決定するために使用されてきたアルゴリズムである。YetiRankは、ランキング学習(Learning to Rank: LTR)と呼ばれる機械学習の手法を用いる。
LTRとは:
特定の評価指標(NDCG, MAPなど)を最大化するように、数多くの特徴量を組み合わせて、最適な検索結果の順序(ランキング)を学習する技術である。またYetiRankは主にリストワイズによる計算アプローチになる。
| 特徴 | ペアワイズ (Pairwise) | リストワイズ (Listwise) |
|---|---|---|
| 学習単位 | 2つのドキュメントのペア | クエリに対するランキングリスト全体 |
| 損失関数 | 順序が逆転したペアの数を最小化 | ランキング指標全体(NDCGなど)を直接最適化 |
| 目標 | 「ドキュメントAはドキュメントBより優れている」を学習 | 「理想的なランキング順序」を学習 |
| 計算複雑性 | 比較的低い (ペアの数) | 高い (リスト全体の状態を考慮) |
| 代表的な手法 | RankSVM, RankNet, LambdaRank | ListNet, ListMLE, SoftRank |
注意:評価指標であるNDCGは、非連続値をとるため(微分不可能なため)、直接の損失関数とすることができない。
- QuerySoftMax
通常のSoftMax関数を特定のクエリに属するドキュメントのリスト全体に適用したものである。QuerySoftMaxは、リストワイズな手法であるListNetの損失関数で中心的な役割を果たす。ListNetでは、理想的なランキング(真の関連性スコア $y_i$ に基づくランキング)も、同様にSoftMaxを用いて確率分布 $P$ に変換される。
特徴と利点
- 順序の考慮: スコアを確率分布に変換することで、リスト全体の相対的な順序と関連性の高低を考慮に入れた学習が可能
- 確率的解釈: モデルの出力(スコア)が、確率的な意味合いを持つようになり、損失関数の設計が容易。
- ランキングの上位重視: SoftMaxの性質上、スコアのわずかな差でも確率には大きな差が出るため、高いスコア(つまり上位)の結果の予測を正確にすることに重点を置く傾向がある。
- LambdaRank(ラムダランク)
Googleで開発され、勾配ブースティングに基づく画期的なアルゴリズムである。これは、従来のランキング手法が抱えていた「評価指標(NDCGなど)を直接最適化できない」という課題を解決するために考案される。
| 特徴 | 説明 |
|---|---|
| NDCGの直接最適化 | NDCGの変化量に基づく勾配($\lambda$)を使用することで、NDCGのような非連続な指標の最適化を実質的に達成します。 |
| 上位重視 | NDCGやMAPはランキングの上位の結果をより重視するため、$\lambda$ も自然と上位の結果の順位が入れ替わったときに大きな値となり、モデルの学習が上位の改善に集中します。 |
| 効率性 | 基礎となるRankNetの損失関数と異なり、すべてのドキュメントペアを明示的に比較する必要がないため、大規模データセットでも効率的に学習できます。 |
| 拡張性 | NDCGだけでなく、その他のランキング評価指標(ERR, MAPなど)に対しても、その指標の変化量を計算できれば適用可能です。 |
4.6 データの定義
Catboostでは、Pool関数を使ってdata, label, group_idを定義する。
train = Pool(
data=X_train1,
label=y_train,
group_id=queries_train1
)
valid = Pool(
data=X_valid1,
label=y_valid,
group_id=queries_valid1
)
4.7 パラメータの設定
parameters = {
'iterations': 2000,
'eval_metric':'NDCG',
'learning_rate':0.05,
'depth':5,
'verbose': False,
'random_seed': 123,
'early_stopping_rounds': 50,
'loss_function':'YetiRank'
}
4.8 学習の実施
検証データをNDCGを用いて評価することでモデルの改善を行う。
model = CatBoostRanker(**parameters)
model.fit(train, eval_set=valid, use_best_model=True,plot=True)
4.8 予測
テストデータの適用。
pred_scores = model.predict(X_test1)
NDCG Scoreを計算する(k=5) :5位までが入賞のため
# NDCG Scoreで評価する
ndcg = ndcg_score([y_test], [pred_scores], k=5)
#> NDCG Score: 0.68
最終目的である正解の2値データに対して予測した順位の精度を調べる。
df_test = X_test1.copy()
df_test['actiual']=y_test
df_test['actiual_bin']=y2_test
df_test['prob']=pred_scores
df_test_sort= pd.DataFrame()
for i in range(150):
d = df_test[df_test['race_id']==(i+851)].copy()
rank = d['prob'].rank(ascending=True) # 降順でランク付け
d['rank'] = rank
d['rank2']=d['rank'].apply(lambda x: 1 if x < 4 else 0)
df_test_sort = pd.concat([df_test_sort,d])
print("混同行列")
print(confusion_matrix(df_test_sort['actiual_bin'],df_test_sort['rank2']))
print(f'精度:{accuracy_score(df_test_sort['actiual_bin'],df_test_sort['rank2']):.2f}')
print(f'適合率:{precision_score(df_test_sort['actiual_bin'],df_test_sort['rank2']):.2f}')
#> 混同行列
#> [[1404 244]
#> [ 242 208]]
#> 精度:0.77
#> 適合率:0.46
4.9 QuerySoftMaxの適用
与えられたクエリに対して、最も関連性の高いオブジェクトをトップ1で予測する必要がある場合に、CatBoostRankerにはQuerySoftMaxと呼ばれるモードが用意されている。最終目的は3位以内とそれ以外であるため、QuerySoftMaxを適用するには、ターゲットをRankから2値に変更する必要がある。
train2 = Pool(
data=X_train1,
label=y2_train,#ターゲットを2値に変更
group_id=queries_train1
)
valid2 = Pool(
data=X_valid1,
label=y2_valid,#ターゲットを2値に変更
group_id=queries_valid1
)
パラメータの設定:
parameters2 = {
'iterations': 2000,
'eval_metric':'NDCG',
'learning_rate':0.05,
'depth':5,
'verbose': False,
'random_seed': 123,
'loss_function':'QuerySoftMax',
'early_stopping_rounds': 100
}
学習の開始
model2 = CatBoostRanker(**parameters2)
model2.fit(train2, eval_set=valid2, plot=True)
テストデータの予測
pred_scores2 = model2.predict(X_test1)
df2_test = X_test1.copy()
df2_test['actiual']=y_test
df2_test['actiual_bin']=y2_test
df2_test['prob']=pred_scores2
NDCG Scoreを計算する(k=5)
ndcg = ndcg_score([y2_test], [pred_scores2], k=5)
print(f"NDCG Score: {ndcg:.2f}")
#> NDCG Score: 0.92
最終目的である正解の2値データに対して予測した順位の精度
df2_test_sort= pd.DataFrame()
for i in range(150):
d = df2_test[df2_test['race_id']==(i+851)].copy()
rank = d['prob'].rank(ascending=False)
d['rank'] = rank
d['rank2']=d['rank'].apply(lambda x: 1 if x < 4 else 0)
df2_test_sort = pd.concat([df2_test_sort,d])
print("混同行列")
print(confusion_matrix(df2_test_sort['actiual_bin'],df2_test_sort['rank2']))
print(f'精度:{accuracy_score(df2_test_sort['actiual_bin'],df2_test_sort['rank2']):.2f}')
print(f'適合率:{precision_score(df2_test_sort['actiual_bin'],df2_test_sort['rank2']):.2f}')
#> 混同行列
#> [[1399 249]
#> [ 247 203]]
#> 精度:0.76
#> 適合率:0.45
5 lightGBMによるランク学習
lightGBMでは、クエリ(group_id)の考え方がCatboostと異なる。group_idは、各整数が1つのクエリ/グループ内のデータポイント(行)数を表す整数のリストまたは配列である必要がある。
例:クエリが3つあり、最初のクエリに100サンプル、2番目に50サンプル、3番目に200サンプルがある場合、group_id は [100, 50, 200] のようになる。
この配列の合計(100 + 50 + 200 = 350)は、訓練データの総行数と一致する必要があることに注意。
group_idの準備
qr1 = X_train1.groupby('race_id').size().reset_index(name='gokei')
qr_train = qr1['gokei']
qr2 = X_valid1.groupby('race_id').size().reset_index(name='gokei')
qr_valid = qr2['gokei']
パラメータの設定
parameters3 = {
'objective': 'lambdarank',
'metric': 'NDCG',
'learning_rate': [0.05],
'max_depth': [5],
'boosting_type': 'gbdt',
}
訓練の開始
moodelgb = lgb.LGBMRanker(**parameters3,n_estimators=2000, random_state=0)
moodelgb.fit(X_train1,
y_train,
group=qr_train,
eval_set=[(X_valid1, y_valid)],
eval_group=[list(qr_valid)])
#> [LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000179 seconds.
#> You can set `force_col_wise=true` to remove the overhead.
#> [LightGBM] [Info] Total Bins 491
#> [LightGBM] [Info] Number of data points in the train set: 9996, number of used features: 2
#> [LightGBM] [Warning] No further splits with positive gain, best gain: -inf
#> [LightGBM] [Warning] No further splits with positive gain, best gain: -inf
#> [LightGBM] [Warning] No further splits with positive gain, best gain: -inf
#> ・・・
予測の適用
lgscore = moodelgb.predict(X_test1)
df3_test = X_test1.copy()
df3_test['actiual']=y_test
df3_test['actiual_bin']=y2_test
df3_test['prob']=lgscore
NDCG Scoreを計算する(k=5)
ndcg = ndcg_score([y_test], [lgscore], k=5)
print(f"NDCG Score: {ndcg:.2f}")
#> NDCG Score: 0.69
最終目的である正解の2値データに対して予測した順位の精度
df3_test_sort= pd.DataFrame()
for i in range(150):
d = df3_test[df3_test['race_id']==(i+851)].copy()
rank = d['prob'].rank(ascending=True)
d['rank'] = rank
d['rank2']=d['rank'].apply(lambda x: 1 if x < 4 else 0)
df3_test_sort = pd.concat([df3_test_sort,d])
print("混同行列")
print(confusion_matrix(df3_test_sort['actiual_bin'],df3_test_sort['rank2']))
print(f'精度:{accuracy_score(df3_test_sort['actiual_bin'],df3_test_sort['rank2']):.2f}')
print(f'適合率:{precision_score(df3_test_sort['actiual_bin'],df3_test_sort['rank2']):.2f}')
#> 混同行列
#> [[1368 280]
#> [ 276 174]]
#> 精度:0.73
#> 適合率:0.38
6 2値分類によるlightGBM
ランク学習ではなく、0,1の2値クラス分類学習にし、その予測確率をソートすることで順位を予測する。
パラメータの設定
parameters4 = {
'objective' : 'binary',
'metric' : 'binary_logloss',
'boosting_type' : 'gbdt',
'learning_rate': [0.05],
'max_depth': [5]
}
学習の開始
moodelgb2 = lgb.LGBMClassifier(**parameters4,n_estimators=2000, random_state=0)
moodelgb2.fit(X_train1,
y2_train,
eval_set=[(X_valid1, y2_valid)]
)
#> [LightGBM] [Info] Number of positive: 2100, number of negative: 7814
#> [LightGBM] [Info] Auto-choosing row-wise multi-threading, the overhead of testing was 0.000071 seconds.
#> You can set `force_row_wise=true` to remove the overhead.
#> And if memory is not enough, you can set `force_col_wise=true`.
#> [LightGBM] [Info] Total Bins 491
#> [LightGBM] [Info] Number of data points in the train set: 9914, number of used features: 2
#> [LightGBM] [Info] [binary:BoostFromScore]: pavg=0.211822 -> initscore=-1.313980
#> [LightGBM] [Info] Start training from score -1.313980
#> [LightGBM] [Warning] No further splits with positive gain, best gain: -inf
#> [LightGBM] [Warning] No further splits with positive gain, best gain: -inf
#> ・・・
テストデータによる予測
lgscore2 = moodelgb2.predict(X_test1)
df4_test = X_test1.copy()
df4_test['actiual']=y_test
df4_test['actiual_bin']=y2_test
df4_test['prob']=lgscore2
NDCG Scoreを計算する(k=5)
ndcg = ndcg_score([y2_test], [lgscore2], k=5)
print(f"NDCG Score: {ndcg:.2f}")
#> NDCG Score: 0.43
最終目的である正解の2値データに対して予測した順位の精度
df4_test_sort= pd.DataFrame()
for i in range(150):
d = df4_test[df4_test['race_id']==(i+851)].copy()
rank = d['prob'].rank(ascending=False)
d['rank'] = rank
d['rank2']=d['rank'].apply(lambda x: 1 if x < 4 else 0)
df4_test_sort = pd.concat([df4_test_sort,d])
print("混同行列")
print(confusion_matrix(df4_test_sort['actiual_bin'],df4_test_sort['rank2']))
print(f'精度:{accuracy_score(df4_test_sort['actiual_bin'],df4_test_sort['rank2']):.2f}')
print(f'適合率:{precision_score(df4_test_sort['actiual_bin'],df4_test_sort['rank2']):.2f}')
#> 混同行列
#> [[1455 193]
#> [ 301 149]]
#> 精度:0.76
#> 適合率:0.44
7 まとめ
- NDCG
| YetiRank | QuerySoftMax | LambdaRank | binary |
|---|---|---|---|
| 0.68 | 0.92 | 0.69 | 0.43 |
- 最終目的に対する精度と適合率
1.Accury(精度)
| YetiRank | QuerySoftMax | LambdaRank | binary |
|---|---|---|---|
| 0.77 | 0.76 | 0.73 | 0.76 |
2.Precision(適合率)
| YetiRank | QuerySoftMax | LambdaRank | binary |
|---|---|---|---|
| 0.46 | 0.45 | 0.38 | 0.44 |
この最終目的の2値分類問題の場合、正解値が少例になるため、あまり精度の指標は当てにならない。それよりは、予測した内、どれぐらい予測が当たっていたかを示す適合率が重要だが、ランク学習と2値分類学習とでの差は見られなかった。
ただし、ランキングを示すNDCGにおいて、ランク学習が優れた結果となった。的中率が同じでもランク学習の方が、並び順は馬券の購入時に大きな意味を持つので、回収率の高い馬券の組み合わせの予測につながる可能性がある。
こうして記事にまとめることで、今まで馴染みがなかったランク学習の理解が深まった気がする。結論として、競馬AIの予測には、NDCGから、CatboostによるQuerySoftMaxの適用が良いと思う。
7 参考
ランク学習と検索結果の精度評価指標
CatBoost learning to rank on Microsoft dataset

