競馬AI開発メモ:Self-Attentionでレース内の相手関係を特徴量化してみる

AI予想

競馬における「相手関係」という問題

競馬の予測モデルを作るとき、ほとんどのアプローチは「この馬が強いかどうか」を個別に評価します。過去走の着順・人気・タイム差を特徴量にして、今走でも好走するかどうかを予測するという設計です。

しかしこれには欠点があります。同じ馬でも、相手が弱いレースなら上位に来やすく、強いレースなら評価は下がります。レース内での相対的な位置が、個体の絶対評価と同じくらい重要なのです。

「このレースのメンバー全体がどういう構成か」「その中でこの馬はどの位置にいるか」というレース内コンテキストを特徴量に組み込みたい、というのが今回の動機です。

アイデア:出走馬をトークンとして扱う

自然言語処理では、文中の各単語を「トークン」として並べ、Self-Attentionで単語間の関係を学習します。今回は同じ仕組みを競馬に応用します。

【レースのイメージ】 [馬A特徴量, 馬B特徴量, 馬C特徴量, …, 馬N特徴量] ↓ Self-Attention (全馬ペアの関係を学習) ↓ [馬A_ctx_emb, 馬B_ctx_emb, 馬C_ctx_emb, …, 馬N_ctx_emb] この race_contextual_embedding が表すもの: 「この馬が、今回のメンバー構成の中でどういう位置にいるか」

この race_contextual_embedding(32次元)を、既存のシフト/ローリング特徴量に追加することで予測精度が改善するかどうかを検証します。

使用データ

項目内容
ソースSQLite DB(result_table)
対象競馬場地方競馬(帯広=ばんえい除外)
データ期間2019〜2024年(過去走 shift 計算には 2017〜 を使用)
学習期間2019〜2022年(46,232レース・470,451頭分)
検証期間2023年(11,911レース・120,553頭分)
評価期間2024年(11,933レース・121,865頭分)
除外レース特殊フラグあり(失・取・除・中・取消・失格・除外・中止レース)
馬の識別馬名(馬IDなし)
馬の識別について
今回使用するDBには馬固有のIDカラムがありません。馬名でグループ化しているため、同名の別馬が存在する場合に過去走が混在するリスクがあります。実運用では一意な馬IDの使用を推奨します。

特徴量設計

数値特徴量(20次元)

種別特徴量正規化
今走(レース前情報)距離(距離−1000)/1000
頭数(同走馬の数)/16
枠番 / 馬番/16
斤量(斤量−50)/10
前走からの日数clip 0-365、/100
前走からの距離差clip ±2000、/1000
(計7個)
過去走(shift/rolling)1走前 着順/人気/1着差/上り/距離各スケーリング
直近3走 平均着順/16
直近5走 平均着順 / 3着内率 / 平均人気/16 or 0-1
直近5走 平均1着差 / 平均上り/20 or /10
直近5走 距離平均 / 距離std/1000 or /500
(計13個)

カテゴリ特徴量(4種)

場所(14分類)・馬場状態(4分類)・天候(6分類)・距離帯(4分類)。いずれもレース前に判明するレース条件で、全馬共通です。

データリーク対策

  • 今走の着順・1着差・上り・タイム等の結果情報は一切使わない
  • 過去走特徴量は、必ず shift(1)rolling(n) の順で計算し、今走の結果を含まない。
  • 今走の人気・単勝オッズは使わない(出馬表段階で評価するため)。
  • 数値スケーリングは固定スケール(データ依存なし)なので学習/検証間のリークなし。
  • カテゴリマップは固定(未知カテゴリは 0=unknown)。

モデル構成

HorseFeatureEncoder

各馬1頭分の特徴量(数値20次元 + カテゴリ4種)を d_model=64 次元に変換します。カテゴリは Embedding(次元=8)に変換してから数値と結合し、Linear+ReLU+Dropout でプロジェクションします。

RaceSelfAttentionModel(Self-Attention 部分)

