🏇

競馬シミュレーションアプリの作成

2024/06/19に公開

はじめに

プログラムのソースコード自体の説明よりも、どのような機能を実装したのかをユーザーやプログラムの観点から詳しく説明する。
また、これは静岡大学 峰野研究室の夏休み課題に課すプロジェクトの際に利用しています。

動作画面

・馬券購入画面(AI予想付き)
スクリーンショット 2024-06-07 005101.png

・レース画面(購入した馬券に色が付く)
スクリーンショット 2024-06-07 005146.png

・オッズ計算画面(差分を自動計算)
スクリーンショット 2024-06-07 005232.png

使用したライブラリ

インポートした主要ライブラリの使用目的

  • レース処理用「pandas」
  • GUI実装用「PySimpleGUI」
  • 学習モデル操作用「pickle」
  • サウンド再生用「playsound」

※ 以下にインポートした全ライブラリを示す

import random
import time
import datetime
import pandas
import PySimpleGUI
import sys
import os
import pickle
import warnings
from playsound import playsound

観戦モード

「観戦モード」は、アプリ起動時に選択可能なモードである。観戦モードを選択すると、その後の全レースの予想を行うことが不可能となり、ただただレースが繰り返されるようなモードである。
プログラム面から言うと、観戦モードを確認する関数を作成し、アプリ起動時に実行することで観戦モードの選択を可能にしている。

# 観戦モードの確認用関数(GUI)
def viewing_mode_gui():
    layout = [[sg.Text('観戦モードにしますか?')],
            [sg.Button('いいえ', button_color=(None, '#dc3545')), sg.Button('はい')]]
    window = sg.Window('--競馬シミュレーションアプリ--', layout, size=(400,75), element_justification='center')
    event, values = window.read()
    if event == sg.WIN_CLOSED:
        sys.exit()
    window.close()

    return event, values
# 使用例
viewing_mode = False
viewing_flag, _ = viewing_mode_gui()
if viewing_flag == 'はい':
    viewing_mode = True

馬情報について

馬情報は、全部で5つの属性で構成されている。具体的には【馬番】【馬名】【騎手】【調子】【タイプ】の5つで一つの馬情報となる。
レースに出場する馬は6体で固定しているため、レースが始まる時には、各属性が(【調子】と【タイプ】を除いて)重複無しで選択される。
プログラム面で説明すると、各属性を配列として管理し、レース時には、各属性から重複無しのランダムに出場数6体分の馬情報を作成する。【調子】と【タイプ】に関しては、出場馬の中で重複して良い属性なので、この属性に関しては重複ありのランダムで選択するようにしている。

# リストから任意数分だけ重複あり/なしで選択する関数
def random_select(all, num, deplication):
    list = []
    # 重複なし
    if deplication == None:
        for a in random.sample(all, num):
            list.append(a)
    # 重複あり
    elif deplication == True:
        for a in range(num):
            for b in random.sample(all, 1):
                list.append(b)
    return list
# 馬情報の各属性リスト
all_horse_number = [0,1,2,3,4,5,6,7,8,9]
all_horse_name = ['ヴェラアズール','タイトルホルダー','ディープポンド','ジャスティンパレス','ウインマイティ―'
                  ,'エフフォーリア','イズジョーノキセキ','ジェラルディーナ','ボルドグフーシュ','イクイノックス','ヒトリヒトコト']
all_rider = ['松山 弘平','横山 和生','川田 将雅','T.マーカンド','和田 竜二','横山 武史','岩田 康真','C.デムーロ','福永 祐一','C.ルメール','峰野 博史']
all_condition = ['↓↓','↓','→','↑','↑↑']
all_character = ['逃げタイプ','先行タイプ','差しタイプ','追い込みタイプ']

# 使用例
horse_number = random_select(all_horse_number, 6, None)
horse_name = random_select(all_horse_name, 6, None)
rider_name = random_select(all_rider, 6, None)

condition = random_select(all_condition, 6, True)
character = random_select(all_character, 6, True)

レース情報について

レース情報は、全部で4つの属性で構成されている。具体的には【レース長】【天気】【グラウンドタイプ】【レース名】4つで一つのレース情報となる。
レース情報は、各レースで1つであるため、馬情報の選択と同様の関数で各属性についてランダムに1つ選択する。

# レース情報の各属性リスト
all_length = [1000, 1600, 2000, 2500]
all_weather = ['雨', '曇り', '晴れ']
all_ground = ['芝', 'ダート']
all_race_name = ['有馬記念','菊花賞','大阪杯','桜花賞','天皇賞','宝塚記念','皐月賞']

# 使用例
length = random_select(all_length, 1, True)
weather = random_select(all_weather, 1, True)
ground = random_select(all_ground, 1, True)
race_name = random_select(all_race_name, 1, True)

レースの反復処理

