NFL data pipeline combining PFF grades and PFR game data for over/under analysis
Project description
sports-quant
An end-to-end data pipeline that scrapes PFF team grades and Pro Football Reference game/betting data, builds analysis-ready datasets, and trains an ensemble XGBoost model for NFL over/under prediction.
Table of Contents
- Features
- Installation
- Prerequisites
- Configuration
- Usage
- Pipeline Architecture
- Modeling
- Example Charts
- Project Structure
- Development
- Contributing
- Known Limitations
- License
Features
- PFF Scraping — Selenium-based scraper for PFF team grades (requires PFF Premium; manual login on first run, cookies cached for subsequent runs)
- PFR Scraping — Proxy-rotated scraper for Pro Football Reference boxscores
- Data Normalization — Standardizes dates and team names across sources
- Dataset Merging — Inner join on date + team columns
- Rolling Averages — Pre-game cumulative stat averages per team per season
- Games Played Tracking — Cumulative games played before each matchup
- Feature Rankings — Per-date rankings across all teams
- Ensemble Training — Trains 50 XGBoost models per game-day, selects top 3 by weighted seasonal accuracy, requires consensus agreement, and runs a financial simulation
- Walk-Forward Backtesting — Trains 50 models across every historical date using walk-forward validation and averages metrics across all models
- CLI + Python API — Run the full pipeline or any individual step
Installation
Install from PyPI:
pip install sports-quant
Or install from source with Poetry:
git clone https://github.com/thadhutch/sports-quant.git
cd sports-quant
poetry install
Prerequisites
| Requirement | Why |
|---|---|
| Python 3.12+ | Runtime |
| Google Chrome | PFF scraper uses Selenium to render client-side data |
| PFF Premium subscription | Authenticates access to PFF team grades |
| Rotating proxies (CSV) | PFR rate-limits aggressively; proxies prevent blocks |
Configuration
Create a .env file (see .env.example) or export environment variables directly:
cp .env.example .env
| Variable | Default | Description |
|---|---|---|
NFL_SEASONS |
2025 |
Comma-separated seasons to scrape from PFF |
NFL_START_YEAR |
2025 |
First year for PFR boxscore URL collection |
NFL_END_YEAR |
2025 |
Last year for PFR boxscore URL collection |
NFL_MAX_WEEK |
18 |
Final week to scrape in the last season |
NFL_DATA_DIR |
data |
Base directory for all output files |
NFL_PROXY_FILE |
proxies/proxies.csv |
Path to proxy list (address:port:user:password per line) |
NFL_MODEL_CONFIG |
model_config.yaml |
Path to model configuration file |
Model hyperparameters and training settings are configured in model_config.yaml:
ou:
model_version: "v1" # Version tag for output directories
models_to_train: 50 # Number of models per ensemble
top_models: 3 # Models kept after selection
accuracy_threshold: 0.50 # Minimum validation accuracy
model_weights: [0.4, 0.35, 0.25] # Consensus weighting
starting_capital: 100.0 # Simulation starting capital ($)
hyperparameters:
objective: "multi:softprob"
num_class: 3
eval_metric: "mlogloss"
backtest:
min_training_seasons: 2
test_size: 0.8
train:
test_size: 0.2
Usage
CLI
The sports-quant command is available after installation.
Note: The first time you run
sports-quant scrape pff, a Chrome window will open for you to log in to PFF manually. After login, cookies are saved locally and reused for future runs.
# Full end-to-end pipeline
sports-quant pipeline
# Scrape from a single source
sports-quant scrape pff # PFF grades (scrape + date parsing + name normalization)
sports-quant scrape pfr # PFR game data (URLs + scrape + date/name normalization)
# Run all post-processing steps
sports-quant process all
# Run individual processing steps
sports-quant process merge
sports-quant process over-under
sports-quant process averages
sports-quant process games-played
sports-quant process rankings
# Modeling
sports-quant model train # Train ensemble O/U prediction model
sports-quant model backtest # Run walk-forward backtesting
# Check installed version
sports-quant --version
Python API
Every pipeline step is importable:
import sports_quant
# Scraping
sports_quant.scrape_pff_data()
sports_quant.collect_boxscore_urls()
sports_quant.scrape_all_game_info()
# Processing
sports_quant.merge_datasets()
sports_quant.process_over_under()
sports_quant.compute_rolling_averages()
sports_quant.add_games_played()
sports_quant.compute_rankings()
# Modeling
sports_quant.run_training()
sports_quant.run_backtest()
Or run an entire pipeline at once:
from sports_quant.pipeline import (
run_full_pipeline,
run_pff_pipeline,
run_pfr_pipeline,
run_processing_pipeline,
)
run_full_pipeline() # end-to-end
run_pff_pipeline() # PFF scraping chain only
run_pfr_pipeline() # PFR scraping chain only
run_processing_pipeline() # post-processing only
Make Targets
A Makefile is included for common development workflows:
make all # Full pipeline end-to-end
make pff # PFF scraping + processing chain
make pfr # PFR scraping + processing chain
make merge # Merge PFF and PFR data (runs both chains first)
make rankings # Full postprocessing through rankings
make model-train # Train ensemble O/U prediction model
make model-backtest # Run walk-forward backtesting
make test # Run the test suite
make clean # Remove all generated data files
make dirs # Create data directory structure
Pipeline Architecture
PFF Scrape PFR Scrape
| |
v v
Extract Dates Normalize Dates
| |
v v
Normalize Names Normalize Names
| |
+----------+ +--------+
| |
v v
Merge
|
v
Over/Under
|
v
Rolling Averages
|
v
Games Played
|
v
Rankings
|
+--------+--------+
| |
v v
Model Train Backtest
(ensemble) (walk-forward)
Output files are written to NFL_DATA_DIR (default: data/):
| Stage | Output |
|---|---|
| PFF scrape | data/pff/raw_team_data.csv |
| PFF normalized | data/pff/normalized_team_data.csv |
| PFR URLs | data/pfr/boxscores_urls.txt |
| PFR normalized | data/pfr/final_pfr_odds.csv |
| Merged | data/pff_and_pfr_data.csv |
| Final dataset | data/over-under/v1-dataset-gp-ranked.csv |
| Training output | data/models/{version}/algorithm/ |
| Backtest output | data/backtest/{version}/ |
Modeling
How It Works
The core idea is simple: don't try to predict every game — find the games where the model is reliably right, and only bet those.
On each game-day the pipeline trains 50 XGBoost models with different random seeds on all available historical data. Most of those models are mediocre. The pipeline filters to the top 3 based on a weighted seasonal accuracy score (heavier weight on the current season, lighter on last season), then requires all three to agree on a pick before it counts. Each consensus pick gets an algorithm score — a weighted blend of each model's per-bin confidence accuracy — that captures how well the ensemble has historically performed at that confidence level.
$$S = \sum_{i=1}^{N} w_i \cdot A_i$$
$$A_i = \alpha \cdot \text{acc}{i,,c,,b} ;+; (1 - \alpha) \cdot \text{acc}{i,,c-1,,b}$$
$$\alpha = \text{clamp}!\bigg(\frac{d_{\text{elapsed}}}{d_{\text{total}}},; 0,; 1\bigg)$$
where
- $N$ = number of top models kept after selection (hyperparameter)
- $w_i$ = ensemble weight for model $i$ (configured in
model_config.yaml) - $A_i$ = adjusted score for model $i$
- $\text{acc}_{i,s,b}$ = model $i$'s historical accuracy in confidence bin $b$ for season $s$
- $b$ = confidence bin of the prediction (binned from $\max(\hat{p})$)
- $c$ = current season
- $d_{\text{elapsed}}$ = days since Sep 1, $d_{\text{total}}$ = days from Sep 1 to Jan 15
The chart at the top of this README tells the story: across 629 total picks, accuracy rises as the algorithm score increases, crossing above coin-flip around the 50% bin and climbing into the 60–70%+ range at higher bins. Most picks cluster in the middle of the distribution (the grey bars), but the profitable edge lives in the tails.
The heatmap below shows that this isn't a one-season fluke. Higher algorithm-score bins tend to stay green (accurate) across multiple seasons, while lower bins stay red:
| Parameter | Value |
|---|---|
| Models trained per game-day | 50 |
| Models kept after selection | Top 3 by weighted seasonal accuracy |
| Consensus requirement | All 3 must agree |
| Algorithm score | Weighted blend of per-model confidence-bin accuracy (0.4 / 0.35 / 0.25) |
| Bet sizing | 1% Kelly criterion |
| Starting simulation capital | $100 |
Ensemble Training (sports-quant model train)
Outputs to data/models/{version}/algorithm/:
combined_picks.csv— all consensus picks with algorithm scores- Accuracy-by-confidence and accuracy-by-algorithm-score charts
cumulative_profit.png— profit over time (units + dollars)performance_statistics.txt— best/worst day/week/month/year
Walk-Forward Backtesting (sports-quant model backtest)
Evaluates model generalization using strict temporal separation: for each test date, train on all prior data, predict the current date, repeat across 50 random seeds, and average the metrics.
Outputs to data/backtest/{version}/:
average_season_accuracy.csv+ chartaverage_confidence_accuracy.csv+ chartaverage_confidence_accuracy_by_season.csv+ chart
Example Charts
Project Structure
sports-quant/
├── src/sports_quant/
│ ├── __init__.py # Public API re-exports
│ ├── _config.py # Paths, env vars, logging
│ ├── cli.py # Click CLI entry point
│ ├── pipeline.py # Pipeline orchestrators
│ ├── teams.py # Team name/abbreviation mappings
│ ├── scrapers/
│ │ ├── pff.py # PFF grades scraper (Selenium)
│ │ ├── pfr.py # PFR game data scraper
│ │ ├── pfr_urls.py # PFR boxscore URL collector
│ │ ├── auth.py # PFF authentication
│ │ └── proxies.py # Proxy loading utilities
│ ├── parsers/
│ │ ├── pff_dates.py # PFF date extraction
│ │ ├── pff_teams.py # PFF team name normalization
│ │ ├── pfr_dates.py # PFR date normalization
│ │ └── pfr_teams.py # PFR team name extraction
│ ├── processing/
│ │ ├── merge.py # Merge PFF + PFR datasets
│ │ ├── over_under.py # O/U betting line extraction
│ │ ├── rolling_averages.py
│ │ ├── games_played.py
│ │ └── rankings.py
│ └── modeling/
│ ├── __init__.py # Public API (run_training, run_backtest)
│ ├── _features.py # Shared feature definitions
│ ├── _data.py # Data loading and preparation
│ ├── _training.py # Single-model and ensemble training
│ ├── _scoring.py # Season weighting, consensus, model selection
│ ├── _simulation.py # Financial simulation / profit tracking
│ ├── train.py # Ensemble training orchestrator
│ ├── backtest.py # Walk-forward backtesting orchestrator
│ └── plots.py # All modeling-related charts
├── tests/
├── model_config.yaml
├── pyproject.toml
├── Makefile
├── LICENSE
└── README.md
Development
# Clone and install with dev dependencies
git clone https://github.com/thadhutch/sports-quant.git
cd sports-quant
poetry install
# Run the test suite
poetry run pytest -v
# Run a specific test file
poetry run pytest tests/test_rolling_averages.py -v
CI runs automatically on every push to master and on pull requests via GitHub Actions. Releases are published to PyPI through Trusted Publishers.
Contributing
Contributions are welcome! To get started:
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Make your changes and add tests where appropriate
- Run the test suite (
poetry run pytest -v) - Commit your changes (
git commit -m "Add my feature") - Push to your fork (
git push origin feature/my-feature) - Open a Pull Request
Please make sure all existing tests pass before submitting a PR.
Known Limitations
- PFF scraping is DOM-dependent. The scraper relies on XPath selectors tied to PFF's frontend. If PFF changes their page structure, the selectors in
scrapers/pff.pywill need updating. - PFR scraping requires rotating proxies. Without them, requests will be rate-limited and blocked.
- The PFF scraper is slow by design. It drives a real Chrome browser via Selenium because PFF renders data client-side.
- PFF login requires manual interaction on first run. A Chrome window will open for you to log in. Cookies are cached afterward, so subsequent runs are fully automated.
- Data files are not tracked in git. Run the pipeline to generate them, or bring your own data in the expected CSV format.
License
This project is licensed under the MIT License.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sports_quant-2.0.0.tar.gz.
File metadata
- Download URL: sports_quant-2.0.0.tar.gz
- Upload date:
- Size: 59.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ba12642dd568f1149673b253b3350b7d13de8ff5f73e6609c018bc1aafc71e17
|
|
| MD5 |
ced03e8e978a885f49a5f033610deaaa
|
|
| BLAKE2b-256 |
9d54d65e3950fc9dde82bd4f8683d00c0ef72a4a95e275aca3fc0c672a9aa9a2
|
Provenance
The following attestation bundles were made for sports_quant-2.0.0.tar.gz:
Publisher:
publish.yml on thadhutch/sports-quant
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sports_quant-2.0.0.tar.gz -
Subject digest:
ba12642dd568f1149673b253b3350b7d13de8ff5f73e6609c018bc1aafc71e17 - Sigstore transparency entry: 939768102
- Sigstore integration time:
-
Permalink:
thadhutch/sports-quant@f5c6b7695b268950b15843e6890c601c2c5961c5 -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/thadhutch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f5c6b7695b268950b15843e6890c601c2c5961c5 -
Trigger Event:
release
-
Statement type:
File details
Details for the file sports_quant-2.0.0-py3-none-any.whl.
File metadata
- Download URL: sports_quant-2.0.0-py3-none-any.whl
- Upload date:
- Size: 89.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8169be67f6cb85a356b9c9d6d358fc7e1d4235af7b8078a6b04bf982ef77f8c4
|
|
| MD5 |
053127d1ce4b6f17de12d710a5c02844
|
|
| BLAKE2b-256 |
ebb20f35cf1f1c2188ed5f68dc8fc9b1483491ba6dbd8e0f2abdd3f32e2fdcc5
|
Provenance
The following attestation bundles were made for sports_quant-2.0.0-py3-none-any.whl:
Publisher:
publish.yml on thadhutch/sports-quant
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sports_quant-2.0.0-py3-none-any.whl -
Subject digest:
8169be67f6cb85a356b9c9d6d358fc7e1d4235af7b8078a6b04bf982ef77f8c4 - Sigstore transparency entry: 939768111
- Sigstore integration time:
-
Permalink:
thadhutch/sports-quant@f5c6b7695b268950b15843e6890c601c2c5961c5 -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/thadhutch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f5c6b7695b268950b15843e6890c601c2c5961c5 -
Trigger Event:
release
-
Statement type: