Skip to content

Methodology

Possession-based ratings, calibrated game distributions, and a market-anchored in-game win probability model

Project Overview
Possession-based power ratings, calibrated score distributions, and a market-anchored in-game win probability model

This model rates every Division 1 college football team on a single scale: expected scoring margin against an average FBS opponent on a neutral field. From those ratings it projects a spread and a total for every game, with a full probability distribution around each number, and an in-game layer turns any game state into a home win probability on every play.

One thing should be said up front. The pregame model does not beat the closing spread. Across 3,853 backtested games from 2021 through 2025, the model's average margin error is 13.40 points and the closing line's is 12.14. The market wins every season. Those are backtested numbers on seasons that already happened, provisional in the way all sports backtests are; the backtest section says more. The result is not buried in a footnote because it drives the most consequential design decision in the system: once a game kicks off, the in-game model anchors on the closing spread rather than the model's own number, and gets measurably better for it.

What the model is for, then, is coverage and calibration. It rates all 266 D1 teams including FCS programs the market barely prices, every projection ships with a distribution whose 50/80/95% intervals have been verified against three untouched holdout seasons, rating uncertainty widens explicitly when inputs are missing, and the entire prediction history is frozen and graded in public on the Performance page.

Prediction target

Home margin and game total, joint distribution

Rating unit

Points per possession, offense and defense per team

Rating engine

Bayesian linear model, posterior in closed form

Training data

2019–2025 play-by-play; holdout 2023–2025

Home field

Fitted each season, currently 3.22 points

In-game model

3-parameter Gaussian on the final margin

Kickoff anchor

Market closing spread, sd 15.45 points

Market output

+EV flags for benchmarking; no picks, no sizing

System Architecture
From raw play-by-play to a live win probability, in four layers

The system splits into a rating engine that learns team strength from possession-level data, a projection layer that turns ratings into calibrated game distributions, and an in-game layer that consumes a pregame anchor plus a play feed and nothing else. Each layer only sees what it would have known at the time: the walk-forward backtest, the preseason snapshot, and the in-game serving path all enforce that boundary mechanically.

Inputs

  • ESPN play-by-play

    SportsDataverse releases, 2019+

  • CFBD API

    Games, lines, talent, portal, returning

  • The Odds API

    Priced spreads and totals

  • Previous season fit

    Seeds the preseason prior

Rating Engine

  • Possession construction

    Scrimmage runs, garbage-time aware

  • Ridge over off/def PPP

    Two numbers per team + fitted HFA

  • EPA process blend

    Scoreboard and process, GLS-combined

  • Preseason prior

    Talent, portal, returning production

Projections

  • Margin and total

    Bivariate Student-t per game

  • Calibrated intervals

    50/80/95% checked on holdout

  • Market comparison

    Cover probability and EV per offer

  • Supabase publish

    The only interface to this site

In-Game

  • Play-boundary states

    Strictly pre-snap information

  • 3-parameter WP model

    Gaussian on the final margin

  • Market anchor

    Closing spread as the kickoff prior

  • Rebuild-per-play serving

    ~9 ms median per event

Everything runs as batch CLI commands over parquet files. There is no daemon and no scheduler; the only network polling in the repo is an append-only live odds capture that writes immutable, content-hashed snapshots. Live in-game serving against a paid low-latency feed is designed for but deliberately not wired up yet.

01

Ingest

Schedules, lines, talent, portal, play-by-play into raw parquet

02

Possessions

Classify plays, build possessions, aggregate team-game features

03

Fit

Ridge over offense/defense PPP plus home field, or the preseason prior

04

Project

Margin and total distributions for every upcoming game

05

Anchor

Outcome-free pregame anchors for in-game serving

06

Publish

Serving tables written to the Supabase cfb schema

Data sources

CFBD APISportsDataverse ESPN PBPThe Odds APISupabase
Rating Engine
A Bayesian linear model over points per possession, blending scoreboard results with process signal

The engine works at the possession level. Raw ESPN play-by-play is classified play by play (scrimmage, special teams, penalty, clock management, administrative), and possessions are rebuilt as maximal chronological runs of scrimmage plays by one offense. Garbage time is excluded with period-specific thresholds: a play no longer counts as competitive once the margin exceeds 43 points in the 1st quarter, 38 in the 2nd, 28 in the 3rd, or 22 late. Each game then contributes an offense and a defense row per team, with points per possession as the target.

The model itself is small on purpose: one offense number and one defense number per team, plus a single shared home-field term. For 266 teams that is roughly 530 parameters, solved as a ridge regression in closed form. Ridge is the computational name; statistically this is a Bayesian update. The priors are genuine Gaussian priors, conjugate with the Gaussian likelihood, so the posterior mean and covariance drop straight out of the normal equations, and that covariance is what feeds projection uncertainty later.

