Skip to contents
On this page

A season average is slow to move. A team’s form changes faster: the offense that struggled in September can look different over its last two games or so. This recipe compares each team’s last 150 offensive plays with the 150 before them, then lists the five biggest risers and the five biggest fallers.

There is no NBA rolling-window release yet, so this recipe is college football only.

library(sdvplotR)
library(gt)
library(dplyr, warn.conflicts = FALSE)

The data

The cfb_rolling_windows release has one file per season and no R loader, so read it by URL. nflreadr::load_from_url() reads the .rds copy with packages the site already uses. The .parquet and .csv copies hold the same rows.

url <- paste0(
  "https://github.com/sportsdataverse/sportsdataverse-data/releases/download/",
  "cfb_rolling_windows/rolling_windows_2026.rds"
)
windows <- as.data.frame(nflreadr::load_from_url(url))
stopifnot(nrow(windows) > 0)
dim(windows)
#> [1] 25228    20
names(windows)
#>  [1] "season"          "entity_type"     "entity_id"       "entity_name"    
#>  [5] "team_id"         "metric"          "window_unit"     "window_n"       
#>  [9] "cur"             "prev"            "season_start"    "career_baseline"
#> [13] "delta_prev"      "delta_season"    "delta_career"    "delta_prev_rank"
#> [17] "n"               "qualified"       "last_event_date" "as_of_date"

Each row is one player or team, one metric and one window size.

  • cur is the metric over the last window_n events (plays, carries, dropbacks or targets, named in window_unit).
  • prev is the metric over the window_n events before those.
  • delta_prev is cur - prev, and delta_prev_rank ranks it (1 is the biggest rise).
  • n is how many events the last window holds. A row is qualified once that window is full.
count(windows, entity_type, metric, window_unit, window_n)
#>    entity_type       metric window_unit window_n    n
#> 1       player          epa       carry       50 2044
#> 2       player          epa       carry      100 2044
#> 3       player          epa    dropback       50  626
#> 4       player          epa    dropback      100  626
#> 5       player          epa    dropback      300  626
#> 6       player          epa      target       30 3089
#> 7       player          epa      target       60 3089
#> 8       player success_rate       carry       50 2044
#> 9       player success_rate       carry      100 2044
#> 10      player success_rate    dropback       50  626
#> 11      player success_rate    dropback      100  626
#> 12      player success_rate    dropback      300  626
#> 13      player success_rate      target       30 3089
#> 14      player success_rate      target       60 3089
#> 15        team          epa        play      150  235
#> 16        team          epa        play      300  235
#> 17        team success_rate        play      150  235
#> 18        team success_rate        play      300  235

This file is the season in progress. The date shows how current it is:

as_of <- unique(windows$as_of_date)
stopifnot(length(as_of) == 1)
as_of
#> [1] "2026-10-10"

Risers and fallers

Keep teams, EPA per play and the 150-play window, and only qualified rows that also have a previous window: a program with no earlier plays has no change to rank. The percentile places each team’s last 150 plays among every team in the same pool, so a big riser can still be below average.

pool <- windows |>
  filter(
    entity_type == "team", metric == "epa", window_n == 150,
    qualified, !is.na(prev)
  ) |>
  mutate(pct = 100 * percent_rank(cur))
stopifnot(all(pool$n == 150), !anyDuplicated(pool$entity_id))
nrow(pool)
#> [1] 228

movers <- bind_rows(
  Risers = slice_max(pool, delta_prev, n = 5, with_ties = FALSE),
  Fallers = slice_min(pool, delta_prev, n = 5, with_ties = FALSE),
  .id = "group"
) |>
  select(group, entity_name, prev, cur, n, pct, delta_prev_rank)
