この広告は、90日以上更新していないブログに表示しています。

基本的に競馬なんてやるべきではないと私は思っている。胴元の取り分が多いからだ。宝くじに比べればまだましだが、それでも賭け金の20~30%は胴元に取られることになる。*1
しかし今回は、ちょっと思い立って競馬の予測をやってみることにした。
理由は馬券の安さだ。私は現在、資金量が少ない人間でも不利にならない投資先を探しているのだが、馬券の一枚100円という安さは魅力的に映る。株の場合にはどんな安い株であれ最低購入額は数万円以上*2なので、ある程度まとまった資金が必要になる。
また、競馬には技術介入の余地(努力次第で勝利できる可能性)がある。
例えばこんな例がある。
160億円ボロ儲け!英投資会社が日本の競馬で荒稼ぎした驚きの手法 - NAVER まとめ
彼らは統計解析によって競馬で勝っており、その所得を隠していたらしい。こういうニュースが出るということは、解析者の腕次第では競馬で勝てる可能性があるということだ。*3
ということで、競馬の統計解析をしたいわけなのだが、解析するためのデータがなければ何も始まらない。
まずは、競馬のデータを以下のサイトからスクレイピングして取ってくることにする。
netkeiba.com - 競馬データベース
netkeiba.comでスピード指数(ある基準を元に走破タイムを数値化したもの)や馬場指数(馬場コンディションを数値化したもの)を閲覧するには有料会員に登録する必要がある。私は有料会員に登録した上でスピード指数や馬場指数まで含めてスクレイピングを行った。
以下にスクレイピング&素性作成用のScalaコードを公開する。
github.com
ちなみにデータ解析はデータを解析できる形に持っていくまでが全工程の九割を占めると言われている。実際私もこのスクレイピング&素性作成用スクリプトを作成するのに数週間はかけている*4。このスクリプトを無料で使える皆さんは幸運である。
作成された素性は最終的にSQLiteに格納されるようになっている。このコードを使うのにnetkeiba.comの有料会員に登録する必要はないが、その場合はスピード指数や馬場指数のカラムにはNULL値が入ることになるので気をつけて欲しい。
データが集まった所で、次に「何を」予測するのか決めよう。
私が調べた限りでは、競馬の予測には2つの方法がある。*5
例えば以下の本の著者は両方の方法を試した上で後者の方法は難しいので前者の方法で予測したほうがうまくいくと結論づけている。

つまり、個別の馬に関するデータを入力とし、その馬がレースで一着になるかどうかの二値を出力とする統計モデルを作成するわけである。
なお今回は、予測するのはレースの着順ではなくあくまでも「一着になるかどうか」の二値だけにする。
| 変数名 | 説明 |
|---|---|
| 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 | 馬の勝ち回数 |
このリストは、私が競馬関連の本とか論文とかを読んで「なんとなく良さそう」と思った変数をかき集めただけなので、これらの変数を使うことに必然性があるわけではない。他の変数を使った場合にどうなるか気になるという方は自分でコードを弄って試すべし。
予測モデルの作成には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:500No. of variables tried at each split:5 OOB estimate of error rate:29.72%Confusionmatrix:FALSETRUE class.errorFALSE842043620.3412611TRUE319694500.2527281>>#素性の重要度を見てみる> importance(rf.model1) MeanDecreaseGinihorse_number276.57124grade191.53030age210.04150avgsr4545.24005avgWin4526.77427dhweight296.32679disRoc443.31973distance232.20557dsl371.28809enterTimes332.80342eps682.54396hweight393.27570jwinper417.62300owinper366.49348preSRa536.27096sex62.81792surface45.83353surfaceScore361.17891twinper348.52685weather123.82181weight165.54897winRun246.36929jEps603.00998jAvgWin4140.99460preOOF870.35176pre2OOF475.39642month737.97377runningStyle456.73422preLastPhase408.51575lateStartPer250.49252course42.43917placeCode564.23156race_number278.57604weightper430.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: 500No. of variables tried at each split: 1 OOB estimate of error rate: 25.7%Confusion matrix: FALSE TRUE class.errorFALSE 8734 4048 0.3166954TRUE 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:500No. of variables tried at each split:5 OOB estimate of error rate:28.63%Confusionmatrix:FALSETRUE class.errorFALSE873940470.3165181TRUE323494080.2558140>>#素性の重要度を見てみる> importance(rf.model3) MeanDecreaseGiniage138.15954grade157.86619distance192.87544sex55.18635weather92.09389course33.38138month529.20000surface33.58647surfaceScore287.95836horse_number222.12282placeCode537.07988race_number193.98961avgsr4858.85621avgWin4726.16178dhweight345.24014disRoc371.22814dsl363.05980enterTimes357.92536eps1005.00112hweight366.85535jwinper471.85535owinper367.94282preSRa890.83216twinper381.33466weight336.16596jEps530.81950jAvgWin4352.48784preOOF794.77337pre2OOF500.63913runningStyle358.16418preLastPhase383.60317lateStartPer338.66961weightper359.51054winRun264.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:500No. of variables tried at each split:6 OOB estimate of error rate:24.88%Confusionmatrix:FALSETRUE class.errorFALSE896737930.2972571TRUE2534101340.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:500No. of variables tried at each split:5 OOB estimate of error rate:25.26%Confusionmatrix:FALSETRUE class.errorFALSE893638500.3011106TRUE2572100700.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%を超えた時点でなんだかやる気が尽きてしまったので、今回はここまで。次回に続きます。
今回の記事を書くにあたって、私が最も参考にしたのはJRA-VANの予測モデル解説と卍氏の書籍(これとこれ)、そしてStefan Lessmannの競馬論文の三つである。「お前の解説は下手すぎて意味わからん」という方はこれらのページも参考にされたし。
*1:ランダムに馬券を買った場合の話
*2:もっと安く買えるミニ株などもあるが、こちらは手数料が高め
*3:もちろん彼らの運が良かっただけの可能性もあるけど
*4:これだけをやっていたわけではないけれど
*5:他にも走破タイムを予測する方法もあるようだが、結局は予測されたタイムを元にして何着かを予測するのだから、後者の方法に含まれる扱いにした
*6:私は実際に実験したわけではないので「厳密な着順の数値」を予測することによりどれだけのバイアスが入るのかは知らない。ひょっとしたら無視できるほどに小さい量かもしれない。しかし仮にそうだったとしても、まず最初はシンプルな方法を試すべきだと思うので、ここでは「一着になるかどうかの二値」を予測する方法を採用する。
*7:正例と負例の比率が偏っているデータ、例えば正と負の比率が1対99となっているようなデータのこと
*8:ちなみに私は分類問題にはランダムフォレストばかり使っているランダムフォレスト信者だ。だってOOBエラーや素性の重要度が簡単に見れるし、ハイパーパラメータのチューニングが楽だし、そもそもチューニング自体をしなくてもデフォルトのパラメータで良い性能が出ることが多いし…
*9:支持率 = 0.788 / (オッズ - 0.1) という式で計算できる
*10:ちなみに、馬の相対的な能力差を使う方法にはJRA-VANの対決型モデルのような方法もある
id:stockedgehttp://stockedge.jp/の中の人による技術メモ
株予測の勝率63.63%を達成
twitter:https://twitter.com/stockedge_tech
mail:stockedge[at]sk2.so-net.ne.jp
github:https://github.com/stockedge
bitbucket:https://bitbucket.org/stockedge
Qiita:http://qiita.com/stockedge
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。