1 イントロ

野球の解析において何らかの要因からの影響の大きさを推定する方法として、試合をシミュレーションすることが考えられる. ここでは, ランナー/アウトカウントごとの状態の移行として試合の進行をモデル化し, 打順の各スポットにおける打席イベントの得点価値の違いについて検討する. これは打順の沿って生じる, 1. ランナー/アウト状況の違い, 2. 打席数の違いを考慮した上で, 打席イベント (単打, 2塁打, …, 三振) の価値を計算することで得られる. 非常に単純な方法だが, 打順変更による得点効率改善の主要な経路であると考えられ, 打順に組み方の基礎的な考え方を提供している.

野球の状況は非常に定義しやすく, 試合の進行は状態の遷移として表現することが可能となっている. アウトカウントとランナーに関してありうる状態としては、アウトカウント (0から2の3通り) x ランナー状況 (2^3 = 8通り), で24通りの状態, それらに加えて3アウト (ランナーに関しては無差別とする) で25の状態と定義できる. 本ポストではマルコフ連鎖モデルを利用する. このモデルでは, 現在の状態が次の状態に移行する確率は, どのようにして現在の状態に至ったかによって影響を受けないことを仮定する. この単純な仮定が成立する状況を考えることで, モデルの中で考慮した要因からの影響を定量的に推定することができる. もちろん現実にはその他の影響はあるが, 幸運なことに影響はそれほど大きくないことが知られており, 極端に状況を分けない限りはそれなりに妥当な推定が可能となっている.

マルコフ連鎖モデルを利用した野球の研究は多くの研究者が行ってきたが, 最も有名なものの一つは, TangoらによるThe Bookだろう. 例えばChap. 5においては, 打席結果イベントサンプリングを利用したマルコフ連鎖モデルを使って, 打順を考える上で重要な要素についての結果と考察が示されている. 打順の評価については様々な議論があるが, 現状ではThe Bookが示した結果が最も重要であるとみなされていると言えるだろう. 以下に示す内容の大半も, The BookのChapter. 5の一部 (pp.121-131) の概念的な追試である.

以下では平均的な選手を並べたモデルを利用して野球の過程をシミュレーションする. 野球のマルコフ連鎖モデルにおいては, なんらかの介入を与えた際に最終的な得点数がどう変化するか調べることで, その介入の効果を定量的に調べることが多い. しかし, ここでは得点数それ自体はモデルがある程度現実を模倣できているかどうかの基準程度としてのみ考える. ここで重視するのは打順の各スポットにおける, 各打席イベントの得点価値である (蛭川, 2019). これによって, 打順における各スポットの重要性の違いや, どのようなタイプの選手をどのスポットに置くとよいかについての大まかな指針が得られることを示す.

2 打順, 試合シミュレーション関数の設定

試合をシミュレーションするための関数に渡す打順を設定する. NPB公式の2018年度成績から打者と投手に分けて打撃成績を集計した. 各イベントの頻度を計算し, 9人分並べる.

ここでは2つの打順;

  • 平均的な野手9人の打線
  • 平均的な野手8人と投手 (9番に入れる) からなる打線

を設定し, それぞれを利用する.

平均的な野手と投手からなる打線におけるイベント発生確率を示す.

require(tidyverse)
require(knitr)
require(kableExtra)
# 打順を考慮するモデルを使うため打順データを用意する
# 平均的な野手のみの打線では, 同じ選手を9人並べており単に計算量が増えているだけ
# だが, 比較のため能力差を考慮できる同じモデルで扱うことにする
average <- read_csv("stats/average_2018.csv", locale=locale(encoding="CP932"))
# NPB公式から
# 1. 全打者 (投手除く) の打撃成績
# 2. 全投手の打撃成績
# を集計したもの

lineup1 <- data.frame(Player = rep("AvgFielder", 9))%>%
  left_join(average)%>%
  select(-c(1:3))

lineup2 <- data.frame(Player = c(rep("AvgFielder", 8), "AvgPitcher"))%>%
  left_join(average)%>%
  select(-c(1:3))

