ココンの情報をいつでも、どこでも。ココントコ。

エンジニア 2018.03.14

競馬予想AIを作る 〜ニューラルネットワークによる相対評価データセットの取り扱い例〜

AI戦略室の坂本です。
近今のブームによりAIが注目されるのは良いのですが、じゃあAIの何が凄いのか、という話になると、どうも未だに誤解が多くはびこっているように感じます。
そんなときに、AIで「これが出来た」→「それは凄い!」と直結するような、【鉄板ネタ】があれば、話が進みやすいと思いませんか?

そこで今回は、
『競馬予想AIを作る』
として、JRA主催の競馬レースを予想する人工知能について書こうと思います。

目次

背景

今回解説する人工知能は、ココングループ内のいわば余興で作成したもので、社内の忘年会で発表するLTで2017年有馬記念のAI予想を行う、という目的で作成したものです。
しかし、作ってみると意外なほど良いものが出来上がり、少なくとも数字上は馬券の購入金額以上の払い戻しが期待できるモデルが作成されました。
このモデルは、以下のGitHubにて公開しています。
https://github.com/cocon-ai-group/turf-tipster

ちなみにライセンスはGNU AGPLv3でどなたでも無償利用可能ですが、このモデルを組み込んだシステムを配布しソースコードを開示したくない場合(AGPLライセンスに準拠したくない場合)などは、AI戦略室までお問い合わせください。

競馬予想AIの作成

さて、競馬予想のAIを作るとなったとき、まず考えられる困難点が、

・馬の数がとても多い(2万以上)が、それぞれの馬のレース数は少ない(〜46)
・勝負事である(絶対評価で求められたデータでは無く、相対評価のセット)
・その時々の調子とかがある、馬も成長する(時系列的データ)
・騎手、競馬場の特徴、天候、馬場状態などにも結果が左右される

というものです。
特に、レースという結果が相対的な着順で争われる以上、学習データを相対評価のセットとして扱わなければならないという点が通常の機械学習とは異なっており、そのデータをどのように扱うかが予想AI開発の焦点となります。
機械学習で利用される回帰分析では、入力されるデータから出力の値を予測しますが、これはあくまで絶対評価として与えられる値の予測であって、その馬のラップタイムを予測しろ等は可能ですが、勝ち負けを予測するのには不向きなのです。
ではかといって、「勝ちそうな馬」を予測するのが全く不可能であるかと言えばそんなはずはなく、少なくとも人間は過去のレースから「この馬は強い」だとか、「この馬は弱い」などの知見を得ていると考えられます(少なくとも競馬をやっている人はそうだと言うはずです)。
そこでここでは、機械学習モデルを作成する上で、ある仮定を作成しました。
それは、

「強い馬は弱い馬より上の順位に位置することが多いはず」

というもので、一見すると当然の事に思えますが、重要なものです。
そして、相対評価の問題ですが、それが本当に問題であるならば、人間はなぜ「この馬は強い馬だ」と認識できるのでしょうか?
一頭の馬が生涯に走るレース数が少ない点は確かに問題になり得ますが、まずシンプルなモデルとして、「弱い相手に勝った馬」と、「強い相手に負けた馬」が居たとき、それをどのように扱うかについて考えます。

ここで、Aは弱い馬に勝ち、Bは強い馬に負けたとします。
この場合のAとBの格付けがどうなるか、というと、単純に勝利数の多いAの方が上、とはゆかない事は解ると思います。
しかし、この場合、そもそも「強い馬」「弱い馬」というのは、どのように評価されたのでしょう?
それは、結局、別の勝負の結果を参照して行われた、強さの格付けに基づいているはずです。
つまり、別のレースで、

というのがあったはずなのです(あるいは、さらに別のレースの結果を元にした序列が作成された)。

