「キレがある」「曲がりが大きい」は褒め言葉です. ではボールが大きく変化するほど, 本当に空振りが増えるのか. 増えるとして, どの球種で効くのか.
ボールの “ノビ” と空振り率 ではフォーシームの縦変化だけを見ました. ここでは全球種を対象にします.
library(data.table)
library(dplyr)
library(ggplot2)
d <- fread("../../data/statcast_2024_all.csv", showProgress = FALSE)
空振り率の分母には注意が要ります. 全投球を分母にすると, 「振ってもらえない球」と「振って当たらない球」が混ざります. ボール球を投げれば分母が増えて空振り率が下がるので, 制球の指標になってしまいます.
見たいのは「振ったのに当たらない」なので, 分母はスイングだけにします.
SWING <- c("foul", "hit_into_play", "swinging_strike", "swinging_strike_blocked", "foul_tip")
WHIFF <- c("swinging_strike", "swinging_strike_blocked")
TYPES <- c(FF = "フォーシーム", SI = "シンカー", FC = "カッター",
SL = "スライダー", ST = "スイーパー", CU = "カーブ",
KC = "ナックルカーブ", CH = "チェンジアップ", FS = "スプリット")
バントは打ちにいく行為が違うので除いています. ファウルチップは バットに当たっているので空振りに数えません.
変化量の向きもそろえます. pfx_x
は捕手から見た左右なので,
右投手と左投手で腕側の符号が逆になります.
混ぜると打ち消し合うので, 腕側 (シュートする向き) を正にそろえます.
sw <- d %>%
filter(description %in% SWING, pitch_type %in% names(TYPES),
!is.na(pfx_x), !is.na(pfx_z), !is.na(release_speed)) %>%
mutate(whiff = as.integer(description %in% WHIFF),
# feet -> cm. 右投手は符号を反転して腕側を正にする
mv_x = pfx_x * ifelse(p_throws == "R", -1, 1) * 30.48,
mv_z = pfx_z * 30.48,
velo = release_speed,
同じ手 = ifelse(stand == p_throws, "同じ手", "逆の手"),
球種 = factor(TYPES[pitch_type], levels = TYPES)) %>%
as_tibble()
符号の確認です. フォーシームの pfx_x の平均は右投手が
-0.63, 左投手が +0.68と逆を向いています.
反転してそろえたのはこのためです.
tot <- d %>% filter(pitch_type %in% names(TYPES)) %>% count(pitch_type, name = "投球")
sw %>%
group_by(球種) %>%
dplyr::summarise(スイング = n(), 空振り率 = mean(whiff),
腕側 = mean(mv_x), 縦 = mean(mv_z), 球速 = mean(velo),
.groups = "drop") %>%
mutate(空振り率 = sprintf("%.1f%%", 100 * 空振り率),
across(c(腕側, 縦), ~ sprintf("%+.1f", .x)),
球速 = sprintf("%.1f", 球速)) %>%
knitr::kable(caption = "球種ごとのスイング数と空振り率 (変化量は cm, 球速は mph)")
| 球種 | スイング | 空振り率 | 腕側 | 縦 | 球速 |
|---|---|---|---|---|---|
| フォーシーム | 110458 | 18.9% | +19.5 | +40.0 | 94.4 |
| シンカー | 51094 | 11.7% | +37.7 | +19.1 | 93.4 |
| カッター | 28501 | 21.3% | -6.6 | +20.4 | 89.6 |
| スライダー | 50918 | 32.4% | -11.5 | +4.2 | 85.9 |
| スイーパー | 23696 | 29.7% | -35.2 | +3.3 | 82.1 |
| カーブ | 18170 | 29.6% | -23.4 | -24.3 | 79.6 |
| ナックルカーブ | 5454 | 32.9% | -17.7 | -24.1 | 81.9 |
| チェンジアップ | 36438 | 29.3% | +35.8 | +13.1 | 85.5 |
| スプリット | 11419 | 32.7% | +26.5 | +7.3 | 86.6 |
球種をまたいで「変化量が大きいほど空振り」と言っても意味がありません. カーブは大きく曲がるが空振り率は高くない, といった球種そのものの差が入るからです. 球種ごとに推定します.
球速も一緒に入れます. 変化量と球速には関係があるので, 変化量だけを見ると球速の効果を拾ってしまいます. ストライク数も入れます. 追い込むと打者は当てにいくので, カウントで空振り率が変わります.
# 変化量が 10cm 増えたときに空振り率が何%ポイント動くか (平均限界効果)
ame <- function(m, var, per = 10) {
b <- coef(m)[[var]]
se <- sqrt(diag(vcov(m)))[[var]]
k <- mean(fitted(m) * (1 - fitted(m))) * per * 100
data.frame(推定 = k * b, lo = k * (b - 1.96 * se), hi = k * (b + 1.96 * se))
}
fit_type <- function(dd) {
m <- glm(whiff ~ mv_x + mv_z + velo + factor(strikes),
data = dd, family = binomial())
rbind(cbind(項目 = "横 (腕側)", ame(m, "mv_x")),
cbind(項目 = "縦", ame(m, "mv_z")))
}
eff <- sw %>% group_by(球種) %>% group_modify(~ fit_type(.x)) %>% ungroup()
ggplot(eff, aes(x = 推定, y = 球種)) +
geom_vline(xintercept = 0, linetype = "dashed") +
geom_pointrange(aes(xmin = lo, xmax = hi)) +
facet_wrap(~ 項目) +
xlab("変化量 +10cm あたりの空振り率の変化 (%ポイント)") + ylab("") +
ggtitle("変化量と空振り率 (95%信頼区間)")