パラメータ
d_model64
nhead4
num_layers2
dim_feedforward128
dropout0.1
ctx_dim(Embedding次元)32
総パラメータ数73,827
positional encoding なし
通常の Transformer(BERT等)は文中の語順を表す positional encoding を加えます。今回は「出走馬の集合」として扱うため、馬番順に並べてはいますが positional encoding は入れていません。Self-Attention が学習するのは「この馬の特徴量と他馬の特徴量の関係」であり、「何番目の馬か」という順序情報を過剰に学習させないようにしています。
ただし枠番・馬番は特徴量として入力しており、位置情報を完全に排除しているわけではありません。

Contextual Embedding の仕組み

[馬A: 枠番/斤量/過去5走平均着順/…] (20次元数値 + 4カテゴリ) [馬B: 枠番/斤量/過去5走平均着順/…] [馬C: …] ↓ HorseFeatureEncoder [馬A: 64次元ベクトル] [馬B: 64次元ベクトル] [馬C: …] ↓ TransformerEncoder(2層 Self-Attention, head=4) パディング馬は attention から除外 [馬A: 64次元 context-aware ベクトル] ← 馬B,C との関係を踏まえた表現 [馬B: 64次元 context-aware ベクトル] [馬C: …] ↓ Linear(64→32) [馬A: race_ctx_emb_00 〜 race_ctx_emb_31] ← 32次元 Contextual Embedding [馬B: race_ctx_emb_00 〜 race_ctx_emb_31] [馬C: …]

損失関数・学習設定

win 損失(CrossEntropy)

1レース内に勝ち馬は1頭という構造を利用します。win_score(各馬のスコア)からパディング馬を -∞ にしたあと softmax を適用し、CrossEntropyLoss で勝ち馬のインデックスを予測します。これにより、同一レース内でスコアの合計が自動的に1になるという構造になります。

top2/top3 損失(BCE)

各馬ごとに「連対するか」「3着内に入るか」を独立に BCEWithLogitsLoss で学習します。パディング馬は損失計算から除外します。pos_weight を使用しています(top2: 4.08、top3: 2.39)。

同着(勝ち馬が複数)の処理
着順が 1 の馬が複数いるレース(同着)が 68 レースありました。これらは CrossEntropy の学習ターゲットとして「最小馬番の馬を勝ち馬」として扱いました。全体の約 0.1% と少数のため、予測への影響は限定的と考えています。

実験結果①:Self-Attention モデル単体評価

学習:2019〜2022年、検証:2023年で早期停止(best epoch=24)、評価:2024年テストデータ

ターゲットAUCloglossBrier score
1着(win)0.78970.26800.0773
連対(top2)0.77270.56350.1928
3着内(top3)0.76270.57610.1976
数値の読み方
これは、Self-Attention モデルが出力する予測確率(win_prob / top2_prob / top3_prob)を直接評価した結果です。モデルには過去走特徴量と今走条件(距離・馬場・枠番・斤量等)が入力されています。
pos_weight ありの学習のため、出力確率が実際の発生率からずれている(キャリブレーション問題)可能性があります。AUC のみでなく logloss・Brier score も確認してください。

実験結果②:LightGBM 比較

テスト期間:2024年(106,697頭分)。race_contextual_embedding を既存特徴量に追加した場合の効果を比較しました。

特徴量セット特徴量数top3 AUCloglossBrier score
A: shift+rolling(15個)150.80990.45900.1496
B: race_contextual_emb(32次元)320.76100.49770.1646
C: A + B(47個)470.81220.45620.1487
Embedding の生成方法について(リーク注意)
今回の実装では、2019〜2022年の学習データに対する Embedding も、2019〜2022年で学習済みのモデルで生成しています。これは理想的な時系列 OOF(Out-Of-Fold)ではなく、学習データ上の Embedding は過学習方向の歪みを持つ可能性があります。評価として意味があるのは 2024年テストセットです。テストセットは学習に使っていないデータなので、より信頼できる評価です。
3指標すべてで改善
改善幅は大きくありませんが、今回の組み合わせ(C)は AUC・logloss・Brier score の 3 指標すべてでベースモデル(A)を上回っています。AUC だけが上がって確率の品質が落ちるケース(logloss や Brier が悪化)ではなく、確率予測の面でも悪化していない点は前向きに評価できます。これは、race_contextual_embedding が既存の shift+rolling 特徴量には含まれない補助情報を一部持っている可能性を示しています。

結果の解釈

Self-Attention モデル全体の性能

