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

競馬AI開発

導入

前回の記事では、各馬の過去レース履歴を Word2Vec(教師なし Embedding) でベクトル化する実験を行いました。

しかし、簡易比較では以下のような結果になりました。

  • shift+rolling 特徴量(従来手法):top3 AUC ≈ 0.8127
  • Word2Vec Embedding 単体:top3 AUC ≈ 0.6950
  • shift+rolling + Word2Vec:top3 AUC ≈ 0.8134(上乗せはわずか)

Word2Vec は「似た文脈のイベントを近づける」ように学習しますが、それが必ずしも 勝率予測に役立つ表現 になるとは限りません。

そこで今回は、今走の結果(1着・連対・3着内)を予測するタスクを通じて、予測に役立つ履歴 Embedding を教師あり学習で作ることを試みます。GRU モデル全体の性能だけでなく、抽出した Embedding を LightGBM 用特徴量として使った場合の性能も分けて検証します。

なぜ教師ありEmbeddingを試すのか

  • Word2Vec:「良馬場マイル1着」「重馬場短距離3着」など似た文脈で出るイベントを近づける。予測精度との直接的な関係はない。
  • 教師ありEmbedding:今走の 1着・連対・3着内 という正解ラベルを使って、「予測に役立つ方向」に各馬の履歴表現を学習させる。
  • GRU で過去10走の系列を読み込み、32次元の history_embedding に圧縮する。これを後続モデル(LightGBM 等)の特徴量として使う。

今回作るもの

過去10走の履歴 ↓ PastRaceEncoder(数値×8 + カテゴリEmbedding) ↓ GRU(hidden_dim=64) ↓ history_proj → 32次元 history_embedding ↓ 今走条件(距離・枠番・斤量等)と結合 ↓ PredictionHead(3出力) → logit_win / logit_top2 / logit_top3 → 中間の 32次元 Embedding を LightGBM 用特徴量として保存

データと前処理

使用データ

項目内容
データソースcommon.db(result_table)
対象期間2019〜2024年
除外条件特殊成績(失走・取消等)、帯広(ばんえい競馬)
読み込み行数1,333,885行
クリーニング後712,869行
ユニーク馬数39,016頭
馬の識別キー馬名
注意:馬の識別について
このDBには馬IDが存在しないため、groupby('馬名') で識別しています。 同名の別馬が混在する可能性があります。実運用では馬IDや血統登録番号による識別が推奨されます。

分割

期間用途件数
2019〜2022年学習(train)470,451
2023年バリデーション120,553
2024年最終評価(テスト)121,865

特徴量設計

種別特徴量次元数
過去走 数値着順割合・3着内フラグ・人気割合・log単勝・距離・1着差・上り・前走からの日数8
過去走 カテゴリ場所(14種)・馬場状態(4種)・距離帯(4種) 各Embedding dim=83カテゴリ
今走 数値距離・頭数・枠番・馬番・斤量・前走からの日数・前走からの距離差7
今走 カテゴリ場所・馬場状態・天候・距離帯 各Embedding dim=84カテゴリ
今回はオッズなしモデルとして実装しています(今走の人気・単勝を特徴量に含めない)。 オッズありモデルにするには今走に log単勝人気割合 を追加します。 オッズあり・なしで用途が異なります(出馬表段階の予測 vs オッズ確定後の予測)。

モデル構成

PastRaceEncoder

過去1走分の特徴量をベクトル化するモジュールです。カテゴリ特徴量を Embedding に通し、数値特徴量と結合します。padding_idx=0 で系列が短い馬のパディング部分を0ベクトルにします。

GRU(SequenceEncoder)

過去10走の系列を GRU で読み込みます。pack_padded_sequence を使い、過去走が少ない馬ではパディング部分を GRU が処理しないようにしています。最後の hidden state を32次元に射影したものが history_embedding です。

PredictionHead

history_embedding(32次元) と今走条件(数値+カテゴリEmbedding)を結合し、win/top2/top3 の3値を同時予測します(マルチタスク学習)。

主要なコード

