導入
競馬AIでは、各馬の過去レース履歴をどう特徴量にするかが設計の核心です。これまでは「1走前の着順」「2走前の人気」「直近5走の平均着順」「直近5走の3着内率」のように、過去走を横持ちにしたり、平均値に集計したりする方法が一般的です。
ただし、それだけだと「どんな条件で走った結果なのか」という文脈が落ちやすいと感じました。たとえば、「良馬場の得意な競馬場で1着」と「重馬場の苦手な距離で1着」は同じ「1着」でも意味が違います。
そこで今回は、word2vec と同じ発想で、過去レース結果を Embedding(意味ベクトル)に変換する実験をしてみます。
word2vec 的に考えるとはどういうことか
単語の並び(文章)を大量に与える → 近くに現れる単語は意味が似ている → 各単語の意味ベクトルを学習する
【競馬に当てはめると】
馬ごとの過去走結果の並び(履歴)を大量に与える → 近くに出てくるイベントは似た文脈を持つ → 各イベントの意味ベクトルを学習する
具体的には、1走分のレース結果を「レース結果イベントトークン」として表現します。
例:
PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID
馬の過去走をトークンの系列にすると、文章のように並べることができます。このイベント系列を Word2Vec に渡して、イベント間の「意味の近さ」を学習します。
最後に、各馬の直近 n 走のイベント Embedding を平均または加重平均することで、1頭分の固定長履歴ベクトルが得られます。これを競馬AIの特徴量として使います。
今回作るもの
- 1走分のレース結果を イベントトークン に変換する
- 馬ごとにイベントトークンを日付順に並べた イベント系列 を作る
- Word2Vec(skip-gram)で イベント Embedding を学習する
- 直近5走・10走の Embedding を平均して 各馬の履歴ベクトル を作る
- その履歴ベクトルを
hist_emb_5_00〜hist_emb_5_15のような特徴量列にする
今回使ったデータ
- 使用DB:
common.db(ローカルSQLite) - 使用テーブル:
result_table - 使用期間:2019/01/01〜2024/12/31
- 読み込み件数:899,785件 → クリーニング後:796,495件
- 除外条件:取消・失格・除外・中止(103,290件)、着順が数値でない行
- 重複(レースID×馬名):0件
- ばんえい除外後(距離≥800m)の系列数:39,016馬
今回のDBには 馬ID・horse_id・血統登録番号などの一意識別子カラムが存在しません。そのため
groupby('馬名') を使っています。同名の別馬が存在する場合、履歴が混在する可能性があります。実運用では一意な馬IDを使うことを強く推奨します。
レース結果イベントをどう作るか
数値をそのまま使わず、意味のある区間にビン分けします。数値の小さな差(1着差 4.5 と 5.0 など)を同じカテゴリに束ねることで、語彙数を抑えて Word2Vec が「似た文脈」を学習しやすくします。
| 分類 | トークン | 範囲 |
|---|---|---|
| 距離帯 | DIST_SPRINT | 〜1200m |
| 距離帯 | DIST_MILE | 1300〜1700m |
| 距離帯 | DIST_MIDDLE | 1800m〜 |
| 人気帯 | POP_1 | 1番人気 |
| 人気帯 | POP_2_3 | 2〜3番人気 |
| 人気帯 | POP_4_6 | 4〜6番人気 |
| 人気帯 | POP_7PLUS | 7番人気以下 |
| 着順帯 | FINISH_WIN | 1着 |
| 着順帯 | FINISH_TOP3 | 2〜3着 |
| 着順帯 | FINISH_MID | 4〜8着 |
| 着順帯 | FINISH_LOSE | 9着以下 |
| 着差帯 | MARGIN_WIN | 1着(0秒) |
| 着差帯 | MARGIN_CLOSE | 0〜3秒 |
| 着差帯 | MARGIN_SMALL | 3〜10秒 |
| 着差帯 | MARGIN_MID | 10〜20秒 |
| 着差帯 | MARGIN_LARGE | 20秒超 |
| 上り帯 | LATE_FAST | 〜38.0秒 |
| 上り帯 | LATE_MID | 38.1〜41.0秒 |
| 上り帯 | LATE_SLOW | 41.1秒以上 |
これらを組み合わせて1走分のトークンを作ります:
出現回数が多いトークンの例:
| イベントトークン | 出現回数 |
|---|---|
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_CLOSE | 18337 |
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_MID|MARGIN_CLOSE | 16078 |
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_2_3|FINISH_MID|MARGIN_CLOSE | 7823 |
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_CLOSE | 7584 |
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_2_3|FINISH_TOP3|MARGIN_CLOSE | 6048 |
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_TOP3|MARGIN_CLOSE | 6033 |
| PLACE_園田|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW | 4303 |
| PLACE_名古屋|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW | 4179 |
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_1|FINISH_WIN|MARGIN_WIN | 3160 |
| PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_2_3|FINISH_WIN|MARGIN_WIN | 3089 |
Word2Vec でイベント Embedding を学習する
馬ごとの過去走トークン列を「文章」のように並べ、Word2Vec(skip-gram)に渡します。近くに出てくるイベント同士は「似た文脈で起きやすい結果」として近いベクトルになります。
| パラメータ | 値(今回設定) |
|---|---|
| vector_size | 16 |
| window | 3 |
| min_count | 5 |
| sg | 1 |
| workers | 4 |
| epochs | 20 |
| seed | 42 |
今回の学習結果:語彙数 8,262トークン(出現5回未満は除外)
各馬の履歴ベクトルを作る
これにより、hist_emb_5_00〜hist_emb_5_15 の 16列の特徴量が得られます(vector_size=16 の場合)。
データリーク対策
各行の履歴ベクトルは、「その行より前のレースのイベントだけ」を使って計算します。今走の event_token は含めません。
今回は全期間のデータで Word2Vec を学習していますが、厳密なバックテストでは、検証期間の未来データを使って Embedding を学習すると情報漏洩に近い状態になります。
本格運用では「学習期間のデータだけで Word2Vec を学習し、検証期間に適用する」か、「時系列分割ごとに Word2Vec を学習し直す」ことを推奨します。今回の記事では仕組みの説明を目的とし、この点を今後の検証課題としています。
実データで試してみる(サンプル馬:ハバネロ)
ハバネロ は通常距離レース(800m以上)で 157戦の出走データがあります(2019〜2024年)。最新レースの直前時点での履歴ベクトルを計算します。
直近10走のイベント履歴
| 順番 | 日付 | 場所 | 距離 | 馬場状態 | 人気 | 着順 | 1着差 | event_token |
|---|---|---|---|---|---|---|---|---|
| 148走目 | 2024/04/28 | 水沢 | 1300 | 良 | 5 | 7.0 | 28.799999999999997 | PLACE_水沢|DIST_MILE|SURFACE_良|POP_4_6|FINISH_MID|MARGIN_LARGE|LATE_SLOW |
| 149走目 | 2024/05/05 | 盛岡 | 1200 | 良 | 9 | 11.0 | 21.6 | PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_MID |
| 150走目 | 2024/05/12 | 盛岡 | 1200 | 良 | 8 | 4.0 | 4.6 | PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_SMALL|LATE_MID |
| 151走目 | 2024/05/19 | 盛岡 | 1200 | 良 | 5 | 12.0 | 42.2 | PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW |
| 152走目 | 2024/05/26 | 盛岡 | 1200 | 良 | 4 | 3.0 | 28.799999999999997 | PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_TOP3|MARGIN_LARGE|LATE_MID |
| 153走目 | 2024/06/02 | 水沢 | 1300 | 稍重 | 2 | 11.0 | 48.199999999999996 | PLACE_水沢|DIST_MILE|SURFACE_稍重|POP_2_3|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW |
| 154走目 | 2024/09/09 | 水沢 | 1300 | 良 | 10 | 11.0 | 46.0 | PLACE_水沢|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_MID |
| 155走目 | 2024/09/16 | 水沢 | 1400 | 良 | 8 | 11.0 | 77.6 | PLACE_水沢|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW |
| 156走目 | 2024/09/23 | 水沢 | 1300 | 重 | 7 | 8.0 | 44.4 | PLACE_水沢|DIST_MILE|SURFACE_重|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW |
| 157走目 | 2024/09/30 | 水沢 | 1400 | 良 | 8 | 8.0 | 50.8 | PLACE_水沢|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW |
最新走のトークン例:PLACE_水沢|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW
履歴 Embedding ベクトル(直近5走・10走)
| 種別 | dim_00 | dim_01 | dim_02 | dim_03 | dim_04 | dim_05 | dim_06 | dim_07 | dim_08 | dim_09 | dim_10 | dim_11 | dim_12 | dim_13 | dim_14 | dim_15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 直近5走平均 | -0.8654 | 0.435 | 0.5912 | 0.6036 | -0.296 | 0.2373 | -1.9735 | 0.4611 | 1.1899 | -0.7629 | 0.5155 | -0.3387 | 0.0518 | 1.3105 | 0.4389 | 0.2794 |
| 直近10走平均 | -0.7963 | 0.5204 | 0.6052 | 0.7363 | -0.5378 | 0.2356 | -2.0428 | 0.4091 | 1.1432 | -0.8574 | 0.5005 | -0.3671 | -0.0446 | 1.2402 | 0.3029 | 0.1965 |
| 直近5走加重平均 | -1.1326 | 0.3763 | 0.6367 | 0.682 | -0.2243 | 0.2211 | -1.9591 | 0.626 | 1.0687 | -0.7963 | 0.5749 | -0.1711 | 0.0368 | 1.4763 | 0.4146 | 0.1853 |
類似イベントの確認
Embedding がそれらしい表現を学習できているかを確認します。これはあくまで「モデルが学習したイベント同士の近さの参考」です。類似度が高いからといって、予測精度を保証するものではありません。
「PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID」に近いイベント
| イベントトークン | コサイン類似度 |
|---|---|
| PLACE_園田|DIST_MILE|SURFACE_稍重|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID | 0.9827 |
| PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_TOP3|MARGIN_SMALL|LATE_MID | 0.982 |
| PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_TOP3|MARGIN_CLOSE|LATE_MID | 0.9798 |
| PLACE_園田|DIST_MILE|SURFACE_重|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID | 0.9752 |
| PLACE_園田|DIST_MILE|SURFACE_重|POP_1|FINISH_TOP3|MARGIN_CLOSE|LATE_MID | 0.9693 |
| PLACE_園田|DIST_MILE|SURFACE_良|POP_2_3|FINISH_WIN|MARGIN_WIN|LATE_MID | 0.9684 |
| PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_MID|MARGIN_MID|LATE_MID | 0.9657 |
| PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_MID|MARGIN_SMALL|LATE_MID | 0.9643 |
「PLACE_園田|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW」に近いイベント
| イベントトークン | コサイン類似度 |
|---|---|
| PLACE_園田|DIST_MILE|SURFACE_稍重|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW | 0.9915 |
| PLACE_園田|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW | 0.9756 |
| PLACE_園田|DIST_MILE|SURFACE_重|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW | 0.9624 |
| PLACE_園田|DIST_MILE|SURFACE_稍重|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW | 0.9577 |
| PLACE_園田|DIST_MILE|SURFACE_不良|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW | 0.9559 |
| PLACE_園田|DIST_MILE|SURFACE_不良|POP_7PLUS|FINISH_LOSE|MARGIN_MID|LATE_SLOW | 0.9514 |
| PLACE_園田|DIST_MILE|SURFACE_重|POP_7PLUS|FINISH_TOP3|MARGIN_SMALL|LATE_SLOW | 0.9497 |
| PLACE_園田|DIST_MILE|SURFACE_不良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW | 0.9475 |
shift+rolling vs Embedding:簡易性能比較
Embedding 特徴量が予測に貢献するかを確認するため、LightGBM を使った簡易比較実験を行いました。目標変数は 3着内(1着〜3着 = 1、それ以外 = 0) です。
実験の設定
- 学習期間:2019〜2022年(470,451件)
- テスト期間:2023〜2024年(242,418件)
- 評価指標:ROC-AUC(1着〜3着を正例とした2値分類)
- モデル:LightGBM(n_estimators=300, max_depth=5)
・ここで使う Embedding は「2022年末時点の各馬の過去走履歴」をカットオフとして計算しています。テスト期間中に馬が更新した履歴は反映されていません。
・Word2Vec 自体は全期間データで学習しています。厳密なバックテストでは学習期間のみで Word2Vec を学習する必要があります。
・この比較は「特徴量設計の傾向を把握するための参考実験」です。最終的な評価には時系列交差検証などが必要です。
比較結果
| 特徴量セット | 特徴量数 | テストAUC |
|---|---|---|
| A: shift+rolling | 13 | 0.8127 |
| B: Embedding(16次元) | 16 | 0.695 |
| C: 組み合わせ | 29 | 0.8134 |
コード:shift/rolling 特徴量の作成
コード:Embedding の付与(学習期間の履歴のみ使用)
・今回のシミュレーション実験では、shift+rolling 特徴量が主要な予測力を持ちます。Embedding はそれに補完的な情報を加える形になります。
・「C: 組み合わせ」のAUCが「A: shift+rolling」を上回る場合、Embedding 特徴量に追加的な情報が含まれていることになります。
・Embedding の効果は、特徴量数・ビン設計・Word2Vec のハイパーパラメータによっても変わります。継続的な検証が必要です。
やってみて分かったこと
- 平均着順や3着内率とは違い、条件と結果の組み合わせをベクトル化できます。「良馬場のマイルで3着内」と「重馬場の短距離で3着内」を別のイベントとして表現できます。
- イベントトークンのビン分け設計(距離帯・人気帯・着差帯)によって結果が大きく変わります。ビンの粒度の最適化が重要な検討課題です。
- Word2Vec の語彙として出てくるトークンを見ると、実際のデータ分布を反映したものになっています。
- 直近5走と10走でベクトルが異なるため、どの窓幅が予測に有効かはバックテストで検証する必要があります。
- Embedding 特徴量が実際の予測精度に貢献するかは、LightGBM などのモデルに入れて検証する必要があります。
注意点・限界
- Embedding は「意味を持つように見えても」、必ずしも人間の直感と一致するとは限りません。
- 類似イベントが近いベクトルを持っていても、同じ予測結果になるわけではありません。
min_count=5で出現回数の少ないトークンを除外しています。珍しい条件(特殊な競馬場・极端な距離など)は Embedding に含まれません。- トークンを細かくしすぎるとスパースになり、逆に粗くしすぎると情報が落ちます。バランスの調整が必要です。
- 馬ID がないため、同名馬が混在する可能性があります。
- 今回の Embedding 学習は全期間のデータを使っているため、厳密なリーク対策にはなっていません(今後の課題)。
- 予測精度への実際の貢献度は別途バックテストで検証が必要です。
まとめ
- 過去レース履歴を Embedding することで、単純な平均や横持ち特徴量とは異なる表現を作ることができます。
- 1走分の結果を「レース結果イベントトークン」として、馬ごとの履歴を文章のように並べ、Word2Vec でイベント間の意味を学習します。
- 直近 n 走の Embedding を平均することで、各馬に固定長の履歴ベクトル(
hist_emb_5_00〜hist_emb_5_15)が付きます。 - データリーク対策(今走の結果を含めない)とビン分け設計が重要なポイントです。
- 今後は、この Embedding 特徴量を既存モデルに追加し、予測精度やキャリブレーションへの影響を検証していきます。
実装コード
horse_history_embedding.py の全文(クリックで展開)
"""
horse_history_embedding.py
各馬の過去レース履歴を Word2Vec でベクトル化するモジュール。
設計方針:
- 1走分のレース結果を「レース結果イベントトークン」に変換する
- 馬ごとにトークンを日付順に並べ、文章のような系列を作る
- Word2Vec (skip-gram) でイベント Embedding を学習する
- 各馬の直近 n 走の Embedding を平均して履歴ベクトルを作る
- データリーク対策: 今走の結果を含めず、今走より前のイベントだけを使う
重要な注意点:
- このモジュールは 馬名 で groupby する。
同名の別馬が存在する場合、履歴が混在する可能性がある。
実運用では一意な馬IDを使うことを強く推奨する。
- result_table には 馬ID / horse_id / 血統登録番号 などの
一意識別子カラムは存在しない(2026/07 確認)。
"""
import sqlite3
import warnings
import numpy as np
import pandas as pd
from pathlib import Path
from typing import Optional
# gensim は optional dependency
try:
from gensim.models import Word2Vec
GENSIM_AVAILABLE = True
except ImportError:
GENSIM_AVAILABLE = False
Word2Vec = None
warnings.warn(
"gensim が未インストールです: pip install gensim",
stacklevel=2,
)
SPECIAL_FLAGS = ['失', '取', '除', '中', '取消レース', '失格レース', '除外レース', '中止レース']
# ----------------------------------------------------------------
# Word2Vec デフォルトパラメータ
# ----------------------------------------------------------------
WORD2VEC_PARAMS = {
'vector_size': 16, # Embedding の次元数(次元を増やすと表現力が上がるが学習データも必要)
'window': 3, # 前後何走を「文脈」として見るか(window=3 → 前後3走)
'min_count': 5, # 出現回数がこれ未満のイベントトークンは学習から除外
'sg': 1, # 1=skip-gram(中心イベントから周辺を予測), 0=CBOW(逆方向)
'workers': 4, # 並列学習スレッド数
'epochs': 20, # 全データを何周学習するか
'seed': 42, # 再現性確保
}
# ================================================================
# 1. DB確認
# ================================================================
def inspect_db(db_path: str) -> None:
"""SQLite DB のテーブル一覧とカラム一覧を表示する。"""
with sqlite3.connect(db_path) as conn:
cur = conn.cursor()
tables = cur.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
print(f"=== テーブル一覧 ({db_path}) ===")
for (tname,) in tables:
print(f"\nTABLE: {tname}")
cols = cur.execute(f"PRAGMA table_info('{tname}')").fetchall()
for col in cols:
print(f" [{col[0]:3d}] {col[1]:<30} {col[2]}")
# ================================================================
# 2. データ読み込み
# ================================================================
def load_data(
db_path: str,
start_date: int = 20190101,
end_date: int = 20241231,
) -> pd.DataFrame:
"""result_table から Embedding に使うカラムを読み込む。"""
base_cols = [
'日付', 'レースID', 'レース番号', '場所', 'レース名',
'距離', '馬場状態', '天候', '同走馬の数',
'馬名', '馬番', '人気', '単勝', '斤量',
'着順', '1着差', '上り',
]
all_cols = base_cols + SPECIAL_FLAGS
q = ', '.join(f'"{c}"' for c in all_cols)
with sqlite3.connect(db_path) as conn:
df = pd.read_sql_query(
f'SELECT {q} FROM result_table'
f' WHERE "日付" >= {start_date} AND "日付" <= {end_date}',
conn,
)
print(f"読み込み: {len(df):,}件")
return df
# ================================================================
# 3. 前処理
# ================================================================
def preprocess(df: pd.DataFrame) -> tuple:
"""
前処理:
- 特殊成績行の除外(取消・失格・除外・中止)
- 着順を数値化(1〜99の範囲のみ有効)
- 日付を datetime 型に変換
- 数値型カラムの型変換
- 馬名・日付・レースID でソート
- レースID×馬名 の重複確認
Returns
-------
(cleaned_df, stats_dict)
"""
n_raw = len(df)
# 特殊成績除外
for f in SPECIAL_FLAGS:
df[f] = df[f].fillna(0).astype(int)
mask_ok = df[SPECIAL_FLAGS].sum(axis=1) == 0
# 着順数値化
df['着順_num'] = pd.to_numeric(df['着順'], errors='coerce')
mask_valid = df['着順_num'].between(1, 99)
n_flag = int((~mask_ok).sum())
n_invalid = int((~mask_valid & mask_ok).sum())
df = df[mask_ok & mask_valid].copy()
df['着順'] = df['着順_num'].astype(float)
df = df.drop(columns=['着順_num'])
# 日付変換
df['日付'] = pd.to_datetime(df['日付'].astype(str), format='%Y%m%d')
# 数値型変換
for c in ['人気', '斤量', '距離', '同走馬の数']:
df[c] = pd.to_numeric(df[c], errors='coerce')
for c in ['1着差', '上り', '単勝']:
df[c] = pd.to_numeric(df[c], errors='coerce')
# ソート(馬名 → 日付 → レースID → レース番号)
df = df.sort_values(
['馬名', '日付', 'レースID', 'レース番号']
).reset_index(drop=True)
n_dup = int(df.duplicated(subset=['レースID', '馬名']).sum())
n_clean = len(df)
stats = {
'n_raw': n_raw, 'n_clean': n_clean,
'n_flag': n_flag, 'n_invalid': n_invalid, 'n_dup': n_dup,
}
print(
f"除外(特殊成績): {n_flag:,}件 着順無効: {n_invalid:,}件 重複: {n_dup}件"
)
print(f"クリーニング後: {n_clean:,}件")
return df, stats
# ================================================================
# 4. イベントトークン作成
# ================================================================
def _dist_band(dist) -> str:
if pd.isna(dist): return 'DIST_UNKNOWN'
d = float(dist)
if d <= 1200: return 'DIST_SPRINT' # 〜1200m(短距離)
if d <= 1700: return 'DIST_MILE' # 1300〜1700m(マイル前後)
return 'DIST_MIDDLE' # 1800m〜(中長距離)
def _pop_band(pop) -> str:
if pd.isna(pop): return 'POP_UNKNOWN'
p = int(pop)
if p == 1: return 'POP_1' # 1番人気
if p <= 3: return 'POP_2_3' # 2〜3番人気
if p <= 6: return 'POP_4_6' # 4〜6番人気
return 'POP_7PLUS' # 7番人気以下
def _finish_band(rank) -> str:
if pd.isna(rank): return 'FINISH_UNKNOWN'
r = float(rank)
if r == 1: return 'FINISH_WIN' # 1着
if r <= 3: return 'FINISH_TOP3' # 2〜3着
if r <= 8: return 'FINISH_MID' # 4〜8着
return 'FINISH_LOSE' # 9着以下
def _margin_band(margin, rank) -> str:
if pd.isna(margin): return 'MARGIN_UNKNOWN'
m = float(margin)
if not pd.isna(rank) and float(rank) == 1: return 'MARGIN_WIN' # 1着
if m <= 3.0: return 'MARGIN_CLOSE' # 0〜3秒(接戦)
if m <= 10.0: return 'MARGIN_SMALL' # 3〜10秒(小差負け)
if m <= 20.0: return 'MARGIN_MID' # 10〜20秒(中差)
return 'MARGIN_LARGE' # 20秒超(大差)
def _surface_token(surface) -> str:
if pd.isna(surface): return 'SURFACE_UNKNOWN'
s = str(surface).strip()
return f'SURFACE_{s}' if s in ('良', '稍重', '重', '不良') else 'SURFACE_UNKNOWN'
def _late_band(late) -> Optional[str]:
"""上り(3ハロン)タイムのバンド。NaN の場合は None。"""
if pd.isna(late): return None
v = float(late)
if v <= 38.0: return 'LATE_FAST' # 速い上り
if v <= 41.0: return 'LATE_MID' # 中程度
return 'LATE_SLOW' # 遅い上り
def make_race_event_token(row: pd.Series) -> str:
"""
1走分のレース結果を Embedding 学習用のイベントトークンに変換する。
形式:
PLACE_{場所}|DIST_{帯}|SURFACE_{馬場}|POP_{帯}|FINISH_{帯}|MARGIN_{帯}[|LATE_{帯}]
数値をそのまま使わずビン分けする理由:
- 数値の小さな違い(1着差 4.5 と 5.0 など)を同じカテゴリに束ねる
- 語彙数を適度に抑え、スパースになるのを防ぐ
- Word2Vec が「似た文脈」を学習しやすくする
"""
place = f"PLACE_{str(row.get('場所', 'UNKNOWN')).strip()}"
dist = _dist_band(row.get('距離'))
surface = _surface_token(row.get('馬場状態'))
pop = _pop_band(row.get('人気'))
finish = _finish_band(row.get('着順'))
margin = _margin_band(row.get('1着差'), row.get('着順'))
parts = [place, dist, surface, pop, finish, margin]
late = _late_band(row.get('上り'))
if late is not None:
parts.append(late)
return '|'.join(parts)
def add_event_tokens(df: pd.DataFrame) -> pd.DataFrame:
"""DataFrame に event_token カラムを追加する。"""
df = df.copy()
df['event_token'] = df.apply(make_race_event_token, axis=1)
print(f"イベントトークン追加: ユニーク数={df['event_token'].nunique():,}")
return df
# ================================================================
# 5. 馬ごとのイベント系列作成
# ================================================================
def build_horse_event_sequences(
df: pd.DataFrame,
horse_key: str = '馬名',
token_col: str = 'event_token',
) -> list:
"""
馬ごとに日付順でevent_tokenを並べ、Word2Vecに渡せる「文章」のような系列を作る。
出力イメージ:
[
['PLACE_園田|DIST_MILE|...', 'PLACE_園田|DIST_MILE|...', ...], # 馬A
['PLACE_大井|DIST_SPRINT|...', ...], # 馬B
]
注意:
horse_key='馬名' の場合、同名の別馬が同一系列に混入する可能性がある。
実運用では一意な馬IDを使うことを推奨する。
"""
sequences = []
for _, group in df.groupby(horse_key, sort=False):
tokens = group.sort_values('日付')[token_col].tolist()
if len(tokens) >= 1:
sequences.append(tokens)
return sequences
# ================================================================
# 6. Word2Vec 学習
# ================================================================
def train_word2vec(sequences: list, **kwargs):
"""
馬ごとのイベント系列から Word2Vec モデルを学習する。
Parameters
----------
sequences : list of list of str
馬ごとの過去走イベントトークン系列
**kwargs : WORD2VEC_PARAMS を上書きするパラメータ
"""
if not GENSIM_AVAILABLE:
raise RuntimeError("gensim が未インストールです: pip install gensim")
params = {**WORD2VEC_PARAMS, **kwargs}
print(f"Word2Vec 学習中... (系列数={len(sequences):,})")
model = Word2Vec(sentences=sequences, **params)
print(f"Word2Vec 完了: 語彙数={len(model.wv):,}")
return model
# ================================================================
# 7. 履歴 Embedding 作成
# ================================================================
def make_horse_history_embedding(
history_tokens: list,
model,
n_recent: int = 5,
method: str = 'mean',
) -> np.ndarray:
"""
直近 n 走のイベント Embedding を集約して1頭分の履歴ベクトルを作る。
Parameters
----------
history_tokens : list of str
今走より前の過去イベントトークン(日付の古い順。今走を含めない)
model : 学習済み Word2Vec モデル
n_recent : 使用する直近走数(末尾 n 件を使う)
method : 'mean' or 'weighted_mean'
'weighted_mean' は直近ほど重みを大きくする
Returns
-------
np.ndarray : shape=(vector_size,)
有効なトークンが1つもない場合はゼロベクトル
"""
dim = model.vector_size
recent = history_tokens[-n_recent:] # 末尾が最新走
valid = [t for t in recent if t in model.wv]
if len(valid) == 0:
return np.zeros(dim)
vecs = np.array([model.wv[t] for t in valid]) # (n_valid, dim)
if method == 'weighted_mean':
# 直近ほど重みを大きくする(末尾=最新走の重みが最大)
weights = np.arange(1, len(valid) + 1, dtype=float)
weights /= weights.sum()
return (vecs * weights[:, None]).sum(axis=0)
else:
return vecs.mean(axis=0)
# ================================================================
# 8. データリーク対策付き全馬 Embedding 生成
# ================================================================
def build_horse_history_embeddings_safe(
df: pd.DataFrame,
model,
horse_key: str = '馬名',
n_recent: int = 5,
method: str = 'mean',
col_prefix: Optional[str] = None,
) -> pd.DataFrame:
"""
データリーク対策版の全馬履歴 Embedding 生成。
各行の履歴 Embedding は「その行より前のイベントだけ」で計算する。
今走の event_token は含めない(今走の結果を今走の特徴量に使わないため)。
実装の考え方:
1. 各馬の過去走を日付順に並べる
2. 現在行を処理する時点では、過去走リストには「前の走まで」しか含まれていない
3. Embedding を計算した後で、今走のトークンをリストに追加する
Returns
-------
pd.DataFrame
元の df と同じインデックス。カラム名: {prefix}_00, {prefix}_01, ...
"""
dim = model.vector_size
prefix = col_prefix or f'hist_emb_{n_recent}'
cols = [f'{prefix}_{d:02d}' for d in range(dim)]
result = {}
for _, group in df.groupby(horse_key, sort=False):
sorted_group = group.sort_values('日付')
past: list = [] # 今走より前の走トークン
for idx, row in sorted_group.iterrows():
emb = make_horse_history_embedding(past, model, n_recent, method)
result[idx] = emb
past.append(row['event_token']) # 今走を追加(次の行で「過去走」になる)
return pd.DataFrame.from_dict(result, orient='index', columns=cols)
# ================================================================
# 9. 類似イベント確認
# ================================================================
def show_similar_events(model, token: str, topn: int = 10) -> pd.DataFrame:
"""
指定したイベントトークンに近いトークンを表示する。
これは Embedding がそれらしい表現を学習できているかを確認するための参考です。
類似度が高いからといって、同じ予測結果になるわけではありません。
"""
if token not in model.wv:
print(f"'{token}' は語彙にありません")
return pd.DataFrame()
similar = model.wv.most_similar(token, topn=topn)
result_df = pd.DataFrame(similar, columns=['イベントトークン', 'コサイン類似度'])
result_df['コサイン類似度'] = result_df['コサイン類似度'].round(4)
return result_df
analyze.py の分析コード部分(クリックで展開)
"""
analyze.py - 馬の過去レース履歴Embeddingのデモと記事生成
horse_history_embedding.py を使って、実際の競馬DBから
各馬の過去走イベントトークンを作り、Word2Vec で Embedding を学習する。
サンプル馬の履歴ベクトルと類似イベントを確認し、article.html を生成する。
"""
import sys
import html as html_lib
import numpy as np
import pandas as pd
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
DB_PATH = SCRIPT_DIR.parents[2] / 'common.db'
OUTPUT_DIR = SCRIPT_DIR
DATA_DIR = SCRIPT_DIR / 'data'
DATA_DIR.mkdir(parents=True, exist_ok=True)
sys.path.insert(0, str(SCRIPT_DIR))
from horse_history_embedding import (
load_data, preprocess, add_event_tokens,
build_horse_event_sequences, train_word2vec,
make_horse_history_embedding,
build_horse_history_embeddings_safe,
show_similar_events,
make_race_event_token,
WORD2VEC_PARAMS,
)
# ================================================================
# 1. データ読み込みと前処理
# ================================================================
df_raw = load_data(str(DB_PATH), 20190101, 20241231)
df, stats = preprocess(df_raw.copy())
n_raw = stats['n_raw']
n_clean = stats['n_clean']
n_flag = stats['n_flag']
n_dup = stats['n_dup']
date_min = df['日付'].min().strftime('%Y/%m/%d')
date_max = df['日付'].max().strftime('%Y/%m/%d')
n_horses_total = df['馬名'].nunique()
print(f"期間: {date_min}〜{date_max}")
print(f"ユニーク馬数: {n_horses_total:,}")
# ================================================================
# 2. イベントトークン作成
# ================================================================
df = add_event_tokens(df)
token_counts = df['event_token'].value_counts().reset_index()
token_counts.columns = ['イベントトークン', '出現回数']
n_unique_tokens = df['event_token'].nunique()
print(f"\nユニークイベントトークン数: {n_unique_tokens:,}")
print("出現回数上位5件:")
print(token_counts.head(5).to_string(index=False))
# ================================================================
# 3. 馬ごとのイベント系列作成
# ================================================================
# ばんえい競馬(距離 ≤ 800m)を除いた通常レースで系列を作る
df_normal = df[df['距離'] >= 800].copy()
sequences = build_horse_event_sequences(df_normal, horse_key='馬名')
n_seqs = len(sequences)
seq_lengths = [len(s) for s in sequences]
print(f"\n系列数: {n_seqs:,}")
print(f"系列長: 中央値={np.median(seq_lengths):.1f}走, 最大={max(seq_lengths)}走, 最小={min(seq_lengths)}走")
# ================================================================
# 4. Word2Vec 学習
# ================================================================
model = train_word2vec(sequences)
vocab_size = len(model.wv)
# ================================================================
# 5. サンプル馬を選ぶ(通常距離・10走以上・出走数上位)
# ================================================================
horse_counts = df_normal.groupby('馬名').size()
valid_horses = horse_counts[horse_counts >= 10].sort_values(ascending=False)
SAMPLE_HORSE = valid_horses.index[0]
n_horse_races = int(valid_horses.iloc[0])
print(f"\nサンプル馬: {SAMPLE_HORSE}(通常距離レース {n_horse_races}戦)")
horse_df = df_normal[df_normal['馬名'] == SAMPLE_HORSE].sort_values('日付').reset_index(drop=True)
tokens_all = horse_df['event_token'].tolist()
# 直近10走のイベントトークン(今走を含む)
recent_10_rows = horse_df.tail(10).reset_index(drop=True)
recent_10_tokens = tokens_all[-10:]
print("\n=== 直近10走のイベントトークン ===")
for i in range(len(recent_10_rows)):
row = recent_10_rows.iloc[i]
tok = recent_10_tokens[i]
print(f" {row['日付'].strftime('%Y/%m/%d')} {row['場所']}{int(row['距離'])}m "
f"人気{row['人気']:.0f}着 着順{row['着順']:.0f}着: {tok}")
# ================================================================
# 6. 履歴 Embedding(データリーク対策: 今走を除いた過去走のみ使う)
# ================================================================
# 直近10走の最新行(今走)を除いたすべての過去走トークン
past_all = tokens_all[:-1] # 今走(最終行)を除く
emb_5_mean = make_horse_history_embedding(past_all, model, n_recent=5, method='mean')
emb_10_mean = make_horse_history_embedding(past_all, model, n_recent=10, method='mean')
emb_5_wt = make_horse_history_embedding(past_all, model, n_recent=5, method='weighted_mean')
print(f"\n=== 直近5走平均 Embedding(先頭8次元) ===")
print(np.round(emb_5_mean[:8], 4))
print(f"=== 直近10走平均 Embedding(先頭8次元) ===")
print(np.round(emb_10_mean[:8], 4))
# ================================================================
# 7. 類似イベント確認
# ================================================================
# 「良馬場マイル1着」に近いトークンを探す
vocab_tokens = list(model.wv.key_to_index.keys())
cands_win = [t for t in vocab_tokens if 'FINISH_WIN' in t and 'SURFACE_良' in t and 'DIST_MILE' in t]
cands_lose = [t for t in vocab_tokens if 'FINISH_LOSE' in t and 'DIST_MILE' in t]
q_win = cands_win[0] if cands_win else vocab_tokens[0]
q_lose = cands_lose[0] if cands_lose else vocab_tokens[1]
similar_win_df = show_similar_events(model, q_win, topn=8)
similar_lose_df = show_similar_events(model, q_lose, topn=8)
print(f"\n=== '{q_win}' に近いイベント ===")
print(similar_win_df.to_string(index=False))
print(f"\n=== '{q_lose}' に近いイベント ===")
print(similar_lose_df.to_string(index=False))
# ================================================================
# 8. CSV 保存
# ================================================================
token_counts.head(200).to_csv(
DATA_DIR / 'event_token_counts.csv', index=False, encoding='utf-8-sig'
)
# サンプル馬の直近10走履歴
hist_rows = []
for i in range(len(recent_10_rows)):
row = recent_10_rows.iloc[i]
hist_rows.append({
'順番': f"{len(horse_df) - 10 + i + 1}走目",
'日付': row['日付'].strftime('%Y/%m/%d'),
'場所': row.get('場所', ''),
'距離': int(row.get('距離', 0)),
'馬場状態': row.get('馬場状態', ''),
'人気': row.get('人気', ''),
'着順': row.get('着順', ''),
'1着差': row.get('1着差', ''),
'event_token': recent_10_tokens[i],
})
hist_df = pd.DataFrame(hist_rows)
hist_df.to_csv(DATA_DIR / 'sample_horse_event_history.csv', index=False, encoding='utf-8-sig')
# 履歴 Embedding
dim = model.vector_size
emb_records = []
for name, emb in [('直近5走平均', emb_5_mean), ('直近10走平均', emb_10_mean), ('直近5走加重平均', emb_5_wt)]:
rec = {'種別': name}
for d in range(dim):
rec[f'dim_{d:02d}'] = round(float(emb[d]), 5)
emb_records.append(rec)
emb_csv_df = pd.DataFrame(emb_records)
emb_csv_df.to_csv(DATA_DIR / 'sample_horse_history_embedding.csv', index=False, encoding='utf-8-sig')
# 類似イベント
similar_win_df.to_csv(DATA_DIR / 'similar_event_tokens_win.csv', index=False, encoding='utf-8-sig')
similar_lose_df.to_csv(DATA_DIR / 'similar_event_tokens_lose.csv', index=False, encoding='utf-8-sig')
print("\n=== CSV保存完了 ===")
# トークン例(記事用)
sample_token_ex = make_race_event_token(horse_df.iloc[-1])
print(f"最新走のトークン例: {sample_token_ex}")
# ================================================================
# 9. 特徴量比較実験(shift/rolling vs Embedding vs 組み合わせ)
# ================================================================
import lightgbm as lgb
from sklearn.metrics import roc_auc_score
TRAIN_CUTOFF = pd.Timestamp('2023-01-01')
# 9-1. shift/rolling 特徴量
df_comp = df_normal.sort_values(['馬名', '日付', 'レースID']).reset_index(drop=True)
for n in range(1, 6):
for col in ['着順', '人気', '1着差']:
df_comp[f'{n}走前_{col}'] = df_comp.groupby('馬名')[col].shift(n)
df_comp['3着内'] = (df_comp['着順'] <= 3).astype(int)
df_comp['直近5走_平均着順'] = (
df_comp.groupby('馬名')['着順']
.transform(lambda s: s.shift(1).rolling(5, min_periods=1).mean())
)
df_comp['直近5走_3着内率'] = (
df_comp.groupby('馬名')['3着内']
.transform(lambda s: s.shift(1).rolling(5, min_periods=1).mean())
)
# 9-2. Embedding 特徴量(全行・データリーク防止版)
# build_horse_history_embeddings_safe は「その行より前の走歴のみ」を使うため
# 学習行・テスト行とも正しいリーク防止済みEmbeddingが得られる
print("Embedding 特徴量を生成中... (全行分・しばらくお待ちください)")
import time as _time
_t0 = _time.time()
emb_all_df = build_horse_history_embeddings_safe(df_comp, model, n_recent=5)
print(f" 完了: {_time.time()-_t0:.1f}秒")
df_comp = df_comp.join(emb_all_df)
emb_dim = model.vector_size
emb_cols_list = list(emb_all_df.columns) # ['hist_emb_5_00', ..., 'hist_emb_5_15']
test_mask = df_comp['日付'] >= TRAIN_CUTOFF
train_mask = ~test_mask
# 9-3. 特徴量グループ定義
shift_roll_feats = (
[f'{n}走前_{c}' for n in range(1, 4) for c in ['着順', '人気', '1着差']] +
['直近5走_平均着順', '直近5走_3着内率', '人気', '距離']
)
emb_feats = emb_cols_list
# 9-4. 学習・評価(2019-2022 train, 2023-2024 test)
X_tr_base = df_comp[train_mask]
X_te_base = df_comp[test_mask]
y_tr = df_comp.loc[train_mask, '3着内']
y_te = df_comp.loc[test_mask, '3着内']
n_comp_train = len(X_tr_base)
n_comp_test = len(X_te_base)
print(f"比較実験: 学習={n_comp_train:,}件 テスト={n_comp_test:,}件")
LGB_PARAMS = dict(
n_estimators=300, learning_rate=0.05,
max_depth=5, num_leaves=31,
min_child_samples=50, random_state=42, verbose=-1
)
comp_results = []
for label, feats in [
('A: shift+rolling', shift_roll_feats),
('B: Embedding(16次元)', emb_feats),
('C: 組み合わせ', shift_roll_feats + emb_feats),
]:
X_tr = X_tr_base[feats].fillna(0.0)
X_te = X_te_base[feats].fillna(0.0)
clf = lgb.LGBMClassifier(**LGB_PARAMS)
clf.fit(X_tr, y_tr)
prob = clf.predict_proba(X_te)[:, 1]
auc = float(roc_auc_score(y_te, prob))
n_f = len(feats)
comp_results.append({'特徴量セット': label, '特徴量数': n_f, 'テストAUC': round(auc, 4)})
print(f" {label}: AUC={auc:.4f} (特徴量{n_f}個)")
comp_df = pd.DataFrame(comp_results)
comp_df.to_csv(DATA_DIR / 'feature_comparison.csv', index=False, encoding='utf-8-sig')
print("\n比較実験完了")
