CFB Team Efficiency

Measuring Team Performance via Opponent Adjusted Expected Points

Author

Phil Henrickson

Published

January 13, 2025

1 Efficiency

I use my model of expected points for college football plays to estimate and measure a team’s overall performance on offense, defense, and special teams. I develop opponent-adjusted measures of team performance by fitting models to partial out the effect of individual teams on expected/predicted points.

1.1 Net Points per Play

Recall that an expected points model aims to measure the value of an individual play by asking how it affected a team’s ability to score points. For an offense this amounts to getting first downs and moving the ball down the field; for a defense it means limiting the other team’s ability to sustain drives and score.

Efficiency refers to the idea that good teams, on average, are net positive on the outcomes of plays - they are typically able to move the ball on offense and stop their opponent’s drives on defense.

Tangibly, we can measure a team’s efficiency by summarizing their net predicted points across all of their plays. For example, the following table shows the the top 5 teams by season in terms of raw overall efficiency for all completed seasons from 2007 to present.

Show the code
raw_efficiency_overall |>
  filter_to_fbs() |>
  filter(metric == 'ppa') |>
  add_overall_efficiency() |>
  add_team_ranks() |>
  group_by(method, season, metric, type) |>
  pivot_wider(names_from = c("type"),
              values_from = c("estimate", "rank")) |>
  group_by(season, method, metric)  |>
  slice_max(estimate_overall, n =5)  |>
  efficiency_tbl(with_estimates = T,
                 with_ranks = T)
team
efficiency
rank
overall offense defense overall offense defense
2007 - raw - ppa
Kansas 0.305 0.177 0.128 1 3 13
Boise State 0.293 0.122 0.171 2 10 5
West Virginia 0.289 0.153 0.136 3 6 11
LSU 0.269 0.118 0.152 4 11 9
Ohio State 0.264 0.092 0.172 5 20 4
2008 - raw - ppa
Florida 0.439 0.267 0.171 1 2 5
USC 0.362 0.169 0.192 2 11 2
Boise State 0.347 0.161 0.186 3 13 3
Penn State 0.346 0.221 0.126 4 7 12
TCU 0.334 0.086 0.248 5 25 1
2009 - raw - ppa
Florida 0.356 0.189 0.168 1 4 10
Alabama 0.326 0.083 0.243 2 23 2
TCU 0.320 0.072 0.248 3 28 1
Boise State 0.315 0.143 0.172 4 8 9
Texas 0.301 0.062 0.239 5 35 3
2010 - raw - ppa
Boise State 0.474 0.251 0.223 1 4 1
TCU 0.439 0.249 0.190 2 5 3
Ohio State 0.350 0.139 0.211 3 15 2
Oregon 0.335 0.208 0.127 4 8 5
Alabama 0.313 0.208 0.105 5 7 12
2011 - raw - ppa
Alabama 0.461 0.142 0.319 1 18 1
Wisconsin 0.400 0.310 0.089 2 1 21
LSU 0.344 0.152 0.192 3 14 2
Houston 0.309 0.235 0.074 4 4 26
Boise State 0.306 0.221 0.085 5 6 23
2012 - raw - ppa
Alabama 0.440 0.260 0.180 1 2 1
Florida State 0.352 0.187 0.164 2 6 5
Oregon 0.290 0.171 0.119 3 12 11
Texas A&M 0.245 0.241 0.004 4 3 52
Georgia 0.228 0.159 0.069 5 15 26
2013 - raw - ppa
Florida State 0.505 0.324 0.181 1 1 1
Alabama 0.392 0.256 0.136 2 4 5
Louisville 0.371 0.230 0.141 3 7 4
Baylor 0.333 0.230 0.103 4 8 10
Oregon 0.294 0.207 0.087 5 10 12
2014 - raw - ppa
TCU 0.319 0.145 0.174 1 23 3
Georgia 0.306 0.242 0.064 2 4 23
Ohio State 0.300 0.239 0.061 3 5 25
Marshall 0.292 0.228 0.063 4 8 24
Baylor 0.272 0.231 0.040 5 7 36
2015 - raw - ppa
Baylor 0.277 0.272 0.005 1 1 47
Ohio State 0.277 0.159 0.118 2 21 7
Alabama 0.276 0.077 0.198 3 50 1
Oklahoma 0.270 0.202 0.068 4 10 22
Boise State 0.253 0.125 0.128 5 32 5
2016 - raw - ppa
Michigan 0.355 0.158 0.197 1 21 2
Alabama 0.354 0.120 0.234 2 39 1
Washington 0.330 0.235 0.094 3 7 10
Ohio State 0.310 0.150 0.160 4 28 3
Western Kentucky 0.288 0.273 0.015 5 5 32
2017 - raw - ppa
Alabama 0.357 0.211 0.146 1 10 3
Ohio State 0.307 0.216 0.091 2 9 12
Wisconsin 0.292 0.111 0.181 3 36 1
Washington 0.291 0.173 0.119 4 16 8
Georgia 0.283 0.203 0.080 5 11 17
2018 - raw - ppa
Clemson 0.416 0.259 0.157 1 5 2
Alabama 0.396 0.318 0.078 2 2 16
Utah State 0.331 0.191 0.140 3 14 5
Appalachian State 0.316 0.164 0.151 4 21 3
Georgia 0.294 0.258 0.036 5 6 30
2019 - raw - ppa
Ohio State 0.465 0.314 0.151 1 4 1
Alabama 0.425 0.324 0.101 2 2 7
Clemson 0.406 0.284 0.122 3 5 3
LSU 0.376 0.362 0.014 4 1 32
Georgia 0.289 0.164 0.125 5 23 2
2020 - raw - ppa
BYU 0.429 0.384 0.045 1 2 16
Alabama 0.423 0.394 0.029 2 1 28
Buffalo 0.349 0.304 0.045 3 5 17
Clemson 0.325 0.202 0.123 4 15 4
Cincinnati 0.282 0.125 0.157 5 39 2
2021 - raw - ppa
Georgia 0.424 0.241 0.183 1 5 1
Ohio State 0.324 0.359 −0.035 2 1 53
Michigan 0.265 0.217 0.048 3 8 12
Alabama 0.257 0.232 0.026 4 7 18
Cincinnati 0.243 0.149 0.095 5 27 5
2022 - raw - ppa
Georgia 0.391 0.299 0.091 1 2 6
Ohio State 0.338 0.283 0.055 2 5 15
Michigan 0.333 0.233 0.099 3 9 5
Alabama 0.311 0.239 0.072 4 8 8
Tennessee 0.289 0.291 −0.002 5 3 42
2023 - raw - ppa
Oregon 0.409 0.403 0.006 1 1 38
Michigan 0.408 0.246 0.162 2 5 1
Notre Dame 0.369 0.221 0.148 3 7 3
Georgia 0.353 0.333 0.020 4 3 30
Penn State 0.319 0.168 0.151 5 22 2

