「この打者は左投手に強い」と言います. 実際, 対左と対右の打率を並べれば差は出ます. 問題は, その差がどこまで本人の性質で, どこからが偶然かです.
打席数は限られています. 右打者が対左投手で立つ打席はシーズン150程度で, 打率にすれば偶然だけで3分や4分は簡単に動きます.
retrosheet の打席結果には投手と打者の利き腕が入っているので, これを数えられます.
library(data.table)
library(dplyr)
library(ggplot2)
YEARS <- 2009:2013 # 試合数が動かなくなった年以降 (data/README.md)
cols <- fread("../../data/reference/retrosheet-columns.csv", header = FALSE)[[1]]
KEEP <- c("BAT_ID", "BAT_HAND_CD", "PIT_HAND_CD", "AB_FL", "H_FL", "BAT_EVENT_FL")
idx <- match(KEEP, cols)
read_year <- function(y) {
d <- fread(sprintf("../../data/all%d.csv", y), select = idx,
col.names = KEEP, showProgress = FALSE)
d[, year := y][]
}
dat <- rbindlist(lapply(YEARS, read_year))
# 打席として成立したものだけを見る (走者の盗塁死などで打席が終わる行を落とす)
dat <- dat[BAT_EVENT_FL == "T"]
dat[, ab := as.integer(AB_FL == "T")]
dat[, hit := as.integer(H_FL > 0)]
dat[, 同じ手 := BAT_HAND_CD == PIT_HAND_CD]
BAT_HAND_CD
はその打席で実際にどちら側に立ったかです.
スイッチヒッターは相手投手に応じて立つ側を変えるので,
ほぼ全打席が「逆の手」になります.
左右の相性を測る対象にはなりません.
hands <- dat[, .(sides = uniqueN(BAT_HAND_CD)), by = BAT_ID]
switch_ids <- hands[sides > 1, BAT_ID]
dat <- dat[!BAT_ID %in% switch_ids]
打席中に立つ側を変えた打者155人を除きました.
対戦相手の利き腕別に打率を出します. 同じ手 (右対右・左対左) と 逆の手 (右対左・左対右) の差を「逆の手有利」と呼びます.
MIN_AB <- 100 # 各側でこれだけ打数がある打者に限る
sp <- dat[ab == 1, .(打数 = .N, 安打 = sum(hit)), by = .(BAT_ID, year, 同じ手)]
w <- dcast(sp, BAT_ID + year ~ 同じ手, value.var = c("打数", "安打"))
# 同じ手 は論理値なので FALSE 側が「逆の手」。位置ではなく名前で対応させる
setnames(w,
c("打数_FALSE", "打数_TRUE", "安打_FALSE", "安打_TRUE"),
c("打数_逆", "打数_同", "安打_逆", "安打_同"))
w <- w[!is.na(打数_逆) & !is.na(打数_同)]
split_of <- function(d) {
d[, avg_逆 := 安打_逆 / 打数_逆][, avg_同 := 安打_同 / 打数_同]
d[, 差 := avg_逆 - avg_同]
# 二項分布から見た, 偶然だけで生じる差のばらつき
d[, se := sqrt(avg_逆 * (1 - avg_逆) / 打数_逆 + avg_同 * (1 - avg_同) / 打数_同)]
d[]
}
season <- split_of(copy(w))[打数_逆 >= MIN_AB & 打数_同 >= MIN_AB]
ggplot(season, aes(x = 差)) +
geom_histogram(bins = 40) +
geom_vline(xintercept = 0, linetype = "dashed") +
geom_vline(xintercept = mean_split, colour = "red") +
xlab("逆の手の打率 − 同じ手の打率") +
ggtitle(paste0(min(YEARS), "-", max(YEARS), "年の打者シーズン (赤線は全体平均)"))

平均は+0.022で, 逆の手のほうがよく打てるという よく知られた傾向がそのまま出ます. 問題はこの分布の広がりです.
観測された差のばらつきには, 本人の性質と偶然が両方入っています.
\[ \mathrm{Var}(\text{観測}) = \mathrm{Var}(\text{実力}) + \mathrm{Var}(\text{偶然}) \]
偶然のぶんは打数から計算できるので, 引き算すれば実力のばらつきが出ます.
var_obs <- var(season$差)
var_noise <- mean(season$se^2)
var_true <- max(0, var_obs - var_noise)
data.frame(
項目 = c("観測された差のばらつき", "偶然だけで説明されるぶん", "残り (実力)"),
標準偏差 = sprintf("%.4f", sqrt(c(var_obs, var_noise, var_true)))) %>%
knitr::kable(caption = "対左右の差のばらつき (打率)")
| 項目 | 標準偏差 |
|---|---|
| 観測された差のばらつき | 0.0466 |
| 偶然だけで説明されるぶん | 0.0451 |
| 残り (実力) | 0.0117 |
観測されたばらつきの94%は偶然で説明できます. 残るのは標準偏差にして0.012です.
つまり「左に強い打者」は存在はするが, 見た目の順位表よりずっと小さい差です.
個々の打者の推定値を, 全体平均に向けて縮めます. 打数が少ないほど大きく縮みます.
season[, w_shrink := var_true / (var_true + se^2)]
season[, 縮小後 := mean_split + w_shrink * (差 - mean_split)]
rbind(data.frame(値 = season$差, 種類 = "観測"),
data.frame(値 = season$縮小後, 種類 = "縮小後")) %>%
ggplot(aes(x = 値, fill = 種類)) +
geom_histogram(bins = 40, alpha = 0.6, position = "identity") +
geom_vline(xintercept = 0, linetype = "dashed") +
xlab("逆の手の打率 − 同じ手の打率") +
ggtitle("縮小前と縮小後")

