framingをGAMで考える

はじめに

これはスポーツアナリティクス Advent Calendar 2019 21日目の記事です。

これを読んでくださる方はSports Analyst Meetup(通称spoana)に来てくださった方が多いんですかね?
spoanaは来年も引き続き開催していきますので、来たことのある方もそうでない方もぜひご参加ください。

また、会場提供してくださる方も募集しております。よろしくお願いいたします。


framing(フレーミング)とは

さて、本題に戻ります。catcher framingの話です。
知らない方もいると思いますので、簡単に説明します。

framing(以下フレーミング)とは、ストライクゾーンギリギリの際どいボールを、捕球動作や捕球体勢などを工夫することによって審判に「ストライク」と判定させる捕球技術。

…ということを知ったうえで百聞は一見に如かず、コチラをご覧ください。


【MLB】フレーミング・世界トップ8 ~MLB Best Framing~


なんとなくご理解いただけましたでしょうか?

そもそもframing(frame)には「枠」「でっち上げる」という意味が含まれていますし、ぴったりな言葉ですよね。 *1


実際、先日DELTAさんが今年のNPB捕手を評価した際も評価指標としてフレーミングを使っていました。

1point02.jp

このテーマを選んだ理由


"投手、捕手、打者、球審"が絡む事象でおもしろそうだったから


捕手が球審の意思決定に影響を与えるかを考えるなんてワクワクするじゃん!と思って、フレーミングの動画をいろいろ見てたのですが、捕手単体というよりはバッテリーが球審に与える影響と考えた方が自然かなと思いました。


ストライクの定義は

「打者の肩の上部とユニフォームのズボンの上部との中間点に引いた水平のラインを上限とし、ひざ頭の下部のラインを下限とする本塁上の空間をいう。このストライクゾーンは打者が投球を打つための姿勢で決定されるべきである。」

球審はこの空間を一部分でも通過した(と判定した)投球をストライクと宣告する(べきな)ので、捕手の捕球には影響を受けないはず。にも関わらずフレーミングの影響は"ある"という先行研究が多いということは捕球だけでなく、投球軌道にも少なからず影響を受けているのではないか?


…ということで

フレーミングはバッテリーの共同作業か

という視点で分析していきたいと思います。

※投手と捕手、球審をそれぞれランダム効果として扱う研究はありましたが、投球軌道を加味している研究は見つからなかったので、その観点から分析してみます。

使用したデータ


2019年のMLBにおけるStatcastのデータを使います。

MLBの詳細なデータはNPBと違い、公開されていて簡単にアクセス可能になっています。

スクレイピングには私が作成した statcastrパッケージを使いました。
※install.packagesではなく、remotes::install_githubでパッケージをインストールしてください。

remotes::install_github("pontsuyu/statcastr")

分析手順

www.crcpress.com

コチラに載っているフレーミングの分析手順を参考にしています。

  1. ベースを通過したときの座標・投手の利き腕・打者位置を使って、フレーミングできたかをGAMでモデリング

  2. 1.の変数に、カウントやボールの変化量、球速(リリース時)などを加え同様にモデリング
    検索でヒットした研究では、分析手法としてGLMM、GAM、GAMM、階層ベイズを使っているものが多かったですが、今回は自分の勉強も兼ねてGA2Mを使ってみます。

    (参考記事)


3. 2.で得られた推定確率を使ってフレーミングした球に対し、その価値を以下の指標で評価してみます。

\sum{\{(1 - 推定確率)×フレーミング得点期待値\}}

集計

ストライクゾーン

2019年における各ゾーンのストライク判定確率はこんな感じでした。(捕手目線、単位はcm)

f:id:tsuyu_pon:20191221024452p:plain

利き腕と打者位置の左右が違うと外角が少し広いようです。
クロスファイヤーの影響ですかね。おもしろい。

フレーミング

f:id:tsuyu_pon:20191221095002p:plain

水色がストライクゾーン外なのにストライク判定された球です。
(描いたストライクゾーンは平均なので、一部、内に入って見えるものもあります)

ちなみに前田投手、田中投手、ダルビッシュ投手はこんな感じ。

f:id:tsuyu_pon:20191221105304p:plain

キャッチャー

フレーミング数降順に並べると

team catcher_name framing catch_N framing/N
MIL Yasmani Grandal 475 8347 5.69
PHI J.T. Realmuto 393 8737 4.50
SD Austin Hedges 382 6047 6.32
BOS Christian Vazquez 355 7598 4.67
MIA Jorge Alfaro 355 7456 4.76
CLE Roberto Perez 337 7138 4.72
TOR Danny Jansen 333 6691 4.98
NYM Wilson Ramos 331 8010 4.13
SF Buster Posey 318 6398 4.97
CHC Willson Contreras 313 6504 4.81
ATL Tyler Flowers 303 5289 5.73
STL Yadier Molina 288 7052 4.08
ARI Carson Kelly 279 5776 4.83
CIN Tucker Barnhart 276 5599 4.93
SEA Omar Narvaez 274 6238 4.39
WSH Yan Gomes 272 5777 4.71
HOU Robinson Chirinos 261 6926 3.77
NYY Gary Sanchez 260 5793 4.49
COL Tony Wolters 237 6574 3.61
BAL Pedro Severino 234 5967 3.92