# 平均的野手と投手が含まれるlineup2だけ示す
tibble(Spot = 1:9)%>%
  cbind(lineup2)%>%
  mutate_all(funs(round),3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Spot Single Double Triple HR BB_IBB HBP K Out
1 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
2 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
3 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
4 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
5 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
6 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
7 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
8 0.158 0.040 0.005 0.026 0.092 0.010 0.181 0.488
9 0.076 0.014 0.001 0.005 0.032 0.003 0.436 0.435

これらの確率を使ってイベントをサンプリングし, MLB (16-17) における, ランナー/アウト状況と各イベントの組み合わせごとに得られた進塁確率を用いて, 打席後の状態を決定する. これら以外のイベント (盗塁関連, 妨害系 etc.) は無視している. MLBでの走塁の状態遷移確率の計算はRetrosheetを利用した.

BBとIBBはまとめている. IBBはランナー/アウト状況に依存して記録されるため, モデルとの相性がやや悪い. IBBが記録される可能性が高い場面だけで記録されるようにするという手もあるだろうが, 面倒だし計算コストの割に得るものが少なそうに思われる. BBも投手側から見てコストが低い状態で発生しやすい (野球を見る限りBBとIBBは故意の成分が0-100%の連続的なものだと思われる). それ以外にも, ここでのモデルは状況によるイベント発生確率変化の要素は考慮されておらず, バイアスの原因となりうる.

SFとSHはOut扱いとした. Retrosheetから計算した進塁状態遷移確率においても, Outが記録された時にSFとSHによるランナー進塁が含まれているため整合性は取れているといえるが, Outの価値の計算ではこれらの比較的中立的な価値を持つイベントが含まれていることは注意が必要かもしれない.

このようにイベントのサンプリングを利用する場合, 状態の遷移確率のみを使う場合に比べて, 1つの遷移を:

  • 打席イベント
  • 進塁

の2つのサンプリングに分けるため計算速度が遅くなる. さらに複雑さを避けるため一部のイベントを無視することになり, 得点の推定自体も正確でなくなる可能性が高い. しかし, 各イベントのラベルによってどのような原因で状態が推移したかを識別可能になるため, 移行過程の解釈が容易になる利点がある. 単にランナー/アウト状態の遷移確率を考慮した場合, どのような原因でその移行が生じたかわからないため, 野球を見ている人間の認識から遠くなり, 解釈が困難となる.

2.1 モデル

本ポストで利用するコードはMarchi et al.で記述されている, 24状態の遷移確率を用いたモデルを元に改変した.

# 使用する関数をまとめたファイル
# 末尾の関数定義参照
source('functions/func_markov_model_using_event_sampling.R')

# 1試合を計算する関数だけを示す
# その他は末尾に掲載
simulate.one.game <- function(df = lineup, game = 1){
  # 初期条件
  s <- "000 0"; # 状態を示す変数 1,2,3塁ランナー状態 (0 or 1) + アウトカウント. 3アウトは"3"でコードしている
  runs <- 0;  # 得点 (イニングごとに0に戻していることに注意)
  spot <- 1; # 打順
  inning <- 1; # イニング
  game_end <- FALSE ; # 試合終了判定用のflag
  
  # 初期条件. 以下は記録のためのオブジェクト
  innings <- NULL; 
  batter.rec = NULL;
  # 下の5つはRE, RVの計算に使う (pre, postは片方あれば, あとでlead()とかでイニングごとにずらしても可)
  event.rec <- NULL; state.pre <- NULL; state.post <- NULL; runs.pre <- NULL; runs.post <- NULL; 
  
  while(game_end == FALSE){ # while 1: game_end flagについては下で記述
    
    while(s != "3"){ # # while 2: イニングは3アウトになるまで
      # イベント前の状態を記録
      state.pre = c(state.pre, s)
      runs.pre = c(runs.pre, runs)
      
      # 打順データの"spot"行からイベント (row name) をサンプリング
      bat.event <- sample(names(df), 1, prob = df[spot,]) 
      
      # 進塁規則に従って状況を遷移させる
      # MLB16-17での確率を利用する
      # もっと単純なD’Esopo and Lefkowitzの進塁規則などでも結果に大差はないだろう
      transition.prob <- transition.table %>%
        ungroup()%>%
        filter(STATE == s, Event_des == bat.event)%>%
        select(-c(1:3))
      s.new <- sample(names(transition.prob), 1, prob = as.numeric(transition.prob))
      # s.newは打席が終わった状態を示す
      
      # sからs.newに移行したときに記録される得点
      runs <- as.numeric(runs + RunTable[RunTable$State_pre == s, s.new])
      
      # 結果を記録する
      runs.post <- c(runs.post, runs) # 得点を記録する
      event.rec <- c(event.rec, bat.event) #eventを記録していく
      state.post <- c(state.post, s.new) #stateを記録していく
      batter.rec <- c(batter.rec, spot) # 打順を記録
      innings <- c(innings, inning) # イニング(ry
      
      # 打順を進める
      spot <- ifelse(spot == 9, 1, spot +1) 
      
      # 次の打席に移る前に状態を示すsをs.newで置換
      s <- s.new 
      # while loop1の先頭に戻る 次の打者に移る
    } # while 2の終わり. sが"3"ならloop終了. つまりイニングの終わり
    
    # 各イニング終了後, 試合終了判定 game_end flangの管理
    # これも無駄に遅くなるがイニング数を合わせるため
    # 実のところRPGではなくRPIを考えるならそもそもあまり必要はない
    # つまり9イニング計算してしまい, 期待イニング数に変換すればいい
    # 1イニングだけ計算して x9すると打順が常に#1から開始するので当然だめ
    
    # このあたりは https://github.com/ssharpe42/BaseballMarkov を参考にしたような気がするが, かなり昔なので憶えていない
    if(inning ==9){ # 9回なら終わり
      game_end = TRUE
    }else if(inning==8){ # inningが8の場合, 次のイニングに入るかどうか調べる
      U = runif(1) # 一様分布から乱数を取得
      if(U < 0.93){ # 乱数が0.93より小さいと9回に入る
        inning = inning + 1
        s <- "000 0"
        runs <- 0
      }else{
        game_end=TRUE}
    }else{ # inningが8 or 9以外では次のイニングに入る
      inning <- inning +1
      s <- "000 0"
      runs <- 0
    } # 試合終了判定おわり
    
  } # while 1のおわり
  
  # 結果をdfにまとめる 
  data <- data.frame(Game = game,
                     Inning = innings,
                     Batter = batter.rec,
                     Event = event.rec, 
                     State_pre =state.pre,
                     State_post = state.post,
                     Runs_pre = runs.pre,
                     Runs_post = runs.post,
                     stringsAsFactors = FALSE)
  # Eventをfactor typeにして見やすいようなレベルを設定しておく
  data$Event <- factor(data$Event, levels = c("Single", "Double", "Triple", "HR",
                                              "BB_IBB", "HBP", "Out", "K"))
  return(data)
} # function終わり

イニングは2018年実測値に合わせて8.93に設定.

c(CL= (7689 + 1/3) / 858, PL = 7643 / 858)
##       CL       PL 
## 8.961927 8.907925
# 1.02で示されていた数値を使っている
# https://1point02.jp/op/gnav/leaders/lg/lps_standard.aspx?sn=2018&lg=0&tm=0&ps=0&sl=1&sr=0&pn=0
# 以前は登録なしで見られたのだが無料会員登録が必要になったようだ

モデルでは延長戦を考慮せずにイニング数を合わせているため, 9回に入る確率が実際より高くなっていると思われる.

一試合の計算例.

simulate.one.game(df = lineup1)
##    Game Inning Batter  Event State_pre State_post Runs_pre Runs_post
## 1     1      1      1 Single     000 0      100 0        0         0
## 2     1      1      2    Out     100 0      100 1        0         0
## 3     1      1      3    Out     100 1      100 2        0         0
## 4     1      1      4    Out     100 2          3        0         0
## 5     1      2      5    Out     000 0      000 1        0         0
## 6     1      2      6    Out     000 1      000 2        0         0
## 7     1      2      7      K     000 2          3        0         0
## 8     1      3      8      K     000 0      000 1        0         0
## 9     1      3      9    Out     000 1      000 2        0         0
## 10    1      3      1 Single     000 2      100 2        0         0
## 11    1      3      2 BB_IBB     100 2      110 2        0         0
## 12    1      3      3    Out     110 2          3        0         0
## 13    1      4      4      K     000 0      000 1        0         0
## 14    1      4      5    Out     000 1      000 2        0         0
## 15    1      4      6    Out     000 2          3        0         0
## 16    1      5      7    Out     000 0      000 1        0         0
## 17    1      5      8 BB_IBB     000 1      100 1        0         0
## 18    1      5      9 Single     100 1      110 1        0         0
## 19    1      5      1 Single     110 1      111 1        0         0
## 20    1      5      2    Out     111 1      111 2        0         0
## 21    1      5      3 Single     111 2      110 2        0         2
## 22    1      5      4    Out     110 2          3        2         2
## 23    1      6      5    Out     000 0      000 1        0         0
## 24    1      6      6    Out     000 1      000 2        0         0
## 25    1      6      7    Out     000 2          3        0         0
## 26    1      7      8 Single     000 0      100 0        0         0
## 27    1      7      9     HR     100 0      000 0        0         2
## 28    1      7      1    Out     000 0      000 1        2         2
## 29    1      7      2    Out     000 1      000 2        2         2
## 30    1      7      3    Out     000 2          3        2         2
## 31    1      8      4 Double     000 0      010 0        0         0
## 32    1      8      5 Single     010 0      101 0        0         0
## 33    1      8      6    Out     101 0      000 2        0         1
## 34    1      8      7    Out     000 2          3        1         1
## 35    1      9      8      K     000 0      000 1        0         0
## 36    1      9      9 Single     000 1      100 1        0         0
## 37    1      9      1     HR     100 1      000 1        0         2
## 38    1      9      2 Single     000 1      100 1        2         2
## 39    1      9      3     HR     100 1      000 1        2         4
## 40    1      9      4 Single     000 1      100 1        4         4
## 41    1      9      5    Out     100 1      100 2        4         4
## 42    1      9      6 BB_IBB     100 2      110 2        4         4
## 43    1      9      7    Out     110 2          3        4         4

2.2 ランナー/アウト状況のコードについて

打席開始時の状況 (State_pre) と, 打席終了時の状況 (State_post)では塁上と, 0から2アウトの状態を4つの数字で表している. 例を示す.

“000 0”: ランナー無し, 0アウト
“100 0”: 1塁、0アウト
“000 1”: ランナー無し, 1アウト

なお, 3アウトはランナーを考える意味が無いので単に“3”と表している.

3 平均的な選手による打順を用いたシミュレーション

平均的な野手9人 (wo_pit) と, 平均的な野手8人+平均的な投手1人 (9番打者; w_pit) とについて, 80,000試合計算し, その結果を比較する.

# eval = FALSE
# 80000試合ずつ計算
res.wo.pit <- 1:80000%>%
  map(~simulate.one.game(df = lineup1,
                         game = .))%>%
  bind_rows()%>%
  mutate(Type = "wo_pit")

res.w.pit <- 1:80000%>%
  map(~simulate.one.game(df = lineup2,
                         game = .))%>%
  bind_rows()%>%
  mutate(Type = "w_pit")

res <- rbind(res.w.pit, res.wo.pit)%>%
  mutate(Type = factor(Type, level = c("wo_pit", "w_pit")))
save(res, file = "2018_avg_80k_games.rdata")

3.1 得点/試合, 得点/イニング

それぞれの打順 (Type) について, 1試合あたりの得点 (RPG) を計算.

load("results/2018_avg_80k_games.rdata")
Game.runs <- res %>%
  group_by(Type, Game, Inning)%>%
  dplyr::summarise(Runs = max(Runs_post))%>%
  ungroup()%>%
  group_by(Type, Game)%>%
  dplyr::summarise(Runs = sum(Runs))

Game.runs%>%
  group_by(Type)%>%
  dplyr::summarise(RPG = mean(Runs),
                   SD = sd(Runs),
                   N = n(),
                   CI_95_upper = RPG + SD /sqrt(N) * qt(0.975, df = N-1),
                   CI_95_lower = RPG - SD /sqrt(N) * qt(0.975, df = N-1))%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Type RPG SD N CI_95_upper CI_95_lower
wo_pit 4.483 3.130 80000 4.505 4.462
w_pit 3.817 2.801 80000 3.836 3.798

95%CIの上限と下限の差は0.04程度. 80,000試合 (正確には半試合) だが多少誤差は残っている.

点推定値の平均.

(3.82 + 4.45) /2
## [1] 4.135

NPB2018の実際の値と比較する.

c(CL= 3751 / 858, PL = 3658 / 858)
##       CL       PL 
## 4.371795 4.263403

投手なし (wo_pit)では4.48でやや実測値より高く, 投手あり (w_pit) では3.82で大幅にRPGが低い. 平均を取ると, 4.135程度で実測値よりやや低い. 現実では打者の能力に差があり, 得点価値や打席数の観点から見て重要な打順に高い能力の打者を置くことで得点を稼いでいるはずである. また, 投手は代打によって打順から除かれやすい. ここではそれらの影響を考慮していないことを考慮すると, これらのモデルで得点が低い事自体は理にかなっているのかもしれない. RPGで見る限り現実からの乖離は数%程度というところだろう.

現実のデータでは投手の打席が多いはずのCLで得点が多い. 野手の打撃能力と投手の投手としての能力のバランスでは, PLのほうが投手優勢だったのかもしれない.

実際にはNPB全体でも, 143 * 12試合で各リーグではその半分なので, 母平均からの誤差は多少あるはずで, 上の2つの議論はやや単純化し過ぎかもしれない.

1.02による, 実際のリーグごとのwOBAは以下の通り.

c(CL= 0.330, PL = 0.326)
##    CL    PL 
## 0.330 0.326

少なくとも実際に試合で起こったイベントからの評価としては, 基本的にDHが無いため投手の打席が多かったにも関わらずCLのほうが得点がわずかに入りやすかったことようだ.

さらに問題があるのは, CLとPLではのイニング数/試合が異なり, 上のモデルとも差がある. このため, 試合数あたり得点に注目した比較はそれ自体が何を比較しているのか理解はやや困難となっている.

より比較が容易な, イニング当たり得点も示しておく.

まずモデル.

# ここのRPGは8.93をかけた近似値
res%>%
  compute.runs.per.inning(split_by_Type = TRUE)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Type RPI Innings SD CI_95_upper CI_95_lower RPG
wo_pit 0.502 714447 1.042 0.504 0.500 4.483
w_pit 0.427 714420 0.937 0.430 0.425 3.817

ここで示したRPGはRPIに期待イニング数の8.93をかけたものだが, これぐらいのサンプル数であればこの計算で概ね正確だろう.

実際の値.

c(CL= 3594 /  (7689 + 1/3), PL = 3815 / 7643)
##        CL        PL 
## 0.4674007 0.4991495

wOBAはCLの方がわずかに高いにも関わらず, RPIはPLの方がやや高い. 誤差の効果かもしれないし, 投手がいることによる影響がなにがしかあったのかもしれない.

PLは投手を含めていないモデルとほぼ同じとなった. モデルでは上述した打順の効果がないため得点が低下しそうなものだが, 少なくとも2018年度に関してはPLの打者のwOBAが低かったため, これらの効果が相殺したのかもしれない.

3.2 得点期待値表 (RE24)

次に, 得点期待値 (RE) 24を示す. これは打者が打席に入った時点の状態に関して, イニングの終了までに記録された得点の平均 (つまり期待値) である. 投手なしモデルの結果を示す.

# このあたりから下で定義している簡単な自作関数を多用していく
RE24 <- res %>%
  compute.RE24(split_by_Type = TRUE,
               out_RE24 = TRUE)

RE.matrix <- RE24%>%
  filter(Type == "wo_pit")%>%
  generate.RE24.matrix()
require(gt)
RE.matrix
##     0 outs 1 out 2 outs
## 000  0.502 0.269  0.103
## 001  1.343 0.928  0.367
## 010  1.111 0.666  0.319
## 011  1.911 1.345  0.584
## 100  0.873 0.511  0.221
## 101  1.734 1.133  0.490
## 110  1.481 0.916  0.454
## 111  2.349 1.589  0.773

DeltagraphsによるNPB2013-2015のRE24を示す.

# NPB 13-15
# http://1point02.jp/op/gnav/column/bs/column.aspx?cid=53003
# NPB 14-18のものが蛭川 2019のpp.29にあり, 
# そこはもう少し得点が入りやすい環境
NPB <- matrix(
  c(0.440, 0.233, 0.087, 1.291, 0.906, 0.349,   
    1.059, 0.682, 0.305, 1.888, 1.321, 0.578,   
    0.807, 0.478, 0.204, 1.684, 1.165, 0.495,   
    1.412, 0.878, 0.417, 2.092, 1.454, 0.758),  
  8, 3, byrow=TRUE)
dimnames(NPB)[[2]] <- c("0 outs", "1 out", "2 outs")
dimnames(NPB)[[1]] <- c("000", "001", "010", "011", 
                        "100", "101", "110", "111")
NPB
##     0 outs 1 out 2 outs
## 000  0.440 0.233  0.087
## 001  1.291 0.906  0.349
## 010  1.059 0.682  0.305
## 011  1.888 1.321  0.578
## 100  0.807 0.478  0.204
## 101  1.684 1.165  0.495
## 110  1.412 0.878  0.417
## 111  2.092 1.454  0.758

全体的な傾向は似ているが, モデルから得られた結果のほうが全体的に高い. 得点環境が違うため, 比較は困難だが差分を示す (モデルにおいて得点環境を無理やり合わせる方法に関しては, WEの計算に関するポストで説明した. 得点環境を調整すればより厳密な比較が可能となるだろうが, ここではそのような比較は重要ではない).

diff.matrix <- RE.matrix - NPB
diff.matrix
##     0 outs  1 out 2 outs
## 000  0.062  0.036  0.016
## 001  0.052  0.022  0.018
## 010  0.052 -0.016  0.014
## 011  0.023  0.024  0.006
## 100  0.066  0.033  0.017
## 101  0.050 -0.032 -0.005
## 110  0.069  0.038  0.037
## 111  0.257  0.135  0.015

3.3 打席イベントの得点価値

ここまでに24状況の得点期待値 (RE24) が得られた. これを使って, 各モデルでの打席イベントの得点価値 (RV) を示す. RVは打席に入る前と終了後 (走者の移行も終了後) に注目し, 各イベントがどれだけ得点期待値を変動させたかについて平均を取った値である. 当然, RVの計算では, それぞれのモデルで個別に計算したRE24を利用している.

res <- res %>%
  compute.RE24(split_by_Type = TRUE,
               out_RE24 = FALSE)

RV <- res%>%
  group_by(Type,Event)%>%
  dplyr::summarise(RV = mean(RV_event))

RV %>%
  spread(key = Type, value = RV)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Event wo_pit w_pit
Single 0.451 0.417
Double 0.760 0.717
Triple 1.048 0.997
HR 1.453 1.444
BB_IBB 0.344 0.309
HBP 0.344 0.310
Out -0.264 -0.223
K -0.282 -0.242

得点が入りやすい投手なしモデルでは, 単打や四球などで価値が顕著に増加し, アウトで顕著な低下している. 一方, 長打, 特にHR, は得点環境が変化しても比率で見ると価値の変化は小さい. これは大まかに言えば, 塁に出たときに得点になる平均的確率が上昇することで, 塁に出ることの効果が大きくなっているためだと考えられる.

これらの得点価値はあくまで平均的なものである. ランナーアウト状態によってその価値は動的に変化する.

res%>%
  group_by(Type, State_pre, Event)%>%
  dplyr::summarise(RV = mean(RV_event))%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  spread(key = Event, value = RV)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)%>%
  row_spec(c(1:3, 25:27), bold = T, color = "white", background = "grey")%>%
  row_spec(c(13,16,19,22, 
             37, 40, 43, 46), bold = T, color = "white", background = "royalblue")