E[ppp_home] = base + off_home − def_away + 0.5 · hfa
E[ppp_away] = base + off_away − def_home − 0.5 · hfa
// hfa applies only off neutral sites; prior 2.5 ± 1.5 points, currently fit at 3.22

Scoreboard points are a noisy signal of team quality, so the fit runs in two stages. The first stage regresses a 50/50 blend of actual points and an EPA-derived process score. The second stage measures the residual covariance between the two signals and re-solves against their precision-weighted combination, so whichever signal has been more reliable that season gets more say. Expected possessions come from a separate small ridge on game pace.

FCS teams are handled inside the same system rather than dropped: their prior is recentered to the observed FCS-to-FBS gap before the solve, and ratings are centered so that zero always means an average FBS team.

The hyperparameters (prior strength 0.45 ppp, covariance shrinkage 0.8, Student-t degrees of freedom 500, covariance scale 1.125) were selected by grid search on the 2019–2022 development seasons only. One search result worth calling out: the grid included recency half-lives from 3 weeks up, and it preferred no time decay at all. College football seasons are short. Throwing away September to sharpen November costs more than it buys.

Unit Ratings
Six opponent-adjusted companions that describe how each team produced its results

The Ratings page also publishes rush offense, pass offense, rush defense, pass defense, pass blocking, and run blocking. Each number is fit with an opponent-adjusted ridge over completed games strictly before the forecast week, then centered so zero is an average FBS unit. Positive is better in every column. The six values are descriptive companions only: they never feed the joint scoring engine and do not add up to the headline offense or defense rating.

Rush and pass units use competitive-play PPA. Pass blocking measures sack PPA relative to the expected sack cost for the team's number of dropbacks. Run blocking uses adjusted line yards, converted to a PPA-equivalent scale from the same admitted training window. Both line ratings capture shared outcomes involving the line, skill players, quarterback, and scheme, so they are not film grades of the offensive line in isolation.

A preseason forecast carries the latest final unit ratings from the prior season. Teams without that history receive a neutral zero and a visible missing-history flag. Special teams is omitted because the current CFBD play contract does not provide reliable PPA for routine punts, kickoffs, and field-goal attempts; inventing a complete rating from sparse scoring-event rows would be misleading.

Preseason Prior
Last season's final fit plus offseason signals, each weighted in points per standard deviation

College football resets hard every offseason: rosters turn over through the draft and the transfer portal, and week 1 arrives with no current-season games to learn from. The preseason rating starts from last season's final fit and layers standardized offseason signals on top. Each input is converted to a z-score across all of D1, then multiplied by a weight denominated directly in points of rating:

InputPoints per SDNote
Previous season rating1.00Carried in points, not standardized
Talent composite (prior season)1.50CFBD roster talent
Talent composite (current season)1.50Zero until CFBD publishes it
Returning production1.20Percent of team PPA returning
Transfer portal quality balance1.00Rated arrivals minus departures
QB continuity1.00Percent of passing PPA returning
Recruiting class points0.80CFBD team recruiting
Transfer portal count balance0.35Headcount in minus out
Coach continuity±0.35Same head coach as last season, or not

Transfer portal quality is measured as the summed rating of rated arrivals minus rated departures, with a separate QB-only version feeding the scoring environment (the offense/defense split). A parallel equation projects each team's scoring environment from QB continuity, QB transfers, and returning receiving production, which is what lets two teams with the same power rating project different totals.

Uncertainty is an explicit budget. Every rating starts at 6.05 points of standard deviation and grows in quadrature for each missing input: 3.0 more if the team has no previous rating, 3.0 for FCS teams, 1.5 for missing returning production, 1.25 for unknown QB continuity, and so on down to 0.75 for recruiting. Injury availability has no data source at all, so its term is always on. The SD column on the Ratings page is this number, and it flows straight into wider spread distributions for affected games. The preseason prior covers week 1 only; from week 2 on, the in-season engine takes over.

From Ratings to a Line
Margin and total as a joint Student-t distribution, compared against every priced offer

Because power rating is offense plus defense and scoring environment is offense minus defense, the projection algebra collapses to something you can do in your head:

home_margin = power_home − power_away + home_field
model_total = league_base + environment_home + environment_away
// league_base = league scoring rate × the teams' blended pace

A team rated +10 is a 10-point favorite over an average FBS team on a neutral field. Home field adds 3.22 points this season. The published spread is the negated margin, following the sportsbook sign convention.

Around that point estimate sits a bivariate Student-t distribution over margin and total, built from the fit's residual covariance plus full parameter uncertainty, including both teams' rating SDs. A typical in-season margin SD is about 17 points, and preseason projections with more missing inputs run closer to 18. Seventeen points sounds enormous until you grade forecasts against final scores for seven seasons; college football is just that noisy, and pretending otherwise produces intervals that fail their coverage checks.