class PastRaceEncoder(nn.Module):
    """過去1走分の数値+カテゴリ特徴量をベクトル化する"""
    def __init__(self, num_numeric, cat_cardinalities, cat_emb_dim=8):
        super().__init__()
        self.embeddings = nn.ModuleList([
            nn.Embedding(card, cat_emb_dim, padding_idx=0)
            for card in cat_cardinalities
        ])
        self.output_dim = num_numeric + cat_emb_dim * len(cat_cardinalities)

    def forward(self, x_num, x_cat):
        parts = [x_num]
        for i, emb in enumerate(self.embeddings):
            parts.append(emb(x_cat[:, :, i]))
        return torch.cat(parts, dim=-1)  # (B, T, output_dim)


class HistoryGRUModel(nn.Module):
    def __init__(self, hist_num_dim, hist_cat_cards, cur_num_dim, cur_cat_cards,
                 cat_emb_dim=8, hidden_dim=64, hist_emb_dim=32):
        super().__init__()
        self.past_encoder = PastRaceEncoder(hist_num_dim, hist_cat_cards, cat_emb_dim)
        self.gru = nn.GRU(self.past_encoder.output_dim, hidden_dim, batch_first=True)
        self.history_proj = nn.Sequential(
            nn.Linear(hidden_dim, hist_emb_dim), nn.ReLU()
        )
        # 今走カテゴリ用 Embedding
        self.current_embeddings = nn.ModuleList([
            nn.Embedding(card, cat_emb_dim, padding_idx=0) for card in cur_cat_cards
        ])
        cur_total = cur_num_dim + cat_emb_dim * len(cur_cat_cards)
        self.head = nn.Sequential(
            nn.Linear(hist_emb_dim + cur_total, 64),
            nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 3),
        )

    def forward(self, x_hist_num, x_hist_cat, x_hist_mask, x_cur_num, x_cur_cat):
        x_seq   = self.past_encoder(x_hist_num, x_hist_cat)
        # pack_padded_sequence でパディング位置を GRU に無視させる
        seq_len = x_hist_mask.long().sum(dim=1).clamp(min=1).cpu()
        packed  = pack_padded_sequence(x_seq, seq_len, batch_first=True, enforce_sorted=False)
        _, h    = self.gru(packed)
        history_embedding = self.history_proj(h[-1])   # (B, 32)
        # 今走特徴量と結合
        cur_parts = [x_cur_num]
        for i, emb in enumerate(self.current_embeddings):
            cur_parts.append(emb(x_cur_cat[:, i]))
        logits = self.head(torch.cat([history_embedding, torch.cat(cur_parts, dim=-1)], dim=-1))
        return logits, history_embedding   # (B, 3), (B, 32)

損失関数・学習設定

# クラス不均衡補正:pos_weight = 負例数 / 正例数
pos_rates   = Y_train.mean(axis=0)
pos_weights = torch.FloatTensor([(1 - r) / max(r, 0.01) for r in pos_rates]).to(device)
criterion   = nn.BCEWithLogitsLoss(pos_weight=pos_weights)

# 損失はマルチタスク(win / top2 / top3 の合計)
loss = criterion(logits, y_batch)   # logits: (B, 3),  y_batch: (B, 3)

実験での設定:

  • 損失関数:BCEWithLogitsLoss(pos_weight あり)
  • pos_weight:win=9.16、top2=4.08、top3=2.39(学習データの正例率から自動計算)
  • オプティマイザ:Adam(lr=0.001)
  • バッチサイズ:2,048
  • 最大エポック数:30(early stopping patience=5)
  • ベストモデル:Epoch 19(val top3 AUC が最大の時点)

データリーク対策

今回実施したリーク対策
  • 今走の着順・1着差・上りなどの結果を入力に含めていない(過去走特徴量は過去走の結果のみ)。
  • 過去走系列は「今走より前のレース」だけで構成(馬名・日付・レースIDでソート後、shift相当で取得)。
  • 数値スケーラーは学習期間(2019〜2022)だけでfitし、バリデーション・評価にはtransformのみ適用。
  • カテゴリエンコードは固定マップ(学習データに依存しない)。
今回の簡略化・注意点
  • GRU の Word2Vec モデル自体は全期間データで学習していません(教師あり学習なので不要)。ただし、GRU は学習・バリデーション・評価期間を通じて同一モデルを使用しています。
  • LightGBM に教師あり Embedding を投入する場合、時系列 OOF(Out-Of-Fold) で Embedding を生成するのが理想です(学習期間のラベルを使って作った Embedding をそのまま学習データに使うと、過大評価になりやすい)。今回は簡易版として実装しています。

