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 stringHomeTeamAfterBye/AwayTeamAfterBye— both sides now tracked; Part 1 only retained the home bye flag after the joinHomeScore/AwayScore— final scores kept as separate columns instead of buried in other fieldsRecordWinPct,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 gameTotal— over/under totalAfterBye— whether the home team was coming off a byeDome— 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 seasonTeam/Opp— home and away teamsDivisionalGame— whether the matchup is within the divisionHomeTeamAfterBye/AwayTeamAfterBye— bye-week flags for each sideDayOfWeek— day the game is played (0 = Sunday)Spread— point spread from the home team’s perspectiveOU— over/under totalDome— indoor stadium flagisGrass— 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.