ここまで考えを進めれば、機械学習モデルの作成方針が見えてきました。
前述の仮定が成り立つのであれば、
「結局のところ、レース全ての組合せを考えれば、その順位の組合せ内に、全出走馬の強さ情報が含まれているはず」
という仮説が成立するはずです。(ただし、勝つときは圧勝するが負けるときはボロ負けする、みたいな馬は前述の仮定にもこの仮説にも当てはまりませんね。なので、当然のようにも思える仮定が重要なのです)
そして、馬の「強さによる序列」は、機械学習で扱える「ベクトルの大きさの並び順」として表現する事が出来ます。
そこで、今回の機械学習モデルの作成方針は、

「馬の名前から、その馬の「強さベクトル」を返すモデルを作成」

することとできます。そして、

「その「強さベクトル」は、過去の全てのレースにおいて、
勝った馬 > 負けた馬
となる。」

ように機械学習すれば、レースに出走する馬の中での強い馬、すなわち勝つ可能性の高い馬を発見できるはずです。
つまり、ここでは、出力のベクトルを並べると出走馬の順位順になるような機械学習モデルを構築し、その「強さベクトル」を元にレースの予想を行うAIを作成します。

ニューラルネットワークのモデル

さて、それでは実際の学習モデルを作成してゆきます。
今回のように単純な回帰やクラス分類ではない問題を扱う場合、やはり柔軟なモデルが作成可能なニューラルネットワークを使用するのが良いでしょう。
まず、ニューラルネットワークへの入力ですが、これは当然、出走する馬と騎手の組合せが必要です。
それらの情報は予め馬名と騎手名から一意な番号を作成しておきます。
そして、Embed層を使用することで、各位の「馬ベクトル」「騎手ベクトル」と言うべきベクトルデータとなるようにモデルを作成します。
さらにここでは、距離、天候、トラックの種類(芝かダートか障害か)などをレース情報として入力し、全結合層でもって最終的な結果となるようにマージします(図には現れていませんが、レース情報にもEmbed層が存在します)。

そして、その結果として出力されるベクトルを、レース結果と同じ順番に並ぶようにディープラーニングさせます。
さらに、「馬の成長」というファクターを扱う為にRNNを組み込んだモデルは以下のようになります。

ここで、それぞれの馬・騎手ペアに対するニューラルネットワークは、それぞれ別々に学習させたいので、1レース分のデータはミニバッチ次元方向へと並べます。
つまり、1レースにおける最大出走数(出走枠)が18ならば、バッチサイズ=18となり、ミニバッチ内のデータは一回のレースのデータとなる訳です。

損失関数

そして、ニューラルネットワークへの学習に必要な損失関数です。
今回の機械学習モデルでは、ミニバッチ次元方向へ1レース分のデータを並べているので、バッチサイズが18であっても、そこから出力される損失の値は1個であることに注意してください。(通常の学習手法では、バッチサイズが18なら損失の値も18個作成される)
このようなひねくれたモデルを作成する際、現状Chainerが最も解りやすいコードを書けるので、ここではChainerを使用してプログラムを作成します。
また、バッチサイズを増やして計算を効率化する手法は、ここでは使用できません(最大出走数の整数倍なら可能ですが実装が面倒になるので実装していません)。
さて、それでは、ニューラルネットワークの出力結果が「レース結果と同じ順番に並ぶように」学習させるための損失関数は、どのようになるでしょう?

