競馬必勝本は本当に当たるのかを検証!〜Pythonで実装する馬券自動選択ツール〜

こんにちは、サーバーサイドエンジニアの竹本です。 この記事は Enigmo Advent Calendar 2020 の3日目の記事です。

みなさまは2020年に買った中でよかったものはなんでしょう?

私はiPadです。

最新 Apple iPad Pro (12.9インチ, Wi-Fi, 128GB) - シルバー (第4世代)

最新 Apple iPad Pro (12.9インチ, Wi-Fi, 128GB) - シルバー (第4世代)

  • 発売日: 2020/03/25
  • メディア: Personal Computers

主に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("=======================")

 

# 出力
# 条件に合致したレースのURLと購入すべき馬券がプリントされる
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」です。

f:id:enigmo7:20201130123855p:plain 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