For market comparisons, the distribution prices every offer directly: the probability the home side covers is the t-CDF of the edge over the scale, and expected value follows from the American price. Plays where the model sees an edge of at least 4 points with positive EV get flagged. Those flags are the model's benchmark against the market, and each one is also a prompt to review the model's inputs first, since the biggest edges in practice tend to involve FCS opponents with degraded data. What never happens is the step after: no sizing, no picks, and every row ships with recommendation_status set to not_recommended.

Backtest vs the Closing Line
Frozen walk-forward, 2021 to 2025. The market is better, and by a consistent amount.

Every backtested prediction was made walking forward through each season using only completed games from strictly earlier weeks, then frozen. The comparison is mean absolute error of the projected home margin against the actual margin, model versus closing spread, on the same games:

SeasonModel MAEMarket MAE
202113.6012.49
202213.8612.28
202313.2612.00
202413.4612.02
202512.8411.93
Pooled13.4012.14

The market wins every season and every week bucket. That is the expected outcome: the closing line aggregates injury news, weather, and the sharpest private models in the world, and beating it consistently is rare enough that claiming to should be treated as a red flag. What the model can claim is calibration. On the untouched 2023–2025 holdout (2,258 games), the 50/80/95% margin intervals covered 51.6%, 80.1%, and 94.2% of outcomes, and the total projection came out unbiased to a hundredth of a point.

One caveat applies to everything in this table, and it applies to sports modeling generally. A backtest reports how this procedure would have done in seasons that already happened, and college football does not hold still: the portal, NIL, realignment, and playoff expansion keep rewriting the sport underneath the model, and the market adapts too. The absolute error levels above are era-specific and should be expected to drift. They stand in because the season has not started yet; once it does, live graded games on the Performance page become the record that matters, and they supersede this table as they accumulate. What the backtest is trusted for is its structural findings, the ones that repeated in every single season: the market beats the model pregame, momentum fails out of sample, the stated intervals cover. Those drove the design decisions, and they transfer far better than any error number.

The known weak segment is early season: games where either team has fewer than two prior games grade at 16.5 points of margin MAE with intervals that run too narrow. That is precisely the hole the preseason prior exists to shrink. Full season-by-season detail is on the Performance page.

In-Game Win Probability
Three fitted parameters, a Gaussian on the final margin, and nothing the model could not know pre-snap

The in-game model answers one question at every play boundary: given the score, the clock, who has the ball and where, and what was believed before kickoff, what is the probability the home team wins? The state for play N is built strictly from plays 1 through N−1 plus the pre-snap situation, and a prefix-stability check proves it: rebuilding any game from only its first N plays must reproduce every earlier state bit for bit, or the pipeline refuses to continue.

The functional form is a Gaussian over the final margin whose mean decays from the pregame expectation toward the live score as the clock runs out:

possession_value = sign · (0.306 + 0.0565 · (75 − yards_to_goal))
μ = home_margin + f · pregame_margin + possession_value
σ² = f · pregame_margin_sd² + 3.355²
P(home win) = Φ(μ / σ)
// f = fraction of regulation remaining; 0 in overtime

Three parameters, all fit by direct search on play-level log loss over the 2019–2022 development seasons: possession is worth 0.306 points at a team's own 25, field position adds 0.0565 points per yard (so first and goal at the 5 is worth about +4.3 points, and being backed up at your own 1 costs about a point), and a 3.35-point floor keeps the distribution from collapsing as time expires.

On the 2023–2025 holdout (400,878 play states across 2,258 games) the model scores 0.4073 log loss and 0.1328 Brier, and passes calibration checks in every quarter and every score-margin bucket. Calibration tolerances are computed on game counts rather than play counts, since every state within a game shares one outcome. There are deliberately no momentum or streak features in this baseline; it exists to be the reference any such idea has to beat.

The Market Anchor
Swapping the model's pregame margin for the closing spread, everywhere the clock still matters

If the closing line is the better pregame forecast, the in-game model should start from it. The anchor construction is deliberately plain: one closing spread per game, taken as the median across every priced provider, enters the win probability equation as pregame_margin = −closing_spread. The market prices a spread but not an uncertainty, so margin_sd is a single frozen constant, 15.445 points, the standard deviation of actual margins around the closing spread over 3,794 development-season games. Holdout sensitivity was flat near that value, so nothing fancier than a constant is warranted yet.

The frozen baseline was then rescored on identical play boundaries with only the anchor swapped. On the 2023–2025 holdout:

MetricModel anchorMarket anchorΔ
Log loss0.407300.38954−0.01775
Brier score0.132800.12576−0.00704

