ABEJA Tech Blog

中の人の興味のある情報を発信していきます

パワプロでよくあるオートペナントっぽいことをやってみる

はじめに

ABEJAアドベントカレンダー2023の10日目です。どうも@Takayoshi_maです。2年前にこんな実験をしていました。

speakerdeck.com

プロ野球のシーズン日程が偏りすぎてて同じ対戦投手とばかり当たってしまう問題について、機会を均等にしてみると実は違った結果になるのでは??という発想の元やってみたシミュレーションです。今回はこの続きで2023年シーズンのセ・パ両リーグを試してみようと思います。また、前回方法から少しだけ変更しているのでそちらも軽く触れていきます。

ソースコードはデータと合わせて、こちらのリポジトリにしておいたので興味ある方は是非

github.com

シミュレーションの概要

Games Class

大雑把にいうと、交流戦を外した125試合について、対戦先発投手をなるべく均等にするように新しいスケジュールを作っています。

▶️ src/game.py

import random

import pandas as pd
import numpy as np


class Game():
    def __init__(self, df_base: pd.DataFrame, league: str, seed: int = 0):
        self.df_base = df_base
        self.seed = seed

        if league == 'central':
            self.teams = ['tigers', 'carp', 'dena',
                          'giants', 'swallows', 'dragons']
        elif league == 'pacific':
            self.teams = ['orix', 'lotte', 'hawks',
                          'rakuten', 'lions', 'fighters']

        self.schedule = None

    def __repr__(self):
        return f'Game(league={self.league}, seed={self.seed})'

    def set_schedule(self) -> None:
        """実際の試合日程に対して、対戦先発投手をセットしていく"""
        starting_pithers = self.get_staring_pithcers()
        opp_pithers = self.set_opp_pither(starting_pithers)

        df_schedule = self.df_base.iloc[1::2][['date', 'my_team', 'opp_team']]
        df_schedule = df_schedule.rename(
            columns={'my_team': 'home_team', 'opp_team': 'away_team'})
        home_pithers, away_pitchers = [], []

        for _, row in df_schedule.iterrows():
            home_team, away_team = row['home_team'], row['away_team']
            home_pithers.append(opp_pithers[home_team][away_team].pop(0))
            away_pitchers.append(opp_pithers[away_team][home_team].pop(0))

        df_schedule['home_pitcher'] = home_pithers
        df_schedule['away_pitcher'] = away_pitchers

        self.schedule = df_schedule

    def get_staring_pithcers(self) -> dict[str, np.ndarray]:
        """先発投手を取得する

        Returns:
            dict[str, np.ndarray]: 125試合分の先発投手

            example:
                {
                    lions: np.array(['高橋', '高橋', '高橋', ...]),   # 125試合分
                    fighters: ...
                }
        """
        starting_pithers = {}
        for team in self.teams:
            _df = self.df_base[self.df_base['my_team'] == team]
            starting_pithers[team] = np.array(sorted(_df['player']))

        return starting_pithers

    def set_opp_pither(self, starting_pithers: dict[str, np.ndarray]):
        """対戦相手の先発投手をセットする

        Args:
            starting_pithers (dict[str, list]): 125試合の先発投手リスト

        Returns:
            dict[str, dict[str, list]]: 対戦チームとの試合に投げる先発投手リスト

            example:
                {
                    lions: {
                        hawks: ['高橋', '今井', '松本', ...],   # vs Hawks 25試合分
                        rakuten: ...
                    },
                    carp: ...
                }
        """
        opp_pithers = {}
        for i, my_team in enumerate(self.teams):
            opp_pithers[my_team] = {}

            # ランダムに並べ替える
            random.seed(self.seed + i)
            opp_teams = [opp for opp in self.teams if opp != my_team]
            opp_teams = random.sample(opp_teams, len(opp_teams))

            for j, opp_team in enumerate(opp_teams):
                # ランダムに並べ替える
                random.seed(self.seed + i + j)
                _pitchers = list(starting_pithers[my_team][j::5])
                _pitchers = random.sample(_pitchers, len(_pitchers))
                opp_pithers[my_team][opp_team] = _pitchers

        return opp_pithers

今回は1万回ペナントレースを行いその結果を集約していくのですが、シミュレーションの度に若干の揺らぎが出るようseed値を与えて調整しています。

Runs Class

AチームのB投手 vs CチームのD投手のように先発投手がそれぞれ決まった際に、両チームの獲得点数を算出します。野球は自チームの攻撃と守備が完全に独立しているので、考慮すべきは、(自チームの獲得点数)=(自チームの攻撃力の影響度)+(相手チームの守備力の影響度)になりますが、ここでは大雑把に以下のように点数を算出します。

  • 方法①:自チームの獲得点数 <- 自チームの先発投手と対戦チームとの過去実績点数からランダムに1つ選択
  • 方法②:自チームの獲得点数 <- 自チームの先発投手と他5チームとの過去実績点数からランダムに1つ選択(Aより範囲広め)
  • 方法③:自チームの獲得点数 <- 自チームの125試合の獲得点数からランダムに1つ選択
  • 上記3つの方法のうち、方法①をメインにしつつ②や③も適度に考慮

