Movatterモバイル変換


[0]ホーム

URL:


stockedge.jpの技術メモ

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

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

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

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前走の順位
pre2OOF2走前の順位
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: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%を超えた時点でなんだかやる気が尽きてしまったので、今回はここまで。次回に続きます。

今後の予定

  • 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の対決型モデルのような方法もある

検索
参加グループ

引用をストックしました

引用するにはまずログインしてください

引用をストックできませんでした。再度お試しください

限定公開記事のため引用できません。

読者です読者をやめる読者になる読者になる

[8]ページ先頭

©2009-2025 Movatter.jp