レースは、色々なレースに対して繰り返し予想できる方が面白いため、1回で終了せずに(観戦モードを除いて)任意のタイミングでプログラムを終了することができるようにした。
プログラムの面で説明すると、毎レースで各変数を初期化し、レース準備から終了処理までをwhile文で括り、毎レースの最後に継続確認を行うように実装している。※当然、所持金が0円以下になると継続確認を行わずに強制終了となる。

# 継続確認用の関数(GUI)
def Yes_or_No_gui():
    layout = [[sg.Text('このまま次のレースに参加しますか?')],
            [sg.Button('いいえ'), sg.Button('はい', button_color=(None, '#dc3545'))]]
    window = sg.Window('--競馬シミュレーションアプリ--', layout, size=(400,75), element_justification='center')
    event, values = window.read()
    if event == sg.WIN_CLOSED:
        sys.exit()
    window.close()

    return event, values
# 使用例
if money > 0: # 所持金の確認
    continue_flag, _ = Yes_or_No_gui()
    # 終了
    if continue_flag == 'いいえ':
        sys.exit()
    # 継続
    elif continue_flag == 'はい':
        os.system('cls')
else:
    sys.exit()

サウンド再生

プログラムの各所に、事前に用意したサウンドを再生する機能を追加している。サウンド再生用のライブラリには「playsound」を実装容易性の観点から採用し、要所要所でサウンドを再生するように実装している。

# 使用例
playsound("XXX.mp3")
playsound("XXX.mp3", None) # バックグラウンド再生

# ファンファーレの実装例
id = random.randint(0, 3)
if id == 0:
    playsound("./fanfare.mp3")
elif id == 1:
    playsound("./fanfare2.mp3")
elif id == 2:
    playsound("./fanfare3.mp3")
else:
    playsound("./fanfare4.mp3")

激アツモード

「激アツモード」は毎レースで入る可能性があり、激アツモードに入った場合、そのレースのオッズが5倍となるような要素を追加している。
プログラムの面から説明すると、1~100の範囲で一様乱数を生成し、95以上の値の場合(5%)チャンス用サウンドを再生し、その後、1~5の範囲で一様乱数を生成し、3以上の場合(60%)、激アツモード突入用サウンドを再生し、激アツモードに突入するように実装している。

# 激アツモード内部抽選
if random.randint(1,100) >= 95: # 5%
    playsound("チャンス用サウンド.mp3")
    if random.randint(1,5) >= 3: # 60%
        playsound("激アツモード突入用サウンド.mp3")
        gekiatu = 1

馬券の種類

馬券は、1位を予想する「単勝」、2位以内を予想する「複勝」、1位と2位の組み合わせを予想する「複勝」のそれぞれ3種類の馬券について予想することができる。
プログラムの面で説明すると、それぞれのオッズ計算については後述でアルゴリズムを説明するが、予想自体はGUIで行うように実装している。観戦モードを選択している場合は、予想は行わないためこの部分はスキップされるように実装している。
更に、予想入力において、出場していない馬を予想したり、所持金を超えた賭け金の入力をしたり、賭け金のみを入力したりした場合、適切なエラーメッセージを提示し、再度入力を求めるようにしている。

# 予想の入力用関数(GUI)
def predict_input_gui(money):
    layout = [[sg.Text('予想してください(馬番を入力しないことも可能です)')],
              [sg.Text('所持金[¥]: ' + str(money))],
            [sg.Text('単勝: '), sg.Input(default_text='馬番', size=(4,5), justification='center'), sg.Text('賭け金[¥]: '), sg.Input(default_text='数字', size=(8,5), justification='center')],
            [sg.Text('複勝: '), sg.Input(size=(4,5), justification='center'), sg.Text('賭け金[¥]: '), sg.Input(size=(8,5), justification='center')],
            [sg.Text('2連複: '), sg.Input(size=(2,5), justification='center'), sg.Text("—"), sg.Input(size=(2,5), justification='center'), sg.Text('賭け金[¥]: '), sg.Input(size=(8,5), justification='center')],
            [sg.Button('次へ')]]
    window = sg.Window('--競馬シミュレーションアプリ--', layout, size=(600,170), element_justification='center', text_justification='center')
    event, values = window.read()
    if event == sg.WIN_CLOSED:
        sys.exit()
    window.close()

    return event, values
# 使用例
correct_flag = 0
while correct_flag == 0:
        event, values = predict_input_gui(start_money) # 予想の入力
        correct_flag, values = correct_input_gui(values, horse_number, start_money) # デバック

※ デバック用関数(correct_input_gui)は、コードの規模的に省略

オッズ計算アルゴリズム

