シーズン序盤に打率4割の打者がいます. 40打数16安打です. この打者は本当に良い打者なのか, それともまだ何も分かっていないのか.
指標によって答えが違います. 三振率は早く固まり, 打率はなかなか固まりません. どれくらい違うのかを数えます.
library(data.table)
library(dplyr)
library(ggplot2)
YEARS <- 2009:2013
cols <- fread("../../data/reference/retrosheet-columns.csv", header = FALSE)[[1]]
KEEP <- c("BAT_ID", "EVENT_CD", "AB_FL", "H_FL", "SF_FL", "BAT_EVENT_FL")
idx <- match(KEEP, cols)
dat <- rbindlist(lapply(YEARS, function(y) {
d <- fread(sprintf("../../data/all%d.csv", y), select = idx,
col.names = KEEP, showProgress = FALSE)
d[, year := y][]
}))
pa <- dat[BAT_EVENT_FL == "T"]
pa[, `:=`(ab = as.integer(AB_FL == "T"),
hit = as.integer(H_FL > 0),
tb = as.integer(H_FL),
k = as.integer(EVENT_CD == 3),
bb = as.integer(EVENT_CD %in% c(14, 15)),
hr = as.integer(H_FL == 4),
sf = as.integer(SF_FL == "T"))]
指標は5つです. 分母がそれぞれ違うことに注意してください.
metrics_of <- function(d, by) {
x <- d[, .(PA = .N, AB = sum(ab), H = sum(hit), TB = sum(tb),
K = sum(k), BB = sum(bb), HR = sum(hr), SF = sum(sf)), by = by]
x[AB > 0 & (AB - K - HR + SF) > 0,
.(PA,
打率 = H / AB,
長打力 = (TB - H) / AB, # ISO: 長打率 - 打率
三振率 = K / PA,
四球率 = BB / PA,
インプレー打率 = (H - HR) / (AB - K - HR + SF)), # BABIP
by = by]
}
METRICS <- c("打率", "長打力", "三振率", "四球率", "インプレー打率")
一番わかりやすい確かめ方は, 今年の値で来年の値を当てられるかです. 当てられるなら実力を測れていて, 当てられないなら偶然を見ていたことになります.
season <- metrics_of(pa, by = c("BAT_ID", "year"))
qual <- season[PA >= 400]
# 翌年の行を year-1 にずらして突き合わせる。
# copy() を忘れると := が qual 自身を書き換え、同じ行同士を結合して r=1 になる
nxt <- copy(qual)[, year := year - 1L]
pairs <- merge(qual, nxt, by = c("BAT_ID", "year"), suffixes = c("", "_翌年"))
yoy <- rbindlist(lapply(METRICS, function(m) {
x <- pairs[[m]]; y <- pairs[[paste0(m, "_翌年")]]
ok <- is.finite(x) & is.finite(y)
ct <- cor.test(x[ok], y[ok])
data.table(指標 = m, r = ct$estimate, lo = ct$conf.int[1], hi = ct$conf.int[2],
n = sum(ok))
}))
setorder(yoy, -r)
ggplot(yoy, aes(x = r, y = reorder(指標, r))) +
geom_vline(xintercept = 0, linetype = "dashed") +
geom_pointrange(aes(xmin = lo, xmax = hi)) +
xlim(0, 1) +
xlab("今年と翌年の相関") + ylab("") +
ggtitle(paste0("翌年との相関 (", min(YEARS), "-", max(YEARS), "年, 400打席以上, n=",
yoy$n[1], "組)"))

yoy[, .(指標, 相関 = sprintf("%.2f", r),
`95%区間` = sprintf("%.2f - %.2f", lo, hi), 組数 = n)] %>%
knitr::kable()
| 指標 | 相関 | 95%区間 | 組数 |
|---|---|---|---|
| 三振率 | 0.87 | 0.85 - 0.89 | 576 |
| 四球率 | 0.75 | 0.71 - 0.78 | 576 |
| 長打力 | 0.67 | 0.63 - 0.71 | 576 |
| 打率 | 0.42 | 0.35 - 0.49 | 576 |
| インプレー打率 | 0.38 | 0.30 - 0.44 | 576 |
三振率が最も再現し (r = 0.87), インプレー打率が最も再現しません (r = 0.38).
打率が上位に来ないのが要点です. 打率は野球で最も語られる指標ですが, 翌年との相関は0.42しかありません.
翌年との相関が低い理由は2つ考えられます.
前者だけを取り出すには, 同じシーズンの中で半分に割って比べます. 同じ年の同じ打者なので, 実力は変わっていないと見なせます.
set.seed(1)
pa[, u := runif(.N)]
setorder(pa, BAT_ID, year, u)
pa[, `:=`(idx = seq_len(.N), npa = .N), by = .(BAT_ID, year)]
# n打席ずつの2つの山に分けて相関を取る
split_half <- function(n) {
d <- pa[npa >= 2 * n]
a <- metrics_of(d[idx <= n], by = c("BAT_ID", "year"))
b <- metrics_of(d[idx > n & idx <= 2 * n], by = c("BAT_ID", "year"))
m <- merge(a, b, by = c("BAT_ID", "year"), suffixes = c("_A", "_B"))
rbindlist(lapply(METRICS, function(mm) {
x <- m[[paste0(mm, "_A")]]; y <- m[[paste0(mm, "_B")]]
ok <- is.finite(x) & is.finite(y)
data.table(指標 = mm, 打席 = n, r = if (sum(ok) > 30) cor(x[ok], y[ok]) else NA_real_,
人数 = sum(ok))
}))
}
grid <- c(50, 75, 100, 150, 200, 250, 300, 350)
half <- rbindlist(lapply(grid, split_half))
ggplot(half, aes(x = 打席, y = r, colour = 指標)) +
geom_hline(yintercept = 0.5, linetype = "dashed") +
geom_line() + geom_point() +
ylim(0, 1) +
xlab("片側の打席数") + ylab("同一シーズン内の半分割相関") +
ggtitle("何打席見れば安定するか (破線 r=0.5 が目安)")