実験結果

ニューラルネット(GRU モデル)単体の評価(テスト期間 2024年)

ターゲットAUCloglossBrier score
1着(win)0.73490.59570.2098
連対(top2)0.72200.60600.2125
3着内(top3)0.71590.61190.2139
数値の読み方に注意
このテスト AUC は、履歴 Embedding(32次元)に加えて今走条件(距離・枠番・斤量等)も合わせて使ったニューラルネット全体の性能です。「教師あり Embedding 自体が AUC 0.7159 の予測力を持つ」という意味ではありません。LightGBM に 32次元 Embedding だけを渡した場合の評価は次のセクションをご覧ください。
また、Word2Vec 参考値(0.695)はテスト期間が今回と異なる(2023〜2024年合算)ため、この GRU モデル全体の数値と直接比較することは適切ではありません。

LightGBM 比較(テスト期間 2024年)

特徴量セット特徴量数top3 AUCloglossBrier score
A: shift+rolling150.81470.46630.1525
B: supervised_embedding320.68860.55640.1887
C: shift+rolling + supervised_embedding470.81510.46590.1523
D: Word2Vec_embedding (参考: 前回実験)160.6950
D の Word2Vec は前回実験(学習:2019〜2022、テスト:2023〜2024 合算)の値です。 A/B/C は今回実験(テスト:2024年のみ)の値です。テスト期間が異なるため厳密な比較ではありません。

結果の解釈

shift+rolling が依然として強い

今回の最も重要な実験結果は、従来の shift+rolling 特徴量が非常に強力であることです。着順・人気・1着差の直近値と rolling 集計という素直な履歴特徴量(15個)だけで top3 AUC 0.8147、logloss 0.4663、Brier score 0.1525 を達成しています。

32次元の教師あり Embedding を LightGBM に渡した場合(B)の top3 AUC は 0.6886、logloss 0.5564、Brier score 0.1887 です。shift+rolling との差は AUC で 0.1261、logloss で 0.0901、Brier score で 0.0362 と大きく、現状の 32次元 Embedding 単体は、直近着順・人気・着差・rolling 集計といった素直な履歴特徴量を置き換えるほどの情報を表現できていないことが確認されました。

GRU モデル全体の性能と Embedding 単体の性能を混同しない

GRU モデル全体の top3 AUC 0.7159 は、履歴 Embedding(32次元)に加えて今走条件(距離・枠番・斤量等)も使ったニューラルネット全体の性能です。履歴 Embedding 自体が 0.7159 の予測力を持つわけではありません。LightGBM に Embedding だけを渡した場合の AUC(0.6886)が、Embedding の特徴量としての実力をより正確に反映しています。

上乗せ効果はあるが非常に小さい

shift+rolling に教師あり Embedding を加えた場合(C)の改善幅は以下の通りです。

  • AUC:0.8147 → 0.8151(+0.0004)
  • logloss:0.4663 → 0.4659(−0.0004)
  • Brier score:0.1525 → 0.1523(−0.0002)

既存特徴量に含まれない情報が Embedding に少し含まれている可能性は示唠されます。ただし、改善幅は非常に小さく、seed・データ分割・ハイパーパラメータの揺らぎで消える可能性があります。現時点で有効性を断定するのは難しい水準です。

Word2Vec との比較は慎重に

LightGBM 比較表の D(Word2Vec Embedding, AUC=0.6950)は前回実験の参考値で、テスト期間が異なります(2023〜2024年合算 vs 今回は 2024 年のみ)。テスト条件が異なるため、今回の B(0.6886)と単純に比較して「洛化した」と結論することはできません。同条件で再実験するまでは保留的に考えるべきです。

改善が小さい理由として考えられること

  • shift+rolling 特徴量(着順・人気・1着差の直近値)が既に過去走の重要情報の多くをカバーしている。
  • 32次元に圧縮する過程で LightGBM が直接利用できる情報を失っている可能性がある。
  • LightGBM への投入では時系列 OOF Embedding が理想だが、今回の簡易版ではリーク方向の歪みが含まれている可能性がある。
  • GRU の hidden_dim=64、hist_emb_dim=32 という設定が最適でない可能性がある。

サンプル馬の例

テスト期間(2024年)で30戦出走した ウィンクルティアラ の最新レースでの予測例:

