Skip to content

Repository files navigation

cfb-analytics — Polyglot College-Football Analytics Pipeline

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_team capturing 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. See PROJECT_PLAN.md for the full design and phased plan.

Dashboard preview

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.

Brier score by week — model vs market Win-probability calibration (2024 holdout)

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:

Multilevel shrinkage of rushing RYOE

Modeling (grounded in the book)

# 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).

Data source & terms (§9 pre-flight)

  • 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 the CFBD_API_KEY environment 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.

Quickstart

Requires the runtime toolchain (R, uv, dbt, Quarto, DuckDB) — see PROJECT_PLAN.md Phase 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 2024

The 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.html

Predictions on the record — the 2026 forecast scoreboard

The 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.

Case study — engineering & modeling decisions

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. cfbfastR rounds its 18-digit id_play to a float upstream, collapsing 531k plays onto 266k distinct values. Fix: type it BIGINT on load (stops further loss) and mint a deterministic surrogate play_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 week alone, 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.

Roadmap

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.

About

Polyglot college-football analytics pipeline: CFBD API -> DuckDB -> dbt (medallion + Kimball star + SCD2) -> statistical models in R AND Python (with an R<->Python parity gate) -> Quarto dashboard.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages