How To: NFL Playoff Leverage Charts
Visualizing Week 12 playoff implications; the NFC West has the most at stake this weekend
It’s the time of year where division races are taking shape and fans start looking forward to the NFL playoff picture. No teams are mathematically eliminated just 11 weeks into the season, but it’s becoming clearer which ones have a real shot of reaching the postseason and others which will need a few prayers to get there.
It’s also the time of year analysts like me start to dust the cobwebs off their playoff leverage chart to see which of the upcoming games have the biggest impact on the playoff picture, which is what the focus of today’s newsletter will be.
We’ll learn how to programmatically create the plot above using R1. If you’re not interested in this type of content feel free to skip to the What Does it Mean section for a bit of analysis, or this week’s newsletter entirely. Or if you just want to see the full code scroll to the bottom of this post.
Motivation
The last few years I’ve seen these types of playoff probability graphics pop up on Twitter and other sites; a good example is the one Timo Riske of PFF put up a few days ago. I decided to try my hand at something similar, which I want to share since I haven’t seen any tutorials on how to create these plots — thus today’s post.
Getting Started
First we need to load the necessary packages. I also like to define a custom theme up front for my graphics, which was inspired by Owen Phillips at The F5.
library(nflverse)
library(tidyverse)
library(teamcolors) # NFL team colors and logos
library(extrafont) # for extra fonts
library(ggimage)
library(glue)
library(ggtext)
library(webshot2)
library(scales)
# Custom ggplot theme (inspired by Owen Phillips at the F5 substack blog)
theme_custom <- function () {
theme_minimal(base_size=11, base_family="Outfit") %+replace%
theme(
panel.grid.minor = element_blank(),
plot.background = element_rect(fill = 'floralwhite', color = "floralwhite")
)
}
# create aspect ration to use throughout
asp_ratio <- 1.618
In addition we can define a function called add_logo
that will allow us to add the NFL logo to our plot at the end of the process.
# Function for plot with logo generation
add_logo <- function(plot_path, logo_path, logo_position, logo_scale = 10){
# Requires magick R Package https://github.com/ropensci/magick
# Useful error message for logo position
if (!logo_position %in% c("top right", "top left", "bottom right", "bottom left")) {
stop("Error Message: Uh oh! Logo Position not recognized\n Try: logo_positon = 'top left', 'top right', 'bottom left', or 'bottom right'")
}
# read in raw images
plot <- magick::image_read(plot_path)
logo_raw <- magick::image_read(logo_path)
# get dimensions of plot for scaling
plot_height <- magick::image_info(plot)$height
plot_width <- magick::image_info(plot)$width
# default scale to 1/10th width of plot
# Can change with logo_scale
logo <- magick::image_scale(logo_raw, as.character(plot_width/logo_scale))
# Get width of logo
logo_width <- magick::image_info(logo)$width
logo_height <- magick::image_info(logo)$height
# Set position of logo
# Position starts at 0,0 at top left
# Using 0.01 for 1% - aesthetic padding
if (logo_position == "top right") {
x_pos = plot_width - logo_width - 0.01 * plot_width
y_pos = 0.01 * plot_height
} else if (logo_position == "top left") {
x_pos = 0.01 * plot_width
y_pos = 0.01 * plot_height
} else if (logo_position == "bottom right") {
x_pos = plot_width - logo_width - 0.01 * plot_width
y_pos = plot_height - logo_height - 0.01 * plot_height
} else if (logo_position == "bottom left") {
x_pos = 0.01 * plot_width
y_pos = plot_height - logo_height - 0.01 * plot_height
}
# Compose the actual overlay
magick::image_composite(plot, logo, offset = paste0("+", x_pos, "+", y_pos))
}
Making the Plot
Now that we have the setup steps out of the way it’s time to load in our data and get it ready to visualize. For this exercise we’ll use Neil Paine’s recent analysis on Week 12 games with the largest potential swings in playoff probability. You can get the data directly from the table in his post and save it as a csv in your working directory.
From there we pre-process the data to get it into a dataframe we can work with in ggplot. We calculate the raw differences in playoff probability in the case each team wins or loses their game this weekend, and also join in team metadata such as team colors, names and their logos in preparation for the final graphic.
## ------------- 1. Playoff Leverage Plots --------------------
# load team logos and colors
team_df <- nflreadr::load_teams() %>%
select(team_logo_espn, team_abbr, team_name, team_conf, team_division, team_color)
# load file from Neil Paine's playoff leverage article: https://neilpaine.substack.com/p/football-bytes-the-detroit-lions
playoff_odds <- read_csv('/Users/Stephan/Desktop/R Projects/NFL/W12_playoff_odds_paine.csv') %>%
janitor::clean_names() %>%
mutate(now = str_remove(now, "%") %>%
as.numeric() / 100,
w_w = str_remove(w_w, "%") %>%
as.numeric() / 100,
w_l = str_remove(w_l, "%") %>%
as.numeric() / 100,
wtd_chg = str_remove(wtd_chg, "%") %>%
as.numeric() / 100,
)
# calculate raw difference in playoff probability outcomes
playoff_odds <- playoff_odds %>%
mutate(leverage_win = now + w_w,
leverage_loss = now + w_l)
# join in colors and logos
playoff_odds_joined <- playoff_odds %>%
left_join(team_df, by = c("team" = "team_abbr")) %>%
mutate(logo_label = glue::glue("<img src='{team_logo_espn}' width='12'/>")) # create html tags for geom_richtext to read
Now we get to the fun stuff. First we should filter for one of the two conferences because putting all 32 teams on the same plot looks a bit cluttered. Once we have our dataframe fully prepared and filtered we can start creating the graphic with one of the most useful packages in the R ecosystem: {ggplot2}
.
The final dataframe should look something like this:
In the ggplot code below you’ll notice we start by ordering the dataset by teams’ current playoff probability given by the ‘now’
column — fct_reorder
from the forcats package in the tidyverse helps with that. To create the bar segments that represent the spread between a team’s playoff chances depending on whether they win or lose we use geom_segment
, and add on points to either side of those with geom_point
.
The team logos are then added using the geom_image
function to represent a team’s current playoff probability. From there we dress it up by adding the percentage labels to the ends of the bars (red for playoff percentage with a loss this weekend and blue with a win), and add a title and subtitle. Notice the use of the {glue}
package in combination with element_markdown
from the {ggtext}
package to match the colors of the probabilities within the subtitle description.
Once we put all of that together and run our code, we use ggsave
to save the plot into the working directory.
# Make plot (NFC)
playoff_odds_nfc <- playoff_odds_joined %>%
filter(team_conf == "NFC")
playoff_odds_nfc %>%
arrange(now) %>%
mutate(team = fct_reorder(team, now)) %>%
ggplot() +
# Connecting segment
geom_segment(aes(x = leverage_loss, xend = leverage_win, y = team, yend = team, color = team_color),
size = 2.5, alpha = 0.7) +
# Left point (loss)
geom_point(aes(x = leverage_loss, y = team, color = team_color), size = 2) +
# Right point (win)
geom_point(aes(x = leverage_win, y = team, color = team_color), size = 2) +
# Team logos
geom_image(aes(x = now, y = team, image = team_logo_espn), size = 0.04) +
# Percentage labels
geom_text(data = playoff_odds_nfc,
aes(x = leverage_win, y = team, label = sprintf("%.1f%%", leverage_win * 100)),
color = "#2E86C1", size = 2.75, vjust = 2.5, family = "Titillium Web", fontface = "bold") +
geom_text(data = playoff_odds_nfc,
aes(x = leverage_loss, y = team, label = sprintf("%.1f%%", leverage_loss * 100)),
color = "#CB4335", size = 2.75, vjust = 2.5, family = "Titillium Web", fontface = "bold") +
# Labels and scales
labs(title = "**NFC Playoff Picture**",
subtitle = glue("Difference between <span style = 'color:#CB4335'>**playoff % if loss**</span> and <span style = 'color:#2E86C1'>**playoff % if win**</span> | Entering **Week 12**"),
caption = "Data: Neil Paine\nGraphic: @steodosescu",
x = "Playoff Probability",
y = "") +
theme_custom() +
scale_fill_identity() +
scale_x_continuous(labels = scales::percent_format(scale = 100)) +
scale_color_identity() + # Suppress legend for team colors
theme(plot.title.position = 'plot',
plot.title = element_markdown(face = 'bold', size = 20, hjust = 0.5),
plot.subtitle = element_markdown(hjust = 0.5),
panel.grid.major.x = element_line(size = 0.05),
panel.grid.major.y = element_blank())
ggsave("Playoff Leverage.png")
Lastly, after saving the plot we then use the add_logo function we defined above to stick the NFL logo in the top left of the graphic.
# Add logo to plot
leverage_with_logo <- add_logo(
plot_path = "/Users/Stephan/Desktop/R Projects/NFL/Playoff Leverage.png", # url or local file for the plot
logo_path = "/Users/Stephan/Desktop/R Projects/NFL/nfl-logo.png", # url or local file for the logo
logo_position = "top left", # choose a corner
# 'top left', 'top right', 'bottom left' or 'bottom right'
logo_scale = 30
)
# save the image and write to working directory
magick::image_write(leverage_with_logo, "Playoff Leverage with Logo.png")
What does it mean?
The NFC West is clearly the tightest division in the NFC right now. According to Neil Paine, every team has odds of making the playoffs between 25 and 50 percent. So it’s no surprise this weekend’s games feel like must wins for every team in that division.
The San Francisco 49ers (5-5) are currently last in the division due to tiebreakers and have to travel to Green Bay this weekend to keep their playoff hopes alive. That task got a lot more difficult with the recent news head coach Kyle Shanahan announced they would be without quarterback Brock Purdy and defensive end Nick Bosa at Lambeau Field. The betting odds went from Packers being 3-point favorites (-3.0) to -5.5 on the news2.
To pour salt on the wound for Niners fans San Francisco has the second toughest schedule of any NFL team the remainder of the season.
The Arizona Cardinals meanwhile lead the division with a 6-4 record. Their game against the division rival Seattle Seahawks (5-5) looks to be the game with the biggest playoff implications in Week 12. If Arizona wins, its playoff odds rise from 47 percent to 72 percent; if the Cardinals lose, their chances fall to 31 percent, according to Paine’s model.
As for the rest of the NFC, the Lions and Eagles have all but wrapped up their playoff berths irrespective of a win or not this weekend. And the New York Giants can safely assume they won’t be making the postseason no matter what happens this weekend.
Here’s what the AFC playoff picture looks like:
Full Code
library(nflverse)
library(tidyverse)
library(teamcolors) # NFL team colors and logos
library(extrafont) # for extra fonts
library(ggimage)
library(glue)
library(ggtext)
library(webshot2)
library(scales)
# Custom ggplot theme (inspired by Owen Phillips at the F5 substack blog)
theme_custom <- function () {
theme_minimal(base_size=11, base_family="Outfit") %+replace%
theme(
panel.grid.minor = element_blank(),
plot.background = element_rect(fill = 'floralwhite', color = "floralwhite")
)
}
# create aspect ration to use throughout
asp_ratio <- 1.618
# Function for plot with logo generation
add_logo <- function(plot_path, logo_path, logo_position, logo_scale = 10){
# Requires magick R Package https://github.com/ropensci/magick
# Useful error message for logo position
if (!logo_position %in% c("top right", "top left", "bottom right", "bottom left")) {
stop("Error Message: Uh oh! Logo Position not recognized\n Try: logo_positon = 'top left', 'top right', 'bottom left', or 'bottom right'")
}
# read in raw images
plot <- magick::image_read(plot_path)
logo_raw <- magick::image_read(logo_path)
# get dimensions of plot for scaling
plot_height <- magick::image_info(plot)$height
plot_width <- magick::image_info(plot)$width
# default scale to 1/10th width of plot
# Can change with logo_scale
logo <- magick::image_scale(logo_raw, as.character(plot_width/logo_scale))
# Get width of logo
logo_width <- magick::image_info(logo)$width
logo_height <- magick::image_info(logo)$height
# Set position of logo
# Position starts at 0,0 at top left
# Using 0.01 for 1% - aesthetic padding
if (logo_position == "top right") {
x_pos = plot_width - logo_width - 0.01 * plot_width
y_pos = 0.01 * plot_height
} else if (logo_position == "top left") {
x_pos = 0.01 * plot_width
y_pos = 0.01 * plot_height
} else if (logo_position == "bottom right") {
x_pos = plot_width - logo_width - 0.01 * plot_width
y_pos = plot_height - logo_height - 0.01 * plot_height
} else if (logo_position == "bottom left") {
x_pos = 0.01 * plot_width
y_pos = plot_height - logo_height - 0.01 * plot_height
}
# Compose the actual overlay
magick::image_composite(plot, logo, offset = paste0("+", x_pos, "+", y_pos))
}
## ------------- 1. Playoff Leverage Plots --------------------
# load team logos and colors
team_df <- nflreadr::load_teams() %>%
select(team_logo_espn, team_abbr, team_name, team_conf, team_division, team_color)
# load file from Neil Paine's playoff leverage article: https://neilpaine.substack.com/p/football-bytes-the-detroit-lions
playoff_odds <- read_csv('/Users/Stephan/Desktop/R Projects/NFL/W12_playoff_odds_paine.csv') %>%
janitor::clean_names() %>%
mutate(now = str_remove(now, "%") %>%
as.numeric() / 100,
w_w = str_remove(w_w, "%") %>%
as.numeric() / 100,
w_l = str_remove(w_l, "%") %>%
as.numeric() / 100,
wtd_chg = str_remove(wtd_chg, "%") %>%
as.numeric() / 100,
)
# calculate raw difference in playoff probability outcomes
playoff_odds <- playoff_odds %>%
mutate(leverage_win = now + w_w,
leverage_loss = now + w_l)
# join in colors and logos
playoff_odds_joined <- playoff_odds %>%
left_join(team_df, by = c("team" = "team_abbr")) %>%
mutate(logo_label = glue::glue("<img src='{team_logo_espn}' width='12'/>")) # create html tags for geom_richtext to read
# Make plot (NFC)
playoff_odds_nfc <- playoff_odds_joined %>%
filter(team_conf == "NFC")
playoff_odds_nfc %>%
arrange(now) %>%
mutate(team = fct_reorder(team, now)) %>%
ggplot() +
# Connecting segment
geom_segment(aes(x = leverage_loss, xend = leverage_win, y = team, yend = team, color = team_color),
size = 2.5, alpha = 0.7) +
# Left point (loss)
geom_point(aes(x = leverage_loss, y = team, color = team_color), size = 2) +
# Right point (win)
geom_point(aes(x = leverage_win, y = team, color = team_color), size = 2) +
# Team logos
geom_image(aes(x = now, y = team, image = team_logo_espn), size = 0.04) +
# Percentage labels
geom_text(data = playoff_odds_nfc,
aes(x = leverage_win, y = team, label = sprintf("%.1f%%", leverage_win * 100)),
color = "#2E86C1", size = 2.75, vjust = 2.5, family = "Titillium Web", fontface = "bold") +
geom_text(data = playoff_odds_nfc,
aes(x = leverage_loss, y = team, label = sprintf("%.1f%%", leverage_loss * 100)),
color = "#CB4335", size = 2.75, vjust = 2.5, family = "Titillium Web", fontface = "bold") +
# Labels and scales
labs(title = "**NFC Playoff Leverage**",
subtitle = glue("Difference between <span style = 'color:#CB4335'>**playoff % if loss**</span> and <span style = 'color:#2E86C1'>**playoff % if win**</span> | As of **Week 12**"),
caption = "Data: Neil Paine\nGraphic: @steodosescu",
x = "Playoff Probability",
y = "") +
theme_custom() +
scale_fill_identity() +
scale_x_continuous(labels = scales::percent_format(scale = 100)) +
scale_color_identity() + # Suppress legend for team colors
theme(plot.title.position = 'plot',
plot.title = element_markdown(face = 'bold', size = 20, hjust = 0.5),
plot.subtitle = element_markdown(hjust = 0.5),
panel.grid.major.x = element_line(size = 0.05),
panel.grid.major.y = element_blank())
ggsave("Playoff Leverage.png")
# Add logo to plot
leverage_with_logo <- add_logo(
plot_path = "/Users/Stephan/Desktop/R Projects/NFL/Playoff Leverage.png", # url or local file for the plot
logo_path = "/Users/Stephan/Desktop/R Projects/NFL/nfl-logo.png", # url or local file for the logo
logo_position = "top left", # choose a corner
# 'top left', 'top right', 'bottom left' or 'bottom right'
logo_scale = 30
)
# save the image and write to working directory
magick::image_write(leverage_with_logo, "Playoff Leverage with Logo.png")
For more content like this check out my NFL analytics website complete with season forecasts, team analytics and game predictions.
This post and tutorial posts are inspired by Owen Phillip’s posts on his Substack The F5. I encourage you to check his work out if you’re at all interested in the NBA and/or data visualization with R.
The game will mark the first time since 2022 the 49ers will be underdogs, breaking a streak of 36 consecutive regular-season games in which they've been favored, the third-longest such streak of the Super Bowl era.