PR

血統だけで回収率130%超えた話

データサイエンス
この記事は約56分で読めます。
スポンサーリンク
スポンサーリンク

はじめに¶

私は競馬予想AIの開発をしています。動画で制作過程の解説をしています。良ければ見ていってください。

競馬予想AI #1 アクセス回数を1000回減らす意外な方法とは?【ゆっくりと作る競馬予想プログラムソフト開発 part1】
競馬予想プログラムソフトの開発過程を投稿していきます。単勝回収率100%超えを目指して、競馬予想AIを機械学習で作成します。今回の内容は、以下のサイトで詳細を解説しています。ソースを参考にされたい方はそちらを閲覧ください。

3.血統ごとの成績とレース条件の取り扱い

前回までは血統の理解を深めるために基礎的な分析を中心に取り上げていたので、
今回はより収益に踏み込んだ分析を行っていく。
取り上げる内容は以下

  1. 過去数年間ごとに単勝・連対立・複勝率を分析
  2. 血統情報をモデルへ追加する方法
スポンサーリンク

3-0.下準備¶

ソースの一部は有料のものを使ってます。
同じように分析したい方は、以下の記事から入手ください。
競馬予想AI 統合分析プログラムの記事

3-0-1.必要そうなものをインポート¶

In [1]:
from typing import Literal
import pathlib
import numpy as np
import warnings
import sys
import pandas as pd
sys.path.append("..")

from src.data_manager.preprocess_tools import DataPreProcessor  # noqa
from src.data_manager.data_loader import DataLoader  # noqa
from src.core.db.controller import execSQL, getTableList, getDataFrame  # noqa

warnings.filterwarnings("ignore")

3-0-2.血統データをDBから取得¶

3-0-2-1.接続先DB情報¶

In [2]:
# 接続先DB (このnotebookの場所が「notebook」フォルダにあるので一つ上の階層に戻ってDBファイルパスを生成)
root = pathlib.Path(".").absolute().parent
dbpath = root / "data" / "keibadata.db"

3-0-2-2.DBから血統情報のテーブル一覧を取得¶

In [3]:
# 血統情報が入っているテーブルは「horseblood」という接頭辞がついたテーブルなので、その一覧を取得
horseblood_list = [tbl for tbl in getTableList(dbpath) if "horseblood" in tbl]
horseblood_list.sort(key=lambda x: int(x[-4:]))

3-0-2-3.テーブル一覧から5代血統情報をDataFrameに変換¶

In [4]:
# DBから取得:concatでhorseblood_listにあるテーブル情報のDataFrameをすべて結合する
dfblood = pd.concat(
    [getDataFrame(tbl, dbpath) for tbl in horseblood_list], ignore_index=True)

3-0-3.2000年から2023年の出走情報を取得¶

3-0-3-1.ベース前処理の実行¶

In [5]:
start_year = 2000
end_year = 2023

data_loader = DataLoader(start_year, end_year, dbpath=dbpath)
dataPreP = DataPreProcessor()
df = data_loader.load_racedata()
df = dataPreP.exec_pipeline(df)
2024-08-03 12:37:15.962 | INFO     | src.data_manager.data_loader:load_racedata:23 - Get Year Range: 2000 -> 2023.
2024-08-03 12:37:15.964 | INFO     | src.data_manager.data_loader:load_racedata:24 - Loading Race Info ...
2024-08-03 12:37:16.868 | INFO     | src.data_manager.data_loader:load_racedata:26 - Loading Race Data ...
2024-08-03 12:37:33.531 | INFO     | src.data_manager.data_loader:load_racedata:28 - Merging Race Info and Race Data ...
2024-08-03 12:37:35.932 | INFO     | src.data_manager.preprocess_tools:__0_check_use_save_checkpoints:35 - Start PreProcess #0 ...
2024-08-03 12:37:35.934 | INFO     | src.data_manager.preprocess_tools:__1_exec_all_sub_prep1:38 - Start PreProcess #1 ...
2024-08-03 12:37:42.910 | INFO     | src.data_manager.preprocess_tools:__2_exec_all_sub_prep2:40 - Start PreProcess #2 ...
2024-08-03 12:37:56.957 | INFO     | src.data_manager.preprocess_tools:__3_convert_type_str_to_number:42 - Start PreProcess #3 ...
2024-08-03 12:38:01.093 | INFO     | src.data_manager.preprocess_tools:__4_drop_or_fillin_none_data:44 - Start PreProcess #4 ...
2024-08-03 12:38:04.970 | INFO     | src.data_manager.preprocess_tools:__5_exec_all_sub_prep5:46 - Start PreProcess #5 ...
2024-08-03 12:38:29.318 | INFO     | src.data_manager.preprocess_tools:__6_convert_label_to_rate_info:48 - Start PreProcess #6 ...
2024-08-03 12:38:42.410 | INFO     | src.data_manager.preprocess_tools:__7_convert_distance_to_smile:50 - Start PreProcess #7 ...
2024-08-03 12:38:42.683 | INFO     | src.data_manager.preprocess_tools:__8_category_encoding:52 - Start PreProcess #8 ...
2024-08-03 12:38:48.275 | INFO     | src.data_manager.preprocess_tools:__9_convert_raceClass_to_grade:54 - Start PreProcess #9 ...

以上で準備完了

3-0-4.パート1の内容: 種牡馬と繁殖牝馬の種牡馬情報を追加する¶

前回, 前々回の内容から出走情報に父と母父などその他の血統情報を追加する