9球種のうち, 縦変化の効果が0から離れているのは6球種, 横変化は6球種です. 向きは球種によって違います. 縦変化の効果が最も大きいのはフォーシーム (+2.68%ポイント), 最も負なのは スライダー (-2.19%ポイント) です.
速球系は縦変化 (ホップ) が正に効き, 変化球は下に落ちるほど (縦変化が小さいほど) 空振りが増えるので負に出ます. 符号の違いはこれで読めます.
ただしナックルカーブ, シンカー, チェンジアップは 区間が0をまたぎ, この枠組みでは効果があるとは言えません.
ここまでの推定は投手をまたいだ比較です. 「変化量の大きい球を投げる投手」は, たいてい球速も制球も優れています. 変化量の効果に, 投手の総合的な質が混ざります.
同じ投手の中で「いつもより曲がった球」が空振りを取れているかを見れば, これを分離できます. 各投手の平均からのずれと, 投手ごとの平均を, 同時にモデルに入れます.
pm <- sw %>%
group_by(pitcher, 球種) %>%
filter(n() >= 200) %>% # 球種ごとに十分投げている投手だけ
mutate(across(c(mv_x, mv_z, velo), list(m = mean), .names = "{.col}_m"),
mv_x_d = mv_x - mv_x_m,
mv_z_d = mv_z - mv_z_m,
velo_d = velo - velo_m) %>%
ungroup()
fit_within <- function(dd) {
m <- glm(whiff ~ mv_x_d + mv_z_d + velo_d + mv_x_m + mv_z_m + velo_m + factor(strikes),
data = dd, family = binomial())
rbind(cbind(比較 = "投手をまたぐ", 項目 = "縦", ame(m, "mv_z_m")),
cbind(比較 = "同じ投手の中", 項目 = "縦", ame(m, "mv_z_d")),
cbind(比較 = "投手をまたぐ", 項目 = "横 (腕側)", ame(m, "mv_x_m")),
cbind(比較 = "同じ投手の中", 項目 = "横 (腕側)", ame(m, "mv_x_d")))
}
wit <- pm %>% group_by(球種) %>% group_modify(~ fit_within(.x)) %>% ungroup()
ggplot(wit, aes(x = 推定, y = 球種, colour = 比較)) +
geom_vline(xintercept = 0, linetype = "dashed") +
geom_pointrange(aes(xmin = lo, xmax = hi), position = position_dodge(width = 0.5)) +
facet_wrap(~ 項目) +
xlab("変化量 +10cm あたりの空振り率の変化 (%ポイント)") + ylab("") +
ggtitle("投手をまたぐ比較と, 同じ投手の中での比較")