本来はリリーフ投手の出来や、対戦打者のその日のメンバーなででも結果は動くはずですが、ここではあえて簡略化するために点数のブレを先発投手に限定しています。

また、2年前にシミュレーションをした際は、基本的に方法①の方法を採用しつつ、投手BとチームCの対戦成績がない場合チームAとチームCの対戦成績からランダムにチョイスする。みたいな方法を採用していましたが、以下の方法で問題があるかなと思いました。

  • どんな好投手でもたまに炎上する。シーズン中Cチームとの対戦が1度しかなく、たまたまその1回炎上してしまうと、その投手がどれだけ他のチームを抑えていたとしても、どうしても炎上時の点数がシミュレーションに反映されてしまい、ブレが大きくなる。
  • チームAとチームCの対戦成績からランダムにチョイスするっていうのが感覚的におかしい気がする。

そこで今回は手法を改めた次第です。なお、方法①をメインにしつつ、それ以外の2つについては、頻度的に 方法② > 方法③ で採用しています。これは数字の根拠をとったわけではありませんが、感覚的にチームの打力よりも先発投手の能力に得点が左右されるケースが思うからです。

▶️ src/runs.py

class Runs:
    def __init__(self, df, league: str):
        self.df = df

        if league == 'central':
            self.teams = ['tigers', 'carp', 'dena',
                          'giants', 'swallows', 'dragons']
        elif league == 'pacific':
            self.teams = ['orix', 'lotte', 'hawks',
                          'rakuten', 'lions', 'fighters']

        self.pitcher_stats1 = None
        self.pitcher_stats2 = None
        self.runs_stats = None

    def __repr__(self):
        return f'Runs(league={self.league}, seed={self.seed})'

    def agg_stats(self) -> None:
        dict[str, dict[str, list[int]]]
        """投手の成績を3種類の軸で取得する

        Returns:
            - pitcher_stats1 (dict[str, dict[str, dict[str, list[int]]]]): 対戦チームを考慮した投手の対戦成績
                example:
                    {
                        'lions': {
                            'hawks': {
                                '高橋': [int, int, ...],   # lions高橋の vsソフトバンク失点実績
                                '松本': [int, int, ...],
                            }
                        },
                    }
            - pitcher_stats2 (dict): 対戦チームを考慮しない投手の対戦成績
                example:
                    {
                        'lions': {
                            '高橋': [int, int, ...],   # lions高橋の失点実績
                            '松本': [int, int, ...],
                        },
                    }
            - runs_stats (dict): 対戦投手を考慮しないチームの得点成績
                example:
                    {
                        'lions': [int, int, ...],   # lionsの得点実績
                        'hawks': [int, int, ...],
                    }
        """
        pitcher_stats1 = {}
        pitcher_stats2 = {}
        runs_stats = {}

        for my_team in self.teams:
            # stats1: 対戦チームを考慮した投手の対戦成績
            pitcher_stats1[my_team] = {
                k: {} for k in self.teams if k != my_team}
            team_df = self.df[self.df['my_team'] == my_team]

            # stats2: 対戦チームを考慮しない投手の対戦成績
            pitcher_stats2[my_team] = {}

            # stats3: 対戦投手を考慮しないチームの得点成績
            runs_stats[my_team] = []

            for _, row in team_df.iterrows():
                _player = row['player']
                _op_team = row['opp_team']
                _run = int(row['runs_lost'])

                if _player in pitcher_stats1[my_team][_op_team]:
                    pitcher_stats1[my_team][_op_team][_player].append(_run)
                else:
                    pitcher_stats1[my_team][_op_team][_player] = [_run]

                if _player in pitcher_stats2[my_team]:
                    pitcher_stats2[my_team][_player].append(_run)
                else:
                    pitcher_stats2[my_team][_player] = [_run]

                runs_stats[my_team].append(_run)

        self.pitcher_stats1 = pitcher_stats1
        self.pitcher_stats2 = pitcher_stats2
        self.runs_stats = runs_stats

Simulation

シミュレーション自体は、先ほどまでクラスを使ってあとは淡々とやるだけです。2年前と違ってnumbaを使っています。そのおかげでだいぶ高速化できました。 補足ですが、セ・パ交流戦の対戦成績は今回のシミュレーションに加えず、実績をそのまま採用しています。よって交流戦の成績はどのシミュレーションにも等しく影響しています。

▶️ src/simulation.py