Self-Attention モデル全体(過去走特徴量 + 今走条件 + Self-Attention)の top3 AUC は 0.7627 でした。このモデルがレース内の相互関係を何らかの形で学習できていることを示しています。

ただし、このAUCは「Self-Attentionが有効である証拠」とは断言できません。モデルには過去走特徴量(着順・人気等)も含まれており、それらだけでも一定の予測性能が出るためです。

shift+rolling が依然として強く、Contextual Embedding は上乗せ特徴量として機能する

LightGBM の比較では、shift+rolling 特徴量(15個)だけで top3 AUC 0.8099 を達成しています。LightGBM に race_contextual_embedding(32次元)だけを渡した場合の AUC は 0.7610 であり、主役は依然として shift+rolling 特徴量です。

Contextual Embedding 単体では、着順・人気・1着差の直近履歴を置き換えるほどの予測力はありませんでした。一方で、既存特徴量に追加した場合は 3 指標すべてが改善しており、置き換えではなく上乗せ特徴量として見るのが自然な使い方と言えます。

追加による改善の評価

shift+rolling に Contextual Embedding を追加した場合(C)の改善は以下の通りです。

  • AUC:0.8099 → 0.8122(+0.0023)
  • logloss:0.4590 → 0.4562(-0.0028)
  • Brier:0.1496 → 0.1487(-0.0009)

今回の改善幅は決して大きくありません。しかし、AUC・logloss・Brier score が同じ方向に改善しているため、少なくとも 2024 年テストデータでは、race_contextual_embedding がベースモデルに対して有効な追加特徴量として働いたと考えられます。既存の shift+rolling 特徴量とは異なる「レース内相互関係の情報」が一部含まれていることを示す結果です。

一方で、改善幅は小さいため、1 回の分割結果だけで有効性を断定するのは危険です。時系列 OOF、複数 seed、別年度テストで再現性を確認することが、採用判断には不可欠です。現時点での評価は「ベースモデルへの有効な追加特徴量候補」であり、本番採用の前にさらなる検証が必要です。

やってみて分かったこと

  • Self-Attention の「レース内全馬の相互比較」というアイデアは直感的に理解しやすく、実装もシンプルにできた。TransformerEncoderLayer の batch_first=Truesrc_key_padding_mask を使えば、可変頭数のレースを自然に扱える。
  • race_contextual_embedding は、単体で使うよりも既存の shift+rolling 特徴量に追加する補助特徴量として使う方が有望だった。Self-Attention が必ず強い特徴量を作るわけではないが、少なくとも今回の設定ではベースモデルにない情報を一部補っている可能性がある。
  • positional encoding を入れないことで、「馬番何番目か」という順序情報ではなく「このメンバー構成の中での相対位置」を学習させる設計になる。ただし枠番・馬番は数値特徴量として入力しているため、位置情報が完全にないわけではない。
  • win loss に CrossEntropy(softmax)を使うことで、「レース内で1頭しか勝てない」という構造を損失関数に組み込める。BCE と比べて自然な設計。
  • AUC だけでなく logloss と Brier score も改善したため、確率予測の品質という面でも悪化していない点は前向きに評価できる。AUC は順位情報しか見ないが、logloss と Brier は確率の精度まで反映するため、3 指標の同時改善は意味がある。
  • pos_weight ありの top2/top3 BCE は少数クラスを拾いやすくなるが、出力確率のキャリブレーション(実際の発生率との一致度)を悪化させる可能性がある。期待値計算に確率を使う場合は logloss・Brier score・キャリブレーションカーブで別途確認が必要。
  • LightGBM に Contextual Embedding を入れる際、学習データ用の Embedding が学習済みモデルによって生成されているため、理想的な時系列 OOF ではない。今回のテストセット(2024年)はどちらのモデルも学習に使っていないため、テスト評価自体は信頼できる。
  • Self-Attention のパラメータ数は約 73,827 と小さく、GPU があれば 5〜10分程度で学習できる。計算コストの面では扱いやすい。

