0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ChatGPTと作る競馬予想AI (8)

Posted at

プログラミング初心者がChatGPTを使って競馬予想AIを作ることで、生成AIとプログラミングについて学んでいく企画の第8回です。

前回の記事はこちら

前回の記事では動的・静的サイトについて学びつつ、馬のレース結果のHTMLを取得しました。
今回はこのHTMLをスクレイピングして、特徴量をまとめたテーブルを作っていきましょう。

特徴量のスクレイピング

前回取得したページは以下の馬ごとの成績がまとめられたページです。
image.png
競走成績のテーブルをpandasのDataFrameにまとめていけばよいのですが、列によっては有料会員でなければ見れないものや、注意してデータを抽出しなければならないものがありそうです。

始めに各列について方針をまとめておきましょう。
まず有料の情報(映像や馬場指数)は列の情報として除外しましょう。
(馬場指数やタイム指数は金を払っても追加すべき有効な情報であれば、ぜひ今後追加していきましょう)
続いて日付や開催といった数字ではない情報の扱いですが、これらを正しく抽出して特徴量として使うには正規表現やエンコーディングといったテクニックが必要になります。

少し正規表現についてChatGPTに聞いてみましょう。
↓↓↓ChatGPTの回答↓↓↓

正規表現とは「文字列のパターンを指定して検索・抽出・置換するための記法」です。たとえば「数字3桁」や「メールアドレスの形」などを、記号で簡潔に表現できます。

🧠 正規表現って何?

  • 正規表現(Regular Expression、略して regex)は、文字列の中から特定のパターンを探すためのルールです。
  • 例:abc → 「abc」という文字列にマッチ
    "123abc456" の中から "abc" を見つける

🔤 よく使う記号(メタ文字)

記号 意味
. 任意の1文字(改行以外) a.c"abc", "axc"
* 直前の文字が0回以上繰り返す a*"", "a", "aaa"
+ 直前の文字が1回以上繰り返す a+"a", "aaa"
? 直前の文字が0回または1回 a?"", "a"
{n,m} n〜m回繰り返す a{2,4}"aa", "aaa"
[abc] aまたはbまたはc [cb]at"cat", "bat"
[^abc] a,b,c以外の1文字 [^0-9] → 数字以外
[0-9] 数字1桁 [0-9]{3} → 数字3桁
^ 行頭 ^Hello"Hello world"
$ 行末 world$"Hello world"

🔍 実用例

  • 電話番号抽出\d{3}-\d{4}-\d{4}090-1234-5678
  • メールアドレス検証\w+@\w+\.\w+
  • URL検出https?://\w+\.\w+(/[^\s]*)?

🧪 Pythonでの使い方

import re

text = "お問い合わせは support@example.com まで"
match = re.search(r"\w+@\w+\.\w+", text)

if match:
    print(match.group())  # → support@example.com

📚 初心者向けにおすすめの記事


正規表現は最初は記号が多くて難しく感じますが、「パターンを探す道具」として使いこなせると、データ処理や検索が一気に楽になります。試してみたい具体例があれば、それに合わせて書き方を提案しますよ!


↑↑↑以上がChatGPTの回答↑↑↑

このようにスクレイピングで特定の文字を抽出したいときは、正規表現で指定してあげることが必要になります。

正規表現の説明は検討中です。
とりあえずコードはできたものとして次の画像をご確認ください。

image.png
一見表ができているように見えますが、馬場(course_condition)の列が空欄になりその後ろが一個ずつずれています。
image.png
変数colsにはtdタグが付いている要素が代入されているのですが、これを1つずつ確認していくと馬場のところが空欄になっています。
さらにHTMLの中身を確認すると非表示のような名前を付けられたtdタグがあります。
image.png
これが何のためにあるかはわかりませんが、今は列をスキップすることで対応しましょう。
こうしてできた馬のレース結果をスクレイピングする関数がこちらです。