@numba.jit
def simulation(game: Game, runs: Runs, seed: int):
    pitcher_stats1 = runs.pitcher_stats1
    pitcher_stats2 = runs.pitcher_stats2
    runs_stats = runs.runs_stats
    home_teams = game.schedule['home_team'].values
    away_teams = game.schedule['away_team'].values
    home_pitchers = game.schedule['home_pitcher'].values
    away_pitchers = game.schedule['away_pitcher'].values

    home_team_runs = np.zeros(len(home_teams))
    away_team_runs = np.zeros(len(away_teams))

    for i, (home_team, away_team, home_pitcher, away_pitcher) in enumerate(zip(
        home_teams, away_teams, home_pitchers, away_pitchers
    )):
        random.seed(seed + i)
        random_number = random.random()

        if random_number < 0.6:
            # 方法1: 自チームの先発投手 vs 対戦チームの過去失点からランダムに選択(ただし対戦実績がなければ、他の方法を選択)
            away_team_run = select_run(
                random_number, pitcher_stats1, pitcher_stats2, runs_stats,
                my_team=home_team, op_team=away_team, pitcher=home_pitcher)
            # 方法1: 対戦チームの先発投手 vs 自チームの過去失点からランダムに選択(ただし対戦実績がなければ、他の方法を選択)
            home_team_run = select_run(
                random_number, pitcher_stats1, pitcher_stats2, runs_stats,
                my_team=away_team, op_team=home_team, pitcher=away_pitcher)
        elif random_number < 0.9:
            # 方法2: 対戦チームの先発投手 vs 全チームの過去失点からランダムに選択
            home_team_run = _random_select_run_from_s2(
                pitcher_stats2, away_team, away_pitcher)
            # 方法3: 自チームの先発投手 vs 全チームの過去失点からランダムに選択
            away_team_run = _random_select_run_from_s2(
                pitcher_stats2, home_team, home_pitcher)
        else:
            # 方法3: 自チームの全得点実績からランダムに選択
            home_team_run = _random_select_run_from_rs(runs_stats, home_team)
            # 方法3: 対戦チームの全得点実績からランダムに選択
            away_team_run = _random_select_run_from_rs(runs_stats, away_team)

        home_team_runs[i] = home_team_run
        away_team_runs[i] = away_team_run

    return home_team_runs, away_team_runs


@numba.jit
def select_run(
    random_number: float,
    pitcher_stats1: dict,
    pitcher_stats2: dict,
    runs_stats: dict,
    my_team: str,
    op_team: str,
    pitcher: str):
    # 方法1
    if pitcher in pitcher_stats1[my_team][op_team]:
        return _random_select_run_from_s1(pitcher_stats1, my_team, op_team, pitcher)
    # 対戦実績がなければ、他の方法を選択
    if random_number < 0.5:
        # 方法2
        return _random_select_run_from_s2(pitcher_stats2, my_team, pitcher)
    # 方法3
    return _random_select_run_from_rs(runs_stats, op_team)


def _random_select_run_from_s1(
    stats: dict,
    my_team: str,
    op_team: str,
    my_pitcher: str
):
    return random.choice(stats[my_team][op_team][my_pitcher])


def _random_select_run_from_s2(
    stats: dict,
    my_team: str,
    my_pitcher: str
):
    return random.choice(stats[my_team][my_pitcher])


def _random_select_run_from_rs(
    stats: dict,
    my_team: str
):
    return random.choice(stats[my_team])

結果

試しに一度だけシミュレーション

league = 'pacific'
seed = 1

# スケジュールの作成
game = Game(df, league=league, seed=seed)
game.set_schedule()

# 対戦成績の実績集計
runs = Runs(df, league=league)
runs.agg_stats()

# シミュレーション
home_team_runs, away_team_runs = simulation(game, runs, seed)

df_schedule = game.schedule.copy()
df_schedule['home_team_runs'] = home_team_runs
df_schedule['away_team_runs'] = away_team_runs

df_schedule.head(18)

result-1simulation

セントラルリーグ

AREで盛り上がった今年のセ・リーグの10000年分シミュレーション結果はこちら

central-2023-simulation

圧倒的ARE感ある結果でした。シミュレーション結果も実際の順位もさほど大きな違いはなかったのですが、最下位と5位だけちょっと違っています。煽っているわけではないですがドラゴンズが昨年の王者よりも上っぽい?という結果に。

パシフィックリーグ

2021年はソフトバンクと西武がオリックスの強ピッチャーと多く当たっていたこともあり、実際の順位と若干の差異がありましたが、今年は、、、

pacific-2023-simulation

無慈悲な結果でした笑 1位・5位・6位は元々のゲーム差もあったし、実力通りといった感じでしょうか。。。 ただし2位・3位・4位に関しては本来の順位とむしろ逆順(誤差程度ですが)の傾向があったのは面白いところです。この3チームは実際終盤までCS争いをしていたし、この結果も割と納得感はあります。

最後に

We Are Hiring! ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! (新卒の方のエントリーもお待ちしております)

careers.abejainc.com