← 記事一覧へ

曲がる球ほど空振りが取れるのか

「キレがある」「曲がりが大きい」は褒め言葉です. ではボールが大きく変化するほど, 本当に空振りが増えるのか. 増えるとして, どの球種で効くのか.

ボールの “ノビ” と空振り率 ではフォーシームの縦変化だけを見ました. ここでは全球種を対象にします.

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)")
球種ごとのスイング数と空振り率 (変化量は 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 あたりの空振り率の変化 (%ポイント)")
縦変化 +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球以上投げた投手の数")
縦変化の標準偏差 (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 を使えば分けられます (別の記事の余地).
  • 球種の分類は Statcast の自動判定です. スライダーとスイーパーの境目のように, 年によって定義が動くものがあります.
  • 2024年の1シーズンだけです. 他の年でも同じかは確かめていません.
  • 平均限界効果は「変化量が10cm違う球を比べたときの差」です. 実際の投手が 自分の変化量を10cm動かせるという意味ではありません.