A portfolio project that ingests Division-I college football data from the CollegeFootballData (CFBD) API, lands it in DuckDB, transforms it with dbt (medallion → Kimball star, incl. an SCD2 dimension), implements the statistical models from Football Analytics with Python & R (Eager & Erickson) in both R and Python, and renders a Quarto dashboard that evaluates the data and the models' accuracy.
📊 View the live interactive dashboard →
🏈 Explore the standalone GRIDIRONIQ pages: Compare any two teams · 2026 Forecast Scoreboard · Stat guide · The models (how they work + how well they perform).
👉 New to this / non-technical? Read the plain-English Guide — it explains what this is, how it works, and how to read every chart, with no jargon assumed.
Stack: R (cfbfastR, tidyverse, tidymodels, lme4, gt, cfbplotR) ·
Python (uv, dbt-duckdb, statsmodels, scikit-learn) · DuckDB · dbt · Quarto.
Orchestrated by a Python CLI that runs the R models as subprocess steps — the data
(DuckDB tables + a metrics JSON) is the contract between languages, not in-memory objects.
Status: pipeline live end-to-end for 2023–25 FBS, with a live 2026-season forecast. Medallion + Kimball star build green (SCD2
dim_teamcapturing the 2024 realignment); the book models M1–M8 and the game win-probability model run in R and Python with a committed R↔Python parity gate (coefficients agree to tolerance; PCA/cluster/mixed-effects agree label-invariantly); the Quarto dashboard plus three standalone GRIDIRONIQ pages (team comparison, stat guide, models explainer) are live on Pages. SeePROJECT_PLAN.mdfor the full design and phased plan.
Explore the full interactive version →
The game win-probability model, trained on prior seasons and evaluated on the sealed 2025 holdout, approaches the betting market using only on-field efficiency — Brier 0.191, AUC 0.77, 70% accuracy vs a home-field-naive baseline (0.243) and the market line (0.176). The models page stress-tests this honestly: it doesn't beat the closing line, and shows exactly why.
Multilevel shrinkage (M7) then applies the book's regression-to-the-mean lesson — raw rushing efficiency is mostly noise (ICC ≈ 1%), so small-sample rushers are pooled toward the league mean:
| # | Method (book ch.) | CFB application |
|---|---|---|
| M1 | EDA + metric stability (Ch 2) | Which efficiency stats are skill vs noise |
| M2/M3 | Simple → multiple linear regression (Ch 3–4) | Rushing Yards Over Expected (RYOE) |
| M4 | Logistic GLM + odds ratios (Ch 5) | Completion % Over Expected (CPOE) |
| M5 | Poisson regression (Ch 6) | Passing-TD counts → betting-prop framing |
| M6 | PCA + clustering (Ch 8) | Team/player archetypes |
| M7 | Multilevel / mixed-effects (Ch 9) | Shrinkage / regression-to-the-mean |
| M8 | Web scraping (Ch 7) | Recruiting rank vs on-field production — do teams beat their recruiting? |
Each method is implemented in R and Python, with an R↔Python parity check surfaced on the dashboard. A game-level win-probability model consumes these features and is benchmarked against the CFBD betting line; a companion preseason priors model forecasts games from prior-season carryover alone (so a new season's schedule can be scored before any current-year form exists — honestly less certain, and the dashboard quantifies the gap).
- Source: CollegeFootballData.com API (v2). Attribution shown in this README and on the dashboard: Data: CollegeFootballData.com.
- Auth: a free API key is required (register at
collegefootballdata.com/key), sent as an
Authorization: Bearer <key>header. The key is read from theCFBD_API_KEYenvironment variable (.env/.Renviron) and is never committed. - Rate limits: free tier = 1,000 API calls/month; Cloudflare blocks bursty parallel requests. Ingestion is therefore bounded, throttled, and cached — a season already in bronze is never re-pulled.
- Redistribution: raw data (play-by-play, the DuckDB warehouse) is gitignored and never published. Only aggregate results (leaderboards, model metrics, charts) and derived code are published — including the live dashboard — always with attribution. This is derived, non-commercial work, not a re-hosting of CFBD's raw dataset.
- Verdict: GO-WITH-CONDITIONS — non-commercial portfolio use, attributed, bounded
collection. The warehouse/model pipeline is on-demand (no perpetual poller); the one
scheduled job is the in-season scoreboard refresh (
.github/workflows/score.yml): one API call per run, three runs a day, Aug–Dec (~90 calls/month against the 1,000/month free-tier quota — CFBD's terms explicitly encourage scheduled polling). Terms re-verified 2026-08-31.
Requires the runtime toolchain (R,
uv, dbt, Quarto, DuckDB) — seePROJECT_PLAN.mdPhase 0.
# 1. Python env
uv sync
# 2. R env
Rscript -e 'renv::restore()'
# 3. set your key (do NOT commit)
cp .env.example .env # then edit CFBD_API_KEY
# 4. run the pipeline end-to-end
uv run python run.py --season 2024The pipeline also runs as individual stages (each a subprocess; a non-zero exit aborts the run):
uv run python run.py ingest # CFBD -> data/bronze/*.csv (quota-aware, cached; needs a key)
uv run python run.py land # bronze CSVs -> DuckDB bronze.*
uv run python run.py build # dbt: staging -> SCD2 snapshot -> silver -> gold (+ tests)
uv run python run.py export # gold/silver -> data/gold/*.csv model feeds
uv run python run.py models # book models M1-M8 + game model, in R
uv run python run.py parity # Python parity fits + market-edge eval + load_results (parity GATE)
uv run python run.py dashboard # prepare feeds, render Quarto -> docs/, refresh preview PNGs
uv run python run.py compare # build the standalone GRIDIRONIQ pages (compare/glossary/models)
uv run python run.py forecast 2026 # score an upcoming season's schedule with the priors model
uv run python run.py forecast 2026 --freeze v2-week5 # ...and seal it as a new registry version
uv run python run.py inseason --freeze v2-inseason # seal the in-season update model + first snapshot
uv run python run.py score # snapshot if new results settled, score every version -> docs/forecast.htmlThe preseason model's full 2026 forecast (740 FBS-vs-FBS game probabilities, 136 team win
projections) was generated on 2026-07-01 and is frozen verbatim under
predictions/2026/v1-preseason/ with a SHA-256 manifest —
the commit history proves the predictions predate the games. As models improve during the season,
each new version is frozen beside it (never over it) and scored forward-only from its freeze
date (the before-kickoff rule: no version is graded on a game it could have known the result
of). The Forecast Scoreboard tracks
every version against actual results and the naive baselines all season; at the end of the year
the original preseason projections stand against the final standings, untouched. See
predictions/README.md for the registry contract.
The updated model lives beside the original. predictions/2026/v2-inseason/ seals an
in-season update model — a Bayesian ridge on scoring margins whose prior mean is each team's
preseason strength (the priors model's own linear predictor, converted to points). The prior is
worth k games of evidence (k = 2, chosen on 2024); every settled FBS-vs-FBS game then pulls a
team toward its opponent-adjusted margins, and a two-parameter logistic map turns the predicted
margin into a win probability. Held out on 2025 it scores Brier 0.174 vs 0.205 for the
preseason-only model on the same 740 games (AUC 0.81). Because it needs only the sealed
coefficients and the season's scores — no play-by-play, no warehouse — the scoreboard refresh
applies it in CI: whenever new results have settled it writes an immutable, timestamped
snapshot of the re-forecast under v2-inseason/snapshots/, and the page scores every
snapshot forward-only plus the live series (for each game, the latest snapshot that predates
its kickoff — what a reader following the season actually saw). Built in R and Python like every
other model here; the ridge weight, margin scale and home-field estimate sit in the parity gate.
Nothing is scored or sealed unchecked. The frozen schedule is only the registry's key space;
hosts, kickoffs and even game ids change after a freeze (CFBD re-designated the home team of the
week-1 Notre Dame–Wisconsin game at Lambeau Field, which briefly scored that game backwards).
Every refresh now verifies the registry's manifest hashes, reconciles CFBD's live listing against
the frozen games by team (orientation, re-keyed ids, current kickoff), validates the results
against hard invariants (winner-by-name, independently derived team records, no result before
kickoff, no ties, sane scores) and checks each snapshot before writing it. A failed check
quarantines the pull: the page shows a banner and the failing check, nothing is scored or
sealed, and the workflow fails so it is noticed. See the gate table in
predictions/README.md.
The point of this repo is craft, not just a result. The decisions that shaped it:
The polyglot contract: files, not a bridge. R and Python never share memory — they exchange
data only through the DuckDB warehouse and flat CSVs (R writes data/bronze and data/results;
Python owns all DuckDB/Parquet I/O and writes data/gold feeds). This was a deliberate rejection
of rpy2/reticulate: an in-process bridge is fragile in CI and couples the languages' lifecycles.
The payoff is that killing any stage fails the pipeline cleanly, and the same discipline shaped the
dashboard — Python (pre-render) reads DuckDB and hands R/knitr flat files, so even the report
honors the contract rather than smuggling a bridge back in.
Every method is built twice, and that's a test. M1–M7 and the game model are each implemented
in R and Python on the identical feed. Because the fits are mathematically the same (OLS, IRLS
GLM, Poisson, logistic MLE), their coefficients must agree — so load_results fails the build
if any term diverges beyond tolerance (currently 23/23 agree; unsupervised/mixed-effects methods
are checked label-invariantly — PC correlation, cluster ARI, BLUP correlation). This gate earned its
keep: it immediately flagged a perfect-collinearity bug in the game model (net_epa_diff = off_epa_diff − def_epa_diff), where R's glm silently dropped a term while sklearn split the
coefficient arbitrarily. Same predictions, divergent coefficients — invisible without the parity check.
Data-quality war stories. Real feeds fight back:
- No usable play key.
cfbfastRrounds its 18-digitid_playto a float upstream, collapsing 531k plays onto 266k distinct values. Fix: type itBIGINTon load (stops further loss) and mint a deterministic surrogateplay_key = hash(game_id, game_play_number, intra-seq)after removing exact-duplicate rows. - An FBS warehouse over an all-divisions feed. Games/plays arrive for every division, so 77% of
team-game rows had no matching FBS team. Fix: a Kimball "Non-FBS" placeholder dimension member
COALESCE, and the star is scoped to FBS-touching games.
- Silent non-determinism. The rolling-form windows ordered by
weekalone, so a team with two games in one week produced order-dependent "entering" features. Fix: a(week, game_id)tiebreaker; the feed is now byte-identical across rebuilds.
SCD2 that captures real history. dim_team is a dbt snapshot replayed one season at a time
(the orchestrator resets and loops seasons), so the 2024 conference realignment — Texas/Oklahoma to
the SEC, the Pac-12's collapse — is versioned history with season-range validity, not a static lookup.
Honest modeling over flattering numbers. The bar for the game model is the betting market, not accuracy: on the sealed 2024 holdout it reaches Brier 0.195 / AUC 0.76 / 70% using only on-field efficiency — crushing a home-field-naive baseline (0.243) and approaching the market (0.183) without beating it, which is the honest expected result. M1 found rushing efficiency is noisy; M7's mixed-effects ICC of ~1.2% quantifies exactly that (almost all RYOE variance is play-to-play noise), which is why the shrinkage is so aggressive. And I chose not to rebuild a from-scratch expected-points model: it would duplicate the warehouse's already-calibrated EPA, and next-score labels reconstructed from the available start-of-play scores validate at only ~91% vs the final scores — shipping a worse model to pad the method list would be the wrong call.
Scraping with a conscience (M8). The recruiting model needed data no API cleanly gave us, so it scrapes — but only after a source pre-flight: the major recruiting sites were rejected (their ToS forbid automated extraction and their pages carry individual, often-minor recruits' data), and Wikipedia was chosen instead (CC BY-SA, robots.txt welcomes low-speed bots). The scraper takes only the team-level rankings table, identifies itself with a descriptive User-Agent, rate-limits, and caches so a page is never re-fetched — and the scraped numbers were validated to match CFBD's sanctioned API exactly. The finding is fun and face-valid: Michigan's 2023 national title team and Florida State's 2024 collapse are the standout over- and under-achievers relative to the talent they signed.
What it demonstrates: end-to-end ELT (API + ethical scrape → medallion → Kimball star), analytics-engineering rigor (grain declarations, SCD2, freshness/volume tests, idempotency), a real ML workflow (leakage- safe time-aware validation, sealed holdout, beat-a-baseline, calibration), and genuine polyglot range — R, Python, and SQL each doing what they're best at, cross-checked against each other.
Done: ingestion (API + ethical scrape) → medallion → Kimball star + SCD2 → book models M1–M8
(R + Python) → in-season + preseason win-probability models → live 2026-season forecast (the
priors model applied to CFBD's 2026 schedule) → Quarto dashboard live on GitHub Pages → frozen
prediction registry + self-updating 2026 scoreboard (in-season GitHub Actions refresh three times a day)
→ in-season update model (preseason prior + season-to-date margins, re-forecast and sealed as a
snapshot after every game day).
Deferred but pre-structured: full CI (lint + dbt build) and a BigQuery push of the gold
tables. See PROJECT_PLAN.md for the full plan.


