見出し画像

【MLB】Statcastデータ取得時にやっていること

 WBCも佳境ですが、皆さんいかがお過ごしでしょうか。侍ジャパンの活躍を日々楽しんでいる方も多いのではないかと思います。その中で、マスメディアの記事やTwitterなどで打球速度が話題になっているのを目にする機会があったかもしれません。これらのデータは近頃よく話題になるホークアイという機械を用いたスタットキャストというシステムによって計測・配信されています。

 このスタットキャストによって提供されるトラッキングデータは、Baseball Savantを始めとする様々な形でネット上でのコンテンツに利用されるだけでなく、無料で一球速報的なデータを提供しており、特定条件での絞り込みやCSVでのダウンロードまでさせてくれるようになっています。日本と比べて個人ユーザーによるトラッキングデータへのアクセスはとても高いのは間違いありません。

 さて、そんなスタットキャストのデータですが、RPythonなどのプログラミング言語にはMLBのスタットキャストデータをダウンロードするためのライブラリが存在します。私はPythonはよくわかりませんが、以下の記事が参考になるかもしれません。

 私が普段扱っているのはR言語ですので、ここからはR言語の話をしたいと思います。ちなみにデータ取得についてはとりあえずこちらのa.na.nさんの記事をお読みいただけると嬉しい感じになっています。


ライブラリの紹介

 スタットキャストデータの取得を行うRパッケージは主に二つあります。アメリカのBill Pettiらが作り、上で紹介した記事でも使われているbaseballrパッケージ、そして日本の露崎博之さん(TwitterではTsuyuponさん)が作成したmlbrパッケージです。

何が違うの?

 私がよく使うbaseballrは、余分なものも含めて全ての列を取得してくれます。mlbrは93列のうち、空であるものや非常に重要なものなど21列を落とし、72列を取得しています。個人的にはこのmlbrで落とされている列にxwOBAxBAWPAなどが含まれていることから現状では不便で、スタットキャストデータを取得するだけならbaseballrのほうが優位だと思っています。ただ、mlbrにはbaseballrと比べて優秀な機能もありますから両方インストールしておくと良いでしょう。

データ取得コード(2023/08/05加筆)

library(tidyverse)
library(baseballr)
Unzip <- function(...) rbind(data.frame(), ...)
dates <- seq.Date(as.Date("任意の日付"),
                  as.Date("任意の日付"), by = 'day')

date_grid <- tibble(start_date = dates, 
                    end_date = dates)

