March Madness Final Update

Results: Not bad! The model ended up in the 99th percentile among all submitted brackets, with an overall rank of 39,059 across all global brackets. It successfully predicted that Michigan would be the ultimate winner and correctly predicted three of the final four (Michigan, Illinois, and Arizona).

Overall, the model made 43 correct picks, 11 incorrect picks, and was unable to pick (neither option was available) 4 times, meaning its accuracy was around 74-79%, depending on how you want to skin the cat statistically.

Future improvements

Overall, I weighted all the teams at the beginning of the tournament, and those with the highest scores continued. It was a non-learning static model. I’m really confident in the algorithm, but without continuous inputs (who won, what game), it lost accuracy as the tournament continued. In the same vein, when a model upset occurred (i.e., TCU versus OSU), the model would be unable to predict future matches because the “tournament score” was set at the beginning of the bracket. You would need to rerun the program for each eventuality into a separate bracket, to predict it in the beginning (the purpose of a bracket).

Possible, yes, but it’s beyond my coding level.

Now, let’s talk about how the model worked…

The Model Explained

First, all the necessary imports. For the fancy stuff, I used ScienceKitLearn for regression and machine learning.

Python
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.neural_network import MLPRegressor
from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error

Next, I loaded in the dataset from this Kaggle directory. Then I took a peek to see what success metric I possibly could have.

Python
df = pd.read_csv('cbb.csv')
df.columns

It came back with the following…

Index(['TEAM', 'CONF', 'G', 'W', 'ADJOE', 'ADJDE', 'BARTHAG', 'EFG_O', 'EFG_D',
'TOR', 'TORD', 'ORB', 'DRB', 'FTR', 'FTRD', '2P_O', '2P_D', '3P_O',
'3P_D', 'ADJ_T', 'WAB', 'POSTSEASON', 'SEED', 'YEAR'],
dtype='object')

Choosing a Success Metric

NGL, I played around with some of them and found the “POSTSEASON” column to be the most handy. Essentially, it was a text field representing the highest ranking each team achieved in the individual season, so I had to add some classification to assign a numerical point structure so I could run analytics on it.

Python
##Adding a numerical classification to the POSTSEASON column to make it easier to analyze and visualize the data. Honestly, ape brain. Big number = good. Small number = bad.
df["Post_Season_Score"] = np.select(
[df["POSTSEASON"] == "Champions", df["POSTSEASON"] == "2ND", df["POSTSEASON"] == "F4",
df["POSTSEASON"] == "E8", df["POSTSEASON"] == "S16", df["POSTSEASON"] == "R32",
df["POSTSEASON"] == "R64"],
[7, 6, 5, 4, 3, 2, 1],
default=0
)

I like positive regressions because my monkey brain likes big numbers, so I assigned a champion ranking to 7, then worked backward to 1. The problem, then would be a negative correlation between seed (ranked 1-17) and the results.

For example, a Post_Season_Score of 7 would be great, but a seed of 1 would then be bad – showing a negative correlation. So I had to create a separate column (Seed Power) that inverted it.

Python
##Changing Seed to Reflect "Power"
df["Seed_Power"] = 17 - df["SEED"]
df.columns

Now I had a success metric, and all the inputs were numerical, which makes neural networks and machine learning easier. I could have theoretically thrown all the inputs into a model and said “go,” but… gross. It would lead to oversampling.

Cleaning and Selecting Model Inputs

I renamed my columns to make them human-readable, because I don’t know sports.

Python
## Renaming columns for Clarity. Imma need a cheat sheat. Seriously, I thought Field Goal was football, not basketball.
df.rename(columns={"G": "Games Played",
"W": "Wins",
"ADJOE": "Adjusted Offensive Efficiency",
"ADJDE": "Adjusted Defensive Efficiency",
"BARTHAG": "Bartag Score",
"EFG_O": "Effective Field Goal Percentage",
"EFG_D": "Effective Field Goal Percentage Defense",
"TOR": "Turnover Rate",
"TORD": "Turnover Rate Defense",
"ORB": "Offensive Rebound Rate",
"DRB": "Defensive Rebound Rate",
"FTR": "Free Throw Rate",
"FTRD": "Free Throw Rate Defense",
"2P_O": "2 Point Percentage",
"2P_D": "2 Point Percentage Defense",
"3P_O": "3 Point Percentage",
"3P_D": "3 Point Percentage Defense",
"ADJ_T": "Adjusted Tempo",
"WAB": "Wins Above Bubble",
}, inplace=True)

And then I ran a simple correlation test…

