このリポジトリに捕手を扱った記事がありませんでした.
フレーミング (際どい球をストライクに見せる技術) は, 「調整しないと順位が意味を持たない」ことがはっきり出る題材です. 見逃しストライク率をそのまま並べると, 捕手の技術ではなく 「その捕手が受けた投手がどこに投げたか」を測ってしまいます.
先に結論です. 単純な見逃しストライク率で作った上位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 (ロジット) でした.
得点価値は決め打ちにせず, データから出します.
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点 (守備側の節約) でした.
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人は, このデータからは「平均と違う」と言えません.
順位表の中ほどを読んではいけない, ということです.
data/build-statcast.R のコメント参照). 原論文 (Deshpande
& Wyner 2017) は球審を明示的に扱っており,
ここの数値とは一致しません