オッズ計算は、ランダムに決定されるのでは無く、実際には「馬情報」「レース情報」によって決定される。
プログラム面の説明としては、馬情報、レース情報が決定され次第、オッズ計算用関数(calcurate_odds)を作成し、馬情報とレース情報を渡すことで、馬の強さ、レースとの相性などの要因によるオッズ変動を実現している。
また、激アツモードの実装に伴って、激アツモードの有無を示す変数も関数に渡す必要がある。
具体的な、アルゴリズムとしては、【レースの長さ】と【タイプ】の相性によって倍率は変動し、【天気】が良いほどオッズが高くなるように実装している。更に、馬券の種類によってもオッズは異なるため、「単勝」に比べて「複勝」の方がオッズが小さくなるように実装している。「2連複」のオッズは、「単勝」オッズの積としている。

※ この部分のプログラムは、コードの規模的に省略

レースのアルゴリズムについて

レース中の処理は、複数の関数が動いており、「レースの様子を表示する関数」「各馬の進み幅を決定する関数」「ゴールを判定する部分」「順位を確定させる関数」がレース処理を構成するための主要要素である。

  • 「レースの様子を表示する関数」:コンソール上に矢印が進んでいくように実装し、その時点の各馬の進んでいる距離を渡すことでその長さに応じた矢印を出力するような関数である。また、観戦モードや予想無しを除いて、予想フェーズで入力した馬券に応じて、予想した馬の矢印の色が変化するように実装している。
  • 「各馬の進み幅を決定する関数」:レースは内部では、減算方式で実装しており、【レースの長さ】によって初期値は変化する。各馬ごとに減算する値を求め、減らしていく操作を繰り返すため、各馬で毎回減算値を計算する関数が必要になる。
    具体的には、【馬名】と【騎手】と【タイプ】によって、減算値が決まり、【調子】が良いほど、行動できる確率が高まるような仕様にしている。また、【タイプ】に関しては、繰り返し回数によって、減算値を変化させることで【タイプ】を実現している。
  • 「ゴールを判定する部分」:先述のようにレースは減算方式であるため、ゴールの定義は0以下となった時となる。レースは全順位が確定するまでレースは繰り返し、ゴールした馬は、Pandasのデータフレームに【その時点の繰り返し回数】【馬番】【ゴール時の長さ】を追加し、既にゴールしている馬以外でレースを行うようにしている。ここでゴール時の長さを追加している理由は、同着で0以下となった場合は、より小さい方が1位となるように判定するためである。
  • 「順位を確定させる関数」:全ての順位が確定し、出場数分の出たデータフレームが作成できた場合、データフレームを【その時点の繰り返し回数】>【ゴール時の長さ】で昇順でソートすることで順位を確定させる。

※ この部分のプログラムは、コードの規模的に省略

AI予想の実装(reference_AI.pyのみ)

今回実装したプログラムの結果をcsvファイルとして出力することで学習用データを作成し、そのデータを用いて学習器:LightGBMで学習モデルを構築した。学習済みモデルを保存し、本プログラムで読み出し、毎レースで学習済みモデルに入力することで出力値を得ている。その出力値を元に、各馬の確率を算出するように実装している。
また、AI予想が出力した確率値やレース情報によって、AIが何かコメントをするような機能を簡易的に実現している。

def ai_predict(lane, num, size, all_length, gs, output):
    ai_predict = []
    lane_sum = []
    lane_base = 0
    max_ai_predict = -1
    comment = ""

    # 確率予測
    for a in range(num):
        sum = 0
        for b in range(1, size):
            if b == 4: #タイプ
                if race_info[1] == all_length[0]: #1000M
                    sum = sum + 8-lane[a][b]
                elif race_info[1] == all_length[1]: #1600M
                    sum = sum + lane[a][b]
                elif race_info[1] == all_length[2]: #2000M
                    sum = sum + 2*lane[a][b]
                elif race_info[1] == all_length[3]: #2500M
                    sum = sum + 2**lane[a][b]
            else:
                sum = sum + lane[a][b]
        lane_sum.append(sum*(output[a]+1))
        lane_base = lane_base + lane_sum[a]
    for c in range(num):
        ai_predict_lane = round(lane_sum[c]/lane_base, 2)
        ai_predict.append(ai_predict_lane)
        if ai_predict_lane > max_ai_predict:
            max_ai_predict = ai_predict_lane
    
    # AIコメントを作成
    if max_ai_predict >= 0.35:
        comment = "今回は自信ありますよ~!!"
    elif max_ai_predict <= 0.25:
        comment = "今回は予想するのが難しいですね~"
    
    if gs == 0:
        comment += "大波乱の予感がします!!"
    elif gs == 1:
        comment += "レースは少し荒れそうな感じがします"
    elif gs == 2:
        comment += "可もなく不可もないレースになりそうです"
    elif gs == 3:
        comment += "馬場の状態は良さそうですね"
    elif gs == 4:
        comment += "手堅い結果になると思います!!"

    
    return ai_predict, comment
# 使用例
model = pickle.load(open("lightgbm_model.pkl", 'rb')) # 学習済みモデルの読み出し
output = model.predict(input_data) # 入力

predict_result, ai_comment = ai_predict(lane, 6, 5, all_length, ground_state, output) # AI予想

Discussion