【コード編】投球ゾーン別BABIPを算出してみよう
こんにちは。
【結論編】投球ゾーン別BABIPを算出してみよう、という記事を先日公開しました。この記事ではこちらの結論を出すまでのPythonのプログラムコードを載せます。まだお読みになっていない方はリンクからどうぞ。
このような記事を公開したのは「分析したいけどプログラムの書き方がわからない」といった方と一緒に頑張りたいという意思です。少しでも分析ハードルが下がると嬉しいです。私も大して詳しくないのですが。
とはいえ説明を端折っているところも多いので、何か質問がございましたら遠慮なくどうぞ。初歩的な質問でもなんでも答えられる限りお答えします。特に今回の記事はどちらかというと関数の説明などはせずに「このコードで何をしているか」というところの説明に重きを置いているので、関数で不明な点があった際にもぜひコメントをお願いします。
また最初に申し上げておきますが、私のコードは洗練されていません。もう少し綺麗なコードが書けると思いますが、許してください。
コード(全て)
いったん全てのコードを載せたあとに細かく解説していきます。
ちなみに環境はGoogle colabです。Googleアカウントさえ持っていれば面倒なインストールなどなしにPythonを使えてしまうツールです。調べたら出てきます。
# ライブラリのインポート
!pip install pybaseball
from pybaseball import statcast
from scipy import stats
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
pd.set_option('display.max_columns', 400) #すべてのカラムを表示するためのコード
# statcastデータの取得
sc2020 = statcast(start_dt='2020-01-01', end_dt='2020-12-31')
sc2021 = statcast(start_dt='2021-01-01', end_dt='2021-12-31')
sc2022 = statcast(start_dt='2022-01-01', end_dt='2022-12-31')
sc2023 = statcast(start_dt='2023-01-01', end_dt='2023-12-31')
sc2024 = statcast(start_dt='2023-01-01', end_dt='2024-12-31')
# 必要な列のみに絞る
sc2019cut = sc2019[['zone', 'stand', "p_throws", "events"]]
sc2020cut = sc2020[['zone', 'stand', "p_throws", "events"]]
sc2021cut = sc2021[['zone', 'stand', "p_throws", "events"]]
sc2022cut = sc2022[['zone', 'stand', "p_throws", "events"]]
sc2023cut = sc2023[['zone', 'stand', "p_throws", "events"]]
sc2024cut = sc2024[['zone', 'stand', "p_throws", "events"]]
# scのデータフレームを合体
sc = pd.concat([sc2019cut, sc2020cut, sc2021cut, sc2022cut, sc2023cut, sc2024cut])
# 打席結果以外の行を除く
# eventが空白の行を除く
sc = sc.dropna(subset=['events'])
# 四死球、犠打、打撃妨害、走塁妨害、三振、本塁打の行を除く
# eventsがstrikeout, walk, hit_by_pitch, sac_bunt, truncated_pa, strikeout_double_play, catcher_interf, home_runの行を除く
sc = sc[~sc['events'].isin(['strikeout', 'walk', 'hit_by_pitch', 'sac_bunt', 'truncated_pa', 'strikeout_double__play', 'catcher_interf', 'home_run'])]
# リスト作成
column = ["single","double","triple","field_out","force_out","grounded_into_double_play","field_error","sac_fly","fielders_choice",
"double_play","fielders_choice_out","sac_fly_double_play","triple_play","sac_bunt_double_play"]
# データフレームを保存する辞書を作成
dataframes = {}
# 各名前に対応するデータフレームを作成し、辞書に保存
for name in column:
data = sc[sc['events'] == name].groupby(['zone', 'stand', "p_throws"]).size().reset_index(name=name)
dataframes[name] = data
# 欠損値をoに置き換える
zonebabip = zonebabip.fillna(0)
# zone, standごとにBABIPを計算
# BABIP = (single + double + triple) / (field_out + single + double + triple + force_out + grounded_into_double_play + field_error + sac_fly + fielders_choice
# + double_play + fielders_choice_out + sac_fly_double_play + triple_play + sac_bunt_double_play)
# まずは分母分子それぞれの計算
zonebabip["BABIPtop"] = zonebabip["single"] + zonebabip["double"] + zonebabip["triple"]
zonebabip["BABIPbottom"] = (zonebabip["field_out"] + zonebabip["single"] + zonebabip["double"] + zonebabip["triple"] + zonebabip["force_out" ]
+ zonebabip["grounded_into_double_play" ] + zonebabip["field_error"] + zonebabip["sac_fly"] + zonebabip["fielders_choice"]
+ zonebabip["double_play"] + zonebabip["fielders_choice_out"]
+ zonebabip["sac_fly_double_play"] + zonebabip["triple_play"] + zonebabip["sac_bunt_double_play"])
# BABIPを計算
zonebabip["BABIP"] = zonebabip["BABIPtop"] / zonebabip["BABIPbottom"]
読むには長いですかね。また途中明らかに繰り返し処理ができそうなところもありますがいったんこれでよしとさせてください。次から細かな説明を書いていきます。
ライブラリのインポート
# ライブラリのインポート
!pip install pybaseball
from pybaseball import statcast
from scipy import stats
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
pd.set_option('display.max_columns', 400) #すべてのカラムを表示するためのコード
ここではStatcastデータを読み込んだり、データを操作したりするのに必要だと思われるコードを一律でダウンロードしています。深く考えずコピペすれば大丈夫です。
あと最後の行がないとStatcastデータを見ようとしても列が省略されてしまうので、全部表示してもらうために書いてます。
Statcastデータの取得
# statcastデータの取得
sc2020 = statcast(start_dt='2020-01-01', end_dt='2020-12-31')
sc2021 = statcast(start_dt='2021-01-01', end_dt='2021-12-31')
sc2022 = statcast(start_dt='2022-01-01', end_dt='2022-12-31')
sc2023 = statcast(start_dt='2023-01-01', end_dt='2023-12-31')
sc2024 = statcast(start_dt='2023-01-01', end_dt='2024-12-31')
ここで期間を指定してStatcastデータを取得しています。別に1年ごとにしなくてもデータは取れるのですが、期間が長すぎるとエラーが起きやすい気がして1年で区切っています。どちらにせよあとで全年数を合体しますので何でも良いかと。
たまにこのコードでエラーが起きるのですが、もう一度実行すると成功したりします。もしエラーが起きたらもう一回やってみてください。
必要な列のみに絞る
# 必要な列のみに絞る
sc2019cut = sc2019[['zone', 'stand', "p_throws", "events"]]
sc2020cut = sc2020[['zone', 'stand', "p_throws", "events"]]
sc2021cut = sc2021[['zone', 'stand', "p_throws", "events"]]
sc2022cut = sc2022[['zone', 'stand', "p_throws", "events"]]
sc2023cut = sc2023[['zone', 'stand', "p_throws", "events"]]
sc2024cut = sc2024[['zone', 'stand', "p_throws", "events"]]
ゾーン、打席の左右、投手の左右、打席結果以外の列を消します。使わないですし、下手にデータの数が多くて重くなるのも嫌なので。
ここはどう考えても繰り返し処理にするべきですよね。気が向いたらちゃんと書き直します。
データフレームを合体
# scのデータフレームを合体
sc = pd.concat([sc2019cut, sc2020cut, sc2021cut, sc2022cut, sc2023cut, sc2024cut])
5年分のデータを合体します。
打席結果以外の行を除く
# 打席結果以外の行を除く
# eventが空白の行を除く
sc = sc.dropna(subset=['events'])
BABIPは打席が終わったときのデータのみを必要とするので、それ以外の一球データは削除します。具体的にはevents列が空白の行を削除します。
BABIPの計算に必要のない行の削除
# 四死球、犠打、打撃妨害、走塁妨害、三振、本塁打の行を除く
# eventsがstrikeout, walk, hit_by_pitch, sac_bunt, truncated_pa, strikeout_double_play, catcher_interf, home_runの行を除く
sc = sc[~sc['events'].isin(['strikeout', 'walk', 'hit_by_pitch', 'sac_bunt', 'truncated_pa', 'strikeout_double__play', 'catcher_interf', 'home_run'])]
BABIP=(安打-本塁打)÷(打数-三振-本塁打+犠飛)
ですので、本塁打以外の安打および三振・本塁打以外の打数が計算できれば良いわけです。そして打数には打席数から四死球、犠打、打撃妨害、走塁妨害を除いたものなので、結果的に「四死球、犠打、打撃妨害、走塁妨害、三振、本塁打+打席が途中で終わった場合」を除けば良いです。ということで、これらの結果が記された行が除かれるようなコードを書きました。
各打席結果ごとの行数を合計する
# リスト作成
column = ["single","double","triple","field_out","force_out","grounded_into_double_play","field_error","sac_fly","fielders_choice",
"double_play","fielders_choice_out","sac_fly_double_play","triple_play","sac_bunt_double_play"]
# データフレームを保存する辞書を作成
dataframes = {}
# 各名前に対応するデータフレームを作成し、辞書に保存
for name in column:
data = sc[sc['events'] == name].groupby(['zone', 'stand', "p_throws"]).size().reset_index(name=name)
dataframes[name] = data
# 辞書のデータフレームを全て結合(how = outer)
key_column = ['zone', 'stand', "p_throws"]
zonebabip = None
for name, df in dataframes.items():
if zonebabip is None:
zonebabip = df
else:
zonebabip = pd.merge(zonebabip, df, on=key_column, how='outer')
リストに全ての打席結果を書き出し、その打席結果ごとにデータフレームを作り、最終的には合体しています。chat GPTに聞きながらやったので解説はAIに任せた方がよさそう。
欠損値を0に置き換える
# 欠損値をoに置き換える
zonebabip = zonebabip.fillna(0)
例えばトリプルプレーのようにそもそも起こる確率が低い事象は、ゾーンや打席の左右によっては発生しないこともあるかもしれません。その場合はおそらくその欄が空白になるのですが、それがBABIP計算時に悪さをしたら嫌だなあ、と思ったので空白を0に変えました。もしかしたら必要ないかも。
BABIPを計算
# zone, standごとにBABIPを計算
# BABIP = (single + double + triple) / (field_out + single + double + triple + force_out + grounded_into_double_play + field_error + sac_fly + fielders_choice
# + double_play + fielders_choice_out + sac_fly_double_play + triple_play + sac_bunt_double_play)
# まずは分母分子それぞれの計算
zonebabip["BABIPtop"] = zonebabip["single"] + zonebabip["double"] + zonebabip["triple"]
zonebabip["BABIPbottom"] = (zonebabip["field_out"] + zonebabip["single"] + zonebabip["double"] + zonebabip["triple"] + zonebabip["force_out" ]
+ zonebabip["grounded_into_double_play" ] + zonebabip["field_error"] + zonebabip["sac_fly"] + zonebabip["fielders_choice"]
+ zonebabip["double_play"] + zonebabip["fielders_choice_out"]
+ zonebabip["sac_fly_double_play"] + zonebabip["triple_play"] + zonebabip["sac_bunt_double_play"])
ようやくBABIPの計算です。BABIPの計算式には割り算が含まれますが、まず分母の列と分子の列を作ります。それから割り算をするというステップを踏みます。一気にやるとややこしそうだからです。その際の計算式はコメント2行目にある通りです。
# BABIPを計算
zonebabip["BABIP"] = zonebabip["BABIPtop"] / zonebabip["BABIPbottom"]
そして割り算して完成です。最後の行を実行すると打席の左右(L/R)、投手の左右(L/R)、ゾーン番号ごとのBABIPが出てくるはずです。