注意点・限界

  • Self-Attention モデル全体の AUC と、Embedding 単体の LightGBM AUC は区別して読む必要がある。前者はモデル全体の性能、後者が Embedding の特徴量としての実力。
  • 今回の改善は 2024 年テストデータでの 1 回の検証結果であり、複数 seed や別年度での再現性は未確認。改善幅が小さいため、条件を変えると結果が変わる可能性がある。採用判断には追加検証が必要。
  • 学習データ用 Embedding は学習済みモデルで生成しているため、時系列 OOF ではない。LightGBM 学習時に学習データ Embedding が過学習方向に歪んでいる可能性がある。ただし、2024 年テストセットは Self-Attention モデル・LightGBM ともに学習に使っていないため、テスト評価としては一定の意味がある
  • 採用判断には、時系列 OOF で生成した Embedding を使った再検証が必要。OOF Embedding の場合、学習データに対する歪みがなくなるため、より公平な比較ができる。
  • pos_weight あり学習は AUC を改善しやすいが、確率のキャリブレーションに悪影響を与える可能性がある。本番で期待値計算に使う場合は別途キャリブレーション確認を推奨。
  • 馬名での識別のため、同名馬の混在リスクがある。
  • 今回の実験でモデルが何を学習しているかの解釈は行っていない。attention weight の可視化や特徴量重要度の分析は今後の課題。
  • 数値スケーリングは固定スケール(例:着順/16)を使っており、StandardScaler によるフィットは行っていない。簡便な実装だが、外れ値の多い特徴量では精度に影響する可能性がある。

まとめ

今回の実験では、race_contextual_embedding 単体では shift+rolling 特徴量を置き換えるほどの性能はありませんでした。しかし、shift+rolling に追加した場合、top3 AUC は 0.8099 から 0.8122 へ改善し、logloss・Brier score も同時に改善しました。このため、Self-Attention で作成したレース内 Contextual Embedding は、ベースモデルに対する有効な追加特徴量候補と言えます。一方で、改善幅は大きくないため、今後は時系列 OOF や複数 seed で再現性を確認したうえで採用判断したいと考えています。

  • 1レース内の出走馬を「トークンの集合」として Self-Attention にかけ、各馬の race_contextual_embedding(32次元)を生成する実験を行った。
  • Self-Attention モデル全体(過去走特徴量 + 今走条件)の top3 AUC は 0.7627。
  • LightGBM 比較では、shift+rolling 特徴量(AUC 0.8099)が主役で、Contextual Embedding 単体(AUC 0.7610)では置き換えには至らなかった。ただし、既存特徴量への上乗せ特徴量として見ると 3 指標すべてで改善しており、補助特徴量としては一定の可能性がある
  • AUC だけでなく logloss と Brier score も改善したことから、確率予測の品質面でも前向きに評価できる結果となった。
  • 改善幅は大きくないため、現時点では本番採用を断定せず、時系列 OOF・複数 seed・別年度テストで追加検証を行う。
  • 今後の検討課題:時系列 OOF Embedding の生成、pos_weight なしとのキャリブレーション比較、attention weight の可視化、騎手・調教師等の追加特徴量、Embedding 次元数比較。

実装コード

01_make_race_dataset.py(データセット構築の核心部分)
# 01_make_race_dataset.py(データセット構築の核心部分)

import sqlite3, numpy as np, pandas as pd, json
from pathlib import Path

MAX_HORSES = 16
SPECIAL_FLAGS = ['失', '取', '除', '中', '取消レース', '失格レース', '除外レース', '中止レース']

# DB から全期間を読み込み(2017-2018 のデータも shift 計算に使う)
con = sqlite3.connect('common.db')
df = pd.read_sql('SELECT ... FROM result_table', con); con.close()

# 特殊フラグ・帯広 除外
mask = (df[SPECIAL_FLAGS].fillna(0).astype(int).sum(axis=1) == 0
        & df['着順'].between(1, 20)
        & (df['場所'] != '帯広'))
df = df[mask].sort_values(['馬名', '日付', 'レースID'])

# 過去走特徴量(shift/rolling)
for col in ['着順', '人気', '1着差', '上り', '距離']:
    df[f'1走前_{col}'] = df.groupby('馬名')[col].shift(1).fillna(0)

g = df.groupby('馬名')
df['直近5走_平均着順'] = g['着順'].transform(lambda s: s.shift(1).rolling(5, min_periods=1).mean())
df['直近5走_3着内率']  = g['is_top3'].transform(lambda s: s.shift(1).rolling(5, min_periods=1).mean())
# ... (他のrolling特徴量も同様)

