← 記事一覧へ

このリポジトリに捕手を扱った記事がありませんでした.

フレーミング (際どい球をストライクに見せる技術) は, 「調整しないと順位が意味を持たない」ことがはっきり出る題材です. 見逃しストライク率をそのまま並べると, 捕手の技術ではなく 「その捕手が受けた投手がどこに投げたか」を測ってしまいます.

先に結論です. 単純な見逃しストライク率で作った上位10人のうち, 位置を調整した後も上位10人に残るのは2人だけでした. 調整後の1位が誰かは下で名前を挙げます.

library(data.table)
library(dplyr)
library(ggplot2)
library(mgcv)
library(knitr)

SEASON <- 2024
src <- sprintf("../../data/statcast_%d_all.csv", SEASON)
if (!file.exists(src)) {
  stop("データがありません: ", src,
       "\n  Rscript data/build-statcast.R ", SEASON, " --all-pitch-types")
}
dat <- fread(src, select = c("description", "fielder_2", "pitcher",
                             "plate_x", "plate_z", "balls", "strikes",
                             "stand", "p_throws", "sz_top", "sz_bot",
                             "delta_run_exp"))

見逃した球だけを見る

フレーミングが働くのは打者が振らなかった球だけです. 振った球の判定に捕手は関与しません.

cd <- dat[description %in% c("called_strike", "ball")]
n_before <- nrow(cd)
cd <- cd[!is.na(plate_x) & !is.na(plate_z) & !is.na(sz_top) & !is.na(sz_bot)]
n_drop <- n_before - nrow(cd)

cd[, strike := as.integer(description == "called_strike")]

2024年の見逃しは351,858球です (位置の欠測136球を除外). うち 33%がストライクと判定されました.

縦の位置は打者によってゾーンの高さが違うので, 打者ごとに正規化します.

# sz_top / sz_bot はその打者のストライクゾーンの上端・下端 (フィート).
# -1 が下端, +1 が上端になるように直す. こうしないと, 背の高い打者を
# 多く受けた捕手が「高めを取っている」ように見えてしまう.
cd[, z := (plate_z - (sz_top + sz_bot) / 2) / ((sz_top - sz_bot) / 2)]
# 捕手の名前を引く。Statcast の player_name は **投手の名前** なので使えない
# (Savant の CSV は投手視点で出力される)。fielder_2 は MLBAM の数値 ID なので
# data/derived/mlbam-names.csv で名前に直す。
names_file <- "../../data/derived/mlbam-names.csv"
if (!file.exists(names_file)) {
  stop("ありません: ", names_file,
       "\n  Rscript data/build-derived.R mlbam-names")
}
mlbam <- fread(names_file)
who <- function(id) mlbam[match(as.integer(as.character(id)), key_mlbam), name]

cd[, `:=`(cat   = factor(fielder_2),
          pit   = factor(pitcher),
          cnt   = factor(paste0(balls, "-", strikes)),
          stand = factor(stand),
          thr   = factor(p_throws))]

捕手は100人, 投手は854人です.

一番弱いベースライン: 単純な見逃しストライク率

まず何も調整せずに並べます.

MIN_CALLED <- 1000
naive <- cd[, .(受球 = .N, CS率 = mean(strike)), by = cat][受球 >= MIN_CALLED]
naive[, 捕手 := who(cat)]
setorder(naive, -CS率)

1,000球以上を受けた77人が対象です.

rbind(head(naive, 5), tail(naive, 5))[
  , .(捕手, 受球, CS率 = round(CS率, 3))] |>
  kable(format.args = list(big.mark = ","))
捕手 受球 CS率
Reese McGuire 2,893 0.373
René Pinto 1,055 0.366
Patrick Bailey 7,801 0.358
Jake Rogers 5,723 0.354
Sean Murphy 4,615 0.352
Brian Serven 1,455 0.309
Mitch Garver 1,478 0.304
Korey Lee 7,525 0.301
Martín Maldonado 3,410 0.301
Omar Narváez 1,676 0.299

上下の差は7.4ポイントあります. 大きく見えますが, この差の大半は捕手の技術ではありません.

制球の良い投手を受ければゾーンの中に多く来るのでストライク率は上がります. 逆に荒れる投手を受ければ下がります. 捕手は投手を選べません.

位置だけのモデルをベースラインにする

投球位置だけからストライク確率を推定します. ここで説明できるぶんは, 捕手の手柄ではありません.

m_loc <- bam(strike ~ te(plate_x, z, k = c(12, 12)),
             data = cd, family = binomial, discrete = TRUE)

推定されたゾーンを見ます.

grid <- CJ(plate_x = seq(-1.6, 1.6, length.out = 120),
           z       = seq(-2.2, 2.2, length.out = 120))