半分割の相関が0.5を超えたところを「安定しはじめる打席数」と呼びます. 観測されたばらつきの半分が実力で説明できる, という意味です.
# r = 0.5 を横切る打席数を線形補間で求める
cross50 <- function(d) {
d <- d[!is.na(r)][order(打席)]
i <- which(d$r >= 0.5)[1]
if (is.na(i)) return(NA_real_)
if (i == 1) return(d$打席[1])
x0 <- d$打席[i - 1]; x1 <- d$打席[i]; y0 <- d$r[i - 1]; y1 <- d$r[i]
x0 + (0.5 - y0) * (x1 - x0) / (y1 - y0)
}
need <- half[, .(必要打席 = cross50(.SD)), by = 指標][order(必要打席)]
need[, .(指標, 必要打席 = ifelse(is.na(必要打席),
paste0(max(grid), "打席でも届かない"),
sprintf("%.0f打席", 必要打席)))] %>%
knitr::kable(caption = "半分割相関が 0.5 に達する打席数")
| 指標 | 必要打席 |
|---|---|
| 三振率 | 50打席 |
| 四球率 | 114打席 |
| 長打力 | 165打席 |
| 打率 | 350打席でも届かない |
| インプレー打率 | 350打席でも届かない |
三振率は50打席で安定します. 一方でインプレー打率は350打席まで見ても0.5に届きません.
冒頭の「40打数で打率4割」は, どの指標の基準にも遠く届きません. この時点で分かるのは, ほぼ何も無いということです.
では序盤の成績をどう読むか. 「観測値をそのまま信じる」でも 「全部無視して平均と思う」でもなく, その間で重みを付けます.
# 前半200打席で後半を予測する。全体平均に向けてどれだけ縮めるのが最適か
d <- pa[npa >= 400]
first <- metrics_of(d[idx <= 200], by = c("BAT_ID", "year"))
second <- metrics_of(d[idx > 200 & idx <= 400], by = c("BAT_ID", "year"))
ev <- merge(first, second, by = c("BAT_ID", "year"), suffixes = c("_前", "_後"))
shrink_rmse <- function(m, w) {
x <- ev[[paste0(m, "_前")]]; y <- ev[[paste0(m, "_後")]]
ok <- is.finite(x) & is.finite(y)
mu <- mean(x[ok])
sqrt(mean((y[ok] - (mu + w * (x[ok] - mu)))^2))
}
ws <- seq(0, 1, by = 0.05)
curve <- rbindlist(lapply(METRICS, function(m)
data.table(指標 = m, w = ws, RMSE = vapply(ws, function(w) shrink_rmse(m, w), 0))))
best <- curve[, .(最適な重み = w[which.min(RMSE)],
そのまま = RMSE[w == 1][1] / min(RMSE)), by = 指標]
ggplot(curve, aes(x = w, y = RMSE, colour = 指標)) +
geom_line() +
facet_wrap(~ 指標, scales = "free_y") +
guides(colour = "none") +
xlab("観測値をどれだけ信じるか (0=全員平均, 1=そのまま)") +
ggtitle("前半200打席から後半200打席を予測したときの誤差")

best[order(-最適な重み)][, .(指標,
最適な重み = sprintf("%.2f", 最適な重み),
`そのまま使うと誤差が` = sprintf("%.0f%%増", 100 * (そのまま - 1)))] %>%
knitr::kable(caption = "前半200打席をどれだけ信じるべきか")
| 指標 | 最適な重み | そのまま使うと誤差が |
|---|---|---|
| 三振率 | 0.80 | 4%増 |
| 四球率 | 0.70 | 8%増 |
| 長打力 | 0.55 | 14%増 |
| 打率 | 0.25 | 25%増 |
| インプレー打率 | 0.25 | 26%増 |
最も高いのは三振率の0.80ですが, それでも2割は平均に寄せたほうが当たります. 打率は0.25で, 観測された差の大半を捨てることになります.
どの指標も, 重み1 (観測値をそのまま使う) は最適から外れています. 序盤の成績をそのまま信じるのは, どの指標でも損だということです.