In [6]:
def add_blood_info_to_df(
    df: pd.DataFrame,
    mode: Literal[
        "s", "ss", "sb", "b", "bs", "bb",
        "sss", "ssb", "sbs", "sbb", "bss", "bsb", "bbs", "bbb",
        "ssss", "sssb", "ssbs", "ssbb", "sbss", "sbsb", "sbbs", "sbbb",
        "bsss", "bssb", "bsbs", "bsbb", "bbss", "bbsb", "bbbs", "bbbb",
    ]
):
    index_map = {
        # region Stallion 1gen, 2gen  3gen
        "s": (0, "stallion", "gen1"),
        "ss": (0, "sStallion", "gen2"), "sb": (8, "sBreed", "gen2"),
        "sss": (0, "s2Stallion", "gen3"), "ssb": (4, "s2Breed", "gen3"),
        "sbs": (8, "sbStallion", "gen3"), "sbb": (12, "sbBreed", "gen3"),
        # endregion
        # region Stallion 4gen
        "ssss": (0, "s3Stallion", "gen4"), "sssb": (2, "s3Breed", "gen4"),
        "ssbs": (4, "s2bStallion", "gen4"), "ssbb": (6, "s2bBreed", "gen4"),
        "sbss": (8, "sbsStallion", "gen4"), "sbsb": (10, "sbsBreed", "gen4"),
        "sbbs": (12, "sb2Stallion", "gen4"), "sbbb": (14, "sb2Breed", "gen4"),
        # endregion
        # region Breed 1gen, 2gen 3gen
        "b": (16, "breed", "gen1"),
        "bs": (16, "bStallion", "gen2"), "bb": (24, "bBreed", "gen2"),
        "bss": (16, "bsStallion", "gen3"), "bsb": (20, "bsBreed", "gen3"),
        "bbs": (24, "b2Stallion", "gen3"), "bbb": (28, "b2Breed", "gen3"),
        # endregion
        # region Breed 4gen
        "bsss": (16, "bs2Stallion", "gen4"), "bssb": (18, "bs2Breed", "gen4"),
        "bsbs": (20, "bsbStallion", "gen4"), "bsbb": (22, "bsbBreed", "gen4"),
        "bbss": (24, "b2sStallion", "gen4"), "bbsb": (26, "b2sBreed", "gen4"),
        "bbbs": (28, "b3Stallion", "gen4"), "bbbb": (30, "b3Breed", "gen4"),
        # endregion
    }
    idx, prefix, genCol = index_map[mode]
    idf = dfblood.iloc[idx::32].reset_index(drop=True)
    idf[genCol] = idf[genCol].str.split("\n", expand=True)[
        0].str.replace("\n", "")
    df[f"{prefix}Id"] = df["horseId"].map(
        idf.set_index("horseId")[f"{genCol}ID"])
    df[f"{prefix}Name"] = df["horseId"].map(idf.set_index("horseId")[genCol])
    return df, idf[["horseId", genCol, f"{genCol}ID"]].rename(columns={genCol: "GenName", f"{genCol}ID": "GenID"})


df, dfstallion = add_blood_info_to_df(df, "s")
df, dfbreed = add_blood_info_to_df(df, "b")
df, dfbStallion = add_blood_info_to_df(df, "bs")
df, dfgen3 = add_blood_info_to_df(df, "bbs")
df, dfgen3 = add_blood_info_to_df(df, "bbbs")
df[["horseId", "stallionId", "stallionName", "bStallionId",
    "bStallionName", "b2StallionId", "b2StallionName"]]
Out[6]:
horseId stallionId stallionName bStallionId bStallionName b2StallionId b2StallionName
0 1997100761 000a000d4b エブロス 000a000081 ブレイヴェストローマン 000a001840 Key to Content
1 1997100656 000a000013 アフリート 000a000d2e ストームオンザルース 000a000456 ラナーク
2 1997100203 1982101222 サクラユタカオー 000a000258 ノーザンテースト 000a000f51 Klairon
3 1997104609 000a000d87 ロイヤルアカデミーII 000a0001ff マナード 000a000ec9 Crepello
4 1997106623 1988106402 カリスタグローリ 000a00010d エンペリー 000a00026d パーソロン
1126068 2019103898 000a012056 Siyouni 000a014559 Solon 000a0022ae Cape Cross
1126069 2019106102 2010105827 キズナ 000a00fa35 ウォーエンブレム 000a00232a Starborough
1126070 2017102603 2008103552 ロードカナロア 1989109110 パラダイスクリーク 000a000d12 マラキム
1126071 2019100653 2011104063 サトノアラジン 2001103114 ダイワメジャー 000a001d7e Kingmambo
1126072 2018103205 000a0113a1 ディスクリートキャット 2000101426 ネオユニヴァース 000a001c1d Machiavellian

1126073 rows × 7 columns

以上で前回までで分析した情報の追加完了

スポンサーリンク

3-1.血統と入賞の関係¶

血統とはもともと強い優秀な親の血を受け継いできた証のこと。
思うに強いや優秀の基準は不明確だと考えていて、十分な成果は残せずとも番いの欠点を補完できる馬であったり、
G1などの重賞クラスで勝利しているなど様々な理由で、馬主がその馬を良いと考え交配が行われてきたと考えられる。
つまり、馬主たちは考え得る最高の組合せで能力の高い競走馬を育てたいという願望(前提)があることから、
全ての競走馬は能力の高い馬になることを期待されて生まれてきていると考えて良い。
つまり、その血統の組み合わせには意図があり、強い馬を生み出すための規則性が存在している可能性が高いと考える。
しかし、今持っている情報や分析結果だけではまだ深堀出来るほどの知見はないので、
今回は簡単に父と母または父と母父の組み合わせから競走馬の入賞の関係を紐解いていく