Based purely on their on field play, this measure highlights a lot of the teams we would expect (2008 Florida, Alabama, 2013 Florida State, 2019 Ohio State/LSU, 2021 Georgia), but it also rates certain teams very highly that we wouldn’t necessarily expect to see (2012 Northern Illinois, 2015/2018 Appalachian State Nevada, 2020 Buffalo,)

Why is that happening? The issue is that these estimates are simply the average net points per play for each team over the course of a season. They don’t take into account the relative strength of the opposition faced - a 10 yard pass against UMass is considered the same as a 10 yard pass against Ohio State. An offense that plays a weaker schedule will generally perform better than one that plays against top teams, which will lead to a higher evaluation in terms of raw efficiency.

As an example, this means that in 2018, from a raw efficiency perspective, Appalachian State finished the regular season rated higher than Oklahoma. This is mainly because Appalachian State’s defense was rated so highly; Oklahoma had the highest rated (raw) offense but their defense was rated so poorly that it pulled their overall score down.

Show the code
example_raw = 
  raw_efficiency_overall |>
  filter(metric == 'ppa') |>
  filter(season == 2018) |>
  filter_to_fbs() |>
  add_overall_efficiency() |>
  add_team_ranks() |>
  filter(team %in% c("Appalachian State", "Oklahoma")) |>
  pivot_wider(names_from = c("type"),
              values_from = c("estimate", "rank"))

example_raw |>
  group_by(metric) |>
  efficiency_tbl(with_ranks = T, with_estimates = T)
season team method
efficiency
rank
overall offense defense overall offense defense
ppa
2018 Appalachian State raw 0.316 0.164 0.151 4 21 3
2018 Oklahoma raw 0.246 0.429 −0.183 10 1 117

Now, compare the schedules of each of these two teams based on their opponents during the regular season, using the raw overall efficiency as the metric of strength.