Type State_pre Single Double Triple HR BB_IBB HBP Out K
wo_pit 000 0 0.370 0.606 0.838 1.000 0.372 0.372 -0.222 -0.231
wo_pit 000 1 0.241 0.396 0.659 1.000 0.243 0.243 -0.158 -0.164
wo_pit 000 2 0.118 0.215 0.262 1.000 0.118 0.118 -0.099 -0.102
wo_pit 001 0 0.527 0.772 1.000 1.158 0.391 0.391 -0.244 -0.405
wo_pit 001 1 0.574 0.732 1.000 1.341 0.205 0.205 -0.177 -0.557
wo_pit 001 2 0.842 0.935 0.943 1.736 0.129 0.123 -0.348 -0.362
wo_pit 010 0 0.662 0.980 1.239 1.390 0.373 0.370 -0.299 -0.440
wo_pit 010 1 0.631 0.990 1.268 1.603 0.253 0.250 -0.313 -0.343
wo_pit 010 2 0.649 0.987 1.029 1.784 0.135 0.135 -0.304 -0.316
wo_pit 011 0 0.867 1.190 1.432 1.590 0.426 0.438 -0.303 -0.562
wo_pit 011 1 0.925 1.305 1.599 1.924 0.245 0.244 -0.303 -0.754
wo_pit 011 2 1.288 1.713 1.783 2.519 0.204 0.190 -0.548 -0.568
wo_pit 100 0 0.668 1.077 1.471 1.628 0.608 0.608 -0.404 -0.366
wo_pit 100 1 0.458 0.931 1.420 1.757 0.405 0.404 -0.313 -0.294
wo_pit 100 2 0.246 0.702 1.162 1.882 0.233 0.233 -0.213 -0.220
wo_pit 101 0 0.798 1.224 1.609 1.768 0.617 0.615 -0.376 -0.595
wo_pit 101 1 0.817 1.291 1.795 2.136 0.459 0.456 -0.353 -0.638
wo_pit 101 2 0.942 1.414 1.825 2.613 0.285 0.283 -0.463 -0.488
wo_pit 110 0 0.933 1.482 1.862 2.020 0.868 0.868 -0.510 -0.562
wo_pit 110 1 0.883 1.520 2.013 2.353 0.673 0.673 -0.524 -0.461
wo_pit 110 2 0.788 1.450 1.856 2.649 0.320 0.319 -0.437 -0.452
wo_pit 111 0 1.064 1.590 1.994 2.152 1.000 1.000 -0.570 -0.760
wo_pit 111 1 1.174 1.821 2.339 2.680 1.000 1.000 -0.622 -0.811
wo_pit 111 2 1.387 2.076 2.594 3.330 1.000 1.000 -0.745 -0.771
w_pit 000 0 0.335 0.566 0.815 1.000 0.337 0.337 -0.190 -0.198
w_pit 000 1 0.214 0.363 0.614 1.000 0.216 0.216 -0.133 -0.139
w_pit 000 2 0.105 0.198 0.241 1.000 0.106 0.106 -0.084 -0.087
w_pit 001 0 0.515 0.758 1.000 1.182 0.359 0.358 -0.206 -0.399
w_pit 001 1 0.589 0.743 1.000 1.386 0.221 0.220 -0.131 -0.507
w_pit 001 2 0.854 0.935 0.927 1.761 0.109 0.104 -0.310 -0.320
w_pit 010 0 0.653 0.981 1.260 1.431 0.346 0.340 -0.263 -0.398
w_pit 010 1 0.629 0.990 1.253 1.635 0.226 0.222 -0.276 -0.303
w_pit 010 2 0.652 0.991 1.022 1.801 0.116 0.115 -0.270 -0.283
w_pit 011 0 0.869 1.190 1.464 1.646 0.344 0.351 -0.272 -0.563
w_pit 011 1 0.985 1.363 1.642 2.013 0.223 0.219 -0.223 -0.675
w_pit 011 2 1.317 1.741 1.800 2.561 0.188 0.174 -0.494 -0.517
w_pit 100 0 0.636 1.065 1.483 1.663 0.572 0.572 -0.353 -0.323
w_pit 100 1 0.434 0.888 1.401 1.784 0.372 0.371 -0.268 -0.253
w_pit 100 2 0.221 0.680 1.150 1.894 0.208 0.208 -0.186 -0.192
w_pit 101 0 0.789 1.238 1.642 1.824 0.532 0.529 -0.315 -0.537
w_pit 101 1 0.799 1.242 1.780 2.166 0.375 0.372 -0.300 -0.626
w_pit 101 2 0.942 1.415 1.818 2.657 0.274 0.270 -0.405 -0.426
w_pit 110 0 0.893 1.503 1.909 2.091 0.796 0.796 -0.456 -0.519
w_pit 110 1 0.861 1.518 2.027 2.413 0.619 0.619 -0.465 -0.413
w_pit 110 2 0.788 1.455 1.887 2.686 0.300 0.299 -0.386 -0.398
w_pit 111 0 1.108 1.691 2.113 2.295 1.000 1.000 -0.477 -0.699
w_pit 111 1 1.204 1.856 2.408 2.794 1.000 1.000 -0.517 -0.728
w_pit 111 2 1.400 2.102 2.626 3.387 1.000 1.000 -0.669 -0.698

