3.1.0 ships a new model generation: expected points, win probability and the
decision models are now the XGBoost boosters in the shared cfb_model_artifacts
bundle, the same files sportsdataverse-py scores with. EPA and WPA therefore
differ from 3.0.0 for the same play. See the model notes below before mixing
outputs from the two releases.
cfb_clear_model_cache() deletes the downloaded models, which are cached in
file.path(tools::R_user_dir("cfbfastR", "cache"), "models"), and drops the
ones already loaded in the session. ?cfb_clear_model_cache,
?calculate_cfb_models and the README now say where the cache is and how
often it refreshes, and that a model already loaded stays in memory for the
session. A restart does not clear the cache, so a new session reads the
cached copy until it expires; cfb_clear_model_cache() picks up a new model
at once.calculate_*() call that cannot fetch its model or card says why (the
download's own message) and that the models come from GitHub.?family_espn_cfb, which lists
the whole family. The smaller families keep their "See also" lists.load_cfb_team_summaries_weekly() is now documented on the
?load_espn_cfb_team_summaries page. The two loaders return the same 385
columns (the weekly frame adds through_week), and the table is no longer
printed twice.espn_cfb_game_drive_plays() and espn_cfb_game_play() are now documented
on the ?espn_cfb_game_pbp page. All three take the same arguments, plus a
drive or play id, and return the same play columns, which the page now lists
once. Their examples make one call each.inst/WORDLIST, is a development file and is no
longer installed.espn_cfb_standings(),
espn_cfb_game_pbp(), espn_cfb_qbr() and
cfbd_pbp_data_v2() ask for one team, conference or page of results instead
of a whole league, so they run in seconds. espn_cfb_seasons(),
espn_cfb_coaches() and espn_cfb_team_roster() make one request per row,
so their examples, like those of load_cfb_pbp() and update_cfb_db(), run
only in an interactive session.espn_cfb_game_player_box() (one request per player),
espn_cfb_groups() (one per conference and division), espn_cfb_pbp() and
espn_cfb_pbp_v2() (several seconds of CPU to parse a game), and the
calculate_*() functions that download a model on first use.
espn_cfb_game_drive_plays() shows two calls instead of five.calculate_win_probability() example scores a hand-built row; it used to
pass a CFBD play-by-play frame that lacks the model's inputs and errored.cfbd_pbp_data(), load_cfb_pbp(), update_cfb_db(), has_cfbd_key(),
create_epa(), create_wpa_naive() and add_play_counts() have examples
(the last three point to the legacy-engine example in ?cfbd_pbp_data).
has_cfbd_key(), calculate_epa(), calculate_wpa(),
cfb_card_reset_cache() and cfb_clear_model_cache() run theirs offline,
outside \donttest.load_espn_cfb_pbp() and load_ncaa_mfb_pbp_cfbfastr() (a
whole season), espn_cfb_awards() and espn_cfb_positions() (one request
per award or position), and the espn_cfb_game_pbp() call with
participants = "wide" (which fetches the position catalog) also run only in
an interactive session. espn_cfb_venues() and espn_cfb_recruits() ask for
5 rows instead of 50 and 25, and espn_cfb_game_player_statistics(),
espn_cfb_game_team_roster() and espn_cfb_player() show one call, with
position_detail = FALSE, instead of two or three.espn_cfb_game_pbp() and its siblings with participant columns, and the
Yahoo season stats, clean each participant type or stat name once per session
instead of once per play or per player. The output is unchanged; a 10-player
yahoo_cfb_player_season_stats() call takes about 0.6 s of CPU instead of
4.5 s, and the participant columns of one game take less than half as long.The EP model is now the XGBoost artifact published in
cfb_model_artifacts
— the same artifact sportsdataverse-py scores with, so both libraries
agree on EPA for a given play and a retrain updates both from one publish
(#138).
epa_wpa = TRUE
no longer aborts with predict.nnet(): missing values in 'x' on mid-era CFBD
data (seasons ~2006–2013), in either engine.MANIFEST.json and asserted in tests, because getting it wrong
yields EP that is wrong yet plausible-looking.cfbfastR.cache_duration TTL (default 24h), so a republished model is picked
up without a package update. An expired cached copy is still used if the
release is unreachable.xgboost (Suggests) is now required to score EP and its floor moved to
>= 1.7 for .ubj support. Without it — or offline with no cached copy —
the retired nnet model is loaded as a fallback and still works.wp_naive.ubj on the
same terms. Eleven of its twelve features already existed on the frame; only
is_home is derived.fg_model.ubj
(yards_to_goal + one-hot era0..era3). season is now threaded through
.run_epa_wpa() and the exported create_epa() / epa_fg_probs() gain a
season argument (defaulting to NULL). Scoring the era-aware model
without a season is an error rather than a silent all-zero one-hot.cp and cpoe columns are added on pass
plays, cpoe on the percentage-point scale 100 * (completion - cp),
matching sportsdataverse-py. The model loads lazily on first use and the
stage degrades to NA columns rather than failing.vegas_wp column is added
alongside the unchanged naive wp_before — the same split nflfastR draws
between wp and vegas_wp. It is NA wherever the game has no pre-game
line (the ESPN path, and CFBD before 2013). Sign convention verified over 83
games: CFBD's spread is negative when the home team is favored, and the
model reads positive spread_time as the team in possession being favored.
vegas_wpa and vegas_wp_after are derived alongside it, using the same
turnover and half/period-end overlays as the naive wpa.xpass / two_pt scored 2018-2020 in the wrong rule era. The
ordinal era column was cut at 2006/2013/2017, but the producer
(cfbfastR-cfb-data) derives BOTH era encodings from one
ERA_BOUNDS = (2006, 2013, 2020) and has never carried a 2017 cut -- so those
three seasons were scored as era 3 against models that trained them as era 2.
era takes 251 splits and ~1.1% of gain in xpass_model: across 630
realistic situations the mismatch moved xpass by a mean of 0.9pp and at most
2.9pp, inherited by pass_oe as a step at the 2017/2018 boundary. prob_2pt
was unaffected -- that model never splits on era. The 2017 figure traces to a
stale docstring on sdv-py's cfb_two_point.py::_era, whose code already read
the 2020 bounds. .XPASS_ERA_CUTS now equals .FG_ERA_CUTS; the two remain
separate constants because the ENCODINGS differ (one ordinal column vs four
one-hot dummies). See
cfbfastR-cfb-data#70.xpass and pass_oe columns are added on
scrimmage plays (nflfastR's xpass / pass_oe), pass_oe on the
percentage-point scale 100 * (pass - xpass). Note this model uses an
ordinal rule-era feature, a different encoding from the FG model's
one-hot era0..era3 -- but the same cutpoints: both cut at 2006/2013/2020.
(An earlier build of this release cut xpass at 2017; see the fix above.)The three remaining cfb_model_artifacts artifacts are now used: the
two-point, fourth-down and QBR models
(#140). Unlike the
model columns above, these are analytic surfaces — they build the game state
that WOULD follow a choice, score it, and compare. They are ports of
cfb4th and degrade to NA
columns rather than failing when a model, the punt table, arrow, or a
pre-game line is missing.
two_pt_wp /
xp_wp / two_pt_wp_diff / two_pt_recommendation compare going for two
against kicking, using the conversion probability prob_2pt and the
opponent's ensuing-drive win probability for each outcome. Verified
bit-identical to sportsdataverse-py to eight decimals. Read
two_pt_wp_diff rather than the bare recommendation: cfb4th's rule has no
margin, and the bundled model is optimistic about college conversion rates.go_wp / punt_wp /
fg_wp and the comparison columns go_boost, go_wp_diff, fg_wp_diff,
punt_wp_diff and fourth_down_recommendation. The go branch expands the
fourth-down model's 76-class yards-gained distribution to one state per
possible outcome; the punt branch joins the empirical end-yardline
distribution (NA inside the 31, where cfb4th's table is empty); the
field-goal branch carries cfb4th's policy clamps (zero beyond 42
yards-to-goal, 0.9x from 35 out). first_down_prob, wp_succeed,
wp_fail, make_fg_wp, miss_fg_wp and fourth_down_fg_make_prob are
exposed alongside. Note the last of those is namespaced: fg_make_prob
already means the make probability of the field goal that was actually
attempted.create_qbr(). Per-quarterback, per-game leverage-weighted EPA
components scored through the bundled QBR model. It emits its own table
rather than columns on the play-by-play frame, so it is an entry point you
call on a modeled frame, not a pipeline stage.sportsdataverse-py's get_go_wp() raises on pandas 3 — so they
were ported from the cfb4th R source and gated behaviorally against known
college conversion and field-goal rates. Three sign conventions were found
inverted in the Python port while doing so; each produced a confident wrong
recommendation rather than an error, and each is pinned by test here.NA on the ESPN engine, which carries no pre-game
spread.library(cfbfastR) no longer downloads anything. The expected points, field
goal and win probability models are fetched the first time a function scores a
play, so loading works offline and writes no files. If that fetch fails, the
next call tries again instead of keeping the failure for the session.httr2::url_modify() and purrr::list_rbind(); with older versions
library(cfbfastR) failed. stats and utils are declared in Imports, and
the unused Rcpp and RcppParallel imports are gone.Ten calculate_*() functions score a data frame with the models cfbfastR already
ships — hand them play-by-play or a row you typed to ask a hypothetical:
calculate_expected_points(pbp)
calculate_field_goal_probability(data.frame(season = 2024, yards_to_goal = 25))
calculate_fourth_down(data.frame(season = 2024, down = 4, distance = 2,
yards_to_goal = 45, posteam_total = 52, posteam_spread = -3))
calculate_expected_points(), calculate_win_probability(), calculate_epa(),
calculate_wpa(), calculate_field_goal_probability(),
calculate_completion_probability(), calculate_xpass(),
calculate_two_point_probability(), calculate_fourth_down() and
calculate_qbr().
create_epa() and create_wpa_naive() remain as the low-level layer these wrap.
They stay useful to callers who already hold booster objects — but they require
you to obtain those objects from an internal loader, which is why an exported
function was still unusable by an ordinary user.
Every calculator validates against the model's own published card. Each model
in the cfb_model_artifacts bundle ships a <model>.card.json declaring the
ordered features it was trained with, and where relevant an era_contract. Feature
lists and era cutpoints are now read from those cards rather than restated in this
package — the duplication that caused cfbfastR-cfb-data#70, where both cfbfastR and
sportsdataverse-py kept a private copy of the era boundary, both drifted to a 2017
cut the trainer never used, and 2018-2020 scored an era off the models trained with
them.
cfb_model_card(), cfb_card_features(), cfb_card_era_contract() and
cfb_card_reset_cache() read those cards directly: the parsed card, the
features in trained order, the era encoding, and a reset of the in-session card
cache. (#152)
A missing column names every absent column and the model that wanted it, rather
than failing inside xgboost. Play-by-play column names are normalized
automatically, so a frame carrying start.TimeSecsRem is accepted as-is, and
every input column is preserved so chaining two calculators is lossless.
calculate_field_goal_probability() returns fg_make_prob, not fg_prob:
calculate_expected_points() uses FG for the probability the next score is a
field goal, which is a different quantity from the probability a kick is made.
A committed fixture pins these against sportsdataverse-py's equivalents across
all four rule eras — 20 output columns, agreeing to 1e-5.
cfbd_game_schedule() (CFBD /games/schedule, upstream v5.31.1).cfbd_game_preview() (CFBD /games/{gameId}/preview, upstream v5.31.1).cfbd_game_preview_adjusted() (CFBD /games/{gameId}/preview/adjusted, upstream v5.31.1; Patreon Tier 1).cfbd_team_season_overview() (CFBD /teams/season/overview, upstream v5.31.1).cfbd_coaches() now sends firstName / lastName
(the API silently ignored first / last, so the name filter returned every coach),
cfbd_game_player_stats() and cfbd_game_team_stats() send the game filter as id
(was gameId, which the API ignored, so year and a week/team were still demanded);
id must travel alone, so year is now optional in both and the season filters are
omitted when game_id is supplied. cfbd_plays() finally sends its conference
argument. Argument names are unchanged.cfbd_game_weather(game_id =), cfbd_ratings_elo(preseason =),
cfbd_pbp_data(offense =) and, on cfbd_pbp_data_v2(), offense, defense,
offense_conference, defense_conference, conference and division.fumble_vec missed 21-60 of them a season
(2014-2025), and with it the fumble attribution and the QBR fumble penalty on
those plays. The other literals change no play ESPN has published; they now
agree with sportsdataverse-py if one appears. punt_block_return_player_name
is filled on "N Yd Return of Blocked Punt" touchdowns (31-59 a season), which
it left empty, and the fumbler / forced-by / recovered-by and the
receiver-is-not-sacked captures read "Fumble" and "SACKED" too
("Fumble recovered by CCaro Roderick Holder" now names him). A
"N Yd Fumble Return" touchdown still has no fumble player: the player it
names is the returner. sportsdataverse-py's name-shaped fumbler capture (one
to three capitalized words before "fumble(d)" or after "fumbled by", which it
measures lifting fumble id joins from 56% to 79%) is not ported yet, so
fumble_player_name / fumble_player_id still trail it on most fumbles.__attach_player_ids does: a "N Yd" yardage
("Ale Kaho 0 Yd" -> "Ale Kaho"), a glued "tackled" / "fumble", a trailing
connective ("... for", "... by"), and a capture that starts in a clause ("to
the MiaOh 21") or holds a spot ("WAS30") is dropped. More *_player_name
values are clean and more *_player_id values resolve (on the 60 parity games,
22 more ids, e.g. "Trapper Pannell 3 Yd Run" now resolves its rusher).espn_cfb_pbp_v2() counts timeouts as sportsdataverse-py does. It charged a
Timeout row only when the ten characters after "Timeout " equaled the team
abbreviation, which ESPN's "Timeout Mississippi State, clock 01:13" never
does: 8 of 116 Timeout rows in 16 test games, so most teams kept 3 timeouts
all game. A row is now charged to the team whose abbreviation, location or
name it matches (or the initialism, "SOUTHERN CAL" for USC, or the shortened
spelling, "WESTRN MICHIGAN"), an adjacent repeat counts once, each overtime
period allots 1 (in feeds that number every overtime 5, the period comes from
pairing the overtime drives, as in sportsdataverse-py), and no count goes
below 0. pos_team_timeouts_rem_before,
def_pos_team_timeouts_rem_before and the columns built on them change, and
so do wp_before, wp_after, wpa and vegas_wp (on 2024's 401628409, 11
of 175 plays, by up to 0.019). EP and EPA do not read timeouts. A new oracle
(tests/testthat/test-pbp_timeouts.R) checks the charges and counts against
sportsdataverse-py on all 3,695 plays of 19 games. Both libraries share one
known overtime error: when ESPN files an overtime drive's rows around another
drive (2025's 401754372), the drive pairing counts it twice and hands a team
back a timeout it had called.kickoff_tb follows the 2018 rule that a fair-caught kickoff
is a touchback, and treats a bare "X kickoff" row with no result as one, as
sportsdataverse-py does. 1,122 fair-caught kickoffs in 2018-2025 (567 in 2025)
now have kickoff_tb = 1, yds_kickoff_return = 25 (0 before) and the next
state at the receiver's 25; earlier seasons are unchanged. Like
sportsdataverse-py, the rule does not read the catch spot, so the 152 of those
caught beyond the 25 are also placed at the 25.yds_punted reads "punt for a loss of 12 yards" as -12, not +12, on both
engines, as sportsdataverse-py does: the punt ended behind the line of
scrimmage, which ESPN's next snap confirms. 16 punts in a 2004-2026 sample
change sign. The distance is read in any case ("Punt for 40 yards" too), as in
sportsdataverse-py, and only when a number follows "punt for" directly, so
"punt for no gain to the BC 20" has no distance rather than 20 (the
sportsdataverse-py yardage stage still takes the first later number there). The punt-distance model was trained on the unsigned values, so
it is a retrain candidate when the punt columns are republished. (#156)load_espn_cfb_model_pbp() reads .parquet. It asked for .rds, which the
espn_cfb_model_pbp release publishes for 2024 only, so every other season
(2004-2025 are published as parquet) came back empty with a warning. (#197)espn_cfb_pbp_v2() already
returns them. They are 18-digit integers, more than a double holds exactly,
and the release .rds files store them as doubles: 2025's 166,360 pbp ids
collapse to 149,864 distinct values, and play participants' 154,815 to 141,272,
so joining the two was many-to-many on about one play in ten.
load_espn_cfb_pbp() and load_espn_cfb_play_participants() now read the
.parquet files, which keep the ids as Int64, and return id / play_id as
character; load_ncaa_mfb_pbp_cfbfastr() returns id_play as character.
The pbp, play participants and model pbp loaders now need the suggested
arrow package; the NCAA one needs bit64. Appending to a database table
created by an earlier version, whose id column is numeric, fails on strict
backends: recreate the table. (#197)overUnderAvailable in the ESPN pbp):
parquet_from_url() now reads all-null columns as logical. (#197)load_espn_cfb_power_index()
accepts 2005-2014 (floor 2015 before), load_cfb_returning_production()
accepts 2004, and load_cfb_schedule_crosswalk() / load_cfb_teams_crosswalk()
accept 2004, 2010 and 2013 (floor 2014 before) and reject the seasons the
release has no file for. (#197)?cfbd_api_key no longer lists register_cfbd() as a function. It is a help
page; the functions are cfbd_key(), has_cfbd_key() and
cfbd_api_key_info(). (#197)load_cfb_schedules(), load_cfb_rosters() and
load_cfb_teams() read release files. They call the CFBD API, need
CFBD_API_KEY and take no dbConnection; load_cfb_teams() takes fbs_only,
not seasons. (#197)tail() works on the result of every loader and every function that returns
a cfbfastR_data tibble. The class vector they set, in the loaders and in
make_cfbfastR_data(), still listed data.table, so tail() failed with
"Can't subset columns past the end" (the bug fixed in hoopR as
hoopR#88). The class is
now cfbfastR_data / tbl_df / tbl / data.frame.load_cfb_pbp(2021:2024) sometimes returned about 690,000 of its 946,889
rows when one season hit R's default 60-second timeout. The .rds and
.parquet readers behind every release-backed load_*() loader raise the download timeout
to at least 150 seconds (a larger options(timeout) is kept, and the option
is restored afterwards) and retry a failed download twice. A 404 is not
retried. If a file still fails, the warning names its URL and says the
result is incomplete; the seasons that loaded are still returned.wp_naive, behind wp_before / wpa, and
wp_spread, behind vegas_wp) were trained with
pos_team_receives_2H_kickoff equal to 1 - is_home on every row. cfbfastR
fed them the true flag, which is the inverse on every first-half play of a
game where the home team kicked off first. They are now fed 1 - is_home, as
sportsdataverse-py does, in the play-by-play engine, calculate_win_probability()
/ calculate_wpa() and the fourth-down / two-point decision surfaces.
wp_before, wp_after, wpa, vegas_wp and the decision-surface WP
columns change, most in games where the home team kicked off first (by up
to 0.11 naive WP on 56 real 2004-2025 games; in-sample Brier 0.1352 -> 0.1335,
and 0.1096 -> 0.1052 where the home team kicked off first). Games where the
away team kicked off first, and spread WP overall, move slightly the other way
(spread Brier 0.1345 -> 0.1346). The
pos_team_receives_2H_kickoff column itself is unchanged: it is a correct
descriptive column, only the model input differs. The shim goes when the
boosters are retrained on the true flag (#199).tests/testthat/test-parity_epa_wp.R) now checks
that cfbfastR's EP models, the EP_end - EP_start difference and naive and
spread WP score sportsdataverse-py's recorded game states the same way, on six
real games, to 1e-5. It replays the scoring step; the EPA possession and
scoring rules are not compared.?cfbd_passing now documents cpoe_attempts_available,
cpoe_completions, expected_completions, expected_completion_rate and
cpoe (28 columns; the locations_* buckets do not carry them), so the player
frames are 194/198 columns and the team frames 381/385.
cfbd_passing_plays() gains completion_probability and cpoe (41 columns).
A week CFBD has not scored, such as 2025 week 5, returns them all NA.cfbd_play_stats_types() now documents 31 rows: CFBD added the punt stat
types (ids 27-31).cfbd_ratings_sp() now stops with the "requires an API key" error when no
CFBD_API_KEY is set, like every other cfbd_*() function. It used to send
the request anyway and return an empty frame after an HTTP 401.espn_cfb_pbp(epa_wpa = TRUE) on the default v2 engine (and
espn_cfb_pbp_v2()) now attaches the pre-game spread (the home team's line,
negative when the home team is favored, as on the CFBD path) and
over_under. It never set them, so vegas_wp, vegas_wpa, the fourth-down
decision columns (go_wp, fg_wp, punt_wp, go_boost, ...) and the
two-point columns were NA on every ESPN game. The line is ported from
sportsdataverse-py: the game summary's pickcenter when it lists more than one
provider (games before 2024), else ESPN's core odds collection (ESPN emptied
pickcenter for 2024+), preferring ESPN BET. A game with no line keeps NA
rather than a default, and a failed request warns. It costs one request from
2024 on and up to two before, only when epa_wpa = TRUE. Expected points,
EPA and the naive win probability are unchanged, as is the legacy engine,
which computes no spread-based column.
create_qbr() now passes the QBR booster the feature columns the booster
itself declares. It used a fixed 10-column list that included spread, so the
2026-09 xQBR retrain (9 features, no spread) would have made predict() fail
and every exp_qbr come back NA without an error once the shared
cfb_model_artifacts release carries it. Both contracts now score.
ep_before, the kickoff wp_before and a touchback's new_yardline now use
80 yards to goal through 2011 and 75 from 2012, and yds_kickoff_return is
20 / 25 for a touchback and 40 / 35 for a kickoff out of bounds, in both the
v2 and the legacy engine. add_yardage(), prep_epa_df_after() and
create_wpa_naive() gain an optional scalar season argument; with NULL
they read the frame's season column, and with neither they keep the 2012
rules and warn once per session. Seasons from 2012 are unchanged. Twin of
sportsdataverse-py#778.add_yardage() output changes for kickoffs out of bounds: yds_kickoff_return
was 40 in every season and is now 35 by default (the 2012 rule). Pass
season, or carry a season column, to get 40 for games through 2011.cfbd_team_roster() (and so load_cfb_rosters()) returns year as the
athlete's class year. CFBD fills that field with the season instead for every
roster before 2014 and most from 2014-2019; those values are now NA. A new
season column carries the roster year. (#157)cfbd_game_team_stats(team =) returned both teams of every game. Inside
dplyr::filter() the bare team resolved to the frame's own team column,
so the filter compared the column with itself. It now keeps only the
requested team, matched case-insensitively as CFBD matches team=.
cfbd_game_box_advanced() gave each team its opponent's havoc_* columns.
CFBD lists the havoc section in the opposite team order to the other
sections, and the parser paired entries by position. Rows are now matched to
teams by name, so havoc_* is the havoc the row's own defense created. The
2025 passing / rushingAdvanced sections, which added ~550 unrenamed
columns and coercion warnings, are no longer returned; the output keeps its
documented 69 columns. If a section's team names cannot be matched, it
warns instead of silently pairing by position.
cfbd_pbp_data(epa_wpa = TRUE) (the v2 engine) left pos_team_id, every
*_player_id, and the turnover, penalty, sack and return attribution NA,
and never counted a lost fumble: CFBD plays name the teams but carry no ids,
and the engine keys on ids. The plays now get home_team_id,
away_team_id, home_team_abbreviation, away_team_abbreviation,
offense_play_id and defense_play_id from CFBD /games and /teams
(60 columns with epa_wpa = FALSE, 368 by default), and player ids resolve
against the season's CFBD roster. On four 2025 games these columns now agree
with espn_cfb_pbp() on every play.
punt_return_player_name and kickoff_return_player_name were always NA
from both cfbd_pbp_data() and espn_cfb_pbp(): the player-name stage
filled punt_returner_player_name / kickoff_returner_player_name, and the
documented names were never set from them.
Kneel-downs ("J. Sayin takes a knee") now name the rusher on the
text-parsed paths (cfbd_pbp_data(), espn_cfb_pbp(engine = "legacy")); the
text has no "run", so rusher_player_name and its id were NA.
The CFBD /teams and season /roster lookups behind those ids are cached per
season (until the next UTC midnight, on the same cache as the ESPN catalogs), so
a season sweep spends one request on each instead of one per call.
espn_cfb_clear_cache() refetches them.
Goal-to-go downs that the feed sends with distance = 0 (ESPN "1st & Goal"
downs in some games, and occasionally CFBD) are now modeled at the yards to the
goal line on every engine of espn_cfb_pbp() and cfbd_pbp_data(), the same
rule sdv-py applied to the EP model's training data. Scored at 0 they skewed EPA
on those plays and on the play before each: on 2025 LSU at Clemson the CFBD and
ESPN paths now agree on every play's EPA (was 159 of 168). ESPN's "& 0 at"
downs (no "Goal") are resolved from the previous snap's end state, as
sportsdataverse-py #636 does: a "Goal" end gives the yards to the goal, a real
end distance gives that distance, anything else stays 0. Raw
(epa_wpa = FALSE) output keeps the feed's value.
ESPN "& 0 at" downs after a snap with no usable end state now stay goal-to-go while the series is the same one (same offense and period, no possession-ending snap, no down reset), as sportsdataverse-py #638/#639 do: "1st & 0 at TULN 15" after a penalty backs "1st & Goal at TULN 10" up is modeled at 15. An overtime possession ("1st & 0 at OHIO 25") never inherits the previous goal line.
espn_cfb_pbp() (v2 engine) returns rows in the order EPA and WPA were computed
on. It used to return them in the ESPN feed's order, which differs for most
pre-2014 games (the feed lists plays out of id order), so game_play_number
stepped backwards and a lag over the returned rows read the wrong play.
espn_cfb_pbp() (v2) now models plays in sportsdataverse-py's play order
instead of ESPN's id order. Since 2024 the feed files some plays under later ids
(re-keyed field goals, touchdown drives entered after the fact), so every
previous/next-play feature -- EPA's end state, drive and play counters, WPA --
read the wrong neighbor. The engine now repairs the order the way sdv-py does:
late inserts go back by sequence number, a row filed inside a later drive rejoins
its own drive, tries filed after the kickoff return behind their touchdown, and
overtime is ordered by sequence or id, whichever steps the score back less. On an
83-game 2004-2026 sample the engine's order now equals sdv-py's in all 83 games
(2024-2026: 12 of 12, was 5); on plays whose previous snap changed, the median EPA
gap to sdv-py fell from 0.25 to 0.05. A separate 130-game check against sdv-py's
full pipeline found one game that differed, 401525903: sdv-py first retypes an
untyped two-point try (that relabel is ported below, and the game now matches).
cfbd_pbp_data() carries none of the fields the repairs read, so it runs in
plain id order. Its drive and play counters used to run in clock order instead,
and about 9% of 2024 games get a different previous play for some rows. Measured
against sdv-py's order on 270 ESPN games, clock order put 685 plays after the
wrong neighbor, against 151 for id order. engine = "legacy" on either
function is unchanged.
espn_cfb_pbp() (v2) places a play ESPN filed under an id outside the feed's
numbering (a 10-digit <game_id>0 among 18-digit ids) where sdv-py does: right
after the last play before it by period, clock and, on a clock tie, feed position
(sportsdataverse-py#908). The id sort used to make it the game's first play, so it
and its neighbors read the wrong previous play. It happens in 17 of the 23,024
2004-2026 raw games, and the engine's order now equals sdv-py's in all 17.
espn_cfb_pbp() (v2) now models plays under the types sdv-py gives them:
Only the modeled play_type (and yards_to_goal) change; type_text and
start_yards_to_endzone keep ESPN's values. On a 138-game 2004-2025 sample,
play types that differ from sdv-py's fell from 426 to 45 (the rest are sdv-py's
later fumble, return-touchdown and try retypes). sdv-py's four "Extra Point
Missed" string rules are not followed: they mistyped about 71 blocked field
goals, and sdv-py removed them in sportsdataverse-py #642; both keep ESPN's
type. (The retyped touchdowns' down and the 2004 start spots are rebuilt too;
see below.)
espn_cfb_pbp() (v2) drops the plays ESPN files twice, as sdv-py does before
it models them: a stub echo (the play repeated with no start spot; its play type
is kept on the play, 401752854), a stale batch of a drive's plays filed at the
drive's start clock ahead of the same plays at their real clocks, the same play
on both sides of a timeout or period marker, and a play the next row repeats
(same id, or same text and start state). They were modeled as extra snaps. On a
92-game 2004-2026 sample the 19 copies sdv-py drops are gone, no row sdv-py
keeps is lost, and the median EPA gap to sdv-py fell from 0.019 to 0.018. The
epa_wpa = FALSE frame drops them too, so the modeled frame still has exactly
its rows. R still models the end-of-period
and end-of-half marker rows sdv-py drops before its features (213 in that
sample); its engine reads them, a separate difference.
espn_cfb_pbp() (v2) rebuilds the start state sdv-py rebuilds before it models
a play:
The v2 engine books period boundaries as sdv-py does:
ep_after is 0, matching its EPA of
-ep_before (the line meant to do this wrote to a frame the join never read);Together with sportsdataverse-py #646 (a play ends at the next play's clock), v2's EPA agrees with sdv-py's to a median of 0.000 on a 92-game 2004-2026 sample (was 0.015); timeouts off by more than 0.5 fall from 20% to 0%, and the game's last play from 53% to 11%.
The v2 engine books the scores ESPN marks and drops its copies and admin rows, as sportsdataverse-py #651 does:
cfbd_* wrapper added or changed in this cycle now documents its
return value as a column table generated from live output.cfbd_pbp_data(): the score columns (offense_score, pos_team_score,
score_diff, ...) were documented as pre-play; they are after the play, and
score_diff_start / pos_score_diff_start hold the pre-play values.Ten new cfbd_*() wrappers close the last gaps against the CollegeFootballData
API (spec v5.27.1), taking coverage from 74 of 84 endpoints to 84 of 84:
| function | endpoint |
|---|---|
| cfbd_passing_players_season() | /passing/players/season |
| cfbd_passing_players_games() | /passing/players/games |
| cfbd_passing_teams_season() | /passing/teams/season |
| cfbd_passing_teams_games() | /passing/teams/games |
| cfbd_passing_plays() | /passing/plays |
| cfbd_rushing_players_season() | /rushing/players/season |
| cfbd_rushing_players_games() | /rushing/players/games |
| cfbd_rushing_teams_season() | /rushing/teams/season |
| cfbd_rushing_teams_games() | /rushing/teams/games |
| cfbd_rushing_plays() | /rushing/plays |
These carry the charting detail the older cfbd_stats_*() and cfbd_metrics_*()
families do not: passing production split across seven pass locations (short and
deep x left/middle/right, plus unknown), rushing production split across four
run directions, and play-level frames with air yards, yards after catch, target
ids, run direction and carrier attribution.
The frames are wide by construction rather than by accident. A 28-column
passing production block sits beside a 23-column form of it per location, and
the team endpoints repeat the whole thing for offense_ and defense_, so
cfbd_passing_teams_season() is 3 + 2 x (28 + 7 x 23) = 381 columns. Rather
than a 381-row returns table,
?cfbd_passing documents the production block and the bucket naming scheme once
and every column follows from it; ?cfbd_rushing does the same for the
24/26-column rushing block and its four directions.
Two things worth knowing before using them:
*_available columns are denominators, not statistics. CFBD parses
air yards, YAC, location and direction out of play text, so an attempt can be
counted while those stay unknown. Dividing by attempts instead of the
matching *_available column understates every mean. Play-level frames expose
the same caveat as parse_status, location_analysis_eligible and
direction_analysis_eligible. Check a column before building on it: as of
this writing CFBD has not populated play-level air_yards,
yards_after_catch, pass_depth/pass_direction/pass_location or
rush_direction at all — every value is NA, so R types them logical —
while target is populated (1,976 of 2,003 week-5 2025 completions) and the
season aggregates do carry air yards.Five new cfbd_*() wrappers cover the CollegeFootballData punting endpoints
(spec v5.33.2), taking coverage from 88 of 93 endpoints to 93 of 93
(#192, #195):
| function | endpoint |
|---|---|
| cfbd_punting_players_season() | /punting/players/season |
| cfbd_punting_players_games() | /punting/players/games |
| cfbd_punting_teams_season() | /punting/teams/season |
| cfbd_punting_teams_games() | /punting/teams/games |
| cfbd_punting_plays() | /punting/plays |
The player frames carry a 15-column punting_* block and a 10-column
returning_* punt return block (30 columns season, 34 games). The team frames
carry one 24-column block twice: punting_* for the team's own punts and
returning_* for the punts it received, so in a game frame one side's
punting_* matches its opponent's returning_* (51 columns season, 55 games).
cfbd_punting_plays() returns one row per punt (46 columns) with gross, net and
return yardage, where the kick and the play ended, and touchback, fair catch,
downed, blocked, muffed and return-touchdown flags. ?cfbd_punting documents
the blocks once.
*_available columns are denominators. A punt can be counted while
its yardage is not parsed from the play text, so divide by
punting_punt_yards_available, not punting_punts. Return yardage is NA,
not 0, on a punt with no return.Four loaders read the new cfb_groups release on sportsdataverse-data, which
records conference membership as it was each season rather than back-applying
today's alignment:
load_cfb_groups(): one row per subdivision, conference or division
lineage. A lineage keeps one group_id (cfb:pac-12, cfb:sec) across
renames that keep continuity.load_cfb_group_seasons(): each group's name, abbreviation, parent group
and member count as of each season.load_cfb_group_aliases(): the names and ids ESPN and CFBD use for each
group, with the seasons each is valid, for mapping a source's conference id
onto group_id.load_cfb_team_group_seasons(seasons): one row per team per season, with
subdivision, conference and division ids, 1869 onward. team_id is
character: the ESPN id where ESPN covers the team, otherwise the CFBD id.Two more read the provider alias tables in the same tag:
load_cfb_team_aliases(): the team ids and names Fox, Yahoo, CBS, CFBD,
247Sports and On3 use for each ESPN team, with the seasons each holds, for
mapping a provider's team id or name onto the ESPN team_id.load_cfb_player_aliases(): the player ids NCAA (stats.ncaa.org), CBS,
Yahoo and Fox use for each ESPN athlete, with the ESPN team, the seasons,
and how each was matched.The loaders read the tag's parquet files, not its csv copies, so ids stay character instead of being parsed as integers.
The 2025+ ESPN vendor text spots the catch with each school's own abbreviation
(UHM, GSO, USC for South Carolina, Sac St, BC.), which is frequently
not the payload's home_team_abbreviation / away_team_abbreviation. The
helper behind air_yards, air_yardsToEndzone and yards_after_catch now
learns each game's abbreviations from its own "... to the ABC nn" end spots
against yards_to_goal_end (majority vote per abbreviation) before falling
back to the payload abbreviations, reads every observed token shape (UA 10,
BC.41, Sac St10, NC ST19) and resolves a spot at the 50 without one. On
2025 new-template games this lifts in-game air-yards coverage from 72% to 90%
of pass plays; every spot-phrase play now resolves. Same logic as sdv-py #418;
the parity oracle was re-captured from sdv-py 9efee9f1 and extended with five
affected 2025/26 games (tests/testthat/fixtures/parity/README.md).
When xgboost is not installed, or a cfb_model_artifacts booster cannot be
downloaded or read, cfbfastR still falls back to the retired nnet/GAM model,
but now says so: a cfbfastR_legacy_model warning, once per session and model,
names the model in use, why the booster was not used, and the fix (install
xgboost, or check the connection). Those values differ from the current models
and from sportsdataverse-py on every play
(#210). xgboost
stays in Suggests.
wp_before and vegas_wp go through the overtime stage sdv-py scores its WP
with (cfb_wp_overtime.adjust_wp), from wp_ot_reach.ubj and its card in
cfb_model_artifacts. The regulation boosters were trained without overtime
games: in regulation the value is now mixed with the overtime the state may
reach (a tied game late in the fourth no longer overrates the team with the
ball), and an overtime snap is valued by the overtime rules (possession
order, the forced two-point try from the second overtime since 2021) rather
than as the last snap of a tied regulation game. sdv-py's two-point shootout
and end-of-possession stages are not ported, and the fourth-down and
two-point decision surfaces still score regulation WP
(#211).cp uses the air-yards CP booster (cfb_cp_model_air_yards.ubj) wherever
air_yards is known, as sdv-py does. Two new columns: cp_model says which
booster scored the play ("air_yards" or "game_state"), and
cp_game_state carries the game-state prediction on every pass play --
aggregate that one, since the two boosters are not on one scale.wp_overtime_oracle.csv.gz, test-parity_wp_overtime.R).period or season by its labels. It
used the level codes, so a caller's factor period hid every overtime
possession. The package's own frames carry numeric periods.update_cfb_db() read the cfbfastR-data games index with utils::read.csv(url()),
which failed on the first slow response. It now goes through
.release_csv_from_url(), the release-read helper the loaders use (150 s
timeout floor, two retries, a warning naming the url, the last error raised when
every try fails), as hoopR and wehoop do.
update_cfb_db(force_rebuild = TRUE) could still lose seasons from your
database: it drops the table and rebuilds only the seasons in the games index,
and a partial index read went through with a warning. .release_csv_from_url()
now downloads the file with libcurl (which honours R's timeout and fails on an
HTTP error page; download.file.method = "curl" did neither) and reads the
local copy. The rebuild reads the index first and strictly: if the read fails or
fread() warns, it stops with an error naming the index url and leaves the
table as it was. A read that fread() warns on fails at once instead of being
downloaded and parsed three times; a failed download is still retried. The
rebuild also stops, with the table untouched, when the index has no seasons from
2014 on, when a full rebuild's index lacks a season the table holds, or when a
season's play-by-play file fails to download: every season is now downloaded
before any rows are deleted. The purge and the write run in one transaction, so
a write that fails puts the purged rows back.
pos_score_pts was the move of the possessing team's running margin, so a feed
whose score fell to 0 for a few rows and came back read values like -70
(cfbfastR-data#15).
It is now each team's gain in its running-best score on the play, the
possessing team's minus the other's (a touchdown is 6, a defensive touchdown or
safety negative), the definition sportsdataverse-py adopts in
sportsdataverse-py#1089.
cfbfastR now loads every published CFB dataset on the
sportsdataverse-data
release repo, closing the gap with sportsdataverse-py's loader surface. All
loaders return cfbfastR_data-tagged tibbles, accept seasons = TRUE for the
full published range, and support the dbConnection/tablename database
write-through.
load_espn_cfb_*() — 27 loaders for the ESPN-derived family: pbp,
schedules, team_box, player_box, drives, game_rosters,
linescores, betting, play_participants, power_index, percentiles,
passing, rushing, receiving, team_summaries, model_pbp, and the
eleven adv_* advanced-stats datasets.load_cfb_ratings(), load_cfb_ratings_weekly(), load_cfb_fpi_weekly(),
load_cfb_team_summaries_weekly(), load_cfb_team_talent(),
load_cfb_recruits(), load_cfb_recruiting_proj(),
load_cfb_returning_production() — season/weekly ratings, talent, and
recruiting datasets.load_cfb_schedule_crosswalk(), load_cfb_teams_crosswalk(),
load_cfb_rosters_crosswalk() — CFBD/ESPN id crosswalks.load_ncaa_mfb_*() — 10 loaders for the stats.ncaa.org men's football
family (covers FCS and lower divisions): pbp, pbp_cfbfastr (the
cfbfastR-schema-shaped variant), drives, linescore, officials,
player_stats, rosters, schedule, team_stats, teams.parquet_from_url() helper backs the parquet-only datasets
(requires the suggested arrow package).Which play-by-play loader do I want? Nothing is deprecated — the classic functions are unchanged; the new families add sources:
load_cfb_pbp() (unchanged) — the classic cfbfastR EPA/WPA play-by-play,
FBS 2014+.load_espn_cfb_pbp() (new) — the ESPN-derived play-by-play (469 columns,
EPA/WPA + participant ids), 2004+.load_ncaa_mfb_pbp() (new) — stats.ncaa.org play-by-play incl. FCS and
lower divisions, 2013+; load_ncaa_mfb_pbp_cfbfastr() is the same data
reshaped onto cfbfastR pbp column conventions for cross-source binds.This release adds a 65-function ESPN college-football API layer, expanding cfbfastR's ESPN surface from 8 wrappers to 73. The new wrappers expose ESPN's core-v2 endpoints in ESPN's own ID space — complementary to the CollegeFootballData (cfbd_*) wrappers, and the natural join partners for espn_cfb_pbp() / espn_cfb_scoreboard(). Every wrapper was verified live against the 2023, 2024, and 2025 seasons.
Naming alignment with the sportsdataverse convention (this dev cycle, never on CRAN): espn_cfb_player_statistics() is renamed to espn_cfb_player_career_stats() (the core-v2 /athletes/{id}/statistics career view, matching hoopR/wehoop/sportsdataverse-py). New espn_cfb_player_stats_v3() wraps the comprehensive web-common-v3 /athletes/{id}/stats payload (all categories, long format) — the _v3 companion to espn_cfb_player_stats() (core-v2 season statistics).
A read-only Fox Sports "Bifrost" college-football layer (api.foxsports.com/bifrost/v1/cfb/*), complementary to the espn_cfb_* and cfbd_* families. Eight wrappers flatten Fox's layout-oriented JSON (sections → tables → rows → cells) into tidy cfbfastR-tagged tibbles. Verified live against the 2025 season.
fox_cfb_pbp() — game play-by-play (quarters → drives → plays), one row per play.fox_cfb_boxscore() — per-team player stat tables, tidy long (one row per player-stat).fox_cfb_odds() — matchup six-pack (spread / to-win / total) per team.fox_cfb_team_roster() — roster by position group, one row per player.fox_cfb_team_stats() — team stat leaders by category.fox_cfb_team_gamelog() — per-game team stats, tidy long (game × category × stat), each game keyed to its Fox event id.fox_cfb_standings() — a team's conference standings table.fox_cfb_league_leaders() — statistical leaderboards by category (stats-con).espn_cfb_powerindex() — ESPN's College Football Power Index (FPI): every predictive metric and efficiency component, in long format.espn_cfb_qbr() — Total Quarterback Rating (QBR) and the full set of clutch-weighted EPA components, one row per qualified passer.espn_cfb_futures() — the season betting-futures board (national championship, conference, and award markets) with each sportsbook's American odds.espn_cfb_recruits() — ESPN's recruiting board for a class, one row per recruit with grade, position/state/region rank, committed school, and hometown.espn_cfb_players(), espn_cfb_player(), espn_cfb_player_eventlog(), espn_cfb_player_gamelog(), espn_cfb_player_statistics(), espn_cfb_player_splits(), espn_cfb_player_overview(), and espn_cfb_player_seasons() — player index, biographical detail, per-game logs, season statistics, and split breakdowns. The season-level wrappers resolve athlete_id to human-readable name/position columns via an athlete_detail argument.espn_cfb_teams(), espn_cfb_team(), espn_cfb_team_roster(), espn_cfb_team_schedule(), espn_cfb_team_record(), and espn_cfb_team_leaders() — team index, team-in-season detail, roster, schedule, records, and statistical leaders.espn_cfb_team_ats(), espn_cfb_team_powerindex(), espn_cfb_team_events(), espn_cfb_team_ranks(), espn_cfb_team_awards(), and espn_cfb_team_coaches() — team against-the-spread records, single-team power index, season event log, poll-rank history, player awards, and coaching staff.espn_cfb_game_teams(), espn_cfb_game_team_linescores(), espn_cfb_game_team_leaders(), espn_cfb_game_team_roster(), espn_cfb_game_team_statistics(), and espn_cfb_game_team_records() — per-game team breakdowns.espn_cfb_game_odds(), espn_cfb_game_broadcasts(), espn_cfb_game_predictor(), espn_cfb_game_probabilities(), espn_cfb_game_powerindex(), and espn_cfb_game_pbp() — per-game odds, broadcasts, pre-game predictor, live win-probability, matchup power index, and play-by-play.espn_cfb_game_drives(), espn_cfb_game_drive_plays(), espn_cfb_game_play(), espn_cfb_game_leaders(), espn_cfb_game_situation(), espn_cfb_game_status(), espn_cfb_game_player_statistics(), and espn_cfb_game_player_box() — drive log, drive-scoped plays, single-play detail, game statistical leaders, situation, status, and per-player game box lines.espn_cfb_game_pbp(), espn_cfb_game_drive_plays(), espn_cfb_game_play()) extract every field ESPN returns for a play and expose its nested child collections through opt-in parameters: participants ("none"/"wide"/"long") and participants_list surface per-play athlete involvement (passer, rusher, tackler, …), and team_participants / team_participants_list surface the offense/defense team participants — "wide" modes pivot to one row per play, the *_list flags keep the raw detail as a list-column.espn_cfb_game_team_records(detail = TRUE) unpacks each record's full statistic breakdown; espn_cfb_game_odds(line_history = TRUE) returns the open/close/current line-movement history. Roster and player-stats wrappers join ESPN position-catalog detail when position_detail = TRUE.espn_cfb_game_drives() gains a plays argument — "list" nests each drive's plays (full play-by-play schema, with the participant pass-through options) as a list-column, "expand" returns the flat one-row-per-play table with drive_* context columns. The new espn_cfb_unnest_plays() performs the same drives-to-play-by-play transform on an already-fetched nested frame.team_detail = TRUE (default): every team-id column (team_id, home_team_id, start_team_id, leader_team_id, …) gains sibling *_name, *_abbreviation, *_location, *_display_name, *_color, *_logo_href, … columns from the ESPN team catalog. espn_cfb_game_teams(format = "wide") collapses the two competitor rows into a single per-game row with home_* / away_* columns for direct joining onto one-row-per-game tables.espn_cfb_pbp_v2() — a core-v2-sourced successor to espn_cfb_pbp(): assembles play-by-play in one structured request (vs. the legacy site-v2 summary parse) and, with epa_wpa = TRUE, runs cfbfastR's full EPA/WPA model pipeline — producing EPA/WPA columns identical to the legacy modeled feed.espn_cfb_seasons(), espn_cfb_season_info(), espn_cfb_season_types(), espn_cfb_season_weeks(), espn_cfb_groups(), and espn_cfb_standings() — season structure, conferences, and standings.espn_cfb_coaches(), espn_cfb_coach(), espn_cfb_venues(), espn_cfb_positions(), espn_cfb_awards(), espn_cfb_rankings(), and espn_cfb_week_rankings() — league catalogs and poll rankings.espn_cfb_coach_record(), espn_cfb_franchises(), espn_cfb_franchise(), espn_cfb_venue(), espn_cfb_position(), and espn_cfb_award() — coach season win/loss records, the league franchise catalog, and single-record venue / position / award detail.yahoo_cfb_player_season_stats(), yahoo_cfb_team_season_stats(), yahoo_cfb_player_season_stats_legacy(), yahoo_cfb_team_season_stats_legacy(), yahoo_cfb_scoreboard(), and yahoo_cfb_boxscore() (scaffold), wrapping Yahoo's shangrila stats graph and editorial feed.cfbd_betting_ats() — season against-the-spread (ATS) summary records by team, wrapping the CollegeFootballData /teams/ats endpoint.cfbd_stats_game_havoc() — per-game havoc statistics (total / front-seven / defensive-back havoc events and rates, offense and defense), wrapping the CollegeFootballData /stats/game/havoc endpoint.cfbd_pbp_data_v2() is a new public function: a modular successor to cfbd_pbp_data() that runs the same EPA/WPA pipeline through a single shared engine (.run_epa_wpa()) and a canonical play-type taxonomy (.pbp_play_types()). The legacy cfbd_pbp_data() is unchanged.espn_cfb_pbp_v2() now sources play-by-play and meta through the shared engine, requests participants = "wide" and team_participants = "wide" from espn_cfb_game_drives(), and adds the meta columns home_team_name, home_team_color, home_team_alternate_color, home_team_rank (and away_*) via the new .espn_pbp_game_meta() bridge. Output is a strict superset of legacy espn_cfb_pbp() on the meta columns.cfbd_pbp_data() and espn_cfb_pbp() now run the v2 engine by default. Both gain penalty enforcement resolution, ESPN-resolved player names, the *_player_id columns and the output tier selector without a code change. The previous behavior is one argument away — engine = "legacy" per call, or options(cfbfastR.pbp_engine = "legacy") for a session — and a once-per-session message says so. engine = "auto" continues to mean "whatever this release considers current". tests/testthat/test-pbp_equivalence.R asserts v2 reproduces the legacy frames column-for-column, with an explicit allow-list of intentional deltas.
Play-by-play now overwrites the regex-extracted *_player_name values with ESPN's own participants[] names (2014 onward), so a capture that trailed narration ("Rod Smith 3 Yd"), abbreviated, or carried a team code becomes the real name. Ported from sportsdataverse's CFBPlayProcess.__join_participants and verified against a 60-game offline oracle (5 games from each of 2004, 2006, 2008, 2010, 2013, 2014, 2017, 2019, 2020, 2021, 2023, 2025, including the postseason): 1,236 of 9,545 × 11 name cells change, with zero divergence from the Python. The stage runs before id resolution, so the roster matcher gets a clean key rather than narration.
espn_cfb_pbp_v2() gains resolve_names (default TRUE). When epa_wpa = TRUE it spends one memoized request per game on ESPN's play-by-play sidecar, which supplies (a) full athlete names — "Jalen Mitchell" rather than the core-v2 roster's "J. Mitchell" — and (b) the per-player box score as a second identity source. The box score is the only identity source on the large share of games where ESPN 404s the roster resource; adding it cuts *_player_id divergence from sdv-py from 52/163,656 to 26/166,482 — fewer mismatches over more plays. Set resolve_names = FALSE for a bulk sweep that would rather have the short names than the requests.
Play-by-play now resolves which team each event belongs to, adding 30 columns cfbfastR could not previously produce: the special-teams flip (kicking_team, return_team, punt_return_team, kick_return_team, fg_team, punt_team), the event-credit columns (sack_team, interception_team, pass_breakup_team, forced_fumble_team, fumble_recovery_team), the fumble/recovery chain (fumble_or_muff, fumbling_team, recovery_team, recovery_team_2), the per-side turnover model (is_turnover, turnover_team, int_turnover, pos_fumble_lost, def_fumble_lost, is_pos_team_turnover, is_def_pos_team_turnover, is_st_turnover, is_blocked_punt_turnover, is_blocked_fg_turnover), penalty attribution (penalized_team, penalty_team_id, penalty_yards_signed) and the id-keyed pos_team_id / def_pos_team_id. Ported from sportsdataverse's CFBPlayProcess.__add_attribution_cols and verified against the 60-game offline oracle at zero divergence over 267,260 cells.
The turnover flags are framed per side because one play can lose the ball twice — the offense fumbles, the defense recovers and fumbles back — and a single boolean cannot say that both teams turned it over. Blocked punts and blocked field goals deliberately stay out of is_turnover: ESPN's official box counts only giveaways, so folding them in would break the reconciliation against it. They get their own flags instead.
On the ESPN path, ESPN's own per-play turnover flag is preserved as espn_is_turnover rather than being silently overwritten. The two legitimately differ — ESPN's also fires on blocked kicks.
Play-by-play gains air_yards, air_yardsToEndzone and yards_after_catch, splitting a completed pass into the yards thrown and the yards run after the catch. ESPN states the catch point as "caught at OU35"; the stated yard line belongs to whichever team owns that side of the field, so the abbreviation is sided against the possessing and defending teams with the same prefix-tolerant matcher the recovery and penalty teams use. yards_after_catch is computed for completions only. Ported from sportsdataverse's CFBPlayProcess.__add_air_yards_cols.
One deliberate divergence from the Python: sdv-py's pattern has no article, so it resolves "caught at OU35" and silently drops "thrown to the ARK30". Both forms occur in ESPN's text — and cfbfastR's core-v2 feed uses the article form almost exclusively, where a verbatim port would have matched nothing at all. This implementation accepts both. The parity test is partitioned accordingly: exact agreement on the 137 oracle rows sdv-py resolves, plus an explicit assertion that the 7 article-form rows it drops are recovered here.
Play-by-play gains pass_depth, pass_direction, rush_direction and qb_hurry, read from the play text ("short"/"deep", "left"/"middle"/"right", and ESPN's "QB hurried by" annotation). Null where ESPN omits the phrase — sacks, screens, and older seasons that never annotated depth or direction.
espn_cfb_pbp_v2() now refuses to run the EPA/WPA models on an obviously malformed feed — no plays, or implausibly few or many for a game that has finished — and returns the unmodeled frame with a warning instead. A truncated game models perfectly cleanly and produces EPA, drive results and a box score that all look reasonable and are all wrong, and nothing downstream can distinguish that from a real blowout. The count rules apply only to completed games so a live feed is never rejected. Ported from CFBPlayProcess.corrupt_pbp_check.
A pbp-to-boxscore parity gate now guards the test suite. The per-play parity tests check a play against itself; this aggregates play-by-play by team and compares it against ESPN's own team box, which is the only cheap end-to-end judge of whether parsing put the right events on the right team. An attribution bug leaves every per-play assertion green and shows up here immediately. Ported from sportsdataverse's tools/validation/checks/boxscore_parity, adapted from a per-season harness check to a per-game library gate.
It encodes three conventions proven against the box, two of which are the opposite of the NFL's: NCAA charges a sack to rushing (attempt and yardage both), pass attempts exclude sacks, and a penalty belongs to the team that committed it — a positive penalty_yards_signed means the offense gained, so the defense was flagged. Floors are measured from the shipped 60-game corpus, never guessed, and are per-stat because parity is strongly era-dependent (interceptions reconcile at 97%, 2004-inclusive rushing yardage at 31%).
Audited against the CollegeFootballData OpenAPI spec (5.24.1, 74 endpoints).
15 endpoints that had no wrapper now have one: cfbd_playoffs_cfp(), cfbd_playoffs_cfp_games(), cfbd_playoffs_cfp_participants(), cfbd_conference_affiliations(), cfbd_conference_changes(), cfbd_coaches_profile(), cfbd_coaches_seasons(), cfbd_coaches_tenures(), cfbd_ratings_core(), cfbd_ratings_srs_expanded(), cfbd_teams_fbs(), cfbd_stats_player_success(), cfbd_stats_player_success_game(), cfbd_player_season_overview() and cfbd_info_usage(). Every one was exercised against the live API before being committed.
26 parameters added to existing wrappers — most importantly division on ten more functions, plus defense / offense_conference / defense_conference / conference / division on cfbd_pbp_data(), competition and round on cfbd_game_info() (College Football Playoff filtering), line_provider on cfbd_betting_lines(), conference on cfbd_play_stats_player() and recruit_type on cfbd_recruiting_position(). cfbd_conferences() previously took no arguments at all and now accepts year and division.
New validate_division() covering fbs / fcs / ii / ii/iii / iii. This validates locally because CFBD ignores an unrecognized filter value rather than rejecting it — so without it a typo silently returns every division.
Two spec parameters were deliberately not exposed after testing them: /rankings declares latest and final as booleans, but the API returns HTTP 400 for every form of both. poll is validated to "cfp", the only value it accepts.
espn_cfb_team_coaches() — the year argument is deprecated. ESPN's core-v2 coaches endpoint returns the current coach whatever season is requested, echoing the requested year back in the response, so historical calls silently returned today's coach labeled with the old season (#125). year now defaults to most_recent_cfb_season(); passing any other season warns and is coerced, rather than returning misattributed data. Existing calls keep working.
espn_cfb_teams() returned zero rows, because site.api.espn.com now answers HTTP 403 to a spoofed browser User-Agent. The failure was silent — the wrapper caught it and returned an empty frame — and every consumer degraded to NA, which took home, away, pos_team, def_pos_team, offense_play, defense_play and every team abbreviation on the ESPN play-by-play path down with it. Measured 2026-08-19: the endpoint answers 200 with httr2's default UA, with curl/8.5.0, or with Accept/Origin/Referer and no UA at all, and 403 with the Chrome string. The User-Agent header is dropped.
Probing every ESPN host the package uses narrowed the blast radius to exactly two callers — espn_cfb_teams() and espn_cfb_team_schedule(), the only two that combine site.api.espn.com with the spoofed header. espn_cfb_team_schedule() was returning zero rows for the same reason and now returns data. The other ~65 occurrences of the header sit on sports.core.api.espn.com and site.web.api.espn.com, which answer 200 either way, so they are left alone.
Worth knowing for anyone adding an ESPN call: cdn.espn.com does something worse than a 403 under the browser UA — it answers 200 with a zero-byte body, so nothing raises and the parse silently yields nothing. tests/testthat/test-espn_http_headers.R is a source-level guard that fails if the header is re-added to a site.api caller.
defense_play was a copy-paste duplicate of offense_play in the ESPN adapter — both case_when() branches returned the home team — so it named the team with the ball on every ESPN play.
Play-by-play gains pos_team_id / def_pos_team_id / offense_play_id / defense_play_id. pos_team, def_pos_team, offense_play and defense_play are team NAMES resolved through the ESPN teams catalog, and when that catalog is unavailable they all go NA together — which silently disabled team-aware roster matching, dropping every player-id lookup to the global-unique fallback. The ids come straight off the play and are always present. (espn_cfb_teams() currently returns zero rows, so this is the live condition, not a hypothetical.)
.espn_cfb_participant_roster() is now memoized alongside the ESPN catalog helpers. espn_cfb_pbp_v2() needs one game's roster twice — once to name participants, once to resolve player ids — and a season sweep asked for it once per game; both now cost a single request. The memoized-helper list is a single constant shared by .onLoad() and espn_cfb_clear_cache(), which had been a second hand-maintained copy that could silently drift into caching a helper it never cleared.
.run_epa_wpa_by_game() had no roster argument, so cfbd_pbp_data_v2() could never resolve *_player_id columns however the roster was supplied. It now threads rosters and participants through, sliced per game_id.
espn_cfb_pbp() now builds its request URL with the ?event= query separator (previously concatenated as summaryevent=, which returned HTTP 404 for every game) and initializes its return frame before the tryCatch so an upstream failure no longer throws object 'plays_df' not found.
cfbd_pbp_data_v2() and espn_cfb_pbp_v2() preserve character id_play precision through the EPA/WPA pipeline. The legacy shared helper used unquoted numeric literals in two ifelse calls (a historical id_play swap for one game), which silently coerced character id_play to numeric and then lost precision past 2^53 — breaking the play-id join-back in espn_cfb_pbp_v2(). The modular .pbp_clean_pbp_dat() quotes those literals so id_play stays character; the legacy clean_pbp_dat() is unchanged.
httr2 package (>= 1.0.0) instead of the legacy httr. End users running existing wrapper calls (cfbd_*, espn_cfb_*) should see no behavioral change -- the migration is internal. Custom code that calls get_req() or check_status() directly must update from httr::content(res, as = "text") to httr2::resp_body_string(res) and from httr::status_code(res) to httr2::resp_status(res).get_req() now resolves a proxy in the order: explicit proxy argument -> getOption("cfbfastR.proxy") -> http_proxy / https_proxy env vars. The proxy value accepts either a URL string or a named list with url / port / username / password / auth for authenticated proxies.lubridate, progressr, memoise, cachem, and magrittr have moved out of Imports (21 -> 16). lubridate is gone entirely -- its two ymd_hm() |> with_tz() calls in espn_cfb_schedule.R are now base-R as.POSIXct(format = "%Y-%m-%dT%H:%M", tz = "UTC") + attr(., "tzone"). progressr, memoise, and cachem moved to Suggests and the helpers degrade gracefully when missing: load_cfb_pbp() / cfbd_pbp_data() / pbp_epa_wpa_engine() run without a progress bar when progressr is absent; ESPN catalog wrappers run uncached when memoise / cachem are absent (espn_cfb_clear_cache() becomes a no-op). Drops the Imports count below the >20 R CMD check NOTE threshold.%>% chains in R/, plus 137 across vignettes/ and tests/, were converted to the base-R native pipe |>. magrittr is no longer an Imports; downstream consumers that load cfbfastR purely for its functions don't get %>% re-exported anymore. User-visible impact is minimal -- the public API is unchanged and dplyr (which is in Imports) still re-exports %>% for users who want to keep writing it. Two non-mechanical fixes were needed during the sweep: three |> [[("url") chains in cfbd_betting.R and cfbd_coaches.R (rejected as RHS in R 4.1's |>) became |> purrr::pluck("url"); seven |> tibble::tibble(col = .data$.) constructs were a magrittr quirk that silently duplicated the LHS into both a . and the named column -- rewritten to tibble::tibble(col = <lhs>), which drops the redundant . column.tests/testthat/setup-cfbd-throttle.R adds a 1-second sleep before every CFBD request made by devtools::test() / R CMD check. It works by monkey-patching cfbfastR:::get_req() for the duration of the test session (restored via withr::defer(., teardown_env())) -- the package code is unchanged, so interactive and production calls pay no penalty. Tunable via options(cfbfastR.test_request_delay = N) (default 1; set to 0 for unthrottled local runs). Resolves the cascading HTTP 429 skip-if-empty results that were turning otherwise-green test runs into "all green, mostly skipped." withr joins Suggests to declare the test-side dependency cleanly (it was already a transitive dep of testthat).validate_week() utility function where some inputs were not being handled correctly (i.e. week 16). Fixes trickle down to cfbd_pbp_data() and other functions.season_type parameter in cfbd_game_info() and cfbd_play_stats_player() function changed from "regular" to "both" to align with other functions in the package.cfbd_pbp_data() where play-by-play data for some games were not as expected.add_yardage() where plays with missing yardage values were not being handled correctly.load_cfb_*() functions now use sportsdataverse-data releases or the CollegeFootballData.com API as their underlying data source to remain in compliance with CFBD API terms and conditions (See Note below).load_cfb_pbp() dataset to include various team- and game-level ID's and flags that were not being included, like home_team_id, away_team_id, season_type, venue_id, some drive_* columns, a half-dozen player stat columns, etc. Essentially, all the leg-work users have undoubtedly had to do while using these datasets is mostly just included now. The downside: this means end users need to check their pipelines which build off these datasets to ensure behavior is as expected and all your joins are doing what is intended.Special thanks are in order for our newest contributor, Brad Hill (@bradisbrad) for providing most of the v2 upgrade via his first PR to cfbfastR!! 🙌🏽 👑 🥇 Your contributions are most appreciated by the community.
Note: The free-tier API key for the CFBD v2 API has a strict 1k calls/month limit, so plan your workflows accordingly! If you receive errors mentioning r Request failed [429], you have most likely run out of API calls for the month in your membership tier.
Added all new cfbd_*() functions accommodated by the new College Football Data API v2. This includes the following functions:
cfbd_metrics_fg_ep() function to access the new field goal expected points added metric from the API.cfbd_metrics_wepa_team_season() function to access the new opponent adjusted team season predicted points added metric from the API.cfbd_metrics_wepa_players_passing() function to access the new opponent adjusted players passing predicted points added metric from the API.cfbd_metrics_wepa_players_rushing() function to access the new opponent adjusted players rushing predicted points added metric from the API.cfbd_metrics_wepa_players_kicking() function to access the new Points Added Above Replacement (PAAR) ratings for kickers from the API.cfbd_ratings_fpi() function to access the new FPI ratings from the API.cfbd_live_scoreboard() function to access live scoreboard data from the API.cfbd_live_plays() function to access live play-by-play data from the API.cfbd_api_key_info() function to get information about your API key, including your Patreon level and usage limits.Minor changes to the existing cfbd_*() functions under the hood to accommodate the new API v2 structure. Please see below for a list of all updated functions:
cfbd_betting_lines() functioncfbd_coaches() functioncfbd_conferences() functioncfbd_drives() functioncfbd_calendar() functioncfbd_game_box_advanced() functioncfbd_game_info() functioncfbd_game_media() functioncfbd_game_player_stats() functioncfbd_game_records() functioncfbd_game_team_stats() functioncfbd_metrics_ppa_games() functioncfbd_metrics_ppa_players_games() functioncfbd_metrics_ppa_players_season() functioncfbd_metrics_ppa_predicted() functioncfbd_metrics_ppa_teams() functioncfbd_metrics_wp() functioncfbd_metrics_wp_pregame() functioncfbd_pbp_data() functioncfbd_play_stats_player() functioncfbd_play_stats_types() functioncfbd_play_types() functioncfbd_plays() functioncfbd_player_info() functioncfbd_player_returning() functioncfbd_player_usage() functioncfbd_rankings() functioncfbd_ratings_sp() functioncfbd_ratings_sp_conference() functioncfbd_ratings_srs() functioncfbd_recruiting_player() functioncfbd_recruiting_position() functioncfbd_recruiting_team() functioncfbd_stats_categories() functioncfbd_stats_game_advanced() functioncfbd_stats_season_advanced() functioncfbd_stats_season_player() functioncfbd_stats_season_team() functioncfbd_team_info() functioncfbd_team_matchup() functioncfbd_team_matchup_records() functioncfbd_team_roster() functioncfbd_team_talent() functioncfbd_venues() functionFixed the following functions and/or documentation:
cfbd_team_info() addressing #97cfbd_stats_game_advanced() returns an empty data frame when there are no resultscfbd_game_team_stats() updated to reflect all parameter requirement scenarios.athlete_id parameter cfbd_player_usage() so that it works as users would expect. There was an API query-parameter mismatchathlete_id parameter for cfbd_play_stats_player() function and added more thorough documentation.position to correct value (instead of NA) from cfbd_stats_season_player()season_type parameter documentation across many functionscfbd_pbp_data() to substitute 3 timeouts per half when the data is missing from the API.stringi v1.8 update in cfbd_play_pbp_data() EPA and WPA processingespn_cfb_scoreboard() and espn_cfb_schedule() functions while adding lubridate dependencyespn_ratings_fpi() functionespn_cfb_player_stats() function added.cfbd_game_team_stats() function return data in a long formatespn_cfb_calendar()espn_cfb_schedule()espn_cfb_pbp()espn_cfb_team_stats()furrr, future dependencies, adds Rcpp, RcppParallel, and purrr dependenciesespn_ratings_fpi() to exports.cfbd_game_team_stats() with _allowed columns duplicating team stats instead of showing opponent stats.cfbd_game_team_stats().cfbd_pbp_data() to account for additional timeout cases (namely, kickoffs/extra point attempts).cfbd_betting_lines()espn_ratings_fpi() now requires headers in httr requestcfbd_ratings_elo() functionupdate_cfb_db() where the function failed when trying to load recent games from the data repo. (#35)cfbfastR.dbdirectory that allows to set the database directory in update_cfb_db() globally.cfbd_stats_season_team() that were not behaving correctlyonly_fbs input in cfbd_team_info() was ignored. It is now possible to get the team info for all the colleges in the API instead of only FBS schools.cfbd_metrics_ppa_teams. cfbd_metrics_ppa_teams and cfbd_metrics_ppa_players_season now require one of team or year to be specifiedcfbd_draft_teams() - Get list of NFL teams
cfbd_draft_positions() - Get list of NFL positions for mapping to collegiatecfbd_draft_picks() - Get list of NFL Draft picksAdded headshot_url to outputs of cfbd_team_roster()
Renamed returns in cfbd_game_box_advanced():
rushing_line_yd_avg to plural rushing_line_yds_avgrushing_second_lvl_yd_avg to plural rushing_second_lvl_yds_avgrushing_open_field_yd_avg to plural rushing_open_field_yds_avgCompleted documentation for all returns except cfbd_pbp_data()
Continued work on intro vignette
Added mini-vignettes pertaining to CFB Data functionality:
id variable to team_id in espn_ratings_fpi()espn_game_id variable to game_id in espn_metrics_wp(), corrected the away_win_percentage calculation and added tie_percentage to the returns.id variable to athlete_id in cfbd_metrics_ppa_players_season()load_cfb_pbp() and update_cfb_db() functions. Pretty much cherry-picking the nflfastR methodology of loading data from the cfbfastR-data repository.furrr, future, and progressr dependencies to the package to allow for parallel processing of the play-by-play data with progress updates if desired.All functions sourced from the College Football Data API will start with cfbd_ as opposed to cfb_ (as in cfbscrapR). One additional cfbd_ function has been added that corresponds to the result when cfbd_pbp_data() has the parameter epa_wpa=FALSE. It has now been separated into its own function for clarity cfbd_plays(). The parameter and functionality still exists in cfbd_pbp_data() but we expect this function will still exist but made obsolete in favor of a function more closely matching nflfastR's naming conventions.
Similarly, data and metrics sourced from ESPN will begin with espn_ as opposed to cfb_. In particular, the two functions are now espn_ratings_fpi() and espn_metrics_wp()
Data generated from any of the cfbfastR methods will use cfb_
The CollegeFootballData API now requires an API key, here's a quick run-down:
To get an API key, follow the directions here: College Football Data Key Registration.
Using the key: You can save the key for consistent usage by adding CFBD_API_KEY=XXXX-YOUR-API-KEY-HERE-XXXXX to your .Renviron file (easily accessed via usethis::edit_r_environ()). Run usethis::edit_r_environ(), a new script will pop open named .Renviron, THEN paste the following in the new script that pops up (without quotations)
CFBD_API_KEY = XXXX-YOUR-API-KEY-HERE-XXXXX
Save the script and restart your RStudio session, by clicking Session (in between Plots and Build) and click Restart R (n.b. there also exists the shortcut Ctrl + Shift + F10 to restart your session). If set correctly, from then on you should be able to use any of the cfbd_ functions without any other changes.
CFBD_API_KEY (with quotations) using a command like the following.Sys.setenv(CFBD_API_KEY = "XXXX-YOUR-API-KEY-HERE-XXXXX")