Skip to contents

Expected points and win probability are the foundation. Everything in this article is built on top of them, and all of it ships in the same cfb_model_artifacts bundle.

There is a useful distinction to draw first. Some of these are models — they predict a thing and you read the number. Others are analytic surfaces: they build the game state that would follow a choice, score it with the WP model, and compare. Fourth down and the two-point decision are surfaces, and they are ports of cfb4th.

The bundle at a glance

Artifact Objective Features Produces
ep_model multi:softprob (7) 8 ep_before, ep_after, EPA
wp_naive binary:logistic 12 wp_before, wpa
wp_spread binary:logistic 13 vegas_wp
cfb_cp_model binary:logistic 8 cp, cpoe
xpass_model binary:logistic 7 xpass, pass_oe
fg_model binary:logistic 5 field-goal probability
two_pt_model binary:logistic 4 prob_2pt
fd_model multi:softprob (76) 9 fourth-down gain distribution
qbr_model reg:squarederror 10 QBR

Plus punt_distribution.parquet, which is not a model at all — see below.

Completion probability and CPOE

cp is the probability a given pass attempt is completed, from down, distance, field position, score, clock, home field, period and a passing-down indicator. CPOE is the difference between what happened and what was expected, on a percentage-point scale:

CPOE=100×(completion−cp)\mathrm{CPOE} = 100 \times (\text{completion} - \mathrm{cp})

Situation cp
1st & 10, own 25 0.639
3rd & 3, opponent 40 0.595
2nd & 20, own 20 0.627
3rd & 12, own 30 0.489

CPOE is the standard way to separate a quarterback’s accuracy from his situation. A 65% completion rate throwing 3rd-and-12s is a much better performance than 65% throwing 1st-and-10s, and CPOE is what says so.

Note that the model has no receiver, pressure, or air-yards input — it is a situational baseline, not a throw-difficulty model. A quarterback checking down constantly will post good CPOE.

Expected pass rate (xpass and pass_oe)

xpass is the probability a play is a pass, given the situation — a model of tendency, not of quality. pass_oe (pass rate over expected) is 100 * (pass - xpass), and measures how much more pass-happy a team is than its situations warrant.

Situation xpass
1st & goal, opponent 3 0.141
2nd & 2, own 33 0.247
3rd & 2, opponent 40 0.255
1st & 10, own 25 0.373
2nd & 8, own 27 0.536
3rd & 8, opponent 40 0.782

The spread is the point: 14% on 1st and goal from the 3, 78% on 3rd and 8. Any raw pass-rate statistic is mostly measuring which of these situations a team found itself in. pass_oe removes that.

An era-encoding trap

The rule era enters these models two different ways, with two different sets of cutpoints:

Models Encoding Cuts Constant
xpass, two_pt ordinal era, 0–3 2006 / 2013 / 2017 .XPASS_ERA_CUTS
fg, qbr, fd one-hot era0–era3 2006 / 2013 / 2020 .FG_ERA_CUTS

Different encodings and different cutpoints, in the same bundle. A 2019 play is ordinal era = 3 but one-hot era2. If you score these by hand, build each model’s era feature from its own rule — reusing one for the other is silent and wrong. cfbfastR and sportsdataverse-py both handle this internally and agree with each other.

A caveat on the ordinal cut. The trainer that produces the bundle (cfbfastR-cfb-data) derives both encodings from one constant, ERA_BOUNDS = (2006, 2013, 2020). So the shipped xpass_model was trained with 2018–2020 in bucket 2, while both consumers score those seasons as bucket 3.

era carries only ~1% of xpass’s gain, so the effect is small but not zero — across a grid of realistic situations the mismatch moves xpass by a mean of 0.9 percentage points and at most 2.9. It does not touch prob_2pt at all, since that model never splits on era. Worth knowing if you are comparing pass_oe across the 2017/2018 boundary; tracked in cfbfastR-cfb-data#70.

The field goal model

Five features: yards_to_goal and the one-hot era. Kick distance is yards_to_goal + 17.

yards_to_goal Kick P(make)
2 19 0.956
10 27 0.893
17 34 0.809
20 37 0.754
25 42 0.675
30 47 0.614
33 50 0.542
38 55 0.455
43 60 0.366
48 65 0.366

A 50-yarder is a coin flip, which is a useful number to carry around.

Era matters, and it is not a rounding effect. Kicking has genuinely improved, and the model captures it — the same 42-yard attempt across the four eras:

Era P(make), 42 yards
era0 (pre-2006) 0.556
era1 (2006–2012) 0.598
era2 (2013–2019) 0.612
era3 (2020+) 0.675

Twelve percentage points from the oldest era to the current one. The era features carry about 20% of the model’s gain between them, so scoring a historical kick with era3 — or a modern one with era0 — is a real error, not a nicety.

The tail runs out at 57 yards. From yards_to_goal 40 outward the model returns a constant 0.3656 — a 57-yard and a 70-yard attempt get identical probabilities. Nobody attempts many 65-yarders except in desperation, so there is nothing to learn from and the model extrapolates flat. Treat anything past a 57-yard kick as “the model has run out of evidence” rather than an estimate.

Two small monotonicity violations. Across 58 steps the curve rises twice where it should fall: a 49-yard kick is rated 0.5882 against 0.5647 for a 48-yarder, and 51 yards beats 50. Both are sub-3-point artifacts of tree splitting rather than anything meaningful, but if you are differencing adjacent yard lines — as the fourth-down surface does when it weighs a make against a miss — be aware the curve is not strictly monotone.

The model also has no kicker identity, weather or altitude input. A kick in Laramie and a kick in a dome get the same number.

The two-point model

Four features: posteam_spread, posteam_total, pos_score_diff, era. It produces prob_2pt, which feeds the two-point decision surface — two_pt_wp, xp_wp, two_pt_wp_diff and two_pt_recommendation.

posteam_total is not the game total. It is the possessing team’s implied team total, built from the line and the over/under:

home_total <- (homeTeamSpread + overUnder) / 2
away_total <- (overUnder - homeTeamSpread) / 2

A 55-point game with a 7-point spread gives roughly 24 and 31, so the feature lives around 14–42, not 45–65. This matters if you score the model yourself: feed it a game total and every prediction lands off the end of the training support, where the model is flat and meaningless.

Over the real support it is the model’s strongest input:

posteam_total prob_2pt
14 0.4521
20 0.5024
24 0.5213
30 0.5100
32 0.5774
38 0.5780

Across a realistic grid — spread ±21, team total 16–40, score margin ±10, all eras — the model runs 0.365 to 0.592, mean 0.511. Its base_score is 0.4816, the observed success rate in training, so it sits about 3 percentage points above its own base rate, which is what conditioning should do. Split counts back that up: posteam_total 54, pos_score_diff 32, posteam_spread 20.

The one real gap: era is never used

era is in the feature list and in none of the 40 trees:

xgboost::xgb.importance(model = two_pt_model)$Feature
#> "posteam_total"  "pos_score_diff"  "posteam_spread"     # no "era"

Sweeping era 0 → 3 with everything else fixed moves the prediction by 0.0000. Two-point conversion rates have moved across 2004–2025 and this model cannot express that. With max_depth = 2, min_child_weight = 40 and 40 rounds over a relatively small set of attempts, the other three features win every split.

Practical consequence. The surface’s arithmetic is sound — verified bit-identical to sportsdataverse-py to eight decimal places — but cfb4th’s rule has no margin, and will recommend two on a difference of 0.0001. Read two_pt_wp_diff and treat anything under a couple of percentage points as a coin flip, rather than the bare recommendation.

The fourth-down decision

This is the most elaborate surface in the package, and the 76-class model is the interesting part.

fd_model is a multi:softprob classifier over 76 classes: yards gained, from −10 to +65. It does not predict whether a fourth down is converted. It predicts the whole distribution of what happens if you go for it.

The go branch then:

  1. Scores the 76-class distribution for the fourth-down state.
  2. Expands the play into one hypothetical game state per outcome — every yardage result gets its own down, distance and field position.
  3. Scores each with the win probability model.
  4. Averages, weighted by the distribution.

That average is go_wp — the win probability of going for it, integrated over everything that could happen.