GrandalやRealmuto、Hedgesはさすがですね。


ここからはデータ量を絞るため、フレーミング数TOP12人に絞って分析していきます。

分析

フレーミングはご覧いただいた通り、そうそう起きることではないので、不均衡データとなります。

not framing framing
81084(95.0%) 4183(5.0%)

そのため、upuraさんの記事を参考にダウンサンプリングして、10回モデリングし、予測結果を平均したものを最終的な推定結果としました。

upura.hatenablog.com


1. 投球座標・投手の利き腕・打者位置を使って、フレーミングできたかをGAMでモデリング

# remotes::install_github("pontsuyu/statcastr")
library(statcastr)
library(tidyverse)
library(mgcv)
library(broom)
library(ROCR)
(一部省略)
pred_res <- list()
temp <- dat %>% filter(framing_flg==1)
for(i in 1:10){
  temp2 <- dat %>% filter(framing_flg==0) %>% sample_n(NROW(temp))
  temp_dat <- rbind(temp, temp2)
  # GAM
  model1 <- gam(framing_flg ~ s(px) + s(pz) + s(px, pz) + p_throws + stand, data = temp_dat, family = binomial)
  print(summary(model1))
  # モデリングに使ったデータでpredict
  temp_pred <- predict(model1, type = "response")
  pred <- prediction(c(temp_pred), temp_dat$framing_flg)
  perf <- performance(pred, "auc")
  print(paste("AUC:", unlist(perf@y.values)))
  # 元データでpredict
  test_pred <- predict(model1, newdata = dat, type = "response")
  pred <- prediction(c(test_pred), dat$framing_flg)
  perf <- performance(pred, "auc")
  print(paste("test AUC:", unlist(perf@y.values)))
  pred_res <- c(pred_res, list(test_pred))
}
dat <- cbind(dat, pred1 = rowMeans(bind_cols(pred_res)))

(一部結果)

Family: binomial 
Link function: logit 

Formula:
framing_flg ~ s(px) + s(pz) + s(px, pz) + p_throws + stand

Parametric coefficients:
            Estimate Std. Error z value Pr(>|z|)    
(Intercept) -7.49555    0.55504 -13.505   <2e-16 ***
p_throwsR    0.12156    0.08251   1.473    0.141    
standR       0.03409    0.08547   0.399    0.690    
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

Approximate significance of smooth terms:
            edf Ref.df Chi.sq p-value    
s(px)     8.959  8.999  109.3  <2e-16 ***
s(pz)     8.958  8.999  147.2  <2e-16 ***
s(px,pz) 26.999 27.000  647.9  <2e-16 ***
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

R-sq.(adj) =  0.651   Deviance explained = 59.1%
UBRE = -0.4221  Scale est. = 1         n = 8366
[1] "AUC: 0.941452570708859"
[1] "test AUC: 0.942231714370219"

Family: binomial 
Link function: logit 

Formula:
framing_flg ~ s(px) + s(pz) + s(px, pz) + p_throws + stand

Parametric coefficients:
            Estimate Std. Error z value Pr(>|z|)    
(Intercept) -9.73758    0.73779 -13.198   <2e-16 ***
p_throwsR   -0.09243    0.08781  -1.053    0.293    
standR       0.07517    0.08817   0.853    0.394    
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

Approximate significance of smooth terms:
            edf Ref.df Chi.sq p-value    
s(px)     8.957  8.999  127.4  <2e-16 ***
s(pz)     8.974  8.999  138.5  <2e-16 ***
s(px,pz) 26.988 27.000  587.7  <2e-16 ***
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

R-sq.(adj) =  0.675   Deviance explained = 61.2%
UBRE = -0.45028  Scale est. = 1         n = 8366
[1] "AUC: 0.94688477872454"
[1] "test AUC: 0.941812682120984"

f:id:tsuyu_pon:20191221102814p:plain

投球位置だけでほぼ説明できてますね。
利き腕、打者位置もフレーミングTOP選手に絞ってるので、関係なくなってしまったのかもしれないです。


2. 1.の変数にカウントやボールの変化量、球速(リリース時)などを加え同様にモデリング

投球位置の横方向、縦方向は交互作用を考えないと角を表現しにくいのは直感的にわかりますが、カウントやボールの変化量、球速を加えるとどこまで考える必要があるのでしょうか…。

