## Run Expectancy Distributions of MLB Game States

# Team wins—a chain of player events

The performance of a baseball player includes numerous on-field (and off-field) activities, roughly divided into skills of offensive (*e.g.*, batting, baserunning) and defensive (*e.g.*, pitching, fielding). The value of each skill depends upon its contribution to team wins, and wins are the difference in team score (runs). Breaking this down further, runs depend on advancing each half inning of baseball from one “state” to another. By state, we mean the base locations of any runners and the number of outs.

We can determine the range of runs expected to result from each state and then link the outcomes of each player’s possible contributions to changes in *run expectancy*. In this article, we estimate the *distribution* of run expectancy for each game state.

# Data and sources for estimating run expectancies

To determine run expectancy for differing “game states” in baseball, we need observations of each *at bat* event over a given time frame. Run expectancy is typically reported over a regular season, so we will focus here on the 2017 MLB season. Along with game states, we need to know the batting team’s score before each at bat in each half inning and the score at the half inning’s end.

At bat information is publicly available from multiple sources. The at bat data from Major League Baseball Advanced Media (MLBAM), which can be conveniently “scraped” using the `R`

package `PitchRx`

provides one source. Another source, a little more tedius to obtain, but easier to work with once gathered is available as *event* files from retrosheet.org, and the website provides instructions for gathering that data. We begin here with data in hand, loaded in the `R`

object named `rs`

, which we subset below to keep only needed variables.

```
rs <-
subset(rs,
select = c(GAME_ID, EVENT_ID, INN_CT, BAT_HOME_ID, OUTS_CT,
EVENT_OUTS_CT, BASE1_RUN_ID, BASE2_RUN_ID, BASE3_RUN_ID,
AWAY_SCORE_CT, HOME_SCORE_CT, EVENT_CD, BAT_EVENT_FL))
```

These variables are structured as follows,

```
# This is simply a convenience function to display the
# structure of an R object as a data frame.
str2df <- function(x, n=5) {
data.frame(Variable = names(x),
Classe = sapply(x, typeof),
Values = sapply(x, function(x) paste0(head(x, n = n), collapse = ", ")),
row.names = NULL)
}
# load libraries
require(dplyr)
require(knitr)
require(stargazer)
str2df(rs, 4)
```

Variable | Classe | Values |
---|---|---|

GAME_ID | character | ANA201704070, ANA201704070, ANA201704070, ANA201704070 |

EVENT_ID | integer | 1, 2, 3, 4 |

INN_CT | integer | 1, 1, 1, 1 |

BAT_HOME_ID | integer | 0, 0, 0, 0 |

OUTS_CT | integer | 0, 0, 1, 2 |

EVENT_OUTS_CT | integer | 0, 1, 1, 0 |

BASE1_RUN_ID | character | , seguj002, seguj002, seguj002 |

BASE2_RUN_ID | character | , , , |

BASE3_RUN_ID | character | , , , |

AWAY_SCORE_CT | integer | 0, 0, 0, 0 |

HOME_SCORE_CT | integer | 0, 0, 0, 0 |

EVENT_CD | integer | 14, 3, 2, 4 |

BAT_EVENT_FL | logical | TRUE, TRUE, TRUE, FALSE |

where `GAME_ID`

is a unique game Id, `EVENT_ID`

is a unique observation (usually at bat) within each game, `BAT_EVENT_FL`

indicates a batting event, `INN_CT`

is the inning number, a `BAT_HOME_ID`

of `0`

indicates top of the inning and `1`

, bottom of the inning, `OUTS_CT`

is the number of outs preceding the observed event (at bat), `EVENT_OUTS_CT`

is the number of outs after the observed at bat, `BASE{1,2,3}_RUN_ID`

identifies any baserunners preceeding the event, `{AWAY, HOME}_SCORE_CT`

are the away and home team scores preceeding the event.

# Calculate runs scored after every game state of a half inning

Before modeling run expectancy with this data, we “transform” some variables and create new variables. We group these data by half inning, sort observations therein by event, remove incomplete half-innings (most typically occurring in bottom of the ninth or greater inning when the home team breaks the tied score), and calculate the difference between the batting team’s score preceeding the event and their score at half-inning’s end. After using `OUTS_CT`

as a number to remove incomplete half innings, we transform it into a category or factor for modeling. In code,