日付レースIDGRU 予測 winGRU 予測 top2GRU 予測 top3
20241229202436122904 0.095 0.126 0.158

hist_emb_gru(先頭8次元の例):GRUが学習した過去走履歴の潜在表現です。各次元の意味は直接解釈できません。

やってみて分かったこと

  • 教師あり Embedding(GRU)は設計上 Word2Vec より「予測目的」に近い。ただし今回の簡易実験では、LightGBM の特徴量としての Embedding 単体の AUC は 0.6886 にとどまり、shift+rolling(0.8147)には大きく劣った。
  • GRU モデル全体の AUC(0.7159)と LightGBM に Embedding だけを渡した場合の AUC(0.6886)は別物であることに注意が必要。前者は今走条件も使ったニューラルネット全体の性能である。
  • pos_weight を使うと少数クラスを拾いやすくなる一方で、出力確率が実際の発生率からずれるキャリブレーション問題が生じる可能性がある。競馬 AI で期待値判断に確率を使う場合は、AUC だけでなく logloss・Brier score・キャリブレーションカーブも確認するべきである。
  • LightGBM に投入する場合、時系列 OOF による Embedding 生成が重要。簡易版では過大評価のリスクがある。
  • 過去走の特徴量設計(何の情報を GRU に渡すか)・Embedding 次元数・hidden_dim・学習設定が結果を大きく左右する可能性があり、今回の設定が最適とは言えない。
  • GRU よりも Transformer(自己注意機構)を使うと、過去走間の相互作用をより豊かに表現できる可能性がある。

注意点・限界

  • 教師あり Embedding はラベルを使って学習するためリーク対策が重要。LightGBM に投入する場合は時系列 OOF Embedding を作るのが理想。
  • 同じデータで Embedding を作って同じデータで LightGBM を学習すると過大評価になりやすい。
  • GRU モデル全体の AUC(0.7159)と LightGBM に Embedding だけを渡した場合の AUC(0.6886)は区別して読む必要がある。
  • pos_weight ありの学習は、クラス不均衡への対策として有効だが、確率のキャリブレーション(出力確率と実際の発生率の一致度)に悪影響を与える可能性がある。期待値判断に予測確率を使う場合はキャリブレーションを別途確認すること。
  • 馬 ID がないため、同名馬の混在リスクがある(実運用では一意の馬 ID を使うべき)。
  • 今走人気・単勝を使う場合は、レース前のどの時点で予測するかを明確にする必要がある。
  • 32次元 Embedding は直接人間が解釈できる特徴量ではない。
  • B(0.6886)と D(0.6950)はテスト期間が異なるため、 「教師あり Embedding の方が悪い」とは言いきれない。同条件での再検証が必要。

まとめ

  • 前回の Word2Vec(教師なし)では、履歴の文脈表現は得られたが LightGBM 用特徴量としての予測性能は限定的だった(参考値 AUC ≈ 0.695)。
  • 今回は GRU を使った教師あり学習で、今走の 1着・連対・3着内を教師信号に 32次元 history_embedding を学習した。
  • GRU モデル全体(履歴 Embedding + 今走条件)の top3 AUC は 0.7159。ただしこれはニューラルネット全体の性能であり、LightGBM に Embedding だけを渡した場合の AUC(0.6886)とは区別して読むこと
  • shift+rolling 特徴量(AUC 0.8147)は依然として非常に強力で、教師あり Embedding 単体では置き換えられなかった。湾わせた場合の改善は +0.0004 と非常に小さく、揺らぎで消える可能性がある。
  • 現時点で教師あり Embedding を本番特徴量として即採用ではなく、追加検証が必要な段階である。
  • 今後の検討課題:時系列 OOF による Embedding 生成、pos_weight なしのキャリブレーション比較、GRU 予測値(確率)自体の特徴量化、Embedding 次元数比較、過去走数の増加、Transformer 化。

実装コード(主要部分)

