---
title: "The ROI of Modern NBA Players"
author: "Austin Blumenthal"
date: "May 1, 2026"
format:
html:
theme: cosmo
toc: true
code-fold: true
code-tools: true
---
## The ROI of Modern NBA Players
## Overview
The NBA is a booming market that is valued at over \$130 billion. When dealing with these large amounts of money, it is important that teams find ways to maximize their ROI on the players they choose to represent their team. I am going to take a modern analytics approach to seeing which players are worth their pay.
```{r}
#| label: setup
#| include: false
#| warning: false
#| message: false
# Our primary datasource is a dataset of NBA player salaries from 2000 - 2025 and can be found here : https://www.kaggle.com/datasets/ratin21/nba-player-salaries-2000-2025
library(tidyverse)
library(knitr)
library(ggrepel)
# Load in the dataset
nba_salaries <- read_csv("NBA Player Salaries_2000-2025.csv")
# Let's filter these to more recent seasons for modern relevance
recent_salaries <- nba_salaries %>%
filter(Season >= 2024)
```
## The Salary Landscape
Before we look at the ROI of each player, we first need to understand what the market is like, and how players are valued.
```{r, message=FALSE, warning=FALSE}
# Here is a summary statistics table that will show some general summaries about these modern salaries.
salary_summary <- recent_salaries %>%
summarize(
`Total Players` = n(),
`Average Salary` = scales::dollar(mean(Salary, na.rm = TRUE)),
`Median Salary` = scales::dollar(median(Salary, na.rm = TRUE)),
`Max Salary` = scales::dollar(max(Salary, na.rm = TRUE)),
`Min Salary` = scales::dollar(min(Salary, na.rm = TRUE))
)
kable(salary_summary, caption = "Summary Stats: NBA Player Salaries (Recent Seasons)")
```
Below is a histogram that shows NBA salaries are heavily skewed to the right. This shows the rarity of supermax contracts, and how the majority of contracts are either smaller or vet minimum contracts.
```{r, message=FALSE, warning=FALSE}
# A histogram showcasing the distribution of salaries across the league.
ggplot(recent_salaries, aes(x = Salary)) +
geom_histogram(bins = 30, fill = "#2c3e50", color = "white", alpha = 0.9) +
scale_x_continuous(labels = scales::label_dollar(scale = 1e-6, suffix = "M")) +
theme_minimal() +
labs(
title = "Distribution of NBA Player Salaries",
subtitle = "Highlighting the heavy right-skew of NBA contracts",
x = "Annual Salary (in Millions)",
y = "Number of Players"
)
```
To narrow down our research, the top 20 earners in recent NBA history are in the below table. This will give you a better idea of who these supermax contracts are being given to.
```{r, message=FALSE, warning=FALSE}
# This will show the top 20 earners in recent NBA history
top_earners <- recent_salaries %>%
group_by(Player) %>%
# Keep only the single highest-salary season for each player
slice_max(order_by = Salary, n = 1, with_ties = FALSE) %>%
ungroup() %>%
arrange(desc(Salary)) %>%
slice_head(n = 20) %>%
select(Player, Season, Salary) %>%
mutate(Salary = scales::dollar(Salary))
kable(top_earners, caption = "Top 20 Highest-Paid NBA Players")
```
```{r, message=FALSE, warning=FALSE}
# Here is the next data frame, it can be downloaded at: https://myxavier-my.sharepoint.com/:u:/g/personal/blumenthala_xavier_edu/IQCtmWtWZJT7TLkx-1Exg9kJAcut8TJ6tSuum_JrHzuToeU?e=JmAZ1b
nba_stats <- read_csv("nba_player_totals_24_to_26.csv")
nba_roi <- recent_salaries %>%
inner_join(nba_stats, by = c("Player", "Season")) %>%
mutate(
PTS = as.numeric(PTS),
AST = as.numeric(AST),
Total_Offense = PTS + (AST * 2)
) %>%
filter(Season == 2025) %>%
# Here are the top 20 earners
mutate(
Salary_Rank = min_rank(desc(Salary)),
Top_20_Flag = if_else(Salary_Rank <= 20, "Top 20 Earner", "Rest of League")
) %>%
# Get rid of players who barely have any minutes of play time
filter(as.numeric(MP) > 500)
```
The chart beneath showcases the total payrolls by each NBA team. The interesting part of this data is that some of the high spending teams are not performing well and some of the teams with lower payrolls are performing very well. This shows that many teams are either overspending on players, while others may have even more room for growth and future success.
```{r, message=FALSE, warning=FALSE, fig.width=10, fig.height=8}
team_payroll <- nba_roi %>%
# Ensure we only count each player's salary once per season
group_by(Player) %>%
slice_head(n = 1) %>%
ungroup() %>%
group_by(Team) %>%
summarize(Total_Payroll = sum(Salary, na.rm = TRUE)) %>%
# Filter out NA teams, "TOT", and Kaggle's "2TM", "3TM" trade designations
filter(!is.na(Team), Team != "TOT", !str_detect(Team, "TM")) %>%
arrange(desc(Total_Payroll))
# Create a ranked bar chart of team payrolls
ggplot(team_payroll, aes(x = reorder(Team, Total_Payroll), y = Total_Payroll, fill = Total_Payroll)) +
geom_col(alpha = 0.9, width = 0.7) +
coord_flip() +
geom_text(aes(label = scales::dollar(Total_Payroll, scale = 1e-6, suffix = "M")),
hjust = 1.1,
color = "white",
fontface = "bold",
size = 3) +
scale_y_continuous(labels = scales::label_dollar(scale = 1e-6, suffix = "M")) +
theme_minimal() +
labs(
title = "Total Committed Payroll by NBA Franchise (2024)",
subtitle = "Aggregated salary commitments across active rosters",
x = "Franchise",
y = "Total Team Payroll",
fill = "Payroll Size"
) +
scale_fill_viridis_c(option = "cividis", direction = -1, guide = "none") +
theme(
panel.grid.major.y = element_blank(),
plot.title = element_text(face = "bold", size = 14)
)
```
## Evaluating the High-Rollers and Steals
Now that we have a good idea of what the salary landscape looks like in the NBA, it is time to see how well these top earners are performing on the court. I am going to integrate a second data frame that contains the season player stats of all NBA players since 2024.
The scatterplot beneath identifies these top 20 earners and the amount of offensive production that they create. Both Joel Embiid and Jimmy Butler are listed as clear under performers, since they both were hurt for the majority of the 2025 season.
```{r, message=FALSE, warning=FALSE, fig.width=10, fig.height=7}
median_salary <- median(nba_roi$Salary, na.rm = TRUE)
median_offense <- median(nba_roi$Total_Offense, na.rm = TRUE)
ggplot(nba_roi, aes(x = Total_Offense, y = Salary)) +
# Draw the Rest of League as highly transparent, smaller dots (reduces clutter)
geom_point(
data = filter(nba_roi, Top_20_Flag == "Rest of League"),
color = "#bdc3c7",
alpha = 0.4,
size = 2
) +
# Draw the Top 20 Earners as solid, larger dots
geom_point(
data = filter(nba_roi, Top_20_Flag == "Top 20 Earner"),
color = "#c0392b",
alpha = 0.9,
size = 4
) +
# Draw the Quadrant Lines
geom_vline(xintercept = median_offense, linetype = "dashed", color = "#34495e", alpha = 0.7) +
geom_hline(yintercept = median_salary, linetype = "dashed", color = "#34495e", alpha = 0.7) +
# Add labels to the Top 20 earners with clean spacing
geom_text_repel(
data = filter(nba_roi, Top_20_Flag == "Top 20 Earner"),
aes(label = Player),
size = 3.5,
fontface = "bold",
color = "#2c3e50",
box.padding = 0.6,
max.overlaps = 15
) +
# Add text annotations for the quadrants
annotate("text", x = max(nba_roi$Total_Offense) * 0.85, y = max(nba_roi$Salary) * 0.95, label = "Expected Output", color = "#7f8c8d", fontface = "italic") +
annotate("text", x = max(nba_roi$Total_Offense) * 0.85, y = min(nba_roi$Salary) * 1.5, label = "High Value / Underpaid", color = "#27ae60", fontface = "italic") +
annotate("text", x = min(nba_roi$Total_Offense) * 1.5, y = max(nba_roi$Salary) * 0.95, label = "Low Value / Overpaid", color = "#c0392b", fontface = "italic") +
scale_y_continuous(labels = scales::label_dollar(scale = 1e-6, suffix = "M")) +
theme_minimal() +
labs(
title = "NBA Contract Efficiency (2025)",
subtitle = "Dashed lines represent league medians for Salary and Total Offense",
x = "Total Offense Generated (Points + Assists * 2)",
y = "Annual Salary"
)
```
This ranked bar chart shows the interesting findings of both Jalen Williams and Josh Giddey being underpaid for the value that they provide for their teams. Jalen Williams is currently on the OKC Thunder, and Josh Giddey was traded away from the thunder for value as well. This may explain the extreme success and dominance of the Thunder these past 2 seasons.
```{r, message=FALSE, warning=FALSE, fig.width=9, fig.height=7}
# Let's search for the players who are the best bang for their buck.
value_players <- nba_roi %>%
filter(Total_Offense > median_offense, Salary < median_salary) %>%
# Sort to find the players outperforming their salaries
arrange(desc(Total_Offense)) %>%
slice_head(n = 15)
# Create a ranked bar chart highlighting these players
ggplot(value_players, aes(x = reorder(Player, Total_Offense), y = Total_Offense, fill = Salary)) +
geom_col(alpha = 0.9, width = 0.7) +
coord_flip() +
geom_text(aes(label = scales::dollar(Salary, scale = 1e-6, suffix = "M")),
hjust = 1.2,
color = "white",
fontface = "bold",
size = 3.5) +
theme_minimal() +
labs(
title = "Top 15 Underpaid Offensive Engines",
subtitle = "Players exceeding league median in offense while making less than median salary",
x = "Player",
y = "Total Offense Generated (Points + Assists * 2)",
fill = "Salary Cap Hit"
) +
scale_fill_viridis_c(option = "mako", direction = -1, labels = scales::label_dollar(scale = 1e-6, suffix = "M")) +
theme(
legend.position = "right",
panel.grid.major.y = element_blank()
)
```
This final chart shows the amount of 'value' players that each NBA team has. This may be the most interesting finding yet, since the Utah Jazz and Washington Wizards have the most of this kind, yet they are currently having the least amount of success of all teams.
```{r, message=FALSE, warning=FALSE, fig.width=10, fig.height=8}
team_steals <- nba_roi %>%
group_by(Player) %>%
slice_head(n = 1) %>%
ungroup() %>%
filter(Total_Offense > median_offense, Salary < median_salary) %>%
group_by(Team) %>%
summarize(Value_Contracts = n()) %>%
filter(!is.na(Team), Team != "TOT", !str_detect(Team, "TM")) %>%
arrange(desc(Value_Contracts))
ggplot(team_steals, aes(x = reorder(Team, Value_Contracts), y = Value_Contracts, fill = Value_Contracts)) +
geom_col(alpha = 0.85, width = 0.7) +
coord_flip() +
geom_text(aes(label = Value_Contracts),
hjust = -0.5,
color = "#2c3e50",
fontface = "bold",
size = 4) +
scale_y_continuous(breaks = scales::pretty_breaks()) +
theme_minimal() +
labs(
title = "'Value' Contracts per Franchise (2025)",
subtitle = "Number of rostered players exceeding league median offense on below-median salaries",
x = "Franchise",
y = "Count of High-Value Players",
fill = "Player Count"
) +
scale_fill_viridis_c(option = "mako", direction = -1, guide = "none") +
theme(
panel.grid.major.y = element_blank(),
plot.title = element_text(face = "bold", size = 14)
)
```
## What Stands Out?
There is a large distribution of total payroll being spent by each team in the NBA, and the amount of success that they are receiving from these payrolls. The OKC Thunder are currently leading in finding people to out perform the salary they are being paid, and this may be a factor driving their recent success. The other large takeaway, is that injuries to players on large contacts can put teams into a rough position.