```
rs <-
group_by(rs, GAME_ID, INN_CT, BAT_HOME_ID) %>%
arrange(GAME_ID, INN_CT, BAT_HOME_ID, EVENT_ID) %>%
filter( (last(OUTS_CT) + last(EVENT_OUTS_CT))==3 ) %>%
mutate(RUNS_AFTER_EVENT =
ifelse(BAT_HOME_ID == 0,
last(AWAY_SCORE_CT) - AWAY_SCORE_CT,
last(HOME_SCORE_CT) - HOME_SCORE_CT))
```

Variables for the base runners (shown in the first two `transform()`

below) are combined into a single variable with 8 “levels”: “0-0-0” represents bases empty, “1-0-0” represents a runner on first, “0-2-0” a runner on second, and so on. Observations in the data primarily relate to the batting event, and we filter out others. Then, we drop variables we no longer need (using `subset()`

),

```
# create variable of base runner states
rs <- transform(rs,
ON_1B = as.integer(!BASE1_RUN_ID == ""),
ON_2B = as.integer(!BASE2_RUN_ID == "")*2,
ON_3B = as.integer(!BASE3_RUN_ID == "")*3)
rs <- transform(rs, RUNNERS = paste0(ON_1B, "-", ON_2B, "-", ON_3B))
runners_on_base <-
c("0-0-0", "1-0-0", "0-2-0", "0-0-3", "1-2-0", "1-0-3", "0-2-3", "1-2-3")
rs <- transform(rs,
RUNNERS = factor(RUNNERS,
levels=runners_on_base,
labels=runners_on_base))
# tranform outs into factor
rs <- transform(rs, OUTS_CT = factor(OUTS_CT,
levels=c("0", "1", "2")))
# drop unneeded variables
rs <- subset(rs,
subset = BAT_EVENT_FL == TRUE,
select = c(-ON_1B, -ON_2B, -ON_3B, -BAT_EVENT_FL,
-BASE1_RUN_ID, -BASE2_RUN_ID, -BASE3_RUN_ID))
```

# Estimating run expectancy

## Preserving and understanding uncertainty of our estimates

Run expectancies are usually reported as “point estimates,” which have been calculated as simple averages or as a “maximum likelihood estimate” of the data. Thus, we may see reported, say, an expectancy of `0.5`

runs in the beginning of each half inning with no one is one base and no outs. But each half inning is different. There is, obviously, no uncertainty in simply counting what has happened. That’s our data. But we’d like to understand how much the outcomes vary and are expected to vary. Thus, here, we depart from the typical approach to estimating run expectancies of game states and use the bayesian modeling software Stan, which models and preserves this uncertainty in our estimates and predictions. The bonus is that this approach makes understanding the uncertainty straight forward.

## In half innings, more runs occur less frequently

Simply from observing baseball games, we glean that, for a given half inning, the batting team scores incrementally, and each subsequent score is generally less likely to occur. The most common half-inning is one without the batting team scoring. A single score is less common, and scoring, say, ten or more is very rare. From basic probability, we can represent counting the frequency of scoring zero or more in a half inning using the poisson distribution or, improving on this intuition, we observe that, as mentioned, many observations will have a zero score (in stats jargon, the data is overdispersed). Thus, instead of poisson, we model the data using a “negative binomial”, which is similar to poisson but considers these many zero observations, too.

Our intuition generally agrees with the data,

```
require(ggplot2)
require(ggthemes)
rs %>%
group_by(GAME_ID, INN_CT, BAT_HOME_ID) %>%
arrange(EVENT_ID) %>%
mutate(RUNS = ifelse(BAT_HOME_ID==1,
last(HOME_SCORE_CT) - first(HOME_SCORE_CT),
last(AWAY_SCORE_CT) - first(AWAY_SCORE_CT))) %>%
summarise(RUNS = max(RUNS)) %>%
arrange(RUNS) %>%
ggplot(mapping = aes(x=RUNS)) + stat_count() +
scale_x_continuous(breaks = c(0:13)) +
labs(x = "Runs scored in half-inning", y = "Frequency") +
theme_tufte(base_family = "sans")
```

## Building a bayesian run expectancy model

The model below uses the `R`

package `rstanarm`

’s interface to Stan’s fully-bayesian inferencing capabilities. We model `RUNS_AFTER_EVENT`

as a function of (`~`

) all combinations of `RUNNERS`

and `OUTS_CT`

, using `neg_binomial_2`

