NFL Prediction Model - Part 2

Table of Contents

Just in time for the 2026 NFL kickoff, I decided to expand on my NFL over/under prediction model from last year. Part 1 used a single K-Nearest Neighbors classifier. It worked as a starting point, but one model and a small feature set left a lot on the table.

This follow-up expands that work in two directions: cleaner, richer training data, and a head-to-head comparison of several models on the same under-the-total task. Alongside the original KNN approach, I trained Random Forest and XGBoost — both strong defaults for tabular data. Google’s TabFM is on the shortlist for a follow-up once I get it running cleanly against this pipeline.

The goal is the same as before: predict whether a game goes under the total, using only information available before kickoff. What changed is the data pipeline and the breadth of models in the mix.

Updated Data

The core sources are the same as Part 1: team and game stats, weather, and stadium metadata. This round focused on fixing join bugs and adding features that were missing or incomplete before.

A few problems showed up once I dug into the merges. Team names did not always line up across sources (renames, abbreviations, alternate spellings), so some fields joined incorrectly or went missing. Away-team attributes were especially fragile — when games were combined into a single row, away-side data sometimes got dropped entirely. Cleaning that up meant standardizing names and rebuilding the join so both home and away fields survive.

New and fixed fields for training:

  • isGrass — binary flag derived from the stadium surface string
  • HomeTeamAfterBye / AwayTeamAfterBye — both sides now tracked; Part 1 only retained the home bye flag after the join
  • HomeScore / AwayScore — final scores kept as separate columns instead of buried in other fields
  • RecordWinPct, AwayRecordWinPct, RecordWinPctDiff, CombinedWinPct — win percentages derived from the existing record strings

The dataset now has 64 columns across 4,561 games. I do not train on all of them — many are labels, identifiers, or post-game stats — but here is the full schema for reference:

Data columns (total 64 columns):
 #   Column                    Non-Null Count  Dtype  
---  ------                    --------------  -----  
 0   Season                    4561 non-null   int64  
 1   Team                      4561 non-null   str    
 2   Opp                       4561 non-null   str    
 3   DivisionalGame            4561 non-null   int64  
 4   Week                      4561 non-null   str    
 5   HomeTeamAfterBye          4561 non-null   int64  
 6   AwayTeamAfterBye          4561 non-null   int64  
 7   Playoff                   4561 non-null   int64  
 8   Day                       4561 non-null   str    
 9   DayOfWeek                 4561 non-null   int64  
 10  Date                      4561 non-null   str    
 11  Month                     4561 non-null   int64  
 12  Time                      4561 non-null   str    
 13  Win                       4561 non-null   int64  
 14  OT                        4561 non-null   int64  
 15  Record                    4561 non-null   str    
 16  AwayRecord                4561 non-null   str    
 17  HomeScore                 4561 non-null   int64  
 18  AwayScore                 4561 non-null   int64  
 19  Home1stD                  4561 non-null   int64  
 20  HomeTotYd                 4561 non-null   int64  
 21  HomePassY                 4561 non-null   int64  
 22  HomeRushY                 4561 non-null   int64  
 23  HomeTO                    4561 non-null   int64  
 24  Away1stD                  4561 non-null   int64  
 25  AwayTotYd                 4561 non-null   int64  
 26  AwayPassY                 4561 non-null   int64  
 27  AwayRushY                 4561 non-null   int64  
 28  AwayTO                    4561 non-null   int64  
 29  Home_ExpPoints            4561 non-null   float64
 30  Away_ExpPoints            4561 non-null   float64
 31  G#                        4561 non-null   int64  
 32  Spread                    4561 non-null   float64
 33  OU                        4561 non-null   float64
 34  SpreadWin                 4561 non-null   int64  
 35  OUWin                     4561 non-null   str    
 36  Stadium                   4561 non-null   str    
 37  City                      4561 non-null   str    
 38  State                     4561 non-null   str    
 39  International             4561 non-null   int64  
 40  Grass                     4561 non-null   str    
 41  Dome                      4561 non-null   int64  
 42  Capacity                  4561 non-null   int64  
 43  Address                   4561 non-null   str    
 44  Zipcode                   4561 non-null   int64  
 45  kickoffWeatherOverview    4561 non-null   str    
 46  kickoffWeatherTemp        4561 non-null   int64  
 47  kickoffWeatherAirSpeed    4561 non-null   int64  
 48  kickoffWeatherAirGust     4561 non-null   int64  
 49  kickoffWeatherAirDir      4561 non-null   str    
 50  kickoffWeatherPrec        4561 non-null   int64  
 51  kickoffWeatherCloudCover  4561 non-null   int64  
 52  kickoffWeatherHumidity    4561 non-null   int64  
 53  kickoffWeatherDewPoint    4561 non-null   int64  
 54  kickoffWeatherVisability  4561 non-null   int64  
 55  Total_Points              4561 non-null   int64  
 56  Over                      4561 non-null   int64  
 57  Under                     4561 non-null   int64  
 58  Push                      4561 non-null   int64  
 59  isGrass                   4561 non-null   int64  
 60  RecordWinPct              4561 non-null   float64
 61  AwayRecordWinPct          4561 non-null   float64
 62  RecordWinPctDiff          4561 non-null   float64
 63  CombinedWinPct            4561 non-null   float64