The improvement lives exactly where theory says it should. The anchor's weight decays with the clock, so the gain is largest early and vanishes late:

1st quarter

−0.0406

2nd quarter

−0.0231

3rd quarter

−0.0083

4th quarter

+0.0002

Overtime

0.0000

The verdict that came out of this experiment now steers the roadmap: the pregame anchor is the binding constraint on in-game accuracy, so effort belongs on the anchor, not on further in-game adjustments. At kickoff the served win probability is essentially the market's own line converted to a probability; by the fourth quarter it is almost entirely the scoreboard. When no market anchor exists for a game, the model's own projection fills in.

Serving and Verification
Outcome-free by construction, verified exact against stored batch output

The serving path is built so that a live deployment cannot accidentally cheat. A served game reads exactly three things: a four-column anchor contract (game_id, model_week, home_margin, margin_sd), the three frozen model parameters, and the play feed. Reads are column-restricted so no outcome field can leak in, and verification against stored results is a separate step that only runs after serving is done.

Rather than maintain incremental state, the server rebuilds the entire game state from the play prefix after every single play. That sounds wasteful until you measure it: across all 759 games of the 2025 season replayed as a simulated live feed, 135,662 served events matched the stored batch predictions exactly, at a median of 8.9 ms per event with a p99 of 11.6 ms, against a 1-second live budget. Exact-match replay means the live path and the evaluated path are provably the same code producing the same numbers.

Momentum: Tested and Rejected
Two attempts at a momentum layer, both worse on holdout, both shelved

The project is named after momentum, so this section is a little painful to write. The hypothesis was that chronological process evidence carries signal the scoreboard has not absorbed yet: sustained stops, drive efficiency, turnovers, field position, fourth-down outcomes, missed kicks, success rate, tempo. Eight evidence families, tracked as home-minus-away totals, shrunk toward zero by a prior play count, shifting the expected final margin on top of the frozen baseline.

Version one used cumulative totals and improved the development seasons by 0.0030 of log loss. On holdout it was worse by 0.0004. Version two weighted recent plays more heavily with a 120-play half-life, and its shrinkage search ran to the top of the grid, which is an optimizer's polite way of saying it would rather not use the features at all. Holdout: worse by 0.0006.

The honest read is that whatever is real in a hot streak shows up on the scoreboard quickly, and the scoreboard is already in the model. Momentum work is paused until there is a structurally different formulation worth testing, and the baseline above stands as the number any future attempt has to beat on holdout, not on the development years it was tuned on.

What the Model Does Not Do
Stated plainly, because the boundaries are design decisions too
  • No injury or availability modeling. There is no reliable public availability feed, and guessing from play-by-play text is a good way to be confidently wrong. The missing input permanently widens every team's rating uncertainty instead.
  • No picks, no sizing, no profitability claims. The model does flag +EV plays; pricing the market and surfacing disagreements is how it gets benchmarked against the strongest available forecast. It stops there. Nothing sizes a wager or sells a pick, the flags are a measurement tool rather than betting advice, and every row ships with recommendation_status set to not_recommended.
  • No live production feed yet. Current data sources are batch. The serving path is proven fast enough for live use, but real-time projections wait on a paid low-latency tier.
  • Early-season fragility. Ratings for teams with fewer than two graded games are the model's weakest output, and FCS teams with sparse data feeds carry the widest uncertainty and produce the largest model-market gaps.
  • Postseason in-game serving is unmapped. Regular-season weeks map cleanly onto serving anchors; bowl season needs its own mapping and does not have one yet.
Tech Stack
Small tools, closed-form math, and parquet files all the way down

Modeling

PythonNumPypandasSciPyclosed-form ridgeNelder-Mead / Powell

Data

CFBD APISportsDataverse ESPN PBPThe Odds APIparquet (pyarrow)

Database

Supabase (PostgreSQL)cfb schemaSQLAlchemyRLS public_read

Frontend

Next.jsTypeScriptTailwind CSSshadcn/ui

Orchestration

Batch CLI commandsAppend-only live odds capture
A note on model size:there is no machine learning framework anywhere in this system, but that is a statement about tooling, and the underlying model is still Bayesian. The rating engine puts Gaussian priors on every team's offense, defense, and the shared home-field term, updates them with each week's possessions, and carries the posterior covariance into every projection interval. Because the model is Gaussian throughout, the posterior has a closed form: what a NUTS sampler does for the MLB model on this site, plain NumPy linear algebra does here. The in-game layer is three parameters fit with a Nelder-Mead search. Nothing needs a GPU, every artifact is a parquet file, and any number on this site can be regenerated from raw data with one CLI command. When a model this small is calibrated across seven seasons, added complexity has to argue for itself on holdout. So far, momentum could not.