いろいろ値を変えながら、良さそうなものを人力で探すのもいいですが、いかんせん手間がかかります。

そこで、ここではGA2Mを使って、フレーミングに効いてくる交互作用項を探索してみたいと思います。

(参考記事)

blog.fiddler.ai

qiita.com

Rでも出来るようになったらしいのですが、上手くいかなかったのでPythonで実装しました。

import numpy as np
import pandas as pd
from interpret.glassbox import ExplainableBoostingClassifier
from interpret import show

r = np.arange(10) + 1
for i in r:
    dat = pd.read_csv("temp_dat"+str(i)+".csv") # ダウンサンプリングした10ファイル
    x = dat[['px','pz','henka_x','henka_z','p_throws','stand','strikes','balls','outs_when_up','release_speed']]
    y = dat['framing_flg']
    ebm = ExplainableBoostingClassifier(random_state=42, interactions=5)
    ebm.fit(x, y)
    explanation = ebm.explain_global()
    show(explanation)

これだけです。

(一部結果) f:id:tsuyu_pon:20191221104247p:plain f:id:tsuyu_pon:20191221104316p:plain f:id:tsuyu_pon:20191221104335p:plain f:id:tsuyu_pon:20191221104404p:plain f:id:tsuyu_pon:20191221104441p:plain

こんな感じに10ファイル分、特徴重要度や特徴の値がどの範囲だと影響を与えているのかがわかります。

ストライクは増えるほどフレーミングしにくく(ストライクをとりにくく)、
ボールは増えるほどフレーミングしやすく(ストライクをとりやすく)なってます。
漸減、漸増しているのはおもしろいですね。


この結果を使って、交互作用つきの変数選択をしていきたいと思います。
正直、GA2Mにおいてこういう使い方が合ってるのかはわかりません…。

変数選択

GA2Mの結果10個を眺めてみると投球位置(px, pz)は交互作用含め必須のようです。
先ほどのGAMの結果通り、これだけで十分なほど説明できるらしい。

次点でstrikes, px*p_throws, px*stand、つまり、

  • ストライクカウント
  • 利き腕と投球位置x(先述のクロスファイヤーの影響?)
  • 打者位置と投球位置x(打者位置ごとの外角の広さを表現?)

です。良さげです。

この結果を使ってもう一度GAMをやってみます。

pred_res <- list()
temp <- dat %>% filter(framing_flg==1)
for(i in 1:10){
  temp2 <- dat %>% filter(framing_flg==0) %>% sample_n(NROW(temp))
  temp_dat <- rbind(temp, temp2)
  # GAM
  model2 <- gam(framing_flg ~ s(px) + s(pz) + s(px, pz) + 
                  strikes + px:p_throws + px:stand,
                data = temp_dat, family = binomial)
  print(summary(model2))
  # モデリングに使ったデータでpredict
  temp_pred <- predict(model2, type = "response")
  pred <- prediction(c(temp_pred), temp_dat$framing_flg)
  perf <- performance(pred, "auc")
  print(paste("AUC:", unlist(perf@y.values)))
  # 元データでpredict
  test_pred <- predict(model2, newdata = dat, type = "response")
  pred <- prediction(c(test_pred), dat$framing_flg)
  perf <- performance(pred, "auc")
  print(paste("test AUC:", unlist(perf@y.values)))
  pred_res <- c(pred_res, list(test_pred))
}
dat<- cbind(dat, pred2 = rowMeans(bind_cols(pred_res)))
Family: binomial 
Link function: logit 

Formula:
framing_flg ~ s(px) + s(pz) + s(px, pz) + strikes + px:p_throws + px:stand

Parametric coefficients:
              Estimate Std. Error z value Pr(>|z|)    
(Intercept)  -9.330056   0.859293 -10.858  < 2e-16 ***
strikes      -0.363723   0.053159  -6.842  7.8e-12 ***
px:p_throwsL  0.704989   0.414163   1.702   0.0887 .  
px:p_throwsR  0.688740   0.414082   1.663   0.0963 .  
px:standR     0.033020   0.003843   8.592  < 2e-16 ***
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

Approximate significance of smooth terms:
            edf Ref.df Chi.sq p-value    
s(px)     7.803  7.979  118.5  <2e-16 ***
s(pz)     8.997  9.000  228.5  <2e-16 ***
s(px,pz) 26.994 27.000  646.3  <2e-16 ***
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

Rank: 49/50
R-sq.(adj) =  0.693   Deviance explained = 63.3%
UBRE = -0.47998  Scale est. = 1         n = 8366
[1] "AUC: 0.95357916784517"
[1] "test AUC: 0.946732555607097"

Family: binomial 
Link function: logit 