Python
inputs = df[["Adjusted Offensive Efficiency", "Adjusted Defensive Efficiency", "Bartag Score",
"Effective Field Goal Percentage", "Effective Field Goal Percentage Defense",
"Turnover Rate", "Turnover Rate Defense", "Offensive Rebound Rate", "Defensive Rebound Rate",
"Free Throw Rate", "Free Throw Rate Defense",
"2 Point Percentage", "2 Point Percentage Defense", "3 Point Percentage", "3 Point Percentage Defense",
"Adjusted Tempo", "Wins Above Bubble", "Games Played", "Wins", "Seed_Power","Post_Season_Score"]].corr()["Post_Season_Score"].sort_values(ascending=False)
inputs
Post_Season_Score 1.000000
Wins Above Bubble 0.604975
Seed_Power 0.573911
Wins 0.563527
Adjusted Offensive Efficiency 0.544232
Bartag Score 0.533577
Games Played 0.371823
Effective Field Goal Percentage 0.330576
2 Point Percentage 0.303341
3 Point Percentage 0.242643
Offensive Rebound Rate 0.234867
Turnover Rate Defense 0.069009
Free Throw Rate 0.049975
Adjusted Tempo -0.035756
Defensive Rebound Rate -0.138606
Free Throw Rate Defense -0.179942
3 Point Percentage Defense -0.253593
Turnover Rate -0.261825
2 Point Percentage Defense -0.323406
Effective Field Goal Percentage Defense -0.357856
Adjusted Defensive Efficiency -0.479776
Name: Post_Season_Score, dtype: float64
Python
## Keep anything that is correlated above 0.3 or below -0.3. Why? Because ape brain like simple.
reduced_df = df[[
"Post_Season_Score",
"Wins Above Bubble",
"Seed_Power",
"Wins",
"Adjusted Offensive Efficiency",
"Bartag Score",
"Games Played",
"Effective Field Goal Percentage",
"2 Point Percentage",
"2 Point Percentage Defense",
"Effective Field Goal Percentage Defense",
"Adjusted Defensive Efficiency"
]]
reduced_df.corr()["Post_Season_Score"].sort_values(ascending=False)

I would have ran a significance test here too, but as I was about to throw them into a neural network, so I decided that it would be handled “in post”. I wanted to let the neural network determine the significance.

Creating the Model

Then I used Sci-Kit to throw it into a neural network

Python
# Keep rows with all required features present. Modeling time! Downloading more ram...
print("Starting data cleaning and modeling process...")
reduced_df_clean = reduced_df.dropna(subset=["Seed_Power"])
X = reduced_df_clean.drop("Post_Season_Score", axis=1)
y = reduced_df_clean["Post_Season_Score"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
model = MLPRegressor(hidden_layer_sizes=(100, 50), max_iter=2000, random_state=42)
model.fit(X_train_scaled, y_train)
y_pred = model.predict(X_test_scaled)
print(f"Mean Squared Error: {mean_squared_error(y_test, y_pred)}")
print(f"R^2 Score: {r2_score(y_test, y_pred)}")
print(f"Mean Absolute Error: {mean_absolute_error(y_test, y_pred)}")
test_results = pd.DataFrame({
"Actual": y_test.values,
"Predicted": y_pred,
"Difference": y_test.values - y_pred
})
print(test_results)
print(f"\nTest Data Shape: {test_results.shape}")

And I came out with an R-Squared of .467, meaning my model can explain around 46% of the variance in their “Post Season Score”. Now, I can use the same scaler and model for future NCAA seasons, assuming I can locate the same inputs, and have a generally serviceable prediction of NCAA brackets.

Applying the Model

Now, I will say that actually applying the model was difficult. The same CBB dataset I used for the modeling wasn’t available with updated stats. So instead, I had to find a similar dataset, convert the variables from multiple sources, and then copy and paste them into an input.

Then I loaded it into the model and ran.

data_input = pd.read_csv('2026_Input.csv')
# Keep team names for display, but only pass training features to scaler/model.
team_col_candidates = ["TEAM", "Team", "team", "SCHOOL", "School", "school"]
team_col = next((c for c in team_col_candidates if c in data_input.columns), None)
if team_col is None:
object_cols = data_input.select_dtypes(include=["object", "string"]).columns.tolist()
team_col = object_cols[0] if object_cols else None
if team_col:
team_names = data_input[team_col].astype(str)
else:
team_names = pd.Series(data_input.index, name="TEAM")
data_input_features = data_input[X.columns]
data_input_scaled = scaler.transform(data_input_features)
predicted_scores = model.predict(data_input_scaled)
predictions_2026 = pd.DataFrame({
"TEAM": team_names.values,
"Predicted_Post_Season_Score": predicted_scores
})
print("\nPredicted Post Season Scores for 2026 Input:")
print(predictions_2026.sort_values("Predicted_Post_Season_Score", ascending=False).reset_index(drop=True))

Finally, that resulted in the following predicted postseason score. Then, for the bracket, I compared the two teams’ postseason scores, ranked them, and was right nearly three-quarters of the time.

Python
Predicted Post Season Scores for 2026 Input:
TEAM Predicted_Post_Season_Score
0 Michigan 6.342807
1 Illinois 5.681282
2 Duke 5.594652
3 Arizona 5.514538
4 Connecticut 4.919858
.. ... ...
64 Baylor 0.357718
65 Tennessee St. 0.199902
66 Texas A&M 0.161865
67 Howard 0.110842
68 Lehigh -0.231930

Personally, fun surface-level dive into sports. I could have 100% have done things differently, but not bad for what I started with.

Stay Curious!

Sources

College Basketball Dataset. n.d. Retrieved April 7, 2026. https://www.kaggle.com/datasets/andrewsundberg/college-basketball-dataset.

scikit-learn: machine learning in Python — scikit-learn 1.8.0 documentation. n.d. Retrieved April 7, 2026. https://scikit-learn.org/stable/.

Team Shooting Stats – Customizable College Basketball Tempo Free Stats – T-Rank. n.d. Retrieved April 7, 2026. https://barttorvik.com/teampbp.php?&conlimit=&sort=1.


Discover more from The Curiosity Project

Subscribe to get the latest posts sent to your email.

Comments

Leave a Reply

Discover more from The Curiosity Project

Subscribe now to keep reading and get access to the full archive.

Continue reading