Models

Part 1 stuck with a single K-Nearest Neighbors classifier (n_neighbors=7). This time I wanted a fair comparison across a few common approaches for tabular data:

  • K-Nearest Neighbors — same baseline as Part 1
  • Random Forest — strong default for mixed numeric/categorical features
  • XGBoost — gradient boosting, often competitive on structured data

Each model trains on the same features and the same train/test split so differences in accuracy are easier to attribute to the model itself rather than the data setup. TabFM would be the natural next model to test against this same setup.

Feature Selection

Part 1’s final feature set was small:

  • Spread — point spread for the game
  • Total — over/under total
  • AfterBye — whether the home team was coming off a bye
  • Dome — whether the game was played indoors

For this round I kept to pregame fields only — no scores, yards, or other post-game stats that would leak the outcome. After comparing feature importance from Random Forest and XGBoost, I settled on the shared set below for all three models:

  • Week — week of the season
  • Team / Opp — home and away teams
  • DivisionalGame — whether the matchup is within the division
  • HomeTeamAfterBye / AwayTeamAfterBye — bye-week flags for each side
  • DayOfWeek — day the game is played (0 = Sunday)
  • Spread — point spread from the home team’s perspective
  • OU — over/under total
  • Dome — indoor stadium flag
  • isGrass — grass vs. turf surface

Weather and record-based win percentages are in the dataset and remain candidates for later experiments, but they were not used in this comparison.

Training

All three models were trained on the shared feature set, using a temporal split so the test set only contains games after the training window. Playoff games were removed so training and evaluation use regular-season matchups only (about 4,430 of the 4,561 rows above).

  • Train: regular season games from seasons before 2024
  • Test: regular season games from 2024 and later
  • Target: Under (1 = under, 0 = over or push)

Prediction Accuracy

Results on the held-out test set (regular season 2024+, 544 games):

Model Accuracy ROC AUC Under precision Under recall
KNN 49.5% 0.498 0.45 0.47
Random Forest 50.9% 0.502 0.47 0.45
XGBoost 52.0% 0.497 0.48 0.51

XGBoost edges the others on raw accuracy, but none of the models clear a meaningful bar. ROC AUC sits right at ~0.50 for all three — basically no ability to rank unders above overs. Under pick precision is also under 50%, so when the models take the under they are wrong more often than not.

For context, the test set has 250 unders and 294 non-unders. A naive “always over” baseline would land around 54% accuracy without learning anything about totals. These results are in line with how hard closing NFL totals are to beat with pregame tabular features alone.

2026 Week 1 Predictions

Totals and spreads from DraftKings Sportsbook as of Tuesday night, September 8. Spread is from the home team’s perspective (negative = home favorite).

Game Spread Total KNN RF XGB
Patriots @ Seahawks -3 44.5 Over Under Under
49ers @ Rams -3.5 48.5 Over Over Over
Buccaneers @ Bengals -3.5 50.5 Over Over Over
Saints @ Lions -7 49.5 Under Over Over
Jets @ Titans -1.5 39.5 Over Over Over
Ravens @ Colts +3.5 47.5 Over Under Under
Falcons @ Steelers -3.5 42.5 Under Under Under
Bears @ Panthers +3 46.5 Under Under Under
Browns @ Jaguars -8.5 40.5 Over Over Over
Bills @ Texans +1.5 44.5 Over Under Under
Dolphins @ Raiders -3.5 40.5 Under Over Over
Packers @ Vikings -1.5 46.5 Under Over Over
Commanders @ Eagles -5.5 44.5 Over Under Under
Cardinals @ Chargers -9.5 47.5 Over Under Under
Cowboys @ Giants +3 48.5 Under Under Under
Broncos @ Chiefs -2.5 43.5 Over Under Under

There were 7 games where all three models predicted the same result.

Game Spread Total Prediction
49ers @ Rams -3.5 48.5 Over
Buccaneers @ Bengals -3.5 50.5 Over
Jets @ Titans -1.5 39.5 Over
Falcons @ Steelers -3.5 42.5 Under
Bears @ Panthers +3 46.5 Under
Browns @ Jaguars -8.5 40.5 Over
Cowboys @ Giants +3 48.5 Under

Conclusion

This round was less about crowning a winner and more about expanding the toolbox. Moving past a single KNN model into Random Forest and XGBoost was useful for seeing how different approaches behave on the same NFL totals data — even when none of them clearly beat the market yet.

Next up I want to get TabFM running against this same pipeline and compare it head-to-head with the tree models. Alongside that, there’s still plenty to tune: hyperparameters, decision thresholds, and which pregame features stay in the mix. Small adjustments there may matter as much as swapping architectures.

References