I need your help!

I want your feedback to make the book better for you and other readers. If you find typos, errors, or places where the text may be improved, please let me know. The best ways to provide feedback are by GitHub or hypothes.is annotations.

You can leave a comment at the bottom of the page/chapter, or open an issue or submit a pull request on GitHub: https://github.com/isaactpetersen/Fantasy-Football-Analytics-Textbook

Hypothesis Alternatively, you can leave an annotation using hypothes.is. To add an annotation, select some text and then click the symbol on the pop-up menu. To see the annotations of others, click the symbol in the upper right-hand corner of the page.

18  Mythbusters: Putting Fantasy Football Beliefs/Anecdotes to the Test

In this chapter, we put a popular fantasy football belief to the test. We evaluate the widely held belief that players perform better during a contract year.

18.1 Getting Started

18.1.1 Load Packages

Code
library("petersenlab")
library("nflreadr")
library("lme4")
library("lmerTest")
library("performance")
library("emmeans")
library("tidyverse")

18.1.2 Specify Package Options

Code
emm_options(lmerTest.limit = 100000)
emm_options(pbkrtest.limit = 100000)

18.1.3 Load Data

Code
load(file = "./data/nfl_playerContracts.RData")
load(file = "./data/player_stats_weekly.RData")
load(file = "./data/player_stats_seasonal.RData")
load(file = "./data/nfl_espnQBR_seasonal.RData")
load(file = "./data/nfl_espnQBR_weekly.RData")

We created the player_stats_weekly.RData and player_stats_seasonal.RData objects in Section 4.4.3.

18.2 Do Players Perform Better in their Contract Year?