結果は「小さくなる」ではありませんでした. 9球種のうち6球種で, 2つの推定は符号すら逆になります.
分かりやすいのはチェンジアップです. 投手をまたいで比べると 縦変化 +10cm あたり+3.16%ポイント, つまり「落ちない球ほど空振りが多い」という妙な向きに出ます. ところが同じ投手の中で見ると-4.56%ポイントで, 「いつもより落ちた球ほど空振りが多い」という素直な向きになります.
cmp %>%
mutate(across(c(`投手をまたぐ`, `同じ投手の中`), ~ sprintf("%+.2f", .x))) %>%
knitr::kable(caption = "縦変化 +10cm あたりの空振り率の変化 (%ポイント)")
| 球種 | 投手をまたぐ | 同じ投手の中 | 符号 |
|---|---|---|---|
| フォーシーム | -0.08 | +8.39 | 逆転 |
| チェンジアップ | +3.16 | -4.56 | 逆転 |
| ナックルカーブ | +1.11 | -6.21 | 逆転 |
| スプリット | +2.75 | -4.42 | 逆転 |
| カーブ | -2.40 | -7.42 | 一致 |
| カッター | -2.28 | +0.40 | 逆転 |
| スイーパー | -2.36 | -0.62 | 一致 |
| スライダー | -3.61 | -2.89 | 一致 |
| シンカー | -0.19 | +0.18 | 逆転 |
投手をまたぐ推定が捉えているのは, 変化量そのものではなく 「その変化量の球を投げる投手はどういう投手か」です. 落ちないチェンジアップを 投げるのは速球の質で勝負する投手で, その投手はもともと空振りを取れます. 球種の中身ではなく投手の顔ぶれを見ているわけです.
では投手内の推定が正しいのかというと, それも言い切れません.
pm %>%
group_by(球種) %>%
dplyr::summarise(投手数 = n_distinct(pitcher),
投手内のばらつき = sd(mv_z_d),
投手間のばらつき = sd(mv_z_m), .groups = "drop") %>%
mutate(across(ends_with("ばらつき"), ~ sprintf("%.1f", .x))) %>%
knitr::kable(caption = "縦変化の標準偏差 (cm) と, 200球以上投げた投手の数")
| 球種 | 投手数 | 投手内のばらつき | 投手間のばらつき |
|---|---|---|---|
| フォーシーム | 198 | 4.4 | 5.8 |
| シンカー | 83 | 5.3 | 12.6 |
| カッター | 46 | 6.2 | 8.0 |
| スライダー | 74 | 6.3 | 8.4 |
| スイーパー | 24 | 7.2 | 9.2 |
| カーブ | 16 | 6.1 | 11.1 |
| ナックルカーブ | 7 | 5.3 | 8.2 |
| チェンジアップ | 46 | 6.3 | 10.8 |
| スプリット | 17 | 7.9 | 7.9 |
投手内のばらつきは標準偏差で4.4-7.9cm しかありません. 10cm は同じ投手にとって2標準偏差前後の外れ値で, そこまでの外れ球は「たまたま大きく変化した」というより 球種の判定を間違えている可能性があります. 投手内の推定はその分だけ大きく出ます.
投手数が7人しかいない球種もあり, 投手をまたぐ推定はそもそも 少数の投手の顔ぶれで決まります.
結局, どちらか一方が正解なのではありません. 同じデータから2通りの答えが出ること, そしてその理由が説明できることが, ここでの結果です.
同じ手 (右対右・左対左) と逆の手では, 変化球の見え方が違います.
eff_hand <- sw %>%
group_by(球種, 同じ手) %>%
group_modify(~ fit_type(.x)) %>%
ungroup()
eff_hand %>% filter(項目 == "縦") %>%
ggplot(aes(x = 推定, y = 球種, colour = 同じ手)) +
geom_vline(xintercept = 0, linetype = "dashed") +
geom_pointrange(aes(xmin = lo, xmax = hi), position = position_dodge(width = 0.5)) +
xlab("縦変化 +10cm あたりの空振り率の変化 (%ポイント)") + ylab("") +
ggtitle("縦変化の効果: 打者の左右別")

sw %>% group_by(球種, 同じ手) %>%
dplyr::summarise(空振り率 = mean(whiff), スイング = n(), .groups = "drop") %>%
tidyr::pivot_wider(names_from = 同じ手, values_from = c(空振り率, スイング)) %>%
mutate(差 = 空振り率_同じ手 - 空振り率_逆の手) %>%
mutate(across(starts_with("空振り率"), ~ sprintf("%.1f%%", 100 * .x)),
差 = sprintf("%+.1f pt", 100 * 差)) %>%
knitr::kable(caption = "打者の左右別の空振り率")
| 球種 | 空振り率_同じ手 | 空振り率_逆の手 | スイング_同じ手 | スイング_逆の手 | 差 |
|---|---|---|---|---|---|
| フォーシーム | 20.2% | 18.0% | 45167 | 65291 | +2.2 pt |
| シンカー | 11.5% | 12.0% | 30935 | 20159 | -0.4 pt |
| カッター | 24.4% | 19.2% | 11822 | 16679 | +5.2 pt |
| スライダー | 33.2% | 31.1% | 29777 | 21141 | +2.1 pt |
| スイーパー | 31.6% | 26.1% | 15701 | 7995 | +5.5 pt |
| カーブ | 29.4% | 29.7% | 6188 | 11982 | -0.3 pt |
| ナックルカーブ | 33.7% | 32.4% | 2218 | 3236 | +1.3 pt |
| チェンジアップ | 30.9% | 28.9% | 7222 | 29216 | +2.0 pt |
| スプリット | 35.2% | 31.3% | 3934 | 7485 | +3.9 pt |
plate_x / plate_z を使えば分けられます
(別の記事の余地).