3-1-0.分析手順¶

  1. 父×母または父×母父の組み合わせでグループ化と統計(どちらの組み合わせを採用するか決定)
  2. 採用した組合せで産駒の単勝・連帯・複勝率を算出
  3. 算出結果を確認し、簡単なシミュレーションを行う

3-1-1.父×母または父×母父の組み合わせでグループ化と統計¶

まずはそれぞれの組合せのグループを作る

In [7]:
# 父×母のグループ
sb_columns = ["stallionId", "breedId", "stallionName", "breedName"]
dfSBg = pd.merge(
    dfstallion[["horseId", "GenID", "GenName"]].rename(
        columns={"GenID": "stallionId", "GenName": "stallionName"}),
    dfbreed[["horseId", "GenID", "GenName"]].rename(
        columns={"GenID": "breedId", "GenName": "breedName"}),
    on="horseId"
).groupby(sb_columns)
# 父×母父のグループ
sbs_columns = ["stallionId", "bStallionId", "stallionName", "bStallionName"]
dfSBSg = pd.merge(
    dfstallion[["horseId", "GenID", "GenName"]].rename(
        columns={"GenID": "stallionId", "GenName": "stallionName"}),
    dfbStallion[["horseId", "GenID", "GenName"]].rename(
        columns={"GenID": "bStallionId", "GenName": "bStallionName"}),
    on="horseId"
).groupby(sbs_columns)

統計を出す

In [8]:
# 父×母の統計
dfSBg[sb_columns].value_counts().sort_values().describe().to_frame().T
Out[8]:
count mean std min 25% 50% 75% max
count 104103.0 1.119046 0.414985 1.0 1.0 1.0 1.0 11.0
In [9]:
# 産駒数上位10組を出す
dfSBg[sb_columns].value_counts().sort_values().tail(10).to_frame().T
Out[9]:
stallionId 1997110152 1998110135 1997101264 1993109188 2002100816 1998101554 1996100292 000a000013 1997110025 2002100816
breedId 1998110151 2001104218 1999101541 1997103708 000a010eb8 2001110111 1998105812 000a00665c 1999100633 2002106979
stallionName ノボジャック クロフネ ゴールドヘイロー ダンスインザダーク ディープインパクト マンハッタンカフェ テイエムオペラオー アフリート アグネスデジタル ディープインパクト
breedName ノボサンシャイン グローバルピース ロングモニュメント ステイヤング ドナブリーニ カフェララルー テイエムシーズン ケイエイローズ モノトーン クロウキャニオン
count 7 7 7 7 7 7 8 8 8 11
In [10]:
# 父×母父の統計
dfSBSg[sbs_columns].value_counts().sort_values().describe().to_frame().T
Out[10]:
count mean std min 25% 50% 75% max
count 55810.0 2.087368 4.694967 1.0 1.0 1.0 2.0 426.0
In [11]:
# 産駒数上位10組を出す
dfSBSg[sbs_columns].value_counts().sort_values().tail(10).to_frame().T
Out[11]:
stallionId 000a00033a 1995108676 2008103552 000a011996 1999100226 000a00013a 1998101786 1999110099 1998110135 2001103460
bStallionId 000a000258 000a00033a 2002100816 000a00033a 000a00033a 000a00033a 000a00033a 000a00033a 000a00033a 000a00033a
stallionName サンデーサイレンス グラスワンダー ロードカナロア ハービンジャー タニノギムレット フレンチデピュティ ジャングルポケット シンボリクリスエス クロフネ キングカメハメハ
bStallionName ノーザンテースト サンデーサイレンス ディープインパクト サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス
count 113 115 119 150 151 177 231 308 316 426

分かり切ってはいたが、父×母より父×母父の方が偏りは激しいが組合せに対するサンプル数は各段に多いことが分かる。
よって、前々回の話からも父系の情報でみるべきという結果から、父×母父の組み合わせを採用する

もう少し深堀…¶

もう少し産駒数について突いてみる
単純にどのタイミングで産駒数が激増するのか確認してみる

In [12]:
idf: pd.DataFrame = dfSBSg[sbs_columns].value_counts().reset_index(
).set_index(sbs_columns[2:])[["count"]].sort_values("count")
idf
Out[12]:
count
stallionName bStallionName
トワイニング アフリート 1
タイキシャトル ハンティングホーク 1
シルヴァーエンディング 1
ティッカネン 1
シアトルダンサーII 1
フレンチデピュティ サンデーサイレンス 177
ジャングルポケット サンデーサイレンス 231
シンボリクリスエス サンデーサイレンス 308
クロフネ サンデーサイレンス 316
キングカメハメハ サンデーサイレンス 426

55810 rows × 1 columns

とりあえず、75%分位以降で1%分位ずつ値を見ていく

In [13]:
idf["count"].quantile(np.arange(0.75, 1.0, 0.01)).to_frame().T
Out[13]:
0.75 0.76 0.77 0.78 0.79 0.80 0.81 0.82 0.83 0.84 0.90 0.91 0.92 0.93 0.94 0.95 0.96 0.97 0.98 0.99
count 2.0 2.0 2.0 2.0 2.0 2.0 2.0 2.0 2.0 3.0 4.0 4.0 4.0 5.0 5.0 6.0 7.0 8.0 11.0 16.0