# 2019-2024 に絞り split 割り当て
df = df[df['日付'].between(20190101, 20241231)]
df['split'] = 'train'
df.loc[df['日付'].between(20230101, 20231231), 'split'] = 'val'
df.loc[df['日付'].between(20240101, 20241231), 'split'] = 'test'

# レース単位テンソルをベクトル演算で構築
races_df['race_idx'] = np.arange(len(races_df))
df['pos'] = df.groupby('race_idx').cumcount()   # 馬番昇順 position

X_num  = np.zeros((N_RACES, MAX_HORSES, NUM_DIM),  dtype=np.float32)
X_mask = np.zeros((N_RACES, MAX_HORSES),            dtype=bool)
X_num[df['race_idx'], df['pos'], :]  = df[NUM_FEAT_NAMES].values
X_mask[df['race_idx'], df['pos']]    = True

# 勝ち馬 index(同着は最小 pos を採用)
winners = df[df['着順'] == 1].groupby('race_idx')['pos'].min()
win_idx[winners.index] = winners.values
02_train_self_attention_model.py(モデル定義の核心部分)
# 02_train_self_attention_model.py(モデル定義の核心部分)

import torch, torch.nn as nn, torch.nn.functional as F

class HorseFeatureEncoder(nn.Module):
    """各馬の数値+カテゴリ特徴量 → d_model 次元"""
    def __init__(self, num_dim, cat_cardinalities, cat_emb_dim=8, d_model=64, dropout=0.1):
        super().__init__()
        self.embeddings = nn.ModuleList([
            nn.Embedding(card, cat_emb_dim, padding_idx=0)
            for card in cat_cardinalities
        ])
        in_dim = num_dim + cat_emb_dim * len(cat_cardinalities)
        self.proj = nn.Sequential(nn.Linear(in_dim, d_model), nn.ReLU(), nn.Dropout(dropout))

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


class RaceSelfAttentionModel(nn.Module):
    """
    1レース内の全馬トークンに Self-Attention をかける。
    - positional encoding なし(出走馬の集合として扱う)
    - src_key_padding_mask でパディング馬を除外
    - TransformerEncoder 出力 → 32次元 race_contextual_embedding
    """
    def __init__(self, num_dim, cat_cardinalities,
                 cat_emb_dim=8, d_model=64, nhead=4,
                 num_layers=2, dim_feedforward=128, dropout=0.1, ctx_dim=32):
        super().__init__()
        self.horse_encoder = HorseFeatureEncoder(...)
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward,
            dropout=dropout, batch_first=True
        )
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
        self.ctx_proj = nn.Linear(d_model, ctx_dim)
        self.head = nn.Sequential(
            nn.Linear(ctx_dim, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 3)
        )

    def forward(self, x_num, x_cat, mask):
        h = self.horse_encoder(x_num, x_cat)                        # (B, H, d_model)
        h = self.transformer(h, src_key_padding_mask=~mask)         # (B, H, d_model)
        ctx_emb    = self.ctx_proj(h)                               # (B, H, ctx_dim=32)
        logits     = self.head(ctx_emb)                             # (B, H, 3)
        win_score  = logits[:, :, 0].masked_fill(~mask, float('-inf'))
        top2_logit = logits[:, :, 1]
        top3_logit = logits[:, :, 2]
        return win_score, top2_logit, top3_logit, ctx_emb

# 損失計算
def compute_loss(win_score, top2_logit, top3_logit, y, win_idx, mask):
    # win: CrossEntropy(softmax はここで内包)
    loss_win = F.cross_entropy(win_score, win_idx)

    # top2/top3: BCE(有効馬のみ)
    flat = mask.view(-1)
    loss_top2 = F.binary_cross_entropy_with_logits(
        top2_logit.view(-1)[flat], y[:,:,1].reshape(-1)[flat], pos_weight=pw_top2
    )
    loss_top3 = F.binary_cross_entropy_with_logits(
        top3_logit.view(-1)[flat], y[:,:,2].reshape(-1)[flat], pos_weight=pw_top3
    )
    return loss_win + loss_top2 + loss_top3
タイトルとURLをコピーしました