制球が良い投手とは誰か. 四球率で並べるのが手軽ですが, 四球は結果です. 打者が振らなかったこと, 審判の判定, 際どい球を避けた配球が全部混ざっています.
測りたいのは「狙った場所に投げられたか」です. ところが狙った場所は データに入っていません. 見えるのは投げた場所だけです.
そこで, 見えない狙いを投球位置の分布から推定することにします. 同じ投手が同じ球種を同じ状況で投げた球を集めれば, その真ん中あたりが狙いで, そこからの散らばりが実行の誤差だろう, という考え方です.
library(data.table)
library(ggplot2)
library(dplyr)
MIN_CELL <- 100 # 1つの条件でこの球数以上あるものだけ使う
d <- fread("../../data/statcast_2024_all.csv", showProgress = FALSE)
# 追跡の外れ値を落とす. 本塁から左右3ft, 高さ-1〜6ft の外は捨てる
p <- d[!is.na(plate_x) & !is.na(plate_z) & pitch_type != "" &
abs(plate_x) < 3 & plate_z > -1 & plate_z < 6]
707,855球を使います (追跡の外れ値4,043球を除外).
カウントによって狙いは動きます. まずリーグ全体でどれだけ動くかを見ます.
p[, cnt := fifelse(strikes > balls, "投手有利",
fifelse(balls > strikes, "打者有利", "平行"))]
shift <- p[, .(mx = mean(plate_x), mz = mean(plate_z)), by = .(cnt, stand)]
| cnt | stand | mx | mz |
|---|---|---|---|
| 投手有利 | R | 0.310 | 2.207 |
| 平行 | R | 0.182 | 2.317 |
| 打者有利 | R | 0.129 | 2.337 |
| 投手有利 | L | -0.188 | 2.269 |
| 平行 | L | -0.141 | 2.354 |
| 打者有利 | L | -0.125 | 2.361 |
右打者に対して, 投手有利のカウントでは平均が高さ2.21 ft, 打者有利では2.34 ft です. 追い込むと低く, 外に投げます. 0.13 ft と幅は小さいものの, 向きは一貫しています.
この寄りを制球の悪さとして数えてはいけません. 狙って動かしているぶんです. リーグ全体の寄りを引いておきます.
p[shift, on = .(cnt, stand),
`:=`(cx = plate_x - i.mx, cz = plate_z - i.mz)]
条件は投手 × 球種 × 打者の左右とします. この単位で100球以上あるものだけ残します.
p[, cell := paste(pitcher, pitch_type, stand)]
big <- p[, .N, by = cell][N >= MIN_CELL]$cell
p <- p[cell %in% big]
2,358条件, 529人の投手が残りました.
ここが肝心です. 同じ球で狙いを推定し, 同じ球で誤差を測ってはいけません. 必ず「自分自身に近い」値が出て, 球数が少ない投手ほど良く見えます.
球を4つに分け, 一方で狙いを推定し, もう一方で誤差を測ります.
set.seed(1)
p[, fold := sample(rep_len(1:4, .N)), by = cell]
# fit 側で狙い所を k 個推定し, eval 側の球からその最寄りまでの距離を測る
err <- function(fit, ev, k) {
if (nrow(fit) < 5 * k || nrow(ev) < 3) return(NA_real_)
if (k == 1) {
ctr <- matrix(c(mean(fit$cx), mean(fit$cz)), nrow = 1)
} else {
km <- tryCatch(kmeans(as.matrix(fit[, .(cx, cz)]), centers = k, nstart = 5),
error = function(e) NULL)
if (is.null(km)) return(NA_real_)
ctr <- km$centers
}
dx <- outer(ev$cx, ctr[, 1], "-")
dz <- outer(ev$cz, ctr[, 2], "-")
sqrt(mean(apply(dx^2 + dz^2, 1, min))) # 最寄りの狙いまでの二乗平均平方根
}
command <- function(k, fit_folds, ev_folds) {
z <- p[, .(e = err(.SD[fold %in% fit_folds], .SD[fold %in% ev_folds], k), n = .N),
by = .(pitcher, cell), .SDcols = c("cx", "cz", "fold")]
z[!is.na(e), .(ctrl = weighted.mean(e, n), n = sum(n)), by = pitcher]
}
ctrl は狙いからの平均的なずれ (ft)
です. 小さいほど制球が良いことになります.
順位を出す前に, 同じ投手の別の球から作っても同じ値になるかを見ます. ならないなら, それは投手の性質ではなく, その日の偶然を測っています.
fold 1→2 で作った値と, fold 3→4 で作った値を突き合わせます.
rel <- function(k) {
A <- command(k, 1, 2); B <- command(k, 3, 4)
merge(A, B, by = "pitcher", suffixes = c("_a", "_b"))
}
m1 <- rel(1); m2 <- rel(2)
sb <- function(r) 2 * r / (1 + r) # Spearman-Brown (全球を使ったときの信頼性)
狙い所を1か所とした場合, 分割標本の相関は0.592, Spearman-Brown で補正すると0.744です.
投球数を増やせば相関は上がるはずです. 上がらないなら手続きが壊れています.
curve <- rbindlist(lapply(c(200, 300, 400, 600, 800, 1000, 1400), function(thr) {
a <- m1[n_a >= thr]; b <- m2[n_a >= thr]
data.table(閾値 = thr,
r = c(cor(a$ctrl_a, a$ctrl_b), cor(b$ctrl_a, b$ctrl_b)),
狙い所 = c("1か所", "2か所"),
n = c(nrow(a), nrow(b)))
}))
ggplot(curve, aes(x = 閾値, y = r, colour = 狙い所)) +
geom_line() + geom_point() +
ylim(0, 1) +
xlab("対象にする最低投球数") + ylab("分割標本の相関") +
ggtitle("投球数を増やすと安定する")