# what: HTMLを解析してhorse resultテーブルをDataFrame化する関数
# for:  特徴量抽出のため
# in:   取得したhtml(.bin)
# out:  レース結果テーブル(DataFrame)
def parse_horse_html(bin_path):
    with open(bin_path, "rb") as f:
            html_text = f.read().decode("EUC-JP", errors="ignore")
    soup = BeautifulSoup(html_text, "html.parser")

    # --- レース成績表の抽出 ---
    result_table = soup.find("table", class_="db_h_race_results")
    
    if not result_table:
        raise ValueError("Horse result table was not found in the HTML.")

    rows = result_table.find_all("tr")[1:]  # ヘッダを除外して行ごとにデータを取得
    horse_data = []

    for row in rows:
        cols = row.find_all("td")
        # --- 日付を取得 ---
    date_tag = row.find("a", href=re.compile(r"/race/list/(\d+)"))
    race_date = re.search(r"/race/list/(\d+)", date_tag["href"]).group(1) if date_tag else None

    # --- race idを取得 ---
    race_id_tag = row.find("a", href=re.compile(r"/race/(\d+)"))
    race_id = re.search(r"/race/(\d+)", race_id_tag["href"]).group(1) if race_id_tag else None

    # --- jockey idを取得 ---
    jockey_id_tag = row.find("a", href=re.compile(r"/jockey/result/recent/(\d+)"))
    jockey_id = re.search(r"/jockey/result/recent/(\d+)", jockey_id_tag["href"]).group(1) if jockey_id_tag else None

    horse_data.append([
        race_date,                     # レース日付
        cols[1].get_text(strip=True),  # 開催
        cols[2].get_text(strip=True),  # 天気
        cols[3].get_text(strip=True),  # R
        race_id,                       # race_id
        cols[6].get_text(strip=True),  # 頭数
        cols[7].get_text(strip=True),  # 枠番
        cols[8].get_text(strip=True),  # 馬番
        cols[9].get_text(strip=True),  # オッズ
        cols[10].get_text(strip=True), # 人気
        cols[11].get_text(strip=True), # 着順
        jockey_id,                     # 騎手
        cols[13].get_text(strip=True), # 斤量
        cols[14].get_text(strip=True), # 距離
        cols[16].get_text(strip=True), # 馬場
        cols[18].get_text(strip=True), # タイム
        cols[19].get_text(strip=True), # 着差
        cols[21].get_text(strip=True), # 通過
        cols[22].get_text(strip=True), # ペース
        cols[23].get_text(strip=True), # 上り
        cols[24].get_text(strip=True), # 馬体重
        # cols[26].get_text(strip=True), # 勝ち馬
        cols[28].get_text(strip=True), # 賞金
    ])
    horse_df = pd.DataFrame(horse_data, columns=[
        "race_date", "place", "weather", "race_no", "race_id", "num_of_horses", "frame_no", "horse_no",
        "odds", "popularity", "finish_position", "jockey_id", "weight", "distance", "course_condition",
        "time", "margin", "passing", "pace", "final_3f", "horse_weight", "prize_money"
    ])

    return horse_df

この関数の出力を確認すると、しっかり全ての列が正しく抽出できていることがわかります。

image.png

さて、続いてこのテーブルを使って特徴量を作っていきましょう。

前処理

特徴量としてはコースごとのタイムや着順といったものが考えられますが、とりあえずあらゆる列を特徴量として使えるように前処理を行っていきます。

  • 開催 (place)
    • {開催回数}{競馬場}{日目}の順で記載されている。これらの情報はrace_idに含まれているためわざわざ処理しなくてもよい。(必要な時はrace_idを分割)

race_idの構成
{年}{場コード}{開催回数}{日目}{レース番号}
ex) race_id=202306050811の場合

  • 2023 → 年
  • 06 → 中山競馬場(場コード)
  • 05 → 開催回数
  • 08 → 日目
  • 11 → レース番号
  • 天気 (weather)
    • 4種類の天気を晴→0, 曇→1, 小雨→2, 雨→3, 雪→4の対応でエンコーディング
  • 距離 (distance)
    • 一文字目を 芝→0, ダート(ダ)→1, 障害(障)→2でエンコーディング、それ以降の4桁の数字は距離を表しておりそのまま
  • 馬場 (course_condition)
    • 良→0, 稍→1, 重→2, 不→3でエンコーディング

馬場(course condition)
芝・ダートの路盤がどれくらい水分を含んでいるか(=走りやすさ) を表す指標

馬場 意味 タイム 有利な馬
乾いて最良 速い スピード型
稍重 少し湿ってる やや遅い パワー型
水を多く含む 遅い パワー・先行
不良 ぐちゃぐちゃ 大幅に遅い パワー・道悪巧者
  • タイム (time)
    • 秒数に換算(race_result tableと同じ処理)
  • 着差(margin)
    • ハナ→0, アタマ→1, クビ→2でエンコーディング。着差が大きくなると**馬の体の長さ(馬身)**で着差が表されているが、普通にfloatで保存
  • 通過、ペース (passing, pace)
    • ハイフンごとに分割しリストに格納
  • 馬体重 (horse_weight)
    • 体重と増減を分割(race_result tableと同じ処理)