grid[, p := predict(m_loc, grid, type = "response")]

ggplot(grid, aes(plate_x, z, fill = p)) +
  geom_raster() +
  scale_fill_viridis_c(name = "ストライク確率", limits = c(0, 1)) +
  annotate("rect", xmin = -0.83, xmax = 0.83, ymin = -1, ymax = 1,
           fill = NA, colour = "white", linewidth = 0.6) +
  coord_fixed(ratio = 0.83) +
  labs(x = "横位置 (フィート, 捕手から見て)",
       y = "縦位置 (1 = ゾーン上端, -1 = 下端)",
       title = "見逃した球のストライク確率") +
  theme_minimal()

白枠が規則上のゾーンです. 推定された確率はここに素直に乗っています.

念のため, 極端な位置での挙動を確かめます.

chk <- data.table(plate_x = c(0, 1.5, -1.5, 0, 0), z = c(0, 0, 0, 2.2, -2.2))
chk[, ストライク確率 := predict(m_loc, chk, type = "response")]
chk[, 場所 := c("ど真ん中", "外に大きく外れる", "内に大きく外れる",
                "高く大きく外れる", "低く大きく外れる")]
kable(chk[, .(場所, plate_x, z, ストライク確率 = round(ストライク確率, 4))])
場所 plate_x z ストライク確率
ど真ん中 0.0 0.0 0.9997
外に大きく外れる 1.5 0.0 0.0004
内に大きく外れる -1.5 0.0 0.0001
高く大きく外れる 0.0 2.2 0.0001
低く大きく外れる 0.0 -2.2 0.0000
p_center <- chk[1]$ストライク確率
p_far    <- max(chk[2:5]$ストライク確率)
fmt <- function(x, d = 4) format(round(x, d), scientific = FALSE, nsmall = d)

ど真ん中は0.9997, 大きく外れた球は0.0004以下でした. 「中央は0.95以上, ゾーン外に十分離れた点は0.05以下」という目安を どちらも満たしています (OK).

捕手・投手・カウントを入れる

位置に加えて, カウント・打者の左右・投手の利き腕を調整し, 捕手と投手を変量効果にします. 変量効果にすると, 受球数の少ない捕手の 推定が全体平均のほうへ縮み, 少数の観測で上位に来ることを防げます.

m_adj <- bam(strike ~ te(plate_x, z, k = c(12, 12)) + cnt + stand + thr +
               s(cat, bs = "re") + s(pit, bs = "re"),
             data = cd, family = binomial, discrete = TRUE)
re_cat <- coef(m_adj)[grep("^s\\(cat\\)", names(coef(m_adj)))]

捕手効果の合計は6.4e-10で, 変量効果の制約どおりほぼ0です (|合計| < 1e-6 を満たす). 標準偏差は0.166 (ロジット) でした.

ストライク1つは何点か

得点価値は決め打ちにせず, データから出します. delta_run_exp は各投球の得点期待値の変化 (打者側から見た値) なので, 同じカウントでの「見逃しストライク」と「ボール」の差がストライクの価値です.

rv <- cd[!is.na(delta_run_exp),
         .(ストライク = mean(delta_run_exp[strike == 1]),
           ボール     = mean(delta_run_exp[strike == 0])), by = cnt]
rv[, 差 := ストライク - ボール]
w  <- cd[, .N, by = cnt]
RUNS_PER_STRIKE <- -merge(rv, w, by = "cnt")[, sum(差 * N) / sum(N)]

merge(rv, w, by = "cnt")[order(cnt)] |>
  kable(digits = 4, format.args = list(big.mark = ","),
        col.names = c("カウント", "ストライク", "ボール", "差", "球数"))
カウント ストライク ボール 差 球数
0-0 -0.0399 0.0359 -0.0758 120,399
0-1 -0.0562 0.0261 -0.0822 44,871
0-2 -0.1668 0.0218 -0.1886 21,875
1-0 -0.0491 0.0615 -0.1106 37,473
1-1 -0.0608 0.0506 -0.1115 30,963
1-2 -0.1894 0.0389 -0.2284 27,167
2-0 -0.0606 0.1135 -0.1742 12,603
2-1 -0.0730 0.1055 -0.1784 14,727
2-2 -0.2281 0.0977 -0.3258 19,572
3-0 -0.0687 0.1344 -0.2030 6,075
3-1 -0.0823 0.2018 -0.2841 6,440
3-2 -0.3247 0.2888 -0.6136 9,693

カウントによって大きく違います (3-2 では0.61点, 0-0 では 0.076点). 球数で加重した平均は 1ストライクあたり0.1448点 (守備側の節約) でした.

3つの並べ方を比べる