200球以上で0.645, 1400球以上で0.824と単調に上がります. 指標としての形はできています.
投手は内と外に投げ分けます. 狙いを1か所とみなすのは乱暴で, 2か所にしたほうが実態に近いはずです. 実際, ずれの大きさは小さくなります.
| 狙い所 | ずれの中央値 | 分割標本の相関 | Spearman-Brown |
|---|---|---|---|
| 1か所 | 1.102 | 0.592 | 0.744 |
| 2か所 | 0.825 | 0.514 | 0.679 |
ずれの中央値は1.102 ft から 0.825 ft へ小さくなります. 当然です. 狙い所を増やせば, どんな投球もどれかの近くに来るからです.
問題は相関のほうです. 0.592 から0.514 へ下がります. 上の図で試した7通りの閾値のうち, 2か所の線が1か所の線を下回ったのは 7通りでした.
当てはまりは良くなったのに, 測っているものは不安定になりました. 2か所目の狙いは投手の性質ではなく, その半分の球にたまたま出た塊を拾っています. 複雑にした結果, 偶然を推定する部分が増えたわけです.
2つの指標の相関は0.856で, 順位はほぼ同じです. 同じものを測るなら, 安定するほうを使ってください. 以降は1か所で進めます.
pa <- d[!is.na(events) & events != "",
.(PA = .N, BB = sum(events == "walk")), by = pitcher][PA >= 100]
pa[, BBpct := BB / PA]
zone <- p[, .(zone = mean(abs(plate_x) <= 0.83 &
plate_z >= sz_bot & plate_z <= sz_top)), by = pitcher]
cmp <- merge(merge(full1, pa, by = "pitcher"), zone, by = "pitcher")
ggplot(cmp, aes(x = ctrl, y = BBpct)) +
geom_point(alpha = 0.5) +
geom_smooth(method = "lm", se = FALSE) +
xlab("狙いからの平均的なずれ (ft)") + ylab("四球率 (四球 / 打席)") +
ggtitle("ずれが大きい投手ほど四球を出す")

四球率との相関は0.539です. 同じものを測ってはいませんが, 無関係でもありません. ずれの大きさで四球率の 29%ぶんが説明できます.
残りは打者の見極め, 審判, 配球の側にあります. 四球率を制球力の代わりに使うと, この部分まで投手の責任にすることになります.
ゾーン率との相関は-0.239と弱いままです. ゾーンに入れることと, 狙った場所に投げることは別です.
指標の両端にいる投手の直球を並べます.
source("../../R/statcast.R")
pnames <- unique(p[, .(pitcher, player_name)])
pnames[, player_name := flip_name(player_name)]
ranked <- merge(full1[n >= 1000], pnames, by = "pitcher")[order(ctrl)]
pick <- ranked[c(1, .N)]
ff <- p[pitcher %in% pick$pitcher & pitch_type == "FF" & stand == "R"]
ff <- merge(ff, pick[, .(pitcher, ctrl)], by = "pitcher")
ff[, ラベル := sprintf("%s (ずれ %.2f ft)", player_name, ctrl)]
ggplot(ff, aes(x = plate_x, y = plate_z)) +
annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.6, ymax = 3.4,
fill = NA, colour = "grey40") +
geom_point(alpha = 0.25, size = 0.8) +
stat_density_2d(colour = "steelblue", bins = 5) +
coord_fixed(xlim = c(-2, 2), ylim = c(0, 5)) +
facet_wrap(~ ラベル) +
xlab("左右 (ft, 捕手から見て右が正)") + ylab("高さ (ft)") +
ggtitle("右打者への直球 (枠は平均的なストライクゾーン)")

| 投手 | 投球数 | ずれ |
|---|---|---|
| Kevin Gausman | 2815 | 0.966 |
| Logan Webb | 3075 | 0.981 |
| Joe Ryan | 1955 | 0.982 |
| Jack Flaherty | 2441 | 0.991 |
| Tyler Alexander | 1401 | 0.991 |
| Joe Ross | 1011 | 0.992 |
| Aaron Nola | 3128 | 0.994 |
| George Kirby | 2790 | 0.998 |
| Ryan Yarbrough | 1486 | 1.004 |
| Cody Bradford | 1021 | 1.004 |
この指標で最も制球が良いのは Kevin Gausman (2,815球, ずれ 0.966 ft) です. 最も散らばっているのは Triston McKenzie (ずれ 1.324 ft) で, 差は0.358 ft ありました.
ただし上の分割標本での確かめのとおり, この順位は年をまたいで そのまま再現するものではありません. 上位10人を「制球の良い投手」として 読むのは構いませんが, 1位と3位の差を意味づけしないでください.
data/build-statcast.R の取得列に on_1b
などがありません).