Predicting Powerlifting Performance¶
A data science case study · Carlos Abel Vivanco / AbleVLabs¶
This project takes real competition data and runs the complete machine-learning pipeline: load, explore, clean, visualize, engineer features, model, evaluate, and interpret. The objective is to predict a lifter's competition total from bodyweight, age, sex, and equipment, and to identify what actually drives strength.
The data is real IPF competition results from OpenPowerlifting (public domain), obtained via the TidyTuesday project. This notebook works with a 1,000-lift sample; the full IPF subset holds 41,152 records.
The tools¶
The analysis uses the standard Python data-science stack: pandas for tabular data, NumPy for numerical work, Matplotlib and Seaborn for visualization, and scikit-learn for modeling.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid")
plt.rcParams["figure.figsize"] = (8, 4.5)
pd.set_option("display.max_columns", None)
1. Loading the data and a first look¶
Any analysis begins by inspecting the data before changing it. The CSV is loaded into a DataFrame, and
four standard questions are asked of it: how large it is (.shape), what the rows look like
(.head()), the type of each column and where values are missing (.info()), and the rough scale of
the numbers (.describe()).
df = pd.read_csv("powerlifting.csv")
print("shape (rows, columns):", df.shape)
df.head()
shape (rows, columns): (1000, 8)
| Sex | Age | BodyweightKg | Best3SquatKg | Best3BenchKg | Best3DeadliftKg | TotalKg | Equipment | |
|---|---|---|---|---|---|---|---|---|
| 0 | M | 30.0 | 59.60 | 200.0 | 142.5 | 207.5 | 550.0 | Wraps |
| 1 | M | 15.5 | 134.00 | 130.0 | 60.0 | 170.0 | 360.0 | Raw |
| 2 | F | 21.5 | 48.00 | NaN | 72.5 | NaN | NaN | Single-ply |
| 3 | M | 40.5 | 59.40 | 177.5 | 130.0 | 185.0 | 492.5 | Single-ply |
| 4 | M | 72.5 | 92.22 | NaN | 150.0 | NaN | NaN | Single-ply |
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Sex 1000 non-null object 1 Age 942 non-null float64 2 BodyweightKg 998 non-null float64 3 Best3SquatKg 660 non-null float64 4 Best3BenchKg 945 non-null float64 5 Best3DeadliftKg 650 non-null float64 6 TotalKg 633 non-null float64 7 Equipment 1000 non-null object dtypes: float64(6), object(2) memory usage: 62.6+ KB
df.describe()
| Age | BodyweightKg | Best3SquatKg | Best3BenchKg | Best3DeadliftKg | TotalKg | |
|---|---|---|---|---|---|---|
| count | 942.000000 | 998.000000 | 660.000000 | 945.000000 | 650.000000 | 633.000000 |
| mean | 34.982484 | 81.477896 | 218.085606 | 143.486243 | 223.163846 | 577.784360 |
| std | 14.900947 | 25.648374 | 72.056128 | 59.555929 | 61.703211 | 179.821815 |
| min | 14.000000 | 41.900000 | 55.000000 | 25.000000 | 87.500000 | 177.500000 |
| 25% | 22.500000 | 61.745000 | 160.000000 | 97.500000 | 172.500000 | 430.000000 |
| 50% | 31.500000 | 75.000000 | 220.000000 | 137.500000 | 225.000000 | 582.500000 |
| 75% | 45.500000 | 97.275000 | 265.000000 | 182.500000 | 270.000000 | 705.000000 |
| max | 85.500000 | 214.000000 | 445.000000 | 400.000000 | 375.000000 | 1125.000000 |
The output reveals issues typical of real-world data. Age and several of the lift columns have
fewer non-null entries than the row count, indicating missing values (many entries are single-lift
performances with no full total). BodyweightKg can carry the odd extreme value, and Sex and
Equipment are text categories rather than numbers. Each is handled below.
for col in ["Sex", "Equipment"]:
print(col, "->", df[col].unique())
Sex -> ['M' 'F'] Equipment -> ['Wraps' 'Raw' 'Single-ply']
2. Cleaning the data¶
Each cleaning decision is deliberate and documented, since every row dropped or altered is a modeling choice.
Standardizing the category¶
Sex is forced to uppercase so that any inconsistent casing collapses into a single category.
df["Sex"] = df["Sex"].str.upper()
print(df["Sex"].value_counts())
Sex M 687 F 313 Name: count, dtype: int64
Removing impossible values¶
A human competitor does not weigh 9 kg or 1900 kg, so only physically plausible bodyweights are kept.
before = len(df)
df = df[(df["BodyweightKg"] >= 35) & (df["BodyweightKg"] <= 230)]
print(f"removed {before - len(df)} implausible-bodyweight rows")
removed 2 implausible-bodyweight rows
Handling missing values¶
Two situations are treated differently. Because TotalKg is the prediction target, rows missing it are
dropped: a model cannot learn from a missing answer. Age is a feature, so rather than discard those
rows it is imputed with the median, preserving the remaining information in each row. The median is
preferred over the mean because it is robust to outliers.
df = df.dropna(subset=["TotalKg"])
median_age = df["Age"].median()
df["Age"] = df["Age"].fillna(median_age)
print("median age used for imputation:", median_age)
print("rows remaining for analysis:", len(df))
median age used for imputation: 28.0 rows remaining for analysis: 632
3. Exploratory data analysis¶
The clean data is explored visually to understand each variable and the relationships between them.
Distribution of the target¶
plt.hist(df["TotalKg"], bins=30, color="#6a2c91", edgecolor="white")
plt.xlabel("Total (kg)"); plt.ylabel("number of lifters")
plt.title("Distribution of powerlifting totals"); plt.show()
Bodyweight versus total¶
Each point is a lifter. The upward slope confirms that heavier lifters tend to total more.
sns.scatterplot(data=df.sample(min(len(df), 3000), random_state=1),
x="BodyweightKg", y="TotalKg", hue="Sex", alpha=0.5, s=18)
plt.title("Bodyweight vs Total"); plt.show()
Total by sex and by equipment¶
fig, ax = plt.subplots(1, 2, figsize=(11, 4.5))
sns.boxplot(data=df, x="Sex", y="TotalKg", ax=ax[0]); ax[0].set_title("Total by sex")
sns.boxplot(data=df, x="Equipment", y="TotalKg",
order=["Raw","Wraps","Single-ply"], ax=ax[1]); ax[1].set_title("Total by equipment")
plt.tight_layout(); plt.show()
Strength across age¶
Average total per five-year age band.
age_bins = pd.cut(df["Age"], bins=range(10, 85, 5))
df.groupby(age_bins, observed=True)["TotalKg"].mean().plot(marker="o")
plt.ylabel("mean total (kg)"); plt.xlabel("age group")
plt.title("Average total by age"); plt.xticks(rotation=45)
plt.tight_layout(); plt.show()
Correlation heatmap¶
Correlation, from -1 to 1, measures how strongly two numeric columns move together.
num = df[["Age","BodyweightKg","Best3SquatKg","Best3BenchKg","Best3DeadliftKg","TotalKg"]]
sns.heatmap(num.corr(), annot=True, fmt=".2f", cmap="rocket_r")
plt.title("Correlation between numeric variables"); plt.show()
The three lifts correlate strongly with the total, as expected since the total is their sum, and bodyweight correlates moderately with all of them.
4. Feature engineering¶
Stronger model inputs are constructed from the raw columns.
A ratio feature¶
A strength-to-bodyweight ratio captures pound-for-pound strength, which the raw total does not.
df["total_per_bw"] = df["TotalKg"] / df["BodyweightKg"]
df[["Sex","BodyweightKg","TotalKg","total_per_bw"]].head()
| Sex | BodyweightKg | TotalKg | total_per_bw | |
|---|---|---|---|---|
| 0 | M | 59.6 | 550.0 | 9.228188 |
| 1 | M | 134.0 | 360.0 | 2.686567 |
| 3 | M | 59.4 | 492.5 | 8.291246 |
| 5 | M | 90.3 | 410.0 | 4.540421 |
| 6 | M | 92.7 | 680.0 | 7.335491 |
One-hot encoding¶
Models operate on numbers, so text categories are converted to 0/1 columns. drop_first=True removes
one redundant category per variable.
model_df = pd.get_dummies(
df[["BodyweightKg","Age","Sex","Equipment","TotalKg"]],
columns=["Sex","Equipment"], drop_first=True)
model_df.head()
| BodyweightKg | Age | TotalKg | Sex_M | Equipment_Single-ply | Equipment_Wraps | |
|---|---|---|---|---|---|---|
| 0 | 59.6 | 30.0 | 550.0 | True | False | True |
| 1 | 134.0 | 15.5 | 360.0 | True | False | False |
| 3 | 59.4 | 40.5 | 492.5 | True | True | False |
| 5 | 90.3 | 57.5 | 410.0 | True | True | False |
| 6 | 92.7 | 39.5 | 680.0 | True | False | False |
5. Building a predictive model¶
The task is framed as regression: predict TotalKg from bodyweight, age, sex, and equipment. The data
is split so the model trains on 80% and is graded on the 20% it has never seen.
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, r2_score
X = model_df.drop(columns=["TotalKg"]); y = model_df["TotalKg"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1)
print("training on", X_train.shape[0], "lifters, testing on", X_test.shape[0])
training on 505 lifters, testing on 127
Baseline: Linear Regression¶
Judged on MAE, the average error in kilograms (lower is better), and R², the fraction of variation the model explains (1.0 is perfect).
lin = LinearRegression().fit(X_train, y_train)
pred_lin = lin.predict(X_test)
print(f"Linear Regression MAE: {mean_absolute_error(y_test, pred_lin):.1f} kg")
print(f"Linear Regression R^2: {r2_score(y_test, pred_lin):.3f}")
Linear Regression MAE: 72.4 kg Linear Regression R^2: 0.712
Random Forest¶
A random forest averages many decision trees and can capture non-linear effects. On a large dataset it often outperforms a linear model; on this smaller, real sample the two turn out to be close, as the scores show. That is itself a useful result: a more complex model does not automatically win on real, noisy data.
rf = RandomForestRegressor(n_estimators=300, max_depth=12, random_state=1, n_jobs=-1)
rf.fit(X_train, y_train)
pred_rf = rf.predict(X_test)
print(f"Random Forest MAE: {mean_absolute_error(y_test, pred_rf):.1f} kg")
print(f"Random Forest R^2: {r2_score(y_test, pred_rf):.3f}")
Random Forest MAE: 75.4 kg Random Forest R^2: 0.690
6. Evaluation and interpretation¶
Predicted versus actual¶
Points near the diagonal are accurate predictions.
plt.scatter(y_test, pred_rf, alpha=0.4, s=14, color="#6a2c91")
lims = [y_test.min(), y_test.max()]
plt.plot(lims, lims, "r--", label="perfect prediction")
plt.xlabel("actual total (kg)"); plt.ylabel("predicted total (kg)")
plt.title("Predicted vs actual"); plt.legend(); plt.show()
Feature importance¶
importances = pd.Series(rf.feature_importances_, index=X.columns).sort_values()
importances.plot(kind="barh", color="#6a2c91")
plt.title("What the model uses to predict total"); plt.xlabel("importance")
plt.tight_layout(); plt.show()
print(importances.sort_values(ascending=False))
BodyweightKg 0.416033 Sex_M 0.374527 Age 0.186795 Equipment_Single-ply 0.022188 Equipment_Wraps 0.000457 dtype: float64
Bodyweight and sex dominate the prediction, with equipment and age contributing less. This matches domain knowledge: how much a competitor totals is driven mostly by their size and sex.
Summary¶
The project ran a complete pipeline on real competition data: loading and inspection; cleaning across dirty categories, impossible outliers, and missing values; exploratory analysis; feature engineering; modeling with two algorithms under a proper train/test split; and evaluation with MAE, R², and feature importance. The models predicted a lifter's total to within roughly 70 kg on unseen data and explained about 70% of the variance, with the simple linear model matching the random forest and bodyweight and sex the dominant drivers.
Data: real IPF competition results from OpenPowerlifting (public domain), via the TidyTuesday project. This notebook uses a representative sample; the full IPF subset holds 41,152 records.