実は、ニューラルネットワークでこのような「並べ替え問題」を扱う事は、とても難しいものだったりします。
その理由は「並べ替え(ソート)」のアルゴリズムについて考えてみれば解ります。
ソートのアルゴリズムにはいくつかの種類がありますが、それらは全て「値の比較(IF演算)」を必要としています。
しかし、ニューラルネットワークの損失関数内部では、基本的には「値の比較(IF演算)」によって処理を切り替えることが出来ません。
なぜかというと、ディープラーニングの為には、ニューラルネットワーク内の全ての計算グラフを作成して、その微分を行う必要があるのですが、「値の比較(IF演算)」によって処理を変更すると、その計算グラフの形が学習毎に変化してしまい、学習アルゴリズムによる損失の伝播が一定化しないためです。
そこで、「値の比較(IF演算)」を使用せずに、微分可能な関数のみを使用して、ニューラルネットワークの出力結果が「レース結果と同じ順番に並ぶように」学習させるための損失関数を作成する必要があります。
さらに、ニューラルネットワークの出力結果が「レース結果と同じ順番に並ぶ」だけではなく、「空間内に均等に分布する」ような力が働く損失関数であればより優れているでしょう。
なぜなら、そのような力が働かなければ、ニューラルネットワークの出力が空間内の一点に固定されてゆき、ベクトル間の差が消失する方向へと学習されてゆくことが予想されるからです。
それらを踏まえた上で、どのような損失関数を作成すれば良いかを考えます。

上の図は、単純に二つの結果に対してどのような損失を計算すべきかを表した図です。
まず、ニューラルネットワークは損失の値が少なくなる方向へと学習してゆきます。
なので、二つの結果が「正しい順序」になっている場合は、「逆の順序」の場合よりも損失の値が小さくなります。

さらに、「正しい順序」のまま、ベクトルの大きさの差が拡大するほど、損失の値は小さくなり、「逆の順序」のまま、ベクトルの大きさが拡大するほど、損失の値は大きくなります。
そしてさらに、「正しい順序」でベクトルの大きさの差が拡大したときよりも、「逆の順序」でベクトルの大きさの差が拡大したときの方が、損失の値の変化は大きくならなくてはなりません。
このような関数を、レース結果となる全てのベクトル同士について組合せ演算する事で、結果として、ニューラルネットワークの出力結果となるベクトルの大きさが「レース結果と同じ順番に並んだ」時に最も損失が小さくなるような、損失関数が作成出来ます。

そのようにして作成した損失関数の数式を、上に乗せます。
そのための関数には、色々なパターンが考えられます。
ここでは、「正しい順序」よりも「逆の順序」の時に大きく損失を変化させるためにsoftplus関数を使用し、「正しい順序」と「逆の順序」を符号として扱う為に、tanh関数またはatan関数を使用しています。

実際の学習