1 rows × 25 columns

まさかの99%分位ですら16頭の産駒しかいない。。。
さらに詳しく、99%分位以降0.0005%分位ずつ見ていく

In [14]:
idf["count"].quantile(np.arange(0.99, 1.0, 0.0005)).to_frame().T
Out[14]:
0.9900 0.9905 0.9910 0.9915 0.9920 0.9925 0.9930 0.9935 0.9940 0.9945 0.9955 0.9960 0.9965 0.9970 0.9975 0.9980 0.9985 0.9990 0.9995 1.0000
count 16.0 16.0 17.0 17.0 18.0 19.0 20.0 20.2415 22.0 23.0 25.0 26.0 28.0 30.0 33.0 37.0 42.0 51.0 69.0955 426.0

1 rows × 21 columns

更に99.9%分位ですら100頭にも到達しないのは、凄まじい偏りぐあい
逆にこれだけ偏りがあるってことは、人気の組み合わせであったりなどの意図が隠れているのではないかと考えられる

どこまでを分析対象とするか¶

どういう調べ方をするかではあるんだけど、絞り込むための考え方として、
① ある程度実績のある産駒がいて
② 出走回数が平均的に走っている産駒がいて、
③ 出走情報のDataFrameで分析可能な産駒であること
としたい。

というのも、全体の出走情報で集計したときに競走馬が中央競馬で出走する回数の中央値が6回だったので、
半数が6回も走らないことから、それ以上を走っている競走馬の実力を見るのが妥当であるのと、
1頭当たりの出走回数のサンプル数を確保するのが狙い。

あとは、G1勝利した産駒がいることは、それだけ強い産駒を輩出できる可能性がある組合せであるので、
G1勝利した産駒がいる組み合わせにだけ絞り込む

ここでは2000年から2023年までのG1を1回以上制覇した競走馬
かつ 出走回数が6回以上の競走馬
かつ 1998年以降に生まれた競走馬について
父×母父の組み合わせに該当する組み合わせを採用する

In [15]:
# というわけでG1勝利馬だけのデータに絞り込む
dfhcnt = df.groupby("horseId")["horseId"].value_counts()
horseIdList = dfhcnt[dfhcnt >= 6].index.tolist()
df["birthH"] = df["horseId"].str[:4].astype(int)
dfG1 = df[df["raceGrade"].isin([8]) & df["label"].isin([1]) & df["horseId"].isin(
    horseIdList) & (df["birthH"] > 1997)][sbs_columns[2:]].drop_duplicates(sbs_columns[2:])
dfG1.T
Out[15]:
41734 42346 59801 61441 62104 81704 87213 87865 101013 102549 1079986 1086943 1091750 1092540 1094341 1096442 1097251 1099882 1117708 1121438
stallionName ダンシングブレーヴ サンデーサイレンス フレンチデピュティ トニービン トニービン サンデーサイレンス セクレト ティンバーカントリー サクラバクシンオー ラストタイクーン ドゥラメンテ Lemon Drop Kid ロードカナロア モーリス キタサンブラック ディープインパクト ドゥラメンテ サトノクラウン ドゥラメンテ ハービンジャー
bStallionName リヴリア マルゼンスキー Classic Go Go Blushing Groom Nureyev Law Society サクラユタカオー トニービン ラッキーソブリン サンキリコ オルフェーヴル Giant’s Causeway サクラバクシンオー Unbridled’s Song Motivator Royal Anthem Reckless Abandon マンハッタンカフェ More Than Ready ダイワメジャー

2 rows × 269 columns

絞り込んだもので産駒数を確認

In [16]:
idfG1 = idf.loc[dfG1.values.tolist()]
idfG1.loc["All", "count"] = idfG1["count"].sum()
idfG1.sort_values("count").T
Out[16]:
stallionName マンハッタンカフェ トウカイテイオー ザグレブ ウォーニング Scat Daddy ケイムホーム Kingmambo Frankel オルフェーヴル トワイニング ルーラーシップ サンデーサイレンス グラスワンダー ハービンジャー フレンチデピュティ ジャングルポケット シンボリクリスエス クロフネ キングカメハメハ All
bStallionName ジャッジアンジェルーチ Nijinsky Rainbow Quest Darshaan Deputy Minister Northern Afleet ラストタイクーン ヘネシー フォーティナイナー アグネスタキオン ディープインパクト ノーザンテースト サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス サンデーサイレンス
count 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 105.0 113.0 115.0 150.0 177.0 231.0 308.0 316.0 426.0 5371.0

1 rows × 270 columns

上手くいってそうなので、これで分析を進める
意外と対象の産駒が1頭だけにもかかわらずG1勝利している競走馬もいるようだ

3-1-2.採用した組合せで産駒の単勝・連帯・複勝率を算出¶

この分析の意義は、前節で絞り込んだG1勝利した産駒がいる血統の組み合わせに対して、
他の産駒についてもどのくらいの能力を見込めるのかを評価できると考えてのことである
つまり、G1勝利した産駒がいたがその血統の組み合わせが100頭いてそのうち1頭だけG1勝利できた場合と、
50頭いて10頭がG1勝利できた場合とでは、その組み合わせに対する期待が大きく変わってくる
そのため、G1勝利できた血統の組み合わせについて、単勝・連帯・複勝率を算出することで、
勝負強さだったりの好成績を残せる産駒を輩出できるかどうかを知ることが出来ると考えている