data<-map(.x = seq_along(date_grid$start_date), 
           ~{message(paste0('\nScraping day of ', date_grid$start_date[.x], '...\n'))
             
             scrape_statcast_savant(start_date = date_grid$start_date[.x],
                                    end_date = date_grid$end_date[.x])
           }
           
)
data <- do.call(Unzip, data)

 ひとまずこれでデータ取得ができます。(このコードをそのままコピペしても当然エラーです。ご自身で引数に代入した部分の編集や削除を行ってください。

 このパートについては詳しい解説はしませんが、選手IDはbaseball savantの選手ページのURLにある6桁の数字です。ID検索で使える便利な関数群は両パッケージともに参照しているウェブサイトのURLが変わったのか404エラーを吐いていて使えません。残念。

実行する時のTips(2023/08/05追記)

 コンソールでコードを実行してデータを取得してもよいのですが、これでは時間がかかってしまい、もしRStudioでほかのことをしたいということがあったら、例えば既に取得したデータを使って分析だとか、この記事のデータ取得~加工~保存を複数回に分けて行うときなどに困ります

 この問題の解決策として、「バックグラウンドで実行する」というのがあります。RStudioの中にある「Background Jobs」というペインを開いてください。すると「Start Background Job」というボタンがあります。パッケージの読み取りを始めとする、実行する処理を書いたコードを書いたファイルを保存した上でこのボタンを押すと何やらポップアップが出てきますね。

 ポップアップの一番上のところに処理を書いたファイルがあることを確認したら、一番下のプルダウン(デフォルトでは「(Don't copy)」)を「To global environment」にした上で実行しましょう。ちなみに、最後の方に出てくる「ここまでのコードのおさらい」のところにあるコードを実行する時はプルダウンがデフォルトのままでもいいです。

データを加工しよう①

 取得したデータをそのまま保存してしまってもいいのですが、せっかくですからスケールの変換をすると共に分析で役に立つ列を追加しておきましょう。この章で登場するコードは全て以下のコードのmutate関数の括弧内にコンマで列挙して記述するものです。mutate関数が二回登場するのは想定と異なる挙動を示すのを防ぐためのものです。本章末尾に完成形のコードを記載しておきますので、説明はいらないという方はそこまで飛ばしていただいてかまいません。

data %>% 
  mutate() %>% 
  mutate() ->data

フィート→インチ変換

sz_top=12*sz_top
sz_bot=12*sz_bot
pfx_x=12*pfx_x
pfx_z=12*pfx_z
vx0=12*vx0
vy0=12*vy0
vz0=12*vz0
ax=12*ax
ay=12*ay
az=12*az
plate_x=12*plate_x
plate_z=12*plate_z

 フィートで表される数値をインチに変換するために単純に12倍しています。単位をセンチメートルにしたい場合は12ではなく30.48にしましょう。


バレルゾーンに入ったかのフラグ

barrel=case_when(launch_speed_angle==6~1,launch_speed_angle>=1|launch_speed_angle<=5~0)

 スタットキャストでは打球速度角度から、打球の質を6種類に分けています。これがlaunch_speed_angleという列に数字として納められており、6が最も価値の高い「バレル」と呼ばれる打球を表しています。元々はこの列はわざわざ自分で作らなくてもダウンロードしたデータに最初からあったのですが、知らない間になくなっていました。もしかしたら復活しているかもしれません。

case_when関数の中身は、「launch_speed_angleが6の時はbarrel=1,launch_speed_angleが1以上かつ5以下の時は0」という意味です。


打席ごとの固有IDの生成

PA_ID = paste(game_pk,at_bat_number,sep = "_")

 打席ごとの投球数を求めたり、カギとなった打席を識別できるようになったりします。

game_pkは試合ごとの固有ID、at_bat_numberはその試合での両チームの打者の打席に振られた通し番号です。paste関数が両者の間をアンダースコアで繋いでいます。

投球ごとの固有IDの生成

pitch_ID=paste(PA_ID,pitch_number,sep = "_")

 先ほど作った打席IDと、打席ごとに投球に振られた通し番号を同様にpaste関数で繋いでいます。


打者がスイングしたかどうかでフラグ作成

swing_flag=ifelse(description=="foul" |description=="swinging_strike" | description=="foul_tip "|description=="hit_into_play"|description=="swinging_strike_blocked",1,0)

 打者がバットを振ったかどうかを示す、0か1のどちらかの値を取る変数です。スイング率コンタクト率の計算などに役立ちます。

「プレーの種類(description)がファウルか空振りかファウルチップかインプレーの時は1,そうでなければ0」という意味のコードです。

ストライクゾーン内かの判別

zone_flag=ifelse(plate_z-sz_bot>=0&sz_top-plate_z>=0&plate_x<=10&plate_x>=-10,1,0)

 ストライクゾーンの定義を基に、ゾーン内か否かを決定する変数です。ゾーン率やZ-○○%、O-まるまる%系の指標の計算に役立ちます。

「ホームベース通過時点のボールの鉛直方向の座標(plate_z)がストライクゾーン上限(sz_top)から下限(sz_bot)に幅に収まっていて、水平方向の座標(plate_x)がベースの中心から横に10インチ以内にあれば1、そうでなければ0」という意味のコードです。

バットに当たったかの判別

contact_flag=ifelse(description=="foul"|description=="hit_into_play",1,0)

 打者がバットにボールを当てたら1となるフラグです。コンタクト率などの計算に役立ちます。

「先述のdescriptionがインプレー打球かファウルなら1、それ以外なら0」というコードです。ファウルチップの扱いを空振りとしていますが、あれは空振り扱いでいいのでしょうか。

試合が行われた月を切り出し

game_month=str_split(game_date,"-",simplify = TRUE)[,2] %>% as.double()

 試合が何月に行われたかを示す変数です。月別成績の計算になくてはならないものです。なぜこんな大事なものがこんな後の方かって?私が手元のコードに追加したのが最近だからですよ。

「2023-03-19のような試合があった年月日を記録している変数(game_date)をハイフンで分割し、2番目を取り出す」というコードです。as.double関数がついているのは適当です。この処理は時間データの処理に特化したlubridateパッケージの関数群でもっとわかりやすく書けるようですが、調べるのが面倒だったため普通の文字列処理で行っています。

Attack Regionの定義(2023/08/05追記)

 Attack Regionというのは、下の図のように「ストライクかボールか」ということよりも具体的に投球位置を区分したものです。真ん中から順に「Heart」「Shadow」「Chase」「Waste」の4つがあります。

Tom Tango氏のウェブサイトより

これを定義するには二つのステップを踏みます。一つでいけるコードを思いついた方はお知らせください。まず、縦と横それぞれに上図の定義を用いて番号を振ります。

num_hor=case_when(abs(plate_x)<6.7~1,abs(plate_x)>=6.7&abs(plate_x)<13.3~2,abs(plate_x)>=13.3&abs(plate_x)<20~3,abs(plate_x)>=20~4)
num_var=case_when(abs(plate_z-30)<8~1,abs(plate_z-30)>=8&abs(plate_z-30)<16~2,abs(plate_z-30)>=16&abs(plate_z-30)<24~3,abs(plate_z-30)>=24~4)

次に、これらを合わせてAttack Regionを求めます。数字が大きい方を優先するというコードですね。

attack_region=if_else(num_hor>num_var,num_hor,num_var)

全体像(2023/08/05加筆)

以下が本章のコードの全体像です。適当にコピペして利用するなり眺めて非効率な点を発見して笑うなりしてください。

data %>% 
  mutate(sz_top=12*sz_top,sz_bot=12*sz_bot,pfx_x=12*pfx_x,pfx_z=12*pfx_z,vx0=12*vx0,vy0=12*vy0,vz0=12*vz0,ax=12*ax,ay=12*ay,az=12*az,plate_x=12*plate_x,plate_z=12*plate_z,barrel=case_when(launch_speed_angle==6~1,launch_speed_angle>=1|launch_speed_angle<=5~0),PA_ID = paste(game_pk,at_bat_number,sep = "_")) %>% 
  mutate(pitch_ID=paste(PA_ID,pitch_number,sep = "_"),swing_flag=ifelse(description=="foul" |description=="swinging_strike" | description=="foul_tip "|description=="hit_into_play"|description=="swinging_strike_blocked",1,0),zone_flag=ifelse(plate_z-sz_bot>=0&sz_top-plate_z>=0&plate_x<=10&plate_x>=-10,1,0),contact_flag=ifelse(description=="foul"|description=="hit_into_play",1,0),game_month=str_split(game_date,"-",simplify = TRUE)[,2] %>% as.double(),num_hor=case_when(abs(plate_x)<6.7~1,abs(plate_x)>=6.7&abs(plate_x)<13.3~2,abs(plate_x)>=13.3&abs(plate_x)<20~3,abs(plate_x)>=20~4),num_var=case_when(abs(plate_z-30)<8~1,abs(plate_z-30)>=8&abs(plate_z-30)<16~2,abs(plate_z-30)>=16&abs(plate_z-30)<24~3,abs(plate_z-30)>=24~4)) %>%
  mutate(attack_region=if_else(num_hor>num_var,num_hor,num_var))->data

データを加工しよう②

 役に立つ列がいくつか追加できたことですし、このままデータを保存してもいいのですが(二回目)、どうせならいらない列を削除して少しでもファイル容量を軽くしてから保存したいですよね。そこで、すべての行で空なのになぜか不親切なことに削除されていない列などを消してから保存しましょう。

usecol<-c("pitch_type","game_date","game_month","release_speed","release_pos_x","release_pos_z","batter","pitcher","events","description","zone","des","game_type","stand","p_throws","home_team","away_team","type","hit_location","bb_type","balls","strikes","game_year","pfx_x","pfx_z","plate_x","plate_z","on_3b","on_2b","on_1b","outs_when_up","inning","inning_topbot","hc_x","hc_y","vx0","vy0","vz0","ax","ay","az","sz_top","sz_bot","hit_distance_sc","launch_speed","launch_angle","effective_speed","release_spin_rate","release_extension","game_pk","fielder_2","fielder_3","fielder_4","fielder_5","fielder_6","fielder_7","fielder_8","fielder_9","release_pos_y","estimated_ba_using_speedangle","estimated_woba_using_speedangle","woba_value","woba_denom","babip_value","iso_value","launch_speed_angle","at_bat_number","pitch_number","pitch_name","home_score","away_score","bat_score","fld_score","post_away_score","post_home_score","post_bat_score","post_fld_score","if_fielding_alignment","of_fielding_alignment","spin_axis","delta_home_win_exp","delta_run_exp","barrel","PA_ID","pitch_ID","swing_flag","zone_flag","contact_flag","num_var","num_hor","attack_region")
as.data.frame(data) %>%   select(all_of(usecol))->data

 説明は面倒なので省きます。もしご自分でこのほかの列を定義した場合はそれを追加するのをお忘れなく。as.data.frame関数に包んでいるのは、データのクラス関連でエラーが出たことがあったためその対策だと思います。具体的にどんなエラーだったかをよく覚えていないので根本的な解決策を示すことはできません。申し訳ないです。

データを保存しよう①

 いい感じに様々な分析に対応可能な形にデータを加工したら、次にデータの保存ですね。これにはいくつか方法がありますが、ここでは最も有力な方法と僕がおすすめした方法の2つを紹介します。

CSV形式での保存

 これは極めて一般的で有力な方法で、誰でも特に事前の用意なくできるのでとても簡単です。私はこの後紹介する2つ目の方法に慣れて久しいのであまりこの方法は使わなくなりましたが、早速実際のコードを見てみましょう。

data %>% write_csv("ファイル名.csv")

 簡単ですね。CSVという極めて一般的な形式であることにより出力されたデータの汎用性が非常に高いことも魅力です。ただ、シーズン全試合の全球などのようにあまり大きいサイズになると少し次回読み込み時に大変になりそうです。そこで登場するのが2つ目の方法です。

データベースでの保存

  MySQLなどのデータベースがどんなものかご存知でしょうか。私は正直よく知りません。知らないなりに説明すると、なんかデータが格納できる便利なやつです。セットアップで苦労した記憶がありますが、そこはたぶんググったらなんとかなる範疇だと思います。困ったらBingのAIにでも聞いてみましょう。

 さて、実際にやってみましょう。今回はMySQLを使います。SQLiteなどほかのサービスを使いたいという人はたぶん大枠は変わらないので適宜応用してください。まず、接続するものを作ります。

library(RMySQL)
conn <- dbConnect(MySQL(),dbname="データベース名",user="root",password="パスワード")

 これができればあとはデータを書き出すだけなのですが、ここで注意点。(MySQL以外でどうなのかは知りませんが)local_infileという変数を1に設定しないとデータが書き込めません。

dbSendQuery(conn,"set global local_infile=0;") #筆者の環境では単にlocal_infile=1では上手く行かなかったため、一旦0を代入することで確実にlocal_infile=1になるようにしている。
dbSendQuery(conn,"set global local_infile=1;")

 これであとは保存するだけです。

dbWriteTable(conn,name="テーブル名",value = data,append=TRUE,row.names=FALSE)

 さて、この形で保存することにどんなメリットがあるのでしょうか?二つあるメリットの一つ目は、読み込み時の便利さです。この言葉だけではかなり抽象的ですので、具体的に説明したいと思います。

 データをCSVファイルから読み込む際のコードは以下の通り。

data<-read_csv("ファイル名.csv")

 うん、普通ですね。少なくともRをある程度使ったことのある方には普通の光景だと思います。しかし、これだと1シーズンの全試合データから大谷翔平選手が投げているシーンだけを抜き出して読み込む、などの操作ができません。この場合だと不要なデータを読み込むことで必要以上にメモリが消費されてしまいます。対策するには分析対象が変わるごとにデータをダウンロードするなどの方法がありますが、いずれも手間ですしストレージももったいないですよね。

 この問題を解決するのがデータベースでの保存。テーブルから必要なデータだけを読み込んだり、何なら集計したものを読み込むこともできます。例えば、2022年の全試合のデータからダルビッシュ有投手の登板だけを読み込むにはこんな感じ。

tbl(conn,"テーブル名") %>% filter(pitcher==506433) %>% collect()->darvish2022 

 このコードでは何が行われているかというと、tbl関数でテーブルを読み込む準備をして、filter関数で絞り込み、そしてcollect関数で前の2つの関数によるコードをSQLに変換してデータベース上で実行しデータを取り出す、という作業です。tbl関数とcollect関数の間には様々な関数を使った処理を記述でき、一度全体のデータを保存したらあとは好きなように切り出せるのでとても便利です。

 先ほど述べたように、集計したものを出力することもできます。

tbl(conn,"テーブル名") %>% filter(pitcher==506433) %>% summerise(N=n(),.by=pitch_type) %>% mutate(pitch_percent=N/sum(N)) %>% collect() -> darvish_pitch_percent

 こちらのコードはダルビッシュ有投手の2022年の球種別投球割合を求めるコードです。これも、元データの全体はおろかダルビッシュ投手の投球のデータすらメモリには読み込まずに処理できます。メモリを占有するのは集計結果の入っている小さな表データだけです。

 このように読み込み時にメモリ使用が減るのが利点の一つ目です。

データを保存しよう②

データベースへの保存って面倒じゃね?

 先ほど挙げたメリットとなる「全体のデータをデータベースのテーブルに入れておけばそこから必要なものだけを切り出せる」は、全体のデータをデータベースに保存できていることを前提としています。でも、一年分のデータをまとめてダウンロードするのは正直に言って時間と手間がかかります。

 そこで、毎日や毎週などの小さな時間単位で定期的にデータをダウンロードして保存してしまおう、というのが本章の内容です。

ここまでのコードのおさらい(2023/08/05加筆)

#パッケージ読み込み
library(tidyverse)
library(baseballr)
library(RMySQL)

#データベースコネクタと関数の定義
conn <- dbConnect(MySQL(),dbname="データベース名",user="root",password="パスワード")
Unzip <- function(...) rbind(data.frame(), ...)

#取得するデータの日付
dates <- seq.Date(as.Date("任意の日付"),
                  as.Date("任意の日付"), by = 'day')

date_grid <- tibble(start_date = dates, 
                    end_date = dates)

#データ取得
data1<-map(.x = seq_along(date_grid$start_date), 
           ~{message(paste0('\nScraping day of ', date_grid$start_date[.x], '...\n'))
             
             scrape_statcast_savant(start_date = date_grid$start_date[.x],
                                    end_date = date_grid$end_date[.x])
           }
           
)
data <- do.call(Unzip, data1)

#データ加工
data %>% 
  mutate(sz_top=12*sz_top,sz_bot=12*sz_bot,pfx_x=12*pfx_x,pfx_z=12*pfx_z,vx0=12*vx0,vy0=12*vy0,vz0=12*vz0,ax=12*ax,ay=12*ay,az=12*az,plate_x=12*plate_x,plate_z=12*plate_z,barrel=case_when(launch_speed_angle==6~1,launch_speed_angle>=1|launch_speed_angle<=5~0),PA_ID = paste(game_pk,at_bat_number,sep = "_")) %>% 
  mutate(pitch_ID=paste(PA_ID,pitch_number,sep = "_"),swing_flag=ifelse(description=="foul" |description=="swinging_strike" | description=="foul_tip "|description=="hit_into_play"|description=="swinging_strike_blocked",1,0),zone_flag=ifelse(plate_z-sz_bot>=0&sz_top-plate_z>=0&plate_x<=10&plate_x>=-10,1,0),contact_flag=ifelse(description=="foul"|description=="hit_into_play",1,0),game_month=str_split(game_date,"-",simplify = TRUE)[,2] %>% as.double(),num_hor=case_when(abs(plate_x)<6.7~1,abs(plate_x)>=6.7&abs(plate_x)<13.3~2,abs(plate_x)>=13.3&abs(plate_x)<20~3,abs(plate_x)>=20~4),num_var=case_when(abs(plate_z-30)<8~1,abs(plate_z-30)>=8&abs(plate_z-30)<16~2,abs(plate_z-30)>=16&abs(plate_z-30)<24~3,abs(plate_z-30)>=24~4)) %>%
  mutate(attack_region=if_else(num_hor>num_var,num_hor,num_var))
  mutate(attack_region=case_when(attack_region==1~"Heart",attack_region==2~"Shadow",attack_region==3~"Chase",attack_region==4~"Waste"))->datadbSendQuery(conn,"set global local_infile=0;")

#データ保存
dbSendQuery(conn,"set global local_infile=1;")
usecol<-c("pitch_type","game_date","game_month","release_speed","release_pos_x","release_pos_z","batter","pitcher","events","description","zone","des","game_type","stand","p_throws","home_team","away_team","type","hit_location","bb_type","balls","strikes","game_year","pfx_x","pfx_z","plate_x","plate_z","on_3b","on_2b","on_1b","outs_when_up","inning","inning_topbot","hc_x","hc_y","vx0","vy0","vz0","ax","ay","az","sz_top","sz_bot","hit_distance_sc","launch_speed","launch_angle","effective_speed","release_spin_rate","release_extension","game_pk","fielder_2","fielder_3","fielder_4","fielder_5","fielder_6","fielder_7","fielder_8","fielder_9","release_pos_y","estimated_ba_using_speedangle","estimated_woba_using_speedangle","woba_value","woba_denom","babip_value","iso_value","launch_speed_angle","at_bat_number","pitch_number","pitch_name","home_score","away_score","bat_score","fld_score","post_away_score","post_home_score","post_bat_score","post_fld_score","if_fielding_alignment","of_fielding_alignment","spin_axis","delta_home_win_exp","delta_run_exp","barrel","PA_ID","pitch_ID","swing_flag","zone_flag","contact_flag","num_hor","num_var","attack_region")
as.data.frame(data) %>% 
  select(all_of(usecol))->data
dbWriteTable(conn,name="テーブル名",value = data,append=TRUE,row.names=FALSE)

毎日/毎週データ取得をしよう

 定期的にデータ取得をすると短い時間を小刻みに取るだけで済みます。ただ、手動でやると忘れそうですよね。自動化しちゃいましょう。ここではコマンドプロンプトとタスクスケジューラを使います。以下の記事がわかりやすく、実際に僕も自動化する時に参考にしたのでこちらをお読みください。

 上の記事を読んで作業をした方は、これでたぶんスタットキャストデータを毎日もしくは毎週実行できるようになっているはずです。

注意点

 データは試合の次の日にならないとアップロードされないようなので、毎日取得される方で日本時間で生活している方はデータ取得部分を以下のコードにしてください。

scrape_statcast_savant(Sys.Date()-2,Sys.Date()-2)->data

 これで毎日問題なくデータ取得ができるはずです。(少なくとも毎日日本時間17:30にコードを実行する設定にしている僕はできています。)

ENJOY!!

 最後までお読みいただきありがとうございました。私がRでスタットキャストデータを触り始めた時にこんな記事があってほしかったなと思いながら書きましたので、皆さんの野球データ分析ライフが少しでもいいものになったらとても嬉しいです。

 今後もより多くの人の野球観戦が楽しくなるような記事を出していきますので、もしよければスキフォローTwitterのフォローもよろしくお願いします。それではまた次回の記事でお会いしましょう。


この記事が参加している募集

野球が好き

この記事が気に入ったらサポートをしてみませんか?