A win-probability model for every fourth down in the NFL, and what it says coaches are still getting wrong. Plus the volume-versus-efficiency dashboard the project started as.
🌐 Live: https://jarvislee511.github.io/nfl-offensive-analytics/ — report and interactive decision calculator · dashboard
Python · pandas · scikit-learn · Plotly · Power BI · decision analysis · win probability
Che-Wei Lee — M.S. in Data Analytics Engineering, Northeastern University
Fourth-down aggression rose 61% between 2015 and 2024 — and then stopped. Almost none of that change reached a team's own half. On fourth-and-1 teams go for it 84% of the time inside the opponent's 40 and 39% from their own 21-40. Same one yard, opposite behaviour.
Graded against a win-probability model, the model agrees with the coach on 65% of 15,545 fourth downs. The disagreements are worth 22 wins a season across the league (0.69 per team), and the single largest pool is fourth-and-1-or-2 from a team's own 21-40: 682 decisions where coaches go 24% of the time and the model says 88%, worth 2.4 wins a season on its own.
Two things keep that honest:
- 16% of all decisions are disagreements worth less than one point of win probability — a coin flip, not a mistake. Only 6% are worth more than three.
- The field-goal model is known to under-rate long attempts, which biases the engine toward going for it. Re-grading with every field goal handed eight extra points of make probability — far more than the measured error — still leaves 36% of the kicks coaches took called as go. The conclusion is not an artefact of that bias.
Four models, all fitted here rather than imported. nflverse ships its own win-probability and expected-points columns; they are excluded from the fitting on purpose.
| Model | Form | Test season |
|---|---|---|
Conversion P(convert) |
spline logistic on yards-to-go, field position, down | log loss 0.6138, AUC 0.702 |
Field goal P(make) |
spline logistic on kick distance, recency-weighted, indoor flag | log loss 0.3776 |
| Punt | kernel-weighted empirical distribution over the opponent's field position | MAE 7.3 yd, CRPS 5.24 |
| Win probability | monotone gradient boosting, 13 features | log loss 0.4688 vs 0.6915 baseline, AUC 0.845 |
Fitted on 2015–2024, graded on 2021–2024, with a strict season split — nothing is fitted on the season it is scored against.
Third downs are in the conversion sample on purpose. Coaches choose which fourth downs to go for, so fitting on those alone risks learning conversion rate given a coach liked the look. The raw gap looks like it confirms the worry — fourth-down attempts convert +12.2 points more often than third-and-the-same-distance — but controlled for field position that effect is +0.31 points, p = 0.65. The gap was composition. Coach selection is undetectable in the conversion rate, which makes pooling third downs free: ten times the sample at no cost.
Win probability, not expected points. Expected points is the right unit for most of a game and the wrong one exactly where fourth downs are decided: down seven with two minutes left, expected points says kick.
pip install -r requirements-dev.txt
python -m src.data # fetch ten seasons of play-by-play, build the derived tables
python -m src.models.conversion
python -m src.models.kicking
python -m src.models.winprob
python -m src.grade # score all 15,545 fourth downs
python -m src.surface # precompute the calculator's lookup
python -m src.report # build docs/index.html
pytest -q # 34 testsRaw play-by-play (~190 MB) is not committed; the derived tables (3.8 MB) are, so the models can be refitted without the download.
There are two implementations of the decision engine: a readable one in src/decision.py that
handles a single state and is covered by tests pinning every sign convention, and a vectorised one
in src/grade.py that makes 15,545 decisions tractable. tests/test_grade.py runs real fourth
downs through both and requires them to agree — a rewrite is exactly where a sign convention
quietly stops matching.
Open it live → Six linked views with season and position filters. The page is built by python dashboard_interactive.py, which writes docs/dashboard.html — open that file directly for an offline copy.
A portfolio-grade redesign of the original course dashboard — explicit DAX measures, a custom navy/red theme, KPI cards, Top-N leaders, efficiency scatters, and season/position slicers. Template: NFL_Offensive_Dashboard.pbit (build steps in PowerBI_Redesign_Spec.md).
2024 leaders
| Category | Leader | Team | Value |
|---|---|---|---|
| Passing yards | Joe Burrow | CIN | 4,918 |
| Rushing yards | Saquon Barkley | PHI | 2,005 |
| Receiving yards | Ja'Marr Chase | CIN | 1,708 |
| Passing EPA (≥200 att) | Lamar Jackson | BAL | 174.2 |
| Receiving EPA (≥50 tgt) | Amon-Ra St. Brown | DET | 96.4 |
- Volume vs. efficiency diverge. The raw-yardage leaders are not the EPA leaders — Lamar Jackson led passing EPA by rewarding down-and-distance value over total yards.
- Elite dual-threat backs. Saquon Barkley reached a 2,000-yard rushing season (2,005), one of only a handful in NFL history.
- Target share drives receiving production, but efficiency metrics (EPA, catch %) separate truly valuable receivers from high-volume ones.
- The league is pass-first. League-wide passing yards run ~2× rushing yards and stay stable across 2021–2024.
Full notes: FINDINGS.md.
nfl-offensive-analytics/
├── README.md ← this file
├── NFL_Offensive_Analytics.ipynb ← main analysis notebook (clean → aggregate → metrics)
├── dashboard.py ← builds the static multi-chart HTML dashboard
├── dashboard_interactive.py ← builds the interactive Plotly dashboard
├── src/ ← the fourth-down models, engine and report build
├── docs/index.html ← the fourth-down report + calculator
├── docs/dashboard.html ← the volume-vs-efficiency dashboard
├── NFL_Offensive_Dashboard.pbit ← Power BI template
├── PowerBI_Theme.json ← custom Power BI theme
├── PowerBI_Redesign_Spec.md ← step-by-step Power BI build spec
├── FINDINGS.md ← key findings & interview talking points
├── data/ ← nflverse data (weekly + season-player aggregates)
└── images/ ← dashboard screenshots
| File | Description |
|---|---|
player_stats_2021.csv … player_stats_2024.csv |
Per-season weekly player box scores (nflverse) |
player_stats_2021_2024.csv |
All four seasons of weekly records combined (~22.6K rows) |
season_player_offense.csv |
Season-player aggregates with engineered metrics (2,345 rows) — the main analysis table |
Requirements: Python 3.x with pandas and plotly.
pip install pandas plotly
python dashboard_interactive.py # regenerates docs/dashboard.htmlThe scripts read season_player_offense.csv / player_stats_2021_2024.csv from data/ (resolved relative to the repo, so a fresh clone runs as-is). The Power BI dashboard is built from data/season_player_offense.csv following PowerBI_Redesign_Spec.md.
- Source: nflverse public play-by-play / player-stats releases.
- Aggregation: weekly box scores rolled up to season totals, keeping additive advanced metrics (EPA) correct and recomputing rate stats (catch %, completion %, YPC, YPA) from totals.
- Metric pairing: every volume visual is deliberately paired with an efficiency visual so the dashboard answers two distinct questions — who produced the most and who was most efficient.
- Limitations: regular season only; offensive skill positions (QB/RB/WR/TE); EPA leaders are filtered by minimum attempts/targets to avoid small-sample noise.
| Stage | Tools |
|---|---|
| Data | nflverse |
| Processing | Python, pandas |
| Visualization | Plotly (interactive HTML), Power BI |