Considerable speculation exists regarding whether players perform better in their last year of their contract (i.e., their “contract year”). Fantasy football talking heads and commentators frequently discuss the benefit of selecting players who are in their contract year, because it supposedly means that player has more motivation to perform well so they get a new contract and get paid more. To our knowledge, no peer-reviewed studies have examined this question for football players. One study found that National Basketball Association (NBA) players improved in field goal percentage, points, and player efficiency rating (but not other statistics: rebounds, assists, steals, or blocks) from their pre-contract year to their contract year, and that Major League Baseball (MLB) players improved in runs batted in (RBIs; but not other statistics: batting average, slugging percentage, on base percentage, home runs, fielding percentage) from their pre-contract year to their contract year (White & Sheldon, 2014). Other casual analyses have been examined contract-year performance of National Football League (NFL) players, including articles in 2012 (Bales, 2012; archived at https://perma.cc/CT3F-QN5E) and 2022 (Niles, 2022; archived at https://perma.cc/F4F5-7RQZ).

Let’s examine the question empirically. Our research questions is: Do players perform better in their “contract year” (i.e., the last year of their contract)? Our hypothesis is that players are motivated to get larger contracts (more money), leading players in their contract year to try harder and perform better. If the hypothesis is true, we predict that players who are in their contract year will tend to score more fantasy points than players who are not in their contract year.

In order to test this question empirically, we have to make some assumptions/constraints. In this example, we will make the following constraints:

  • We will determine a player’s contract year programmatically based on the year the contract was signed. For instance, if a player signed a 3-year contract in 2015, their contract would expire in 2018, and thus their contract year would be 2017. Note: this is a coarse way of determining a player’s contract year because it could depend on when during the year the player’s contract is signed. If we were submitting this analysis as a paper to a scientific journal, it would be important to verify each player’s contract year.
  • We will examine performance in all seasons since 2011, beginning when most data for player contracts are available.
  • For maximum statistical power to detect an effect if a contract year effect exists, we will examine all seasons for a player (since 2011), not just their contract year and their pre-contract year.
  • To ensure a more fair, apples-to-apples comparison of the games in which players played, we will examine per-game performance (except for yards per carry, which is based on \(\frac{\text{rushing yards}}{\text{carries}}\) from the entire season).
  • We will examine regular season games only (no postseason).
  • To ensure we do not make generalization about a player’s performance in a season from a small sample, the player has to play at least 5 games in a given season for that player–season combination to be included in analysis.

For analysis, the same player contributes multiple observations of performance (i.e., multiple seasons) due to the longitudinal nature of the data. Inclusion of multiple data points from the same player would violate the assumption of multiple regression that all observations are independent. Thus, we use mixed-effects models that allow nonindependent observations. In our mixed-effects models, we include a random intercept for each player, to allow our model to account for players’ differing level of performance. We examine two mixed-effects models for each outcome variable: one model that accounts for the effects of age and experience, and one model that does not.

The model that does not account for the effects of age and experience includes:

  1. random intercepts to allow the model to estimate a different starting point for each player
  2. a fixed effect for whether the player is in a contract year

The model that accounts for the effects of age and experience includes:

  1. random intercepts to allow the model to estimate a different starting point for each player
  2. random linear slopes (i.e., random effect of linear age) to allow the model to estimate a different form of change for each player
  3. a fixed quadratic effect of age to allow for curvilinear effects
  4. a fixed effect of experience
  5. a fixed effect for whether the player is in a contract year
Code
# Subset to remove players without a year signed
nfl_playerContracts_subset <- nfl_playerContracts |> 
  dplyr::filter(!is.na(year_signed) & year_signed != 0)

# Determine the contract year for a given contract
nfl_playerContracts_subset$contractYear <- nfl_playerContracts_subset$year_signed + nfl_playerContracts_subset$years - 1

# Arrange contracts by player and year_signed
nfl_playerContracts_subset <- nfl_playerContracts_subset |>
  dplyr::group_by(player, position) |> 
  dplyr::arrange(player, position, -year_signed) |> 
  dplyr::ungroup()

# Determine if the player played in the original contract year
nfl_playerContracts_subset <- nfl_playerContracts_subset |>
  dplyr::group_by(player, position) |>
  dplyr::mutate(
    next_contract_start = lag(year_signed)) |>
  dplyr::ungroup() |>
  dplyr::mutate(
    played_in_contract_year = ifelse(
      is.na(next_contract_start) | contractYear < next_contract_start,
      TRUE,
      FALSE))

# Check individual players
#nfl_playerContracts_subset |> 
#  dplyr::filter(player == "Aaron Rodgers") |> 
#  dplyr::select(player:years, contractYear, next_contract_start, played_in_contract_year)
#
#nfl_playerContracts_subset |> 
#  dplyr::filter(player %in% c("Jared Allen", "Aaron Rodgers")) |> 
#  dplyr::select(player:years, contractYear, next_contract_start, played_in_contract_year)

# Subset data
nfl_playerContractYears <- nfl_playerContracts_subset |> 
  dplyr::filter(played_in_contract_year == TRUE) |> 
  dplyr::filter(position %in% c("QB","RB","WR","TE")) |> 
  dplyr::select(player, position, team, contractYear) |> 
  dplyr::mutate(merge_name = nflreadr::clean_player_names(player, lowercase = TRUE)) |> 
  dplyr::rename(season = contractYear) |> 
  dplyr::mutate(contractYear = 1)

# Merge with weekly and seasonal stats data
player_stats_weekly_offense <- player_stats_weekly |> 
  dplyr::filter(position_group %in% c("QB","RB","WR","TE")) |> 
  dplyr::mutate(merge_name = nflreadr::clean_player_names(player_display_name, lowercase = TRUE))
#nfl_actualStats_offense_seasonal <- nfl_actualStats_offense_seasonal |> 
#  mutate(merge_name = nflreadr::clean_player_names(player_display_name, lowercase = TRUE))

player_statsContracts_offense_weekly <- dplyr::full_join(
  player_stats_weekly_offense,
  nfl_playerContractYears,
  by = c("merge_name", "position_group" = "position", "season")
) |> 
  dplyr::filter(position_group %in% c("QB","RB","WR","TE"))

#player_statsContracts_offense_seasonal <- full_join(
#  player_stats_seasonal_offense,
#  nfl_playerContractYears,
#  by = c("merge_name", "position_group" = "position", "season")
#) |> 
#  filter(position_group %in% c("QB","RB","WR","TE"))

player_statsContracts_offense_weekly$contractYear[which(is.na(player_statsContracts_offense_weekly$contractYear))] <- 0
#player_statsContracts_offense_seasonal$contractYear[which(is.na(player_statsContracts_offense_seasonal$contractYear))] <- 0

#player_statsContracts_offense_weekly$contractYear <- factor(
#  player_statsContracts_offense_weekly$contractYear,
#  levels = c(0, 1),
#  labels = c("no", "yes"))

#player_statsContracts_offense_seasonal$contractYear <- factor(
#  player_statsContracts_offense_seasonal$contractYear,
#  levels = c(0, 1),
#  labels = c("no", "yes"))

player_statsContracts_offense_weekly <- player_statsContracts_offense_weekly |> 
  dplyr::arrange(merge_name, season, season_type, week)

#player_statsContracts_offense_seasonal <- player_statsContracts_offense_seasonal |> 
#  arrange(merge_name, season)

player_statsContractsSubset_offense_weekly <- player_statsContracts_offense_weekly |> 
  dplyr::filter(season_type == "REG")

#table(nfl_playerContracts$year_signed) # most contract data is available beginning in 2011

# Calculate Per Game Totals
player_statsContracts_seasonal <- player_statsContractsSubset_offense_weekly |> 
  dplyr::group_by(player_id, season) |> 
  dplyr::summarise(
    player_display_name = petersenlab::Mode(player_display_name),
    position_group = petersenlab::Mode(position_group),
    age = min(age, na.rm = TRUE),
    years_of_experience = min(years_of_experience, na.rm = TRUE),
    rushing_yards = sum(rushing_yards, na.rm = TRUE), # season total
    carries = sum(carries, na.rm = TRUE), # season total
    rushing_epa = mean(rushing_epa, na.rm = TRUE),
    receiving_yards = mean(receiving_yards, na.rm = TRUE),
    receiving_epa = mean(receiving_epa, na.rm = TRUE),
    fantasyPoints = sum(fantasyPoints, na.rm = TRUE), # season total
    contractYear = mean(contractYear, na.rm = TRUE),
    games = n(),
    .groups = "drop_last"
  ) |> 
  dplyr::mutate(
    player_id = as.factor(player_id),
    ypc = rushing_yards / carries,
    contractYear = factor(
      contractYear,
      levels = c(0, 1),
      labels = c("no", "yes")
    ))

player_statsContracts_seasonal[sapply(player_statsContracts_seasonal, is.infinite)] <- NA

player_statsContracts_seasonal$ageCentered20 <- player_statsContracts_seasonal$age - 20
player_statsContracts_seasonal$ageCentered20Quadratic <- player_statsContracts_seasonal$ageCentered20 ^ 2

# Merge with seasonal fantasy points data

18.2.1 QB

First, we prepare the data by merging and performing additional processing:

Code
# Merge with QBR data
nfl_espnQBR_weekly$merge_name <- paste(nfl_espnQBR_weekly$name_first, nfl_espnQBR_weekly$name_last, sep = " ") |> 
  nflreadr::clean_player_names(lowercase = TRUE)

nfl_contractYearQBR_weekly <- nfl_playerContractYears |> 
  dplyr::filter(position == "QB") |> 
  dplyr::full_join(
    nfl_espnQBR_weekly,
    by = c("merge_name","team","season")
  )

nfl_contractYearQBR_weekly$contractYear[which(is.na(nfl_contractYearQBR_weekly$contractYear))] <- 0
#nfl_contractYearQBR_weekly$contractYear <- factor(
#  nfl_contractYearQBR_weekly$contractYear,
#  levels = c(0, 1),
#  labels = c("no", "yes"))

nfl_contractYearQBR_weekly <- nfl_contractYearQBR_weekly |> 
  dplyr::arrange(merge_name, season, season_type, game_week)

nfl_contractYearQBRsubset_weekly <- nfl_contractYearQBR_weekly |> 
  dplyr::filter(season_type == "Regular") |> 
  dplyr::arrange(merge_name, season, season_type, game_week) |> 
  mutate(
    player = coalesce(player, name_display),
    position = "QB") |> 
  group_by(merge_name, player_id) |> 
  fill(player, .direction = "downup")

# Merge with age and experience
nfl_contractYearQBRsubset_weekly <- player_statsContractsSubset_offense_weekly |> 
  dplyr::filter(position == "QB") |> 
  dplyr::select(merge_name, season, week, age, years_of_experience, fantasyPoints) |> 
  full_join(
    nfl_contractYearQBRsubset_weekly,
    by = c("merge_name","season", c("week" = "game_week"))
  ) |> select(player_id, season, week, player, everything()) |> 
  arrange(player_id, season, week)

#hist(nfl_contractYearQBRsubset_weekly$qb_plays) # players have at least 20 dropbacks per game

# Calculate Per Game Totals
nfl_contractYearQBR_seasonal <- nfl_contractYearQBRsubset_weekly |> 
  dplyr::group_by(merge_name, season) |> 
  dplyr::summarise(
    age = min(age, na.rm = TRUE),
    years_of_experience = min(years_of_experience, na.rm = TRUE),
    qbr = mean(qbr_total, na.rm = TRUE),
    pts_added = mean(pts_added, na.rm = TRUE),
    epa_pass = mean(pass, na.rm = TRUE),
    qb_plays = sum(qb_plays, na.rm = TRUE), # season total
    fantasyPoints = sum(fantasyPoints, na.rm = TRUE), # season total
    contractYear = mean(contractYear, na.rm = TRUE),
    games = n(),
    .groups = "drop_last"
  ) |> 
  dplyr::mutate(
    contractYear = factor(
      contractYear,
      levels = c(0, 1),
      labels = c("no", "yes")
    ))

nfl_contractYearQBR_seasonal[sapply(nfl_contractYearQBR_seasonal, is.infinite)] <- NA

nfl_contractYearQBR_seasonal$ageCentered20 <- nfl_contractYearQBR_seasonal$age - 20
nfl_contractYearQBR_seasonal$ageCentered20Quadratic <- nfl_contractYearQBR_seasonal$ageCentered20 ^ 2

nfl_contractYearQBR_seasonal <- nfl_contractYearQBR_seasonal |> 
  group_by(merge_name) |>
  mutate(player_id = as.factor(as.character(cur_group_id())))

nfl_contractYearQBRsubset_seasonal <- nfl_contractYearQBR_seasonal |> 
  dplyr::filter(
    games >= 5, # keep only player-season combinations in which QBs played at least 5 games
    season >= 2011) # keep only seasons since 2011 (when most contract data are available)

Then, we analyze the data.

18.2.1.1 Quarterback Rating

Below is a mixed model that examines whether a player has a higher QBR per game when they are in a contract year compared to when they are not in a contract year. The first model includes just contract year as a predictor. The second model includes additional covariates, including player age and experience. In terms of Quarterback Rating (QBR), findings from the models indicate that Quarterbacks did not perform significantly better in their contract year.

Code
mixedModel_qbr <- lmerTest::lmer(
  qbr ~ contractYear + (1 | player_id),
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_qbr)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: qbr ~ contractYear + (1 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 10013.5

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.1498 -0.5380  0.0910  0.5724  3.1917 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 112.0    10.58   
 Residual              204.7    14.31   
Number of obs: 1192, groups:  player_id, 274

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)       44.2840     0.8412  245.2217  52.644   <2e-16 ***
contractYearyes   -0.8738     1.1368 1063.7272  -0.769    0.442    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.236
Code
performance::r2(mixedModel_qbr)
# R2 for Mixed Models

  Conditional R2: 0.354
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_qbr, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             44.3 0.842 284     42.6     45.9
 yes            43.4 1.250 804     41.0     45.9

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_qbr <- lmerTest::lmer(
  qbr ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_qbr)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: qbr ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 9943.3

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.3056 -0.4979  0.0813  0.5508  3.2409 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   149.4669 12.2257        
           ageCentered20   0.7425  0.8617  -0.54 
 Residual                193.8632 13.9235        
Number of obs: 1186, groups:  player_id, 271

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              39.56847    2.20015  246.27636  17.984  < 2e-16 ***
contractYearyes          -0.50460    1.17248 1049.28996  -0.430   0.6670    
ageCentered20             0.28897    0.62750  355.64282   0.461   0.6454    
ageCentered20Quadratic   -0.08953    0.02217  178.29145  -4.038 7.99e-05 ***
years_of_experience       1.52469    0.51847  333.39962   2.941   0.0035 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.050                     
ageCentrd20 -0.749 -0.060              
agCntrd20Qd  0.751  0.038 -0.635       
yrs_f_xprnc  0.150 -0.027 -0.683 -0.077
Code
performance::r2(mixedModelAge_qbr)
# R2 for Mixed Models

  Conditional R2: 0.402
     Marginal R2: 0.026
Code
emmeans::emmeans(mixedModelAge_qbr, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             44.3 0.879 257     42.6     46.0
 yes            43.8 1.260 740     41.3     46.3

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.1.2 Points Added

In terms of points added, Quarterbacks did not perform better in their contract year.

Code
mixedModel_ptsAdded <- lmerTest::lmer(
  pts_added ~ contractYear + (1 | player_id),
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_ptsAdded)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: pts_added ~ contractYear + (1 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 5446.2

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.6374 -0.5031  0.0901  0.5459  4.2578 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.551    1.597   
 Residual              4.368    2.090   
Number of obs: 1192, groups:  player_id, 274

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)       -0.8380     0.1254  234.3168  -6.682  1.7e-10 ***
contractYearyes   -0.2104     0.1664 1051.0003  -1.264    0.206    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.231
Code
performance::r2(mixedModel_ptsAdded)
# R2 for Mixed Models

  Conditional R2: 0.369
     Marginal R2: 0.001
Code
emmeans::emmeans(mixedModel_ptsAdded, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no           -0.838 0.125 284    -1.09   -0.591
 yes          -1.048 0.184 795    -1.41   -0.687

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_ptsAdded <- lmerTest::lmer(
  pts_added ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_ptsAdded)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: pts_added ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 5413.9

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.8312 -0.5112  0.0876  0.5217  4.2918 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   3.88640  1.9714         
           ageCentered20 0.01774  0.1332   -0.65 
 Residual                4.16345  2.0405         
Number of obs: 1186, groups:  player_id, 271

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)            -1.540e+00  3.309e-01  2.339e+02  -4.654 5.45e-06 ***
contractYearyes        -1.832e-01  1.717e-01  1.038e+03  -1.067 0.286066    
ageCentered20           2.813e-02  9.314e-02  3.455e+02   0.302 0.762813    
ageCentered20Quadratic -1.204e-02  3.275e-03  1.685e+02  -3.676 0.000318 ***
years_of_experience     2.341e-01  7.604e-02  3.202e+02   3.078 0.002263 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.050                     
ageCentrd20 -0.753 -0.061              
agCntrd20Qd  0.746  0.043 -0.643       
yrs_f_xprnc  0.160 -0.028 -0.685 -0.066
Code
performance::r2(mixedModelAge_ptsAdded)
# R2 for Mixed Models

  Conditional R2: 0.401
     Marginal R2: 0.023
Code
emmeans::emmeans(mixedModelAge_ptsAdded, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no           -0.790 0.128 260    -1.04   -0.538
 yes          -0.973 0.185 745    -1.34   -0.611

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.1.3 Expected Points Added

In terms of expected points added (EPA) from passing plays, when not controlling for player age and experience, Quarterbacks performed better in their contract year. However, when controlling for player age and experience, Quarterbacks did not perform significantly better in their contract year.

Code
mixedModel_epaPass <- lmerTest::lmer(
  epa_pass ~ contractYear + (1 | player_id),
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_epaPass)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: epa_pass ~ contractYear + (1 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 5056.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.0423 -0.4972  0.0336  0.5464  4.4114 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.521    1.588   
 Residual              2.974    1.724   
Number of obs: 1192, groups:  player_id, 274

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)        1.1342     0.1172  255.5034   9.674   <2e-16 ***
contractYearyes    0.3395     0.1386 1031.9883   2.449   0.0145 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.203
Code
performance::r2(mixedModel_epaPass)
# R2 for Mixed Models

  Conditional R2: 0.461
     Marginal R2: 0.003
Code
emmeans::emmeans(mixedModel_epaPass, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             1.13 0.117 283    0.903     1.37
 yes            1.47 0.162 733    1.155     1.79

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_epaPass <- lmerTest::lmer(
  epa_pass ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 | player_id), # removed random slopes to address convergence issue
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_epaPass)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: epa_pass ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 5017.5

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.1323 -0.5224  0.0649  0.5388  4.3523 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.338    1.529   
 Residual              2.929    1.711   
Number of obs: 1186, groups:  player_id, 271

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             4.918e-01  2.603e-01  1.028e+03   1.889 0.059113 .  
contractYearyes         1.707e-01  1.436e-01  1.056e+03   1.189 0.234842    
ageCentered20          -5.464e-02  7.594e-02  7.301e+02  -0.720 0.472050    
ageCentered20Quadratic -6.021e-03  2.395e-03  1.100e+03  -2.514 0.012073 *  
years_of_experience     2.600e-01  6.637e-02  4.642e+02   3.917 0.000103 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.061                     
ageCentrd20 -0.726 -0.062              
agCntrd20Qd  0.730  0.040 -0.572       
yrs_f_xprnc  0.196 -0.028 -0.731 -0.103
Code
performance::r2(mixedModelAge_epaPass)
# R2 for Mixed Models

  Conditional R2: 0.464
     Marginal R2: 0.036
Code
emmeans::emmeans(mixedModelAge_epaPass, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             1.28 0.117 283     1.05     1.52
 yes            1.45 0.162 731     1.14     1.77

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.1.4 Fantasy Points

In terms of fantasy points, Quarterbacks performed significantly worse in their contract year, even controlling for player age and experience.

Code
mixedModel_fantasyPtsPass <- lmerTest::lmer(
  fantasyPoints ~ contractYear + (1 | player_id),
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_fantasyPtsPass)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: fantasyPoints ~ contractYear + (1 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 14071

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.7579 -0.5680 -0.0806  0.6294  2.7203 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 6150     78.42   
 Residual              5546     74.47   
Number of obs: 1192, groups:  player_id, 274

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)      111.575      5.580  315.462  19.997  < 2e-16 ***
contractYearyes  -32.306      6.024 1048.808  -5.363 1.01e-07 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.184
Code
performance::r2(mixedModel_fantasyPtsPass)
# R2 for Mixed Models

  Conditional R2: 0.532
     Marginal R2: 0.014
Code
emmeans::emmeans(mixedModel_fantasyPtsPass, "contractYear")
 contractYear emmean   SE  df lower.CL upper.CL
 no            111.6 5.58 283    100.6    122.6
 yes            79.3 7.42 680     64.7     93.8

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_fantasyPtsPass <- lmerTest::lmer(
  fantasyPoints ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 | player_id), # removed random slopes to address convergence issue
  data = nfl_contractYearQBR_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_fantasyPtsPass)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: 
fantasyPoints ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 | player_id)
   Data: nfl_contractYearQBR_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 13949.2

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.8643 -0.5769 -0.0811  0.6252  2.5728 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 5955     77.17   
 Residual              5338     73.06   
Number of obs: 1186, groups:  player_id, 271

Fixed effects:
                        Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)             140.8230    11.6423 1036.7002  12.096  < 2e-16 ***
contractYearyes         -25.3706     6.1920 1059.5436  -4.097 4.50e-05 ***
ageCentered20           -14.8645     3.4566  822.9553  -4.300 1.91e-05 ***
ageCentered20Quadratic   -0.1610     0.1036 1092.2642  -1.554     0.12    
years_of_experience      16.4575     3.0832  585.1456   5.338 1.35e-07 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.063                     
ageCentrd20 -0.712 -0.061              
agCntrd20Qd  0.705  0.037 -0.544       
yrs_f_xprnc  0.228 -0.024 -0.759 -0.096
Code
performance::r2(mixedModelAge_fantasyPtsPass)
# R2 for Mixed Models

  Conditional R2: 0.562
     Marginal R2: 0.073
Code
emmeans::emmeans(mixedModelAge_fantasyPtsPass, "contractYear")
 contractYear emmean   SE  df lower.CL upper.CL
 no            113.1 5.64 285    102.0      124
 yes            87.8 7.41 667     73.2      102

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.2 RB

Code
player_statsContractsRB_seasonal <- player_statsContracts_seasonal |> 
  dplyr::filter(
    position_group == "RB",
    games >= 5, # keep only player-season combinations in which QBs played at least 5 games
    season >= 2011) # keep only seasons since 2011 (when most contract data are available)

18.2.2.1 Yards Per Carry

In terms of yards per carry (YPC), Running Backs did not perform significantly better in their contract year.

Code
mixedModel_ypc <- lmerTest::lmer(
  ypc ~ contractYear + (1 | player_id),
  data = player_statsContractsRB_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_ypc)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: ypc ~ contractYear + (1 | player_id)
   Data: player_statsContractsRB_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 6822.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-7.9318 -0.3929  0.0089  0.4039 15.0512 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.4291   0.655   
 Residual              1.9194   1.385   
Number of obs: 1870, groups:  player_id, 560

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)     3.908e+00  4.871e-02 5.719e+02  80.241   <2e-16 ***
contractYearyes 1.201e-02  7.823e-02 1.825e+03   0.154    0.878    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.396
Code
performance::r2(mixedModel_ypc)
# R2 for Mixed Models

  Conditional R2: 0.183
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_ypc, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no             3.91 0.0487  676     3.81     4.00
 yes            3.92 0.0741 1307     3.77     4.07

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_ypc <- lmerTest::lmer(
  ypc ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = player_statsContractsRB_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_ypc)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: ypc ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: player_statsContractsRB_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 6808.2

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-7.7418 -0.3806 -0.0056  0.3938 14.4738 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   0.3187   0.5645         
           ageCentered20 0.0109   0.1044   -0.37 
 Residual                1.8481   1.3594         