観測の標準偏差0.047が, 縮小後は0.003になります. 縮小の重みは中央値0.06で, 個人差として残せるのは観測された差の6%程度です.
縮小推定が正しいなら, 別の年のデータを当てられるはずです. 2009-2011年で推定し, 2012-2013年を予測します.
比べる相手は3つです.
agg <- function(d) {
x <- d[, .(打数_逆 = sum(打数_逆), 打数_同 = sum(打数_同),
安打_逆 = sum(安打_逆), 安打_同 = sum(安打_同)), by = BAT_ID]
split_of(x)
}
train <- agg(w[year <= max(YEARS) - 2])[打数_逆 >= MIN_AB & 打数_同 >= MIN_AB]
test <- agg(w[year > max(YEARS) - 2])[打数_逆 >= MIN_AB & 打数_同 >= MIN_AB]
m_tr <- weighted.mean(train$差, train$打数_逆 + train$打数_同)
v_tr <- max(0, var(train$差) - mean(train$se^2))
train[, 縮小後 := m_tr + (v_tr / (v_tr + se^2)) * (差 - m_tr)]
ev <- merge(train[, .(BAT_ID, 予測_観測 = 差, 予測_縮小 = 縮小後)],
test[, .(BAT_ID, 実測 = 差, 重み = 打数_逆 + 打数_同)], by = "BAT_ID")
ev[, 予測_平均 := m_tr]
rmse <- function(p) sqrt(weighted.mean((ev$実測 - p)^2, ev$重み))
data.frame(
予測 = c("全員に同じ値 (ベースライン)", "観測された差をそのまま", "縮小推定"),
RMSE = sprintf("%.4f", c(rmse(ev$予測_平均), rmse(ev$予測_観測), rmse(ev$予測_縮小)))) %>%
knitr::kable(caption = paste0(max(YEARS)-1, "-", max(YEARS), "年の対左右の差を予測したときの誤差 (",
nrow(ev), "人)"))
| 予測 | RMSE |
|---|---|
| 全員に同じ値 (ベースライン) | 0.0382 |
| 観測された差をそのまま | 0.0437 |
| 縮小推定 | 0.0371 |
観測された差をそのまま使うのは, ベースラインより悪くなります (0.0437 対 0.0382). 「左に強い」という評判を鵜呑みにするより, 全員に平均値を答えるほうがましだということです.
縮小推定は0.0371で, 一番良いのは縮小推定でした.
# 差が偶然かどうかを, 打者を再標本化して確かめる
set.seed(1)
boot_gain <- replicate(2000, {
i <- sample(nrow(ev), nrow(ev), replace = TRUE)
b <- ev[i]
rb <- sqrt(weighted.mean((b$実測 - b$予測_平均)^2, b$重み))
rs <- sqrt(weighted.mean((b$実測 - b$予測_縮小)^2, b$重み))
rb - rs
})
gain_ci <- quantile(boot_gain, c(0.025, 0.975))
縮小推定はベースラインを2.8%上回りました. 差は 0.0011, 打者を再標本化した95%区間は 0.0004-0.0017です.
区間は0を含みません. 差は小さいものの, 個人差は実在します.
names_tbl <- fread("../../data/derived/player-names.csv")
season[, .(BAT_ID, year, 打数_逆, 打数_同, 差, 縮小後)][order(-縮小後)][1:10] %>%
merge(names_tbl, by.x = "BAT_ID", by.y = "retroID", all.x = TRUE) %>%
.[order(-縮小後)] %>%
.[, .(name, year, 打数_逆, 打数_同,
観測 = sprintf("%+.3f", 差), 縮小後 = sprintf("%+.3f", 縮小後))] %>%
knitr::kable(caption = "逆の手に強いと推定された打者シーズン")
| name | year | 打数_逆 | 打数_同 | 観測 | 縮小後 |
|---|---|---|---|---|---|
| Adam Lind | 2010 | 432 | 137 | +0.159 | +0.035 |
| Shin-Soo Choo | 2012 | 392 | 206 | +0.128 | +0.031 |
| Nyjer Morgan | 2009 | 366 | 103 | +0.170 | +0.031 |
| Robinson Cano | 2012 | 384 | 243 | +0.121 | +0.031 |
| Ryan Howard | 2009 | 394 | 222 | +0.113 | +0.030 |
| Carlos Peña | 2011 | 373 | 120 | +0.121 | +0.030 |
| Derek Norris | 2013 | 150 | 114 | +0.171 | +0.029 |
| Andre Ethier | 2009 | 431 | 165 | +0.108 | +0.029 |
| Buster Posey | 2012 | 164 | 366 | +0.141 | +0.029 |
| Josh Hamilton | 2010 | 352 | 166 | +0.129 | +0.029 |
観測では+0.177のような大きな差が出ますが, 縮小後は+0.035までしか残りません.