movers
#>      group                 entity_name        prev          cur   n       pct
#> 1   Risers     Louisiana Tech Bulldogs -0.26370043  0.339675717 150 98.237885
#> 2   Risers             Kansas Jayhawks -0.17726527  0.328040471 150 97.356828
#> 3   Risers          UL Monroe Warhawks -0.07478280  0.269625081 150 95.594714
#> 4   Risers       Texas Southern Tigers -0.34182483 -0.023425918 150 51.541850
#> 5   Risers Southern Miss Golden Eagles -0.07446965  0.231102587 150 91.189427
#> 6  Fallers      Northern Iowa Panthers  0.27039169 -0.273207534 150  9.251101
#> 7  Fallers        Holy Cross Crusaders  0.30123470 -0.204168123 150 18.061674
#> 8  Fallers   Eastern Washington Eagles  0.14616080 -0.271634752 150  9.691630
#> 9  Fallers   Northwestern State Demons -0.13158858 -0.543159627 150  0.000000
#> 10 Fallers         Texas State Bobcats  0.38671371 -0.007728742 150 54.185022
#>    delta_prev_rank
#> 1                1
#> 2                2
#> 3                3
#> 4                4
#> 5                5
#> 6              228
#> 7              227
#> 8              226
#> 9              225
#> 10             224

The pool holds every team the release covers, FCS programs as well as FBS.

The table

gt_delta() computes the change from the two rate columns, so the table shows the arithmetic it ranks. Every rate shows its sample: the earlier window is 150 plays by definition, and the last window’s n sits next to its rate. The change column’s green and red are not the only cue, since every change also carries its sign. Pass color_positive and color_negative to gt_delta() for a pair that does not rely on red-green vision.

movers |>
  gt(groupname_col = "group") |>
  gt_sdv_logos(entity_name, league = "cfb", height = 22, include_name = TRUE) |>
  gt_delta(prev, cur, column_label = "Change", decimals = 3) |>
  fmt_number(c(prev, cur), decimals = 3) |>
  cols_merge(c(cur, n), pattern = "{1} (n = {2})") |>
  gt_percentile_bar(pct, width = 160) |>
  cols_label(
    entity_name = "Team",
    prev = "Previous 150 plays",
    cur = "Last 150 plays",
    pct = "Last 150, percentile",
    delta_prev_rank = "Change rank"
  ) |>
  tab_header(
    title = "Offensive form: last 150 plays vs the 150 before",
    subtitle = paste("EPA per play, 2026 season, as of", as_of)
  ) |>
  tab_source_note("Data: sportsdataverse-data release cfb_rolling_windows") |>
  gt_theme_sdv(density = "compact")
Offensive form: last 150 plays vs the 150 before
EPA per play, 2026 season, as of 2026-10-10
Team Previous 150 plays Last 150 plays Change Last 150, percentile Change rank
Risers
Louisiana Tech Bulldogs −0.264 0.340 (n = 150) +0.603
98
1
Kansas Jayhawks −0.177 0.328 (n = 150) +0.505
97
2
UL Monroe Warhawks −0.075 0.270 (n = 150) +0.344
96
3
Texas Southern Tigers −0.342 −0.023 (n = 150) +0.318
52
4
Southern Miss Golden Eagles −0.074 0.231 (n = 150) +0.306
91
5
Fallers
Northern Iowa Panthers 0.270 −0.273 (n = 150) −0.544
9
228
Holy Cross Crusaders 0.301 −0.204 (n = 150) −0.505
18
227
Eastern Washington Eagles 0.146 −0.272 (n = 150) −0.418
10
226
Northwestern State Demons −0.132 −0.543 (n = 150) −0.412
0
225
Texas State Bobcats 0.387 −0.008 (n = 150) −0.394
54
224
Data: sportsdataverse-data release cfb_rolling_windows

Citation

To cite the sdvplotR R package in publications, use:

BibTeX Citation

@misc{gilani_carl_lee_kay_weatherman_sdvplotR,
  author = {Gilani, Saiem and Carl, Sebastian and Lee, Jared and Kay, Camden and Weatherman, Andrew},
  title = {sdvplotR: The SportsDataverse's R Package for Multi-League Sports Plotting.},
  url = {https://sdvplotR.sportsdataverse.org},
  year = {2026}
}