競馬AI開発メモ:各馬の過去レース履歴をEmbeddingでベクトル化してみる

競馬AI開発

導入

競馬AIでは、各馬の過去レース履歴をどう特徴量にするかが設計の核心です。これまでは「1走前の着順」「2走前の人気」「直近5走の平均着順」「直近5走の3着内率」のように、過去走を横持ちにしたり、平均値に集計したりする方法が一般的です。

ただし、それだけだと「どんな条件で走った結果なのか」という文脈が落ちやすいと感じました。たとえば、「良馬場の得意な競馬場で1着」と「重馬場の苦手な距離で1着」は同じ「1着」でも意味が違います。

そこで今回は、word2vec と同じ発想で、過去レース結果を Embedding(意味ベクトル)に変換する実験をしてみます。

word2vec 的に考えるとはどういうことか

【word2vec の考え方】
 単語の並び(文章)を大量に与える → 近くに現れる単語は意味が似ている → 各単語の意味ベクトルを学習する

【競馬に当てはめると】
 馬ごとの過去走結果の並び(履歴)を大量に与える → 近くに出てくるイベントは似た文脈を持つ → 各イベントの意味ベクトルを学習する

具体的には、1走分のレース結果を「レース結果イベントトークン」として表現します。

例:

PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID

馬の過去走をトークンの系列にすると、文章のように並べることができます。このイベント系列を Word2Vec に渡して、イベント間の「意味の近さ」を学習します。

最後に、各馬の直近 n 走のイベント Embedding を平均または加重平均することで、1頭分の固定長履歴ベクトルが得られます。これを競馬AIの特徴量として使います。

今回作るもの

  1. 1走分のレース結果を イベントトークン に変換する
  2. 馬ごとにイベントトークンを日付順に並べた イベント系列 を作る
  3. Word2Vec(skip-gram)で イベント Embedding を学習する
  4. 直近5走・10走の Embedding を平均して 各馬の履歴ベクトル を作る
  5. その履歴ベクトルを hist_emb_5_00hist_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_MILE1300〜1700m
距離帯DIST_MIDDLE1800m〜
人気帯POP_11番人気
人気帯POP_2_32〜3番人気
人気帯POP_4_64〜6番人気
人気帯POP_7PLUS7番人気以下
着順帯FINISH_WIN1着
着順帯FINISH_TOP32〜3着
着順帯FINISH_MID4〜8着
着順帯FINISH_LOSE9着以下
着差帯MARGIN_WIN1着(0秒)
着差帯MARGIN_CLOSE0〜3秒
着差帯MARGIN_SMALL3〜10秒
着差帯MARGIN_MID10〜20秒
着差帯MARGIN_LARGE20秒超
上り帯LATE_FAST〜38.0秒
上り帯LATE_MID38.1〜41.0秒
上り帯LATE_SLOW41.1秒以上

これらを組み合わせて1走分のトークンを作ります:

def make_race_event_token(row: pd.Series) -> str: “”” 1走分のレース結果を Embedding 学習用のイベントトークンに変換する。 数値をそのまま使わずビン分けする理由: – 語彙数を適度に抑えてスパースにならないようにする – Word2Vec が「似た文脈」を学習しやすくする “”” place = f”PLACE_{str(row[‘場所’]).strip()}” dist = _dist_band(row[‘距離’]) # DIST_SPRINT / DIST_MILE / DIST_MIDDLE surface = _surface_token(row[‘馬場状態’]) # SURFACE_良 / 稍重 / 重 / 不良 pop = _pop_band(row[‘人気’]) # POP_1 / POP_2_3 / POP_4_6 / POP_7PLUS finish = _finish_band(row[‘着順’]) # FINISH_WIN / TOP3 / MID / LOSE margin = _margin_band(row[‘1着差’], row[‘着順’]) # MARGIN_WIN / CLOSE / SMALL / MID / LARGE parts = [place, dist, surface, pop, finish, margin] late = _late_band(row.get(‘上り’)) # LATE_FAST / MID / SLOW (NaN なら省略) if late: parts.append(late) return ‘|’.join(parts)

出現回数が多いトークンの例:

イベントトークン出現回数
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_CLOSE18337
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_MID|MARGIN_CLOSE16078
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_2_3|FINISH_MID|MARGIN_CLOSE7823
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_CLOSE7584
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_2_3|FINISH_TOP3|MARGIN_CLOSE6048
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_TOP3|MARGIN_CLOSE6033
PLACE_園田|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW4303
PLACE_名古屋|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW4179
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_1|FINISH_WIN|MARGIN_WIN3160
PLACE_帯広|DIST_SPRINT|SURFACE_良|POP_2_3|FINISH_WIN|MARGIN_WIN3089

