前回は、今プロジェクトの背景や現在のスキルセットなどについて説明しました。
今回は早速、競艇AIの実現に向けて実装していこうと思います。
概要
今回は以下のことをやっていきます。
- 競艇AIの実装に必要なデータの内容を把握する
- 過去のデータをどこで・どうやって取得できるのかを見定める
- データを取得し、扱いやすい形に加工する
競艇のこと知らない方もいらっしゃるかと思うので、前半は競艇に関する知識もドシドシ書こうと思います。
使えそうなデータ
まずは予測に使えそうなデータを探します。
出走表
競艇でも競馬でも、各レースにつきそれぞれの選手の情報を1つにまとめてくれた**「出走表」**というものがあります。みんなが実際に買い目を決めるときに見るヤツですね。番組表とも言います。
競艇では、公式サイトから見ることができ、各レースの出走表はレース前日には閲覧できるようになっております。
実際の出走表はこんな感じです。
左半分には、1号艇から6号艇の各選手のこれまでの成績や、使っているモーター・ボートの性能を示す数値が記載されてます。
右半分の「今節成績」には、その節間での成績が記載されており、選手の調子を把握できたりします。(レースは3~7日間で構成されているシリーズの中で行われ、各シリーズのことを「節」といいます)
各数値の意味は、公式サイトのこちらで丁寧に説明してくれてます。
この辺りの数値は、そのまま特徴量として役立ちそうですね。まるまる頂いちゃいましょう。
直前情報
出走表とは別に、「直前情報」というものがあり、レース当日の天気や風・水面の状態などの情報や、各選手の展示航走の結果・部品交換の実績などを見ることができます。
**「展示航走」**とはレース直前に行われるリハーサルのようなもので、よく略して「展示」と言われます。
各選手は展示で最終調整を行うので、調子やモーターとの相性・仕上がり具合を把握するのに非常に大切だと言われてます。
展示航走には**「スタート展示」と「周回展示」**の2種類があり、それぞれから得られる情報があります。
スタート展示
スタート展示では、各選手が一斉にスタートの練習をします。
ここでは、各選手のモーターの加速具合 (出足) を確認することができます。
競艇では「フライングスタート」と呼ばれる独特のスタート方式を採用しており、定められたレース開始時刻に間に合うように、助走をつけてスタートラインを通過しなければいけないルールになっています。
そのため「早すぎてもダメ、遅れすぎてもダメ」となっており、スタートタイミング (ST)が早すぎた場合はフライング (F)、遅すぎた場合は出遅れ (L)となって失格となってしまいます。
下の画像の例では、2号艇がスタート展示でSTが「F.04」となっているのでレース本番でも同じことをするとフライングとなるわけですが、実際には少しの調整をすれば適正になるので「2号艇はスタートの調子良さそう」という見方ができます。
周回展示
周回展示では、各選手が順番にターンの練習をします。
ここでは、スタートを切ってからの直線距離におけるスピード (伸び足) や、ターン時の安定度 (回り足) を確認できます。
実際の直前情報はこんな感じです。
定量値として分かるのは、以下の内容あたりでしょうか。
① 各選手の体重, スタートタイミング (ST), 展示タイム (一定の直線距離で計測した時間), チルト角度
② 天気, 気温, 風速・風向, 水温, 波高
ターンにかかった時間も本当は欲しいのですが、ここでは見れなさそうなので諦めます。(会場によっては公開しているところもあります)
その他のデータ
上記の出走表と直前情報の他にも使えそうなデータはあります。すぐ使えそうなものを2つ紹介します。
オッズ
「入れたお金が何倍になって返ってくるのか」を表す数値です。
オッズの厳密な計算方法については触れませんが、**「人気な出目ほどオッズが低くなり、不人気な出目ほどオッズが高くなる」**とだけざっくり認識していただければ大丈夫です。数学的に言えば、投票数の分布がオッズから分かる訳ですね。
オッズは予想をする上で、とても重要です。
下記のグラフは、2020年の全レースにおける3連単の人気番 (全120通り) のそれぞれの出現確率を示したものです。ご覧の通り、人気がある舟券ほど的中しています。
この事象は一見当たり前に見えますが、実はちょっと不思議な話なんです。
これらの確率は、一定期間の間で収束をします。つまり時系列的な要素も、ギャンブルの予測には重要であるということが言えます。
この辺りが、ギャンブルの面白いところだと僕は思っています。
話が少し逸れてしまいましたが、なんにしてもオッズは予想に不可欠な要素であると言えます。
ただ注意しなければいけないのが、オッズは常に揺れ動くものである点です。
特に近年では自動投票ツールを使用する人の増加などの影響で、締切直前オッズと確定オッズに大きな差が生じる傾向があります。
また、そもそも締切直前のオッズがデータとして公式サイトには残っておりません。(これまでの締切直前オッズが見れる親切なサイトもあります)
そういった背景から、今回は使用しないことにしないことにします。いつか使おう。
コンピューター予想
実は公式サイトでは、2017年ごろからコンピューターによる予想を公開してくれています。
実際のコンピューター予想はこんな感じです。
丁寧にも、自信の度合いまで教えてくれています。上のレースでは、あまり自信はなさそうですね。
ただ気をつけなければいけないのが、このコンピューター予想、自信があっても平気でハズします。もちろん的中することも多いのですが、オッズが低い舟券であることが多く、トータルの回収率としてはあまり期待できないかと思います。
なので、こちらのコンピューター予想も一旦使わない方向で行きます。
データの取得と加工
出走表と結果
各レースの出走表と結果は、公式サイトから1日ごとのデータをまとめて一つのファイルとしてダウンロードできるようになっています。
しかし困ったことに、これらは謎のフォーマットで書かれたテキストファイルとなっており、そのまま使うことはできません。
実際のファイルの中身の一部をお見せします。
racelists20210328.txt
STARTB
22BBGN
ボートレース福 岡 3月28日 第56回ボートレース 第 6日
*** 番組表 ***
第56回ボートレースクラシック
第 6日 2021年 3月28日 ボートレース福 岡
−内容については主催者発行のものと照合して下さい−
1R 一般 H1800m 電話投票締切予定10:32
-------------------------------------------------------------------------------
艇 選手 選手 年 支 体級 全国 当地 モーター ボート 今節成績 早
番 登番 名 齢 部 重別 勝率 2率 勝率 2率 NO 2率 NO 2率 123456見
-------------------------------------------------------------------------------
1 4262馬場貴也36滋賀54A1 7.65 55.07 7.12 52.00 54 31.11119 30.28 235 232 6 10
2 4352下條雄太34長崎52A1 6.99 53.21 6.56 50.00 49 29.63101 33.33 6 4 524 31
3 4459片岡雅裕35香川52A1 6.83 51.13 6.90 60.00 41 36.02132 31.43 3 4 641 64 8
4 4337平本真之36愛知54A1 7.31 47.57 8.31 75.86 73 27.85116 38.14 2 565 265 6
5 4503上野真之33佐賀51A1 7.75 58.71 8.15 67.31 15 38.42162 44.93 2 153 453 7
6 4397西村拓也34大阪52A1 7.11 52.86 7.16 57.89 40 29.71130 37.50 5 431 3 15 5
2R 一般 H1800m 電話投票締切予定11:01
-------------------------------------------------------------------------------
艇 選手 選手 年 支 体級 全国 当地 モーター ボート 今節成績 早
番 登番 名 齢 部 重別 勝率 2率 勝率 2率 NO 2率 NO 2率 123456見
-------------------------------------------------------------------------------
1 4296岡崎恭裕34福岡51A1 7.42 58.96 7.46 56.12 39 27.56141 34.78 436 4 5251
2 4108吉村正明41山口50A1 7.24 64.00 7.93 60.71 30 50.72104 28.91 1 S54 2 52 6
3 4445宮地元輝34佐賀53A1 6.50 42.98 7.82 65.45 64 35.17142 31.34 4 3 1S5 24 8
4 4604岩瀬裕亮32愛知52A1 7.08 55.48 7.92 68.00 16 38.97144 35.96 2 164 613 11
5 3573前本泰和48広島51A1 7.64 55.05 7.22 55.56 59 35.59138 48.55 4 113 545 10
6 4387平山智加35香川45A1 6.66 40.52 8.70 80.00 66 37.14108 30.61 543 5 2F6
すごく見づらいですが、フォーマットが統一されているのはありがたいですね。こういうのはプログラムで処理するのが一番です。
下記のような流れで処理を行います。
- 圧縮ファイルをウェブからダウンロードし解凍 >> テキストファイルを保存
- テキストファイルを読み込む
- フォーマットを解析し、いい感じにテーブルに変形して出力
ということで早速、機能ごとに関数を実装しました。
(ちょいちょいインストールが必要なライブラリを使ってますが、pip install
すれば大丈夫です)
download_file
# 圧縮ファイルをウェブからダウンロードし解凍 >> テキストファイルを保存
def download_file(obj, date):
"""
obj (str): 'racelists' or 'results'
"""
date = str(pd.to_datetime(date).date())
ymd = date.replace('-', '')
S, s = ('K', 'k') if obj == 'results' else ('B', 'b')
if os.path.exists(f'downloads/{obj}/{ymd}.txt'):
return
else:
os.makedirs(f'downloads/{obj}', exist_ok=True)
try:
url_t = f'http://www1.mbrace.or.jp/od2/{S}/'
url_b = f'{ymd[:-2]}/{s}{ymd[2:]}.lzh'
wget.download(url_t + url_b, f'downloads/{obj}/{ymd}.lzh')
archive = LhaFile(f'downloads/{obj}/{ymd}.lzh')
d = archive.read(archive.infolist()[0].filename)
open(f'downloads/{obj}/{ymd}.txt', 'wb').write(d)
subprocess.run(['rm', f'downloads/{obj}/{ymd}.lzh'])
except urllib.request.HTTPError:
print(f'There are no data for {date}')
read_file
# テキストファイルを読み込み、会場ごとのデータにテキストを区切って出力
def read_file(obj, date):
"""
obj (str): 'racelists' or 'results'
"""
date = str(pd.to_datetime(date).date())
ymd = date.replace('-', '')
f = open(f'downloads/{obj}/{ymd}.txt', 'r', encoding='shift-jis')
Lines = [l.strip().replace('\u3000', '') for l in f]
Lines = [mojimoji.zen_to_han(l, kana=False) for l in Lines][1:-1]
lines_by_plc = {}
for l in Lines:
if 'BGN' in l:
place_cd = int(l[:-4])
lines = []
elif 'END' in l:
lines_by_plc[place_cd] = lines
else:
lines.append(l)
return lines_by_plc
get_racelists
# 出走表ファイルのフォーマットを解析し、いい感じにテーブルに変形して出力
place_mapper = {
1: '桐生', 2: '戸田', 3: '江戸川', 4: '平和島', 5: '多摩川',
6: '浜名湖', 7: '蒲郡', 8: '常滑', 9: '津', 10: '三国',
11: '琵琶湖', 12: '住之江', 13: '尼崎', 14: '鳴門', 15: '丸亀',
16: '児島', 17: '宮島', 18: '徳山', 19: '下関', 20: '若松',
21: '芦屋', 22: '福岡', 23: '唐津', 24: '大村'
}
def get_racelists(date):
info_cols = ['title', 'day', 'date', 'place_cd', 'place']
race_cols = ['race_no', 'race_type', 'distance', 'deadline']
keys = ['toban', 'name', 'area', 'class', 'age', 'weight',
'glob_win', 'glob_in2', 'loc_win', 'loc_in2',
'moter_no', 'moter_in2', 'boat_no', 'boat_in2']
racer_cols = [f'{k}_{i}' for k in keys for i in range(1, 7)]
cols = info_cols + race_cols + racer_cols
stack = []
date = str(pd.to_datetime(date).date())
for place_cd, lines in read_file('racelists', date).items():
min_lines = 11
if len(lines) < min_lines:
continue
title = lines[4]
day = int(re.findall('第(\d)日', lines[6].replace(' ', ''))[0])
place = place_mapper[place_cd]
info = {k: v for k, v in zip(
info_cols, [title, day, date, place_cd, place])}
head_list = []
race_no = 1
for i, l in enumerate(lines[min_lines:]):
if f'{race_no}R' in l:
head_list.append(min_lines + i)
race_no += 1
for race_no, head in enumerate(head_list, 1):
try:
race_type = lines[head].split()[1]
distance = int(re.findall('H(\d*)m', lines[head])[0])
deadline = re.findall('電話投票締切予定(\d*:\d*)', lines[head])[0]
arr = []
for l in lines[head + 5: head + 11]:
split = re.findall('\d \d{4}.*\d\d\.\\d\d', l)[0].split()
bno = [0]
name, area, cls1 = [e for e in re.findall(
'[^\d]*', split[1]) if e != '']
toban, age, wght, cls2 = [e for e in re.findall(
'[\d]*', split[1]) if e != '']
tmp = [toban, name, area, cls1 + cls2, age, wght] + split[2:10]
if len(tmp) == 14:
arr.append(tmp)
else:
continue
if len(arr) == 6:
dic = info.copy()
dic.update(zip(race_cols, [race_no, race_type, distance, deadline]))
dic.update(dict(zip(racer_cols, np.array(arr).T.reshape(-1))))
stack.append(dic)
except IndexError:
continue
except ValueError:
continue
if len(stack) > 0:
df = pd.DataFrame(stack)[cols].dropna()
return df.astype(get_dtype('racelists'))
else:
return None
get_results
# 結果ファイルのフォーマットを解析し、いい感じにテーブルに変形して出力
def get_results(date):
conv_racetime = lambda x: np.nan if x == '.' else\
sum([w * float(v) for w, v in zip((60, 1, 1/10), x.split('.'))])
info_cols = ['title', 'day', 'date', 'place_cd', 'place']
race_cols = ['race_no', 'race_type', 'distance']
keys = ['toban', 'name', 'moter_no', 'boat_no',
'ET', 'SC', 'ST', 'RT', 'position']
racer_cols = [f'{k}_{i}' for k in keys for i in range(1, 7)]
res_cols = []
for k in ('tkt', 'odds', 'poprank'):
for type_ in ('1t', '1f1', '1f2', '2t', '2f',
'w1', 'w2', 'w3', '3t', '3f'):
if (k == 'poprank') & (type_ in ('1t', '1f1', '1f2')):
pass
else:
res_cols.append(f'{k}_{type_}')
res_cols.append('win_method')
cols = info_cols + race_cols + racer_cols + res_cols
stack = []
date = str(pd.to_datetime(date).date())
for place_cd, lines in read_file('results', date).items():
min_lines = 26
if len(lines) < min_lines:
continue
title = lines[4]
day = int(re.findall('第(\d)日', lines[6].replace(' ', ''))[0])
place = place_mapper[place_cd]
info = {k: v for k, v in zip(
info_cols, [title, day, date, place_cd, place])}
head_list = []
race_no = 1
for i, l in enumerate(lines[min_lines:]):
if f'{race_no}R' in l:
head_list.append(min_lines + i)
race_no += 1
for race_no, head in enumerate(head_list, 1):
try:
race_type = lines[head].split()[1]
distance = int(re.findall('H(\d*)m', lines[head])[0])
win_method = lines[head + 1].split()[-1]
_, tkt_1t, pb_1t = lines[head + 10].split()
_, tkt_1f1, pb_1f1, tkt_1f2, pb_1f2 = lines[head + 11].split()
_, tkt_2t, pb_2t, _, pr_2t = lines[head + 12].split()
_, tkt_2f, pb_2f, _, pr_2f = lines[head + 13].split()
_, tkt_w1, pb_w1, _, pr_w1 = lines[head + 14].split()
tkt_w2, pb_w2, _, pr_w2 = lines[head + 15].split()
tkt_w3, pb_w3, _, pr_w3 = lines[head + 16].split()
_, tkt_3t, pb_3t, _, pr_3t = lines[head + 17].split()
_, tkt_3f, pb_3f, _, pr_3f = lines[head + 18].split()
race_vals = [race_no, race_type, distance]
res_vals = [
tkt_1t, tkt_1f1, tkt_1f2, tkt_2t, tkt_2f,
tkt_w1, tkt_w2, tkt_w3, tkt_3t, tkt_3f,
pb_1t, pb_1f1, pb_1f2, pb_2t, pb_2f,
pb_w1, pb_w2, pb_w3, pb_3t, pb_3f,
pr_2t, pr_2f, pr_w1, pr_w2, pr_w3,
pr_3t, pr_3f, win_method
]
dic = info.copy()
dic.update(dict(zip(race_cols, race_vals)))
dic.update(dict(zip(res_cols, res_vals)))
dic = {k: float(v) / 100 if 'odds' in k else v
for k, v in dic.items()}
for i in range(6):
bno, *vals = lines[head + 3 + i].split()[1:10]
vals.append(i + 1)
keys = ['toban', 'name', 'moter_no', 'boat_no',
'ET', 'SC', 'ST', 'RT', 'position']
dic.update(zip([f'{k}_{bno}' for k in keys], vals))
stack.append(dic)
except IndexError:
continue
except ValueError:
continue
if len(stack) > 0:
df = pd.DataFrame(stack)[cols].dropna(how='all')
repl_mapper = {'K': np.nan, '.': np.nan}
for i in range(1, 7):
df[f'ET_{i}'] = df[f'ET_{i}'].replace(repl_mapper)
df[f'ST_{i}'] = df[f'ST_{i}'].replace(repl_mapper)\
.str.replace('F', '-').str.replace('L', '1')
df[f'RT_{i}'] = df[f'RT_{i}'].map(conv_racetime)
waku = np.array([('{}'*6).format(*v) for v in df[
[f'SC_{i}' for i in range(1, 7)]].values])
df['wakunari'] = np.where(waku == '123456', 1, 0)
df = df.replace({'K': np.nan})
return df.astype(get_dtype('results'))
else:
return None
上記の関数を使えば、任意の日程の出走表と結果のcsvが手に入ります。
後々作業がしやすくなることを考慮して、1行に1レースの情報が入るようにしました。
# 2021/03/28の結果ファイルをcsvで取得
date = '2021-03-28'
download_file('results', date)
df = get_results(date)
display(df.head())
直前情報
次に直前情報を取得します。
残念ながら、直前情報については出走表や結果のようなファイルが存在しなかったので、ウェブスクレイピングを行うことにします。
ウェブスクレイピングとは、ウェブサイトから情報を抽出することです。
スクレイピングを行う際は、サーバーに負荷をかけないように一定の間隔を空けて実行するのが良いとされています。
スクレイピングを行うためのpythonライブラリはいくつかありますが、ここでは有名な Beautiful Soup
を使います。
下記のような流れで処理を行います。
- 知りたい日程の直前情報が載っているサイトのURLを調べる
- URL先のHTMLファイルを取得
- 取得したHTMLファイルを解析し、欲しい部分を抽出する
ということで早速実装してみました。
get_url
# 任意の日程のレースについて直前情報やオッズの情報が記載されたURLを取得する
def get_url(date, place_cd, race_no, content):
"""
content (str): ['odds3t', 'odds3f', 'odds2tf', 'beforeinfo']
"""
url_t = 'https://www.boatrace.jp/owpc/pc/race/'
ymd = str(pd.to_datetime(date)).split()[0].replace('-', '')
jcd = f'0{place_cd}' if place_cd < 10 else str(place_cd)
url = f'{url_t}{content}?rno={race_no}&jcd={jcd}&hd={ymd}'
return url
get_beforeinfo
# 直前情報のサイトからHTMLを取得し解析する
def get_beforeinfo(date, place_cd, race_no):
url = get_url(date, place_cd, race_no, 'beforeinfo')
soup = BeautifulSoup(requests.get(url).text, 'lxml')
arr1 = arr1 = [[tag('td')[4].text, tag('td')[5].text]
for tag in soup(class_='is-fs12')]
arr1 = [[v if v != '\xa0' else '' for v in row] for row in arr1]
arr2 = [[tag.find(class_=f'table1_boatImage1{k}').text
for k in ('Number', 'Time')]
for tag in soup(class_='table1_boatImage1')]
arr2 = [[v.replace('F', '-') for v in row] for row in arr2]
arr2 = [row + [i] for i, row in enumerate(arr2, 1)]
arr2 = pd.DataFrame(arr2).sort_values(by=[0]).values[:, 1:]
air_t, wind_v, water_t, wave_h = [
tag.text for tag in soup(class_='weather1_bodyUnitLabelData')]
wether = soup(class_='weather1_bodyUnitLabelTitle')[1].text
wind_d = int(soup.select_one(
'p[class*="is-wind"]').attrs['class'][1][7:])
df = pd.DataFrame(np.concatenate([arr1, arr2], 1),
columns=['ET', 'tilt', 'EST', 'ESC'])\
.replace('L', '1').astype('float')
if len(df) < 6:
return None
try:
data = pd.concat([
pd.Series(
{'date': date, 'place_cd': place_cd, 'race_no': race_no}),
pd.Series(df.values.T.reshape(-1),
index=[f'{col}_{i}' for col in df.columns
for i in range(1, 7)]),
pd.Series({
'wether': wether, 'air_t': float(air_t[:-1]),
'wind_d': wind_d, 'wind_v': float(wind_v[:-1]),
'water_t': float(water_t[:-1]),
'wave_h': float(wave_h[:-2])})])
for i in range(1, 7):
data[f'ESC_{i}'] = int(data[f'ESC_{i}'])
return data
except ValueError:
return None
date = '2021-03-28'
place_cd = 22
race_no = 1
bi = get_beforeinfo(date, place_cd, race_no)
print(bi)
出力はこんなイメージです。ちゃんと直前情報が取れてますね。
sample_beforeinfo
date 2021-03-28
place_cd 1
race_no 12
ET_1 6.63 # 展示タイム
ET_2 6.75
ET_3 6.78
ET_4 6.82
ET_5 6.83
ET_6 6.82
tilt_1 -0.5 # チルト角度
tilt_2 -0.5
tilt_3 -0.5
tilt_4 -0.5
tilt_5 -0.5
tilt_6 -0.5
EST_1 0.08 # 展示スタートタイミング
EST_2 -0.04
EST_3 0.36
EST_4 0.16
EST_5 0.23
EST_6 0.19
ESC_1 1 # 展示スタートコース
ESC_2 2
ESC_3 3
ESC_4 4
ESC_5 5
ESC_6 6
wether 雨 # 天気
air_t 12.0 # 気温
wind_d 7 # 風向 (16方向 + 無風)
wind_v 1.0 # 風速
water_t 16.0 # 水温
wave_h 1.0 # 波高
これでやっと、出走表・直前情報・結果のデータをcsvで出力できるようになりました。
とりあえず2016年から2020年までの5年間のデータを取得したので、一旦は十分かと思います。
まとめ
今回は、競艇の予想に必要なデータを洗い出し、過去データを取得できるプログラムを実装しました。
次回は、モデルを組むための前処理を行っていこうと思います。