グレーはランナー無し状態を示しているが, アウトカウントが増えるごとに多くのイベントの価値の振れ幅が小さくなっていることがわかる (つまり, +の値も-の値も絶対値が小さくなる). ノーアウトランナー無し (“000 0”; 状況のコード方法についてはsection 2.2参照) と比べ, 2アウトランナー無しは単打四死球を取る価値は1/3以下まで低下し, 一方でアウトとなるコストも1/2以下まで減少した. HRだけはアウトカウントが増えるほど価値が上がる. これはHRでは得点数自体はランナーの数+1 (ここではランナーがいないので1) で固定されているためである.

青はノーアウトでランナーが少なくとも1塁にいる状況を示しているが, ランナーが貯まるほど一般的に振れ幅は大きくなっている.

上で示した, 状況に依存した得点価値の変動は打順を考える上で重要となりうる. これは打順の各スポットはランナーやアウトの状態に関して異なる傾向を持つためである. この傾向における差異の結果, 打順ごとにイベントの得点価値は異なることになる.

投手なしモデルに関して, 各打順におけるイベントの得点価値を示す.

RV.by.spots <- res%>%
  group_by(Type, Batter, Event)%>%
  dplyr::summarise(RV = mean(RV_event))

RV.by.spots %>%
  filter(Type == "wo_pit")%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  spread(key = Batter, value = RV)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Type Event 1 2 3 4 5 6 7 8 9
wo_pit Single 0.437 0.439 0.435 0.460 0.468 0.455 0.455 0.457 0.461
wo_pit Double 0.739 0.736 0.737 0.782 0.790 0.753 0.771 0.774 0.768
wo_pit Triple 1.008 1.020 1.018 1.065 1.076 1.060 1.060 1.078 1.055
wo_pit HR 1.377 1.414 1.460 1.487 1.475 1.455 1.477 1.473 1.476
wo_pit BB_IBB 0.350 0.346 0.333 0.345 0.349 0.345 0.342 0.344 0.343
wo_pit HBP 0.350 0.348 0.331 0.348 0.348 0.340 0.343 0.342 0.344
wo_pit Out -0.258 -0.259 -0.257 -0.267 -0.271 -0.267 -0.265 -0.267 -0.268
wo_pit K -0.273 -0.274 -0.274 -0.288 -0.290 -0.283 -0.283 -0.286 -0.287

1-3番で全体的に価値の振れ幅が小さく, 特にHRで顕著である. これは塁上の走者が少ないこと (1, 2番) や, 2アウトが多いこと (3番) などが影響していると思われる. このモデルでは実際の野球とは異なり, 打者の能力差が存在しないが, このような平坦な打順に沿って, 得点価値への影響の違いが生じた. これは:

  • 1番打者から打撃を開始して打順はループする
  • 3アウトでランナーがすべて取り除かれる

などのルール (とNPBの平均的なイベント確率) だけでこのようなパターンが生じる. 変動させた得点価値によって打者を評価する場合, 結果が同じであっても, 打順のどこに置くかによって結果が変わることがわかる. wOBAが平均的な価値を利用することは, 打順のどこに置かれたかという影響を取り除くことを意図した操作でもあると理解できるだろう.

この結果を見ると下位打線が重要なように見えるかもしれない. しかし, 下位打線に比べて打順の前の方は打席数が多くなるためその補正が必要となる.  

打席数補正の前にとりあえず投手ありモデルの結果を示す.

RV.by.spots %>%
  filter(Type == "w_pit")%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  spread(key = Batter, value = RV)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Type Event 1 2 3 4 5 6 7 8 9
w_pit Single 0.370 0.385 0.391 0.433 0.447 0.438 0.433 0.440 0.441
w_pit Double 0.635 0.662 0.692 0.750 0.766 0.743 0.742 0.757 0.751
w_pit Triple 0.898 0.944 0.959 1.067 1.043 1.028 1.040 1.012 1.040
w_pit HR 1.295 1.367 1.434 1.508 1.505 1.478 1.486 1.494 1.500
w_pit BB_IBB 0.294 0.305 0.296 0.311 0.318 0.320 0.315 0.316 0.314
w_pit HBP 0.296 0.306 0.296 0.308 0.318 0.320 0.317 0.314 0.333
w_pit Out -0.202 -0.211 -0.214 -0.229 -0.234 -0.230 -0.229 -0.232 -0.231
w_pit K -0.216 -0.222 -0.229 -0.250 -0.255 -0.250 -0.251 -0.251 -0.252