Word2Vec でイベント Embedding を学習する

馬ごとの過去走トークン列を「文章」のように並べ、Word2Vec(skip-gram)に渡します。近くに出てくるイベント同士は「似た文脈で起きやすい結果」として近いベクトルになります。

def build_horse_event_sequences(df, horse_key=’馬名’, token_col=’event_token’) -> list: 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 # 例: sequences[0] の中身 # [‘PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID’, # ‘PLACE_園田|DIST_MILE|SURFACE_稍重|POP_1|FINISH_TOP3|MARGIN_CLOSE|LATE_MID’, # ‘PLACE_姫路|DIST_MILE|SURFACE_良|POP_2_3|FINISH_MID|MARGIN_SMALL|LATE_SLOW’, …]
from gensim.models import Word2Vec model = Word2Vec( sentences=sequences, # list of list of str vector_size=16, # Embedding の次元数 window=3, # 前後何走を文脈として見るか min_count=5, # 出現 5 回未満のトークンは除外 sg=1, # skip-gram(中心トークンから周辺を予測) workers=4, epochs=20, seed=42, )
パラメータ値(今回設定)
vector_size16
window3
min_count5
sg1
workers4
epochs20
seed42
skip-gram vs CBOW:skip-gram は中心のトークンから周辺を予測する方式です。レースイベントのように珍しいトークンが多い場合、skip-gram の方が性能が出やすい傾向があります。ただし、必ずしも優れるとは限りません。

今回の学習結果:語彙数 8,262トークン(出現5回未満は除外)

各馬の履歴ベクトルを作る

def make_horse_history_embedding(history_tokens, model, n_recent=5, method=’mean’): “”” 直近 n 走のイベント Embedding を集約して1頭分の履歴ベクトルを作る。 history_tokens: 今走より前の過去走トークンリスト(日付の古い順) “”” recent = history_tokens[-n_recent:] # 末尾が最新走 valid = [t for t in recent if t in model.wv] # 語彙にあるものだけ if len(valid) == 0: return np.zeros(model.vector_size) 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) return vecs.mean(axis=0)

これにより、hist_emb_5_00hist_emb_5_15 の 16列の特徴量が得られます(vector_size=16 の場合)。

データリーク対策

今走の結果を今走の特徴量に含めてはいけません。
各行の履歴ベクトルは、「その行より前のレースのイベントだけ」を使って計算します。今走の event_token は含めません。
# データリーク対策版: 各行の履歴Embeddingを「今走より前のイベントだけ」で計算する for _, group in df.groupby(‘馬名’, sort=False): sorted_group = group.sort_values(‘日付’) past = [] # 今走より前の走トークン for idx, row in sorted_group.iterrows(): # この時点の past には「今走より前の走」しか入っていない emb = make_horse_history_embedding(past, model, n_recent=5) result[idx] = emb # 今走のトークンを追加(次の行で「過去走」になる) past.append(row[‘event_token’])
Embedding 学習自体にも注意点があります。
今回は全期間のデータで Word2Vec を学習していますが、厳密なバックテストでは、検証期間の未来データを使って Embedding を学習すると情報漏洩に近い状態になります。
本格運用では「学習期間のデータだけで Word2Vec を学習し、検証期間に適用する」か、「時系列分割ごとに Word2Vec を学習し直す」ことを推奨します。今回の記事では仕組みの説明を目的とし、この点を今後の検証課題としています。

実データで試してみる(サンプル馬:ハバネロ)

ハバネロ は通常距離レース(800m以上)で 157戦の出走データがあります(2019〜2024年)。最新レースの直前時点での履歴ベクトルを計算します。

直近10走のイベント履歴

順番日付場所距離馬場状態人気着順1着差event_token
148走目2024/04/28水沢130057.028.799999999999997PLACE_水沢|DIST_MILE|SURFACE_良|POP_4_6|FINISH_MID|MARGIN_LARGE|LATE_SLOW
149走目2024/05/05盛岡1200911.021.6PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_MID
150走目2024/05/12盛岡120084.04.6PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_SMALL|LATE_MID
151走目2024/05/19盛岡1200512.042.2PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW
152走目2024/05/26盛岡120043.028.799999999999997PLACE_盛岡|DIST_SPRINT|SURFACE_良|POP_4_6|FINISH_TOP3|MARGIN_LARGE|LATE_MID
153走目2024/06/02水沢1300稍重211.048.199999999999996PLACE_水沢|DIST_MILE|SURFACE_稍重|POP_2_3|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW
154走目2024/09/09水沢13001011.046.0PLACE_水沢|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_MID
155走目2024/09/16水沢1400811.077.6PLACE_水沢|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW
156走目2024/09/23水沢130078.044.4PLACE_水沢|DIST_MILE|SURFACE_重|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW
157走目2024/09/30水沢140088.050.8PLACE_水沢|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_00dim_01dim_02dim_03dim_04dim_05dim_06dim_07dim_08dim_09dim_10dim_11dim_12dim_13dim_14dim_15
直近5走平均-0.86540.4350.59120.6036-0.2960.2373-1.97350.46111.1899-0.76290.5155-0.33870.05181.31050.43890.2794
直近10走平均-0.79630.52040.60520.7363-0.53780.2356-2.04280.40911.1432-0.85740.5005-0.3671-0.04461.24020.30290.1965
直近5走加重平均-1.13260.37630.63670.682-0.22430.2211-1.95910.6261.0687-0.79630.5749-0.17110.03681.47630.41460.1853
ここに示すのは、今走より前の過去走イベント Embedding を集約したベクトルです。データリーク対策として、今走の event_token は含めていません。vector_size=16 なので各馬に 16次元のベクトルが付きます。

類似イベントの確認

Embedding がそれらしい表現を学習できているかを確認します。これはあくまで「モデルが学習したイベント同士の近さの参考」です。類似度が高いからといって、予測精度を保証するものではありません。

「PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID」に近いイベント

イベントトークンコサイン類似度
PLACE_園田|DIST_MILE|SURFACE_稍重|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID0.9827
PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_TOP3|MARGIN_SMALL|LATE_MID0.982
PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_TOP3|MARGIN_CLOSE|LATE_MID0.9798
PLACE_園田|DIST_MILE|SURFACE_重|POP_1|FINISH_WIN|MARGIN_WIN|LATE_MID0.9752
PLACE_園田|DIST_MILE|SURFACE_重|POP_1|FINISH_TOP3|MARGIN_CLOSE|LATE_MID0.9693
PLACE_園田|DIST_MILE|SURFACE_良|POP_2_3|FINISH_WIN|MARGIN_WIN|LATE_MID0.9684
PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_MID|MARGIN_MID|LATE_MID0.9657
PLACE_園田|DIST_MILE|SURFACE_良|POP_1|FINISH_MID|MARGIN_SMALL|LATE_MID0.9643

「PLACE_園田|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW」に近いイベント

イベントトークンコサイン類似度
PLACE_園田|DIST_MILE|SURFACE_稍重|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW0.9915
PLACE_園田|DIST_MILE|SURFACE_良|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW0.9756
PLACE_園田|DIST_MILE|SURFACE_重|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW0.9624
PLACE_園田|DIST_MILE|SURFACE_稍重|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW0.9577
PLACE_園田|DIST_MILE|SURFACE_不良|POP_7PLUS|FINISH_MID|MARGIN_LARGE|LATE_SLOW0.9559
PLACE_園田|DIST_MILE|SURFACE_不良|POP_7PLUS|FINISH_LOSE|MARGIN_MID|LATE_SLOW0.9514
PLACE_園田|DIST_MILE|SURFACE_重|POP_7PLUS|FINISH_TOP3|MARGIN_SMALL|LATE_SLOW0.9497
PLACE_園田|DIST_MILE|SURFACE_不良|POP_7PLUS|FINISH_LOSE|MARGIN_LARGE|LATE_SLOW0.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+rolling130.8127
B: Embedding(16次元)160.695
C: 組み合わせ290.8134

コード:shift/rolling 特徴量の作成

# shift/rolling 特徴量の作成 df_comp = df_normal.sort_values([‘馬名’, ‘日付’, ‘レースID’]).reset_index(drop=True) # 直近5走の横持ち特徴量 for n in range(1, 6): for col in [‘着順’, ‘人気’, ‘1着差’]: df_comp[f'{n}走前_{col}’] = df_comp.groupby(‘馬名’)[col].shift(n) # rolling集計(今走を含めないため shift(1) 後に rolling) 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()) )

コード:Embedding の付与(学習期間の履歴のみ使用)

# Embedding 特徴量の付与(データリーク防止版) # build_horse_history_embeddings_safe は「その行より前の走歴のみ」を参照するため # 学習行・テスト行とも正しくリーク防止された Embedding が得られる import time as _time _t0 = _time.time() emb_all_df = build_horse_history_embeddings_safe(df_comp, model, n_recent=5) print(f” Embedding 生成完了: {_time.time()-_t0:.1f}秒”) df_comp = df_comp.join(emb_all_df) emb_cols = list(emb_all_df.columns) # [‘hist_emb_5_00’, …, ‘hist_emb_5_15’]
結果の解釈
・今回のシミュレーション実験では、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_00hist_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比較実験完了")
タイトルとURLをコピーしました