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.

In [1]:
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()).

In [2]:
df = pd.read_csv("powerlifting.csv")
print("shape (rows, columns):", df.shape)
df.head()
shape (rows, columns): (1000, 8)
Out[2]:
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
In [3]:
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
In [4]:
df.describe()
Out[4]:
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.

In [5]:
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.

In [6]:
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.

In [7]:
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.

In [8]:
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¶

In [9]:
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()
No description has been provided for this image

Bodyweight versus total¶

Each point is a lifter. The upward slope confirms that heavier lifters tend to total more.

In [10]:
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()
No description has been provided for this image

Total by sex and by equipment¶

In [11]:
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()
No description has been provided for this image

Strength across age¶

Average total per five-year age band.

In [12]:
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()
No description has been provided for this image

Correlation heatmap¶

Correlation, from -1 to 1, measures how strongly two numeric columns move together.

In [13]:
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()
No description has been provided for this image

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.

In [14]:
df["total_per_bw"] = df["TotalKg"] / df["BodyweightKg"]
df[["Sex","BodyweightKg","TotalKg","total_per_bw"]].head()
Out[14]:
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.

In [15]:
model_df = pd.get_dummies(
    df[["BodyweightKg","Age","Sex","Equipment","TotalKg"]],
    columns=["Sex","Equipment"], drop_first=True)
model_df.head()
Out[15]:
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.

In [16]:
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).

In [17]:
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.

In [18]:
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.

In [19]:
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()
No description has been provided for this image

Feature importance¶

In [20]:
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))
No description has been provided for this image
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.