はじめに
本シリーズでは、動画として解説しなかった前処理箇所の詳細な部分を解説しています。
ロードマップ2で決定したモデル作成時の前提一覧については以下のページを参照ください。
競馬予想プログラムソフト開発の制作過程動画リスト
このNoteBookの趣旨
前処理部分の開発にあたり、前提を実現するための処理である項目1番~項目4番のソースを公開します。
※今後も前提が増えていくと思います。前提が増えるたびに本ページに追記していくのは読みやすさの観点から良くないと思いますのでページを分けます。すみません。
Notebook形式で公開していますが、実際の本ソフトで使用する前処理箇所はBookersで公開しています。(チャンネル登録で250円で購入できます。)
本Notebookと有料ソースの違いはモジュール化しているかの違いしかないです。以下の解説ソースを参照して、自分でモジュール化できる方は自力でする方がいいと思います。
250円というのはモジュール化の手間賃だと思っていただければ。(実際時給換算すると250円払った方がリーズナブルと思います。)
Bookers記事一覧ページの『ロードマップ3 – 前処理』を参照ください。
前処理箇所の作成:項目1~4の処理内容解説
0. 必要なモジュールのインポートと競馬データのロード¶
一部有料ソースを使用しています。Bookersで公開していますので、一緒に競馬予想プログラムを開発したい方はぜひ購入ください。
Bookersリンク:https://bookers.tech/post/82ee8cf4-e95d-4f67-8cef-c038dbe0c957/
import re
import pandas as pd
import sys
import warnings
sys.path.append(".")
from src.core.db.controller import getDataFrame # noqa
warnings.filterwarnings("ignore")
# 競馬データのロード
dbpath = "./data/keibadata.db"
start_year = 2010
end_year = 2023
year_list = list(range(start_year, end_year+1))
# レース結果
dfrace = pd.concat([getDataFrame(f"racedata{year}", dbpath) for year in year_list])
# レース情報
dfinfo = pd.concat([getDataFrame(f"raceinfo{year}", dbpath) for year in year_list])
# 2つのDataFrameをマージ
df = pd.merge(dfinfo, dfrace, on="raceId")
1.障害レースは除外する¶
出走間隔の算出¶
障害データを削除する前に、データを「開催日」「レースID」「馬番」でソートして、
「出走間隔」を事前に計算しておく。
# ソートするために、各種カラムのデータ型を変換する
df["raceDate"] = df["raceDate"].astype("datetime64[ns]")
df["number"] = df["number"].astype(int)
df.sort_values(["raceDate", "raceId", "number"]).reset_index(drop=True, inplace=True)
# horseIdでgroupbyし、raceDateに関してdiff関数を用いると出走間隔が出せる。
# 扱いやすいように、apply関数で数値に変換している
df["race_span"] = df.groupby("horseId")["raceDate"].diff().apply(lambda d: d.days)
障害データの削除¶
ここで使うテクニックは、DataFrameのisnaメソッドです。
よくあるやり方だと、非常に処理がかかるため基本的に障害レースを除外するときのやり方で、条件に合うデータの抽出を行います。
# バックアップ残しておく:後で戻す
dfback = df.copy()
よくあるやり方¶
特定の条件に一致するものを条件式で指定する方法
df = df[df["field"] != "障"]
print(df["field"].unique())
['ダ' '芝']
# 元に戻す
df = dfback.copy()
del dfback
効率的なやり方¶
小規模な処理では、上記のような条件式でやっても良いのですが、for文処理とかでやると結構遅いです。かなり遅くなって困ります。
そのため、ここでは以下のやり方で削除するようにしましょう。
df = df[~df["field"].isin(["障"])]
print(df["field"].unique())
['ダ' '芝']
実行時間テスト¶
それでは実際に、レースIDごとに処理したい場合を想定して、よくあるやり方と効率的なやり方で実行時間の差を見てみます。
ベンチマークテストして、共通のレースIDのデータを抽出する処理を全レース分で実行した場合の時間を見ます。
※すみません、実際に試したら恐ろしく時間がかかったので、試したい人は1行目のif文をTrueに変えて試してください。
実際にやると1時間弱はかかるので、忙しい方は結果を表にして後述しています。
if False:
import datetime
raceId_list = df["raceId"].unique().tolist()
print("レースデータ数:", len(raceId_list))
# よくあるやり方
start = datetime.datetime.now()
for raceId in raceId_list:
idf = df[df["raceId"] == raceId]
print("よくあるやり方処理時間:", (datetime.datetime.now() - start).seconds, "秒")
# 効率的なやり方
start = datetime.datetime.now()
for raceId in raceId_list:
idf = df[df["raceId"].isin([raceId])]
print("効率的なやり方処理時間:", (datetime.datetime.now() - start).seconds, "秒")
【実行結果】 レースデータ数:46573
方法 | 処理時間(秒) | 単位時間(秒/回) |
---|---|---|
よくあるやり方 | 2116 | 0.0454 |
効率的なやり方 | 898 | 0.01928 |
上記の結果から、よくあるやり方と比べて効率的なやり方の方では、処理時間が2.4倍弱早いという雲泥の差が出ているのが分かります。
本当はisinメソッドよりもさらに効率的なやり方があるのですが、こういったやり方もあるよということで紹介しました。
また、isinメソッドの良いところは、該当するデータをリストで指定できることから複雑な条件もリストで処理できるところにあります。
例えば、2つのレースIDについてまとめて見たい場合、よくあるやり方だと以下のような条件式を2つ書くことになります。
df[(df["raceId"] == "202306050911") | (df["raceId"] == "202206050911")]
当然ながら、可読性にも優れず処理時間も遅いという2重苦を抱えたコードになりますし、今は条件が2つしかないですが、これが3つ4つと増えていくと恐ろしいことになります。
一方で、効率的なやり方で行くと、以下のようなスッキリとした形で書くことができます。
df[df["raceId"].isin(["202306050911", "202206050911"])]
ぜひご活用ください。
2.失格降格データは所定の処理、取,除,中は削除¶
降格や失格となったお馬さんにはマークを付けて、該当馬が出たレースにはフラグを立てて置く処理と、
取消と除外と中止のあるデータは走破タイムがないので除外する。
最後に失格データは着順を19にし、降格データはその着順として着順データを数値に変換する。
また、注意点としてデータを削除する前に、出走頭数のデータを作成しておくこと。
出走頭数の算出¶
DataFrameのmapメソッドでは、対象のカラムにあるデータを逐次処理してくれ、Dict型を渡すとカラムのデータをKeyとし対応するValueに置き換えてくれます。
# 出走頭数の作成
horseNum_map = df.groupby("raceId")["raceId"].count().to_dict()
df["horseNum"] = df["raceId"].map(horseNum_map)
失格・降格データのマークと対応レースのフラグ立て¶
失格・降格のデータは、正規表現を用いて見つけます。正規表現が分からない方は調べて自分で動かしてみてください。
要するに、re.searchの第一引数で検索する文字列パターンを指定しており、第二引数では検索対象の文字列を表します。
re.searchは検索対象があれば該当オブジェクトを返し、なければNoneとなるので条件分岐として活用できます。
# 失格・降格データのマーク
df["rough_horse"] = df["label"].apply(lambda d: 1 if re.search(r"失|降", d) else 0)
# 対応レースのフラグ立て
rough_raceId_list = df[df["rough_horse"].astype(bool)]["raceId"].tolist()
df["rough_race"] = df["raceId"].apply(lambda d: 1 if d in rough_raceId_list else 0)
取消, 中止, 除外データの削除¶
ここでは、項目1番で解説したisinメソッドが役立ちます。
df = df[~df["label"].isin(["中", "取", "除"])].reset_index(drop=True)
# 残っている着順データの内容を確認
print(df["label"].unique())
['2' '5' '9' '14' '4' '7' '10' '6' '16' '1' '8' '13' '3' '11' '15' '12' '失' '9(降)' '17' '18' '10(降)' '5(降)' '16(降)' '12(降)' '14(降)' '8(降)' '18(降)' '11(降)' '15(降)' '2(降)' '6(降)' '7(降)' '4(降)' '13(降)' '17(降)' '3(降)']
失格データは着順を19にし、降格データはその着順として着順データを数値に変換¶
難しいことはせずに素直に正規表現で処理します。
df["label"] = df["label"].apply(
lambda d: int(
re.search(r"\d+", d.replace("失", "19")).group()
)
)
print(df["label"].unique())
[ 2 5 9 14 4 7 10 6 16 1 8 13 3 11 15 12 19 17 18]
3.数値化できるカラムは数値化する¶
数値化対象のカラムを以下に列挙します。
- ‘distance’
- ‘number’
- ‘boxNum’
- ‘label’
- ‘odds’
- ‘favorite’
- ‘age’
- ‘jweight’
- ‘time’
- ‘last3F’
- ‘weight’
- ‘gl’
# 数値化対象のカラムを列挙
target_columns = [
'distance', 'number', 'boxNum',
'label', 'odds', 'favorite', 'age',
'jweight', 'time', 'last3F', 'weight', 'gl'
]
# 数値変換
for col in target_columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# 数値化されたか確認
print(df[target_columns].info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 659574 entries, 0 to 659573 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 distance 659574 non-null int64 1 number 659574 non-null int32 2 boxNum 659574 non-null int64 3 label 659574 non-null int64 4 odds 659574 non-null float64 5 favorite 659574 non-null int64 6 age 659574 non-null int64 7 jweight 659574 non-null float64 8 time 659574 non-null float64 9 last3F 659571 non-null float64 10 weight 659573 non-null float64 11 gl 659573 non-null float64 dtypes: float64(6), int32(1), int64(5) memory usage: 57.9 MB None
4.上り3Fがないものは削除、それ以外は直近で埋合せ¶
項目3番の結果を見ると、”last3F”と”weight”と”gl”にNoneデータが含まれていることが分かるので、
実際に内容を確認する。
print(
df[df["last3F"].isna() | df["weight"].isna() | df["gl"].isna()][
["raceId", "horseName", "label", "time","last3F", "weight", "gl"]]
)
raceId horseName label time last3F weight gl 164295 201305030305 サビーナクレスタ 8 84.5 35.6 NaN NaN 242465 201506010808 ガヤルド 11 177.2 NaN 450.0 4.0 287706 201509050807 エアマチュール 18 220.4 NaN 460.0 8.0 297573 201606020601 サングラスポテト 16 184.7 NaN 516.0 0.0
それぞれのNoneデータの扱いは以下のようにする。
- last3F:該当データを削除
- 前後の値で埋める
また、これから同様にNoneのデータが出てきた場合は、2番目の対応で対処する。
実際の処理では、groupbyして対象のカラムをまとめてapplyメソッドでffillとbfillを行いNoneデータを埋めています。
処理時間が結構かかるので、開発用のソースではキャッシュを残す機能を持たせています。
# 1. last3FのNoneデータを削除
df = df[~df["last3F"].isna()].reset_index(drop=True)
# 2. 前後の値で埋める
dfisna = df[target_columns].isna().sum()
fill_columns = dfisna[dfisna>0].index.tolist()
dfhorse_fill = df.groupby("horseId")[
fill_columns].apply(
lambda x: x.ffill().bfill()).sort_index(level=1).reset_index(level=0)
dfhorse_fill = dfhorse_fill.sort_index(level=1).reset_index(level=0)
for fcol in fill_columns:
df[fcol] = dfhorse_fill[fcol]
# Noneがなくなったか確認
df[target_columns].info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 659571 entries, 0 to 659570 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 distance 659571 non-null int64 1 number 659571 non-null int32 2 boxNum 659571 non-null int64 3 label 659571 non-null int64 4 odds 659571 non-null float64 5 favorite 659571 non-null int64 6 age 659571 non-null int64 7 jweight 659571 non-null float64 8 time 659571 non-null float64 9 last3F 659571 non-null float64 10 weight 659571 non-null float64 11 gl 659571 non-null float64 dtypes: float64(6), int32(1), int64(5) memory usage: 57.9 MB
データの保存¶
# 次回以降も使うため、バイナリで残しておく
import pickle, pathlib
save_dir = pathlib.Path("./data/data_prep")
save_dir.parent.mkdir(exist_ok=True)
save_dir.mkdir(exist_ok=True)
with open(save_dir/"process1_to_4.pkl", "wb") as f:
pickle.dump(df, f)
コメント