as we just discussed, and give our model the cleaned data `rs`

. Absent including `-1`

in the formula, the model would use the first game state as a reference instead of separately reporting it with the other game states. It is not always appropriate to exclude this intercept, and we can *google* to learn more. The remaining options, `QR = TRUE`

and `chains = 1, iter = 500, cores = 4, seed = TRUE`

, have been described in depth here mc-stan.org/rstanarm/reference/stan_glm.html.

```
require(rstanarm)
fit <- stan_glm(RUNS_AFTER_EVENT ~ -1 + RUNNERS : OUTS_CT,
family = neg_binomial_2,
data = rs,
QR = TRUE,
chains = 1, iter = 500, cores = 4, seed = TRUE)
```

## Model summary

We summarise the model,

`summary(fit)`

```
##
## Model Info:
##
## function: stan_glm
## family: neg_binomial_2 [log]
## formula: RUNS_AFTER_EVENT ~ -1 + RUNNERS:OUTS_CT
## algorithm: sampling
## priors: see help('prior_summary')
## sample: 250 (posterior sample size)
## num obs: 184463
##
## Estimates:
## mean sd 2.5% 25% 50%
## RUNNERS0-0-0:OUTS_CT0 -0.7 0.0 -0.7 -0.7 -0.7
## RUNNERS1-0-0:OUTS_CT0 -0.1 0.0 -0.1 -0.1 -0.1
## RUNNERS0-2-0:OUTS_CT0 0.2 0.0 0.1 0.1 0.2
## RUNNERS0-0-3:OUTS_CT0 0.3 0.1 0.2 0.3 0.3
## RUNNERS1-2-0:OUTS_CT0 0.4 0.0 0.3 0.4 0.4
## RUNNERS1-0-3:OUTS_CT0 0.6 0.0 0.5 0.6 0.6
## RUNNERS0-2-3:OUTS_CT0 0.7 0.1 0.6 0.7 0.7
## RUNNERS1-2-3:OUTS_CT0 0.8 0.1 0.7 0.7 0.8
## RUNNERS0-0-0:OUTS_CT1 -1.3 0.0 -1.3 -1.3 -1.3
## RUNNERS1-0-0:OUTS_CT1 -0.7 0.0 -0.7 -0.7 -0.7
## RUNNERS0-2-0:OUTS_CT1 -0.4 0.0 -0.4 -0.4 -0.4
## RUNNERS0-0-3:OUTS_CT1 0.0 0.0 -0.1 -0.1 0.0
## RUNNERS1-2-0:OUTS_CT1 -0.1 0.0 -0.1 -0.1 -0.1
## RUNNERS1-0-3:OUTS_CT1 0.2 0.0 0.1 0.1 0.2
## RUNNERS0-2-3:OUTS_CT1 0.4 0.0 0.3 0.4 0.4
## RUNNERS1-2-3:OUTS_CT1 0.5 0.0 0.4 0.4 0.5
## RUNNERS0-0-0:OUTS_CT2 -2.2 0.0 -2.3 -2.3 -2.2
## RUNNERS1-0-0:OUTS_CT2 -1.5 0.0 -1.6 -1.5 -1.5
## RUNNERS0-2-0:OUTS_CT2 -1.1 0.0 -1.2 -1.2 -1.1
## RUNNERS0-0-3:OUTS_CT2 -1.0 0.0 -1.1 -1.1 -1.0
## RUNNERS1-2-0:OUTS_CT2 -0.8 0.0 -0.9 -0.9 -0.8
## RUNNERS1-0-3:OUTS_CT2 -0.8 0.0 -0.8 -0.8 -0.8
## RUNNERS0-2-3:OUTS_CT2 -0.6 0.1 -0.7 -0.7 -0.6
## RUNNERS1-2-3:OUTS_CT2 -0.3 0.0 -0.4 -0.3 -0.3
## reciprocal_dispersion 0.5 0.0 0.5 0.5 0.5
## mean_PPD 0.5 0.0 0.5 0.5 0.5
## log-posterior -158999.2 3.9 -159007.9 -159001.6 -158998.8
## 75% 97.5%
## RUNNERS0-0-0:OUTS_CT0 -0.7 -0.7
## RUNNERS1-0-0:OUTS_CT0 -0.1 -0.1
## RUNNERS0-2-0:OUTS_CT0 0.2 0.2
## RUNNERS0-0-3:OUTS_CT0 0.4 0.5
## RUNNERS1-2-0:OUTS_CT0 0.4 0.4
## RUNNERS1-0-3:OUTS_CT0 0.6 0.7
## RUNNERS0-2-3:OUTS_CT0 0.8 0.8
## RUNNERS1-2-3:OUTS_CT0 0.8 0.9
## RUNNERS0-0-0:OUTS_CT1 -1.3 -1.3
## RUNNERS1-0-0:OUTS_CT1 -0.7 -0.6
## RUNNERS0-2-0:OUTS_CT1 -0.3 -0.3
## RUNNERS0-0-3:OUTS_CT1 0.0 0.1
## RUNNERS1-2-0:OUTS_CT1 -0.1 0.0
## RUNNERS1-0-3:OUTS_CT1 0.2 0.3
## RUNNERS0-2-3:OUTS_CT1 0.4 0.5
## RUNNERS1-2-3:OUTS_CT1 0.5 0.5
## RUNNERS0-0-0:OUTS_CT2 -2.2 -2.2
## RUNNERS1-0-0:OUTS_CT2 -1.5 -1.5
## RUNNERS0-2-0:OUTS_CT2 -1.1 -1.1
## RUNNERS0-0-3:OUTS_CT2 -1.0 -1.0
## RUNNERS1-2-0:OUTS_CT2 -0.8 -0.8
## RUNNERS1-0-3:OUTS_CT2 -0.7 -0.7
## RUNNERS0-2-3:OUTS_CT2 -0.6 -0.5
## RUNNERS1-2-3:OUTS_CT2 -0.2 -0.2
## reciprocal_dispersion 0.5 0.5
## mean_PPD 0.5 0.5
## log-posterior -158996.6 -158993.0
##
## Diagnostics:
## mcse Rhat n_eff
## RUNNERS0-0-0:OUTS_CT0 0.0 1.0 250
## RUNNERS1-0-0:OUTS_CT0 0.0 1.0 250
## RUNNERS0-2-0:OUTS_CT0 0.0 1.0 250
## RUNNERS0-0-3:OUTS_CT0 0.0 1.0 250
## RUNNERS1-2-0:OUTS_CT0 0.0 1.0 250
## RUNNERS1-0-3:OUTS_CT0 0.0 1.0 250
## RUNNERS0-2-3:OUTS_CT0 0.0 1.0 250
## RUNNERS1-2-3:OUTS_CT0 0.0 1.0 250
## RUNNERS0-0-0:OUTS_CT1 0.0 1.0 250
## RUNNERS1-0-0:OUTS_CT1 0.0 1.0 250
## RUNNERS0-2-0:OUTS_CT1 0.0 1.0 250
## RUNNERS0-0-3:OUTS_CT1 0.0 1.0 250
## RUNNERS1-2-0:OUTS_CT1 0.0 1.0 250
## RUNNERS1-0-3:OUTS_CT1 0.0 1.0 250
## RUNNERS0-2-3:OUTS_CT1 0.0 1.0 250
## RUNNERS1-2-3:OUTS_CT1 0.0 1.0 250
## RUNNERS0-0-0:OUTS_CT2 0.0 1.0 250
## RUNNERS1-0-0:OUTS_CT2 0.0 1.0 250
## RUNNERS0-2-0:OUTS_CT2 0.0 1.0 250
## RUNNERS0-0-3:OUTS_CT2 0.0 1.0 250
## RUNNERS1-2-0:OUTS_CT2 0.0 1.0 250
## RUNNERS1-0-3:OUTS_CT2 0.0 1.0 250
## RUNNERS0-2-3:OUTS_CT2 0.0 1.0 250
## RUNNERS1-2-3:OUTS_CT2 0.0 1.0 250
## reciprocal_dispersion 0.0 1.1 32
## mean_PPD 0.0 1.0 250
## log-posterior 0.4 1.0 80
##
## For each parameter, mcse is Monte Carlo standard error, n_eff is a crude measure of effective sample size, and Rhat is the potential scale reduction factor on split chains (at convergence Rhat=1).
```

