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.
import pandas as pdimport matplotlib.pyplot as pltimport numpy as npfrom sklearn.model_selection import train_test_splitfrom sklearn.preprocessing import StandardScalerfrom sklearn.neural_network import MLPRegressorfrom 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.
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.
##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.

##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.
## 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…
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.000000Wins Above Bubble 0.604975Seed_Power 0.573911Wins 0.563527Adjusted Offensive Efficiency 0.544232Bartag Score 0.533577Games Played 0.371823Effective Field Goal Percentage 0.3305762 Point Percentage 0.3033413 Point Percentage 0.242643Offensive Rebound Rate 0.234867Turnover Rate Defense 0.069009Free Throw Rate 0.049975Adjusted Tempo -0.035756Defensive Rebound Rate -0.138606Free Throw Rate Defense -0.1799423 Point Percentage Defense -0.253593Turnover Rate -0.2618252 Point Percentage Defense -0.323406Effective Field Goal Percentage Defense -0.357856Adjusted Defensive Efficiency -0.479776Name: Post_Season_Score, dtype: float64
## 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
# 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 Noneif 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.
Predicted Post Season Scores for 2026 Input: TEAM Predicted_Post_Season_Score0 Michigan 6.3428071 Illinois 5.6812822 Duke 5.5946523 Arizona 5.5145384 Connecticut 4.919858.. ... ...64 Baylor 0.35771865 Tennessee St. 0.19990266 Texas A&M 0.16186567 Howard 0.11084268 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.

Leave a Reply