01_make_dataset.py(系列データセット構築)
# 各馬の過去10走を系列データに変換する(01_make_dataset.py より)
def make_horse_sequences(num_vals, cat_vals, n_hist=10):
    n, num_dim = num_vals.shape
    cat_dim    = cat_vals.shape[1]
    X_num  = np.zeros((n, n_hist, num_dim), dtype=np.float32)
    X_cat  = np.zeros((n, n_hist, cat_dim), dtype=np.int32)
    X_mask = np.zeros((n, n_hist),          dtype=bool)
    for lag in range(1, n_hist + 1):
        pos   = n_hist - lag   # 直近1走前 → pos=9
        valid = np.arange(lag, n)
        if len(valid) == 0:
            continue
        X_num [valid, pos] = num_vals[valid - lag]
        X_cat [valid, pos] = cat_vals[valid - lag]
        X_mask[valid, pos] = True
    return X_num, X_cat, X_mask
02_train_gru_embedding.py(GRUモデル定義)
class PastRaceEncoder(nn.Module):
    """過去1走分の数値+カテゴリ特徴量をベクトル化する"""
    def __init__(self, num_numeric, cat_cardinalities, cat_emb_dim=8):
        super().__init__()
        self.embeddings = nn.ModuleList([
            nn.Embedding(card, cat_emb_dim, padding_idx=0)
            for card in cat_cardinalities
        ])
        self.output_dim = num_numeric + cat_emb_dim * len(cat_cardinalities)

    def forward(self, x_num, x_cat):
        parts = [x_num]
        for i, emb in enumerate(self.embeddings):
            parts.append(emb(x_cat[:, :, i]))
        return torch.cat(parts, dim=-1)  # (B, T, output_dim)


class HistoryGRUModel(nn.Module):
    def __init__(self, hist_num_dim, hist_cat_cards, cur_num_dim, cur_cat_cards,
                 cat_emb_dim=8, hidden_dim=64, hist_emb_dim=32):
        super().__init__()
        self.past_encoder = PastRaceEncoder(hist_num_dim, hist_cat_cards, cat_emb_dim)
        self.gru = nn.GRU(self.past_encoder.output_dim, hidden_dim, batch_first=True)
        self.history_proj = nn.Sequential(
            nn.Linear(hidden_dim, hist_emb_dim), nn.ReLU()
        )
        # 今走カテゴリ用 Embedding
        self.current_embeddings = nn.ModuleList([
            nn.Embedding(card, cat_emb_dim, padding_idx=0) for card in cur_cat_cards
        ])
        cur_total = cur_num_dim + cat_emb_dim * len(cur_cat_cards)
        self.head = nn.Sequential(
            nn.Linear(hist_emb_dim + cur_total, 64),
            nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 3),
        )

    def forward(self, x_hist_num, x_hist_cat, x_hist_mask, x_cur_num, x_cur_cat):
        x_seq   = self.past_encoder(x_hist_num, x_hist_cat)
        # pack_padded_sequence でパディング位置を GRU に無視させる
        seq_len = x_hist_mask.long().sum(dim=1).clamp(min=1).cpu()
        packed  = pack_padded_sequence(x_seq, seq_len, batch_first=True, enforce_sorted=False)
        _, h    = self.gru(packed)
        history_embedding = self.history_proj(h[-1])   # (B, 32)
        # 今走特徴量と結合
        cur_parts = [x_cur_num]
        for i, emb in enumerate(self.current_embeddings):
            cur_parts.append(emb(x_cur_cat[:, i]))
        logits = self.head(torch.cat([history_embedding, torch.cat(cur_parts, dim=-1)], dim=-1))
        return logits, history_embedding   # (B, 3), (B, 32)
損失関数・学習設定
# クラス不均衡補正:pos_weight = 負例数 / 正例数
pos_rates   = Y_train.mean(axis=0)
pos_weights = torch.FloatTensor([(1 - r) / max(r, 0.01) for r in pos_rates]).to(device)
criterion   = nn.BCEWithLogitsLoss(pos_weight=pos_weights)

# 損失はマルチタスク(win / top2 / top3 の合計)
loss = criterion(logits, y_batch)   # logits: (B, 3),  y_batch: (B, 3)
03_export_embeddings.py(Embedding出力)
# 03_export_embeddings.py より:全サンプルの Embedding を一括出力する
model.eval()
all_embs = []
with torch.no_grad():
    for Xhn, Xhc, Xhm, Xcn, Xcc in loader:
        _, emb = model(Xhn.to(device), Xhc.to(device), Xhm.to(device),
                       Xcn.to(device), Xcc.to(device))
        all_embs.append(emb.cpu().numpy())
embs_np = np.concatenate(all_embs, axis=0)   # (N, 32)
タイトルとURLをコピーしました