The punt branch is different again. cfb4th does not model punts. It joins an empirical distribution of punt end-yardlines from punt_distribution.parquet, expands to one state per landing spot, scores each, and averages. The field-goal branch uses the FG model above, weighting a make against a miss and the field position each produces.

The outputs:

Column Meaning
go_wp, fg_wp, punt_wp win probability of each choice
go_wp_diff, fg_wp_diff, punt_wp_diff each against the best alternative
go_boost how much going for it gains over the best kicking option
first_down_prob conversion probability from the distribution
wp_succeed, wp_fail WP after converting / failing
fourth_down_recommendation the call
# Do not add an install step for cfbfastR here. pkgdown and R CMD check
# render this vignette against the package being built; installing the
# CRAN release would overwrite that dev build, and any function added
# since the last release would vanish mid-render.
library(cfbfastR)
library(dplyr)

pbp <- cfbfastR::load_cfb_pbp(2025)

pbp |>
  dplyr::filter(down == 4, !is.na(go_boost)) |>
  dplyr::select(pos_team, distance, yards_to_goal, go_wp, fg_wp, punt_wp,
                go_boost, first_down_prob, fourth_down_recommendation) |>
  dplyr::arrange(dplyr::desc(go_boost)) |>
  dplyr::slice_head(n = 20)

Sorting by go_boost finds the fourth downs where going for it was most clearly right — and, sorted the other way, the punts that cost the most.

As with the two-point surface, go_boost is more useful than the recommendation: a 0.001 edge and a 0.08 edge are different decisions, and only the magnitude distinguishes them.

QBR

A regression (reg:squarederror) on ten features, all of which are already EPA components: qbr_epa, sack_epa, pass_epa, rush_epa, pen_epa, plus spread and the one-hot era.

This is worth being clear about, because QBR is often described as a black box. Here it is not a new measurement of anything — it is a re-scaling of EPA components onto the 0–100 scale people recognise, fit to reproduce the published QBR values. Everything it knows, it knows from EPA. If you want to understand why a quarterback’s QBR is what it is, look at his EPA splits; there is nothing else in there.

Everything degrades to NA

A design decision worth knowing about: none of these surfaces fail the pipeline. If a model artifact cannot be fetched, or arrow is missing, or the punt table is unavailable, or the game has no pre-game line, the columns are added as NA rather than the stage erroring.

This means you must check for NA rather than assuming a column is populated — particularly vegas_wp and the two decision surfaces, which need a spread. The upside is that a network problem degrades your play-by-play instead of destroying it.

pbp |>
  dplyr::summarise(dplyr::across(
    c(EPA, wpa, vegas_wp, cpoe, xpass, go_boost),
    ~ mean(is.na(.x))
  ))

Seeing it applied

Game on Paper surfaces most of this per game: the win probability chart, per-play EPA, and fourth-down decision context. Its glossary defines the derived metrics in the same terms used here.

Frequently asked

What is CPOE in college football? Completion percentage over expected — the gap between a passer’s actual completions and what the situation predicted, in percentage points. It separates accuracy from difficulty of situation.

What is pass rate over expected (pass_oe)? How much more often a team passes than its down, distance, score and clock suggest. It measures aggression, not effectiveness.

How accurate is the college field goal model? In the modern era (era3, 2020+) it gives about 81% at 34 yards and 54% at 50. Older eras are materially lower — 42 yards is 0.68 in era3 against 0.56 in era0. It has no kicker, weather or altitude input, and flattens beyond roughly 57 yards where data runs out.

Should I trust two_pt_recommendation? Not on its own — cfb4th’s rule has no margin and will recommend two on a difference of 0.0001. Read two_pt_wp_diff and treat small margins as coin flips. The underlying prob_2pt model is reasonable (mean 0.511 against a 0.4816 base rate) but ignores era.

Should I trust fourth_down_recommendation? Use go_boost instead. The recommendation has no margin, so it treats a 0.0001 edge as a decision.

Data and artifacts

Citation

Gilani, S., Easwaran, A., Lee, J., and Hess, E. (2026). cfbfastR: Access College
Football Play by Play Data. R package version 3.0.0.9000.
https://cfbfastr.sportsdataverse.org

Authors, contributors and related SportsDataverse packages are listed on the package home page.