Imagine you’re the performance analyst for a national Olympic weightlifting federation, and the head coach walks in asking a hard question: which athletes are actually worth the investment? That question is the whole reason for this project. It falls under Objective 4, recruitment and talent identification, and the goal is simple to state, harder to answer — figure out which athlete profiles tend to medal.
For data, I’m using the “120 Years of Olympic History: Athletes and Results” dataset. It originally came from Kaggle and sports-reference.com; my copy came through the TidyTuesday mirror. I filtered it down to Weightlifting only. Records run from 1896 through 2016, and cover the basics — age, height, weight, sex — along with country (NOC), event/weight class, and whether the athlete medaled.
My focus on this is because a federation with limited resources can’t scout everyone equally. If certain traits, or certain program-level factors, actually correlate with medaling, coaches can put scouting dollars where they’ll count before the next Olympic cycle starts.
raw <- read_csv("data/athlete_events_raw.csv", show_col_types = FALSE) %>%
clean_names()
wl <- raw %>% filter(sport == "Weightlifting")
cat("Total weightlifting records:", nrow(wl), "\n")
## Total weightlifting records: 3937
Early Olympic Games didn’t track everything I’d want. Plenty of older records are missing age, height, or weight entirely. Since those three fields are core predictors here, I just drop any row missing one of them — a smaller clean dataset beats a bigger messy one.
wl <- wl %>% filter(!is.na(age), !is.na(height), !is.na(weight))
cat("Records after removing missing age/height/weight:", nrow(wl), "\n")
## Records after removing missing age/height/weight: 2974
A few new columns make the modeling step possible. BMI (weight
divided by height in meters, squared) gives a body-composition measure
that’s comparable across weight classes — something raw weight alone
can’t do. I also build the actual target variable,
medal_binary: did the athlete medal at all (gold, silver,
or bronze), or not.
Two more additions round things out. An era flag splits
records before and after 2000, since rules and equipment shifted enough
over the decades to be worth capturing. And weight_class
gets pulled straight out of the event name, so something like
“Weightlifting Men’s Featherweight” just becomes “Featherweight”.
wl <- wl %>%
mutate(
bmi = weight / ((height / 100)^2),
medal_binary = if_else(!is.na(medal) & medal %in% c("Gold", "Silver", "Bronze"), 1L, 0L),
medal_binary = factor(medal_binary, levels = c(0, 1), labels = c("No_Medal", "Medal")),
era = factor(if_else(year >= 2000, "Post_2000", "Pre_2000"), levels = c("Pre_2000", "Post_2000")),
weight_class = str_remove(event, "^Weightlifting (Men's|Women's) "),
sex = factor(sex)
) %>%
select(id, name, sex, age, height, weight, bmi, noc, team, year, era,
weight_class, event, medal, medal_binary)
wl_clean <- wl
Here I split the data 80/20 into training and test sets, stratified by medal outcome. That keeps the medal rate consistent across both sets, so I’m not accidentally testing on a skewed sample.
set.seed(42)
train_idx <- createDataPartition(wl_clean$medal_binary, p = 0.8, list = FALSE)
train_data <- wl_clean[train_idx, ]
test_data <- wl_clean[-train_idx, ]
cat("Train rows:", nrow(train_data), " | Test rows:", nrow(test_data), "\n")
## Train rows: 2380 | Test rows: 594
cat("Train medal rate:", round(mean(train_data$medal_binary == "Medal"), 3),
" | Test medal rate:", round(mean(test_data$medal_binary == "Medal"), 3), "\n")
## Train medal rate: 0.179 | Test medal rate: 0.178
Before modeling anything, it’s worth just looking at the plain numbers first.
summary(wl_clean %>% select(age, height, weight, bmi))
## age height weight bmi
## Min. :15.00 Min. :140.0 Min. : 47.00 Min. :14.68
## 1st Qu.:22.00 1st Qu.:160.0 1st Qu.: 60.00 1st Qu.:23.88
## Median :25.00 Median :168.0 Median : 75.00 Median :26.53
## Mean :25.29 Mean :167.8 Mean : 79.61 Mean :27.75
## 3rd Qu.:28.00 3rd Qu.:175.0 3rd Qu.: 90.00 3rd Qu.:30.35
## Max. :43.00 Max. :205.0 Max. :176.50 Max. :53.79
wl_clean %>%
group_by(sex) %>%
summarise(n = n(), medal_rate = round(mean(medal_binary == "Medal"), 3))
## # A tibble: 2 × 3
## sex n medal_rate
## <fct> <int> <dbl>
## 1 F 460 0.228
## 2 M 2514 0.17
wl_clean %>%
filter(medal_binary == "Medal") %>%
count(noc, sort = TRUE) %>%
head(10)
## # A tibble: 10 × 2
## noc n
## <chr> <int>
## 1 CHN 55
## 2 URS 51
## 3 BUL 36
## 4 POL 32
## 5 USA 30
## 6 RUS 26
## 7 HUN 20
## 8 KAZ 16
## 9 IRI 14
## 10 JPN 14
A few charts make these patterns easier to spot than raw numbers ever could.
ggplot(wl_clean, aes(x = age, fill = medal_binary)) +
geom_density(alpha = 0.5) +
labs(title = "Age Distribution by Medal Outcome", x = "Age", y = "Density", fill = "Outcome") +
theme_minimal()
ggplot(wl_clean, aes(x = medal_binary, y = bmi, fill = medal_binary)) +
geom_boxplot(alpha = 0.7) +
labs(title = "BMI by Medal Outcome", x = NULL, y = "BMI", fill = "Outcome") +
theme_minimal()
wl_clean %>%
group_by(year) %>%
summarise(medal_rate = mean(medal_binary == "Medal")) %>%
ggplot(aes(x = year, y = medal_rate)) +
geom_line(color = "steelblue") +
geom_point(color = "steelblue") +
labs(title = "Medal Rate by Olympic Year", x = "Year", y = "Medal Rate") +
theme_minimal()
ggplot(wl_clean, aes(x = height, y = weight, color = medal_binary)) +
geom_point(alpha = 0.5) +
labs(title = "Height vs Weight by Medal Outcome", x = "Height (cm)", y = "Weight (kg)", color = "Outcome") +
theme_minimal()
top_classes <- wl_clean %>% count(weight_class, sort = TRUE) %>% slice_head(n = 15) %>% pull(weight_class)
wl_clean %>%
filter(weight_class %in% top_classes) %>%
count(weight_class, medal_binary) %>%
ggplot(aes(x = reorder(weight_class, n), y = n, fill = medal_binary)) +
geom_col() +
coord_flip() +
labs(title = "Records by Weight Class and Medal Outcome", x = "Weight Class", y = "Count", fill = "Outcome") +
theme_minimal()
One problem before I can fit anything: noc, the country
code, has 143 distinct levels. That’s far too many categories to throw
into a factor variable directly — the models would either choke on it or
overfit badly. So instead, each country gets collapsed into a single
number: its historical weightlifting medal count, a rough stand-in for
“program strength.” It’s not a perfect solution. This is a
backward-looking, aggregate feature, not something you’d actually know
ahead of time for a brand-new athlete with no international track record
— worth flagging now, and I’ll come back to it in the discussion.
full_data <- bind_rows(train_data, test_data)
country_strength <- full_data %>%
group_by(noc) %>%
summarise(country_medal_count = sum(medal_binary == "Medal"), .groups = "drop")
prep_features <- function(df) {
df %>%
left_join(country_strength, by = "noc") %>%
mutate(
sex = factor(sex),
era = factor(era, levels = c("Pre_2000", "Post_2000")),
weight_class = factor(weight_class),
medal_binary = factor(medal_binary, levels = c("No_Medal", "Medal"))
) %>%
select(medal_binary, sex, age, height, weight, bmi, era, weight_class, country_medal_count)
}
train_m <- prep_features(train_data)
test_m <- prep_features(test_data)
all_levels <- union(levels(train_m$weight_class), levels(test_m$weight_class))
train_m$weight_class <- factor(train_m$weight_class, levels = all_levels)
test_m$weight_class <- factor(test_m$weight_class, levels = all_levels)
Let’s start simple, before trying anything fancier.
log_model <- glm(medal_binary ~ sex + age + height + weight + bmi + era +
weight_class + country_medal_count,
data = train_m, family = binomial)
summary(log_model)
##
## Call:
## glm(formula = medal_binary ~ sex + age + height + weight + bmi +
## era + weight_class + country_medal_count, family = binomial,
## data = train_m)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 29.470274 882.786132 0.033 0.9734
## sexM -0.325407 0.269127 -1.209 0.2266
## age -0.012250 0.015158 -0.808 0.4190
## height -0.091966 0.049098 -1.873 0.0611 .
## weight 0.071614 0.043347 1.652 0.0985 .
## bmi -0.188744 0.141176 -1.337 0.1812
## eraPost_2000 -0.044962 0.154364 -0.291 0.7708
## weight_classBantamweight -16.980872 882.743458 -0.019 0.9847
## weight_classFeatherweight -16.569562 882.743419 -0.019 0.9850
## weight_classFlyweight -16.886131 882.743483 -0.019 0.9847
## weight_classHeavyweight -16.113318 882.743429 -0.018 0.9854
## weight_classHeavyweight I -16.287872 882.743469 -0.018 0.9853
## weight_classHeavyweight II -16.545426 882.743496 -0.019 0.9850
## weight_classLight-Heavyweight -16.277454 882.743395 -0.018 0.9853
## weight_classLightweight -16.732942 882.743400 -0.019 0.9849
## weight_classMiddle-Heavyweight -16.386105 882.743408 -0.019 0.9852
## weight_classMiddleweight -16.378919 882.743392 -0.019 0.9852
## weight_classSuper-Heavyweight -16.377964 882.743520 -0.019 0.9852
## weight_classUnlimited, One Hand 0.379180 984.471336 0.000 0.9997
## weight_classUnlimited, Two Hands -13.439263 882.743790 -0.015 0.9879
## country_medal_count 0.068376 0.004015 17.029 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 2236.5 on 2379 degrees of freedom
## Residual deviance: 1840.0 on 2359 degrees of freedom
## AIC: 1882
##
## Number of Fisher Scoring iterations: 13
log_probs <- predict(log_model, newdata = test_m, type = "response")
log_pred <- factor(if_else(log_probs > 0.5, "Medal", "No_Medal"), levels = c("No_Medal", "Medal"))
log_cm <- confusionMatrix(log_pred, test_m$medal_binary, positive = "Medal")
log_cm
## Confusion Matrix and Statistics
##
## Reference
## Prediction No_Medal Medal
## No_Medal 471 80
## Medal 17 26
##
## Accuracy : 0.8367
## 95% CI : (0.8045, 0.8655)
## No Information Rate : 0.8215
## P-Value [Acc > NIR] : 0.1817
##
## Kappa : 0.2742
##
## Mcnemar's Test P-Value : 3.071e-10
##
## Sensitivity : 0.24528
## Specificity : 0.96516
## Pos Pred Value : 0.60465
## Neg Pred Value : 0.85481
## Prevalence : 0.17845
## Detection Rate : 0.04377
## Detection Prevalence : 0.07239
## Balanced Accuracy : 0.60522
##
## 'Positive' Class : Medal
##
log_roc <- roc(response = test_m$medal_binary, predictor = log_probs,
levels = c("No_Medal", "Medal"), direction = "<")
cat("Logistic Regression AUC:", round(auc(log_roc), 3), "\n")
## Logistic Regression AUC: 0.806
Now for something with a bit more flexibility.
set.seed(42)
rf_model <- randomForest(medal_binary ~ sex + age + height + weight + bmi + era +
weight_class + country_medal_count,
data = train_m, ntree = 500, importance = TRUE)
rf_model
##
## Call:
## randomForest(formula = medal_binary ~ sex + age + height + weight + bmi + era + weight_class + country_medal_count, data = train_m, ntree = 500, importance = TRUE)
## Type of random forest: classification
## Number of trees: 500
## No. of variables tried at each split: 2
##
## OOB estimate of error rate: 16.68%
## Confusion matrix:
## No_Medal Medal class.error
## No_Medal 1874 80 0.04094166
## Medal 317 109 0.74413146
rf_probs <- predict(rf_model, newdata = test_m, type = "prob")[, "Medal"]
rf_pred <- predict(rf_model, newdata = test_m, type = "response")
rf_cm <- confusionMatrix(rf_pred, test_m$medal_binary, positive = "Medal")
rf_cm
## Confusion Matrix and Statistics
##
## Reference
## Prediction No_Medal Medal
## No_Medal 468 76
## Medal 20 30
##
## Accuracy : 0.8384
## 95% CI : (0.8063, 0.8671)
## No Information Rate : 0.8215
## P-Value [Acc > NIR] : 0.1542
##
## Kappa : 0.3051
##
## Mcnemar's Test P-Value : 1.984e-08
##
## Sensitivity : 0.28302
## Specificity : 0.95902
## Pos Pred Value : 0.60000
## Neg Pred Value : 0.86029
## Prevalence : 0.17845
## Detection Rate : 0.05051
## Detection Prevalence : 0.08418
## Balanced Accuracy : 0.62102
##
## 'Positive' Class : Medal
##
rf_roc <- roc(response = test_m$medal_binary, predictor = rf_probs,
levels = c("No_Medal", "Medal"), direction = "<")
cat("Random Forest AUC:", round(auc(rf_roc), 3), "\n")
## Random Forest AUC: 0.795
So which variables is the random forest actually leaning on?
importance_df <- as.data.frame(importance(rf_model)) %>%
rownames_to_column("feature") %>%
arrange(desc(MeanDecreaseGini))
importance_df
## feature No_Medal Medal MeanDecreaseAccuracy
## 1 country_medal_count 43.405925 79.456958 75.156968
## 2 bmi 28.958916 -3.527162 30.096129
## 3 age 6.290835 -1.749097 4.605189
## 4 height 25.879523 -1.160072 27.450039
## 5 weight 27.047223 -11.636999 27.949419
## 6 weight_class 35.902754 -18.892882 34.395820
## 7 era 4.369466 5.509961 6.629381
## 8 sex 9.080627 3.203887 11.163930
## MeanDecreaseGini
## 1 187.456757
## 2 103.232336
## 3 89.598863
## 4 81.090303
## 5 74.839551
## 6 54.942566
## 7 11.938028
## 8 6.920498
ggplot(importance_df, aes(x = reorder(feature, MeanDecreaseGini), y = MeanDecreaseGini)) +
geom_col(fill = "steelblue") +
coord_flip() +
labs(title = "Random Forest Feature Importance", x = "Feature", y = "Mean Decrease in Gini") +
theme_minimal()
Side by side, here’s how the two models stack up.
plot(log_roc, col = "darkorange", main = "ROC Curve: Logistic Regression vs Random Forest")
plot(rf_roc, col = "steelblue", add = TRUE)
legend("bottomright",
legend = c(paste0("Logistic (AUC=", round(auc(log_roc), 3), ")"),
paste0("Random Forest (AUC=", round(auc(rf_roc), 3), ")")),
col = c("darkorange", "steelblue"), lwd = 2)
And the numbers, all in one place:
metrics_summary <- tibble(
model = c("Logistic Regression", "Random Forest"),
accuracy = c(log_cm$overall["Accuracy"], rf_cm$overall["Accuracy"]),
sensitivity = c(log_cm$byClass["Sensitivity"], rf_cm$byClass["Sensitivity"]),
specificity = c(log_cm$byClass["Specificity"], rf_cm$byClass["Specificity"]),
auc = c(auc(log_roc), auc(rf_roc))
)
metrics_summary
## # A tibble: 2 × 5
## model accuracy sensitivity specificity auc
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 Logistic Regression 0.837 0.245 0.965 0.806
## 2 Random Forest 0.838 0.283 0.959 0.795
So what did I actually learn? Both models land around an AUC of 0.80, solidly above chance. That tells me athlete profile data, combined with country-level program information, really does carry a signal about who’s likely to medal. This isn’t noise.
The single biggest predictor, by a wide margin, is country program strength — essentially a country’s historical medal count in weightlifting. Athletes coming out of established programs (China, the former Soviet Union, Bulgaria, Poland, USA) are systematically more likely to medal. Makes sense once you think about it: those countries bring decades of coaching infrastructure and a deep competitive pool behind every athlete they send.
After that, BMI, weight class, and weight round out the next tier. Body composition relative to weight class clearly matters — an athlete with a favorable strength-to-mass ratio for their class tends to be more competitive.
One thing worth being upfront about: sensitivity sits around 25–30% for both models. In plain terms, they’re much better at correctly ruling out non-medalists (specificity near 96%) than at confidently picking out who will medal. Given the medal rate is only about 18% of the field, and elite competition is inherently unpredictable, that’s not a shock.
A few limitations deserve a mention too.
country_medal_count looks backward — it summarizes history,
not something you’d know for a brand-new athlete with no international
track record yet. The dataset also only includes athletes who already
qualified for the Olympics; there’s no data on the ones who didn’t make
it. So really, the model tells us “who medals among Olympians,” not “who
becomes an Olympian” in the first place. And records stretching back to
1896 don’t map cleanly onto today’s training, nutrition, or competitive
standards.
Given all that, here’s what I’d tell stakeholders:
Stepping back, this project took a talent-identification angle on Olympic weightlifting. I built and compared two classifiers in RStudio, logistic regression and random forest, to predict medal outcomes from athlete and country-level data. Both landed in the same ballpark, around an AUC of 0.80, with national program strength and body composition doing most of the predictive work. Those results back two concrete recommendations: invest in program infrastructure, and use weight-class-specific scouting criteria.
None of this would have been nearly as rigorous in Tableau alone. RStudio’s modeling tools — glm, randomForest, pROC — gave me a reproducible, interpretable way to actually test predictions rather than just visualize patterns, which fits a prediction-driven objective like this one much better.