\[ \text{ARM}_i = (WP \cdot c_1) + (\frac{(PD - \alpha_1)}{\alpha_2} \cdot c_1) + ((EPA + c_2) \cdot c_3) + ((EPA_A - c_2) \cdot -c_3) \]
\[ \text{ARM} = \text{ARM}_i + (\frac{(SOS - \alpha_3)}{\alpha_4} \cdot c_1) \]
library(rvest)
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(nflfastR)
## Warning: package 'nflfastR' was built under R version 4.5.2
library(DT)
library(ggplot2)
## Warning: package 'ggplot2' was built under R version 4.5.2
library(nflplotR)
## Warning: package 'nflplotR' was built under R version 4.5.2
pbp <- load_pbp(2025)
team_abbr <- c(
"Arizona Cardinals" = "ARI",
"Atlanta Falcons" = "ATL",
"Baltimore Ravens" = "BAL",
"Buffalo Bills" = "BUF",
"Carolina Panthers"= "CAR",
"Chicago Bears" = "CHI",
"Cincinnati Bengals" = "CIN",
"Cleveland Browns" = "CLE",
"Dallas Cowboys" = "DAL",
"Denver Broncos" = "DEN",
"Detroit Lions" = "DET",
"Green Bay Packers"= "GB",
"Houston Texans" = "HOU",
"Indianapolis Colts" = "IND",
"Jacksonville Jaguars" = "JAX",
"Kansas City Chiefs" = "KC",
"Las Vegas Raiders" = "LV",
"Los Angeles Chargers" = "LAC",
"Los Angeles Rams" = "LA",
"Miami Dolphins" = "MIA",
"Minnesota Vikings"= "MIN",
"New England Patriots" = "NE",
"New Orleans Saints" = "NO",
"New York Giants" = "NYG",
"New York Jets" = "NYJ",
"Philadelphia Eagles" = "PHI",
"Pittsburgh Steelers" = "PIT",
"San Francisco 49ers" = "SF",
"Seattle Seahawks" = "SEA",
"Tampa Bay Buccaneers" = "TB",
"Tennessee Titans" = "TEN",
"Washington Commanders" = "WAS"
)
url <- "https://www.profootballnetwork.com/nfl/standings/league/2025"
page <- read_html(url)
tables <- page %>% html_nodes("table") %>% html_table(fill = TRUE)
standings <- tables[[1]]
colnames(standings) <- standings[1, ]
standings <- standings[-1, ]
colnames(standings)[1] <- "team"
standings <- standings %>%
mutate(pct = as.numeric(PCT), pf = as.numeric(PF), pa = as.numeric(PA)) %>%
select(team, pct, pf, pa) %>%
mutate(diff = pf-pa) %>%
mutate(team = gsub("\\s*\\(([a-z]|\\*)\\)", "", team)) %>%
mutate(team = team_abbr[team]) %>%
select(team, pct, diff)
standings
## # A tibble: 32 × 3
## team pct diff
## <chr> <dbl> <dbl>
## 1 DEN 0.824 90
## 2 SEA 0.824 191
## 3 NE 0.824 170
## 4 JAX 0.765 138
## 5 BUF 0.706 116
## 6 HOU 0.706 109
## 7 LA 0.706 172
## 8 SF 0.706 66
## 9 PHI 0.647 54
## 10 CHI 0.647 26
## # ℹ 22 more rows
get_epa <- function(team_input) {
off_output <- pbp %>%
filter(posteam == team_input) %>%
group_by(posteam) %>%
summarize(off_epa = round(mean(epa, na.rm = TRUE), 3)) %>%
rename(team = posteam)
def_output <- pbp %>%
filter(defteam == team_input) %>%
group_by(defteam) %>%
summarize(def_epa = round(mean(epa, na.rm = TRUE), 3)) %>%
rename(team = defteam)
combined <- off_output %>%
left_join(def_output, by = "team")
return(combined)
}
get_epa_wo_team <- function(team_input, team_remove) {
off_output <- pbp %>%
filter(posteam == team_input, defteam != team_remove) %>%
group_by(posteam) %>%
summarize(off_epa = round(mean(epa, na.rm = TRUE), 3)) %>%
rename(team = posteam)
def_output <- pbp %>%
filter(defteam == team_input, posteam != team_remove) %>%
group_by(defteam) %>%
summarize(def_epa = round(mean(epa, na.rm = TRUE), 3)) %>%
rename(team = defteam)
combined <- off_output %>%
left_join(def_output, by = "team")
return(combined)
}
teams <- pbp %>%
filter(!is.na(posteam)) %>%
distinct(posteam) %>%
arrange(posteam) %>%
pull(posteam)
epa_results <- tibble()
for (i in teams) {
epa_results <- bind_rows(epa_results, get_epa(i))
}
epa_results
## # A tibble: 32 × 3
## team off_epa def_epa
## <chr> <dbl> <dbl>
## 1 ARI -0.021 0.091
## 2 ATL -0.017 0.024
## 3 BAL 0.045 0.027
## 4 BUF 0.119 0.024
## 5 CAR -0.018 0.047
## 6 CHI 0.053 0.029
## 7 CIN 0.032 0.1
## 8 CLE -0.159 -0.072
## 9 DAL 0.1 0.138
## 10 DEN 0.029 -0.037
## # ℹ 22 more rows
initial_values <- standings %>%
left_join(epa_results, by = "team")
initial_values
## # A tibble: 32 × 5
## team pct diff off_epa def_epa
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 DEN 0.824 90 0.029 -0.037
## 2 SEA 0.824 191 0.047 -0.121
## 3 NE 0.824 170 0.109 -0.036
## 4 JAX 0.765 138 0.055 -0.059
## 5 BUF 0.706 116 0.119 0.024
## 6 HOU 0.706 109 -0.003 -0.109
## 7 LA 0.706 172 0.099 -0.046
## 8 SF 0.706 66 0.088 0.027
## 9 PHI 0.647 54 0.012 -0.037
## 10 CHI 0.647 26 0.053 0.029
## # ℹ 22 more rows
min_diff <- min(initial_values$diff)
max_diff <- max(initial_values$diff)
diff_range <- max_diff - min_diff
get_init_ratings <- function(input_df) {
output <- input_df %>% mutate(
pct = round(pct * 2, 2), # min: 0%, mean: 50%, max: 100%
diff = round(((diff-min_diff) / diff_range) * 2, 2), # min: min_diff, mean: ?, max: max_diff
off_epa = round(pmax(pmin((off_epa + .15) * 6.67, 2), 0), 2), # min: -.15, mean: 0, max: .15
def_epa = round(pmax(pmin((def_epa - .15) * -6.67, 2), 0), 2), # min: .15, mean: 0, max: -.15
total = pct + diff + off_epa + def_epa
) %>%
arrange(-total)
return(output)
}
# Without a certain team (used for SOS, can only remove epa)
get_init_ratings_wo_team <- function(team_remove, opponents) {
epa_results_wo_team <- tibble()
for (i in opponents) {
epa_results_wo_team <- bind_rows(epa_results_wo_team, get_epa_wo_team(i, team_remove))
}
epa_results_wo_team <- epa_results_wo_team %>%
distinct(team, .keep_all = TRUE)
initial_values_wo_team <- rows_update(initial_values, epa_results_wo_team, by = "team")
return(get_init_ratings(initial_values_wo_team))
}
initial_ratings <- get_init_ratings(initial_values)
initial_ratings
## # A tibble: 32 × 6
## team pct diff off_epa def_epa total
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 SEA 1.65 2 1.31 1.81 6.77
## 2 NE 1.65 1.89 1.73 1.24 6.51
## 3 LA 1.41 1.9 1.66 1.31 6.28
## 4 JAX 1.53 1.73 1.37 1.39 6.02
## 5 HOU 1.41 1.58 0.98 1.73 5.7
## 6 BUF 1.41 1.62 1.79 0.84 5.66
## 7 DEN 1.65 1.49 1.19 1.25 5.58
## 8 SF 1.41 1.37 1.59 0.82 5.19
## 9 PHI 1.29 1.3 1.08 1.25 4.92
## 10 DET 1.06 1.38 1.46 0.89 4.79
## # ℹ 22 more rows
#test <- get_init_ratings_wo_team("LAC")
#test
get_opponents <- function(team_input) {
output <- pbp %>%
filter(posteam == team_input) %>%
group_by(game_id) %>%
slice_min(., order_by = play_id) %>% # Take only first play
ungroup() %>%
pull(defteam) %>%
unique() # Remove duplicate teams
return(output)
}
sos_results <- tibble()
for (i in teams) {
opp <- get_opponents(i)
ratings <- c()
# Get EPA data without x team
initial_ratings_wo_team <- get_init_ratings_wo_team(i, opp)
for (j in opp) {
#opp_rating <- initial_ratings %>%
#filter(team == j) %>%
#pull(total)
#ratings <- append(ratings, opp_rating)
opp_rating <- initial_ratings_wo_team %>%
filter(team == j) %>%
pull(total)
ratings <- append(ratings, opp_rating)
}
mean_rating <- round(mean(ratings), 2)
rating_tibble <- tibble(i, mean_rating)
sos_results <- bind_rows(sos_results, rating_tibble)
}
sos_results <- sos_results %>%
rename(team = i, sos = mean_rating)
sos_results
## # A tibble: 32 × 2
## team sos
## <chr> <dbl>
## 1 ARI 4.23
## 2 ATL 4.1
## 3 BAL 4.33
## 4 BUF 3.81
## 5 CAR 4.3
## 6 CHI 3.63
## 7 CIN 4.2
## 8 CLE 3.78
## 9 DAL 3.57
## 10 DEN 3.59
## # ℹ 22 more rows
min_sos <- min(sos_results$sos)
max_sos <- max(sos_results$sos)
sos_range <- max_sos - min_sos
get_sos <- function(input_df) {
output <- input_df %>%
mutate(
sos = round((((sos - min_sos) / sos_range) * 2), 2)
)
return(output)
}
sos_ratings <- get_sos(sos_results)
sos_ratings
## # A tibble: 32 × 2
## team sos
## <chr> <dbl>
## 1 ARI 1.61
## 2 ATL 1.42
## 3 BAL 1.75
## 4 BUF 1.01
## 5 CAR 1.7
## 6 CHI 0.76
## 7 CIN 1.56
## 8 CLE 0.97
## 9 DAL 0.68
## 10 DEN 0.7
## # ℹ 22 more rows
final_ratings <- initial_ratings %>%
left_join(sos_ratings, by = "team") %>%
select(-total) %>%
mutate(total = round(pct + diff + off_epa + def_epa + sos, 2)) %>%
arrange(-total)
datatable(final_ratings)
final_ratings_tier <- final_ratings %>%
mutate(
tier = case_when(
total >= 7 ~ "7+",
total >= 6 ~ "6-7",
total >= 5 ~ "5-6",
total >= 4 ~ "4-5",
total >= 3 ~ "3-4",
TRUE ~ "3-"
)
)
final_ratings_tier
## # A tibble: 32 × 8
## team pct diff off_epa def_epa sos total tier
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr>
## 1 SEA 1.65 2 1.31 1.81 1.34 8.11 7+
## 2 LA 1.41 1.9 1.66 1.31 1.68 7.96 7+
## 3 HOU 1.41 1.58 0.98 1.73 1.89 7.59 7+
## 4 JAX 1.53 1.73 1.37 1.39 1.23 7.25 7+
## 5 BUF 1.41 1.62 1.79 0.84 1.01 6.67 6-7
## 6 NE 1.65 1.89 1.73 1.24 0 6.51 6-7
## 7 SF 1.41 1.37 1.59 0.82 1.32 6.51 6-7
## 8 PHI 1.29 1.3 1.08 1.25 1.54 6.46 6-7
## 9 IND 0.94 1.3 1.57 0.79 1.69 6.29 6-7
## 10 DEN 1.65 1.49 1.19 1.25 0.7 6.28 6-7
## # ℹ 22 more rows
final_ratings_tier <- final_ratings_tier %>%
group_by(tier) %>%
arrange(desc(total)) %>%
mutate(
xpos = seq_along(team),
xpos_text = xpos + 0.25
) %>%
ungroup()
ggplot(final_ratings_tier, aes(x = xpos, y = tier)) +
geom_nfl_logos(aes(team_abbr = team), width = 0.07) +
geom_text(aes(x = xpos_text, label = total), hjust = -0.25, vjust = -2, size = 3) +
scale_y_discrete(limits = rev(levels(final_ratings_tier$tier))) +
scale_x_continuous(expand = expansion(mult = c(0.05, 0.1))) +
labs(x = NULL, y = NULL) +
theme(
axis.text.x = element_blank(),
axis.ticks.x = element_blank()
)