team opponent overall
2015 - raw - ppa
Oklahoma Akron 0.020
Oklahoma Clemson 0.247
Oklahoma Tennessee 0.161
Oklahoma Tulsa −0.053
Oklahoma West Virginia 0.123
Oklahoma Texas −0.019
Oklahoma Kansas State −0.092
Oklahoma Texas Tech 0.007
Oklahoma Kansas −0.409
Oklahoma Iowa State −0.112
Oklahoma Baylor 0.277
Oklahoma TCU 0.144
Oklahoma Oklahoma State 0.138
total 0.432
team opponent overall
2015 - raw - ppa
Appalachian State Ohio 0.026
Appalachian State Clemson 0.247
Appalachian State Old Dominion −0.153
Appalachian State Wyoming −0.205
Appalachian State Georgia State −0.006
Appalachian State Louisiana Monroe −0.217
Appalachian State Georgia Southern 0.161
Appalachian State Troy −0.024
Appalachian State Arkansas State 0.122
Appalachian State Idaho −0.171
Appalachian State Louisiana −0.064
Appalachian State South Alabama −0.155
total -0.439

Oklahoma didn’t play the toughest schedule in college football, but it was considerably harder than Appalachian State.

If we adjust for the strength of opponents, we can see that Appalachian State’s defense is still pretty highly rated, but their offense is heavily penalized due to opponent quality and their overall rating falls. Oklahoma, meanwhile, gets a slight improvement to their defense (they were still very poor overall that season) which boosts their overall rating.

Show the code
example_adjusted = 
  adjusted_efficiency_overall_ppa |>
  filter(play_situation != 'special') |>
  select(-play_situation) |>
  filter(season == 2018) |>
  add_overall_efficiency() |>
  add_team_ranks() |>
  filter(team %in% c("Oklahoma", "Appalachian State")) |>
  pivot_wider(names_from = c("type"),
              values_from = c("estimate", "rank"))

example_adjusted |>
  mutate(method = 'adjusted') |>
  bind_rows(
    example_raw
  ) |>
  arrange(team) |>
  group_by(metric) |>
  select(season, team, method, everything()) |>
  efficiency_tbl(with_ranks = T, with_estimates = T)
season team method
efficiency
rank
overall offense defense overall offense defense
ppa
2018 Appalachian State adjusted 0.221 0.016 0.205 20 60 9
2018 Appalachian State raw 0.316 0.164 0.151 4 21 3
2018 Oklahoma adjusted 0.357 0.429 −0.072 5 1 103
2018 Oklahoma raw 0.246 0.429 −0.183 10 1 117

1.2 Opponent Adjusted

How do we adjust a team’s offensive/defensive efficiency rating based on their opponents?

I use a ridge regression to partial out the effect of all offenses on all defenses on predicted points oer play. That is, I regress the net predicted points per play on a dummy variable for every offense, defense, as well as an indicator for home field advantage:

\[PPA = Offense_{i} + Defense_{j} + Home\]

The coefficient for each offense and defense represent that particular school’s average effect on predicted points per play conditional on all other teams. Good offenses will have positive coefficients (how much more the team scored on a given play than average) while good defenses will have negative coefficients (because they prevented other teams from scoring). Flipping the sign for defense (so that positive is considered good) produces each team’s offensive/defensive net points per play conditional on their opponents.

I fit regressions at the season level for all teams to examine each team’s offensive/defensive efficiency over the course of an entire season. The coefficients from these regressions can then used to examine how team’s perform on offensive/defense in terms of net points per play.

For example, the following visualization places all teams based on their offensive and defensive strengths in the 2023 season. The best teams are those in the upper right quadrant that have strong offenses and defenses. The worst teams are those in the bottom left quadrant with poor offenses and defenses.

Show the code
plot_teams_by_season = function(data, lim = 0.4) {
  
  data |>
    pivot_wider(names_from = c("type"),
                values_from = c("estimate")) |>
    ggplot(aes(x=offense,
               y=defense,
               label = team,
               color = team))+
    geom_label(size = 2, alpha = 0.8) +
    scale_color_cfb()+
    facet_wrap(season ~.)+
    geom_hline(yintercept = 0, linetype = 'dotted')+
    geom_vline(xintercept = 0, linetype = 'dotted')+
    coord_cartesian(xlim = c(-lim, lim),
                    ylim = c(-lim, lim))+
    labs(title = "Offensive and Defensive Efficiency by Season",
         subtitle = stringr::str_wrap("Opponent adjusted team offensive and defensive efficiency ratings based on net predicted points per play.", 120))+
    xlab("Offensive Net Points per Play")+
    ylab("Defensive Net Points per Play")
  
}

adjusted_efficiency_overall_ppa |>
  filter(play_situation != 'special' ) |>
  filter(season == 2023) |>
  plot_teams_by_season()

The same information, but in a table.

