プログラミング初心者がChatGPTを使って競馬予想AIを作ることで、生成AIとプログラミングについて学んでいく企画の第12回です。
↓前回の記事はこちら
前回の記事では、オッズを特徴量として使おうとして馬の過去成績テーブルにいくつか問題が発覚。
それを対処した後ようやく6つの特徴量(と正しい馬の過去成績テーブル)が得られました。
結果的に97,500あったデータが27,000ほどに減少し、使おうとした食材が加工したら可食部はほんの一部だった感覚を味わっています。
いつか競馬AI開発をカレー作りに例えましたが、一貫してこの説明が使えるのでは…と思い始めています。
さて今回は、前回作成した特徴量とレースの天気、馬場状態などの馬の過去成績テーブルから得られる結果をマージし、予測精度がどれくらい変わるか見ようと思っていたのですが…
マージが全くうまくいかず、原因を探っていくと馬の過去成績テーブルが不完全だったことに気づきました。
今回はそのデバッグの過程をまとめようと思います。
特徴量のマージ
まずはシンプルに馬の過去成績テーブルとレース結果テーブルをマージしようとします。
前回作成した特徴量は「対数化したオッズのnレース平均」と「平均との差分を分布で割ったz-score」の2つです。
前回の記事では平均をとる際各馬の最初のnレースを取っており、各レース時点での過去nレースの平均にはなっていませんでした。
なのでこちらは修正した特徴量になります。
これとレースごとの"天気(weather)", "馬場状態(course_condition)", "馬場(course_state)", "距離(distance_length)"を追加します。
horse_idとrace_idをキーとして、上記特徴量をマージしていきましょう。
DataFrame型のMerge
DataFrame型のMergeは簡単です。
# 抽出する特徴量の指定
feature_cols = ["horse_id", "race_id", "weather", "course_condition", "course_state", "distance_length", "log_odds", "log_odds_avg_3", "log_odds_avg_5", "log_odds_avg_10", "odds_zscore_in_race"]
df_merged_race = df_race.merge(df_clean[feature_cols], on=["horse_id", "race_id"], how="left")
ここで異変に気付きます。
マージしたテーブルを確認したところ、データがほぼすべて欠損しています。
マージの際にキーとしているカラム(今回はrace_idとhorse_id)が両方のテーブルに存在しないとNaNになるようです。
改めてそれぞれのデータ数を確認すると、レース結果テーブルは28506, 馬の過去成績テーブルは27153と後者の方が少ないことがわかりました。
後者はリークなどに対応するようにテーブルを加工したのですが、その際にデータを削除しすぎたのでしょうか?
マージの際にindicatorという引数をTrueにすると、キーがそれぞれのテーブルに存在するかがわかります。
merged = df_race.merge(
df_clean,
on=["horse_id", "race_id"],
how="outer",
indicator=True
)
matched_count = (merged["_merge"] == "both").sum()
print(f"Number of matched records: {matched_count}")
unmatched_count = (merged["_merge"] != "both").sum()
print(f"Number of unmatched records: {unmatched_count}")
matched_rows = merged[merged["_merge"] == "both"][["horse_id", "race_id"]]
matched_rows
実行した結果、マージできるデータ数は65, マージができないデータ数は55529とほとんどのキーが一致しないことがわかりました。
デバッグ1 ~型のチェック~
ここまで不一致のデータが多いと、キーの型違いをまずは疑います。
実際に確認してみると、レース結果テーブルにint型が混ざっているのがわかりました。
この他id系のカラムにintとstrが混ざっていることが判明したため、id系のカラムはすべて文字列型に変換しておきます。
# idのカラムをすべて文字列型に変換し、前後の空白を削除する
df_race["race_id"] = df_race["race_id"].astype(str).str.strip()
df_race["horse_id"] = df_race["horse_id"].astype(str).str.strip()
df_horse["race_id"] = df_horse["race_id"].astype(str).str.strip()
df_horse["horse_id"] = df_horse["horse_id"].astype(str).str.strip()
再度両方のテーブルのキーを比較すると
Number of matched records: 27012
Number of unmatched records: 1635
だいぶ減りましたが、未だ一致しないものが1635もあります。
どんなデータがなぜ一致しないかを突き止めるため、まずはマッチしていないデータが何なのか表示してみます。
unmatched_rows = merged[merged["_merge"] != "both"][["horse_id", "race_id"]]
unmatched_rows
試しに先頭行のhorse_id, race_idがどんなデータか、それぞれのテーブルで確認してみると
df_raceの方には存在するが、df_clean(加工した馬の過去成績テーブル)にはないことがわかります。
すなわち加工の際、データを削除しすぎていることが考えられます。
デバッグ2 ~出走結果の扱い~
まずは加工時に削除されたデータを見てみましょう。
加工は日付のフィルタリングと地方競馬場の成績を除外するために行ったデータ数と出走頭数の比較の2点を行っており、後者の頭数比較の対象であるnum_of_horses(=15)とactual_horses(=16)が一致していないことがわかります。
netkeiba.comで当該データを見てみると、本来16頭いるはずが中止や取消によってデータが欠損していることがわかります。
さらにスクレイピングした馬データを見ると、着順にはこの他除外や降着、失格などがあり、こういったパターンで欠損値が発生するようです。
どうやら出走取消(取)と競走除外(除)の馬は出走頭数には含まないらしく、さらに着順が空欄になっているデータもエラーを引き起こしているようです。
これら3つの異常値を省いてデータ数を計算、出走頭数と比較することで正確にデータを残せるはずです。
# 直近nレースから集約できるように並び替えておく
df_horse = df_horse.sort_values(by=["horse_id", "race_date"], ascending=[True, True])
# race_idをスクレイピングした期間のデータに絞る
df_horse_indate = df_horse[(df_horse["race_date"] >= "2023-10-01") & (df_horse["race_date"] <= "2024-04-30")]
# 1. "取"(取消), "除"(除外), ""(空欄)になっている行を出走頭数(actual_horses)のカウントから除外する
df_horse_indate["is_cash"] = df_horse_indate["finish_position"].astype(str).str.strip().isin(["取", "除", ""])
actual_counts = df_horse_indate[~df_horse_indate["is_cash"]].groupby("race_id").size().rename("actual_horses")
# 2. num_of_horses と結合
df_check = df_horse_indate.merge(actual_counts, on="race_id")
# 2.5 num_of_horses を数値型に変換
df_check["num_of_horses"] = pd.to_numeric(df_check["num_of_horses"], errors="coerce")
# 3. 一致しているデータだけ残す
df_clean = df_check[df_check["actual_horses"] == df_check["num_of_horses"]]
この結果matchしていないデータは120に減少しました。
しかしまだ0ではありません。
デバッグ3 ~地方競馬データの削除~
今度はhorse_tableのデータがrace_dfより多くなってしまっており、どうやら地方競馬のデータが混じっているようです。
パラパラと混ざった地方競馬のデータを除外するために頭数比較を行ったのですが、期間の中で完全にデータがそろってしまうと頭数比較がTrueになり、除外できなくなってしまいます。
今度は完全に地方競馬のデータを削除するため、race_idから競馬場コードを抽出し、中央競馬場のデータだけを残すようにします。
# 4. 地方競馬場のデータを除外する
# race_idの5-6桁目を抽出(1-basedで5-6桁 → 0-based slice [4:6])
df_clean['race_course_code'] = df_clean['race_id'].str[4:6]
jra_codes = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"] # JRAの中央競馬場コード
df_clean = df_clean[df_clean['race_course_code'].isin(jra_codes)]
これでようやく両テーブルのキーがそろい、全データを正しくマージできるようになりました。
また最終的にこうして地方競馬のデータを削除したため、頭数比較も必要なくなります。
初めからこうしておけばよかったのですが、出走頭数として数えられないパターンがあると知るための高い授業料と思っておきましょう…
(ここでかなり沼り時間を費やしました…)
それでは次回こそ、2つのテーブルを使った予測を行っていきましょう。








