JvLink To Importer

馬体重と枠順は勝率にどう効くか — bucket分析のすすめ

馬体重と枠順は勝率にどう効くか — bucket分析のすすめ

「馬体重の大幅な増減は危険」「内枠が有利」といった通説は、競馬を見ていれば自然と耳に入ってきます。しかしそれが本当にデータに現れるのか、現れるとしてどの程度なのかは、思い込みではなく手元のデータベースで確かめられます。この記事では、連続値の効き方を確認する基本手法であるbucket分析を、馬体重(bataiju / zogen_sa)と枠順(wakuban)を題材に紹介します。


bucket分析とは

bucket分析は、連続値の特徴量を区間(bucket)に区切り、各bucketの実勝率を並べて見るだけのシンプルな手法です。回帰係数やSHAP値を読む前に、まず「この変数は単調に効くのか、山型なのか、そもそも効いていないのか」を裸眼で確認できます。

ただし1つだけ重要な前提があります。交絡を避けるために層別することです。たとえば馬体重の重い馬は牡馬に多く、出走するクラスや距離も偏ります。何も揃えずに集計すると「馬体重の効果」ではなく「性別やクラスの効果」を見てしまいます。最低限、頭数やクラスなど条件を揃えた層の中で比較する、という意識を持って集計します。


データの準備: bataiju / zogen_saのパース

umagoto_race_johoから必要なカラムを取り出します。馬体重(bataiju)は3桁の文字列、増減は符号(zogen_fugo)と値(zogen_sa)に分かれています。

SELECT u.race_code,
u.umaban,
u.wakuban,
u.bataiju,
u.zogen_fugo,
u.zogen_sa,
u.kakutei_chakujun,
r.shusso_tosu,
r.grade_code
FROM umagoto_race_joho u
JOIN race_shosai r USING (race_code)
WHERE u.kakutei_chakujun BETWEEN '01' AND '18';  -- 完走馬のみ

ここで注意が必要なのは無効値です。馬体重には**「計量不能」を表す '999' 系の値**が入ることがあり、増減も初出走などで値が立たないことがあります。これらを実数として通すと、bucketの端が無効値で汚染されます。防御的なパース関数を作っておきます。

import pandas as pd
import numpy as np

def parse_bataiju(raw: str | None) -> float | None:
"""3桁文字列の馬体重をfloatに。'999'系(計不)や異常値はNone。"""
s = (raw or '').strip()
if not s.isdigit():
return None
v = int(s)
if v < 300 or v >= 700:  # 計不コードと入力異常を弾く
return None
return float(v)

def parse_zogen(fugo: str | None, sa: str | None) -> float | None:
"""符号カラムと増減値カラムを合成して符号つきの増減に。"""
s = (sa or '').strip()
if not s.isdigit():
return None
v = int(s)
if v >= 99:  # 計不・初出走など
return None
return float(-v) if (fugo or '').strip() == '-' else float(v)

df['bataiju_kg'] = df.apply(lambda r: parse_bataiju(r['bataiju']), axis=1)
df['zogen'] = df.apply(
lambda r: parse_zogen(r['zogen_fugo'], r['zogen_sa']), axis=1
)
df['is_win'] = (df['kakutei_chakujun'].astype(int) == 1).astype(int)

馬体重増減のbucket集計

増減はキリのいい固定幅で、絶対値の馬体重は分位(qcut)で区切るのが扱いやすいです。bucketごとの件数と実勝率に加えて、二項分布の標準誤差も一緒に出しておきます。

bins = [-99, -16, -8, -2, 2, 8, 16, 99]
df['zogen_bucket'] = pd.cut(df['zogen'], bins=bins)

summary = (
df.dropna(subset=['zogen'])
.groupby('zogen_bucket', observed=True)
.agg(n=('is_win', 'size'), win_rate=('is_win', 'mean'))
)
summary['se'] = np.sqrt(
summary['win_rate'] * (1 - summary['win_rate']) / summary['n']
)
print(summary.round(4))

これで「-16kg以下」「+17kg以上」のような端のbucketと中央のbucketで勝率がどう違うかが一覧になります。さらに一歩進めるなら、頭数帯やクラスで層別して同じ表を作り、層をまたいで同じ傾向が出るかを確認します。層によって向きが変わるなら、それは増減そのものではなく交絡を見ている可能性が高いサインです。


