• トップ
  • ブログ一覧
  • Pythonと皐月賞の過去20年のデータで遊ぼう
  • Pythonと皐月賞の過去20年のデータで遊ぼう

    新田(エンジニア)新田(エンジニア)
    2024.03.21

    IT技術

    はじめに

    新田新田
    こんにちは、分析基盤や分析のお仕事をやっている新田です。
    今回は毎年4月中旬に中山競馬場で行われるG1・皐月賞についての記事です!
    過去20年分のデータを眺めて調べたり、プロットしたりして遊びますよ〜

    データ準備

    データはプライベートで使っている競馬DBからとってきてまとめました。
    皐月賞に出た馬、皐月賞に出た馬のそれまでのレースを準備して、比較用に2023年の芝中距離(1600m〜2400m)の全レースのデータも用意しました。
    さて、データを見ていきますがまずは...

    1df = pd.read_csv('satsuki_data_2004_2023.csv')
    2total_df = pd.read_csv('total_data_2023.csv')
    3satsuki = df[df.racename == "皐月賞"].copy()
    4satsuki.place.value_counts()


    皐月賞といえば中山なんですが、2011年は東日本大地震の影響で東京競馬場で開催されています。
    中山芝2000mと東京芝2000mでは舞台が全く異なるのでこの年のデータは除外しておきます。(ちなみにこの年に優勝したのはオルフェーヴル)

    1satsuki.drop(satsuki[satsuki.place == "東京"].index, inplace=True)
    2df.drop(df[(df.place=="東京") & (df.racename == "皐月賞")].index, inplace=True)

    人気別成績

    人気をすこしまとめたカテゴリ値にして集計してみます。競馬予想記事でよく出てくるようなやつですね。

    1satsuki["popularity_cat"] = satsuki.tanshou_odds_rank.map(
    2    lambda x: " 1番人気" if x == 1 else " 2~3番人気" if x <= 3 else " 4~6番人気" if x <= 6
    3        else " 7~10番人気" if x <= 10 else "11番人気~"
    4)
    5satsuki.groupby('popularity_cat')[[
    6    "is_win", "is_win_fukushou", "tanshou_result", "fukushou_result"
    7]].mean().style.background_gradient()


    is_win, is_win_fukushouはそれぞれ単勝馬券・複勝馬券が当たったかを表すので平均を取ると勝率・複勝率です。
    tanshou_result, fukushou_resultはその馬の単勝馬券・複勝馬券を100円分買った場合の結果が入っているので平均を取ると単勝回収率・複勝回収率となります。

    上位人気、特に3番人気までが強いようですが7〜10番人気の中穴もなかなか高い値です。
    どのような馬が穴を開けたのか見てみましょう。

    1satsuki[(satsuki.popularity_cat == " 7~10番人気") & (satsuki.is_win_fukushou)][[
    2    "date", "umaname", "tanshou_odds_rank", "tanshou_odds", "result"
    3]]


    なかなか懐かしい穴馬がでてきました。(2016年、2018年、2021年は個人的にとてもよく覚えています。)
    また、11番人気〜の馬はほとんど馬券になることがなく、最近では2017年のダンビュライトくらいです。(それでも56.1倍です。)

    1satsuki[(satsuki.popularity_cat == "11番人気~") & (satsuki.is_win_fukushou)][[
    2    "date", "umaname", "tanshou_odds_rank", "tanshou_odds", "result"
    3]]

    2023年の芝中距離全体での集計と比較してみましょう。

    1total_df.groupby('popularity_cat')[[
    2    "is_win", "is_win_fukushou", "tanshou_result", "fukushou_result"
    3]].mean().style.background_gradient()


    大穴の馬が来ることは滅多にないので全く来ないとはいえませんが、芝中距離の平均的なレースよりも皐月賞の方が大穴が馬券になりづらく、おいしくない馬券が多いように見えます。皐月賞は大穴に夢をみる人が多いレースなのかもしれません。
    手広く抑えるにしても、10番人気以内や単勝オッズ50倍くらいまでにしておいたほうがいいかもしれませんね。

    枠番別成績

    皐月賞ともなると毎年ほぼフルゲートなので、頭数を気にせずに枠番別成績を見ることができます。
    人気別成績と同じように集計してみます。

    1satsuki.groupby('wakuban')[[
    2    "is_win", "is_win_fukushou", "tanshou_result", "fukushou_result"
    3]].mean().style.background_gradient()


    5枠が1勝もしていません。
    もうすこしデータで補足してみましょう。単勝人気順の平均を入れてみるようにします。また、1着の回数, 2着の回数, 3着の回数, 着外の回数 を表す着度数も集計するようにして関数にまとめました。

    1def aggregate_by_condition(data: pd.DataFrame, condition: str):
    2    """
    3    条件ごとに集計した結果を返します
    4    Args:
    5        data: 集計対象のDataFrane
    6        conditions: 条件のリスト
    7    """
    8    
    9    aggregated  = data.groupby(condition)[[
    10        "is_win", "is_win_fukushou", "tanshou_result", "fukushou_result", "tanshou_odds_rank"
    11    ]].mean()
    12    # 着度数の集計(ex. 3-2-1-31)
    13    result_cnts = data.groupby(condition).result_cat.value_counts()
    14    result_cnts.name = "result_cnt"
    15    pivot_result_cnts = pd.pivot_table(
    16        result_cnts.to_frame(),
    17        index=condition, columns="result_cat"
    18    ).fillna(0).astype(int).astype(str)
    19    pivot_result_cnts.columns = [c[1] for c in pivot_result_cnts.columns]
    20    aggregated["freq_result"] = pivot_result_cnts["1"] + "-" + pivot_result_cnts["2"]  + "-" \
    21        + pivot_result_cnts["3"]  + "-" + pivot_result_cnts["4以上"] 
    22    aggregated["cnt"] = data.groupby(condition)[condition].count()
    23    
    24    # カラム名の変更
    25    aggregated.columns = [
    26        "勝率", "複勝率", "単勝回収率", "複勝回収率", "単勝人気順平均", "着度数", "出走頭数"
    27    ]
    28    return aggregated

    5枠は他の枠と比較して人気のない馬が多いことがわかりました。
    内枠不利と見ることができるかもしれません。1, 2枠の回収率がやや低めです。開催最終週で内が痛んでいて内が痛んでいるか影響かもしれませんね。ちなみに近年内枠で勝った馬はソールオリエンスにコントレイルですが、どちらも最後の直線は大外を走っています。
    20年分とはいってもデータが少ないので、ばらつきなのか傾向なのかどうしても判断しにくいですね。
    やや内枠不利に見えますが、そこまで枠による有利不利は考えすぎない方が良さそうです。

    生産者別成績

    先ほど作った関数で、他の条件別に集計してみます。生産者別成績です。

    1aggregated = aggregate_by_condition(satsuki, 'seisansha').sort_values("出走頭数", ascending=False)
    2aggregated.style.background_gradient()

    ノーザンファームが8勝で最多ですが出走頭数もなかなか多いです。
    ノーザンファームの皐月賞出走頭数をグラフにしてみます。

    1satsuki[satsuki.seisansha == "ノーザンファーム"].groupby('date').seisansha.count()
    2satsuki[satsuki.seisansha == "ノーザンファーム"].groupby('date').seisansha.count().plot(
    3    title="ノーザンファームの皐月賞出走頭数", figsize=(16, 5), style="o-", ylim=0
    4)


    2004年でも3頭ですがさらに増加傾向です。2021年は10頭ですので半分を超えてたんですね。
    今年も5, 6頭出てきそうですね。

    脚質別成績

    脚質を判定する関数を作成し、適用して集計します。
    脚質傾向の決め方のロジックはJRA-VAN NEXTのページに書かれているロジックを使いました。

    1def get_kyakushitsu(record):
    2    """
    3    このレースでの脚質を判定して返します。
    4    脚質傾向の決め方のロジックは以下のJRA-VAN NEXTのページを参考にしています
    5    http://next5.jra-van.jp/appli/kyakushitsu3.html
    6    """
    7    if record.corner1pos == 1 or record.corner2pos == 1 or record.corner3pos == 1:
    8        return "逃げ"
    9    if record.corner4pos <= 4:
    10        return "先行"
    11    if record.corner4pos <= record.umacount * 2 / 3:
    12        return "差し"
    13    return "追込"
    14
    15
    16satsuki["kyakushitsu"] = satsuki.apply(get_kyakushitsu, axis=1)
    17aggregated = aggregate_by_condition(satsuki, 'kyakushitsu').loc[["逃げ", "先行", "差し", "追込"]]
    18aggregated.style.background_gradient()

    逃げ・先行が回収率・勝率ともに良いですが、差して勝っている馬が10頭と一番多いです。

    4コーナー通過順

    脚質についてもう少し詳しくみてみます。
    4コーナーの通過順と着順を散布図にしました。

    1satsuki.plot.scatter(x="corner4pos", y="result", alpha=0.3, s=100, figsize=(5, 5))

    これでもいいんですが、4コーナーの通過順も着順も整数しか取り得ず離散的なため重なって見にくいグラフになっています。このような場合はそれぞれのデータにランダムな変動を加えて重なりを減らすと見やすくなります。

    1satsuki["jitter_corner4position"] = satsuki.corner4pos + np.random.normal(0, 0.5, len(satsuki))
    2satsuki["jitter_result"] = satsuki.result + np.random.normal(0, 0.5, len(satsuki))
    3satsuki.plot.scatter(
    4    x="jitter_corner4position", y="jitter_result", alpha=0.3, s=100, title="皐月賞の通過順と着順", figsize=(5, 5)
    5)

    同様に作成した2023年の芝中距離でのグラフと比較してみます。

    2023年の芝中距離のレースのほうがより前にいる馬が有利で、直線で巻き返せていない傾向がみられますが、皐月賞では4コーナー後方から追い込んでいるケースが多く見られます。
    ただし、これは皐月賞の傾向というより上位クラスの競馬でみられる傾向かもしれません。
    2023年の芝中距離のデータをクラス別にプロットしてみましょう。

    1fig, axes = plt.subplots(nrows=3, ncols=2, figsize=(10, 15))
    2
    3for i, jouken in enumerate(["新馬", "未勝利", "1勝クラス", "2勝クラス", "3勝クラス", "オープン"]):
    4    total_df[total_df.jouken == jouken].plot.scatter(
    5        x="jitter_corner4position", y="jitter_result", alpha=0.1, s=100, title=f"2023年芝中距離戦({jouken})の通過順と着順",
    6        ax=axes[i//2, i%2]
    7    )
    8
    9plt.tight_layout()

    クラスが上がるごとに4コーナー通過順と着順の相関がなくなっていく様子を見ることができました。下位のクラスほど前に行った馬がそのまま上位に入線し、上位クラスほど最後の直線で巻き返す馬が多い傾向があるようです。
    もう少し違う角度からデータを見てみましょう。
    複勝圏内の馬の4コーナー通過順のヒストグラムです。

    1satsuki[satsuki.is_win_fukushou].corner4pos.plot(
    2    kind="hist", bins=18, title="皐月賞 複勝圏内の馬の4コーナー通過順"
    3)

    皐月賞では5番手以内までが多いですが、後方の馬も馬券になっています。
    一方、芝中距離全体では、明らかに前有利の傾向が見えます。

    1total_df[total_df.is_win_fukushou].corner4pos.plot(
    2    kind="hist", bins=18, title="2023年芝中距離 複勝圏内の馬の4コーナー通過順"
    3)

    これもクラスごとに分割してみます。

    1fig, axes = plt.subplots(nrows=3, ncols=2, figsize=(10, 15))
    2
    3for i, jouken in enumerate(["新馬", "未勝利", "1勝クラス", "2勝クラス", "3勝クラス", "オープン"]):
    4    total_df[(total_df.jouken==jouken)&(total_df.is_win_fukushou)].corner4pos.plot(
    5        kind="hist", title=f"({jouken}) 複勝圏内の馬の4コーナー通過順", ax=axes[i//2, i%2], figsize=(10, 10)
    6    )
    7
    8plt.tight_layout()


    皐月賞はオープンクラスのヒストグラムに一番近いですが、後方から馬券になった馬は皐月賞の方が出やすいかもしれないくらいです。
    単に皐月賞に出てくる強い馬が多いということもありますが、前で競馬をしたい馬が多くペースが速くなりがちで後方からの競馬でも巻き返せるということかもしれません。

    ローテーション

    さて、次はローテーションを考えてみましょう。
    前走のレース別の集計をします。
    馬ごとにレコードを一つずらすには、pd.DataFrame.shift関数が便利です。

    1df[["racename_pre1", "result_pre1", "course_pre1"]] = df.groupby('umaname')[[
    2    "racename", "result", "course"]].shift(1)
    3df["pre_race_cnt"] = df.groupby('umaname').date.rank(ascending=False) - 1
    4pre1_races = df[df.pre_race_cnt == 1][["racename", "umaname"]].copy()
    5pre1_races.columns = ["pre1_racename", "umaname"]
    6satsuki = pd.merge(satsuki, pre1_races, on="umaname")
    7aggregated = aggregate_by_condition(satsuki, 'pre1_racename')
    8aggregated.sort_values('勝率', ascending=False).style.background_gradient()

    データ数が少ないのでなかなか難しいですが、共同通信杯組がすこし好成績でしょうか。
    近年でもジオグリフ、エフフォーリア、ステラヴェローチェと記憶に新しいです。
    少し範囲を狭めて過去10年に絞ってみましょう。

    1aggregated = aggregate_by_condition(satsuki[satsuki.date >= "2014-01-01"], 'pre1_racename')
    2aggregated.sort_values('勝率', ascending=False).style.background_gradient()

    トライアル競争として設定されている弥生賞、スプリングS、若葉Sは、どれもあまりよくないですね。
    弥生賞は本番と同じコースなのに皐月賞の成績が悪いことは有名ですが、近年はタイトルホルダー、ドウデュース、タスティエーラと3年連続馬券には絡んでいます。(どれもスーパーホースですけどね...)

    もう少しローテーションで遊んでみましょう。
    holoviewsというライブラリを使ってサンキーダイアグラムを書いてみます。
    ごにょごにょ加工して、以下のようなsatsuki_rotationというDataFrameを準備しました。

    from_が前のレースでto_が次のレース、cntは件数ですね。umanamesは皐月賞で馬券になった馬の名前です。
    あまり古いレースまで表示しすぎても細かくなりすぎるので、近2走に絞ります。それでも細かくなりすぎたので、1件しかないパスは除外して描画しました。

    1import holoviews as hv
    2from holoviews import dim
    3
    4hv.extension('bokeh')
    5
    6basic_sankey = hv.Sankey(
    7    satsuki_rotation[satsuki_rotation.cnt >= 2],
    8    kdims=["from_", "to_"],
    9    vdims=["cnt", "umanames"],
    10    label="皐月賞近2走",
    11).opts(
    12    edge_color=dim("from_").str(),
    13)
    14
    15hv.ipython.display(basic_sankey)

    こちらは皐月賞で3着以内になった馬のみに絞って表示しています。

    3着以内の方が共同通信杯の存在感が大きいですね。
    マウスをホバーしたときにそのローテーションで馬券になった馬を表示するようにしました。なんだかかっこよくてちょっと便利です。タイトルホルダーとサンリヴァルがどちらもホープフルS → 弥生賞 → 皐月賞 だったんだ!とかを発見してなかなか楽しいです。

    牝馬の成績

    これはデータが少ないので特に何もできませんが、皐月賞の牝馬の出走歴を確認しておきましょう。

    1satsuki[satsuki.seibetsu == "牝"][[
    2    "date", "umaname", "tanshou_odds_rank", "tanshou_odds", "result"
    3]]


    今年はホープフルSを制したレガレイラが参戦するそうです。牝馬の皐月賞制覇は1948年のヒデヒカリまで遡ります。トリッキーな中山コースでまだ若い牝馬、気性的にもベストパフォーマンスを発揮しづらい舞台と想像しますが、レガレイラはホープフルSを勝ち切っているのが面白いです。(4角10番手から上がり最速で差し切り)
    ホープフルSからの皐月賞直行のローテーションはサートゥルナーリア、コントレイルなどノーザンファームの馬がたまに行っています。ローテーションも悪くなさそうです。
    牝馬の皐月賞制覇なるか、今から楽しみですね。

    おわりに

    新田新田

    データをいろいろ眺めると楽しいですね。最後に見つけた傾向をまとめてこの記事は終わりにしたいと思います。

    1. 大穴はほとんど空いていないので10番人気以内や、単勝オッズ50倍くらいまでにしておいた方がいいかも?
    2. 枠による大きな有利不利はなさそう、強いていうなら内がすこし不利?
    3. 逃げ・先行馬の成績が良いが、差して馬券に絡む馬も多い
    4. 共同通信杯組に注目したい

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background