算出関数の作成¶

In [17]:
# inLabelで指定した着順に対して、target_columnsで指定した血統の組み合わせでdfGを集計する
# 集計方法は2種類で各産駒の平均値かカウント数を選択できる
def calcurate_winrate_by_blood(
    dfG: pd.DataFrame,
    inLabel: list[int],
    target_columns=["stallionName", "bStallionName"]
) -> tuple[pd.DataFrame]:
    dfG["win"] = dfG["label"].isin(inLabel).astype(int)
    dflist = []
    dflist2 = []
    idf: pd.DataFrame = dfG.groupby(
        target_columns+["horseId"])["win"].mean().rename("winrate").to_frame()
    dflist += [idf.reset_index(level=len(target_columns))]
    idf: pd.DataFrame = dfG.groupby(
        target_columns+["horseId"])["win"].sum().rename("wincount").to_frame()
    dflist += [idf.droplevel(level=len(target_columns)), dfG.groupby(
        target_columns+["horseId"])["win"].count().rename("racecount").droplevel(level=len(target_columns))]

    idf = idf.reset_index().groupby(target_columns)["wincount"]
    dflist2 += [(idf.sum()/dfG.groupby(target_columns)
                 ["win"].count()).rename("winrate").to_frame()]
    idf: pd.DataFrame = dfG.groupby(
        target_columns+["horseId"])["win"].sum().rename("wincount").to_frame()
    dflist2 += [idf.reset_index().groupby(target_columns)["wincount"].sum().sort_values().to_frame(),
                dfG.groupby(target_columns)["win"].count().rename("racecount")]
    return pd.concat(dflist, axis=1), pd.concat(dflist2, axis=1)

単勝率を出してみる¶

In [18]:
dfG = df.copy()
dfG = dfG.set_index(sbs_columns[2:]).loc[idfG1.index.tolist()[
    :-1]].reset_index()
idf1, idf1_des = calcurate_winrate_by_blood(dfG, [1])
idf1
Out[18]:
horseId winrate wincount racecount
stallionName bStallionName
American Pharoah More Than Ready 2017110151 0.545455 6 11
More Than Ready 2019110125 0.090909 1 11
Bernstein Grand Slam 2005110186 0.133333 2 15
Cozzene Seeking the Gold 2004110114 0.176471 3 17
Seeking the Gold 2006110089 0.000000 0 3
ワイルドラッシュ トニービン 2013105926 0.000000 0 1
ヴィクトワールピサ Pistolet Bleu 2013105582 0.333333 2 6
Pistolet Bleu 2014105619 0.000000 0 6
Pistolet Bleu 2017104861 0.062500 1 16
Pistolet Bleu 2018104726 0.000000 0 5

5339 rows × 4 columns

簡単に統計を出してみる

In [19]:
# 産駒ごとの成績
idf1.describe().T
Out[19]:
count mean std min 25% 50% 75% max
winrate 5339.0 0.087335 0.133667 0.0 0.0 0.0 0.136364 1.0
wincount 5339.0 1.404570 1.969043 0.0 0.0 0.0 2.000000 13.0
racecount 5339.0 12.446713 10.571684 1.0 4.0 9.0 18.000000 72.0

結果から産駒の単勝率は平均8%程度で、G1勝利馬を出したとてその他の産駒が強いという訳でもなさそうである。
というか中央値で見ると単勝率・単勝回数ともに0であることから、産駒の良しあしもかなりあることが分かる。

連対率を出してみる¶

In [20]:
idf2, idf2_des = calcurate_winrate_by_blood(dfG, [1, 2])
# 産駒ごとの成績
idf2.describe().T
Out[20]:
count mean std min 25% 50% 75% max
winrate 5339.0 0.158105 0.190925 0.0 0.0 0.111111 0.257143 1.0
wincount 5339.0 2.587938 3.344789 0.0 0.0 1.000000 4.000000 20.0
racecount 5339.0 12.446713 10.571684 1.0 4.0 9.000000 18.000000 72.0

結果から産駒の連対率は平均15%程度で、中央値で見ても11%とそれなりに善戦しているように見える。
血統が良いとそれだけ馬券に絡める素質があるのだと考えられそう。
とはいえ、4分の1は全く連対にも入れないようなので、参考程度の範疇になるだろうか・・・

複勝率を出してみる¶

In [21]:
idf3, idf3_des = calcurate_winrate_by_blood(dfG, [1, 2, 3])
# 産駒ごとの成績
idf3.describe().T
Out[21]:
count mean std min 25% 50% 75% max
winrate 5339.0 0.225078 0.232210 0.0 0.0 0.1875 0.371761 1.0
wincount 5339.0 3.662109 4.475035 0.0 0.0 2.0000 6.000000 31.0
racecount 5339.0 12.446713 10.571684 1.0 4.0 9.0000 18.000000 72.0

連対率と大きく変わらない結果である
特徴的な結果でない以上考察は割愛

スポンサーリンク

3-2.簡単にルールベースで回収率のシミュレーション¶

血統からその産駒が勝てる勝てないの特徴を割り出すのは結構難しい。
というか、血統だけをみて勝てる馬を見分ける人なんてほとんどいないのではとも思う。
ほとんどは、他の競走馬との兼合いによるレース展開だとか、
騎乗する騎手やレース条件との相性だとかを過去の成績から考え、
その馬の基礎値を見るという意味合いで血統を考慮するのではないかと思う

