
CPOE, xpass, Field Goals and Fourth Downs: The Derived Models (Part VI)
Saiem Gilani, Akshay Easwaran, Jared Lee
2026-09-03
Source:vignettes/college-football-expected-points-model-fundamentals-part-vi.Rmd
college-football-expected-points-model-fundamentals-part-vi.RmdExpected
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:
| 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) / 2A 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:
- Scores the 76-class distribution for the fourth-down state.
- Expands the play into one hypothetical game state per outcome — every yardage result gets its own down, distance and field position.
- Scores each with the win probability model.
- 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.
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.
The series
- Expected points: how the model works
- How the model is built
- A history of expected points models
- What is EPA?
- Win
probability, WPA and
vegas_wp - CPOE, xpass, field goals and fourth downs — this article
Data and artifacts
-
Models —
cfb_model_artifacts -
Season play-by-play —
cfbfastR-data -
Source —
cfbfastR·sportsdataverse-py·cfb4th - Applied, live — Game on Paper
- Raw API — CollegeFootballData.com, courtesy of @CFB_data
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.