season <- as.numeric(2025)
box <- wehoop::load_wnba_team_box(season)DATA 607 - Project 1 - WNBA Elo Ratings
Introduction and Overview
In this project, the Elo rating methodology, which was developed to rank Chess players by Arpad Elo, will be applied to WNBA teams. Data will be sourced from the wehoop R package. A convention established by the FiveThirtyEight blog, and repeated in this article applying Elo ratings to NBA teams, is to start with a baseline value of 1300 for each team.
Anticipated challenges involve maintaining a reasonable scale: WNBA data exists extending back to 1997, and computing 29 seasons of Elo ratings may prove cumbersome if an efficient and repeatable architecture is not implemented from the start. The 2025 season will be used as a baseline from which the methods can be extended backward to cover the entire WNBA history, if practical. The 2025 season is the most recently resolved regular season. Other anticipated challenges may arise when visualizing the results of this analysis. A potential graph could compare Elo ratings vs time for a select group of teams.
Adaptation
The original project assignment, related to a chess tournament, asked for specific outputs in a .csv file (e.g. player name, player state, total number of points, player pre-rating, and average opponent rating). For this adapted project, the output .csv file will contain columns for: team name, total wins, final Elo rating, and opponents’ average Elo rating.
In the context of a single WNBA season, the last metric (opponents’ average Elo rating) is not an effective proxy for strength of schedule, due to the balanced, round-robin structure of the WNBA. There are 13 teams (as of 2025) and each team plays each opponent team 3 or 4 times total in a season, amounting to 44 total games per team. Because Elo is a zero-sum system with a baseline value, the mean rating of all teams played over the course of a season will tend toward the baseline Elo rating of 1300. If this project is extended to trace multiyear Elo rating trajectories, then the opponents’ average Elo rating may hold more significance for analysis.
Codebase
Importing Data from wehoop
To maintain the reduced scope of analyzing only the 2025 season, a request through the wehoop R package is made for team-level box-score data in 2025.
Tidying and Pruning Data
The data frame has clean naming conventions applied via the janitor package’s clean_names() function. Then, only the relevant columns are selected, omitting superfluous columns that are not meaningful in this methodology.
library(janitor)
box <- janitor::clean_names(box)
df <- box |>
select(game_id,
season_type,
game_date,
game_date_time,
team_id,
team_display_name,
team_home_away,
team_winner,
opponent_team_id,
opponent_team_display_name) |>
filter(season_type == 2) |> #only regular season games, no preseason
arrange(game_date_time)The data frame is split into two frames: one for away teams and the other for home teams.
away_df <- df |>
filter(team_home_away == "away") |>
select(game_id,
game_date,
away_team_id = team_id,
away_team_display_name = team_display_name,
away_team_winner = team_winner
)
home_df <- df |>
filter(team_home_away == "home") |>
select(game_id,
home_team_id = team_id,
home_team_display_name = team_display_name,
home_team_winner = team_winner
)The two split data frames are then rejoined around a common game_id key. Inspecting the results revealed that “TEAM CLARK” and “TEAM COLLIER” are present in the home team display names and away team display names, respectively. This row represents the All-Star Game match-up, and this game can be removed from the data frame by filtering for teams with display names that are neither “TEAM CLARK” nor “TEAM COLLIER”. The success of this step is confirmed via the unique() function.
In an initial run of this pipeline, two teams finished the season with 45 games played, compared to the overwhelming mode of 44 total games played. The Minnesota Lynx and the Indiana Fever had an extra game compared to other teams. No duplicate game_id rows were found. Instead, a data discrepancy in the ESPN source data was identified: a Commissioner’s Cup game (specifically, the championship game between the Minnesota Lynx and Indiana Fever) was included in the regular season data set (season_type == 2). This game was removed from the data frame by filtering out that game’s specific game_id.
full_df <- inner_join(away_df,
home_df,
by = "game_id") |>
mutate(home_win = if_else(home_team_winner == TRUE,
1,
0)) |>
select(game_id,
game_date,
home_team_id,
home_team_display_name,
away_team_id,
away_team_display_name,
home_win
)
home_teams <- unique(full_df$home_team_display_name)
# TEAM CLARK present
away_teams <- unique(full_df$away_team_display_name)
# TEAM COLLIER present
knitr::kable(home_teams, caption = "Home Teams List")| x |
|---|
| Washington Mystics |
| Dallas Wings |
| Golden State Valkyries |
| New York Liberty |
| Indiana Fever |
| Phoenix Mercury |
| Connecticut Sun |
| Los Angeles Sparks |
| Minnesota Lynx |
| Atlanta Dream |
| Chicago Sky |
| Las Vegas Aces |
| Seattle Storm |
| TEAM CLARK |
knitr::kable(away_teams, caption = "Away Teams List")| x |
|---|
| Atlanta Dream |
| Minnesota Lynx |
| Los Angeles Sparks |
| Las Vegas Aces |
| Chicago Sky |
| Seattle Storm |
| Washington Mystics |
| Dallas Wings |
| Indiana Fever |
| New York Liberty |
| Connecticut Sun |
| Golden State Valkyries |
| Phoenix Mercury |
| TEAM COLLIER |
full_df <- full_df |>
filter(away_team_display_name != "TEAM COLLIER", # Exclude All-Star Game
home_team_display_name != "TEAM CLARK", # Exclude All-Star Game
game_id != 401736430 # Exclude Commissioner's Cup Championship Game
)
home_teams <- unique(full_df$home_team_display_name)
# TEAM CLARK removed
away_teams <- unique(full_df$away_team_display_name)
# TEAM COLLIER removed
knitr::kable(home_teams, caption = "Corrected Home Teams List")| x |
|---|
| Washington Mystics |
| Dallas Wings |
| Golden State Valkyries |
| New York Liberty |
| Indiana Fever |
| Phoenix Mercury |
| Connecticut Sun |
| Los Angeles Sparks |
| Minnesota Lynx |
| Atlanta Dream |
| Chicago Sky |
| Las Vegas Aces |
| Seattle Storm |
knitr::kable(away_teams, caption = "Corrected Away Teams List")| x |
|---|
| Atlanta Dream |
| Minnesota Lynx |
| Los Angeles Sparks |
| Las Vegas Aces |
| Chicago Sky |
| Seattle Storm |
| Washington Mystics |
| Dallas Wings |
| Indiana Fever |
| New York Liberty |
| Connecticut Sun |
| Golden State Valkyries |
| Phoenix Mercury |
Troubleshooting Extra Lynx vs. Fever Game
A temporary data frame was constructed to display debugging results. First, the count() function was utilized to confirm no duplicate games existed in the full_df data frame. Second, the temporary data frame inspected only games between the Minnesota Lynx and Indiana Fever, confirming that only 3 regular season games occurred between the pairing. The debugging results are presented in tables below.
tmp <- full_df |>
count(game_id) |>
filter(n > 1)
knitr::kable(tmp, caption = "Confirm No Duplicate Games")| game_id | n |
|---|
tmp <- full_df |>
filter(
(home_team_display_name == "Minnesota Lynx" &
away_team_display_name == "Indiana Fever") |
(home_team_display_name == "Indiana Fever" &
away_team_display_name == "Minnesota Lynx")
) |>
select(
game_id,
game_date,
home_team_display_name,
away_team_display_name,
home_win
)
knitr::kable(tmp, caption = "Confirm Three Regular Season Lynx-Fever Match-ups")| game_id | game_date | home_team_display_name | away_team_display_name | home_win |
|---|---|---|---|---|
| 401736343 | 2025-08-22 | Indiana Fever | Minnesota Lynx | 0 |
| 401736351 | 2025-08-24 | Minnesota Lynx | Indiana Fever | 1 |
| 401736389 | 2025-09-09 | Indiana Fever | Minnesota Lynx | 1 |
Calculating Running Elo Ratings
A running Elo rating is calculated using the Elo R package. The elo.run() function will be used, which is documented in this specific vignette. The initial K value has been set to a value of 50. In the github article above, the author noted that varying K between 20 and 50 did not fundamentally alter the accuracy of their Elo-based predictions. Since the WNBA season is relatively short (44 games) compared to the NBA (82 games), a high, stable K value is preferred so that the Elo ratings are more responsive to intra-season momentum. The baseline Elo rating for the beginning of the 2025 season was set to 1300, as is convention for NBA Elo ratings analysis. Future work could perform experiments where these two parameters are tuned to find optimal settings for predictive accuracy.
K <- as.integer(50)
default_elo <- as.integer(1300)
elo_run <- elo.run(
formula = home_win ~ home_team_display_name + away_team_display_name,
data = full_df,
k = K,
initial.elos = default_elo
)
elo_results <- as.data.frame(elo_run)Tidying Elo Results
The elo_results data frame is appended to the full_df data frame, which merges the Elo calculations into the transformed game data. Pre- and post-game Elo ratings are stored for each home and away team, and only columns relevant to further analysis are selected.
full_elo_df <- bind_cols(full_df, elo_results)
full_elo_df <- full_elo_df |>
mutate(
home_elo_pre =elo.A - update.A,
away_elo_pre = elo.B - update.B,
home_elo_post = elo.A,
away_elo_post = elo.B
) |>
ungroup()
full_elo_df <- full_elo_df |>
select(game_date,
home_team_display_name,
away_team_display_name,
home_elo_pre,
home_elo_post,
home_win,
away_elo_pre,
away_elo_post
)An initial attempt was made to mutate in calculations to the assembled full_elo_df data frame. These calculations were aimed at totaling the number of wins, losses, and total games for each team. However, this procedure produced erroneous results with high variance in the total games per team. A second approach was successful in calculating these totals, but a tidy, long data frame was needed. To achieve this long data frame, the full_elo_df frame was split into home and away team data frames, with identical column names. This allowed for the use of the bind_rows() function, in which a second data frame is appended vertically to a first data frame. The resulting long data frame was ready for calculating summary metrics.
home_team_df <- full_elo_df |>
select(game_date,
team = home_team_display_name,
opp_team = away_team_display_name,
win = home_win,
team_elo_pre = home_elo_pre,
team_elo_post = home_elo_post,
opp_elo_pre = away_elo_pre
)
away_team_df <- full_elo_df |>
mutate(away_win = 1-home_win) |>
select(game_date,
team = away_team_display_name,
opp_team = home_team_display_name,
win = away_win,
team_elo_pre = away_elo_pre,
team_elo_post = away_elo_post,
opp_elo_pre = home_elo_pre
)
all_teams_df <- bind_rows(home_team_df, away_team_df) |> # make a long data frame
arrange(team, game_date)Summarizing Season Results
The summary_2025 data frame is formed via the tidyverse package’s summarize() function, which (as opposed to window functions) flattens rows into a single aggregated row. New data frames also contained summary data for the top 3 teams (top_3_df) and bottom 3 teams (bottom_3_df) in the WNBA for the 2025 season. The combined ext_6_df was constructed for verification and visualization purposes, and its output is displayed below.
summary_2025 <- all_teams_df |>
group_by(team) |>
summarize(total_games = n(),
total_wins = sum(win),
total_losses = total_games - total_wins, #not independent column| nice for viz
final_elo = round(last(team_elo_post),0),
avg_opp_elo = round(mean(opp_elo_pre),0)
) |>
arrange(desc(total_wins))
top_3_df <- head(summary_2025,3) # top 3 teams summary
bottom_3_df <- tail(summary_2025,3) # bottom 3 teams summary
ext_6_df <- bind_rows(top_3_df, bottom_3_df) #combine into 6 team divergent summary
knitr::kable(ext_6_df, caption = "2025 WNBA Compressed Elo Summary Output")| team | total_games | total_wins | total_losses | final_elo | avg_opp_elo |
|---|---|---|---|---|---|
| Minnesota Lynx | 44 | 34 | 10 | 1449 | 1294 |
| Atlanta Dream | 44 | 30 | 14 | 1491 | 1298 |
| Las Vegas Aces | 44 | 30 | 14 | 1559 | 1291 |
| Connecticut Sun | 44 | 11 | 33 | 1172 | 1309 |
| Chicago Sky | 44 | 10 | 34 | 1085 | 1324 |
| Dallas Wings | 44 | 10 | 34 | 1096 | 1311 |
Visualizing Season Results
First, a plot showing the linearity of Total Wins vs. Elo Ratings is constructed. Here, custom formatting is achieved using the theme() function.
summary_2025 |>
ggplot(aes(x=final_elo,
y=total_wins)) +
geom_point(alpha = 1,
color = "black",
show.legend = FALSE) +
geom_smooth(formula = 'y ~ x',
method = lm,
se = TRUE) +
labs(title="Wins vs. End of Season Elo Rating (2025)",
subtitle = "data sourced from wehoop R package",
y = "Total Wins",
x = "Final Elo Rating"
) +
theme_dark() +
theme(
plot.title.position = "plot",
plot.caption.position = "plot",
plot.title = element_text(size = 15, face = "bold"),
plot.subtitle = element_text(size = 13, face = "italic"),
axis.text.y = element_text(size = 11, face = "bold"),
axis.text.x = element_text(size = 11, face = "bold"),
axis.title.y = element_text(size = 12, face = "bold"),
axis.title.x = element_text(size = 12, face = "bold")
)Next, the Elo trajectories for the top 3 and bottom 3 teams for the WNBA 2025 season are shown.
ext_6_names <- unique(ext_6_df$team)
ext_6_game_df <- all_teams_df |>
filter(team %in% ext_6_names) |>
group_by(team) |>
arrange(game_date)
ext_6_game_df |>
ggplot(aes(x=game_date,
y=team_elo_post,
color = team)) +
geom_point(alpha = 0.5,
show.legend = FALSE,
size = 2.75) +
geom_smooth(method = 'loess',
formula = 'y~x',
show.legend = TRUE,
linewidth = 1.75) +
labs(title = "Elo Trajectory Through 2025 Season for Top and Bottom 3 Teams",
subtitle = "data sourced from wehoop R package",
y= "Elo Rating",
x= "Game Date (2025)",
color = "Team") +
theme_dark() +
theme(
plot.title.position = "plot",
plot.caption.position = "plot",
plot.title = element_text(size = 15, face = "bold"),
plot.subtitle = element_text(size = 13, face = "italic"),
axis.text.y = element_text(size = 11, face = "bold"),
axis.text.x = element_text(size = 11, face = "bold"),
axis.title.y = element_text(size = 12, face = "bold"),
axis.title.x = element_text(size = 12, face = "bold")
)A new data frame is prepared with mutated columns calculating Elo differential between winner and loser and a matchup column pasting together character vectors to generate labels for a subsequent plot.
upsets <- full_elo_df |>
mutate(
winning_team = if_else(home_win == 1,
home_team_display_name,
away_team_display_name),
losing_team = if_else(home_win == 1,
away_team_display_name,
home_team_display_name),
elo_diff = if_else(home_win == 1,
abs(home_elo_post - home_elo_pre),
abs(away_elo_post - away_elo_pre)),
matchup = paste0(winning_team,
" over ",
losing_team,
"\n(date: ",
game_date,
")")
) |>
arrange(desc(elo_diff))
upsets <- head(upsets, 6)Finally, a column chart showing the victories with the largest upsets (as indicated by greatest Elo differential pre- and post-game) was rendered. Here, a geom_text() layer was utilized to add specific labels to the figure. Additionally, the scaling of the x-axis was reapplied.
upsets |> ggplot(aes(x=elo_diff, y=reorder(matchup, elo_diff))) +
geom_col(fill = "red",
width = 0.75,
alpha = 0.7) +
geom_text(aes(color = "white",
label = paste("+", round(elo_diff, 0), " pts")),
hjust = 1.15,
size = 4,
fontface = "bold",
show.legend = FALSE) +
labs(title = "Largest Upset Victories in 2025 Season",
subtitle = "(ranked by Elo gain)",
y=NULL,
x = "Elo Gain",
caption = "data sourced from wehoop R package") +
theme_dark() +
scale_x_continuous(
limits = c(0, 50)) +
theme(
plot.title.position = "plot",
plot.caption.position = "plot",
plot.title = element_text(size = 15,
face = "bold"),
plot.subtitle = element_text(size = 13,
face = "italic"),
plot.caption = element_text(size = 10,
face = "italic",
hjust = 0),
axis.text.y = element_text(size = 11,
face = "bold.italic"),
axis.text.x = element_text(size = 11,
face = "bold"),
axis.title.x = element_text(size = 12,
face = "bold")
)Exporting to .csv
The summary_2025 data frame can be exported as a .csv file using the readr package’s write_csv() function. The .csv file can be validated by re-importing it as a new data frame and knitting a table to display the .csv file’s contents.
output_2025 <- summary_2025 |>
select(team,
total_wins,
final_elo,
avg_opp_elo)
write_csv(output_2025, "wnba_elo_summary_2025.csv")
check_df <- read_csv("wnba_elo_summary_2025.csv")
knitr::kable(check_df, caption = "Validated 2025 WNBA Elo Summary Output")| team | total_wins | final_elo | avg_opp_elo |
|---|---|---|---|
| Minnesota Lynx | 34 | 1449 | 1294 |
| Atlanta Dream | 30 | 1491 | 1298 |
| Las Vegas Aces | 30 | 1559 | 1291 |
| New York Liberty | 27 | 1344 | 1300 |
| Phoenix Mercury | 27 | 1333 | 1295 |
| Indiana Fever | 24 | 1343 | 1284 |
| Golden State Valkyries | 23 | 1304 | 1293 |
| Seattle Storm | 23 | 1295 | 1293 |
| Los Angeles Sparks | 21 | 1323 | 1301 |
| Washington Mystics | 16 | 1106 | 1301 |
| Connecticut Sun | 11 | 1172 | 1309 |
| Chicago Sky | 10 | 1085 | 1324 |
| Dallas Wings | 10 | 1096 | 1311 |
Findings and Recommendations
There were several data quirks encountered in this project. First, the WNBA All-Star game was included as a regular season game within the source ESPN data set (marked season_type == 2). Second, the Commissioner’s Cup Championship game was also included within the source data set. Both games were removed from data frames using the dplyr package’s filter() function. Third, the data frame containing game data and running Elo calculations (full_elo_df) required transformation into a long format to facilitate successful analysis via the summarize() function.
Figure 1 shows how Total Wins varies linearly with a team’s Final Elo Rating. While Total Wins aggregates game performance to a single binary result (win vs. loss), the Elo methodology rewards teams overcoming difficult match-ups more than easier ones. This allows for the mapping of momentum over the course of a season.
The Elo trajectories in Figure 2 demonstrate how the performance of top-tier teams diverges from that of bottom-tier teams early on in the season. This may be an artifact of the K-factor set to a value of 50, resulting in a more responsive running Elo calculation. A future study could investigate the impact of changing the K-factor value between 20 and 100 to determine a relative optimal value.
The upset analysis presented in Figure 3 demonstrates that large Elo differentials between teams do not always translate to a predictable outcome. The Connecticut Sun, a team ranked 3rd to last in the 2025 season, secured three major upsets over playoff-bound teams such as the Seattle Storm, Phoenix Mercury, and New York Liberty, despite high pre-game Elo differentials.
There are several avenues through which this work can be extended and refined. Namely, the K-factor analysis previously suggested could be useful in calibrating the Elo calculations to better mirror WNBA dynamics. Another series of experiments could vary the baseline Elo rating for all teams and determine a representative value for the WNBA context. Additionally, a natural extension could be using this framework for predictive analysis. Using regular-season Elo ratings to predict playoff outcomes could be increasingly informative, particularly as the 2026 WNBA playoffs are soon approaching.
The scope for this project was limited to the 2025 WNBA regular season. Future work should aim to convert this approach into a reusable, durable formula that can compute regular season Elo ratings for any of the WNBA seasons between 1997 and the present. Extending the project to multi-year horizons would require the application of a dampening calculation to regress end-of-season Elo ratings back toward a mean or baseline value before the subsequent season’s running Elo ratings are calculated. Common approaches to this specific inter-season problem have employed exponential decay functions for mean reversion of each team’s end-of-season Elo rating to account for off-season roster dynamics and rest.
Lastly, the Elo trajectories in Figure 2 reveal important late-season momentum that win/loss totals can miss. While the Minnesota Lynx maintained the highest Elo rating for much of the season, the Las Vegas Aces demonstrated unparalleled momentum moving into the playoffs, securing the highest Elo rating by late August. Their momentum, captured by the slope of their Elo trajectory, likely helped the Aces in their path to winning the WNBA Championship in October 2025.