stockedge.jpの技術メモ

http://stockedge.jp/の中の人が書いてる技術メモ

競馬の予測をガチでやってみた

f:id:stockedge:20151229204808j:plain
基本的に競馬なんてやるべきではないと私は思っている。胴元の取り分が多いからだ。宝くじに比べればまだましだが、それでも賭け金の20~30%は胴元に取られることになる。*1
しかし今回は、ちょっと思い立って競馬の予測をやってみることにした。
理由は馬券の安さだ。私は現在、資金量が少ない人間でも不利にならない投資先を探しているのだが、馬券の一枚100円という安さは魅力的に映る。株の場合にはどんな安い株であれ最低購入額は数万円以上*2なので、ある程度まとまった資金が必要になる。
また、競馬には技術介入の余地(努力次第で勝利できる可能性)がある。
例えばこんな例がある。
160億円ボロ儲け!英投資会社が日本の競馬で荒稼ぎした驚きの手法 - NAVER まとめ
彼らは統計解析によって競馬で勝っており、その所得を隠していたらしい。こういうニュースが出るということは、解析者の腕次第では競馬で勝てる可能性があるということだ。*3

まずはデータを集める

ということで、競馬の統計解析をしたいわけなのだが、解析するためのデータがなければ何も始まらない。
まずは、競馬のデータを以下のサイトからスクレイピングして取ってくることにする。
netkeiba.com - 競馬データベース
netkeiba.comでスピード指数(ある基準を元に走破タイムを数値化したもの)や馬場指数(馬場コンディションを数値化したもの)を閲覧するには有料会員に登録する必要がある。私は有料会員に登録した上でスピード指数や馬場指数まで含めてスクレイピングを行った。
以下にスクレイピング&素性作成用のScalaコードを公開する。
github.com
ちなみにデータ解析はデータを解析できる形に持っていくまでが全工程の九割を占めると言われている。実際私もこのスクレイピング&素性作成用スクリプトを作成するのに数週間はかけている*4。このスクリプトを無料で使える皆さんは幸運である。
作成された素性は最終的にSQLiteに格納されるようになっている。このコードを使うのにnetkeiba.comの有料会員に登録する必要はないが、その場合はスピード指数や馬場指数のカラムにはNULL値が入ることになるので気をつけて欲しい。

何を予測するのか

データが集まった所で、次に「何を」予測するのか決めよう。
私が調べた限りでは、競馬の予測には2つの方法がある。*5

  1. あるレースに関する情報を入力として、そのレースが荒れるか否か(一番人気の馬が一着になるかどうか)を予測
  2. ある馬の過去の勝率や騎手の勝率などを入力として、その馬がレースにおいて何着になるかを予測

例えば以下の本の著者は両方の方法を試した上で後者の方法は難しいので前者の方法で予測したほうがうまくいくと結論づけている。

実践データマイニング―金融・競馬予測の科学

実践データマイニング―金融・競馬予測の科学

しかし本記事では、まずは後者の方法を使うことにしよう。なんとなくそのほうが面白そうだから。(駄目だったら前者の方法も試します)


つまり、個別の馬に関するデータを入力とし、その馬がレースで一着になるかどうかの二値を出力とする統計モデルを作成するわけである。
なお今回は、予測するのはレースの着順ではなくあくまでも「一着になるかどうか」の二値だけにする。

変数名 説明
order_of_finish 一着であればTRUE、そうでなければFALSEとなる変数