Number of obs: 1870, groups:  player_id, 560

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             4.141e+00  1.631e-01  7.770e+02  25.393   <2e-16 ***
contractYearyes         9.562e-02  8.507e-02  1.739e+03   1.124    0.261    
ageCentered20          -4.734e-02  5.650e-02  8.201e+02  -0.838    0.402    
ageCentered20Quadratic -5.905e-03  4.128e-03  4.357e+02  -1.430    0.153    
years_of_experience     5.894e-02  3.718e-02  5.674e+02   1.585    0.113    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.162                     
ageCentrd20 -0.872 -0.166              
agCntrd20Qd  0.814  0.151 -0.804       
yrs_f_xprnc -0.062 -0.131 -0.300 -0.248
Code
performance::r2(mixedModelAge_ypc)
# R2 for Mixed Models

  Conditional R2: 0.235
     Marginal R2: 0.021
Code
emmeans::emmeans(mixedModelAge_ypc, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no             3.87 0.0519  579     3.77     3.97
 yes            3.97 0.0776 1306     3.81     4.12

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.2.2 Expected Points Added

In terms of expected points added (EPA) from rushing plays, Running Backs did not perform significantly better in their contract year.

Code
mixedModel_epaRush <- lmerTest::lmer(
  rushing_epa ~ contractYear + (1 | player_id),
  data = player_statsContractsRB_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_epaRush)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: rushing_epa ~ contractYear + (1 | player_id)
   Data: player_statsContractsRB_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 5393.5

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.6983 -0.5062  0.0782  0.5862  3.4360 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.1040   0.3224  
 Residual              0.9552   0.9774  
Number of obs: 1870, groups:  player_id, 560

Fixed effects:
                  Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)       -0.64512    0.03077  694.49360  -20.96   <2e-16 ***