読み方の注意: 小さい差を「効く」と断定しない

bucket間で勝率に差が見えても、すぐに「効く特徴量だ」と結論するのは早計です。チェックすべきは3点あります。

  • サンプル数: 端のbucket(大幅増減など)は件数が少なく、勝率の見かけの差が標準誤差に埋もれていないかを確認します。win_rate ± 2 * se が隣のbucketと重なるなら、その差は偶然の範囲かもしれません
  • 交絡: 上で述べた通り、性別・クラス・季節(夏は自然に体重が増える)などが裏で動いていないか。層別しても傾向が残るかを見ます
  • 他の特徴量との相関: 既にモデルに入っている特徴量(休養明けフラグなど)と強く相関しているなら、bucket分析で差が見えても新しい情報はほとんど持っていないことがあります

bucket分析は「効くかどうかの最終判定器」ではなく、仮説を捨てる・残すための一次スクリーニングと捉えるのが健全です。


枠順は頭数で意味が変わる — 相対枠への正規化

枠順の分析には固有の罠があります。8頭立ての8枠と18頭立ての8枠はまったくの別物だということです。前者は最外枠、後者は中ほどの枠です。wakubanの値をそのままbucketにすると、頭数の違うレースが混ざって解釈不能になります。

対策は、頭数で割った相対枠に正規化してから区切ることです。

df['rel_waku'] = df['wakuban'].astype(int) / df['shusso_tosu'].astype(int)
df['rel_waku_bucket'] = pd.qcut(df['rel_waku'], q=5,
labels=['最内', '内', '中', '外', '大外'])

# 頭数帯で層別して相対枠ごとの勝率を見る
df['tosu_band'] = pd.cut(df['shusso_tosu'].astype(int),
bins=[0, 10, 14, 18],
labels=['少頭数', '中頭数', '多頭数'])
pivot = (
df.groupby(['tosu_band', 'rel_waku_bucket'], observed=True)['is_win']
.agg(['size', 'mean'])
)
print(pivot.round(4))

頭数帯で層別しているのは、多頭数レースほど1頭あたりの勝率の基準値が下がるためです(18頭立てなら平均勝率は約5.6%、10頭立てなら10%)。層の中で相対枠を比較すれば、この基準値の差に惑わされずに済みます。コース形態(直線の長さ、最初のコーナーまでの距離)でも傾向は変わるので、興味があれば競馬場・距離別にも掘ってみてください。


当日情報のタイミング問題

最後に、分析がうまくいって「使えそうだ」となったときの注意です。馬体重はレース当日、発走の1時間ほど前にならない と確定しない情報です。ここで確認すべきなのは、学習時と予測時で同じタイミングのデータが使えるか、という点です。

  • 当日の発走直前に予測を出す運用なら、当日馬体重を特徴量に使えます
  • 前日の夜に予想を出す運用なら、当日体重はまだ存在しません。学習データにだけ当日体重を入れると、学習時は見えていた情報が予測時には欠損する、という不整合が起きます

後者の場合は「前走の馬体重」「過去の平均体重」のような前日時点で確定している値に置き換えるか、当日体重なしで学習したモデルを別に持つ、といった設計になります。枠順も同様で、枠順確定(通常は前々日〜前日)より前に予測するなら使えません。特徴量ごとに「いつ確定する情報か」を一覧にしておくと、この種の事故を設計段階で防げます。


まとめ

  • 通説は思い込みで語らず、bucket分析(区間ごとの実勝率の比較)でデータから確かめる
  • bataijuは3桁文字列で**'999'系の計不値**が混ざる。zogen_saは符号カラム(zogen_fugo)と合成する。防御的なパース関数を通す
  • bucket間の差はサンプル数・標準誤差・交絡・既存特徴量との相関を確認してから解釈する。小さい差を「効く」と断定しない
  • 枠順は頭数で意味が変わるため、相対枠(wakuban ÷ 頭数)に正規化し、頭数帯で層別してから比較する
  • 馬体重・枠順は当日〜直前にしか確定しない情報。学習時と予測時で同じタイミングのデータが使えるかを必ず確認する

bucket分析自体はgroupbyが書ければ誰でもできます。難しいのは集計ではなく、交絡とサンプル数を意識した読み方です。まずは手元のデータベースで、気になっている通説を1つ検証してみてください。

この記事をシェア

Post