こんにちは、サーバーサイドエンジニアの竹本です。
この記事は Enigmo Advent Calendar 2020 の3日目の記事です。
みなさまは2020年に買った中でよかったものはなんでしょう?
私はiPad です。
主にkindle を見開きで読むことに活用しています。
エニグモ の福利厚生の一つ「エンジニアサポート」で5万円の補助を受けました。わーい。
https://enigmo.co.jp/recruit/culture/
そしてみなさまは馬券、買っていますか?
馬券は競馬に賭ける際に購入する投票券です。
1口100円から、ネットでも気軽に購入することができます。(競馬は20歳から)
弊社にも数名競馬好きが在籍しており、時折競馬トーク で盛り上がることもあります。
「競馬必勝本」は本当に当たるのか?
「競馬必勝本」というものが巷にはあります。
こういった本では勝った時の馬券と払戻金だけセンセーショナルに紹介されていることが多く、
実際どのくらい賭けてどのくらい当たっているのかわからないことが多いです。
なので実際に本の通りに馬券を購入していたらどのくらい勝つのかを検証してみます。
今回参考にするのは「競馬力を上げる馬券統計学 の教科書」です。
世にある競馬必勝本の中でも、オッズのデータから勝ち馬を選ぶという、タイトルの通り統計的な要素の多い本になっています。
また馬やジョッキーに関係なくオッズのみを参考にしているので、さまざまなレースがあるなかで汎用的に活用できるという利点があります。
この本に書いてあることをざっくりまとめると以下のようになります。
3000円分の馬券を書い続けて3回に1回10000円が当たれば1000円プラスということですね。つまり当たれば万馬券 になるようなレース、「穴馬」が勝ち馬にいる馬券を買う必要があります。
「馬連 オッズの壁」の法則で穴馬を選ぼう
本書の中で最もシンプルな穴馬選択方法として紹介されているのが「馬連 オッズの壁」の法則です。
(馬連 とは上位2頭を当てる馬券のこと)
この法則を簡単に紹介すると
レース
14頭以上出走する
馬連 1位人気オッズが9倍以上
単勝 オッズ30倍以内の馬が10頭以上
馬連 オッズ1位人気馬に単勝 1位人気馬が含まれる
穴馬
単勝 1位人気馬の馬連 オッズを人気順に並べた時1.8倍以上の壁がある時、その壁の前2頭を選ぶ
馬券の組み立て方
本書ではさまざまな条件を組み合わせて馬券を選択していますが、
今回は実装の簡便さのため条件も簡略化していることをご了承ください。
実際のコード
今回はGoogle Colabratoryを利用しました。
Google アカウントがあれば環境構築もなしにpython が使えて便利ですね。
https://colab.research.google.com/
2020/11/27現在動くことを確認しているのでみなさまもぜひ使ってみてください。
まず必要なライブラリのインストールします
!apt-get update
!apt install chromium-chromedriver
!cp /usr/lib/chromium-browser/chromedriver /usr/bin
!pip install selenium
!pip install lxml
importします
import pandas as pd
from bs4 import BeautifulSoup
import urllib.request as req
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import numpy as np
import urllib.parse
今回用意した関数
def set_selenium ():
options = webdriver.ChromeOptions()
options.add_argument('--headless' )
options.add_argument('--no-sandbox' )
options.add_argument('--disable-dev-shm-usage' )
driver = webdriver.Chrome('chromedriver' ,options=options)
driver.implicitly_wait(15 )
return driver
def get_raceids (date):
url = "https://race.netkeiba.com/top/race_list_sub.html?kaisai_date=" + date
res = req.urlopen(url)
racesoup = BeautifulSoup(res, "html.parser" )
racelist = racesoup.select("#RaceTopRace > div > dl > dd > ul > li > a:nth-of-type(1)" )
raceids = [ urllib.parse.parse_qs(urllib.parse.urlparse(race.get('href' )).query)['race_id' ][0 ] for race in racelist ]
return raceids
def get_tansho_ichiban (raceid):
driver = set_selenium()
driver.get("https://race.netkeiba.com/odds/index.html?type=b1&race_id=" +raceid+"&rf=shutuba_submenu" )
html = driver.page_source.encode('utf-8' )
tanhukusoup = BeautifulSoup(html, "html.parser" )
tanhukudfs = pd.read_html(str (tanhukusoup.html))
tansho_ichiban = tanhukudfs[0 ][tanhukudfs[0 ]['オッズ' ] == tanhukudfs[0 ]['オッズ' ].min()]['馬番' ].values[0 ]
return tansho_ichiban, tanhukudfs[0 ]
def get_umarenodds (raceid):
driver = set_selenium()
driver.get("https://race.netkeiba.com/odds/index.html?type=b4&race_id=" +raceid+"&housiki=c0&rf=shutuba_submenu" )
html = driver.page_source.encode('utf-8' )
soup = BeautifulSoup(html, "html.parser" )
dfs = pd.read_html(str (soup.html))
umarendf = pd.DataFrame(index=[1 ])
for i, df in enumerate (dfs):
umarendf = pd.concat([umarendf, df.set_index(str (i+1 )).dropna(how='all' , axis=1 )], axis=1 )
if umarendf.isin(['取消' ]).values.any() | umarendf.isin(['除外' ]).values.any():
return False
umarendf[umarendf.index.max()]=0
umarenodds = pd.DataFrame(umarendf.fillna(0 ).astype('float64' ).values + umarendf.astype('float64' ).fillna(0 ).values.T, columns=list (map (int ,map (float ,umarendf.columns))), index=umarendf.index).replace(0 ,np.nan)
return umarenodds
def get_baken (raceid):
tansho_ichiban, tanhukudf = get_tansho_ichiban(raceid)
umarenodds = get_umarenodds(raceid)
if umarenodds is False :
return False
umarenninki = umarenodds.min()
umaren_ichiban = umarenninki[umarenninki == umarenninki.min()].index
if umarenninki.min() >= 9 and any (umaren_ichiban == tansho_ichiban) and umarenninki.index.max() >= 14 and sum (tanhukudf["オッズ" ]<=30 )>=10 :
ninkiuma = umarenodds[tansho_ichiban].sort_values()
anaumalist = []
for idx in np.where((ninkiuma/ninkiuma.shift(1 ) > 1.8 ).values == True )[0 ]:
two = idx -1
anaumalist = anaumalist + (ninkiuma/ninkiuma.shift(1 ) > 1.8 ).index.values[two:two+2 ].tolist()
if not anaumalist:
return False
formation1 = ninkiuma.fillna(0 ).sort_values()[0 :4 ].index.values
formation2 = ninkiuma.fillna(0 ).sort_values()[4 :8 ].index.values
return {'anauma' :anaumalist, 'formation1' :formation1, 'formation2' :formation2}
return False
def get_dayresult (date):
kakekin = 0
modorikin = 0
raceids = get_raceids(date)
for raceid in raceids:
baken = get_baken(raceid)
if not baken:
continue
result = pd.read_html("https://race.netkeiba.com/race/result.html?race_id=" +raceid)
sanrenpuku = list (map (int ,result[2 ].set_index(0 )[1 ]['3連複' ].split()))
money = int (result[2 ].set_index(0 )[2 ]['3連複' ].replace(',' ,'' ).replace('円' ,'' ))
if bool (set (sanrenpuku) & set (baken['formation1' ])) & bool (set (sanrenpuku) & set (baken['formation2' ])) & bool (set (sanrenpuku) & set (baken['anauma' ])):
kakekin += 100 *len (baken['formation1' ])*len (baken['formation2' ])*len (baken['anauma' ])
modorikin += money
else :
kakekin += 100 *len (baken['formation1' ])*len (baken['formation2' ])*len (baken['anauma' ])
cols = ["賭け金" ,"払戻金" ]
return pd.Series([kakekin,modorikin],index=cols,name=date)
netkeibaからSelenium + BeautifulSoupでオッズのデータをスクレイピング します。
その結果をpandasで前処理し、上記の「馬連 オッズの壁」の法則 から馬券を選択
選択した馬券が当たっているのかを検証します。
回収率にこだわるのが本書の方針なので、検証項目として
条件に合致した全ての三連複馬券を100円で購入 = 賭け金
当たったレースの実際の払戻金
以上の差額を見ることにします。
今回は11月の1~23日までのレースを検証します。
datelist = [
'20201101' ,
'20201107' ,
'20201108' ,
'20201114' ,
'20201115' ,
'20201121' ,
'20201122' ,
'20201123'
]
moukaridf = pd.DataFrame()
for date in datelist:
onedaydf = get_dayresult(date)
moukaridf = moukaridf.append(onedaydf)
時間かかりますが待ちましょう
結果
moukaridf.sum()['払戻金' ] - moukaridf.sum()['賭け金' ]
-14630.0
金額としては14630円負けてしまいました。
moukaridf
払戻金
賭け金
20201101
33500.0
12800.0
20201107
0.0
9600.0
20201108
5560.0
16000.0
20201114
0.0
25600.0
20201115
45510.0
25600.0
20201121
0.0
0.0
20201122
0.0
9600.0
20201123
0.0
0.0
日別に見ると勝っている日もありますね。
本書では回収率を上げるための馬券選択方法がさらに細かく紹介されていたので、その通りに実装すればもっと良い結果となるかもしれません。
t検定をすると、「賭け金、払戻金の差額の平均が0ではない(正負どちらかに傾く)」という帰無仮説 が棄却されます。(p > 0.05 平均 -1828.8 ± 14776 円)
sagaku = moukaridf[['払戻金' ]].values - moukaridf[['賭け金' ]].values
print (sagaku.mean())
print (sagaku.std())
-1828.75
14776.743076114573
よって結論は「勝つこともあれば負けることもある!」
馬券を買おう
ではせっかく作ったので実際に賭けてみようと思います!
検証では最終オッズから馬券を選択していましたが、当日は10:30のオッズを元に馬券を選択します。
予算の関係で条件に合致した全ての馬券ではなく、1レース選択してフォーメーション3連複馬券を購入します。
11/29の10:30時点で候補が4レースありました。
date = "20201129"
raceids = get_raceids(date)
for raceid in raceids:
baken = get_baken(raceid)
if baken:
print ('https://race.netkeiba.com/race/shutuba.html?race_id=' +raceid)
print (baken)
print ("=======================" )
https://race.netkeiba.com/race/shutuba.html?race_id=202005050907
{'anauma' : [4 , 16 ], 'formation1' : array([7 , 6 , 3 , 2 ]), 'formation2' : array([10 , 14 , 15 , 8 ])}
============
https://race.netkeiba.com/race/shutuba.html?race_id=202005050911
{'anauma' : [11 , 6 , 1 , 16 ], 'formation1' : array([ 4 , 14 , 9 , 13 ]), 'formation2' : array([12 , 5 , 2 , 3 ])}
============
https://race.netkeiba.com/race/shutuba.html?race_id=202009050904
{'anauma' : [3 , 7 ], 'formation1' : array([ 8 , 15 , 5 , 1 ]), 'formation2' : array([16 , 4 , 13 , 12 ])}
============
https://race.netkeiba.com/race/shutuba.html?race_id=202009050912
{'anauma' : [7 , 6 ], 'formation1' : array([13 , 3 , 10 , 11 ]), 'formation2' : array([ 2 , 9 , 15 , 5 ])}
============
今回は東京7Rに賭けます! (出力で一番上のレース)
穴馬が「4,16」1~4位が「7, 6, 3, 2」,5~8位が「10, 14, 15, 8」です。
2020年11月29日アクセス
https://www.ipat.jra.go.jp/
頼むぞ〜!
結果は…
1位 2番
2位 10番
3位 8番
3歳以上2勝クラス 結果・払戻 | 2020年11月29日 東京7R レース情報(JRA) - netkeiba.com
残念!穴馬候補だった4番と16番が3位以内に入りませんでした。惜しかったですね。
それではみなさまも良い競馬ライフをお送りください。
スクレイピング 、クローリングはアクセス先に配慮してやりましょう。
参考資料
競馬力を上げる馬券統計学の教科書
増補改訂Pythonによるスクレイピング&機械学習 開発テクニック
ColaboratoryでSeleniumが使えた:JavaScriptで生成されるページも簡単スクレイピング - Qiita
明日の記事の担当はサーバーサイドエンジニアの寺田さんです。お楽しみに。
株式会社エニグモ 正社員の求人一覧
hrmos.co