contractYearyes    0.04076    0.05361 1867.57072    0.76    0.447    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.445
Code
performance::r2(mixedModel_epaRush)
# R2 for Mixed Models

  Conditional R2: 0.098
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_epaRush, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no           -0.645 0.0308  690   -0.706   -0.585
 yes          -0.604 0.0486 1239   -0.700   -0.509

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_epaRush <- lmerTest::lmer(
  rushing_epa ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = player_statsContractsRB_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_epaRush)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: rushing_epa ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: player_statsContractsRB_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 5406.9

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.7422 -0.5040  0.0672  0.5781  3.4174 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   0.236649 0.48647        
           ageCentered20 0.003396 0.05827  -0.76 
 Residual                0.934249 0.96657        
Number of obs: 1870, groups:  player_id, 560

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)            -6.872e-01  1.147e-01  4.765e+02  -5.992 4.09e-09 ***
contractYearyes         6.854e-02  5.739e-02  1.649e+03   1.194    0.233    
ageCentered20           4.618e-02  3.838e-02  4.844e+02   1.203    0.229    
ageCentered20Quadratic -2.377e-03  2.722e-03  2.580e+02  -0.873    0.383    
years_of_experience    -3.278e-02  2.307e-02  5.885e+02  -1.421    0.156    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.160                     
ageCentrd20 -0.884 -0.185              
agCntrd20Qd  0.825  0.177 -0.832       
yrs_f_xprnc -0.049 -0.124 -0.283 -0.225
Code
performance::r2(mixedModelAge_epaRush)
# R2 for Mixed Models

  Conditional R2: 0.121
     Marginal R2: 0.004