こちらがゾーン番号の表になります。ちなみに捕手視点ですのでお気を付けください。
投手の左右を考慮しないバージョン
# 投手の左右考慮なしバージョン
# p_throws列の削除
sc_n = sc.drop(["p_throws"], axis=1)
# リスト作成
column = ["single","double","triple","field_out","force_out","grounded_into_double_play","field_error","sac_fly","fielders_choice",
"double_play","fielders_choice_out","sac_fly_double_play","triple_play","sac_bunt_double_play"]
# データフレームを保存する辞書を作成
df_zs = {}
# 各名前に対応するデータフレームを作成し、辞書に保存
for name_n in column:
data_zs = sc_n[sc_n['events'] == name_n].groupby(['zone', 'stand']).size().reset_index(name=name_n)
df_zs[name_n] = data_zs
# 辞書のデータフレームを全て結合(how = outer)
key_column_n = ['zone', 'stand']
zonestand = None
for name_n, df_n in df_zs.items():
if zonestand is None:
zonestand = df_n
else:
zonestand = pd.merge(zonestand, df_n, on=key_column_n, how='outer')
# 欠損値をoに置き換える
zonestand = zonestand.fillna(0)
# zone, standごとにBABIPを計算
# BABIP = (single + double + triple) / (field_out + single + double + triple + force_out + grounded_into_double_play + field_error + sac_fly + fielders_choice
# + double_play + fielders_choice_out + sac_fly_double_play + triple_play + sac_bunt_double_play)
# まずは分母分子それぞれの計算
zonestand["BABIPtop"] = zonestand["single"] + zonestand["double"] + zonestand["triple"]
zonestand["BABIPbottom"] = (zonestand["field_out"] + zonestand["single"] + zonestand["double"] + zonestand["triple"] + zonestand["force_out" ]
+ zonestand["grounded_into_double_play" ] + zonestand["field_error"] + zonestand["sac_fly"] + zonestand["fielders_choice"]
+ zonestand["double_play"] + zonestand["fielders_choice_out"]
+ zonestand["sac_fly_double_play"] + zonestand["triple_play"] + zonestand["sac_bunt_double_play"])
# BABIPを計算
zonestand["BABIP"] = zonestand["BABIPtop"] / zonestand["BABIPbottom"]
先ほどのコードを全て無事に実行できたなら、そのあとこれを一気に実行すれば投手左右考慮なし版ができますのでこちらもぜひ。
まとめ
一応コードの簡単な解説はこれで全てです。何かの参考になったら嬉しいです。