cd[, p_loc  := predict(m_loc, type = "response")]
cd[, p_with := predict(m_adj, type = "response", exclude = "s(pit)")]
cd[, p_wo   := predict(m_adj, type = "response",
                       exclude = c("s(pit)", "s(cat)"))]

res <- cd[, .(受球 = .N,
              CS率 = mean(strike),
              位置調整 = (sum(strike) - sum(p_loc)) * RUNS_PER_STRIKE,
              フル調整 = sum(p_with - p_wo) * RUNS_PER_STRIKE),
          by = cat][受球 >= MIN_CALLED]
res[, 捕手 := who(cat)]
sp_naive <- cor(res$CS率, res$フル調整, method = "spearman")
sp_loc   <- cor(res$位置調整, res$フル調整, method = "spearman")
並べ方 フル調整との Spearman
単純な見逃しストライク率 0.383
位置だけを調整した得点 0.964

ほとんどの仕事を位置の調整がしています. カウント・左右・投手を足しても順位は 0.96 の一致で, ほぼ動きません. 一方, 何も調整しない見逃しストライク率は 0.38 しか一致せず, 別の指標になっています.

順位がどう入れ替わるかを直接見ます.

res[, `:=`(順位_CS率 = frank(-CS率), 順位_調整後 = frank(-フル調整))]
moved <- res[順位_CS率 <= 10][order(順位_CS率)]
moved[, .(捕手, 受球, CS率 = round(CS率, 3),
          順位_CS率, 順位_調整後,
          フル調整 = round(フル調整, 1))] |>
  kable(format.args = list(big.mark = ","),
        col.names = c("捕手", "受球", "CS率", "CS率での順位", "調整後の順位",
                      "調整後(点)"))
捕手 受球 CS率 CS率での順位 調整後の順位 調整後(点)
Reese McGuire 2,893 0.373 1 20 2.5
René Pinto 1,055 0.366 2 46 -0.7
Patrick Bailey 7,801 0.358 3 1 22.9
Jake Rogers 5,723 0.354 4 3 14.1
Sean Murphy 4,615 0.352 5 24 1.5
Adrian Del Castillo 1,415 0.349 6 40 -0.2
William Contreras 8,606 0.347 7 14 3.9
Dillon Dingler 1,667 0.345 8 32 0.6
Travis d’Arnaud 5,629 0.344 9 39 0.0
Iván Herrera 3,694 0.344 10 37 0.2

単純な見逃しストライク率の上位10人のうち, 調整後も上位10位以内に残るのは2人だけです. 最も下がった人は46位まで落ちました (77人中).

ggplot(res, aes(CS率, フル調整)) +
  geom_hline(yintercept = 0, colour = "grey70") +
  geom_point(alpha = 0.7, colour = "steelblue") +
  labs(x = "単純な見逃しストライク率", y = "調整後のフレーミング得点 (点)",
       title = "単純な率と調整後の得点は結びつかない") +
  theme_minimal()

調整後の順位

setorder(res, -フル調整)
rbind(head(res, 8), tail(res, 5))[
  , .(捕手, 受球, CS率 = round(CS率, 3),
      位置調整 = round(位置調整, 1), フル調整 = round(フル調整, 1))] |>
  kable(format.args = list(big.mark = ","),
        col.names = c("捕手", "受球", "CS率", "位置調整(点)", "フル調整(点)"))
捕手 受球 CS率 位置調整(点) フル調整(点)
Patrick Bailey 7,801 0.358 25.2 22.9
Cal Raleigh 8,371 0.339 15.7 18.1
Jake Rogers 5,723 0.354 11.2 14.1
Austin Wells 7,301 0.324 14.9 13.0
Christian Vázquez 5,788 0.336 10.8 12.2
Alejandro Kirk 6,251 0.343 14.6 11.9
Jose Trevino 4,818 0.328 13.1 11.9
Bo Naylor 7,196 0.338 12.5 10.2
J.T. Realmuto 6,894 0.336 -10.0 -7.1
Luis Campusano 5,391 0.320 -8.3 -7.6
Korey Lee 7,525 0.301 -9.2 -8.0
Connor Wong 6,956 0.339 -9.9 -8.6
Will Smith 8,537 0.327 -10.4 -9.4
best  <- res[which.max(フル調整)]
worst <- res[which.min(フル調整)]

2024年で最も得をさせた捕手は Patrick Bailey です. 7,801球を受けて 22.9点を稼ぎました. 逆に最も損をさせたのは Will Smith で -9.4点です.

幅はおよそ32点で, 1勝がおよそ10点なので 上下で3勝ぶんほどの差になります.

Patrick Bailey は単純な見逃しストライク率でも 3位なので, この人については どちらの測り方でも上位です. 順位が入れ替わるのは中位から下です.