Code
emmeans::emmeans(mixedModelAge_epaRush, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no           -0.655 0.0318  600   -0.717   -0.592
 yes          -0.586 0.0506 1256   -0.686   -0.487

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.2.3 Fantasy Points

In terms of fantasy points, Running Backs performed significantly worse in their contract year, even controlling for player age and experience.

Code
mixedModel_fantasyPtsRush <- lmerTest::lmer(
  fantasyPoints ~ contractYear + (1 | player_id),
  data = player_statsContractsRB_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_fantasyPtsRush)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: fantasyPoints ~ contractYear + (1 | player_id)
   Data: player_statsContractsRB_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 21521.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.2401 -0.4903 -0.1709  0.4078  3.8531 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2451     49.50   
 Residual              2091     45.73   
Number of obs: 1973, groups:  player_id, 577

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       65.760      2.474  695.816  26.581  < 2e-16 ***
contractYearyes  -12.571      2.745 1705.934  -4.579 5.01e-06 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.240
Code
performance::r2(mixedModel_fantasyPtsRush)
# R2 for Mixed Models

  Conditional R2: 0.543
     Marginal R2: 0.007
Code
emmeans::emmeans(mixedModel_fantasyPtsRush, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             65.8 2.47  631     60.9     70.6
 yes            53.2 3.23 1281     46.9     59.5

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_fantasyPtsRush <- lmerTest::lmer(
  fantasyPoints ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = player_statsContractsRB_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_fantasyPtsRush)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: 
fantasyPoints ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: player_statsContractsRB_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 21360

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.7104 -0.4882 -0.1531  0.4265  3.5522 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   4263.03  65.292         
           ageCentered20   39.38   6.275   -0.74 
 Residual                1813.93  42.590         
Number of obs: 1973, groups:  player_id, 577

Fixed effects:
                        Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)              58.6423     6.8397  772.5162   8.574  < 2e-16 ***
contractYearyes         -10.4358     2.8749 1686.3435  -3.630 0.000292 ***
ageCentered20            -2.3643     2.3713 1049.2986  -0.997 0.318979    
ageCentered20Quadratic   -1.0239     0.1466  607.5037  -6.985 7.49e-12 ***
years_of_experience      15.4261     1.6765  738.3768   9.201  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.166                     
ageCentrd20 -0.855 -0.151              
agCntrd20Qd  0.727  0.167 -0.737       
yrs_f_xprnc  0.203 -0.115 -0.530 -0.113
Code
performance::r2(mixedModelAge_fantasyPtsRush)
# R2 for Mixed Models

  Conditional R2: 0.612
     Marginal R2: 0.106
Code
emmeans::emmeans(mixedModelAge_fantasyPtsRush, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             67.0 2.43  639     62.3     71.8
 yes            56.6 3.15 1287     50.4     62.8

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.3 WR/TE

Code
player_statsContractsWRTE_seasonal <- player_statsContracts_seasonal |> 
  dplyr::filter(
    position_group %in% c("WR","TE"),
    games >= 5, # keep only player-season combinations in which QBs played at least 5 games
    season >= 2011) # keep only seasons since 2011 (when most contract data are available)

18.2.3.1 Receiving Yards

In terms of receiving yards, Wide Receivers/Tight Ends performed significantly worse in their contract year, even controlling for player age and experience.

Code
mixedModel_receivingYards <- lmerTest::lmer(
  receiving_yards ~ contractYear + (1 | player_id),
  data = player_statsContractsWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_receivingYards)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: receiving_yards ~ contractYear + (1 | player_id)
   Data: player_statsContractsWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 35244.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.8705 -0.5237 -0.1144  0.5037  4.5875 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 275.1    16.59   
 Residual              180.4    13.43   
Number of obs: 4146, groups:  player_id, 1146

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)       24.9202     0.5698 1375.5668  43.734  < 2e-16 ***
contractYearyes   -4.2752     0.5274 3524.9605  -8.106 7.14e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.236
Code
performance::r2(mixedModel_receivingYards)
# R2 for Mixed Models

  Conditional R2: 0.607
     Marginal R2: 0.008
Code
emmeans::emmeans(mixedModel_receivingYards, "contractYear")
 contractYear emmean    SE   df lower.CL upper.CL
 no             24.9 0.570 1257     23.8       26
 yes            20.6 0.679 2141     19.3       22

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_receivingYards <- lmerTest::lmer(
  receiving_yards ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = player_statsContractsWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_receivingYards)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: 
receiving_yards ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: player_statsContractsWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 34706

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-2.9358 -0.5189 -0.0997  0.4779  3.9916 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   514.160  22.68          
           ageCentered20   5.855   2.42    -0.71 
 Residual                134.199  11.58          
Number of obs: 4146, groups:  player_id, 1146

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              14.49275    1.45972 1618.24317   9.928  < 2e-16 ***
contractYearyes          -3.17620    0.51635 3284.86822  -6.151 8.61e-10 ***
ageCentered20             1.50128    0.50311 2307.12056   2.984  0.00288 ** 
ageCentered20Quadratic   -0.46762    0.02552 1766.33024 -18.323  < 2e-16 ***
years_of_experience       4.91082    0.39735 1400.69138  12.359  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.118                     
ageCentrd20 -0.819 -0.137              
agCntrd20Qd  0.675  0.073 -0.640       
yrs_f_xprnc  0.276  0.008 -0.663 -0.077
Code
performance::r2(mixedModelAge_receivingYards)
# R2 for Mixed Models

  Conditional R2: 0.747
     Marginal R2: 0.155
Code
emmeans::emmeans(mixedModelAge_receivingYards, "contractYear")
 contractYear emmean    SE   df lower.CL upper.CL
 no             24.1 0.587 1279     23.0     25.3
 yes            20.9 0.668 1984     19.6     22.2

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.3.2 Expected Points Added

In terms of expected points added (EPA) from receiving plays, Wide Receivers/Tight Ends performed significantly worse in their contract year, even controlling for player age and experience.

Code
mixedModel_epaReceiving <- lmerTest::lmer(
  receiving_epa ~ contractYear + (1 | player_id),
  data = player_statsContractsWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_epaReceiving)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: receiving_epa ~ contractYear + (1 | player_id)
   Data: player_statsContractsWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 13548.4

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-5.6043 -0.5669 -0.0416  0.5273  3.9029 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.5422   0.7364  
 Residual              1.3001   1.1402  
Number of obs: 4065, groups:  player_id, 1126

Fixed effects:
                  Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)        0.66996    0.03231 1521.78392   20.73  < 2e-16 ***