Show the code
adjusted_efficiency_overall_ppa |>
  filter(play_situation != 'special' ) |>
  select(-play_situation) |>
  filter(season == 2023) |>
  add_overall_efficiency() |>
  add_team_ranks() |>
  pivot_wider(names_from = c("type"),
              values_from = c("estimate", "rank")) |>
  arrange(desc(estimate_overall)) |>
  select(-metric) |>
  efficiency_tbl(with_ranks = T, with_estimates = T) |>
  gt::opt_interactive(page_size_default = 25)

2 Team Efficiency by Season

2.1 Individual Teams

I can examine a team’s performance year over year to see how program has fared since 2007.

2.1.1 Alabama

Alabama, for example, was a highly efficient team for basically the entirety of Nick Saban’s tenure. It shouldn’t be a shock that Alabama would rate highly, but it is interesting to see the difference in compositions of Nick Saban’s teams. The 2015/2016 Alabama teams were evidently defensive powerhouses while not being particularly noteworthy on offense. After 2017 Alabama became one of the best offensive teams in the country while their defenses were less highly rated.

Show the code
adjusted_efficiency_overall_ppa |>
  filter(play_situation == 'offense/defense') |>
  add_overall_efficiency() |>
  add_team_ranks() |>
  plot_team_efficiency(teams = 'Alabama')

In addition to examining team’s by their overall offensive/defensive performance, I further break down a team’s offensive/defensive based on their net points per play when passing or rushing. As before, I regress each team’s offense and defense on predicted points per play, fitting individual regressions by play type (pass or run). This allows me to estimate a team’s performance in different aspects of the game on both sides of the ball.

Alabama, for instance, had three down years of offensive passing efficiency from 2015-2017. That changed in 2018 when Alabama led the nation in passing efficiency for three straight years.

Show the code
adjusted_efficiency_category_ppa |>
  add_overall_efficiency() |>
  add_team_ranks(groups = c("season", "type", "metric", "play_category")) |>
  filter(type == 'offense',
         play_category %in% c('pass', 'rush')) |>
  plot_team_efficiency(teams = 'Alabama') +
  facet_grid(play_category ~ type)

2.1.2 Iowa

Iowa, meanwhile, looks exactly like what you would expect. They had fairly strong teams overall at the end of the 2010s, and their defensive has consistently been top tier since 2017, but their offensive efficiency has been, in highly sophisticated analytics terms, complete garbo.

Show the code
adjusted_efficiency_overall_ppa |>
  filter(play_situation == 'offense/defense') |>
  add_overall_efficiency() |>
  add_team_ranks() |>
  plot_team_efficiency(teams = 'Iowa')

Also it’s evidently not just because they don’t believe in the forward pass; they seem to be getting worse at passing and running in recent years.

Show the code
adjusted_efficiency_category_ppa |>
  add_overall_efficiency() |>
  add_team_ranks(groups = c("season", "type", "metric", "play_category")) |>
  filter(type == 'offense',
         play_category %in% c('pass', 'rush')) |>
  plot_team_efficiency(teams = 'Iowa') +
  facet_grid(play_category ~ type)

At least they’re also equally decent at stopping the pass and run?

Show the code
adjusted_efficiency_category_ppa |>
  add_overall_efficiency() |>
  add_team_ranks(groups = c("season", "type", "metric", "play_category")) |>
  filter(type == 'defense',
         play_category %in% c('pass', 'rush')) |>
  plot_team_efficiency(teams = 'Iowa') +
  facet_grid(play_category ~ type)

Looking at Iowa’s offensive/defensive breakdown historically in a table.

Show the code
adjusted_efficiency_category_ppa |>
  filter(play_category != 'special') |>
  team_efficiency_category_tbl(team = 'Iowa') |>
  gt::opt_interactive(
    page_size_default = 25
  )

2.1.3 Florida State

Florida State was pretty up and down during this time period, with gradual improvement at the start of the Jimbo era culminating in the 2013 national championship. They then fell off during the Taggart era before rebounding (seemingly) under Norell.

Show the code
adjusted_efficiency_overall_ppa |>
  filter(play_situation == 'offense/defense') |>
  add_overall_efficiency() |>
  add_team_ranks() |>
  plot_team_efficiency(teams = 'Florida State')

What happened with their offensive efficiency in 2017? It looks like the 2017 team struggled to pass and run the ball.

Show the code
adjusted_efficiency_category_ppa |>
  add_overall_efficiency() |>
  add_team_ranks(groups = c("season", "type", "metric", "play_category")) |>
  filter(type == 'offense',
         play_category %in% c('pass', 'rush')) |>
  plot_team_efficiency(teams = 'Florida State') +
  facet_grid(play_category ~ type)