なので、血統だけを見てその馬が走れるかそうでないかを判断するのは、新馬戦までを対象とすることにする

3-2-1.シミュレーションの条件¶

1. 扱うデータ
新馬戦を対象
2. 評価方法
年度別にレース条件ごとの単勝収益を見る
3. 産駒の勝率計算方法
対象の年度に対して過去15年間の産駒の新馬戦の成績を使って予測する
4. ベットする条件(ルール;規則)
① 十分に産駒の出走回数に実績がある(racecountが10以上にあてはまる)こと
血統の産駒の勝率が最も高いものを選択(同率の場合はどちらも選択)
5. シミュレーション期間
予測対象期間は2019年から2023年とする

3-2-2.産駒の成績で単勝を賭けた場合の結果を集計する関数を定義¶

In [22]:
def calcurate_betperformance_by_blood(start=2019, end=2023):
    span = 15
    target_blood_cross = ["stallionId", "bStallionId"]

    dflist, dflist2 = [], []
    for bet_year in range(start, end+1):
        target_year_list = list(range(bet_year-span, bet_year))
        # 集計対象のレースに絞り込み、過去出走分も考慮する
        horseIdList = df[df["raceDate"].dt.year.isin(
            target_year_list)]["horseId"].unique()
        idf = df[df["horseId"].isin(horseIdList) & (
            df["raceDate"].dt.year < bet_year)]

        _, idfdes = calcurate_winrate_by_blood(
            idf[idf["raceDetail"].str.contains(r"新馬", regex=True)], [1], target_blood_cross)

        # 予測対象データ
        dftarget = df[
            # 予測対象年
            df["raceDate"].dt.year.isin([bet_year]) &
            # 新馬戦のみ
            df["raceDetail"].str.contains(r"新馬", regex=1)
        ].set_index(target_blood_cross)
        # 父×母父の組み合わせの勝率のデータを追加
        dftarget["winrate"] = idfdes["winrate"]
        # 父×母父の組み合わせの合計出走数のデータを追加
        dftarget["racecount"] = idfdes["racecount"]
        dftarget = dftarget.reset_index(
        )[dftarget.columns.tolist() + target_blood_cross]

        # レース条件ごとに集計
        for g, idfg in dftarget.groupby(["field", "dist_cat"]):
            # ベット条件を追加
            # ①の条件
            idfg["bet"] = idfg["racecount"] > 9
            # ②の条件
            idfg["bet"] = idfg[idfg["bet"]].groupby(
                "raceId")["winrate"].rank() < 2
            # ①かつ②でないものを全てFalseにする
            idfg["bet"].fillna(False, inplace=True)

            dflist2 += [idfg]

            # 単勝に賭けた場合の成績を算出
            # 収益
            profit = idfg[idfg["bet"] &
                          idfg["label"].isin([1])]["odds"].sum()*100
            # 的中率
            hitRate = (
                (idfg[idfg["bet"]]["label"].isin([1])).mean()*100).round(1)
            # 的中数
            hitNum = (idfg["bet"] & idfg["label"].isin([1])).sum()
            # ベット回数
            betNum = max(1, idfg["bet"].sum())
            # ベット率
            betRate = (100*idfg["bet"].mean()).round(1)
            dflist += [
                pd.DataFrame(
                    list(g)+[int(profit),
                    f"{round(profit/betNum, 1)}%", hitNum, f"{hitRate}%", betNum, f"{betRate}%"],
                    index=["馬場", "距離カテゴリ", "収益", "回収率", "的中数", "的中率", "ベット回数", "ベット率"],
                    columns=[bet_year]
                ).T
            ]

    dfsummary = pd.concat(dflist).reset_index(names="年度").set_index(
        ["馬場", "距離カテゴリ", "年度"]).sort_index()
    return dfsummary, pd.concat(dflist2, ignore_index=True).sort_values(["raceDate", "raceId", "number"])

3-2-3.集計の実行と結果の確認¶

In [23]:
dfsummary, dfbet = calcurate_betperformance_by_blood()

dfsg = dfsummary.reset_index().groupby(["馬場", "距離カテゴリ"])
(dfsg["収益"].sum()/dfsg["ベット回数"].sum()
 ).astype(float).round(1).rename("回収率").to_frame().T
Out[23]:
馬場
距離カテゴリ M S I M S
回収率 134.0 93.6 88.0 68.2 75.8

かなり泥臭く行った
全体の集計結果をみるとダートのマイル距離で回収率が130%を超えている

3-2-4.ダートのマイル距離の年度別結果を見てみる¶

In [24]:
dfsummary.loc[("ダ", "M")]
Out[24]:
収益 回収率 的中数 的中率 ベット回数 ベット率
年度
2019 1100 18.6% 1 1.7% 59 5.8%
2020 14969 241.5% 5 8.1% 62 6.2%
2021 9240 188.6% 6 12.2% 49 5.4%
2022 7539 137.1% 7 12.7% 55 5.7%
2023 3470 75.4% 4 8.7% 46 4.8%

上記の結果を見るに、ほとんどの年度で回収率100%を超えられている
しかし、2020年以降は年度を追うごとに回収率が下がっていることが見て取れる。
とはいえベット回数と的中回数が少ないというのもあり、再現性の高いやり方かと言われるとそうでもないのが瑕

3-2-5.裏にありそうな原因を考えてみる¶

