library(elo)
library(BradleyTerry2)
# Fetch and prepare data
df <- irw::irw_fetch("fivethirtyeight_nfl_1920_2023", source = 'comp')
df <- df[df$season >= 2010 & df$season <= 2019, ] # the 2010-2019 seasons, playoffs included
df <- df[order(df$date), ]
# ===== Elo ANALYSIS =====
er <- elo.run(score(score_a, score_b) ~ agent_a + agent_b,
data = df, k = 20)
# Convert Elo results to long format
elo_results <- as.data.frame(er)
elo_long <- lapply(seq_len(nrow(elo_results)), function(i) {
data.frame(
team = c(elo_results$team.A[i], elo_results$team.B[i]),
elo = c(elo_results$elo.A[i], elo_results$elo.B[i]),
date = df$date[i]
)
})
elo_long <- do.call(rbind, elo_long)
# Convert dates to years since the start of the 2010 season
elo_long$year <- (elo_long$date - as.numeric(as.POSIXct("2010-09-01", tz = "UTC")))/(365.25*24*60*60)
elo_by_team <- split(elo_long, elo_long$team)
# ===== BRADLEY-TERRY ANALYSIS =====
# Prepare data for BT model
df_bt <- df[df$winner != 'draw', ]
df_bt$win <- as.numeric(df_bt$winner == 'agent_a')
df_bt$agent_a <- factor(df_bt$agent_a)
df_bt$agent_b <- factor(df_bt$agent_b)
# Fit BT models by season. br = TRUE uses bias-reduced estimation: a team that
# never won in a season (Cleveland in 2017) has no finite maximum-likelihood
# strength, and this keeps its estimate on the same scale as everyone else.
# Each season is a separate model with its own reference team, so the
# coefficients are centred within season to make them comparable across years.
bt_by_season <- lapply(split(df_bt, df_bt$season), function(season_df) {
mod <- BTm(win, agent_a, agent_b, data = season_df, id = "team", br = TRUE)
co <- coef(mod)
names(co) <- gsub("team", "", names(co))
co <- c(co, setNames(0, setdiff(levels(season_df$agent_a), names(co)))) # the reference team
data.frame(
season = unique(season_df$season),
est = co - mean(co),
team = names(co),
row.names = NULL
)
})
bt_results <- do.call(rbind, bt_by_season)
bt_by_team <- split(bt_results, bt_results$team)
# ===== PLOTTING =====
# Thirty-two labels at the right edge overwrite each other, so only the three
# strongest and three weakest teams at the end of the window are drawn dark and
# named; the rest are context. Labels are nudged apart where they would collide.
spread_labels <- function(y, min_gap) {
o <- order(y); y_s <- y[o]
for (k in seq_along(y_s)[-1]) y_s[k] <- max(y_s[k], y_s[k - 1] + min_gap)
y[o] <- y_s; y
}
plot_teams <- function(by_team, xcol, ycol, min_gap, n_label = 3) {
final <- sapply(by_team, function(d) d[[ycol]][nrow(d)])
picked <- names(sort(final))[c(seq_len(n_label), length(final) - seq_len(n_label) + 1)]
for (team_data in by_team[setdiff(names(by_team), picked)])
lines(team_data[[xcol]], team_data[[ycol]], col = rgb(0, 0, 0, 0.15))
for (team_data in by_team[picked])
lines(team_data[[xcol]], team_data[[ycol]], col = rgb(0, 0, 0, 0.8), lwd = 1.5)
y_lab <- spread_labels(final[picked], min_gap)
x_lab <- sapply(by_team[picked], function(d) d[[xcol]][nrow(d)])
text(x_lab, y_lab, picked, pos = 4, cex = 0.7, xpd = TRUE)
}
par(mfrow = c(2, 1), mgp = c(2.5, 0.7, 0), mar = c(4, 4, 2, 2))
# Plot 1: Elo ratings over time
plot(NULL,
xlim = c(0, 10),
ylim = c(1200, 1800),
xlab = "Years since start of 2010 season",
ylab = "Elo Rating",
main = "NFL Team Elo Ratings (2010-2019)",
xaxt = "n")
axis(1, at = 0:10)
plot_teams(elo_by_team, "year", "elo", min_gap = 40)
# Plot 2: Bradley-Terry strengths by season, centred within season
plot(NULL,
xlim = c(2010, 2020),
ylim = range(bt_results$est, na.rm = TRUE) + c(-0.3, 0.6),
xlab = "Season",
ylab = "Strength (season-centred)",
main = "NFL Team Strength by Season (Bradley-Terry Model)",
xaxt = "n")
axis(1, at = 2010:2019)
plot_teams(bt_by_team, "season", "est", min_gap = 0.4)