2.2 Top Teams

Which teams are considered the best overall using this methodology? I examine the top teams based on offensive/defensive efficiency since 2007.

Show the code
adjusted_efficiency_overall_ppa |>
  filter(play_situation != 'special') |>
  select(-play_situation) |>
  efficiency_top_teams_tbl(n = 5000) |>
  gt::opt_interactive(page_size_default = 25,
                      use_filters = T)

Ohio State 2019 and Alabama 2018 at the top will probably start some fights but this looks pretty reasonable overall.

I can similarly break this out based on pass/rush offense and defense.

2.3 Categorizing Teams

What is the relationship between offense and defense? Do teams with good rushing offense tend to also have good passing?

On offense, there’s generally a positive relationship between passing and rushing, as good offenses tend to be able to pass and run the ball. There are some interesting teams that stick out though, such as Georgia Southern in 2015 with a strong rushing game but essentially no passing game.

Show the code
team_efficiency_categories |>
  ggplot(aes(x=pass_offense, y=rush_offense, color = team))+
  geom_vline(xintercept = 0, linetype = 'dotted')+
  geom_hline(yintercept = 0, linetype = 'dotted')+
  geom_label(
    aes(label = paste(team, season)),
    size = 1.5,
    alpha = 0.5
  )+
  scale_color_cfb()+
  xlab("Pass Offense Efficiency")+
  ylab("Rush Offense Efficiency")

Pass/rush defense also tends to be related in the same way, though there are some outliers such as Navy 2022 and Miami Ohio in 2020 that were evidently strong at defending the pass but bad at defending the run?

Show the code
team_efficiency_categories |>
  ggplot(aes(x=pass_defense, y=rush_defense, color = team))+
  geom_vline(xintercept = 0, linetype = 'dotted')+
  geom_hline(yintercept = 0, linetype = 'dotted')+
  geom_label(
    aes(label = paste(team, season)),
    size = 1.5,
    alpha = 0.5
  )+
  scale_color_cfb()+
  xlab("Pass Defense Efficiency")+
  ylab("Rush Defense Efficiency")

I’m interested to see what it looks if we map every team-season based on each of these offensive/defensive categories. Basically, I want to take the information of the previous two plots and collapse it into one chart where we can see teams that are strong on both sides of the ball vs teams that are strong at only area.

To do this, I’ll fit a PCA to offensive/defensive categories to reduce the dimensionality of the data and plot every team on the first two resulting principal components.

Show the code
team_pca = 
  team_efficiency_categories |>
  select(contains("offense"), contains("defense")) |>
  scale() |>
  prcomp()

team_pca |> 
  tidy(matrix = "rotation") |>
  mutate(PC = paste0("PC",PC)) |> pivot_wider(names_from = "PC", values_from = "value") |>
  gt_tbl() |>
  gt::fmt_number(decimals = 3)
column PC1 PC2 PC3 PC4
pass_offense 0.494 −0.498 −0.689 0.179
rush_offense 0.488 −0.518 0.689 −0.138
pass_defense 0.515 0.470 −0.152 −0.701
rush_defense 0.502 0.513 0.165 0.677

This should provide us a (somewhat messy) mapping that characterizes different types of teams based on their team efficiences in offense/defense situations. I’ll place all teams in 2023.

Show the code
plot_teams_pca = function(data) {
  
  data |>
    rename_with(.fn = ~ gsub(".fitted", "", .x)) |>
    ggplot(aes(x=PC2, y=PC1, color = team))+
    geom_label(
      aes(label = paste(team, season)),
      size = 1.5,
      alpha = 0.5
    )+
    scale_color_cfb()+
    coord_cartesian(ylim = c(-6, 6),
                    xlim = c(-4.2, 4.2))
}

team_pca |>
  augment(team_efficiency_categories) |>
  filter(season == 2023) |>
  plot_teams_pca()

The first principal component maps to overall strength, meaning the best teams are those that are highest on y axis (Michigan, Oregon, Ohio State) while the second principal component maps to a team’s balance between offense/defense.

Teams that are stronger at offense than defense (Oregon, Liberty, New Mexico) are on the left side of the charter while teams that are stronger at defense than offense are on the right (Penn State, Iowa).

Show the code
team_efficiency_categories |>
  filter(season == 2023) |>
  arrange(desc(pass_offense)) |>
  efficiency_top_categories_tbl() |>
  gt::opt_interactive(page_size_default = 25)

I’ll plot all teams over this time period using the same approach via principal components.

Show the code
team_pca |>
  augment(team_efficiency_categories) |>
  plot_teams_pca()