単純にサンプル数が少ないので、すべての結果がたまたまであるとも言えるが、
敢えて理由を作るのであれば、2個ぐらい考えられて
1つ目が、2019年のダートのマイルは全く稼げておらず、
血統の勝率をみる戦法は注目されてなかったが2020年で回収率が241%と2倍のリターンが来るという結果から、
多くの競馬予想家が目を付け出し、オッズの低下でアルファを取れなくなってきたのだろうと。

Tip:
なぜなら、この集計方法は血統ごとの勝率だけを見ているからで、
これはExcelなどの表計算ソフトさえあれば簡単に割り出せることから、
3年の間にバレてしまって利益が出せなくなったと考えられるからである。

2つ目が、年度ごとにデビューする新馬たちの種牡馬や繫殖牝馬の親たちに大きな変化がこの3年間で起きたことで、
2020年から2022年までにデビューした新馬たちを産んだ親たちにたまたま過去の血統と似たような特徴が出ており、
2023年以降の新馬を産んだ親には過去の血統ごとの勝率の関係が当てはまらなくなってしまったのではないかと考えられる。

Tip:
この2つ目の仮説が正しいのであれば、その血統の流れみたいなものが
特定の期間で繰り返されている可能性があることを示唆しているとも考えられる。
この仮説の簡単な確認方法としては、
先の分析の対象期間を2008年ぐらいから長めにとって調べてみて回収率の変遷を見ると良い

どことなく2つ目の方は深堀しても良いように思う。
別の機会があれば取り上げることとする

Tip:
実際にディープインパクトが種牡馬に移行した2012年からその産駒が出てきた2015年の新馬戦で見ると、
ディープインパクト産駒の新馬が軒並み1番人気となっていた。
中にはそのような産駒が過度に人気していて、去年まで好成績していた産駒の人気が薄くなった影響で、
収益が伸びるというのは可能性としてあり得そうであるため深堀する価値はありそう。

3-2-6.最後にダートのマイルでどの人気に賭けていたのか確認¶

In [32]:
dfbet["year"] = dfbet["raceDate"].dt.year
idfbet = dfbet[dfbet["field"].isin(
    ["ダ"]) & dfbet["dist_cat"].isin(["M"]) & dfbet["bet"]]
dflist = []
for year, idfbg in idfbet.groupby("year"):
    dflist += [idfbg["favorite"].value_counts().rename(year).sort_index().to_frame().T]
print("人気別ベット回数分布")
display(pd.concat(dflist).fillna(0).convert_dtypes().T.sort_index().T)

idfbet2 = dfbet[dfbet["field"].isin(
    ["ダ"]) & dfbet["dist_cat"].isin(["M"]) & dfbet["bet"] & dfbet["label"].isin([1])]
dflist = []
for year, idfbg in idfbet2.groupby("year"):
    dflist += [idfbg["favorite"].value_counts().rename(year).sort_index().to_frame().T]
print("人気別的中回数分布")
display(pd.concat(dflist).fillna(0).convert_dtypes().T.sort_index().T)
人気別ベット回数分布
favorite 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
2019 3 6 3 3 4 7 4 5 1 1 5 2 8 2 3 2
2020 7 7 1 3 7 6 4 3 3 4 5 5 1 2 2 2
2021 3 6 2 2 1 8 4 3 3 0 7 4 2 2 2 0
2022 5 3 6 4 5 5 5 3 5 2 5 0 1 4 2 0
2023 3 4 2 7 2 8 3 3 5 3 2 1 0 2 1 0
人気別的中回数分布
favorite 1 2 3 4 5 6 7 8 10 15
2019 0 0 0 0 1 0 0 0 0 0
2020 2 1 0 0 0 0 1 0 0 1
2021 1 0 1 0 0 1 1 2 0 0
2022 2 1 1 1 0 1 0 0 1 0
2023 2 0 0 0 0 2 0 0 0 0

結果を見るに結構漫勉なく色んな人気にベットしていることが分かる
的中したものをみると、10番人気や15番人気を当てているなどかなり尖った結果をしている

3-2-7.10番人気を当てているレースを見てみる¶

In [26]:
display_columns = ["raceId", "place", "field", "distance", "weather", "condition", "number", "label", "favorite",
                   "odds", "horseName", "stallionName", "bStallionName", "b2StallionName"]
In [33]:
# 実際に10番人気以降にベットして的中したレースに絞り込み
idfbet2 = idfbet2[idfbet2["favorite"].isin([10])]
target_raceId_list = idfbet2["raceId"].unique()
idfbet2[display_columns]
Out[33]:
raceId place field distance weather condition number label favorite odds horseName stallionName bStallionName b2StallionName
12529 202205030606 東京 1400 11 1 10 32.8 モルチャン ヘニーヒューズ フジキセキ サクラバクシンオー

母父にフジキセキが、父にヘニーヒューズがいる
他にどういった馬が出走していたのか確認

