2005年に開発した初代「馬心君」から21年
2005年に開発した初代「馬心君(まごころくん)」は当時の代表的AI、遺伝的アルゴリズムを駆使して予想してました。
穴寄りの予想でそれなりに的中し、楽しめたと思ってます。
その後、新しいアルゴリズム、新しいAIが発表される度に改良版に挑戦。
大体は年末年始などの大型連休をフルに活用して開発することが多く、もう10回は改良したと思います。
そんな中、2026年に還暦を迎える丙午の私が意を決して最新のAIにチャレンジしたのが今回の「馬心Former」です。
http://magokorokun.web.fc2.com/magokoro.htm
当てる予想、から、勝てるレースを買う予想、へ…。
今回の改良点
まずは最新のディープラーニング技術のひとつ、Transformerモデルを活用すること。
次に予想のためのデータを徹底的にクレンジング、正規化すること。
闇雲に的中率や回収率の向上だけを目指すのではなく、買うべきレースと購入金額の強弱に着目すること。
このために投資理論のスタンダードであるケリー基準を参考にし、購入ジャッジすること。
学習データ及びテストデータの準備
今回は2017年1月から2025年11月までの9年間のデータを準備して、2017-2024年で学習、2025年でテストしました。
データは新馬と障害を除く全レースが対象で、各レース18頭×過去7レース×40次元のレース情報という構成。
過去7走のデータが無い馬が出走する場合(未勝利戦に新馬が登場、過去芝しか走ってない馬がダートに出走等)は、その馬が1着になっている場合はそのレースを除外してます。
40次元のデータは過去7レースそれぞれの過去情報、今回レース情報、今回との差分情報、過去レースを今回条件で走った場合の能力値情報、に分けました。
それぞれカテゴリ情報(その数字同士に関連性がない)と数値情報(その数字同士に関連性がある)に分かれてます。
過去情報(カテゴリ)
# 0 競馬場10
# 1 芝/ダート2
過去情報(数値)
# 2 馬場状態
# 3 ペース
# 4 前走脚質
# 5 距離
差分情報(カテゴリ)
# 6 コース形状2
# 7 内回り/外回り2
# 8 同一騎手2
# 9 ブリンカー有無2
# 10 坂有無2
# 11 左回り/右回り2
# 12 小回り2
# 13 芝/ダート2
差分情報(数値)
# 14 距離
# 15 馬場状態
# 16 枠
# 17 ペース
# 18 前走脚質
# 19 出走頭数
# 20 レース間隔
# 21 負担重量
# 22 条件
今回情報(カテゴリ)
# 23 競馬場10
# 24 芝/ダート2
# 25 坂有無2
# 26 左回り/右回り2
# 27 小回り2
今回情報(数値)
# 28 馬場状態
# 29 ペース
# 30 距離
# 31 条件
# 32 出走頭数
能力値情報(数値)
# 33 枠の差(大きいほど良い)
# 34 騎手成績の差(大きいほど良い)
# 35 ペースと脚質(大きいほど良い)
# 36 距離/Time(1秒当たり何m)(大きいほど良い)
# 37 能力値(5着馬とのタイム差、大きいほど良い)
# 38 負担重量(大きいほど良い)
# 39 クラスタイム差(大きいほど良い)
それぞれのデータは0~1に正規化してます。ヒストグラムは下記画像の通りです。
学習モデルの構造(Transformerベース)
学習モデルの核はTransformerですが、その前の埋め込み層については、カテゴリデータはembedded層、数値データは全結合層でそれぞれ特徴量を取り出します。
次に、過去、差分、今回のそれぞれのデータを結合して、再度全結合で特徴量を取り出します。
ここで特徴量同士の関連性をAttensionで抽出し、TransformerEncoderで判断します。
・差分情報を使って、過去7走のどのレースに着目すべきか?
・今回情報を使って、どの特徴量に着目すべきか?
・能力値情報を使って、今回どの程度のタイムで走れるか?
とざっくりこのような思考でモデルは構築されています。
入力データは(バッチサイズ, 18頭分, 7走分, 40次元)、出力データは(バッチサイズ, 18頭の勝率)です。
出力される18頭の勝率は全部足すと1になるSoftmaxなので、ケリー基準にそのまま活用できます。
モデルのダイアグラムは下記画像の通りです。
損失関数(どのような性格のモデルに育成するか)
ここはいつも悩むのですが、全て当てようと欲張るとイマイチな成長となる、の繰り返しの歴史の中、今回は「WIN5に強い」モデルを目指しました。
つまり、1着馬を当てることに特化した損失関数を準備したということです。
1着馬を当てること、その馬ができるだけ高いオッズだとなお良し、外しても出来るだけ上位にくるとなお良し、と3つの基準を設け、それぞれを
当てる:高いオッズ:上位入着 = 6:3:1
の割合でブレンドした損失関数です。
学習結果
上記データとモデルでハイパーパラメータを微調整しながら何度も学習を繰り返し、最終的に満足できるモデルに落ち着きました。
最適化関数や過学習防止のための施策も複数ありますが、その辺りはデータとの兼ね合いもあると思うので、今回は割愛します。
2025年1月から11月末までのテストデータ(予想対象2640レース分)の成績は
単勝的中率31.7%
単勝回収率102.5%
この数字だけを見ると今までと大差ないように見えますが、ともあれ全レース購入で回収率が100%を超えてるのは大きな成果です。
JRAの発表では単勝還元率は80%なので、そこから+22%という成績です。
今回のキモ、どのレースを買うべきか
さてこのまま全レース購入だと年間(実際は11ヶ月分)102%なので年間複利2%ということになります。これは銀行に預けるよりマシですがNISAファンドよりも低い結果ですね…(笑)
初期投資額10万円で運用開始すると最終的には102,000円ということになります。
そこで、今回の全2640レース中、どのレースを買うか、どのレースを見(ケン)するか、という発想になりました。
本当はこのテーマこそAI向きのデータマイニングなのですが、ここは泥臭く経験則から思い当たるところをチェックしました。
そして、最終的に納得できるルールに到達しました。それは…
AI予測上位5頭の3連複BOX10点のオッズのうち、
配当の低い順(人気順)に並べた6番目のオッズが70倍を超える場合のみ購入
です。このルールでの購入対象は1200レース程で全体の40%程度となります。つまり購入頻度は1日のレースの半分以下となります。
このルールを発見できたのは大きかったと思います。今まで経験則的にこの6番目と7番目のオッズを見てレース購入判断をしてました。
このルールに基づく成績は
単勝的中率31.2%
単勝回収率112.1%
と回収率が大幅に向上してます。
初期投資額10万円で運用開始すると最終的には112,000円ということになります。競馬やってる人なら、この成績でも十分と感じますよね。
さて、ここからが最大のエポックメイキング的内容になります。ケリー基準の導入です。
ケリー基準は投資の世界では有名な指標で、競馬の場合、「どのレースをどのくらい購入すべきか」を計算します。
ケリー基準 f = p - (1 - p) / b = Rn - (1 - Rn) / (On - 1)
的中率:p(馬心Formerの出力する勝利確率Rn、nは予測1位の馬番)
利益:b=単勝オッズOn-1(1は購入原資)
この値が大きいほど、そのレースを買うための資金も増えます。例えばfが0.01だとしたら、残高の1%で購入することになります。10万だったら1000円ですね。
マイナスの場合は見送ります。回収が期待できないということになります。
大きなプラスの場合、購入額が残高の30%とかはさすがに怖いので、リミットを設けることにしました。
これも色々とシミュレーションして、初期投資額の1/20までとしました。10万の場合は5000円までですね。
このケリー基準に従った買い方を行うと、かなりヤバいことになります。
対象レースは571レースと全対象レースの21.6%程度に減りますが、
単勝的中率21.5%
単勝回収率129.4%
と回収率が大幅向上しただけでなく、大きく当たるときに大きな原資を投入してますので、実質年間900%の回収率となります。10万が90万になってます。
まぁ、これは2025年度データでのたまたまの成績かもしれませんが…とにかくヤバい(笑)
3年続けたら10万が7300万…(笑) まぁ夢物語ですが、とても興味深い結果が出たと思います。
私は毎回計算して判断してたらドキドキしてしまうので、全て自動計算して購入することにしました。
皆さんは自己責任でご判断くださいね。
まずは初期投資額10万で始めてみます。年末の結果をお楽しみに~(笑)
追記
その後検証してみると、ケリー基準値がマイナスの場合と3連複オッズ6位が70倍未満はほぼ同じレースを示していたので、ケリー基準のみで購入判断することにしました。
ケリー基準値の1/8の値をベースに、上下限を0.02~0.03でクリップし、購入直前の残高の2%~3%で購入することに。10万円の残高だと1レースの単勝1点に2000円~3000円を投資する感じです。
ちなみに2025年度の最大ハマり回数(外し続ける回数)は24回だったので、この買い方で残高0になる確率は低いと思ってます。
購入対象レース:1141R(全レース2640Rの43.2%)
◎単勝的中率:23.0%
◎単勝回収率:118.8%(均等買いの場合)
自動購入アプリのロジック(コード掲載はありません)
1 レース発走10分前になったら、馬場状態を確認
2 HP掲載用の予想は前日に行うため、馬場状態に変化がある場合は再度予想を実施
3 予想できない馬(初出走や初ダート等)が1番人気の場合は見送り
4 ◎の単勝オッズと馬心Formerの馬心確率(予測勝率)からケリー基準値を計算
5 ケリー基準値がマイナスの場合は見送り
6 あらかじめ指定した上下限値(現在は下限2%、上限3%)にクリップして購入額を決定
7 Seleniumを使用してIPAT購入を実施
8 メールで購入した内容を通知
このアプリは結構苦労しました。
JRAVANの提供APIは32bit版Pythonでしか動作しません。
なので仮想環境を構築し32bitPython環境下で動かす必要がありました。
ここで問題は10分前に馬場状態を確認し、変化がある場合は再度予想を行うとき、馬心Formerを動かす必要があるのですが、これは64bit版Pythonの環境でしか動作しません。
そこで、別環境で待機するアプリを起動し、再予想申請があれば、馬心Formerを動かして結果を返すメッセージ処理を行ってます。実際には同じフォルダにいる2つのアプリですが、それぞれ32bitと64bitで動作環境が異なるというややこしい関係です。
連携自体はアトミック操作を活用したファイル連携というシンプルな構造です。
参考ソースコード
以下、モデル定義(一部抜粋)です。
class ImprovedHorseRacingTransformer(nn.Module):
def __init__(self, d_model=128, nhead=8, num_layers=1, dim_feedforward=512, dropout=0.3,
stochastic_depth=0.0):
super().__init__()
# 特徴量のインデックス定義
self.gp1_idx = (0, 2)
self.gp2_idx = (2, 6)
self.gp3_idx = (6, 14)
self.gp4_idx = (14, 23)
self.gp5_idx = (23, 28)
self.gp6_idx = (28, 33)
self.gp7_idx = (33, 40)
self.gp1_vocab = [10, 2]
self.gp3_vocab = [2, 2, 2, 2, 2, 2, 2, 2]
self.gp5_vocab = [10, 2, 2, 2, 2]
embed_dim = 32
# ===== Embedding層 =====
self.gp1_embeddings = nn.ModuleList([
nn.Embedding(vocab_size, embed_dim if vocab_size > 2 else 8)
for vocab_size in self.gp1_vocab
])
self.gp3_embeddings = nn.ModuleList([
nn.Embedding(2, 8) for _ in range(len(self.gp3_vocab))
])
self.gp5_embeddings = nn.ModuleList([
nn.Embedding(vocab_size, embed_dim if vocab_size > 2 else 8)
for vocab_size in self.gp5_vocab
])
self.gp1_projection = nn.Sequential(
ProjectionBlock(40, 40),
ProjectionBlock(40, 40),
)
self.gp3_projection = nn.Sequential(
ProjectionBlock(64, 64),
ProjectionBlock(64, 64),
)
self.gp5_projection = nn.Sequential(
ProjectionBlock(64, 64),
ProjectionBlock(64, 64),
)
# ===== 連続値の投影(LayerNorm追加) =====
self.gp2_projection = nn.Sequential(
nn.Linear(4, 32),
nn.LayerNorm(32), # ★ 追加
nn.GELU(), # ★ ReLU → GELU
nn.Dropout(dropout), # ★ dropout * 0.5 → dropout
ProjectionBlock(32, 32),
ProjectionBlock(32, 32),
)
self.gp4_projection = nn.Sequential(
nn.Linear(9, 64),
nn.LayerNorm(64), # ★ 追加
nn.GELU(), # ★ ReLU → GELU
nn.Dropout(dropout), # ★ dropout * 0.5 → dropout
ProjectionBlock(64, 64),
ProjectionBlock(64, 64),
)
self.gp6_projection = nn.Sequential(
nn.Linear(5, 32),
nn.LayerNorm(32), # ★ 追加
nn.GELU(), # ★ ReLU → GELU
nn.Dropout(dropout), # ★ dropout * 0.5 → dropout
ProjectionBlock(32, 32),
ProjectionBlock(32, 32),
)
# gp1: 40, gp2: 32, gp3: 64, gp4: 64 = 200
# gp5: 64, gp6: 32 = 96
past_total_dim = 40 + 32 + 64 + 64
current_total_dim = 64 + 32
# ===== 特徴統合(LayerNorm追加) =====
self.past_projection = nn.Sequential(
nn.Linear(past_total_dim, d_model),
nn.LayerNorm(d_model), # ★ 追加
nn.GELU(), # ★ ReLU → GELU
nn.Dropout(dropout), # ★ dropout * 0.5 → dropout
ProjectionBlock(d_model, d_model),
ProjectionBlock(d_model, d_model),
)
self.current_projection = nn.Sequential(
nn.Linear(current_total_dim, d_model),
nn.LayerNorm(d_model), # ★ 追加
nn.GELU(), # ★ ReLU → GELU
nn.Dropout(dropout), # ★ dropout * 0.5 → dropout
ProjectionBlock(d_model, d_model),
ProjectionBlock(d_model, d_model),
)
# ===== レース重要度学習 =====
self.race_importance = nn.Sequential(
#ProjectionBlock(d_model, d_model),
#ProjectionBlock(d_model, d_model),
nn.Linear(d_model, d_model // 2),
nn.GELU(),
nn.Dropout(dropout * 0.5),
nn.Linear(d_model // 2, 1),
nn.Softmax(dim=1)
)
# ===== Temporal Attention =====
self.temporal_attention = nn.MultiheadAttention(
embed_dim=d_model,
num_heads=nhead,
dropout=dropout,
batch_first=True
)
self.temporal_norm = nn.LayerNorm(d_model)
self.temporal_dropout = nn.Dropout(dropout)
# ===== Channel Attention =====
self.channel_attention = nn.Sequential(
ProjectionBlock(d_model, d_model),
ProjectionBlock(d_model, d_model),
nn.Linear(d_model, d_model // 2),
nn.GELU(),
nn.Dropout(dropout * 0.5),
nn.Linear(d_model // 2, d_model),
nn.Sigmoid()
)
self.channel_norm = nn.LayerNorm(d_model)
# ===== Ability Gate =====
self.ability_gate = nn.Sequential(
nn.Linear(7, d_model // 2),
nn.GELU(),
nn.Dropout(dropout * 0.5),
nn.Linear(d_model // 2, d_model),
#ProjectionBlock(d_model, d_model),
#ProjectionBlock(d_model, d_model),
nn.Sigmoid()
)
# ===== Stochastic Depth =====
self.stochastic_depth = StochasticDepth(stochastic_depth)
# ===== 特徴統合 =====
self.feature_fusion = nn.Sequential(
nn.Linear(d_model, d_model),
nn.LayerNorm(d_model),
nn.GELU(), # ★ ReLU → GELU
nn.Dropout(dropout),
#ProjectionBlock(d_model, d_model),
#ProjectionBlock(d_model, d_model),
)
# ===== Positional Encoding =====
self.pos_encoding = self._create_positional_encoding(d_model, max_len=7)
# ===== Transformer Encoder =====
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=nhead,
dim_feedforward=dim_feedforward,
batch_first=True,
dropout=dropout,
activation='gelu'
)
self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
# ===== 馬の特徴集約 =====
self.horse_aggregation = nn.Sequential(
nn.Linear(d_model * 7, d_model * 2),
nn.LayerNorm(d_model * 2),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_model * 2, d_model),
nn.LayerNorm(d_model),
nn.Dropout(dropout * 0.5),
)
# ===== レース全体のAttention =====
self.race_attention = nn.MultiheadAttention(
d_model, nhead, dropout=dropout, batch_first=True
)
self.race_norm = nn.LayerNorm(d_model)
self.race_dropout = nn.Dropout(dropout)
# ===== 予測ヘッド =====
self.predictor = nn.Sequential(
nn.Linear(d_model, d_model * 2),
nn.LayerNorm(d_model * 2),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_model * 2, d_model),
nn.LayerNorm(d_model),
nn.GELU(),
nn.Dropout(dropout * 0.5),
nn.Linear(d_model, d_model // 2),
nn.LayerNorm(d_model // 2),
nn.GELU(),
nn.Dropout(dropout * 0.3),
nn.Linear(d_model // 2, 1)
)
self._init_weights()
def _create_positional_encoding(self, d_model, max_len=7):
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() *
(-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
return nn.Parameter(pe.unsqueeze(0), requires_grad=False)
def _init_weights(self):
"""He初期化(ReLU/GELU用)"""
for m in self.modules():
if isinstance(m, nn.Linear):
nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu')
if m.bias is not None:
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.Embedding):
nn.init.normal_(m.weight, mean=0, std=0.1) # stdを大きく
def forward(self, horse_info, running_mask=None, padding_mask=None):
batch_size, num_horses, num_races, _ = horse_info.shape
horse_info_reshaped = horse_info.view(batch_size * num_horses, num_races, -1)
# ===== グループ抽出 =====
gp1 = horse_info_reshaped[:, :, self.gp1_idx[0]:self.gp1_idx[1]].long()
gp2 = horse_info_reshaped[:, :, self.gp2_idx[0]:self.gp2_idx[1]]
gp3 = horse_info_reshaped[:, :, self.gp3_idx[0]:self.gp3_idx[1]].long()
gp4 = horse_info_reshaped[:, :, self.gp4_idx[0]:self.gp4_idx[1]]
gp5 = horse_info_reshaped[:, :, self.gp5_idx[0]:self.gp5_idx[1]].long()
gp6 = horse_info_reshaped[:, :, self.gp6_idx[0]:self.gp6_idx[1]]
gp7 = horse_info_reshaped[:, :, self.gp7_idx[0]:self.gp7_idx[1]]
# ===== Embedding =====
gp1_embs = [emb(gp1[:, :, i]) for i, emb in enumerate(self.gp1_embeddings)]
gp1_embedded = torch.cat(gp1_embs, dim=-1)
gp3_embs = [emb(gp3[:, :, i]) for i, emb in enumerate(self.gp3_embeddings)]
gp3_embedded = torch.cat(gp3_embs, dim=-1)
gp5_embs = [emb(gp5[:, :, i]) for i, emb in enumerate(self.gp5_embeddings)]
gp5_embedded = torch.cat(gp5_embs, dim=-1)
gp1_embedded = self.gp1_projection(gp1_embedded)
gp3_embedded = self.gp3_projection(gp3_embedded)
gp5_embedded = self.gp5_projection(gp5_embedded)
gp2_projected = self.gp2_projection(gp2)
gp4_projected = self.gp4_projection(gp4)
gp6_projected = self.gp6_projection(gp6)
# ===== 過去・現在の特徴統合 =====
past_features = torch.cat([gp1_embedded, gp2_projected, gp3_embedded, gp4_projected], dim=-1)
past_features = self.past_projection(past_features)
current_features = torch.cat([gp5_embedded, gp6_projected], dim=-1)
current_features = self.current_projection(current_features)
# パディングマスク適用
if padding_mask is not None:
padding_mask_reshaped = padding_mask.view(batch_size * num_horses, num_races)
padding_mask_expanded = padding_mask_reshaped.unsqueeze(-1).float()
past_features = past_features * padding_mask_expanded
current_features = current_features * padding_mask_expanded
# ===== レース重要度の計算 =====
race_weights = self.race_importance(past_features)
past_features_weighted = past_features * race_weights
# ===== Temporal Attention =====
current_race_emb = current_features[:, 0, :]
current_race_query = current_race_emb.unsqueeze(1).expand(-1, num_races, -1)
temporal_attended, _ = self.temporal_attention(
query=current_race_query,
key=past_features_weighted,
value=past_features_weighted
)
temporal_attended = self.temporal_dropout(temporal_attended)
temporal_attended = self.temporal_norm(temporal_attended + past_features)
# ===== Channel Attention =====
channel_weights = self.channel_attention(current_race_emb)
channel_weights_expanded = channel_weights.unsqueeze(1)
channel_attended = temporal_attended * channel_weights_expanded
channel_attended = self.channel_norm(channel_attended)
# ===== Ability Gate =====
ability_weight = self.ability_gate(gp7)
final_features = channel_attended * ability_weight
# Stochastic Depth適用
final_features = self.stochastic_depth(final_features) # 18,7,d_model
if padding_mask_expanded is not None:
final_features = final_features * padding_mask_expanded # 18,7,d_model x 18,7,1 = 18,7,d_model
# ===== 特徴統合 + Positional Encoding =====
x = self.feature_fusion(final_features) # 18,7,d_model
x = x + self.pos_encoding[:, :num_races, :] # 18,7,d_model
if padding_mask_expanded is not None:
x = x * padding_mask_expanded # 18,7,d_model x 18,7,1 = 18,7,d_model
# ===== Transformer =====
x = self.transformer_encoder(x)
# ===== 馬の特徴集約 =====
x = x.view(batch_size * num_horses, -1)
horse_features = self.horse_aggregation(x) # 18,d_model
# ===== レース全体のAttention =====
horse_features = horse_features.view(batch_size, num_horses, -1)
attn_output, _ = self.race_attention(horse_features, horse_features, horse_features)
horse_features = self.race_norm(horse_features + attn_output)
horse_features = self.race_dropout(horse_features)
# ===== 予測 =====
predictions = self.predictor(horse_features).squeeze(-1) # batch,18
if running_mask is not None:
predictions = predictions + (1 - running_mask) * (-100.0)
return predictions
以下、損失関数の定義です。
class ImprovedRacingLoss(nn.Module):
def __init__(self, alpha=0.3, beta=0.6, gamma=0.1):
super().__init__()
self.alpha = alpha # 回収率重視
self.beta = beta # 的中率重視
self.gamma = gamma # ランキング精度
def forward(self, predictions, winner_idx, winner_odds, running_mask):
batch_size = predictions.size(0)
# 1. CrossEntropy Loss(的中率)
ce_loss = F.cross_entropy(predictions, winner_idx)
# 2. オッズ重み付き損失(回収率)
# 高オッズ馬を的中させるほど報酬が大きい
probs = F.softmax(predictions, dim=1)
winner_probs = probs[torch.arange(batch_size), winner_idx]
# オッズの対数を使って過度な重み付けを防ぐ
odds_weight = torch.log(winner_odds.clamp(min=1.0, max=50.0) + 1.0)
odds_weighted_loss = -(torch.log(winner_probs + 1e-8) * odds_weight).mean()
# 3. Ranking Loss(上位予測精度)
# 勝ち馬が予測上位に来るように
sorted_indices = torch.argsort(predictions, dim=1, descending=True)
winner_expanded = winner_idx.unsqueeze(1).expand(-1, predictions.size(1))
winner_ranks = (sorted_indices == winner_expanded).float().argmax(dim=1).float()
# 順位を正規化(0-1の範囲)
num_horses = running_mask.sum(dim=1).float()
normalized_ranks = winner_ranks / (num_horses + 1e-8)
ranking_loss = normalized_ranks.mean()
total_loss = (self.beta * ce_loss +
self.alpha * odds_weighted_loss +
self.gamma * ranking_loss)
return total_loss, ce_loss, odds_weighted_loss, ranking_loss
なお、全ソースコードは現状公開予定ありません。