contractYearyes   -0.16120    0.04321 3868.38080   -3.73 0.000194 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.362
Code
performance::r2(mixedModel_epaReceiving)
# R2 for Mixed Models

  Conditional R2: 0.296
     Marginal R2: 0.003
Code
emmeans::emmeans(mixedModel_epaReceiving, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no            0.670 0.0323 1342    0.607    0.733
 yes           0.509 0.0436 2563    0.423    0.594

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_epaReceiving <- lmerTest::lmer(
  receiving_epa ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = player_statsContractsWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_epaReceiving)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: 
receiving_epa ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: player_statsContractsWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 13492.7

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-5.7529 -0.5617 -0.0371  0.5236  3.9446 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   0.955305 0.97740        
           ageCentered20 0.006654 0.08157  -0.70 
 Residual                1.240787 1.11391        
Number of obs: 4065, groups:  player_id, 1126

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             3.615e-01  9.962e-02  1.230e+03   3.629 0.000296 ***
contractYearyes        -1.643e-01  4.563e-02  3.767e+03  -3.601 0.000321 ***
ageCentered20           2.227e-02  3.253e-02  1.404e+03   0.685 0.493555    
ageCentered20Quadratic -1.106e-02  1.841e-03  5.636e+02  -6.010 3.33e-09 ***
years_of_experience     1.535e-01  2.286e-02  1.285e+03   6.713 2.86e-11 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.139                     
ageCentrd20 -0.847 -0.192              
agCntrd20Qd  0.775  0.122 -0.739       
yrs_f_xprnc  0.116  0.011 -0.503 -0.149
Code
performance::r2(mixedModelAge_epaReceiving)
# R2 for Mixed Models

  Conditional R2: 0.335
     Marginal R2: 0.028
Code
emmeans::emmeans(mixedModelAge_epaReceiving, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no            0.686 0.0330 1276    0.621    0.750
 yes           0.521 0.0441 2603    0.435    0.608

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.3.3 Fantasy Points

In terms of fantasy points, Wide Receivers/Tight Ends performed significantly worse in their contract year, even controlling for player age and experience.

Code
mixedModel_fantasyPtsReceiving <- lmerTest::lmer(
  fantasyPoints ~ contractYear + (1 | player_id),
  data = player_statsContractsWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_fantasyPtsReceiving)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: fantasyPoints ~ contractYear + (1 | player_id)
   Data: player_statsContractsWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 42535.3

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.3628 -0.5293 -0.1534  0.4389  4.9251 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 1215     34.86   
 Residual              1127     33.56   
Number of obs: 4146, groups:  player_id, 1146

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       49.427      1.256 1443.311  39.362  < 2e-16 ***
contractYearyes  -10.302      1.304 3647.812  -7.901 3.62e-15 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.269
Code
performance::r2(mixedModel_fantasyPtsReceiving)
# R2 for Mixed Models

  Conditional R2: 0.523
     Marginal R2: 0.009
Code
emmeans::emmeans(mixedModel_fantasyPtsReceiving, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             49.4 1.26 1284     47.0     51.9
 yes            39.1 1.55 2313     36.1     42.2

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_fantasyPtsReceiving <- lmerTest::lmer(
  fantasyPoints ~ contractYear + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = player_statsContractsWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_fantasyPtsReceiving)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: 
fantasyPoints ~ contractYear + ageCentered20 + ageCentered20Quadratic +  
    years_of_experience + (1 + ageCentered20 | player_id)
   Data: player_statsContractsWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 42115.3

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.1207 -0.5024 -0.1214  0.4287  5.2992 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   2426.12  49.256         
           ageCentered20   24.89   4.989   -0.74 
 Residual                 902.42  30.040         
Number of obs: 4146, groups:  player_id, 1146

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              30.92265    3.43909 1579.07395   8.992  < 2e-16 ***
contractYearyes          -7.32187    1.30535 3479.25298  -5.609 2.19e-08 ***
ageCentered20             1.92954    1.16055 2233.04071   1.663   0.0965 .  
ageCentered20Quadratic   -0.91016    0.06143 1374.85900 -14.815  < 2e-16 ***
years_of_experience      10.53521    0.87748 1362.34115  12.006  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.126                     
ageCentrd20 -0.830 -0.155              
agCntrd20Qd  0.707  0.090 -0.678       
yrs_f_xprnc  0.235  0.010 -0.617 -0.090
Code
performance::r2(mixedModelAge_fantasyPtsReceiving)
# R2 for Mixed Models

  Conditional R2: 0.655
     Marginal R2: 0.134
Code
emmeans::emmeans(mixedModelAge_fantasyPtsReceiving, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             48.0 1.28 1285     45.5     50.5
 yes            40.7 1.53 2203     37.7     43.7

Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.2.4 QB/RB/WR/TE

Code
player_statsContractsQBRBWRTE_seasonal <- player_statsContracts_seasonal |> 
  dplyr::filter(
    position_group %in% c("QB","RB","WR","TE"),
    games >= 5, # keep only player-season combinations in which QBs played at least 5 games
    season >= 2011) # keep only seasons since 2011 (when most contract data are available)

18.2.4.1 Fantasy Points

In terms of fantasy points, Quarterbacks/Running Backs/Wide Receivers/Tight Ends performed significantly worse in their contract year, even controlling for player age and experience.

Code
mixedModel_fantasyPts <- lmerTest::lmer(
  fantasyPoints ~ contractYear + position_group + (1 | player_id),
  data = player_statsContractsQBRBWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModel_fantasyPts)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: fantasyPoints ~ contractYear + position_group + (1 | player_id)
   Data: player_statsContractsQBRBWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 73360.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.8226 -0.4764 -0.1298  0.4211  4.1893 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2006     44.79   
 Residual              1866     43.20   
Number of obs: 6816, groups:  player_id, 1898

Fixed effects:
                 Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       152.013      4.011 2036.243   37.90   <2e-16 ***
contractYearyes   -13.799      1.335 5971.228  -10.34   <2e-16 ***
position_groupRB  -85.728      4.561 2016.427  -18.80   <2e-16 ***
position_groupTE -112.436      4.810 2000.614  -23.37   <2e-16 ***
position_groupWR  -96.187      4.430 2014.771  -21.71   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY pst_RB pst_TE
contrctYrys -0.081                     
postn_grpRB -0.874  0.008              
postn_grpTE -0.828 -0.007  0.728       
postn_grpWR -0.899 -0.002  0.791  0.750
Code
performance::r2(mixedModel_fantasyPts)
# R2 for Mixed Models

  Conditional R2: 0.616
     Marginal R2: 0.204
Code
emmeans::emmeans(mixedModel_fantasyPts, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             78.4 1.44 2021     75.6     81.3
 yes            64.6 1.73 3457     61.2     68.0

Results are averaged over the levels of: position_group 
Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 
Code
mixedModelAge_fantasyPts <- lmerTest::lmer(
  fantasyPoints ~ contractYear + position_group + ageCentered20 + ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 | player_id),
  data = player_statsContractsQBRBWRTE_seasonal,
  control = lmerControl(optimizer = "bobyqa")
)

summary(mixedModelAge_fantasyPts)
Linear mixed model fit by REML. t-tests use Satterthwaite's method [
lmerModLmerTest]
Formula: fantasyPoints ~ contractYear + position_group + ageCentered20 +  
    ageCentered20Quadratic + years_of_experience + (1 + ageCentered20 |  
    player_id)
   Data: player_statsContractsQBRBWRTE_seasonal
Control: lmerControl(optimizer = "bobyqa")

REML criterion at convergence: 72795.8

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.3477 -0.4691 -0.1119  0.4138  4.0275 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   3402.76  58.333         
           ageCentered20   39.54   6.288   -0.69 
 Residual                1561.65  39.518         
Number of obs: 6816, groups:  player_id, 1898

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             135.86006    5.03863 3053.85830  26.964  < 2e-16 ***
contractYearyes          -9.68928    1.36446 5817.88670  -7.101 1.38e-12 ***
position_groupRB        -80.60227    4.50068 1957.69476 -17.909  < 2e-16 ***
position_groupTE       -108.43652    4.72268 1905.41648 -22.961  < 2e-16 ***
position_groupWR        -92.31773    4.36877 1949.16102 -21.131  < 2e-16 ***
ageCentered20            -1.20662    1.09148 3405.82504  -1.105    0.269    
ageCentered20Quadratic   -0.89417    0.05601 2019.27013 -15.966  < 2e-16 ***
years_of_experience      13.25415    0.86621 2376.66276  15.301  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY pst_RB pst_TE pst_WR agCn20 agC20Q
contrctYrys  0.106                                          
postn_grpRB -0.710 -0.032                                   
postn_grpTE -0.656 -0.029  0.738                            
postn_grpWR -0.734 -0.040  0.798  0.758                     
ageCentrd20 -0.503 -0.135 -0.028 -0.044 -0.010              
agCntrd20Qd  0.463  0.087 -0.029 -0.012 -0.032 -0.644       
yrs_f_xprnc  0.070 -0.024  0.099  0.089  0.077 -0.629 -0.111
Code
performance::r2(mixedModelAge_fantasyPts)
# R2 for Mixed Models

  Conditional R2: 0.693
     Marginal R2: 0.262
Code
emmeans::emmeans(mixedModelAge_fantasyPts, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             75.5 1.45 1959     72.7     78.4
 yes            65.9 1.72 3396     62.5     69.2

Results are averaged over the levels of: position_group 
Degrees-of-freedom method: kenward-roger 
Confidence level used: 0.95 

18.3 Conclusion

There is a widely held belief that NFL players perform better in the last year of the contract because they are motivated to gain another contract. There is some evidence in the NBA and MLB that players tend to perform better in their contract year. We evaluated this possibility among NFL players who were Quarterbacks, Running Backs, Wide Receivers, or Tight Ends. We evaluated a wide range of performance indexes, including Quarterback Rating, yards per carry, points added, expected points added, receiving yards, and fantasy points. None of the positions showed significantly better performance in their contract year for any of the performance indexes. By contrast, if anything, players tended to perform more poorly during their contract year, as operationalized by fantasy points, receiving yards (WR/TE), and EPA from receiving plays (WR/TE), even when controlling for player age and experience. In sum, we did not find evidence in support of the contract year hypothesis and consider this myth debunked. However, we are open to this possibility being reexamined in new ways or with additional performance metrics.

18.4 Session Info

Code
sessionInfo()
R version 4.6.1 (2026-06-24)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.5 LTS

Matrix products: default
BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
 [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
 [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
[10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   

time zone: UTC
tzcode source: system (glibc)

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] lubridate_1.9.5    forcats_1.0.1      stringr_1.6.0      dplyr_1.2.1       
 [5] purrr_1.2.2        readr_2.2.0        tidyr_1.3.2        tibble_3.3.1      
 [9] ggplot2_4.0.3      tidyverse_2.0.0    emmeans_2.0.4      performance_0.18.2
[13] lmerTest_3.2-1     lme4_2.0-6         Matrix_1.7-5       nflreadr_1.5.1    
[17] petersenlab_1.2.3 

loaded via a namespace (and not attached):
 [1] Rdpack_2.6.6        DBI_1.3.0           mnormt_2.1.2       
 [4] gridExtra_2.3.1     sandwich_3.1-3      rlang_1.3.0        
 [7] magrittr_2.0.5      multcomp_1.4-32     otel_0.2.0         
[10] compiler_4.6.1      vctrs_0.7.3         reshape2_1.4.5     
[13] quadprog_1.5-8      pkgconfig_2.0.3     fastmap_1.2.0      
[16] backports_1.5.1     pbivnorm_0.6.0      rmarkdown_2.32     
[19] tzdb_0.5.0          nloptr_2.2.1        xfun_0.61          
[22] cachem_1.1.0        jsonlite_2.0.0      psych_2.6.5        
[25] broom_1.0.13        parallel_4.6.1      lavaan_0.7-2       
[28] cluster_2.1.8.2     R6_2.6.1            stringi_1.8.9      
[31] RColorBrewer_1.1-3  boot_1.3-32         rpart_4.1.27       
[34] numDeriv_2016.8-1.1 estimability_2.0.0  Rcpp_1.1.2         
[37] knitr_1.52          zoo_1.9-0           base64enc_0.1-6    
[40] splines_4.6.1       nnet_7.3-20         timechange_0.4.0   
[43] tidyselect_1.2.1    rstudioapi_0.19.0   yaml_2.3.12        
[46] codetools_0.2-20    lattice_0.22-9      plyr_1.8.9         
[49] withr_3.0.3         S7_0.2.2            coda_0.19-4.1      
[52] evaluate_1.0.5      foreign_0.8-91      survival_3.8-6     
[55] pillar_1.11.1       checkmate_2.3.4     stats4_4.6.1       
[58] reformulas_0.4.4    insight_1.5.4       generics_0.1.4     
[61] mix_1.0-13          hms_1.1.4           scales_1.4.0       
[64] minqa_1.2.8         xtable_1.8-8        glue_1.8.1         
[67] Hmisc_5.3-0         tools_4.6.1         data.table_1.18.6.1
[70] mvtnorm_1.4-2       grid_4.6.1          mitools_2.7        
[73] rbibutils_2.4.1     colorspace_2.1-3    nlme_3.1-169       
[76] htmlTable_2.5.0     Formula_1.2-6       cli_3.6.6          
[79] viridisLite_0.4.3   gtable_0.3.6        digest_0.6.39      
[82] pbkrtest_0.5.5      TH.data_1.1-5       htmlwidgets_1.6.4  
[85] farver_2.1.2        memoise_2.0.1       htmltools_0.5.9    
[88] lifecycle_1.0.5     MASS_7.3-65        

Feedback

Please consider providing feedback about this textbook, so that I can make it as helpful as possible. You can provide feedback at the following link: https://forms.gle/LsnVKwqmS1VuxWD18

Email Notification

The online version of this book will remain open access. If you want to know when the print version of the book is for sale, enter your email below so I can let you know.