これらの前処理を1つずつ関数として用意し、作成したテーブルに処理をかけていきます。

# what: 馬結果テーブルの前処理をする関数
# for:  AIモデルがうけつけられるようにする
# in:   レース結果テーブルの列(.pkl)
# out:  レース結果テーブルの列(.pkl)

def encoding_weather(weather):
    # {晴:0, 曇:1, 雨:2, 小雨:3, 雪:4, その他:np.nan}
    if pd.isna(weather): 
        return np.nan
    weather_char = str(weather)
    if weather_char == "":
        return 0
    elif weather_char == "":
        return 1
    elif weather_char == "小雨":
        return 2
    elif weather_char == "":
        return 3
    elif weather_char == "":
        return 4
    else:
        return np.nan

def parse_encoding_distance(distance):
    # 例: "芝1800" -> (0, 1800)
    # {芝:0, ダ:1, 障: 2, その他:np.nan}
    if pd.isna(distance): 
        return (np.nan, np.nan)
    
    # --- 馬場(state) ---
    state_char = str(distance[0])
    if state_char == "":
        state = 0
    elif state_char == "":
        state = 1
    elif state_char == "":
        state = 2
    else:
        state = np.nan
    
    # --- 距離(length) ---
    try:
        long = int(distance[1:])
    except:
        long = np.nan

    return (state, long)

def encoding_course_condition(condition):
    # {良:0, 稍重:1, 重:2, 不良:3, その他:np.nan}
    if pd.isna(condition): 
        return np.nan
    condition_char = str(condition)
    if condition_char == "":
        return 0
    elif condition_char == "稍重":
        return 1
    elif condition_char == "":
        return 2
    elif condition_char == "不良":
        return 3
    else:
        return np.nan

def time_to_seconds(tstr):
    # "1:51.3" -> seconds float
    try:
        if pd.isna(tstr): return np.nan
        if ":" in str(tstr):
            mm, ss = str(tstr).split(":")
            return int(mm) * 60 + float(ss)
        else:
            return float(tstr)
    except:
        return np.nan
    
def parse_margin(margin):
    # "1.1" -> 1.1 float
    # "クビ" -> 0.1 float
    # "ハナ" -> 0.05 float
    try:
        if pd.isna(margin): return np.nan
        s = str(margin)
        if s == "アタマ":
            return 0.2
        elif s == "クビ":
            return 0.1
        elif s == "ハナ":
            return 0.05
        else:
            return float(s)
    except:
        return np.nan

def parse_passing(passing):
    # "3-3-2-2" -> [3,3,2,2]
    try:
        if pd.isna(passing): return [np.nan, np.nan, np.nan, np.nan]
        parts = str(passing).split("-")
        return [int(p) for p in parts]
    except:
        return [np.nan, np.nan, np.nan, np.nan]

def parse_pace(pace):
    # "34.1-44.8" -> [34.4,44.8]
    try:
        if pd.isna(pace): return [np.nan, np.nan]
        parts = str(pace).split("-")
        return [float(p) for p in parts]
    except:
        return [np.nan, np.nan]

def parse_bodyweight(bw):
    # "494(-4)" -> weight=494, diff=-4
    try:
        s = str(bw)
        if "(" in s:
            w = int(s.split("(")[0])
            diff = int(s.split("(")[1].rstrip(")"))
        else:
            w = int(s)
            diff = np.nan
        return (w, diff)
    except:
        return (np.nan, np.nan)

この関数をDataFrameであるhourse_dfに適用します。

horse_df.drop(columns=["place"], inplace=True)
horse_df["weather"] = horse_df["weather"].apply(encoding_weather)
horse_df[["course_state", "distance_length"]] = horse_df["distance"].apply(lambda x: pd.Series(parse_encoding_distance(x)))
horse_df.drop(columns=["distance"], inplace=True)
horse_df["course_condition"] = horse_df["course_condition"].apply(encoding_course_condition)
horse_df["time"] = horse_df["time"].apply(time_to_seconds)
horse_df["margin"] = horse_df["margin"].apply(parse_margin)
horse_df[["passing_1st", "passing_2nd", "passing_3rd", "passing_4th"]] = horse_df["passing"].apply(lambda x: pd.Series(parse_passing(x)))
horse_df.drop(columns=["passing"], inplace=True)
horse_df[["pace_1st", "pace_2nd"]] = horse_df["pace"].apply(lambda x: pd.Series(parse_pace(x)))
horse_df.drop(columns=["pace"], inplace=True)
horse_df[["body_weight","body_diff"]] = horse_df["horse_weight"].apply(lambda x: pd.Series(parse_bodyweight(x)))
horse_df.drop(columns=["horse_weight"], inplace=True)