全体的に低下しているが, 特に打順の前の方で顕著となっている. これは9番に投手が入ったことで, 望ましくない状態で打席に入りやすくなっているためかもしれない. このような変化については後述する (section 3.5.2).

3.4 打席数の補正

打席数の補正のために各打順での打席数をカウントし, 相対的な頻度とともに示す.

PA.by.spots <- res%>%
  group_by(Type, Batter)%>%
  dplyr::summarise(N = n() / 80000)%>%
  mutate(freq = N / sum(N))

require(gt)
PA.by.spots%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  gt()
Batter N freq
wo_pit
1 4.754 0.123
2 4.638 0.120
3 4.525 0.117
4 4.414 0.114
5 4.307 0.111
6 4.199 0.108
7 4.092 0.106
8 3.983 0.103
9 3.870 0.100
w_pit
1 4.625 0.122
2 4.514 0.119
3 4.404 0.117
4 4.299 0.114
5 4.197 0.111
6 4.097 0.108
7 3.992 0.106
8 3.880 0.103
9 3.769 0.100

打席数/試合 (N) 自体は投手を含まないモデルのほうが多い. これはアウトになりやすい投手がいないためだろう. しかし, 相対的な頻度 (freq) は異なるモデル間でほとんど同じである. いずれのモデルでも打順が一つ前にあるごとに0.1打席程度多くなった. 例えば, 1番と5番では1試合で0.4-0.45程度の打席数の違いがあり, これは5番の打席数 (4.2-4.3) を基準とすると11%程度にもなる.

3.4.1 各スポットにおける打席イベントの得点価値

上で得られた打席数を使って打順ごとの打席数の多寡を補正し, 各打順スポットにおける各イベントの得点価値を計算する. 5番打者を1として, 打席数の割合を計算し, これを使って重み付けする. 投手なしモデル.

RV.by.spots <-
  RV.by.spots %>%
  left_join(PA.by.spots)

RV.by.spots <- RV.by.spots%>%
  mutate(Relative_PA = freq / (1/9),
         Adjusted_RV = RV * Relative_PA)

RV.by.spots %>%
  filter(Type == "wo_pit")%>%
  select(-c(4:7))%>%
  spread(key = Batter, value = Adjusted_RV)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Type Event 1 2 3 4 5 6 7 8 9
wo_pit Single 0.482 0.472 0.457 0.471 0.468 0.444 0.432 0.423 0.414
wo_pit Double 0.815 0.793 0.774 0.801 0.790 0.733 0.732 0.716 0.690
wo_pit Triple 1.113 1.098 1.069 1.091 1.075 1.033 1.007 0.996 0.947
wo_pit HR 1.520 1.523 1.534 1.523 1.474 1.417 1.402 1.361 1.325
wo_pit BB_IBB 0.386 0.372 0.349 0.353 0.349 0.336 0.324 0.318 0.308
wo_pit HBP 0.386 0.375 0.348 0.356 0.348 0.332 0.325 0.316 0.309
wo_pit Out -0.285 -0.279 -0.270 -0.273 -0.271 -0.260 -0.252 -0.247 -0.240
wo_pit K -0.302 -0.295 -0.288 -0.295 -0.290 -0.276 -0.269 -0.264 -0.257

打席数を補正したことで1-3番など全体的に得点価値が高めになった. 特に出塁関連の効果が他の打順よりも高く, アウトになる (Out, K) コストが大きい. 3番打者はHRこそ価値が高いが, それ以外は1, 2, 4, 5よりも絶対値が低く, この打線では3番は比較的重要ではなかったことがわかる. これは打順が1番から始まるというルール上, 初回にアウトが記録された状態で回ってくる確率が高いことが影響している可能性が高い. 3番の重要性の低さはThe Bookでも強調されている.

下位打線では打席数が少ないため, 補正によって重要性が大きく低下した. 少なくともこのモデルでは, 下位打線が重要でないのはランナー/アウト状況によるものではなく, 主に打席数の問題だったようだ.

1, 2番で全体的に振れ幅が大きいが, 特にBBによる出塁 (BB_IBB) からの利得, やK以外のアウト (Out) へのペナルティが大きい. このモデルではおそらく1, 2番が最も重要なスポットと言えるだろう. 4番はそれに次ぐ重要なスポットであり, 特に長打の価値は1, 2番に近い. 3番, 5番は, 3番のHRを除けば, 4番よりも全体的に振れ幅が小さい. 特に2番では主要な打球イベントである単打と2塁打の価値が低い. 6-9番の重要性が低いのは明らかだろう.

投手ありモデルでも同様に計算する.

RV.by.spots %>%
  filter(Type == "w_pit")%>%
  select(-c(4:7))%>%
  spread(key = Batter, value = Adjusted_RV)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)
Type Event 1 2 3 4 5 6 7 8 9
w_pit Single 0.408 0.414 0.411 0.443 0.447 0.427 0.411 0.407 0.396
w_pit Double 0.700 0.711 0.726 0.768 0.766 0.726 0.706 0.700 0.674
w_pit Triple 0.989 1.015 1.007 1.093 1.043 1.003 0.989 0.935 0.934
w_pit HR 1.426 1.470 1.505 1.545 1.505 1.442 1.413 1.381 1.347
w_pit BB_IBB 0.324 0.327 0.311 0.318 0.318 0.312 0.300 0.292 0.282
w_pit HBP 0.327 0.329 0.311 0.315 0.318 0.312 0.301 0.291 0.299
w_pit Out -0.223 -0.227 -0.225 -0.234 -0.234 -0.225 -0.218 -0.214 -0.207
w_pit K -0.238 -0.239 -0.241 -0.256 -0.254 -0.244 -0.238 -0.232 -0.226

投手を9番に入れることで, 1, 2番は重要性はかなり低下した. 出塁の価値はこのモデルでも高いが, その他のイベントで変化は大きく, 3番や6番とそう大差がない程度になっているのではないか. このモデルでは4, 5番が比較的重要だったようだ.

3.5 各打順のランナー/アウト頻度

3.5.1 投手なしモデル

打順の各スポットにおける得点価値に違いが見られた. この違いには打席に入った時のランナー/アウト状態が貢献していると考えられる. 投手なしモデルについて各スポットでの状況の頻度を示す.

res %>%
  filter(Type == "wo_pit")%>%
  group_by(Batter, State_pre)%>%
  dplyr::summarise(N = n())%>%
  mutate(freq = N / sum(N))%>%
  select(-N)%>%
  spread(key = Batter, value = freq)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)%>%
  row_spec(3, bold = T, color = "white", background = "gray")
State_pre 1 2 3 4 5 6 7 8 9
000 0 0.388 0.182 0.179 0.255 0.242 0.217 0.224 0.231 0.225
000 1 0.129 0.270 0.135 0.132 0.181 0.174 0.158 0.162 0.167
000 2 0.105 0.103 0.205 0.111 0.107 0.142 0.138 0.126 0.130
001 0 0.001 0.003 0.002 0.002 0.002 0.002 0.002 0.002 0.002
001 1 0.005 0.005 0.009 0.007 0.006 0.007 0.007 0.007 0.006
001 2 0.010 0.010 0.010 0.017 0.013 0.011 0.013 0.013 0.013
010 0 0.009 0.019 0.011 0.010 0.013 0.012 0.011 0.011 0.012
010 1 0.018 0.017 0.033 0.020 0.018 0.023 0.023 0.021 0.021
010 2 0.023 0.024 0.023 0.039 0.028 0.025 0.030 0.030 0.029
011 0 0.002 0.002 0.004 0.003 0.003 0.003 0.003 0.003 0.003
011 1 0.006 0.006 0.006 0.010 0.007 0.007 0.008 0.008 0.008
011 2 0.007 0.007 0.007 0.007 0.011 0.009 0.008 0.009 0.010
100 0 0.049 0.106 0.051 0.049 0.070 0.067 0.060 0.062 0.064
100 1 0.062 0.061 0.125 0.065 0.063 0.085 0.082 0.075 0.076
100 2 0.063 0.065 0.063 0.121 0.071 0.067 0.086 0.084 0.078
101 0 0.004 0.004 0.007 0.005 0.004 0.005 0.005 0.005 0.005
101 1 0.009 0.009 0.009 0.016 0.012 0.010 0.012 0.013 0.012
101 2 0.014 0.014 0.014 0.014 0.022 0.018 0.017 0.018 0.019
110 0 0.014 0.014 0.028 0.015 0.014 0.019 0.018 0.017 0.017
110 1 0.024 0.025 0.025 0.045 0.029 0.026 0.033 0.033 0.030
110 2 0.030 0.030 0.031 0.030 0.052 0.037 0.033 0.040 0.041
111 0 0.004 0.004 0.004 0.008 0.006 0.005 0.006 0.006 0.006
111 1 0.009 0.009 0.009 0.009 0.016 0.012 0.010 0.012 0.013
111 2 0.012 0.011 0.011 0.011 0.011 0.017 0.014 0.013 0.014

