Enough people have asked for it that I’ve finally decided to write up a tutorial on how to create Diamond Plots in R. Not only do I think Diamond Plots look cool, but I think they are functionally useful once you’re used to looking at them.
But before we get into the tutorial we need to talk about paid subscriptions.
The F5 makes money by providing paid subscribers with the code and data used to generate the visualizations found throughout this newsletter. However, paid subscriptions have been “paused” ever since I abandoned The F5 in 2021 to go work for the New York Knicks. The issue with pausing paid subscriptions is that new users can’t sign up for paid memberships. Also, I can’t send emails exclusively to paid subscribers (hence why you might be receiving this email even though you’re on a free subscription).
But now that I’m publishing regularly again, I plan to “unpause” paid subscriptions starting November 1, 2024.
So that means if you’re currently signed up for a paid subscription, you’ll start getting billed $5/month (or $50/year) in November. I think that’s far enough out that it gives everyone ample time to renew, update, or cancel their plan as they see fit.
If you’re on a free subscription and want to sign up for a paid subscription in order to access the archive, you can buy me a coffee and I’ll send you a one month subscription. That’ll give you access to everything for 30 days and then you can sign up through Substack once subscriptions are unpaused in November. You can also buy me a coffee if you just want to buy me a coffee.
Okay, that’s all. Feel free to drop me an email with any questions or comments.
This tutorial will go over how I made the chart below for week 7 of the 2024 NFL season, but you should be able to adapt this code for whatever your purposes are. Full R code is at the bottom of this post.
Lets start by loading the packages we need.
# need for data wrangling
library(tidyverse)
# need for plotting nfl team logos
library(nflplotR)
# need this for data
library(nflreadr)
# need this for rotation and saving
library(grid)
# need this for labels
library(ggtext)
Next we’re going to load in the NFL play-by-play data and calculate offensive and defensive EPA per play for each team this season. Then we’re going to join the offensive and defensive dataframes together into one dataframe (df). Nothing fancy here, I just copied the code from here.
# data loading and wrangling copied from: https://www.nflfastr.com/articles/beginners_guide.html#figures-with-qb-stats
# get pbp and filter to regular season rush and pass plays
pbp <- nflreadr::load_pbp(2024) %>%
dplyr::filter(season_type == "REG") %>%
dplyr::filter(!is.na(posteam) & (rush == 1 | pass == 1))
# offense epa
offense <- pbp %>%
dplyr::group_by(team = posteam) %>%
dplyr::summarise(off_epa = mean(epa, na.rm = TRUE))
# defense epa
defense <- pbp %>%
dplyr::group_by(team = defteam) %>%
dplyr::summarise(def_epa = mean(epa, na.rm = TRUE))
# join offense and defense
df <- offense %>%
inner_join(defense, by = "team")
For Diamond Plots to work effectively, they need to be symmetric. Meaning, the X-axis has to mirror the Y-axis. For our purposes, that means finding the maximum and minimum for offensive and defensive EPA because those will serve as our upper bounds for the plot.
There’s probably a better way to do this, but I just run the following code and then take the absolute value of the biggest number and round it to the nearest clean number to create the upper and lower bounds of our plot.
# find the max absolute value of off/def epa for our plot
sort(abs(c(max(df$off_epa), max(df$def_epa), min(df$off_epa), min(df$def_epa))), decreasing = T)
In this case, the Washington Commanders have a .24 Offensive EPA per play so I used -.25/.25 as our upper/lower bounds.
I’ll hard code the max/min’s for offensive and defensive EPA as well as the angle that we want to rotate everything by.
# set max and min for off and def epa (want them to be symmetric for chart)
off_epa_min <- -.25
off_epa_max <- .25
def_epa_min <- -.25
def_epa_max <- .25
# set rotation to 45 degrees
rotation <- 45
Now we can start plotting our data. Start by plotting offensive EPA on the x-axis and defensive EPA on the y-axis.
p_cb <- df %>%
ggplot(aes(x = off_epa, y = def_epa))
Next, we’re going to draw four squares in the corners of each plot that serve as our four quadrants.
p_cb <- p_cb +
# add color blocking
annotate("rect", xmin = (off_epa_max + off_epa_min) / 2, xmax = off_epa_max,
ymin = def_epa_min, ymax = (def_epa_max + def_epa_min) / 2, fill= "#a6dba0", alpha = .5, color = 'transparent') +
annotate("rect", xmin = off_epa_min, xmax = (off_epa_max + off_epa_min) / 2,
ymin = (def_epa_max + def_epa_min) / 2, ymax = def_epa_max, fill= "#c2a5cf", alpha = .5, color = 'transparent') +
annotate("rect", xmin = (off_epa_max + off_epa_min) / 2, xmax = off_epa_max,
ymin = (def_epa_max + def_epa_min) / 2, ymax = def_epa_max, fill= "#f7f7f7", alpha = .5, color = 'transparent') +
annotate("rect", xmin = off_epa_min, xmax = (off_epa_max + off_epa_min) / 2,
ymin = def_epa_min, ymax = (def_epa_max + def_epa_min) / 2, fill= "#f7f7f7", alpha = .5, color = 'transparent')
Then we’re going to add Good O, Good D labels to each quadrant.
I don’t have a good programmatic way of doing this step so I just play around with the x/y location of each label until I think it looks good enough.
p_cb <- p_cb +
# hack together a few chart guides (ie, 'Good D, Bad D')
suppressWarnings(geom_richtext(aes(x = .20, y = .20, label = "Good O, Bad D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'black', fill = "#f7f7f7")) +
suppressWarnings(geom_richtext(aes(x = -.20, y = -.20, label = "Bad O, Good D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'black', fill = "#f7f7f7")) +
suppressWarnings(geom_richtext(aes(x = -.205, y = .205, label = "Bad O, Bad D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'white', fill = "#762a83", label.colour = 'black')) +
suppressWarnings(geom_richtext(aes(x = .215, y = -.215, label = "Good O, Good D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'white', fill = "#1b7837", label.colour = 'black'))
Next, we’ll add the team logos.
p_cb <- p_cb +
# add team logos
geom_nfl_logos(aes(team_abbr = team), width = 0.09, alpha = 0.75, angle = -1*rotation)
Now we can adjust our axis and force the plot to be symmetric with coord_equal(). We’ll also turn clipping off so that things can spill off the page if they need to.
p_cb <- p_cb +
scale_y_reverse(limits = c(def_epa_max, def_epa_min), breaks = seq(.25, -.25, -.05),
labels = scales::number_format(style_positive = "plus", accuracy = .01)) +
scale_x_continuous(limits = c(off_epa_min, off_epa_max), breaks = seq(-.25, .25, .05),
labels = scales::number_format(style_positive = "plus", accuracy = .01)) +
coord_equal(clip = 'off') +
# add axis labels
labs(
x = "Offensive EPA/play",
y = "Defensive EPA/play"
)
Then we’re going to make a bunch of thematic tweaks that include:
using theme_minimal()
rotating each axis and adjusting their positions
changing the plot margins
removing some gridlines
changing the background color of the plot (floralwhite is an F5 trademark)
p_cb <- p_cb +
# thematic stuff
theme_minimal() +
theme(axis.text.x = element_text(angle=(-1 * rotation), hjust = 0.5, margin = margin(t = -9.5)),
axis.text.y = element_text(angle=(-1 * rotation), hjust = 0.5, margin = margin(r = -5)),
axis.title.x = element_text(size = 12,
vjust = 0.5,
margin = margin(t = 10),
face = 'bold',
color = "black"),
axis.title.y = element_text(size = 12,
angle=(-1 * rotation - 45),
hjust = 0.5,
margin = margin(r = 10),
color = "black",
face = 'bold'),
plot.margin = margin(1.15, .5, .5, -.25, unit = 'in'),
panel.grid.minor = element_blank(),
plot.background = element_rect(fill = 'floralwhite', color = "floralwhite"))
Lastly, we’ll back together a custom title and subtitle. Again, you’ll have to play with the x/y location of the text depending on your data. Don’t worry if the title/subtitle is spilling off the page. It’ll right itself when we rotate the plot.
p_cb <- p_cb +
# hack together a title and subtitle
annotate(geom = 'text', x = .235, y = -.235, label = "2024 NFL Offensive and Defensive EPA per Play", angle = -1 * rotation, vjust = -3.5, fontface = 'bold', size = 4') +
annotate(geom = 'text', x = .235, y = -.235, label = paste0("As of ", format.Date(Sys.Date(), "%B %d, %Y"), ""), angle = -1 * rotation, vjust = -2.5, size = 3)
Okay, all we’ve done is plot everything on an angle, but we haven’t actually rotated the plot itself yet. That’s what the next bit of code does. It creates a file called “epa_diamond_plot.png” and then uses the print() function to rotate it by 45 degrees so that it looks like a diamond.
# save plot
png("epa_diamond_plot.png", res = 300, width = 6, height = 6, units = 'in', bg = 'floralwhite')
print(p_cb, vp=viewport(angle=rotation,
width = unit(6, "in"),
height = unit(6, "in")))
dev.off()
There you have it. If you liked this tutorial, consider subscribing for more.
Full R Code
library(tidyverse)
library(nflplotR)
library(nflreadr)
library(grid)
library(ggtext)
# data loading and wrangling copied from: https://www.nflfastr.com/articles/beginners_guide.html#figures-with-qb-stats
pbp <- nflreadr::load_pbp(2024) %>%
dplyr::filter(season_type == "REG") %>%
dplyr::filter(!is.na(posteam) & (rush == 1 | pass == 1))
offense <- pbp %>%
dplyr::group_by(team = posteam) %>%
dplyr::summarise(off_epa = mean(epa, na.rm = TRUE))
defense <- pbp %>%
dplyr::group_by(team = defteam) %>%
dplyr::summarise(def_epa = mean(epa, na.rm = TRUE))
df <- offense %>%
inner_join(defense, by = "team")
# find the max absolute value of off/def epa for our plot
sort(abs(c(max(df$off_epa), max(df$def_epa), min(df$off_epa), min(df$def_epa))), decreasing = T)
# set max and min for off and def epa (want them to be symmetric for chart)
off_epa_min <- -.25
off_epa_max <- .25
def_epa_min <- -.25
def_epa_max <- .25
# set rotation to 45 degrees
rotation <- 45
p_cb <- df %>%
ggplot(aes(x = off_epa, y = def_epa)) +
# add color blocking
annotate("rect", xmin = (off_epa_max + off_epa_min) / 2, xmax = off_epa_max,
ymin = def_epa_min, ymax = (def_epa_max + def_epa_min) / 2, fill= "#a6dba0", alpha = .5, color = 'transparent') +
annotate("rect", xmin = off_epa_min, xmax = (off_epa_max + off_epa_min) / 2,
ymin = (def_epa_max + def_epa_min) / 2, ymax = def_epa_max, fill= "#c2a5cf", alpha = .5, color = 'transparent') +
annotate("rect", xmin = (off_epa_max + off_epa_min) / 2, xmax = off_epa_max,
ymin = (def_epa_max + def_epa_min) / 2, ymax = def_epa_max, fill= "#f7f7f7", alpha = .5, color = 'transparent') +
annotate("rect", xmin = off_epa_min, xmax = (off_epa_max + off_epa_min) / 2,
ymin = def_epa_min, ymax = (def_epa_max + def_epa_min) / 2, fill= "#f7f7f7", alpha = .5, color = 'transparent') +
# hack together a few chart guides (ie, 'Good D, Bad D')
suppressWarnings(geom_richtext(aes(x = .20, y = .20, label = "Good O, Bad D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'black', fill = "#f7f7f7")) +
suppressWarnings(geom_richtext(aes(x = -.20, y = -.20, label = "Bad O, Good D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'black', fill = "#f7f7f7")) +
suppressWarnings(geom_richtext(aes(x = -.205, y = .205, label = "Bad O, Bad D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'white', fill = "#762a83", label.colour = 'black')) +
suppressWarnings(geom_richtext(aes(x = .215, y = -.215, label = "Good O, Good D"), angle = -1 * rotation, size = 2.75, fontface = 'bold', color = 'white', fill = "#1b7837", label.colour = 'black')) +
# add team logos
geom_nfl_logos(aes(team_abbr = team), width = 0.09, alpha = 0.75, angle = -1*rotation) +
scale_y_reverse(limits = c(def_epa_max, def_epa_min), breaks = seq(.25, -.25, -.05),
labels = scales::number_format(style_positive = "plus", accuracy = .01)) +
scale_x_continuous(limits = c(off_epa_min, off_epa_max), breaks = seq(-.25, .25, .05),
labels = scales::number_format(style_positive = "plus", accuracy = .01)) +
coord_equal(clip = 'off') +
# add axis labels
labs(
x = "Offensive EPA/play",
y = "Defensive EPA/play"
) +
# thematic stuff
theme_minimal() +
theme(axis.text.x = element_text(angle=(-1 * rotation), hjust = 0.5, margin = margin(t = -9.5)),
axis.text.y = element_text(angle=(-1 * rotation), hjust = 0.5, margin = margin(r = -5)),
axis.title.x = element_text(size = 12,
vjust = 0.5,
margin = margin(t = 10),
face = 'bold',
color = "black"),
axis.title.y = element_text(size = 12,
angle=(-1 * rotation - 45),
hjust = 0.5,
margin = margin(r = 10),
color = "black",
face = 'bold'),
plot.margin = margin(1.15, .5, .5, -.25, unit = 'in'),
panel.grid.minor = element_blank(),
plot.background = element_rect(fill = 'floralwhite', color = "floralwhite")) +
# hack together a title and subtitle
annotate(geom = 'text', x = .235, y = -.235, label = "2024 NFL Offensive and Defensive EPA per Play", angle = -1 * rotation, vjust = -3.5, fontface = 'bold', size = 4) +
annotate(geom = 'text', x = .235, y = -.235, label = paste0("As of ", format.Date(Sys.Date(), "%B %d, %Y"), ""), angle = -1 * rotation, vjust = -2.5, size = 3)
#save plot
png("epa_diamond_plot.png", res = 300, width = 6, height = 6, units = 'in', bg = 'floralwhite')
print(p_cb, vp=viewport(angle=rotation,
width = unit(6, "in"),
height = unit(6, "in")))
dev.off()
I'm so happy you're back!
These are really creative, thanks for sharing!