プログラミング初心者がChatGPTを使って競馬予想AIを作ることで、生成AIとプログラミングについて学んでいく企画の第14回です。
↓前回の記事
https://qiita.com/yoshiasa1117/items/3dfddf3b08bad40f1fc0
馬の過去成績テーブルから特徴量を追加しているのですが、そもそもそのテーブルに不備があったりとかなり時間を要しています。
しかし前回ようやく過去オッズをもとに特徴量を生成し、実際に的中率が3倍 になることを確認できました。
まだまだ追加していない特徴量はたくさんあるのですが、今回はその中でも人気・最終順位・斤量を特徴量として加工していきます。
人気(popularity)
人気もオッズと同じく同レースで使うと確定した後のリーク特徴量となります。
したがって現状では過去の人気特徴量を使ったローリング特徴量で対応していきます。
popularity_inverse(人気の逆数)
人気は逆数を取ることで、人気が高いほど値が大きくなるという連続量にします。
これには以下の2つの狙いがあります。
- LightGBMが高い値ほど重要な特徴量として認識する
- 順位の差を強調し、下位人気の馬をまとめて人気薄にすることができる
前回SHAP値を確認したところ、オッズの高い馬が軒並み予測に悪影響を与えることがわかりました。
逆数を使うことで下位人気(=オッズの高い)馬の影響を薄めてしまいましょう。
df['popularity_inverse'] = 1.0 / df['popularity']
df["popularity_inverse_avg3"] = df.groupby("horse_id")["popularity_inverse"].shift(1).rolling(3).mean()
df["popularity_inverse_avg5"] = df.groupby("horse_id")["popularity_inverse"].shift(1).rolling(5).mean()
df["popularity_inverse_avg10"] = df.groupby("horse_id")["popularity_inverse"].shift(1).rolling(10).mean()
popularity_bucket(人気のビン分割)
人気をビンごとに分割して非連続カテゴリに落とし込みます。
例えば1~2番人気を1のビン、3~5番人気を2のビンといったというイメージですね。
このようにbucket化することで、木モデルが人気帯ごとの挙動を安定して学習できるようになります。
def popularity_bucket(pop):
if pop <= 2:
return 1 # 超上位人気
elif pop <= 5:
return 2 # 上位人気
elif pop <= 9:
return 3 # 中穴
else:
return 4 # 大穴
df['popularity_bucket'] = df['popularity'].apply(popularity_bucket)
df["popularity_inverse_avg3"] = df.groupby("horse_id")["popularity_inverse"].shift(1).rolling(3).mean()
df["popularity_inverse_avg5"] = df.groupby("horse_id")["popularity_inverse"].shift(1).rolling(5).mean()
df["popularity_inverse_avg10"] = df.groupby("horse_id")["popularity_inverse"].shift(1).rolling(10).mean()
最終順位(finish_position)
人気やオッズと違い、こちらは明らかにレース出走前には取得できない情報になります。
したがってこちらも過去、最終順位がどうだったかという使い方をしていきましょう。
finish_position_inverse(最終順位の逆順)
人気のときにも使った特徴量と同じですね。
説明は割愛します。
df["finish_position_inverse"] = 1 / df["finish_position"]
df["finish_position_inverse_avg3"] = df.groupby("horse_id")["finish_position_inverse"].shift(1).rolling(3).mean()
df["finish_position_inverse_avg5"] = df.groupby("horse_id")["finish_position_inverse"].shift(1).rolling(5).mean()
df["finish_position_inverse_avg10"] = df.groupby("horse_id")["finish_position_inverse"].shift(1).rolling(10).mean()
expected_vs_actual_gap(期待順位と実順位の乖離)
人気順位と実際の着順の差分を取ることで、市場予想よりも好走したか、または凡走したかがわかります。
df['expected_vs_actual_gap'] = df['popularity'] - df['finish_position']
df["expected_vs_actual_gap_avg3"] = df.groupby("horse_id")["expected_vs_actual_gap"].shift(1).rolling(3).mean()
df["expected_vs_actual_gap_avg5"] = df.groupby("horse_id")["expected_vs_actual_gap"].shift(1).rolling(5).mean()
df["expected_vs_actual_gap_avg10"] = df.groupby("horse_id")["expected_vs_actual_gap"].shift(1).rolling(10).mean()
斤量(weight)
斤量はハンデとしてレース内平均との差分が負荷としてのしかかります。
weight_minus_avg_in_race(レース内平均との差斤量)
同一レース内でその馬がどれくらい重い斤量を背負っているかを評価します。
いかにハンデを背負いながら好成績を残しているかを相対評価で特徴量化しましょう。
df['weight_minus_avg_in_race'] = (
df['weight'] -
df.groupby('race_id')['weight'].transform('mean')
)
ハンデに対してどのくらいの成績を残せたか、をうまく特徴量化できたらよいのですが…
あと斤量はハンデ戦やクラス昇降直後で効くらしいです。
weight_per_body_weight(馬体重に対する斤量比)
斤量を馬体重で割ることで、体格に対してどれくらいの負荷を背負っているかがわかります。
例えば同じ斤量でも、馬体重480kgと560kgではわけが違いますよね。
df['weight_per_body_weight'] = df['weight'] / df['body_weight']
このデータは他に
- 平均を取ることで過去nレースでどれくらいのハンデを背負ってきたかを明らかにする
- 人気と絡めた相互作用(相関)を持たせることで、「人気の割に斤量が良すぎる馬」を見つける
などの特徴量が思い浮かびます。
走破タイム(time)
タイムは距離や馬場状態によって変わるため、一概にそれを見ただけでは比較できません。
したがって常にレース内(同じ条件でどうだったか)で比較してあげます。
タイムもレースが終わった後の未来の結果に該当します。
したがってそのレースより過去の結果から特徴量を作らないといけません。
オッズや人気も直前まで変動する値なので、注意して取り扱わなければいけません。
(前回使用していた"odds"や"last3f"はリーク特徴量なので除外するべきで、その結果なんと的中率は0% になりました。笑ってくれ)
逆にオッズや人気を直前までどれだけ正確に取得できるかが、的中率向上のカギになりそうですね🔑
time_diff_from_winner(勝ち馬とのタイム差)
一着の馬とのタイム差を使って距離や馬場の違いを吸収しましょう。
df["time_diff_from_winner"] = df["time"] - df.groupby("race_id")["time"].transform("min")
time_zscore_in_race(レース内Zスコア)
レース内での相対的な速さを指標化します。
こちらは上と違って標準化されるためLightGBMが扱いやすいです。
df["time_zscore_in_race"] = (
df["time"] - df.groupby("race_id")["time"].transform("mean")
) / df.groupby("race_id")["time"].transform("std")
"time_diff_from_winner"と同じような気もしますが、ChatGPTが重要だというので両方をSHAP値で評価しましょう。
着差(margin)
テーブルを作る際、計算方法に不備があったため着差を正しく計算できていませんでした。
したがって下記に特徴量のコードだけは示しますが、一旦テーブルへの追加は保留にしています。
margin_to_winner(勝ち馬との差)
netkeiba.comの着差は前の馬との差を表しているようなので、まずは勝ち馬のとの差に変換してあげましょう。
df["margin_to_winner"] = df.groupby("race_id")["margin"].cumsum()
margin_zscore(レース内平均とのmarginの差)
timeのzscoreと意味するところは全く変わらないような気がしますが一応…
df["margin_zscore_in_race"] = (
df["margin_to_winner"] - df.groupby("race_id")["margin_to_winner"].transform("mean")
) / df.groupby("race_id")["margin_to_winner"].transform("std")
上がり3F(final_3f)
上がり3Fはレース最後の600m(=3ハロン)を走るのにかかったタイムのことで、終盤の末脚(ラストスパート)の速さを示す指標です。
したがって上がりが速いと「末脚が強い差し馬・追い込み馬」、遅いと「逃げ・先行で粘る馬」と判断できます。
これもレースごとに条件が変わるため、レース内での評価をしてあげましょう。
final_3f_rank(レース内順位)
まずはレース内での速さをランクづけした指標です。
df["final_3f_rank"] = df.groupby("race_id")["final_3f"].rank(ascending=True)
final_3f_diff_from_best(最速との差)
レース内で最速だった馬に対して、この馬はどれだけの速さだったのかを表します。
df["final_3f_diff_from_best"] = df["final_3f"] - df.groupby("race_id")["final_3f"].transform("min")
final_3f_zscore
最後にいつものzscoreです。
df["final_3f_zscore"] = (
df["final_3f"] - df.groupby("race_id")["final_3f"].transform("mean")
) / df.groupby("race_id")["final_3f"].transform("std")
馬場や距離だけではなくレース相手にも依存する指標になっているため、そこはいつか改善したいですね。
特徴量を追加して予測した結果は…
最後に今回追加した特徴量を用いてLightGBMで予測を行いました。
長くなりますが特徴量は以下の通り。
feature = [
'weight_carried',
# 'popularity',
# 'odds',
# 'last3f',
'body_weight',
'sex',
'age',
# 'time_sec',
'body_diff',
'weather',
'course_condition',
'course_state',
'distance_length',
# 'log_odds',
'log_odds_avg_3',
'log_odds_avg_5',
'log_odds_avg_10',
'log_odds_zscore_avg3',
'log_odds_zscore_avg5',
'log_odds_zscore_avg10',
'popularity_inverse_avg3',
'popularity_inverse_avg5',
'popularity_inverse_avg10',
'popularity_bucket_avg3',
'popularity_bucket_avg5',
'popularity_bucket_avg10',
'finish_position_inverse_avg3',
'finish_position_inverse_avg5',
'finish_position_inverse_avg10',
'expected_vs_actual_gap_avg3',
'expected_vs_actual_gap_avg5',
'expected_vs_actual_gap_avg10',
'time_diff_from_winner_avg3',
'time_diff_from_winner_avg5',
'time_diff_from_winner_avg10',
'time_zscore_in_race_avg3',
'time_zscore_in_race_avg5',
'time_zscore_in_race_avg10',
'margin_to_winner_avg3',
'margin_to_winner_avg5',
'margin_to_winner_avg10',
'final_3f_rank_avg3',
'final_3f_rank_avg5',
'final_3f_rank_avg10',
'final_3f_diff_avg3',
'final_3f_diff_avg5',
'final_3f_diff_avg10',
'final_3f_zscore_avg3',
'final_3f_zscore_avg5',
'final_3f_zscore_avg10'
# 'odds_zscore_in_race',
]
X = model[feature]
y = model['is_win']
# リークとなる特徴量を除外
# 'popularity', 'odds', 'log_odds', 'odds_zscore_in_race'は直前までの正確な情報を得ることが難しいため、モデルから除外しています。
そして予測結果は419頭中1頭、一位の馬を予測することができました。
Actual values:
actual
0 5283
1 419
Name: count, dtype: int64
Predicted values:
predicted
0 5701
1 1
Name: count, dtype: int64
人気順をそのまま買ってももう少し高いんじゃないか…
あまりに的中率が低いですが思い当たるところは無数にあります。
SHAP値を確認するとこんな感じ。
予測をプラスに押し上げているワンツーが、追加した特徴量ではなく既存の情報とは…
なんとも言えない気持ちです。
とはいえ改善できるポイントは無数にあるため、引き続き改良していきます。