Reviewing the model summary, `n_eff`

and `Rhats`

both look good. The various quantiles of coefficients are reported as the `log`

of run expectancies because the model used `neg_binomial_2`

family, which defaults to a `link`

function transforming these median estimates of our game states to `log`

units. We can more easily interpret them by applying an `exp()`

, as shown below along with organizing their median values into a matrix.

## The median run expectancy matrix

The *median* run expectancy for each of the 24 game states are as follows,

```
# Extract and name coefficients from model
m <- exp(coef(fit))
m <- matrix(m, nrow = 8, ncol = 3, byrow = F)
rownames(m) <- levels(rs$RUNNERS)
colnames(m) <- levels(rs$OUTS_CT)
# Reorder rows according to bases
runners_on_base <-
c("0-0-0", "1-0-0", "0-2-0", "0-0-3", "1-2-0", "1-0-3", "0-2-3", "1-2-3")
m <- m[match(runners_on_base, rownames(m)),]
# Show Matrix as Table
m
```

```
## 0 1 2
## 0-0-0 0.5110524 0.2682680 0.1068107
## 1-0-0 0.8927785 0.5117634 0.2193748
## 0-2-0 1.1664494 0.6953203 0.3179510
## 0-0-3 1.3992135 0.9726433 0.3545467
## 1-2-0 1.4842034 0.9216748 0.4308111
## 1-0-3 1.7917773 1.1931853 0.4676016
## 0-2-3 2.0476125 1.4653425 0.5341225
## 1-2-3 2.2020040 1.5968817 0.7635635
```