なぜこうするのかというと、競馬ではレースの途中で騎手が「このままでは上位になれないな」と気付いたとき、馬を無駄に疲れさせないためにあえて遅く走らせることがあるのだそうだ(競馬は着順が上位じゃないと賞金が貰えないため)。つまり、着順が上位ならばその馬には実力があると言えるが、着順が下位だからといって必ずしも実力が無いとはいえないのだ。だから「厳密な着順の数値」ではなく「一着になるかどうかの二値」だけを予測するシンプルなモデルを作成したほうがうまくいくようだ。*6(参考:Identifying winners of competitive events: A SVM-based classification model for horserace prediction


気をつけないといけないのは、一着になった馬は少ない一方で、一着にならなかった馬はたくさんいるということだ。このままだと学習データが不均衡*7になってしまい、予測モデルを作成すると偏ったモデルが出来てしまう。不均衡データを扱う方法はいくつかあるが、今回は面倒臭いので多い方のクラス(一着にならなかった馬)のデータをサンプリングで減らしてしまうことにする。

何を統計モデルの入力とするのか

次に問題なのは、統計モデルの入力に何の変数を使うかだ。
このモデルの入力として、私は以下の素性を使うことにした。

変数名 説明
age 馬の年齢
avgsr4 過去4レースのスピード指数の平均
avgWin4 過去4レースの三着までに入っていた割合
course コースが右回りか左回りか直線か
dhweight 前回のレース時からの馬の体重変化量
disavesr 今回と同一の距離コースにおけるスピード指数の平均
disRoc 平均距離との差÷平均距離
distance 今回のコースの距離
dsl 前回のレースから何日空いたか
enterTimes 出場回数
eps 馬の平均獲得賞金額
grade グレードは何か
horse_number 馬番
hweight 馬の現在の重さ
jAvgWin4 騎手の過去4走の勝率
jEps 騎手の平均獲得賞金額
jwinper 騎手の一着率
owinper 馬主の一着率
placeCode 競馬場はどこか
preOOF 前走の順位
pre2OOF 2走前の順位
preSRa 前回のスピード指数
preLastPhase 前走の上がり3ハロンタイム
race_number 一日の内の何レース目か
runningStyle 馬の脚質
lateStartPer 出遅れ率
month レース日は何月か
sex 馬の性別
surface コースは芝かダートか
surfaceScore 馬場指数
twinper 調教師の勝率
weather レース日の天候
weight 斤量
weightper 斤量÷馬の体重
winRun 馬の勝ち回数

このリストは、私が競馬関連の本とか論文とかを読んで「なんとなく良さそう」と思った変数をかき集めただけなので、これらの変数を使うことに必然性があるわけではない。他の変数を使った場合にどうなるか気になるという方は自分でコードを弄って試すべし。

randomForestを使って予測してみる

予測モデルの作成にはRのrandomForestパッケージを使うことにする。random forestとは2001年にLeo Breiman によって提案された教師あり学習のアルゴリズムである。このブログを見に来るような人には解説の必要はないかもしれないが、ざっくり言うと、decision treeはbias-variance分解で言うところのvariance(学習結果の不安定性)が高いのでbaggingと素性のsamplingを適用してみたらvarianceが下がって汎化性能アップしました、というアルゴリズムがrandom forestである。*8
それでは、実際にRのrandomForestパッケージを使って予測モデルを作成してみよう。

> library(randomForest)
> library(RSQLite)
>  
> randomRows <- function(df, n) {
+   df[sample(nrow(df),n),]
+ }
>  
> downSample <- function(df) {
+   c1 <- df[df$order_of_finish == "TRUE",]
+   c2 <- df[df$order_of_finish == "FALSE",]
+   size <- min(nrow(c1), nrow(c2))
+   rbind(randomRows(c1,size), randomRows(c2,size))
+ }
>  
> drv <- dbDriver('SQLite')
>  
> conn <- dbConnect(drv, dbname='race.db')
>  
> rs <- dbSendQuery(conn, 
+ 'select 
+    order_of_finish,
+    race_id,
+    horse_number,
+    grade,
+    age,
+    avgsr4,
+    avgWin4,
+    dhweight,
+    disRoc,
+    r.distance,
+    dsl,
+    enterTimes,
+    eps,
+    hweight,
+    jwinper,
+    odds,
+    owinper,
+    preSRa,
+    sex,
+    f.surface,
+    surfaceScore,
+    twinper,
+    f.weather,
+    weight,
+    winRun,
+    jEps,
+    jAvgWin4,
+    preOOF,
+    pre2OOF,
+    month,
+    runningStyle,
+    preLastPhase,
+    lateStartPer,
+    course,
+    placeCode,
+    race_number
+ from 
+    feature f 
+ inner join 
+    race_info r 
+ on 
+    f.race_id = r.id
+ where 
+    order_of_finish is not null
+ and 
+    preSRa is not null
+ limit 250000')
>  
> allData <- fetch(rs, n = -1)
> 
> dbClearResult(rs)
[1] TRUE
> dbDisconnect(conn)
[1] TRUE
> 
> #カテゴリ変数をファクターに変換しておく
> allData$placeCode <- factor(allData$placeCode)
> allData$month     <- factor(allData$month)
> allData$grade     <- factor(allData$grade)
> allData$sex       <- factor(allData$sex)
> allData$weather   <- factor(allData$weather)
> allData$surface   <- factor(allData$surface)
> allData$course    <- factor(allData$course)
> 
> #負担重量/馬体重を素性に追加
> allData$weightper <- allData$weight / allData$hweight
> 
> #オッズを支持率に変換
> allData$support <- 0.788 / (allData$odds - 0.1)
> allData$odds <- NULL
> 
> #着順をカテゴリ変数に変換
> allData$order_of_finish <- factor(allData$order_of_finish == 1)
> 
> #クラスバランスを50/50にする
> allData.s <- downSample(na.omit(allData))
> allData.s <- allData.s[order(allData.s$race_id),]
> 
> #今回の実験で使用するデータのサンプル数
> nrow(allData.s)
[1] 30428
> 
> #データを学習用25428サンプルとテスト用5000サンプルに分割する
> train <- allData.s[1:(nrow(allData.s)-5000),]
> test  <- allData.s[(nrow(allData.s)-4999):nrow(allData.s),]
> 
> #予測モデルを作成
> (rf.model1 <- randomForest(
+   order_of_finish ~ . - support - race_id, train))

Call:
 randomForest(formula = order_of_finish ~ . - support - race_id,      data = train) 
               Type of random forest: classification
                     Number of trees: 500
No. of variables tried at each split: 5

        OOB estimate of  error rate: 29.72%
Confusion matrix:
      FALSE TRUE class.error
FALSE  8420 4362   0.3412611
TRUE   3196 9450   0.2527281
>
> #素性の重要度を見てみる
> importance(rf.model1)
             MeanDecreaseGini
horse_number        276.57124
grade               191.53030
age                 210.04150
avgsr4              545.24005
avgWin4             526.77427
dhweight            296.32679
disRoc              443.31973
distance            232.20557
dsl                 371.28809
enterTimes          332.80342
eps                 682.54396
hweight             393.27570
jwinper             417.62300
owinper             366.49348
preSRa              536.27096
sex                  62.81792
surface              45.83353
surfaceScore        361.17891
twinper             348.52685
weather             123.82181
weight              165.54897
winRun              246.36929
jEps                603.00998
jAvgWin4            140.99460
preOOF              870.35176
pre2OOF             475.39642
month               737.97377
runningStyle        456.73422
preLastPhase        408.51575
lateStartPer        250.49252
course               42.43917
placeCode           564.23156
race_number         278.57604
weightper           430.13985
>
> #テストデータで予測力を見てみる
> pred <- predict(rf.model1, test)
> tbl <- table(pred, test$order_of_finish)
> sum(diag(tbl)) / sum(tbl)
[1] 0.7067173

OOBエラーとテストデータでの正解率が共に約70%になっている。50%を超えているので、このモデルに予測力があることは確かなようだ。
しかし本番はここからである。問題は、このモデルの予測力が他の馬券購入者達の予測力に勝てるかどうかだ。
「他の馬券購入者達の予測」を表すモデルとして、以下の素性だけを用いて学習したモデルを使用する。

変数名 説明
support 単勝オッズから逆算*9した支持率

単勝オッズから逆算された支持率は「他の馬券購入者達の予測」そのものである。だから、もし競馬市場が効率的であるならば、この支持率を使ったモデルを超える予測精度は生み出せないはずである。なので、このモデルの予測精度を超えられるかどうかが競馬市場の効率性を測る一つの目安となる。

> #支持率だけを用いて予測モデルを作成する
> (rf.model2 <- randomForest(
+   order_of_finish ~ support, train))

Call:
 randomForest(formula = order_of_finish ~ support, data = train) 
               Type of random forest: classification
                     Number of trees: 500
No. of variables tried at each split: 1

        OOB estimate of  error rate: 25.7%
Confusion matrix:
      FALSE  TRUE class.error
FALSE  8734  4048   0.3166954
TRUE   2486 10160   0.1965839
> 
> pred <- predict(rf.model2, test)
> tbl <- table(pred, test$order_of_finish)
> sum(diag(tbl)) / sum(tbl)
[1] 0.7379048

このモデルの予測精度は約74%である。
残念ながら私のモデルは70%なので予測力で負けている…。

レース毎の相対的な能力差を素性にしてみる

ある馬がレースで勝てるかどうかは、その馬の絶対的な能力ではなく、他の馬との相対的な能力差で決定される。ということは、絶対的な能力値ではなく、同じレースに出る他の馬との相対的な能力差の情報を使うことで予測精度を向上できるのではないか?
具体的にどうするのかというと、同じレースにでる馬のデータだけを集めて正規化(平均0分散1にする操作)すればいい。そうすれば、同じレースに出る他の馬との能力差だけを考慮することができる。(参考:Identifying winners of competitive events: A SVM-based classification model for horserace prediction*10
このアイデアをRのコードに落とし込んでみよう。

> racewiseFeature <- 
+ c("avgsr4",
+ "avgWin4",
+ "dhweight",
+ "disRoc",
+ "dsl",
+ "enterTimes",
+ "eps",
+ "hweight",
+ "jwinper",
+ "owinper",
+ "preSRa",
+ "twinper",
+ "weight",
+ "jEps",
+ "jAvgWin4",
+ "preOOF",
+ "pre2OOF",
+ "runningStyle",
+ "preLastPhase",
+ "lateStartPer",
+ "weightper",
+ "winRun")
> 
> splited.allData <- split(allData, allData$race_id)
> 
> scaled.allData <- unsplit(
+   lapply(splited.allData,
+     function(rw) {
+       data.frame(
+         order_of_finish = rw$order_of_finish,
+         race_id = rw$race_id,
+         age = rw$age,
+         grade = rw$grade,
+         distance = rw$distance,
+         sex = rw$sex,
+         weather = rw$weather,
+         course = rw$course,
+         month = rw$month,
+         surface = rw$surface,
+         surfaceScore = rw$surfaceScore,
+         horse_number = rw$horse_number,
+         placeCode = rw$placeCode,
+         race_number = rw$race_number,
+         support = rw$support,
+         scale(rw[,racewiseFeature])) #ここで正規化している
+     }),
+   allData$race_id)
> 
> scaled.allData$order_of_finish = factor(scaled.allData$order_of_finish)
> 
> is.nan.df <- function(x) do.call(cbind, lapply(x, is.nan))
> scaled.allData[is.nan.df(scaled.allData)] <- 0
> 
> scaled.allData <- downSample(na.omit(scaled.allData))
> scaled.allData <- scaled.allData[order(scaled.allData$race_id),]
>
> #データを学習用とテスト用に分割する
> scaled.train <- scaled.allData[1:(nrow(scaled.allData)-5000),]
> scaled.test  <- scaled.allData[(nrow(scaled.allData)-4999):nrow(scaled.allData),]
> 
> #レース毎に正規化されたデータで予測モデルを作成
> (rf.model3 <- randomForest(
+   order_of_finish ~ . - support - race_id, scaled.train))

Call:
 randomForest(formula = order_of_finish ~ . - support - race_id,      data = scaled.train) 
               Type of random forest: classification
                     Number of trees: 500
No. of variables tried at each split: 5

        OOB estimate of  error rate: 28.63%
Confusion matrix:
      FALSE TRUE class.error
FALSE  8739 4047   0.3165181
TRUE   3234 9408   0.2558140
> 
> #素性の重要度を見てみる
> importance(rf.model3)
             MeanDecreaseGini
age                 138.15954
grade               157.86619
distance            192.87544
sex                  55.18635
weather              92.09389
course               33.38138
month               529.20000
surface              33.58647
surfaceScore        287.95836
horse_number        222.12282
placeCode           537.07988
race_number         193.98961
avgsr4              858.85621
avgWin4             726.16178
dhweight            345.24014
disRoc              371.22814
dsl                 363.05980
enterTimes          357.92536
eps                1005.00112
hweight             366.85535
jwinper             471.85535
owinper             367.94282
preSRa              890.83216
twinper             381.33466
weight              336.16596
jEps                530.81950
jAvgWin4            352.48784
preOOF              794.77337
pre2OOF             500.63913
runningStyle        358.16418
preLastPhase        383.60317
lateStartPer        338.66961
weightper           359.51054
winRun              264.60148
> 
> #テストデータで予測力を見てみる
> pred <- predict(rf.model3, scaled.test)
> tbl <- table(pred, scaled.test$order_of_finish)
> sum(diag(tbl)) / sum(tbl)
[1] 0.7221112

OOBエラーおよびテストデータでの予測精度が約72%になっている。先ほどより2%精度が向上している。やはり相対的な能力差の情報を使うことで精度が向上するようだ。
しかし、これでもまだ支持率を使ったモデルの予測精度74%には届かない。

支持率を素性に加えてみる

最後のひと押しに、支持率を私のモデルの素性に加えてしまうことにしよう。
というのも、人間の予測力はかなりのものだが、同時に人間には心理学的なバイアス(アンカリングとか)があることもわかっている。一方で、機械ははっきりと数値化できる素性しか考慮できないが、その代わりに機械には心理学的なバイアスは存在しない。つまり、人間が得意な領域と機械が得意な領域は異なっているわけである。ということは、それぞれが弱点を補い合えばより良い予測ができるのではないか? 支持率は人間の予測の結果なので、私のモデルと支持率を組み合わせれば予測精度を向上できるかもしれない。
というわけで、絶対的能力値モデルと相対的能力差モデルの両方の素性に支持率を加えてみた。その結果が以下である。

> #絶対的能力値モデルの素性に支持率を追加して予測モデルを作成
> (rf.model4 <- randomForest(
+   order_of_finish ~ . - race_id, train))

Call:
 randomForest(formula = order_of_finish ~ . - race_id, data = train) 
               Type of random forest: classification
                     Number of trees: 500
No. of variables tried at each split: 6

        OOB estimate of  error rate: 24.88%
Confusion matrix:
      FALSE  TRUE class.error
FALSE  8967  3793   0.2972571
TRUE   2534 10134   0.2000316
> 
> #テストデータで予測力を見てみる
> pred <- predict(rf.model4, test)
> tbl <- table(pred, test$order_of_finish)
> sum(diag(tbl)) / sum(tbl)
[1] 0.7491004
> 
> #相対的能力差モデルの素性に支持率を追加して予測モデルを作成
> (rf.model5 <- randomForest(
+   order_of_finish ~ . - race_id, scaled.train))

Call:
 randomForest(formula = order_of_finish ~ . - race_id, data = scaled.train) 
               Type of random forest: classification
                     Number of trees: 500
No. of variables tried at each split: 5

        OOB estimate of  error rate: 25.26%
Confusion matrix:
      FALSE  TRUE class.error
FALSE  8936  3850   0.3011106
TRUE   2572 10070   0.2034488
> 
> #テストデータで予測力を見てみる
> pred <- predict(rf.model5, scaled.test)
> tbl <- table(pred, scaled.test$order_of_finish)
> sum(diag(tbl)) / sum(tbl)
[1] 0.7457017

両モデルとも0.5~1%程度だが、支持率だけを使ったモデルの予測力を上回っている。
これでようやく予測精度が74%を超えることができた。ヤッター!(*´ω`*)
ちなみにここまでのRコードはここにまとめてあるのでよかったらどうぞ。


まぁこの程度の予測力向上では、控除率が高い競馬では儲けることができないだろうけれど、今回は競馬市場の効率性が完全ではないとわかっただけでも良しとしよう。
予測精度が74%を超えた時点でなんだかやる気が尽きてしまったので、今回はここまで。次回に続きます。

今後の予定

  • JRDBのデータを使う。JRDBには、IDMや馬の体格、蹄など、他にはないデータが豊富に含まれているので、予測精度向上の余地がありそう。ちなみにJRDBのデータは外れ馬券裁判で話題になった卍氏も使用している。
  • 今回は最終支持率をそのまま素性に追加したが、実際に利用可能なデータはレース開始直前の支持率であり、最終支持率とはズレがあるかもしれない。レース開始直前の単勝オッズがJRDBの直前情報データに含まれているのでこれを使うようにする。
  • 今回は中央競馬のデータを使っているが、地方競馬の方が儲けられる可能性が高そう。なぜなら地方競馬のほうが注目度が低く、市場の効率性が低そうなので。(その代わりにレース開始直前のオッズの変動が激しいみたいだけど…)
  • 血統のデータを活用する。血統をどう数値化するかがちょっと悩ましいが、多分JRA-VANがやっている親馬をカテゴリ変数にしてしまう方法が一番簡単。
  • レースが荒れるかどうかを予測するほうが簡単らしいので、後でそっちの方法も試そう…と思って今ちょっとだけやってみたけどあんまりうまくいかないぞこれ…
  • そもそもの目的は競馬で儲けることであり、そのためには予測力ではなく回収率を高めなければならない。なので強化学習や遺伝的アルゴリズムを使って回収率が高くなるように学習させる方が望みがありそう。(教師あり学習とは違い強化学習や遺伝的アルゴリズムであれば回収率の高さそのものを最大化するように学習させることができる)

参考ページ

今回の記事を書くにあたって、私が最も参考にしたのはJRA-VANの予測モデル解説と卍氏の書籍(これこれ)、そしてStefan Lessmannの競馬論文の三つである。「お前の解説は下手すぎて意味わからん」という方はこれらのページも参考にされたし。

続きを書きました

stockedge.hatenablog.com

*1:ランダムに馬券を買った場合の話

*2:もっと安く買えるミニ株などもあるが、こちらは手数料が高め

*3:もちろん彼らの運が良かっただけの可能性もあるけど

*4:これだけをやっていたわけではないけれど

*5:他にも走破タイムを予測する方法もあるようだが、結局は予測されたタイムを元にして何着かを予測するのだから、後者の方法に含まれる扱いにした

*6:私は実際に実験したわけではないので「厳密な着順の数値」を予測することによりどれだけのバイアスが入るのかは知らない。ひょっとしたら無視できるほどに小さい量かもしれない。しかし仮にそうだったとしても、まず最初はシンプルな方法を試すべきだと思うので、ここでは「一着になるかどうかの二値」を予測する方法を採用する。

*7:正例と負例の比率が偏っているデータ、例えば正と負の比率が1対99となっているようなデータのこと

*8:ちなみに私は分類問題にはランダムフォレストばかり使っているランダムフォレスト信者だ。だってOOBエラーや素性の重要度が簡単に見れるし、ハイパーパラメータのチューニングが楽だし、そもそもチューニング自体をしなくてもデフォルトのパラメータで良い性能が出ることが多いし…

*9:支持率 = 0.788 / (オッズ - 0.1) という式で計算できる

*10:ちなみに、馬の相対的な能力差を使う方法にはJRA-VANの対決型モデルのような方法もある