それでは実際に競馬レースのデータを使って学習した例について書いてゆきます。
まずは競馬レースのデータを入手する必要がありますが、それにはJRAのデータサービスであるJRA-VAN(https://jra-van.jp/)にアプリ開発者として登録し、公式のデータをダウンロードするのが良いでしょう。(他にも競馬情報サイトからスクレイピングする手もありますが、その場合は各サイトの利用規程を守り個人利用の範囲で行う必要があります)
後はダウンロードしたデータを、csvファイルに保存します。ここで紹介するAIで使用するには、レース情報と着順、馬名、機種名のリストを、以下のような形式で保存する必要があります。

レース情報:

レース名|競馬場|馬場|距離|天気|オッズ|日付

出走馬情報:

着順|馬名|騎手名

csvデータ:

レース情報,出走馬情報,出走馬情報,出走馬情報・・・

例:

$ head -n1 race_train.csv
新潟日報賞|新潟|芝|1400|曇|600:230_160_980:410:1210:490_6730_3340:2700:22900:102190|20170812,1|アポロノシンザン|津村明秀,2|ビップライブリー|大野拓弥,3|ディアマイダーリン|田中勝春,4|ラプソディーア|吉田豊,5|ルグランパントル|柴田善臣,6|メイショウメイゲツ|江田照男,7|ネオスターダム|内田博幸,8|ピンストライプ|M.デム,9|ナイトフォックス|丸田恭介,10|テンテマリ|丸山元気,11|エリーティアラ|北村宏司,12|クリノタカラチャン|野中悠太,13|スズカシャーマン|伊藤工真,14|スズカアーサー|武士沢友,15|オヒア|小崎綾也,16|マリオーロ|吉田隼人,17|フレンドスイート|柴田大知,18|ディープジュエリー|石橋脩

レースのデータは、過去3000レース分を用意し、そのうち古い2500件を学習に、新しい500件を検証に利用しました。
今回のような時系列データを学習させる場合、データからランダムに検証用データを取り分けると、未来のデータを利用して学習したモデルを検証することになってしまうので、不正確に良い結果が出てしまいます。ここでは最新の500件を検証用にしているので、おおむね5ヶ月前時点のデータを元に学習したモデルを、直近5ヶ月の結果で検証していることになります。
また、検証の値は損失の値だけではなく、オッズを元に計算した払い戻し金も表示するようにします。
以下がオッズを元に払い戻し金を計算する評価関数ですが、馬券の買い方によるそれぞれの払い戻し金を計算し、500レースにおける平均値を求めています。

# 評価関数
def acc_gate(t, ext):
    s = []
    for i in range(num_gates):
        s.append(F.sum(t[i]).data)
    # オッズを元にリターンベースで評価
    v = cp.argsort(cp.array(s, dtype=cp.float32))
    v = cp.flip(v, axis=0)
    tansho = 0
    if v[0] == 0:
        tansho = ext[0][0] # 単勝
    fukusho1 = 0
    if v[0] < 3:
        fukusho1 = ext[0][1] # 複勝(1枚買ったとき)
    fukusho2 = 0
    if v[0] < 3:
        fukusho2 += ext[0][1] # 複勝(2枚買ったとき)
    if v[1] < 3:
        fukusho2 += ext[0][2] # 複勝(2枚買ったとき)
    fukusho2 = fukusho2 / 2
    fukusho3 = 0
    if v[0] < 3:
        fukusho3 += ext[0][1] # 複勝(3枚買ったとき)
    if v[1] < 3:
        fukusho3 += ext[0][2] # 複勝(3枚買ったとき)
    if v[2] < 3:
        fukusho3 += ext[0][3] # 複勝(3枚買ったとき)
    fukusho3 = fukusho3 / 3
    umaren = 0
    if v[0] == 0 and v[1] == 1:
        umaren = ext[0][5]
    elif v[0] == 1 and v[1] == 0:
        umaren = ext[0][5] # 馬連
    wide = 0
    if (v[0] == 0 and v[1] == 1) or (v[0] == 1 and v[1] == 0):
        wide = ext[0][6] # ワイド
    elif (v[0] == 0 and v[1] == 2) or (v[0] == 2 and v[1] == 0):
        wide = ext[0][7] # ワイド
    elif (v[0] == 1 and v[1] == 2) or (v[0] == 2 and v[1] == 1):
        wide = ext[0][8] # ワイド
    umatan = 0
    if v[0] == 0 and v[1] == 1:
        umatan = ext[0][9] # 馬単
    triren = 0
    if v[0] <= 2 and v[1] <= 2 and v[2] <= 2:
        triren = ext[0][10] # 3連複
    tritan = 0
    if v[0] == 0 and v[1] == 1 and v[2] == 2:
        tritan = ext[0][11] # 3単連
    return (tansho,fukusho1,fukusho2,fukusho3,umaren,wide,umatan,triren,tritan)

ちなみにオッズの表記は100円分の勝ち馬投票券を買って、何円の払い戻しがあるかを表しているので、100を超えれば勝ちの目ということになります。
それでは実際に学習させた際の評価を以下に提示します。

$ python3 train.py -t race_train.csv -s race_test.csv -g 0
epoch main/loss validation/main/loss main/tansho validation/main/tansho main/fukusho1 validation/main/fukusho1 main/fukusho2 validation/main/fukusho2 main/fukusho3 validation/main/fukusho3 main/umaren validation/main/umaren main/wide validation/main/wide main/triren validation/main/triren main/tritan validation/main/tritan
1 10.0332 9.07242 570.22 530.64 181.864 166.5 119.914 107.2 107.6 92.5467 150.764 81.14 136.028 55.58 304.788 33.68 991.616 0 
2 7.31421 8.79142 593.248 518.24 188.616 164.32 130.716 106.23 115.605 95.7067 187.872 83.66 135.752 47.6 145.276 3.64 272.876 7.5 
・・・(略)


単勝馬券のみを購入し続けた場合の、期待リターンはどちらのモデルも驚異の500超えです。
また、複勝馬券を1枚だけ買った場合も100を超えますが、それ以外の買い方だと100を下回るようですね。三単連とか、確率は低いが当たれば万馬券のような馬券は、500レース程度のレース数では正しく評価できないでしょう(それでも5ヶ月間ずっと同じ戦略で買い続ける必要がありますが)。

スクレイピングのプログラムにバグがあり、単勝馬券の期待リターン率が実際より高く出るようになっていました(現在は修正済み)。
より精度の良い予想を目指して、ランキング学習のアルゴリズムを使用した版も作成したので、そちらの方も参考にしてみてください。

※新しいアルゴリズムを使用した記事を作成しました。
・競馬予想AI再び -前編- 〜LambdaRank編〜
・競馬予想AI再び 後編- 〜アンサンブル学習編〜

最後に、学習済みのモデルを元にレースの予想を行うには、以下のようにします。

$ python3 prefigure.py -e "中山|芝|1800|晴" -r "サトノスティング|横山典弘,ウイングチップ|丸田恭介,カレンリスベット|蛯名正義,キャプテンペリー|大野拓弥,コスモナインボール|柴田大知,バルデ ス|戸崎圭太,ブラックスビーチ|北村宏司,ウインファビラス|松岡正海,クラウンディバイダ|石橋脩,タブレットピーシー|田中勝春"
予想順位    馬名    Accuracy
1    ウイングチップ    0.10088613
2    キャプテンペリー    0.10074568
3    コスモナインボール    0.09816859
・・・(略)

まとめ

いかがでしたか?
このAIでは、勝つ馬だけではなくレースに出走する全ての馬の結果を予測するので、仮想の馬/騎手の組合せによる「夢のレース」をシミュレーションしたり、過去のレースから騎手だけ入れ替えてみたり、全ての馬に同じ騎手を乗せた場合のシミュレーションをしてみたりと、他にも色々な楽しみ方があると思います。
単なる余興から始まった今回のAI開発ですが、勝負事であるレースの結果を予測するという特性上、単純にデータをニューラルネットワークに突っ込めば良いというものではなく、きちんと考えを巡らせた機械学習モデルを作成する必要がありました。
ディープラーニングの手法は確かに非常に柔軟な学習を可能にしてくれますが、対象となるデータの形によっては、なかなか単純に突っ込んでそれでOKという訳にはゆかないのが現状です。
とは言っても、きちんと予想の前提となる仮説を立て、適切なモデルを考えて機械学習を行えば、相当高度なAIであっても作成する事が出来るのです。
最後になりましたが、今回紹介したAIを使用したとしても、馬券で勝つことが出来るとは、坂本もココングループも全く保証しません。馬券の購入はあくまで自己責任の上、ギャンブルであることを自覚して適度に楽しみましょう。

イベントインタビュー広報 2020.01.31

「OpenID Summit Tokyo 2020」参加レポート

広報の馬場です。4年に一度のOpenIDの祭典「OpenID Summit Tokyo 2020」が渋谷ストリーム・ホールにて1月2…

イベントインタビュー広報 2019.11.11

世界のハッカーが競う「DEF CON CTF Finals 2019」参加者インタビュー

広報の馬場です。 DEF CONの人気コンテンツの一つである「DEF CON CTF」は、熾烈な予選を勝ち抜いたチーム…

エンジニア 2019.08.23

危険すぎると話題の文章生成モデル「GPT-2」で会社紹介文を生成してみました

ココングループのAI TOKYO LABの細川です。AI、特にディープラーニング系のホットな技術を実際に動かすと…