More interestingly, we can also compare the distributions of expected runs for each game state,

```
# Extract posterior draws of the predictors, transform and reshape for plotting
pp <- as.data.frame(fit)
pp <- reshape2::melt(pp,
variable.name = "Game.State",
value.name="Expected.Runs")
pp <- transform(pp, Game.State = as.character(Game.State))
pp <- subset(pp, subset = Game.State != "reciprocal_dispersion")
# Separate Base States from Outs
pp <- transform(pp, Outs = substr(Game.State, start = 21, stop = 21))
pp <- transform(pp, Runners = substr(Game.State, start = 8, stop = 12))
# Reorganize order of Base States and Outs for a cleaner plot
pp <- transform(pp, Outs = factor(Outs,
levels=c("0", "1", "2"),
labels=c("0 Outs", "1 Out", "2 Outs")))
pp <- transform(pp,
Runners = factor(Runners,
levels=runners_on_base,
labels=runners_on_base))
# Drop Unneeded Variables
pp$Game.State <- NULL
# Transform posterior estimates
pp <- transform(pp, Expected.Runs = exp(Expected.Runs))
# create the plot
ggplot(pp) +
geom_density(aes(x = Expected.Runs), fill = "#C4D8E2") +
facet_grid(Runners ~ Outs, scales = "free_y", switch = "y") +
theme_gray() +
theme(axis.text.x = element_text(size = 6),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
strip.text.x = element_text(size = 9, face = "bold"),
strip.text.y = element_text(size = 9, face = "bold", angle = 180),
panel.grid.minor = element_blank(),
panel.grid.major = element_blank(),
panel.spacing.x = unit(1, "lines")) +
labs(x = "Expected Runs", y = "Baserunners")
```

The distributions above reflect the model’s uncertainty in the *predictors*, but importantly, when predicting or forecasting, we need to consider both this uncertainty *and* the natural variation in events. The mean posterior estimates of observed at bats in the 2017 season are shown below.

```
# combine preditions with original data
yrep <- posterior_predict(fit)
rs <- cbind(rs, yrep = colMeans(yrep))
# reorder levels for RUNNERS and OUTS_CT
# Reorganize order of Base States and Outs for a cleaner plot
rs <- transform(rs, Outs = factor(as.character(OUTS_CT),
levels=c("0", "1", "2"),
labels=c("0 Outs", "1 Out", "2 Outs")))
rs <- transform(rs,
Runners = factor(as.character(RUNNERS),
levels=runners_on_base,
labels=runners_on_base))
# plot posterior estimates from original data
ggplot(rs) +
geom_density(aes(x = yrep), fill = "#C4D8E2") +
facet_grid(Runners ~ Outs, scales = "free_y", switch = "y") +
theme_gray() +
theme(axis.text.x = element_text(size = 6),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
strip.text.x = element_text(size = 9, face = "bold"),
strip.text.y = element_text(size = 9, face = "bold", angle = 180),
panel.grid.minor = element_blank(),
panel.grid.major = element_blank(),
panel.spacing.x = unit(1, "lines")) +
labs(x = "Expected Runs", y = "Baserunners")
```