出力結果がこちら
(DataFrameで出力すると途中の列が省略されて確認できないため、to_stringを使っています)

print(horse_df[:1].to_string())
race_date  weather race_no       race_id num_of_horses frame_no horse_no odds popularity finish_position jockey_id weight  course_condition   time  margin final_3f prize_money  course_state  distance_length  passing_1st  passing_2nd  passing_3rd  passing_4th  pace_1st  pace_2nd  body_weight  body_diff
0  20240323        2      11  202407010511            16        4        8  9.8          5               4     01095     56               2.0  116.5     0.9     36.5       280.0             1             1900         11.0         11.0         11.0         10.0      29.1      36.4          490         -4

全ての列が数字データとして保存されています!
ようやくこれで機械学習に使えるデータになりました!

エンコーディングの工夫

最後におまけとして、馬場についてのエンコーディングをChatGPT聞いてみたときの回答を載せておきます。

↓↓↓ChatGPTの回答↓↓↓

馬場状態(良・稍重・重・不良)を 機械学習モデルに入れるための一般的な特徴量化(スケーリング)方法 をまとめます。

あなたの用途(競馬予想モデル)に合わせて、どれを採用すべきかも解説します。


🟩 1. 順序付きカテゴリ → 数値(Ordinal Encoding)※最も一般的

馬場状態は 「良 → 稍重 → 重 → 不良」 のように
明確な“順序”があるカテゴリなので、Ordinal Encoding が一般的です。

例:

condition_map = {
    "": 0,
    "稍重": 1,
    "": 2,
    "不良": 3
}

df['condition_encoded'] = df['condition'].map(condition_map)

メリット

  • 馬場の重さに“線形の順序性”を反映できる
  • XGBoost / LightGBM など木系では非常に相性がいい
  • シンプルで過学習しにくい

デメリット

  • 「重」と「不良」の差、「稍重」と「重」の差 が同じ扱いになる
     → 問題ないことが多いが、厳密には非線形

🟦 2. One-Hot Encoding(非推奨気味)

カテゴリを全部バラバラのベクトルにする方法。

pd.get_dummies(df['condition'], prefix='cond')

例:

cond_良 cond_稍重 cond_重 cond_不良
1 0 0 0

メリット

  • モデルに馬場の順序性を強制しない
  • 線形モデルに向いている

デメリット

  • 馬場状態には「順序」があるのに無視することになる
  • 木系モデル(XGBoost等)では逆に性能が悪化しやすい
  • 次元が増えて不利

👉 競馬の場合、あまり使いません。


🟧 3. 数値化+正規化(0〜1)

Ordinal Encoding した後に 0〜1 に圧縮する方法。

df['condition_norm'] = df['condition_encoded'] / 3.0

意味:

状態
0.0
稍重 0.33
0.66
不良 1.0

メリット

  • ニューラルネットで学習しやすい
  • 「徐々に悪化していく」連続性を表現できる

デメリット

  • 距離の差を固定してしまう(モデルが自由に学習できない)

🟥 4. 物理量ベースの「含水量」風に変換(高度)

競馬専門の人たちは、
馬場状態を 指数化 して扱うこともあります。

例(便宜的):

馬場 想定含水量% 数値化例
5% 0.05
稍重 12% 0.12
20% 0.20
不良 30% 0.30

※独自に「馬場指数」を算出する場合など。

メリット

  • 連続値として扱え、モデル性能が上がりやすい
  • 馬場状態の非線形性を自然に取り込める

デメリット

  • 自分で指数の根拠を設計する必要がある
  • データサイエンスとしてはやや複雑

🔰 結論:あなたへの最適解

あなたが使っているのはおそらく XGBoost / LightGBM / CatBoost だと思うので…


最適:Ordinal Encoding(整数化)

condition_map = {"":0, "稍重":1, "":2, "不良":3}
df['condition_encoded'] = df['condition'].map(condition_map)

これだけで十分にパフォーマンスが出ます。
多くの競馬AIもこの形式を採用しています。


必要なら…

✔ 距離、天気、脚質なども同じように数値化する方法を教えます
✔ 馬場指数を高度に数値化したい → 専門的な方法を作れます
✔ 特徴量エンジニアリング全体の流れを設計することもできます

続けますか?

↑↑↑ChatGPTの回答↑↑↑

始めの段階はOriginal Encodingで良いと思いますが、そのうち物理量ベースの指数化もやってみたいですね。

次回は今回作ったhorse_result tableをrace_result tableに結合していきます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?