Code
library(tidyverse)
library(janitor)
library(knitr)
library(kableExtra)An independent, data-driven ranking exposing bias in college hockey
library(tidyverse)
library(janitor)
library(knitr)
library(kableExtra)This project analyzes the relationship between recruiting quality and team success in the NCAA Division III Men’s Hockey 2025-26 season, to give an accurate power ranking not based solely off wins and losses, or conference bias.
To start… I collected data of where every single player in Division III hockey played junior hockey prior to this season, unless they are a Division I or III transfer, which was also taken into account. Below is the amount of players in D3 hockey, split up by conference.
| conference | n |
|---|---|
| NESCAC | 306 |
| LEC | 296 |
| NCHA | 278 |
| SUNYAC | 277 |
| CNE | 270 |
| MIAC | 259 |
| UCHC | 250 |
| MASCAC | 229 |
| MAC | 222 |
| WIAC | 165 |
| Independent | 60 |
Next… I found the percentage of each Junior/NCAA league per roster, and gave a weight (1-6) to these previous leagues that the players on these rosters came from.
Once that is done, I compiled the data to show each conference’s weight, and each team’s average recruit weight based off their junior league/previous NCAA team…
library(tidyverse)
library(readr)
library(janitor)
library(scales)
library(tidyr)
library(knitr)
library(kableExtra)
library(DT)
# Calculate Junior/Previous Team % in D3 #
league_percentages <- real_team_data %>%
count(prev_league) %>%
mutate(
pct_d3 = round(100 * n / sum(n), 3)) %>%
arrange(desc(pct_d3))
# Calculate Junior/Previous Team % Per Conference #
conference_percentages <- real_team_data %>%
count(conference, prev_league) %>%
group_by(conference) %>%
mutate(
pct_conference = round(100 * n / sum(n), 3))
# Create Weights For Each League #
league_weights <- real_team_data %>%
mutate(conference_group = ifelse(is.na(conference) | conference == "",
"Independent",
conference),
recruit_weight = case_when(
prev_league %in% c("NCAA DI", "USHL", "BCHL") ~ 6,
prev_league %in% c("NAHL") ~ 5,
prev_league %in% c("AJHL","MJHL", "SJHL", "NCDC", "CCHL", "OJHL", "NCAA DIII") ~ 4,
prev_league %in% c("EHL", "USPHL", "Prep", "MN-HS", "Euro Junior", "MHL") ~ 3,
prev_league %in% c("GOJHL", "NOJHL", "NA3HL", "ACHA", "SIJHL", "KIJHL", "GMHL", "MJAHL", "QJHL") ~ 2,
prev_league %in% c("U18", "CAHS", "HS", "QCHL", "VIJHL", "PJHL", "OPJHL", "CJHL") ~ 1))
# Show Conference Tiers #
conference_scores <- league_weights %>%
group_by(conference_group) %>%
summarise(
players = n(),
conference_avg_weight = mean(recruit_weight, na.rm = TRUE)) %>%
arrange(desc(conference_avg_weight))
conference_scores %>%
rename(
"Conference" = conference_group,
"Total Players in Conference" = players,
"Conference Average Recruit Weight" = conference_avg_weight) %>%
kable(digits = 3) %>%
kable_styling(full_width = FALSE)| Conference | Total Players in Conference | Conference Average Recruit Weight |
|---|---|---|
| NCHA | 278 | 4.144 |
| NESCAC | 306 | 4.141 |
| MIAC | 259 | 4.108 |
| WIAC | 165 | 4.042 |
| SUNYAC | 277 | 3.968 |
| UCHC | 250 | 3.904 |
| CNE | 270 | 3.785 |
| MAC | 222 | 3.491 |
| LEC | 296 | 3.426 |
| Independent | 60 | 3.333 |
| MASCAC | 229 | 3.266 |
# Show All Teams #
team_scores <- league_weights %>%
group_by(college_team, conference_group) %>%
summarise(
players = n(),
avg_recruit_weight = mean(recruit_weight, na.rm = TRUE)) %>%
arrange(desc(avg_recruit_weight))
team_scores %>%
rename(
"School" = college_team,
"Conference" = conference_group,
"Players on Roster" = players,
"Average Recruit Weight" = avg_recruit_weight) %>%
mutate(
`Players on Roster` = round(`Players on Roster`, 0),
`Average Recruit Weight` = round(`Average Recruit Weight`, 3)) %>%
DT::datatable(
options = list(pageLength = 20, scrollX = TRUE),
caption = htmltools::tags$caption(
style = "caption-side: top; text-align: center; font-weight: bold;",
"Team Average Recruit Weight"))Now recruiting isn’t the only variable. I took each team’s record, found their win percentage, and created a regression model to look at how each team’s roster played a part in their record for this season, and the difficulty of their conference, if in one.
library(tidyverse)
library(readr)
library(janitor)
library(scales)
library(tidyr)
# Separate Record into Wins-Losses-Ties For R #
Team_Stats <- Team_Stats %>%
separate(`Record`,
into = c("Wins", "Losses", "Ties"),
sep = "-",
remove = FALSE)
Team_Stats$Ties[is.na(Team_Stats$Ties)] <- 0
Team_Stats <- Team_Stats %>%
mutate(conference_group = ifelse(is.na(Conference) | Conference == "",
"Independent",
Conference))
New_Stats <- Team_Stats %>%
mutate(
Ties = ifelse(is.na(Ties), 0, Ties),
Wins = as.numeric(Wins),
Losses = as.numeric(Losses),
Ties = as.numeric(Ties),
Games = Wins + Losses + Ties,
WinPct = Wins / Games)
# Create Roster Analysis Table #
# Team's Avg Recruit Weight #
# Team Roster Balance by Standard Deviation #
# Player Total #
team_analysis <- league_weights %>%
group_by(college_team, conference_group) %>%
summarise(
avg_recruit_weight = mean(recruit_weight, na.rm = TRUE),
recruit_sd = sd(recruit_weight, na.rm = TRUE),
players = n())
# Create Conference-Strength Table Based on Recruit Average #
conference_scores <- team_analysis %>%
filter(conference_group != "Independent") %>%
group_by(conference_group) %>%
summarise(
conference_avg_weight = mean(avg_recruit_weight, na.rm = TRUE))
# Create Master Table #
# Join Team Stats w/ Full Roster Analysis Table #
team_recruiting <- team_analysis %>%
left_join( New_Stats,
by = c(
"college_team" = "Team",
"conference_group" = "Conference"))
team_recruiting <- team_recruiting %>%
select(-conference_group.y)
# Add in Conference Strength #
team_recruiting <- team_recruiting %>%
left_join(
conference_scores %>%
select(conference_group, conference_avg_weight),
by = "conference_group")
team_recruiting <- team_recruiting %>%
relocate(conference_avg_weight, .after = avg_recruit_weight)
# Start a Correlation Between Wins & Team Recruiting #
cor_value <- cor(team_recruiting$avg_recruit_weight,
team_recruiting$WinPct,
use = "complete.obs")
# Correlation is .6117, meaning strong relationship in higher recruiting to success in D3 #
# Plot a linear model of the correlation #
model1 <- lm(
WinPct ~ avg_recruit_weight + conference_avg_weight,
data = team_recruiting,
na.action = na.exclude)
# Every +1 increase in recruit weight, win % increases by .272 #
# Recruiting combined with conference weight causes 45% variation in win % across D3 hockey #
# Graph my Linear Model #
team_recruiting <- team_recruiting %>%
mutate(
independent_flag = ifelse(conference_group ==
"Independent",
"Independent",
"Teams In Conference"))
ggplot(team_recruiting,
aes(
x = avg_recruit_weight,
y = WinPct,
color = conference_avg_weight)) +
geom_point(
aes(shape = independent_flag),
size = 2.5) +
scale_color_gradient(
low = "red",
high = "blue") +
scale_shape_manual(values = c(
"Teams In Conference" = 16,
"Independent" = 17)) +
geom_smooth(method = "lm", color = "black", se = FALSE) +
theme_minimal() +
labs(
title = "Recruiting Quality vs Team Success in Division III Hockey",
x = "Team's Average Recruit Weight",
y = "Team's Win Percentage",
color = "Conference Strength",
shape = "Team Type")ggsave(
filename = "d3_hockey_plot.png",
width = 10,
height = 6,
dpi = 300)A couple of points from this data:
Being that there is an upward line, and a correlation of .612, the graph shows a pretty strong relationship in higher quality recruiting to success in D3 hockey, as well as the importance of a strong conference.
For every +1 increase in recruit weight, a team’s win percentage increases by 26%.
Recruiting quality combined with conference strength influence a 41.4% variation in win percentage across D3 hockey.
But that’s only part of the picture..
From this model, residuals were used to identify the top 10 overachieving and underachieving teams this season based on the relationship between roster quality and actual performance.
These residuals highlight which programs exceeded expectations and which fell short relative to their roster strength. Positive, and higher values, represent teams outperforming their predicted win percentage, while negative values indicate under-performance.
library(knitr)
# Find Residuals #
team_recruiting$residuals <- residuals(model1)
# Top 10 Overachievers #
team_recruiting %>%
arrange(desc(residuals)) %>%
select(
Team = college_team,
Conference = conference_group,
"Average Recruit Weight" = avg_recruit_weight,
"Conference Average Recruit Weight" = conference_avg_weight,
"Win %" = WinPct,
Residuals = residuals) %>%
head(10) %>%
knitr::kable(digits = 3, caption = "Top 10 Overachieving Programs")| Team | Conference | Average Recruit Weight | Conference Average Recruit Weight | Win % | Residuals |
|---|---|---|---|---|---|
| University of Wisconsin - Stout | WIAC | 3.808 | 4.069 | 0.767 | 0.360 |
| Bowdoin College | NESCAC | 3.824 | 4.155 | 0.680 | 0.286 |
| Babson College | LEC | 3.538 | 3.457 | 0.741 | 0.283 |
| Westfield State University | MASCAC | 2.923 | 3.262 | 0.609 | 0.272 |
| Suffolk University | CNE | 3.394 | 3.768 | 0.630 | 0.271 |
| Hobart College | SUNYAC | 4.966 | 3.958 | 0.968 | 0.238 |
| Fitchburg State University | MASCAC | 3.321 | 3.262 | 0.667 | 0.227 |
| Anna Maria College | MASCAC | 3.536 | 3.262 | 0.714 | 0.219 |
| Chatham University | UCHC | 4.179 | 3.911 | 0.741 | 0.207 |
| Aurora University | NCHA | 4.560 | 4.155 | 0.781 | 0.196 |
# Top 10 Underachievers #
team_recruiting %>%
arrange(residuals) %>%
select(
Team = college_team,
Conference = conference_group,
"Average Recruit Weight" = avg_recruit_weight,
"Conference Average Recruit Weight" = conference_avg_weight,
"Win %" = WinPct,
Residuals = residuals) %>%
head(10) %>%
knitr::kable(digits = 3, caption = "Top 10 Underachieving Programs")| Team | Conference | Average Recruit Weight | Conference Average Recruit Weight | Win % | Residuals |
|---|---|---|---|---|---|
| Augsburg University | MIAC | 4.714 | 4.126 | 0.280 | -0.351 |
| Rivier University | MASCAC | 3.167 | 3.262 | 0.095 | -0.304 |
| Lake Forest College | NCHA | 4.143 | 4.155 | 0.222 | -0.254 |
| College of St. Scholastica | MIAC | 4.185 | 4.126 | 0.240 | -0.253 |
| King’s College (PA) | MAC | 3.391 | 3.502 | 0.160 | -0.250 |
| Morrisville State College (SUNY) | SUNYAC | 3.308 | 3.958 | 0.080 | -0.219 |
| Milwaukee School of Engineering (MSOE) | NCHA | 4.533 | 4.155 | 0.370 | -0.208 |
| Wentworth Institute of Technology | CNE | 3.741 | 3.768 | 0.250 | -0.199 |
| Misericordia University | MAC | 3.321 | 3.502 | 0.200 | -0.192 |
| Salem State University | MASCAC | 3.452 | 3.262 | 0.292 | -0.182 |
Next, I plotted those residuals on the previous regression model to really show how many teams succeeded, and didn’t, based off their roster quality and conference strength. Teams located near the regression line performed about as expected, while teams above the line exceeded expectations, and teams below the line underperformed based on their team makeups.
team_recruiting$residuals <- residuals(model1)
team_recruiting <- team_recruiting %>%
mutate(independent_flag = ifelse(conference_group ==
"Independent",
"Independent",
"Teams In Conference"))
# Plot residuals on Regression map #
ggplot(team_recruiting,
aes(x = avg_recruit_weight,
y = WinPct)) +
geom_point(
aes(
color = residuals,
shape = independent_flag),
size = 3) +
scale_color_gradient2(
low = "red",
mid = "gray",
high = "blue",
midpoint = 0) +
geom_smooth(method = "lm", color = "black") +
scale_shape_manual(values = c(
"Teams In Conference" = 16,
"Independent" = 17),
name = "Team Type") +
theme_minimal() +
labs(
title = "Recruiting vs Team Success in Division III Hockey",
x = "Team's Average Recruit Weight",
y = "Team's Win Percentage",
color = "Over / Under\nPerformance Score")ggsave(
filename = "d3_residual_plot.png",
width = 10,
height = 6,
dpi = 300)However, all conferences cannot be weighted the same before recruiting. So, I gathered the scores from every single game this year, and created each team and conference’s strength of schedule based off those scores as an ELO.
With the ELO, I assigned every team a starting rating of 1500, and it updates after each game based on opponent strength and game result. Wins against stronger opponents create larger gains, while losses to weaker teams result in declines.
library(tidyverse)
library(readr)
library(stringr)
library(lubridate)
library(kableExtra)
library(DT)
# Call schedules from environment #
CNE_Schedule <- read_csv("CNE_Schedule.csv")
Independent_Schedules <- read_csv("Independent_Schedules.csv")
LEC_Schedule <- read_csv("LEC_Schedule.csv")
MAC_Schedule <- read_csv("MAC_Schedule.csv")
MASCAC_Schedule <- read_csv("MASCAC_Schedule.csv")
MIAC_Calendar <- read_csv("MIAC_Calendar.csv")
NCHA_Schedule <- read_csv("NCHA_Schedule.csv")
NESCAC_Schedule <- read_csv("NESCAC_Schedule.csv")
SUNYAC_Schedule <- read_csv("SUNYAC_Schedule.csv")
UCHC_Standings <- read_csv("UCHC_Standings.csv")
WIAC_Schedule <- read_csv("WIAC_Schedule.csv")
# Clean Each dataset first #
clean_schedule <- function(df) {
df %>%
mutate(
Date = as.Date(Date, tryFormats = c("%m/%d/%y", "%m/%d/%Y", "%Y-%m-%d")),
`Away Team` = str_squish(`Away Team`),
`Home Team` = str_squish(`Home Team`),
`Away Score` = as.numeric(`Away Score`),
`Home Score` = as.numeric(`Home Score`)) %>%
# remove duplicates INSIDE each file first
distinct(Date, `Away Team`, `Home Team`, `Away Score`, `Home Score`, .keep_all = TRUE)}
CNE_Schedule <- clean_schedule(CNE_Schedule)
Independent_Schedules <- clean_schedule(Independent_Schedules)
LEC_Schedule <- clean_schedule(LEC_Schedule)
MAC_Schedule <- clean_schedule(MAC_Schedule)
MASCAC_Schedule <- clean_schedule(MASCAC_Schedule)
MIAC_Calendar <- clean_schedule(MIAC_Calendar)
NCHA_Schedule <- clean_schedule(NCHA_Schedule)
NESCAC_Schedule <- clean_schedule(NESCAC_Schedule)
SUNYAC_Schedule <- clean_schedule(SUNYAC_Schedule)
UCHC_Standings <- clean_schedule(UCHC_Standings)
WIAC_Schedule <- clean_schedule(WIAC_Schedule)
# Bind ALL Schedules #
nationwide_schedule <- bind_rows(
CNE_Schedule,
Independent_Schedules,
LEC_Schedule,
MAC_Schedule,
MASCAC_Schedule,
MIAC_Calendar,
NCHA_Schedule,
NESCAC_Schedule,
SUNYAC_Schedule,
UCHC_Standings,
WIAC_Schedule)
nationwide_schedule <- nationwide_schedule %>%
mutate(
`Away Team` = str_replace(`Away Team`, "Milwaukee School of Engineering", "Milwaukee School of Engineering (MSOE)"),
`Home Team` = str_replace(`Home Team`, "Milwaukee School of Engineering", "Milwaukee School of Engineering (MSOE)"),
`Away Team` = str_squish(`Away Team`),
`Home Team` = str_squish(`Home Team`))
# Make True Game ID #
nationwide_schedule <- nationwide_schedule %>%
mutate(
team_a = pmin(`Away Team`, `Home Team`),
team_b = pmax(`Away Team`, `Home Team`),
score_a = pmin(`Away Score`, `Home Score`),
score_b = pmax(`Away Score`, `Home Score`),
game_id = paste(Date, team_a, team_b, score_a, score_b)) %>%
distinct(game_id, .keep_all = TRUE)
# Create full schedule #
games_long <- bind_rows(
nationwide_schedule %>%
transmute(
game_id,
team = `Away Team`,
opponent = `Home Team`,
team_score = `Away Score`,
opponent_score = `Home Score`,
Conf_Game),
nationwide_schedule %>%
transmute(
game_id,
team = `Home Team`,
opponent = `Away Team`,
team_score = `Home Score`,
opponent_score = `Away Score`,
Conf_Game))
games_long <- games_long %>%
mutate(
team = str_squish(team),
opponent = str_squish(opponent),
result = as.integer(team_score > opponent_score))
# ELO Setup #
# Give Each Team Start Rating #
team_elo <- games_long %>%
distinct(team) %>%
mutate(elo = 1500)
# ELO Function #
update_elo <- function(df, k = 20) {
elo <- df %>%
distinct(team) %>%
mutate(elo = 1500)
for (i in 1:nrow(df)) {
t1 <- df$team[i]
t2 <- df$opponent[i]
r <- df$result[i]
r1 <- elo$elo[elo$team == t1]
r2 <- elo$elo[elo$team == t2]
p1 <- 1 / (1 + 10 ^ ((r2 - r1) / 400))
elo$elo[elo$team == t1] <- r1 + k * (r - p1)
elo$elo[elo$team == t2] <- r2 + k * ((1 - r) - (1 - p1))
}
elo
}
# Join national schedule with team info #
team_lookup <- team_scores %>%
select(college_team, conference_group)
games_long <- games_long %>%
left_join(team_lookup %>%
rename(team_conference = conference_group),
by = c("team" = "college_team")) %>%
left_join(team_lookup %>%
rename(opponent_conference = conference_group),
by = c("opponent" = "college_team"))
# Put NAs as Non DIII #
invisible(
games_long %>%
filter(is.na(team_conference) | is.na(opponent_conference)) %>%
select(team, opponent))
invisible(
team_scores %>%
filter(college_team %in% c("problem team here")))
games_long <- games_long %>%
mutate(
team_conference = ifelse(is.na(team_conference), "Non DIII", team_conference),
opponent_conference = ifelse(is.na(opponent_conference), "Non DIII", opponent_conference))
# Run ELO and Join with Game Log #
team_elo <- update_elo(games_long)
team_ratings <- team_elo %>%
arrange(desc(elo)) %>%
mutate(rank = row_number())
# Attach ELO to opponent for SOS calculation
games_long <- games_long %>%
left_join(team_elo, by = c("opponent" = "team")) %>%
rename(opp_elo = elo)
# NEW: Weight postseason games more heavily
# Determine Team SOS #
team_sos <- games_long %>%
group_by(team, team_conference) %>%
summarise(
sos = mean(opp_elo, na.rm = TRUE),
win_pct = mean(result, na.rm = TRUE),
games = n(),
.groups = "drop")
team_sos <- team_sos %>%
left_join(team_ratings %>% select(team, elo),
by = "team")
# Graph Team SOS #
team_sos %>%
filter(team_conference != "Non DIII") %>%
select(
Team = team,
Conference = team_conference,
"SOS" = sos,
"Win %" = win_pct,
Games = games) %>%
mutate(
SOS = round(SOS, 3),
`Win %` = round(`Win %`, 3)) %>%
arrange(desc(SOS)) %>%
DT::datatable(
options = list(pageLength = 20, scrollX = TRUE),
caption = htmltools::tags$caption(
style = "caption-side: top; text-align: center; font-weight: bold;",
"Team Strength of Schedule Rankings"))# Determine Conference Strength Based off Team SOS #
conference_strength <- team_sos %>%
group_by(team_conference) %>%
summarise(
avg_sos = mean(sos, na.rm = TRUE),
avg_win_pct = mean(win_pct, na.rm = TRUE),
avg_elo = mean(elo, na.rm = TRUE),
teams = n(),
.groups = "drop") %>%
arrange(desc(avg_elo))
# Graph Conference SOS #
conference_strength %>%
filter(team_conference != "Non DIII") %>%
select(
Conference = team_conference,
"Conference SOS (Avg)" = avg_sos,
"Conference Win %" = avg_win_pct,
"Teams" = teams) %>%
arrange(desc(`Conference SOS (Avg)`)) %>%
knitr::kable(digits = 3, caption = "Conference Strength of Schedule Rankings") %>%
kableExtra::kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE)| Conference | Conference SOS (Avg) | Conference Win % | Teams |
|---|---|---|---|
| NESCAC | 1555.461 | 0.510 | 10 |
| UCHC | 1518.811 | 0.506 | 9 |
| NCHA | 1513.027 | 0.460 | 10 |
| WIAC | 1512.008 | 0.511 | 6 |
| SUNYAC | 1504.586 | 0.454 | 10 |
| LEC | 1503.379 | 0.465 | 10 |
| MIAC | 1502.997 | 0.435 | 9 |
| CNE | 1501.533 | 0.464 | 9 |
| Independent | 1482.774 | 0.417 | 2 |
| MAC | 1480.982 | 0.424 | 8 |
| MASCAC | 1472.105 | 0.424 | 8 |
Now that we can see how many teams did well, and who disappointed all around, I made an accurate and representative power ranking that takes more into account than just winning, talent, or conference notoriety.
The ranking is based on a program score that combines each team’s performance in the previous three aspects, relative to the rest of Division III hockey. The weighted combination goes as such:
Winning remains the most heavily weighed factor, as success is the primary indicator of a team’s performance, followed by recruiting quality, overall conference strength, and strength of schedule. Higher program scores indicate teams that were better positioned to succeed this season based on both roster strength and competitive competition.
library(knitr)
library(kableExtra)
library(stats)
library(DT)
# Ungroup rankings so final ranking is separate #
team_recruiting <- ungroup(team_recruiting)
# Join ELO into Table #
team_recruiting <- team_recruiting %>%
select(-starts_with("elo"))
team_recruiting <- team_recruiting %>%
left_join(
team_ratings %>% select(team, elo),
by = c("college_team" = "team"))
# Standardize recruiting strength, conference strength, and winning percentage #
# Arrange in order of program score #
final_rankings <- team_recruiting %>%
mutate(
avg_recruit_weight = as.numeric(avg_recruit_weight),
WinPct = as.numeric(WinPct),
elo = as.numeric(elo),
recruit_z = (avg_recruit_weight - mean(avg_recruit_weight, na.rm = TRUE)) /
sd(avg_recruit_weight, na.rm = TRUE),
win_z = (WinPct - mean(WinPct, na.rm = TRUE)) /
sd(WinPct, na.rm = TRUE),
elo_z = (elo - mean(elo, na.rm = TRUE)) /
sd(elo, na.rm = TRUE),
conference_z = ifelse(
conference_group == "Independent",
0,
(conference_avg_weight -
mean(conference_avg_weight, na.rm = TRUE)) /
sd(conference_avg_weight, na.rm = TRUE)),
program_score =
0.25 * recruit_z +
0.35 * win_z +
0.20 * conference_z +
0.20 * elo_z) %>%
arrange(desc(program_score)) %>%
mutate(rank = row_number())
# Display Final Program Rankings #
final_rankings %>%
mutate(
program_score = round(program_score, 3),
avg_recruit_weight = round(avg_recruit_weight, 2),
conference_avg_weight = round(conference_avg_weight, 2),
WinPct = round(WinPct, 3),
elo = round(elo, 0)) %>%
select(
Rank = rank,
Team = college_team,
Conference = conference_group,
"Program Score" = program_score,
"Team's Avg Recruit Weight" = avg_recruit_weight,
"Conference Avg Recruit Weight" = conference_avg_weight,
"Team's Win %" = WinPct,
"ELO Rating" = elo) %>%
DT::datatable(
options = list(pageLength = 20, scrollX = TRUE),
caption = htmltools::tags$caption(
style = "caption-side: top; text-align: center; font-weight: bold;",
"Final Program Rankings"))