「長距離砲」「アベレージヒッター」「巧打の1番打者」といった言い方をします. これは本当に分かれているのか, 分かれているとして何種類なのかを, データから決めます.
打率や本塁打の多い少ないではなく, 打席の結果の内訳で分けます. 良い打者と悪い打者ではなく, タイプの違いを取り出したいためです.
library(Lahman)
library(dplyr)
library(ggplot2)
SINCE <- 1970
QUAL <- 300
season <- Batting %>%
filter(yearID >= SINCE) %>%
group_by(playerID, yearID) %>%
dplyr::summarise(across(c(AB, H, X2B, X3B, HR, BB, SO, SB, HBP, SF, SH),
~ sum(.x, na.rm = TRUE)), .groups = "drop") %>%
mutate(PA = AB + BB + HBP + SF + SH) %>%
filter(PA >= QUAL)
feat <- season %>%
mutate(単打 = (H - X2B - X3B - HR) / PA,
長打 = (X2B + X3B) / PA,
本塁打 = HR / PA,
四球 = BB / PA,
三振 = SO / PA,
盗塁 = SB / PA)
COLS <- c("単打", "長打", "本塁打", "四球", "三振", "盗塁")
そのまま標準化すると, 一番大きな違いは「時代」になります. 三振は年々増え, 盗塁は減っています. タイプの違いより時代の違いが大きいのです.
そこでその年の中で標準化します. 以降の値は「同じ年の打者の中で何σ離れているか」です.
z <- feat %>%
group_by(yearID) %>%
mutate(across(all_of(COLS), ~ as.numeric(scale(.x)))) %>%
ungroup() %>%
filter(if_all(all_of(COLS), ~ !is.na(.x)))
X <- as.matrix(z[, COLS])
pca <- prcomp(X)
data.frame(主成分 = paste0("PC", 1:length(pca$sdev)),
寄与率 = sprintf("%.1f%%", 100 * pca$sdev^2 / sum(pca$sdev^2)),
累積 = sprintf("%.1f%%", 100 * cumsum(pca$sdev^2) / sum(pca$sdev^2))) %>%
knitr::kable(caption = "主成分の寄与率")
| 主成分 | 寄与率 | 累積 |
|---|---|---|
| PC1 | 38.9% | 38.9% |
| PC2 | 17.3% | 56.2% |
| PC3 | 16.1% | 72.4% |
| PC4 | 13.8% | 86.2% |
| PC5 | 8.2% | 94.4% |
| PC6 | 5.6% | 100.0% |
round(pca$rotation[, 1:2], 2) %>% as.data.frame() %>%
knitr::kable(caption = "第1・第2主成分の負荷量")
| PC1 | PC2 | |
|---|---|---|
| 単打 | 0.56 | 0.06 |
| 長打 | 0.07 | -0.91 |
| 本塁打 | -0.51 | -0.21 |
| 四球 | -0.37 | 0.30 |
| 三振 | -0.47 | 0.05 |
| 盗塁 | 0.25 | 0.20 |
第1主成分は「本塁打」と「単打」が逆を向く軸, 第2主成分は「長打」と「四球」の軸です. 2つで全体の56%を説明します.
k-means のクラスタ数は自分で決めます. 群内平方和が折れ曲がるところを探します.
set.seed(1)
wss <- sapply(1:8, function(k) kmeans(X, centers = k, nstart = 10, iter.max = 50)$tot.withinss)
data.frame(k = 1:8, wss = wss) %>%
ggplot(aes(x = k, y = wss)) + geom_line() + geom_point() +
xlab("クラスタ数") + ylab("群内平方和") +
ggtitle("クラスタ数の候補")