In [35]:
# 10番人気を当てた最近のレースを見てみる
idf10 = df[df["raceId"].isin([target_raceId_list[-1]])]
idf10[display_columns].sort_values("favorite")
Out[35]:
raceId place field distance weather condition number label favorite odds horseName stallionName bStallionName b2StallionName
1056445 202205030606 東京 1400 12 2 1 3.1 アドバンスファラオ American Pharoah Tiznow Devil His Due
1056449 202205030606 東京 1400 16 6 2 5.1 スクーバー ヘニーヒューズ ゴールドアリュール Proud Citizen
1056434 202205030606 東京 1400 1 15 3 6.8 ナリノビスケッツ マインドユアビスケッツ フレンチデピュティ サンデーサイレンス
1056440 202205030606 東京 1400 7 4 4 7.8 マスグラバイト ヘニーヒューズ ワイルドラッシュ サンデーサイレンス
1056436 202205030606 東京 1400 3 13 5 9.1 ウォーターレモン Lemon Drop Kid Stormy Atlantic Carson City
1056447 202205030606 東京 1400 14 9 6 16.5 コスモグングニール ヘニーヒューズ アドマイヤムーン Danzig
1056448 202205030606 東京 1400 15 8 7 21.4 シャトーボビー シャンハイボビー ジャングルポケット ゴールドアリュール
1056437 202205030606 東京 1400 4 5 8 22.3 カタリナマリー マジェスティックウォリアー ロードカナロア サンデーサイレンス
1056439 202205030606 東京 1400 6 14 9 23.2 モノクロームスター デクラレーションオブウォー アグネスタキオン Cadeaux Genereux
1056444 202205030606 東京 1400 11 1 10 32.8 モルチャン ヘニーヒューズ フジキセキ サクラバクシンオー
1056443 202205030606 東京 1400 10 11 11 62.6 ワタシハマジョ ドレフォン ディアブロ コマンダーインチーフ
1056446 202205030606 東京 1400 13 10 12 63.7 サラサブルー ダンカーク ディープインパクト アサティス
1056435 202205030606 東京 1400 2 3 13 87.7 サノノウォーリア ベストウォーリア フサイチコンコルド ブライアンズタイム
1056441 202205030606 東京 1400 8 7 14 89.9 ディーズメイト コパノリッキー Forest Wildcat Rahy
1056442 202205030606 東京 1400 9 12 15 178.8 ショウリノシナリオ メイショウボーラー アドマイヤムーン ブラックホーク
1056438 202205030606 東京 1400 5 16 16 186.6 アネモネポルト バンブーエール マイニング トウショウボーイ

結果からちらほら見たことのある血統がいるように見える。
産駒の勝率を出してみる

In [36]:
_, idfdes = calcurate_winrate_by_blood(df[df["raceDate"].dt.year.isin(list(
    range(2022-15, 2022))) & df["raceDetail"].str.contains(r"新馬", regex=True)], [1])

さっきの10番人気を当てたレース情報に加える(産駒の勝率データがない場合は削除)

In [37]:
idfs = pd.merge(idf10, idfdes.reset_index(), on=[
    "stallionName", "bStallionName"], how="inner")[display_columns[:-3]+["winrate", "racecount"]].sort_values("favorite")
pd.merge(idfs, idfbet[["raceId", "horseName", "bet"]],
         on=["raceId", "horseName"], how="left")
Out[37]:
raceId place field distance weather condition number label favorite odds horseName winrate racecount bet
0 202205030606 東京 1400 16 6 2 5.1 スクーバー 0.20 5 NaN
1 202205030606 東京 1400 7 4 4 7.8 マスグラバイト 1.00 1 NaN
2 202205030606 東京 1400 14 9 6 16.5 コスモグングニール 0.25 4 NaN
3 202205030606 東京 1400 11 1 10 32.8 モルチャン 0.08 25 True
4 202205030606 東京 1400 13 10 12 63.7 サラサブルー 0.00 5 NaN
5 202205030606 東京 1400 9 12 15 178.8 ショウリノシナリオ 0.00 1 NaN

というわけで、蓋を開けるとたまたまベット条件に当てはまっていただけであるというのは否めない結果である。
とはいえ、他の競馬予想モデルとの違いは、ベットする条件がルールベースで行われていることである。
つまり、その馬を選択した理由を明確に答えられるのがルールベースの良いところである。
しかし、その一方で人が考え出せるルール(今回でいう勝率が一番高い競走馬に賭けるなど(1))には限界があり、
そのルールを採用する意図((
1)の意図はなるべく勝てる馬を選ぶためだとか)が明確で単純になりがちなため、
すぐにオッズに対する割安感がなくなる可能性が高いと思われる(=その結果回収率が安定しない)

スポンサーリンク

3-3.一旦まとめ¶

3-3-1.分かったこと¶

血統と入賞の関係を見て分かるように、
血統だけではその馬が将来勝てる馬になるかは実際に出走してみないと分からないのだろうと考える。(自明ではある)
しかし、回収率の分析結果で見るに一部のダートのレース条件で極端に勝てていることから、
芝とダートでは前提が大きく違っている可能性があるように思う
もしくは、芝で大活躍していた親の産駒がダートに出走した際に過度に人気が集まっているとかで、
ダートに強い血統の競走馬が低く評価され、結果高い回収率につながっていたのかもしれない。

3-3-2.次回やること¶

2つの決定事項を決めるために以下の目的でモデルを作成する

確認したいこと
1. 芝とダート問題
これまでの分析で芝とダートでは血統の段階から大きく性質が違うように見える
初期のころの簡易分析でも芝とダートでは馬場状態が与える影響から違うことも分かっている
そのため、芝とダートでテストデータを分けるべきではと考え検証したいのが動機
2. 血統どこまで入れれば良いか問題
血統の父と母父のみ取り上げて3回にわたって分析してきた
産駒数や成長度合い、回収率と様々な切り口で血統を味見した結果、
血統だけの情報でも一部の条件で高い回収率を見込めることが分かったので、
血統の情報は入れるべきなのは自明である
問題はどこまでの情報を考慮すべきかであるが、
これといった解決が思い浮かばないので検証したいのが動機

コメント

タイトルとURLをコピーしました