Formula:
framing_flg ~ s(px) + s(pz) + s(px, pz) + strikes + px:p_throws + px:stand

Parametric coefficients:
              Estimate Std. Error z value Pr(>|z|)    
(Intercept)  -7.474502   0.697652 -10.714  < 2e-16 ***
strikes      -0.329700   0.050758  -6.496 8.28e-11 ***
px:p_throwsL  1.436310   0.522935   2.747  0.00602 ** 
px:p_throwsR  1.424914   0.522947   2.725  0.00643 ** 
px:standR     0.033271   0.003727   8.928  < 2e-16 ***
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

Approximate significance of smooth terms:
            edf Ref.df Chi.sq p-value    
s(px)     7.155  7.774  94.19  <2e-16 ***
s(pz)     8.979  8.998 133.01  <2e-16 ***
s(px,pz) 27.000 27.000 556.22  <2e-16 ***
---
Signif. codes:  0***0.001**0.01*0.05 ‘.’ 0.1 ‘ ’ 1

Rank: 49/50
R-sq.(adj) =  0.662   Deviance explained = 60.3%
UBRE = -0.43774  Scale est. = 1         n = 8366
[1] "AUC: 0.945024111745396"
[1] "test AUC: 0.945746932789921"

AUCが先ほどより微増してますし、変数もこれで良さそうです。

この結果を使って評価していきます。

キャッチャー評価

3. 2.で得られた推定確率を使ってフレーミングした球に対し、その価値を以下の指標で評価

\sum{\{(1 - 推定確率)×フレーミング得点期待値\}}

フレーミング得点価値はコチラに載っていたものを使用します。

カウント フレーミング得点価値
0-0 0.080
0-1 0.092
0-2 0.199
1-0 0.112
1-1 0.117
1-2 0.241
2-0 0.156
2-1 0.098
2-2 0.339
3-0 0.173
3-1 0.251
3-2 0.590

このカウントからストライクを増やしたときの防いだ失点だと思ってください。

ランキング結果

team catcher_name sum_framing_run_value
MIL Yasmani Grandal 10.763427
SD Austin Hedges 10.378748
PHI J.T. Realmuto 9.944429
BOS Christian Vazquez 9.515722
SF Buster Posey 8.485142
TOR Danny Jansen 8.471106
NYM Wilson Ramos 7.579195
CLE Roberto Perez 7.285535
CHC Willson Contreras 6.980723
ATL Tyler Flowers 6.901930
MIA Jorge Alfaro 6.850372
STL Yadier Molina 6.316730

先ほどの順位はこちら

team catcher_name framing catch_N framing/N
MIL Yasmani Grandal 475 8347 5.69
PHI J.T. Realmuto 393 8737 4.50
SD Austin Hedges 382 6047 6.32
BOS Christian Vazquez 355 7598 4.67
MIA Jorge Alfaro 355 7456 4.76
CLE Roberto Perez 337 7138 4.72
TOR Danny Jansen 333 6691 4.98
NYM Wilson Ramos 331 8010 4.13
SF Buster Posey 318 6398 4.97
CHC Willson Contreras 313 6504 4.81
ATL Tyler Flowers 303 5289 5.73
STL Yadier Molina 288 7052 4.08

Grandalは不動の1位ですが、多少順位が変動してますね。

www.youtube.com

ちなみにバッテリーで見るとこんな感じ。

team pitcher_name catcher_name sum_framing_run_value
SD Joey Lucchesi Austin Hedges 1.8572258
BOS David Price Christian Vazquez 1.8165831
NYM Steven Matz Wilson Ramos 1.7600996
CHC Jon Lester Willson Contreras 1.7513555
MIL Zach Davies Yasmani Grandal 1.6911975
SF Madison Bumgarner Buster Posey 1.5669637
MIA Caleb Smith Jorge Alfaro 1.5570445
PHI Jake Arrieta J.T. Realmuto 1.5231500
TOR Marcus Stroman Danny Jansen 1.4430287
BOS Eduardo Rodriguez Christian Vazquez 1.4092936

www.youtube.com

まとめ

  • 先行研究とはちょっと違う視点でフレーミングを評価してみました
  • ダウンサンプリング + バギングをしてみました
  • GA2Mを使ってGAMの変数選択をしてみました
  • 引き続き、転職活動中です

課題

  • ダウンサンプリングのサンプリング方法や回数はまだまだ改良の余地はありそう
  • モデリングの速さを鑑みて、データをかなり絞ったので、絞らずやってみたい

なにか間違っているところがあればそっと教えてください。

それでは、良いお年を!!

*1:まさに心理学や経済学の世界におけるフレーミング効果(意思決定する際、情報の内容が同じであっても、問題や質問の提示方法によって結果が異なること)ですね