K <- 4
set.seed(1)
km <- kmeans(X, centers = K, nstart = 50, iter.max = 100)
z$cluster <- factor(km$cluster)
はっきりした折れ曲がりはありません. 打者は本当は連続的に分布していて, 自然な区切りが無いということです. ここでは4つに切りますが, 4という数はデータが決めたものではありません.
中心 (centroid) を見ます. 値は「同じ年の平均から何σ離れているか」です.
ctr %>%
dplyr::select(タイプ, 人数, all_of(COLS)) %>%
mutate(across(all_of(COLS), ~ round(.x, 2))) %>%
knitr::kable(caption = "クラスタ中心 (単位はσ, 打者シーズン数)")
| タイプ | 人数 | 単打 | 長打 | 本塁打 | 四球 | 三振 | 盗塁 |
|---|---|---|---|---|---|---|---|
| 1: 長打多・四球少 | 4003 | -0.19 | 0.91 | 0.17 | -0.25 | 0.05 | -0.20 |
| 2: 単打多・本塁打少 | 4356 | 0.70 | -0.40 | -0.67 | -0.41 | -0.66 | -0.30 |
| 3: 盗塁多・本塁打少 | 1671 | 0.77 | -0.04 | -0.78 | -0.16 | -0.34 | 2.05 |
| 4: 単打少・本塁打多 | 3805 | -0.94 | -0.48 | 0.93 | 0.80 | 0.84 | -0.35 |
z$タイプ <- factor(paste0(z$cluster, ": ", labels[km$cluster]))
pc <- as.data.frame(pca$x[, 1:2])
pc$タイプ <- z$タイプ
set.seed(2)
pc_s <- pc[sample(nrow(pc), min(3000, nrow(pc))), ]
ggplot(pc_s, aes(x = PC1, y = PC2, colour = タイプ)) +
geom_point(alpha = 0.4, size = 0.8) +
ggtitle("打者シーズンの分布 (第1・第2主成分)")

境目に選手がぎっしり詰まっています. 分類というより, 連続した雲に線を引いたものです.
年ごとに, どのタイプが何割を占めるかを見ます.
share <- z %>%
group_by(yearID, タイプ) %>%
dplyr::summarise(n = n(), .groups = "drop_last") %>%
mutate(比率 = n / sum(n)) %>%
ungroup()
ggplot(share, aes(x = yearID, y = 比率, fill = タイプ)) +
geom_area() +
xlab("年") + ylab("構成比") +
ggtitle("打者タイプの構成比")

chg %>%
mutate(across(c(比率_旧, 比率_新, 差, ぶれ), ~ sprintf("%+.3f", .x))) %>%
knitr::kable(caption = paste0("最初の5年と最後の5年の構成比 (", SINCE, "-", SINCE + 4,
" と ", max(share$yearID) - 4, "-", max(share$yearID),
"). 「ぶれ」は年ごとの構成比の標準偏差"))
| タイプ | 比率_旧 | 比率_新 | 差 | ぶれ |
|---|---|---|---|---|
| 2: 単打多・本塁打少 | +0.345 | +0.313 | -0.032 | +0.021 |
| 1: 長打多・四球少 | +0.258 | +0.281 | +0.023 | +0.024 |
| 3: 盗塁多・本塁打少 | +0.113 | +0.121 | +0.008 | +0.013 |
| 4: 単打少・本塁打多 | +0.283 | +0.284 | +0.001 | +0.018 |
一番動いたのは「2: 単打多・本塁打少」で, 0.345から0.313へ -0.032です. ところがこの型の構成比は年ごとに 標準偏差0.021でぶれています. 55年かけた変化が, 1年ごとのぶれの 1.5倍にしかなりません.
構成比はほとんど変わっていない, というのがここでの結論です. ただしこれは「打者が変わっていない」という意味ではありません. 見ているのは同じ年の中での相対的な位置なので, 三振も本塁打も全体として増えていても, 打者の間の並び方が同じなら構成比は動きません. 実際の水準がどう動いたかは, 標準化する前の値で見る必要があります.
feat %>%
group_by(yearID) %>%
dplyr::summarise(三振 = mean(三振), 本塁打 = mean(本塁打),
盗塁 = mean(盗塁), .groups = "drop") %>%
tidyr::pivot_longer(-yearID, names_to = "指標", values_to = "打席あたり") %>%
ggplot(aes(x = yearID, y = 打席あたり, colour = 指標)) +
geom_line() +
xlab("年") + ggtitle("標準化する前の水準 (規定打席以上の打者の平均)")

水準は動いています. 打席あたりの三振は0.124から 0.218へ76%増えました. 全員が一緒に増えたので, タイプの構成比は変わらなかったわけです.
nstart = 50
と乱数シードを固定しています.