基本的に多くの打順でノーアウトランナー無しが多い. これは, すべての状態の中で唯一, 1イニングに少なくとも1度は発生する状況であるためだと理解できる. しかし, 2番では1アウトランナー無し, 3番では2アウトランナー無し (灰色で示した) が最も多い状況となっている. これは初回に1番打者から始まるというルールのためである. この傾向は3番打者において, 出塁する利得やアウトになるコストが2, 4番などに比べて小さかったことと関連があるはずである. 2番は1アウトなので振れ幅の減少がやや小さく, また打席数がさらに多いため, このモデルでは, 3番よりも重要な打順になりやすかったと考えられる.

24状態は見るのが困難なのでランナーとアウトで大まかに分割して, 打順ごとの特徴を示す.

まず, ランナー数の頻度を示す.

res %>%
  filter(Type == "wo_pit")%>%
  group_by(Batter, Runner_CT)%>%
  dplyr::summarise(N = n())%>%
  mutate(freq = N / sum(N))%>%
  select(-N)%>%
  spread(key = Batter, value = freq)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)%>%
  row_spec(3:4, bold = T, color = "white", background = "gray")
Runner_CT 1 2 3 4 5 6 7 8 9
0 0.622 0.555 0.519 0.497 0.530 0.532 0.519 0.520 0.523
1 0.241 0.309 0.326 0.329 0.283 0.299 0.313 0.305 0.302
2 0.111 0.111 0.131 0.146 0.154 0.134 0.138 0.145 0.144
3 0.025 0.025 0.025 0.028 0.032 0.034 0.031 0.031 0.032

左の列の0-3は塁上のランナー数を示す. 特に得点価値が高いであろうランナーが複数いる状態は1-2番では少なかった. 1番では特にランナー無しの頻度が他の打順に比べて高く, これがHRの価値の低さ (打席数補正前の数値) に繋がっているだろう. 3番でも複数ランナーがいるような状態の頻度は, 4番以後に比べて小さいかもしれない.

次にアウトカウント.

res %>%
  filter(Type == "wo_pit")%>%
  group_by(Batter, Out)%>%
  dplyr::summarise(N = n())%>%
  mutate(freq = N / sum(N))%>%
  select(-N)%>%
  spread(key = Batter, value = freq)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  kable()%>%
  kable_styling(bootstrap_options = "striped", full_width = F)%>%
  row_spec(3, bold = T, color = "white", background = "gray")
Out 1 2 3 4 5 6 7 8 9
0 0.473 0.334 0.286 0.347 0.353 0.331 0.329 0.337 0.334
1 0.263 0.403 0.349 0.304 0.332 0.343 0.332 0.331 0.333
2 0.264 0.263 0.364 0.349 0.315 0.326 0.339 0.332 0.333

2アウトはやはり3番で多いが, 4番でもそれなりに多い. しかし, 上で見たように4番は2アウトランナーなしは少ないことから, ランナーはいる状態が多かったことが推測できる.

3.5.2 投手ありモデルとの比較

投手あり/なしで得られた24状況の頻度を示す.

res %>%
  group_by(Batter, State_pre, Type)%>%
  dplyr::summarise(N = n())%>%
  mutate(freq = N / sum(N))%>%
  select(-N)%>%
  spread(key = Batter, value = freq)%>%
  mutate_if(is.numeric, funs(round), 3)%>%
  gt()
Type 1 2 3 4 5 6 7 8 9
000 0
wo_pit 0.480 0.508 0.511 0.505 0.503 0.509 0.506 0.505 0.506
w_pit 0.520 0.492 0.489 0.495 0.497 0.491 0.494 0.495 0.494
000 1
wo_pit 0.452 0.481 0.508 0.512 0.506 0.503 0.510 0.508 0.505
w_pit 0.548 0.519 0.492 0.488 0.494 0.497 0.490 0.492 0.495
000 2
wo_pit 0.460 0.467 0.484 0.507 0.511 0.506 0.505 0.509 0.507
w_pit 0.540 0.533 0.516 0.493 0.489 0.494 0.495 0.491 0.493
001 0
wo_pit 0.794 0.522 0.511 0.488 0.518 0.508 0.477 0.511 0.520
w_pit 0.206 0.478 0.489 0.512 0.482 0.492 0.523 0.489 0.480
001 1
wo_pit 0.567 0.642 0.500 0.508 0.509 0.512 0.502 0.511 0.503
w_pit 0.433 0.358 0.500 0.492 0.491 0.488 0.498 0.489 0.497
001 2
wo_pit 0.525 0.571 0.601 0.496 0.508 0.503 0.504 0.498 0.509
w_pit 0.475 0.429 0.399 0.504 0.492 0.497 0.496 0.502 0.491
010 0
wo_pit 0.744 0.493 0.511 0.509 0.508 0.498 0.511 0.505 0.512
w_pit 0.256 0.507 0.489 0.491 0.492 0.502 0.489 0.495 0.488
010 1
wo_pit 0.560 0.581 0.493 0.508 0.510 0.507 0.503 0.507 0.503
w_pit 0.440 0.419 0.507 0.492 0.490 0.493 0.497 0.493 0.497
010 2
wo_pit 0.525 0.533 0.538 0.496 0.506 0.510 0.508 0.502 0.509
w_pit 0.475 0.467 0.462 0.504 0.494 0.490 0.492 0.498 0.491
011 0
wo_pit 0.727 0.683 0.515 0.496 0.505 0.479 0.485 0.515 0.512
w_pit 0.273 0.317 0.485 0.504 0.495 0.521 0.515 0.485 0.488
011 1
wo_pit 0.580 0.632 0.605 0.511 0.509 0.509 0.503 0.516 0.521
w_pit 0.420 0.368 0.395 0.489 0.491 0.491 0.497 0.484 0.479
011 2
wo_pit 0.520 0.585 0.581 0.557 0.507 0.508 0.504 0.498 0.508
w_pit 0.480 0.415 0.419 0.443 0.493 0.492 0.496 0.502 0.492
100 0
wo_pit 0.698 0.481 0.508 0.510 0.503 0.503 0.505 0.506 0.504
w_pit 0.302 0.519 0.492 0.490 0.497 0.497 0.495 0.494 0.496
100 1
wo_pit 0.551 0.533 0.482 0.508 0.514 0.504 0.503 0.510 0.507
w_pit 0.449 0.467 0.518 0.492 0.486 0.496 0.497 0.490 0.493
100 2
wo_pit 0.528 0.508 0.504 0.485 0.509 0.509 0.503 0.504 0.511
w_pit 0.472 0.492 0.496 0.515 0.491 0.491 0.497 0.496 0.489
101 0
wo_pit 0.673 0.700 0.503 0.521 0.503 0.504 0.507 0.506 0.508
w_pit 0.327 0.300 0.497 0.479 0.497 0.496 0.493 0.494 0.492
101 1
wo_pit 0.582 0.603 0.608 0.511 0.503 0.514 0.512 0.510 0.502
w_pit 0.418 0.397 0.392 0.489 0.497 0.486 0.488 0.490 0.498
101 2
wo_pit 0.552 0.565 0.553 0.563 0.508 0.511 0.514 0.512 0.505
w_pit 0.448 0.435 0.447 0.437 0.492 0.489 0.486 0.488 0.495
110 0
wo_pit 0.704 0.701 0.488 0.506 0.506 0.508 0.500 0.503 0.500
w_pit 0.296 0.299 0.512 0.494 0.494 0.492 0.500 0.497 0.500
110 1
wo_pit 0.596 0.580 0.577 0.490 0.511 0.512 0.513 0.502 0.510
w_pit 0.404 0.420 0.423 0.510 0.489 0.488 0.487 0.498 0.490
110 2
wo_pit 0.565 0.551 0.542 0.536 0.491 0.508 0.511 0.506 0.508
w_pit 0.435 0.449 0.458 0.464 0.509 0.492 0.489 0.494 0.492
111 0
wo_pit 0.699 0.709 0.701 0.509 0.503 0.511 0.502 0.500 0.503
w_pit 0.301 0.291 0.299 0.491 0.497 0.489 0.498 0.500 0.497
111 1
wo_pit 0.624 0.616 0.607 0.600 0.508 0.502 0.502 0.507 0.502
w_pit 0.376 0.384 0.393 0.400 0.492 0.498 0.498 0.493 0.498
111 2
wo_pit 0.575 0.574 0.578 0.562 0.562 0.496 0.500 0.511 0.506
w_pit 0.425 0.426 0.422 0.438 0.438 0.504 0.500 0.489 0.494

