いものやま。

雑多な知識の寄せ集め

競馬に関して最適化問題を解いた話。

ウマ娘から実際の競馬に興味を持って、最近はむしろ実際の競馬の方が面白いくらい。 そんな中でちょっと最適化問題を解くことがあったので、今日はその紹介をしてみたい。

これは数理最適化 Advent Calendar 2022の2日目の記事です。

的中したのにマイナス!?

馬券を買っててあるのが、的中したのに金額としてはマイナスになってるケース。 そんなアホなことあるのかって思うだろうけど、テレビ中継を見ててもけっこうあったりする。

なんでそんなことが起こるのかというと、予想した馬の組み合わせに対して一律に金額を賭けてたりするから。

たとえば1, 2, 3, 4番の馬のいずれか2頭が1着と2着に入る(順番は問わない)と予想して(馬連)、その全部の組み合わせに100円ずつ賭けるとする。この場合、組み合わせは「1-2」「1-3」「1-4」「2-3」「2-4」「3-4」の6通りあるので、合計の購入金額は600円。 でもここで「1-2」は当たったとしても5倍にしかならない組み合わせだとしたら、的中しても500円しか返ってこないのでマイナスになってしまう。

そういうことを避けるためには、オッズ(的中したときに何倍になって返ってくるか)を考えて賭ける口数を調整してやらないといけない。 そこで出番となるのが数理最適化。

例題

例題として次のような問題を考えてみる。

まず、1着の馬を当てるとして(単勝)、当たると予想した馬が1〜8番で、そのオッズが次のようになっているとする:

番号 1 2 3 4 5 6 7 8
倍率 19 5 13 27 8 9 3 33

賭けは1口100円で、当てたときには最低500円はプラスになってほしいとする。 また、外れたときに失う金額もできるだけ小さくしたいとする。

さて、どのように賭けるのが最適だろうか?

まずダメなのは先程も言及したように100円ずつ賭ける方法。 合計で800円かかるのに対して、2番や7番が1着になった場合は500円や300円しか返ってこないのでマイナスになってしまう。

あるいはたとえば投資金額は10,000円(100口)と決めうちで、リターンが10,500円を超えるように口数を決めていくというのも考えられる。 この場合、1番は6口、2番は21口、3番は9口、4番は4口、5番は14口、6番は12口、7番は35口、8番は4口となるけど、合計すると105口となって予算オーバーとなってしまう。

そんな感じで素朴にやってるとダメなんだけど、数理最適化で整数計画問題として定式化してやれば、ソルバーを使ってスパッと解ける。

PuLPを使った求解

以下ではJupyter NotebookとかでPythonを使って解くとする。

まずは必要なライブラリのインポート:

import pandas as pd
import pulp

そしてデータとして、一口の金額や最低限プラスになってほしい金額、オッズと馬のリストを用意しておく:

unit_cost = 100   # 1口あたりの金額
min_profit = 500  # 勝った場合に最低得たい金額

odds = pd.Series({
    "1": 19., "2": 5., "3": 13., "4": 27.,
    "5":  8., "6": 9., "7":  3., "8": 33.,
}, name="オッズ")

horses = list(odds.index)

あとはPuLPを使って最適化問題を記述していけばいい。

まず求めたいのは各馬に何口賭けるかなので、それを変数にする(整数変数なのでcat=pulp.LpIntegerを指定することに注意):

# 変数 ----------
# 購入口数
buy = pulp.LpVariable.dicts("buy", horses, lowBound=0, cat=pulp.LpInteger)

次に目的関数だけど、負けたときに失う金額をできるだけ小さくしたいというのは購入金額の合計を最小化したいということなので、それを表現してやればいい:

# 問題 ----------
problem = pulp.LpProblem(sense=pulp.LpMinimize)

# 目的変数 ----------
total_buy = sum(unit_cost * buy[horse] for horse in horses)
problem += total_buy

そして制約は、どの馬が勝ったとしてもプラスが最低限以上になっていること。 各馬が勝ったときに得られる金額は「1口の金額 x その馬の口数 x その馬のオッズ」なので、これは次のような制約として表現できる:

# 制約 ----------
# どの馬が勝ったとしても獲得金額が条件を満たす
for horse in horses:
    problem += unit_cost * buy[horse] * odds[horse] - total_buy >= min_profit

これであとは解くだけ:

# 求解 ----------
solver = pulp.PULP_CBC_CMD(msg=False)
status = problem.solve(solver)

# 出力 ----------
print(pulp.LpStatus[status])
print(f"total: {total_buy.value()}")
for horse in horses:
    print(f"{horse}: {buy[horse].value()} ({(unit_cost * buy[horse] * odds[horse] - total_buy).value()})")

これを実行してみると、次のような出力になる:

Optimal
total: 18400.0
1: 10.0 (600.0)
2: 38.0 (600.0)
3: 15.0 (1100.0)
4: 7.0 (500.0)
5: 24.0 (800.0)
6: 21.0 (500.0)
7: 63.0 (500.0)
8: 6.0 (1400.0)

ということで最適解は求まって、合計18,400円使って上のように買ってやれば、当たったときには最低500円のプラスになってくれる。 まぁ、この例では18,400円も投資して当たったときのリターンの最小値が500円なので、かなり効率の悪い賭けになってるんだけど(^^;

ちなみに例題では単勝を考えたけど、馬連三連単とかでもオッズの項目数が増えるだけなので同じ定式化で解けたりする(馬の番号の代わりに馬の番号の組み合わせや並びを各項目とすればいい)。 なお、複勝やワイドは馬券の当たり外れが排他的でなくなるので、また違った定式化が必要となる。 それについて考えてみるのも面白いかもしれない。

今日はここまで!