CS率と調整後の得点が食い違う例を見ておきます.

res[, ずれ := 順位_調整後 - 順位_CS率]
rbind(res[order(ずれ)][1:3], res[order(-ずれ)][1:3])[
  , .(捕手, 受球, CS率 = round(CS率, 3), 順位_CS率, 順位_調整後,
      フル調整 = round(フル調整, 1))] |>
  kable(format.args = list(big.mark = ","),
        col.names = c("捕手", "受球", "CS率", "CS率での順位", "調整後の順位",
                      "調整後(点)"))
捕手 受球 CS率 CS率での順位 調整後の順位 調整後(点)
Austin Wells 7,301 0.324 52 4 13.0
Andrew Knizner 1,962 0.321 59 19 2.5
Chuckie Robinson 1,741 0.312 71 31 0.8
Connor Wong 6,956 0.339 17 76 -8.6
J.T. Realmuto 6,894 0.336 25 73 -7.1
René Pinto 1,055 0.366 2 46 -0.7

CS率が低いのに調整後は上位に来る捕手がいます. 荒れる投手を多く受けていた, ということです.

推定のばらつき

点推定だけを並べるのは不誠実なので, 各捕手の区間を出します. 捕手の変量効果 b の標準誤差から, デルタ法で得点の標準誤差に移します.

V  <- vcov(m_adj)
ix <- grep("^s\\(cat\\)", rownames(V))
se_b <- sqrt(diag(V)[ix])
names(se_b) <- levels(cd$cat)

# 得点は b について局所的に線形: d(runs)/db = RUNS_PER_STRIKE * sum p(1-p)
slope <- cd[, .(s = sum(p_with * (1 - p_with))), by = cat]
res2  <- merge(res, slope, by = "cat")
res2[, se := RUNS_PER_STRIKE * s * se_b[as.character(cat)]]
res2[, `:=`(lo = フル調整 - 1.96 * se, hi = フル調整 + 1.96 * se)]
res2[, 区間が0を跨ぐ := lo < 0 & hi > 0]
setorder(res2, フル調整)
res2[, i := .I]
# 上下5人だけ名前を出す (全員だと読めない)
res2[, ラベル := fifelse(i > nrow(res2) - 5 | i <= 5, 捕手, NA_character_)]

ggplot(res2, aes(フル調整, i)) +
  geom_vline(xintercept = 0, colour = "grey70") +
  geom_errorbarh(aes(xmin = lo, xmax = hi, colour = 区間が0を跨ぐ), height = 0) +
  geom_point(aes(colour = 区間が0を跨ぐ), size = 1.2) +
  geom_text(aes(label = ラベル), hjust = -0.15, size = 2.8, na.rm = TRUE) +
  scale_colour_manual(values = c("FALSE" = "firebrick", "TRUE" = "grey60"),
                      name = "0 を含む") +
  scale_x_continuous(expand = expansion(mult = c(0.05, 0.35))) +
  labs(x = "フレーミング得点 (95%区間)", y = "捕手 (得点順)") +
  theme_minimal() + theme(axis.text.y = element_blank())

77人のうち, 95%区間が0を跨がないのは27人です. 残りの50人は, このデータからは「平均と違う」と言えません.

順位表の中ほどを読んではいけない, ということです.

限界

  • 球審をモデルに入れていません. 公開されている Statcast には球審 ID が 入っていないためです (data/build-statcast.R のコメント参照). 原論文 (Deshpande & Wyner 2017) は球審を明示的に扱っており, ここの数値とは一致しません
  • 打者の姿勢や捕手の構えといったトラッキング由来の特徴も使っていません
  • 2024年の1シーズンだけです. 年をまたぐ再現性は見ていません
  • 変量効果による縮小は入れていますが, 捕手と投手の割り当ては ランダムではありません. 同じ投手を長く受けた捕手の効果は, その投手の効果と完全には分離できません
  • 最低受球数を1,000球で切っています. これを下げると, 少数の観測で極端な値を取る捕手が上位に現れます

まとめ

  • 見逃しストライク率の上下差は7.4ポイント あるが, その大半は投球位置の違い
  • 位置を調整すると順位は大きく入れ替わる (単純な率とのSpearman 0.38). 単純な率の上位10人のうち, 調整後も上位10人に残るのは2人
  • カウント・左右・投手をさらに足しても順位はほとんど動かない (Spearman 0.96). 効くのは位置の調整
  • ストライク1つの価値はカウント次第で 0.076点から0.61点まで違う. 加重平均は0.145点
  • 調整後の得点は上下でおよそ32点 (約3勝) の幅. ただし95%区間が0を跨がないのは77人中27人だけ
  • 2024年の1位は Patrick Bailey (22.9点), 最下位は Will Smith (-9.4点)