全体的に好ましい状況 (アウトカウントが少ない, ランナーが多い) が減少している. 特に, ノーアウトでランナーがいる状況の減少は顕著である. 打順の前ほど大きな変化があることも確認できる. この結果は, 投手は非常に高い確率でアウトになることで, アウトカウントを増やす, あるいは3アウト目を記録して塁上の走者を除くであろうという常識的な予想と一致する. この状況の変化が投手なしモデルで見られた, 1, 2番における得点価値の振れ幅の減少の原因だろう.

4 まとめ

単純なサンプリングを利用したマルコフ連鎖モデルで野球をシミュレーションし, 主に打順ごとのイベントの得点価値について検討した.

打順の組み立てを評価する上では,

  • 各打順での打撃結果の得点価値 (ランナー/アウト状況に依存; 投手からの“距離”の影響が大きい)
  • 打席数 (打順は前にあればあるほど打席数が増える)

などの要素が重要になると考えられる. この2つの要素はそれぞれ異なる効果で得点力を改善すると考えることができる. 1つめの要素は起こるであろうイベントのもつ得点価値を高め, チームwOBAのわりに得点が高い状況を狙う方法と言えるだろう. 一方, 2つめの要素は, チームwOBA自体を高めることを志向した方策と言える. 打順の効果の議論に関して, チームwOBA (or BsR, RC, XR, etc..) とチーム得点の相関が十分に強いためにそれほど重要では無い, というようなものがあるが, 実のところチームwOBA自体に, 打順の効果が部分的に含まれている可能性がある. この議論では打順の効果を適切に捉えることはできていないだろう

マルコフ連鎖モデルを使った打順の評価では, 多くの場合, 最終的な得点を主要な評価項目として利用する. ここでは, 主に打順ごとの得点価値に注目した. これは, 打順への介入によって生じる得点力変化に至る主要なメカニズムだと考えられる. これはThe Bookで使われた方法だが, 単に得点の大小が生じるかどうかという結果ではなく, なぜ得点の大小が生じるかを説明できるようになる利点がある. もちろん, 打順自体の評価としては最終的な得点をみる必要があるだろう. この得点価値は打線を構成する打者やその順序によってある程度変動する. しかし, 平均的な選手で構成された打線と最低限の野球のルールによって生じる得点価値の変化を捉えるだけでも, おおまかな打順の改善方法を考えるための基準が得られる. どの程度の改善が可能かは, 元の打順が持つ非効率性の大きさにも依存するが, The Bookでは10-15点程度の改善が可能だと議論している (The Book chapter 5は, ここでは考えていないような走力や併殺などのさらに細かい要素についても検討している. この効果の大きさはそれらも含めての話だろうが, そのような改善が可能である場合においては, おそらく大部分はここで議論したような要素によってもたらされるだろう).

打順ごとの得点価値は, 打席数の補正がない状態では, 打線の後ろの方が高い. これは, 主に初回に1番から試合が始まるために, 1巡目において1番はランナーがなく, 2-3番はアウトが記録された状態かつ複数のランナーは存在しないことが多いためだろう. しかし, 打線の前は当然打席数が多く, 重要性の評価ではこの補正が必要となる. 1番と5番を比較しても1試合あたりで0.4打席以上の違いがあり, そのためこの要素を考慮すると上位打線の重要性が高まる. しかし, 3番打者は補正の程度も小さく, 元の価値も低いため, 投手を入れないモデルでは重要性が低い結果となった. この打席数の違いのため, 効率性の高い打順では能力の高い打者を前に揃えることになる. これは先制点につながりやすい一方で, 前借り的な側面も持ちうるため, その後の得点を減らし, 勝負弱い打線と言った主観的な評価を受けるリスクもあるかもしれない (その効果は, 人間の認知で検出できるほどの大きさではないと思うが).

The Bookにおいて示された, 打順の各スポットについての指針を大まかに示す (pp.132).

  • チームで特に優秀な3人の打者は, 1, 2, 4に置く.
  • それに次ぐ2人の打者は, 3, 5に置く.
  • 特に出塁に優れたタイプは, 1, 2に置く.

本ポストにおいて, 投手なしモデル得られた結果は概ねこの指針と合致すると言えるだろう. 投手ありモデルでは1, 2番の重要性は大きく低下した. これは9番に投手を置いたことで, アウトカウントが多いか, ランナーが少ない状態で, 打順の先頭に戻ったためだろう. DHの有無, 投手のスポット (8 or 9) は, 適切な打順を考慮する上で重要な要素になりうるだろう.

ここでのモデルは平均的な能力を持つ打者だけで構成されていたが, 実際の各打者からイベント確率を計算すれば打者の能力の違いを考慮したモデルも容易に構築できる. 打者の能力の違いを考慮したモデルについては以後のポストで検討する.

ここではモデルだけを扱ったが, 打順ごとの状況の違いと, それに伴う打順ごとの得点価値などは現実のply-by-play dataでも容易に計算可能であり, より現実的な状況における評価にも利用できるだろう. 注意点としては, 現実の試合数はモデルに比べて非常に少ないので, 打順ごとに分けていくタイプのデータでは推定が非常に甘くならざるを得ない (大雑把に言って, 全体に適用する場合に比べてサンプルが1/9程度まで減るので誤差が3倍程度になる). 特定のチームに対して適用するのはおそらく意味がないだろう. NPBでは年間の試合数が少ないために特に注意が必要だろう.

5 参考文献

蛭川皓平, (岡田友輔 監修), 2019, 打順の文脈を解析する in セイバーメトリクス入門, 水曜社.

Marchi, Albert, Baumer, 2018, Analyzing Baseball Data with R, CRC press.

Sokol, 2018, An Intuitive Markov Chain Lesson From Baseball, INFORMS Transactions on Education.

Tango, MGL, Dolphin, 2007, The Book, Potomac Books.

Retrosheet.

NPB公式.

ssharpe42, BaseballMarkov.

Deltagraphs, 1.02.

Tango, How are runs really created.

5.1 関数定義

# 各イベント+状態による進塁規則
require(tidyverse)
# retrosheetから計算した状態遷移した時に記録される得点を示す行列
transition.table <- read_csv("required_tables/Make_New_State_retro_16-17.csv", locale=locale(encoding="CP932"))
# Outはretrosheetのgeneric outをそのまま使用
# バントやSFの大部分はgeneric outに含まれる
# error, FC etc.は無視

# 状態が遷移した時に記録される得点計算に利用する表
# Albert et al.から拝借している気がする
RunTable <- read_csv("required_tables/StateTransitionRunMatrix.csv", locale=locale(encoding="CP932"))

# 1試合を計算する関数の設定
simulate.one.game <- function(df = lineup, game = 1){
  # 初期条件
  s <- "000 0"; # 状態を示す変数 1,2,3塁ランナー状態 (0 or 1) + アウトカウント. 3アウトは"3"でコードしている
  runs <- 0;  # 得点 (イニングごとに0に戻していることに注意)
  spot <- 1; # 打順
  inning <- 1; # イニング
  game_end <- FALSE ; # 試合終了判定用のflag
  # 初期条件. 以下は記録のためのオブジェクト
  innings <- NULL; 
  batter.rec = NULL;
  # 下の5つはRE, RVの計算に使う (pre, postは片方あれば, あとでleadとかでイニングごとにずらしても可)
  event.rec <- NULL; state.pre <- NULL; state.post <- NULL; runs.pre <- NULL; runs.post <- NULL; 
  
  while(game_end == FALSE){ # while 1: game_end flagについては下で記述
    
    while(s != "3"){ # # while 2: イニングは3アウトになるまで
      # イベント前の状態を記録
      state.pre = c(state.pre, s)
      runs.pre = c(runs.pre, runs)
      
      # 打順データの"spot"行からイベントをサンプリング
      bat.event <- sample(names(df), 1, prob = df[spot,]) 
      
      # 進塁規則に従って状況を遷移させる
      # MLB16-17での確率を利用する
      # もっと単純なD’Esopo and Lefkowitzの進塁規則などでもそこそこの精度はでる
      transition.prob <- transition.table %>%
        ungroup()%>%
        filter(STATE == s, Event_des == bat.event)%>%
        select(-c(1:3))
      s.new <- sample(names(transition.prob), 1, prob = as.numeric(transition.prob))
      # s.newは打席が終わった状態を示す
      
      # sからs.newに移行したときに記録される得点
      runs <- as.numeric(runs + RunTable[RunTable$State_pre == s, s.new])
      
      # 結果を記録する
      runs.post <- c(runs.post, runs) # 得点を記録する
      event.rec <- c(event.rec, bat.event) #eventを記録していく
      state.post <- c(state.post, s.new) #stateを記録していく
      batter.rec <- c(batter.rec, spot) # 打順を記録
      innings <- c(innings, inning) # イニング(ry
      
      # 打順を進める
      spot <- ifelse(spot == 9, 1, spot +1) 
      
      # 次の打席に移る前に状態を示すsをs.newで置換
      s <- s.new 
      # while loop1の先頭に戻る
    } # while 2の終わり. つまりイニングの終わり
    
    # 試合終了判定 game_end flangの管理
    if(! inning %in% 8:9){ # inningが8 or 9以外では次のイニングに入る
      inning <- inning +1
      s <- "000 0"
      runs <- 0
    }else if(inning==8){ # inningが8の場合, 次のイニングに入るかどうか調べる
      U = runif(1) # 一様分布から乱数を取得
      if(U < 0.93){ # 乱数が0.93より小さいと9回に入る
        inning = inning + 1
        s <- "000 0"
        runs <- 0
      }else{
        game_end=TRUE}
    }else{ # 9回なら終わり
      game_end = TRUE
    } # 試合終了判定おわり
    
  } # while 1のおわり
  
  # 結果をdfにまとめる 
  data <- data.frame(Game = game,
                     Inning = innings,
                     Batter = batter.rec,
                     Event = event.rec, 
                     State_pre =state.pre,
                     State_post = state.post,
                     Runs_pre = runs.pre,
                     Runs_post = runs.post,
                     stringsAsFactors = FALSE)
  # Eventをfactor typeにして見やすいようなレベルを設定しておく
  data$Event <- factor(data$Event, levels = c("Single", "Double", "Triple", "HR",
                                              "BB_IBB", "HBP", "Out", "K"))
  return(data)
} # function終わり

# 計算結果のdfを受け取ってRPIを計算する関数
compute.runs.per.inning <- function(df = res,
                                    split_by_Type = FALSE){
  if(split_by_Type == TRUE){
    out <- df%>%
      group_by(Type, Game, Inning)%>%
      dplyr::summarise(Runs = max(Runs_post))%>%
      ungroup()%>%
      group_by(Type)%>%
      dplyr::summarise(RPI = mean(Runs),
                       Innings = n(),
                       SD = sd(Runs),
                       CI_95_upper = RPI + SD /sqrt(Innings) * qt(0.975, df = Innings-1),
                       CI_95_lower = RPI - SD /sqrt(Innings) * qt(0.975, df = Innings-1),
                       RPG = RPI * 8.93)
  }else{
    out <- df%>%
      group_by(Game, Inning)%>%
      dplyr::summarise(Runs = max(Runs_post))%>%
      ungroup()%>%
      dplyr::summarise(RPI = mean(Runs),
                       Innings = n(),
                       SD = sd(Runs),
                       CI_95_upper = RPI + SD /sqrt(Innings) * qt(0.975, df = Innings-1),
                       CI_95_lower = RPI - SD /sqrt(Innings) * qt(0.975, df = Innings-1),
                       RPG = RPI * 8.93)
  }
  return(out)
}

# 計算結果を受け取ってRE24を返す (out_RE24 = TRUE) か,
# あるいは計算結果をRVの計算が利用可能なdfに変換する (out_RE24 = FALSE)
# ための関数
compute.RE24 <- function(df = res,
                         split_by_Type = FALSE,
                         out_RE24 = TRUE){ 
  if(split_by_Type == TRUE){
    max.runs <- df%>%
      group_by(Type, Game, Inning)%>%
      dplyr::summarise(Max_runs = max(Runs_post))
    
    df <- df %>%
      left_join(max.runs)
    
    df <- df %>%
      mutate(Runs_ROI = Max_runs - Runs_pre)
    
    RE24 <- df %>%
      group_by(Type, State_pre)%>%
      dplyr::summarise(RE = mean(Runs_ROI))
    
    if(out_RE24 == TRUE){ # RE24の計算結果を返す場合
      
      return(RE24)
      
    }else{                  # RVの計算のためにRE24の結果をdfに付与する場合      
      names(RE24)[3] <- "RE_pre"
      
      df <- df %>%
        left_join(RE24)
      
      names(RE24) <- c("Type","State_post", "RE_post")
      df <- df %>%
        left_join(RE24)
      
      df[is.na(df)] <- 0
      
      df <- df %>%
        mutate(RV_event = RE_post - RE_pre + Runs_post - Runs_pre)
      
      return(df)
    }
    
  }else{
    max.runs <- df%>%
      group_by(Game, Inning)%>%
      dplyr::summarise(Max_runs = max(Runs_post))
    
    df <- df %>%
      left_join(max.runs)
    
    df <- df %>%
      mutate(Runs_ROI = Max_runs - Runs_pre)
    
    RE24 <- df %>%
      group_by(State_pre)%>%
      dplyr::summarise(RE = mean(Runs_ROI))
    
    if(out_RE24 == TRUE){ # RE24の計算結果を返す場合
      
      return(RE24)
      
    }else{                  # RVの計算のためにRE24の結果をdfに付与する場合      
      names(RE24)[2] <- "RE_pre"
      
      df <- df %>%
        left_join(RE24)
      
      names(RE24) <- c("State_post", "RE_post")
      df <- df %>%
        left_join(RE24)
      
      df[is.na(df)] <- 0
      
      df <- df %>%
        mutate(RV_event = RE_post - RE_pre + Runs_post - Runs_pre)
      
      return(df)
    }
  }
}

# RE24のlong tableを受け取って表に変換する関数
generate.RE24.matrix <- function(df = RE24){
  RE.matrix <- t(round(matrix(df$RE, 3, 8), 3))
  dimnames(RE.matrix)[[2]] <- c("0 outs", "1 out", "2 outs")
  dimnames(RE.matrix)[[1]] <- c("000", "001", "010", "011", 
                                "100", "101", "110", "111")
  return(RE.matrix)
}

# 打席数で補正したイベントごとの得点価値を計算する
compute.adjusted.RV <- function(df = res,
                                split_by_Type = FALSE){
  
  if(split_by_Type == TRUE){
    RV.by.spots <- df%>%
      group_by(Type, Batter, Event)%>%
      dplyr::summarise(RV = mean(RV_event))
    
    PA.by.spots <- df%>%
      group_by(Type, Batter)%>%
      dplyr::summarise(N = n())%>%
      mutate(freq = N / sum(N))
    
    RV.by.spots <-
      RV.by.spots %>%
      left_join(PA.by.spots)
    
    RV.by.spots <- RV.by.spots%>%
      mutate(Relative_PA = freq / (1/9),
             Adjusted_RV = RV * Relative_PA)
    
  }else{
    RV.by.spots <- df%>%
      group_by(Batter, Event)%>%
      dplyr::summarise(RV = mean(RV_event))
    
    PA.by.spots <- df%>%
      group_by(Batter)%>%
      dplyr::summarise(N = n())%>%
      mutate(freq = N / sum(N))
    
    RV.by.spots <-
      RV.by.spots %>%
      left_join(PA.by.spots)
    
    RV.by.spots <- RV.by.spots%>%
      mutate(Relative_PA = freq / (1/9),
             Adjusted_RV = RV * Relative_PA)
  }
  return(RV.by.spots)
}