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 here] and 2022 [Niles (2022); archived here].

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.4

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.1438 -0.5379  0.0903  0.5719  3.1897 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 111.9    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.3079     0.8418  245.9324  52.637   <2e-16 ***
contractYearyes   -0.9822     1.1351 1070.8136  -0.865    0.387    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.241
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.7     46.0
 yes            43.3 1.240 797     40.9     45.8

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: 9958.3

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.2952 -0.4944  0.0821  0.5502  3.2427 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   148.060  12.1680        
           ageCentered20   0.754   0.8683  -0.54 
 Residual                193.631  13.9151        
Number of obs: 1188, groups:  player_id, 272

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              39.48556    2.19458  245.59561  17.992  < 2e-16 ***
contractYearyes          -0.66767    1.16702 1059.27994  -0.572  0.56737    
ageCentered20             0.31325    0.62455  355.06653   0.502  0.61629    
ageCentered20Quadratic   -0.08998    0.02217  179.41581  -4.058 7.37e-05 ***
years_of_experience       1.51209    0.51312  320.38855   2.947  0.00345 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.048                     
ageCentrd20 -0.747 -0.062              
agCntrd20Qd  0.753  0.040 -0.641       
yrs_f_xprnc  0.141 -0.027 -0.680 -0.073
Code
performance::r2(mixedModelAge_qbr)
# R2 for Mixed Models

  Conditional R2: 0.402
     Marginal R2: 0.027
Code
emmeans::emmeans(mixedModelAge_qbr, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             44.4 0.878 258     42.6     46.1
 yes            43.7 1.250 733     41.2     46.2

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.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.6351 -0.5035  0.0888  0.5462  4.2567 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.546    1.596   
 Residual              4.369    2.090   
Number of obs: 1192, groups:  player_id, 274

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)       -0.8360     0.1255  234.9785  -6.662  1.9e-10 ***
contractYearyes   -0.2156     0.1661 1058.3856  -1.298    0.195    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.236
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.836 0.126 285    -1.08   -0.589
 yes          -1.052 0.183 788    -1.41   -0.692

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: 5421.2

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.8253 -0.5109  0.0882  0.5198  4.2928 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   3.86471  1.966          
           ageCentered20 0.01821  0.135    -0.65 
 Residual                4.15832  2.039          
Number of obs: 1188, groups:  player_id, 272

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)            -1.554e+00  3.304e-01  2.334e+02  -4.705 4.35e-06 ***
contractYearyes        -1.962e-01  1.709e-01  1.048e+03  -1.148 0.251226    
ageCentered20           3.257e-02  9.272e-02  3.459e+02   0.351 0.725640    
ageCentered20Quadratic -1.213e-02  3.279e-03  1.705e+02  -3.699 0.000291 ***
years_of_experience     2.316e-01  7.515e-02  3.088e+02   3.082 0.002240 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.049                     
ageCentrd20 -0.751 -0.063              
agCntrd20Qd  0.749  0.045 -0.650       
yrs_f_xprnc  0.152 -0.028 -0.681 -0.060
Code
performance::r2(mixedModelAge_ptsAdded)
# R2 for Mixed Models

  Conditional R2: 0.401
     Marginal R2: 0.024
Code
emmeans::emmeans(mixedModelAge_ptsAdded, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no           -0.783 0.128 261    -1.03   -0.531
 yes          -0.979 0.183 738    -1.34   -0.619

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.5

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.0398 -0.4989  0.0322  0.5476  4.4112 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.527    1.590   
 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.1346     0.1174  256.0275   9.662   <2e-16 ***
contractYearyes    0.3280     0.1385 1037.7967   2.369    0.018 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.207
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 284    0.903     1.37
 yes            1.46 0.162 727    1.144     1.78

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: 5024.9

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.1342 -0.5234  0.0635  0.5388  4.3611 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.313    1.521   
 Residual              2.930    1.712   
Number of obs: 1188, groups:  player_id, 272

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             4.877e-01  2.595e-01  1.036e+03   1.879   0.0605 .  
contractYearyes         1.500e-01  1.431e-01  1.063e+03   1.049   0.2945    
ageCentered20          -5.822e-02  7.542e-02  7.323e+02  -0.772   0.4404    
ageCentered20Quadratic -6.047e-03  2.393e-03  1.104e+03  -2.527   0.0116 *  
years_of_experience     2.656e-01  6.555e-02  4.552e+02   4.052 5.97e-05 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.059                     
ageCentrd20 -0.724 -0.063              
agCntrd20Qd  0.734  0.043 -0.579       
yrs_f_xprnc  0.187 -0.029 -0.727 -0.100
Code
performance::r2(mixedModelAge_epaPass)
# R2 for Mixed Models

  Conditional R2: 0.462
     Marginal R2: 0.038
Code
emmeans::emmeans(mixedModelAge_epaPass, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             1.30 0.117 284     1.07     1.53
 yes            1.45 0.161 727     1.13     1.76

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: 14070.4

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.7607 -0.5646 -0.0765  0.6277  2.7171 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 6117     78.21   
 Residual              5549     74.49   
Number of obs: 1192, groups:  player_id, 274

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)      112.435      5.573  316.134  20.175  < 2e-16 ***
contractYearyes  -32.921      6.021 1053.698  -5.468 5.68e-08 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.531
     Marginal R2: 0.015
Code
emmeans::emmeans(mixedModel_fantasyPtsPass, "contractYear")
 contractYear emmean   SE  df lower.CL upper.CL
 no            112.4 5.57 283      101      123
 yes            79.5 7.40 676       65       94

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: 13972.5

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.8688 -0.5757 -0.0819  0.6272  2.5759 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 5940     77.07   
 Residual              5337     73.05   
Number of obs: 1188, groups:  player_id, 272

Fixed effects:
                        Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)             140.3003    11.6134 1042.8569  12.081  < 2e-16 ***
contractYearyes         -26.1750     6.1694 1064.3229  -4.243 2.40e-05 ***
ageCentered20           -14.5261     3.4373  824.1128  -4.226 2.64e-05 ***
ageCentered20Quadratic   -0.1617     0.1035 1095.0343  -1.562    0.119    
years_of_experience      16.1450     3.0537  576.8851   5.287 1.77e-07 ***
---
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.710 -0.062              
agCntrd20Qd  0.709  0.039 -0.550       
yrs_f_xprnc  0.220 -0.024 -0.757 -0.093
Code
performance::r2(mixedModelAge_fantasyPtsPass)
# R2 for Mixed Models

  Conditional R2: 0.561
     Marginal R2: 0.073
Code
emmeans::emmeans(mixedModelAge_fantasyPtsPass, "contractYear")
 contractYear emmean   SE  df lower.CL upper.CL
 no            113.9 5.64 287    102.8      125
 yes            87.7 7.37 662     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: 6749.2

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-8.0616 -0.3988  0.0101  0.4104 15.1586 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.4509   0.6715  
 Residual              1.8445   1.3581  
Number of obs: 1865, groups:  player_id, 558

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)     3.906e+00  4.865e-02 5.636e+02  80.281   <2e-16 ***
contractYearyes 5.948e-03  7.699e-02 1.809e+03   0.077    0.938    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.196
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_ypc, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no             3.91 0.0487  673     3.81     4.00
 yes            3.91 0.0734 1304     3.77     4.06

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: 6733.3

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-7.8722 -0.3818 -0.0044  0.4022 14.5651 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   0.34352  0.5861         
           ageCentered20 0.01178  0.1085   -0.39 
 Residual                1.76996  1.3304         
Number of obs: 1865, groups:  player_id, 558

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             4.114e+00  1.610e-01  7.960e+02  25.545   <2e-16 ***
contractYearyes         8.517e-02  8.381e-02  1.737e+03   1.016   0.3097    
ageCentered20          -4.103e-02  5.577e-02  8.521e+02  -0.736   0.4621    
ageCentered20Quadratic -6.475e-03  4.070e-03  4.695e+02  -1.591   0.1122    
years_of_experience     6.152e-02  3.672e-02  5.558e+02   1.675   0.0944 .  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.165                     
ageCentrd20 -0.867 -0.165              
agCntrd20Qd  0.814  0.153 -0.803       
yrs_f_xprnc -0.071 -0.136 -0.301 -0.246
Code
performance::r2(mixedModelAge_ypc)
# R2 for Mixed Models

  Conditional R2: 0.252
     Marginal R2: 0.022
Code
emmeans::emmeans(mixedModelAge_ypc, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no             3.87 0.0520  577     3.77     3.97
 yes            3.95 0.0768 1300     3.80     4.11

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: 5373.8

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.7029 -0.5050  0.0776  0.5840  3.4426 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.1040   0.3225  
 Residual              0.9523   0.9759  
Number of obs: 1865, groups:  player_id, 558

Fixed effects:
                  Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)       -0.64807    0.03080  694.42627 -21.044   <2e-16 ***
contractYearyes    0.04345    0.05353 1862.57554   0.812    0.417    
---
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.099
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_epaRush, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no           -0.648 0.0308  690   -0.709   -0.588
 yes          -0.605 0.0485 1229   -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: 5387.9

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.7451 -0.5008  0.0678  0.5769  3.4337 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   0.243750 0.49371        
           ageCentered20 0.003432 0.05858  -0.77 
 Residual                0.930638 0.96470        
Number of obs: 1865, groups:  player_id, 558

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)            -6.789e-01  1.147e-01  4.841e+02  -5.917 6.22e-09 ***
contractYearyes         7.143e-02  5.732e-02  1.647e+03   1.246    0.213    
ageCentered20           4.053e-02  3.825e-02  4.933e+02   1.060    0.290    
ageCentered20Quadratic -2.185e-03  2.710e-03  2.656e+02  -0.806    0.421    
years_of_experience    -2.905e-02  2.278e-02  5.775e+02  -1.275    0.203    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.164                     
ageCentrd20 -0.882 -0.188              
agCntrd20Qd  0.827  0.182 -0.835       
yrs_f_xprnc -0.060 -0.129 -0.278 -0.226
Code
performance::r2(mixedModelAge_epaRush)
# R2 for Mixed Models

  Conditional R2: 0.122
     Marginal R2: 0.004
Code
emmeans::emmeans(mixedModelAge_epaRush, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no           -0.658 0.0319  599   -0.720   -0.595
 yes          -0.586 0.0505 1251   -0.685   -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: 22311.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.4809 -0.4980 -0.1645  0.4193  3.9107 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 3683     60.69   
 Residual              2990     54.68   
Number of obs: 1979, groups:  player_id, 575

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       82.021      3.016  688.292  27.195  < 2e-16 ***
contractYearyes  -14.658      3.289 1704.577  -4.457 8.85e-06 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.555
     Marginal R2: 0.006
Code
emmeans::emmeans(mixedModel_fantasyPtsRush, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             82.0 3.02  627     76.1     87.9
 yes            67.4 3.90 1264     59.7     75.0

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: 22151.3

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.9394 -0.4940 -0.1411  0.4195  3.6298 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   6347.82  79.673         
           ageCentered20   54.47   7.381   -0.74 
 Residual                2607.45  51.063         
Number of obs: 1979, groups:  player_id, 575

Fixed effects:
                        Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)              72.1729     8.1190  785.9451   8.889  < 2e-16 ***
contractYearyes         -12.5928     3.4473 1689.6301  -3.653 0.000267 ***
ageCentered20            -2.7877     2.8056 1051.6854  -0.994 0.320636    
ageCentered20Quadratic   -1.2008     0.1696  587.6050  -7.080 4.12e-12 ***
years_of_experience      18.3685     2.0084  712.1883   9.146  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.168                     
ageCentrd20 -0.846 -0.150              
agCntrd20Qd  0.720  0.171 -0.727       
yrs_f_xprnc  0.203 -0.116 -0.545 -0.109
Code
performance::r2(mixedModelAge_fantasyPtsRush)
# R2 for Mixed Models

  Conditional R2: 0.618
     Marginal R2: 0.103
Code
emmeans::emmeans(mixedModelAge_fantasyPtsRush, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             84.0 2.96  636     78.2     89.8
 yes            71.4 3.80 1271     64.0     78.9

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: 35228.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.8693 -0.5238 -0.1139  0.5045  4.5870 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 274.9    16.58   
 Residual              180.4    13.43   
Number of obs: 4144, groups:  player_id, 1147

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)       24.9219     0.5696 1378.0369  43.755  < 2e-16 ***
contractYearyes   -4.2902     0.5270 3522.5864  -8.141 5.38e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.237
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 1259     23.8       26
 yes            20.6 0.678 2140     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: 34689.1

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-2.9367 -0.5202 -0.0991  0.4775  3.9966 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   511.936  22.626         
           ageCentered20   5.859   2.421   -0.70 
 Residual                134.019  11.577         
Number of obs: 4144, groups:  player_id, 1147

Fixed effects:
                        Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)              14.2374     1.4562 1622.8865   9.777  < 2e-16 ***
contractYearyes          -3.1918     0.5156 3280.7026  -6.190 6.76e-10 ***
ageCentered20             1.5577     0.5061 2311.5831   3.078  0.00211 ** 
ageCentered20Quadratic   -0.4722     0.0256 1776.3575 -18.447  < 2e-16 ***
years_of_experience       4.8877     0.4008 1398.6380  12.196  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.117                     
ageCentrd20 -0.814 -0.136              
agCntrd20Qd  0.677  0.073 -0.638       
yrs_f_xprnc  0.271  0.008 -0.667 -0.075
Code
performance::r2(mixedModelAge_receivingYards)
# R2 for Mixed Models

  Conditional R2: 0.747
     Marginal R2: 0.154
Code
emmeans::emmeans(mixedModelAge_receivingYards, "contractYear")
 contractYear emmean    SE   df lower.CL upper.CL
 no             24.2 0.588 1279     23.0     25.3
 yes            21.0 0.669 1975     19.7     22.3

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: 13543.6

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-5.6074 -0.5669 -0.0418  0.5273  3.9027 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.541    0.7356  
 Residual              1.300    1.1401  
Number of obs: 4064, groups:  player_id, 1127

Fixed effects:
                  Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)        0.67104    0.03231 1526.48873  20.772  < 2e-16 ***
contractYearyes   -0.16380    0.04314 3866.10064  -3.796 0.000149 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr)
contrctYrys -0.364
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.671 0.0323 1345    0.608    0.734
 yes           0.507 0.0435 2562    0.422    0.593

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: 13486.4

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-5.7547 -0.5629 -0.0370  0.5211  3.9493 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   0.953838 0.97665        
           ageCentered20 0.006845 0.08273  -0.70 
 Residual                1.239792 1.11346        
Number of obs: 4064, groups:  player_id, 1127

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             3.556e-01  9.968e-02  1.234e+03   3.567 0.000375 ***
contractYearyes        -1.677e-01  4.556e-02  3.766e+03  -3.681 0.000235 ***
ageCentered20           2.095e-02  3.271e-02  1.417e+03   0.641 0.521900    
ageCentered20Quadratic -1.125e-02  1.853e-03  5.724e+02  -6.071 2.32e-09 ***
years_of_experience     1.572e-01  2.304e-02  1.294e+03   6.821 1.38e-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.844 -0.191              
agCntrd20Qd  0.777  0.122 -0.737       
yrs_f_xprnc  0.110  0.011 -0.506 -0.149
Code
performance::r2(mixedModelAge_epaReceiving)
# R2 for Mixed Models

  Conditional R2: 0.335
     Marginal R2: 0.029
Code
emmeans::emmeans(mixedModelAge_epaReceiving, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no            0.689 0.0330 1272    0.624    0.754
 yes           0.521 0.0441 2592    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: 45799.7

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.2650 -0.5274 -0.1468  0.4768  4.8082 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2873     53.60   
 Residual              2447     49.47   
Number of obs: 4144, groups:  player_id, 1147

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       75.622      1.906 1425.543  39.666  < 2e-16 ***
contractYearyes  -14.811      1.925 3613.082  -7.693 1.85e-14 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.544
     Marginal R2: 0.009
Code
emmeans::emmeans(mixedModel_fantasyPtsReceiving, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             75.6 1.91 1279     71.9     79.4
 yes            60.8 2.33 2271     56.2     65.4

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: 45348.3

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.0392 -0.4987 -0.1230  0.4545  5.1688 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   5408.09  73.540         
           ageCentered20   60.18   7.757   -0.72 
 Residual                1940.67  44.053         
Number of obs: 4144, groups:  player_id, 1147

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              43.27625    5.10293 1574.12949   8.481  < 2e-16 ***
contractYearyes         -11.00339    1.92378 3440.86901  -5.720 1.16e-08 ***
ageCentered20             3.44886    1.74445 2260.96144   1.977   0.0482 *  
ageCentered20Quadratic   -1.48995    0.09213 1489.09822 -16.173  < 2e-16 ***
years_of_experience      17.18091    1.33446 1360.63480  12.875  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.123                     
ageCentrd20 -0.824 -0.149              
agCntrd20Qd  0.705  0.084 -0.669       
yrs_f_xprnc  0.232  0.008 -0.625 -0.090
Code
performance::r2(mixedModelAge_fantasyPtsReceiving)
# R2 for Mixed Models

  Conditional R2: 0.677
     Marginal R2: 0.147
Code
emmeans::emmeans(mixedModelAge_fantasyPtsReceiving, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             73.8 1.95 1281     70.0     77.6
 yes            62.8 2.29 2150     58.3     67.3

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: 76673

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.8195 -0.5134 -0.1384  0.4742  4.3861 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 3360     57.97   
 Residual              2922     54.06   
Number of obs: 6831, groups:  player_id, 1899

Fixed effects:
                 Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       151.285      5.114 2041.058   29.58   <2e-16 ***
contractYearyes   -17.117      1.672 5950.928  -10.24   <2e-16 ***
position_groupRB  -68.481      5.827 2021.976  -11.75   <2e-16 ***
position_groupTE  -90.000      6.150 2008.195  -14.63   <2e-16 ***
position_groupWR  -67.387      5.655 2020.986  -11.92   <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.080                     
postn_grpRB -0.873  0.008              
postn_grpTE -0.826 -0.007  0.725       
postn_grpWR -0.898 -0.003  0.789  0.747
Code
performance::r2(mixedModel_fantasyPts)
# R2 for Mixed Models

  Conditional R2: 0.578
     Marginal R2: 0.093
Code
emmeans::emmeans(mixedModel_fantasyPts, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             94.8 1.85 2018     91.2     98.4
 yes            77.7 2.20 3417     73.4     82.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: 76069.7

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.1103 -0.4994 -0.1206  0.4520  4.6667 

Random effects:
 Groups    Name          Variance Std.Dev. Corr  
 player_id (Intercept)   5812.12  76.24          
           ageCentered20   64.33   8.02    -0.71 
 Residual                2435.53  49.35          
Number of obs: 6831, groups:  player_id, 1899

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             127.04591    6.36402 3026.89259  19.963  < 2e-16 ***
contractYearyes         -12.78391    1.70337 5804.11965  -7.505 7.07e-14 ***
position_groupRB        -61.12324    5.68295 1938.46685 -10.756  < 2e-16 ***
position_groupTE        -83.88260    5.96603 1885.35762 -14.060  < 2e-16 ***
position_groupWR        -61.59645    5.51095 1925.81620 -11.177  < 2e-16 ***
ageCentered20            -1.77536    1.38388 3444.05994  -1.283      0.2    
ageCentered20Quadratic   -1.17681    0.07018 2010.75855 -16.769  < 2e-16 ***
years_of_experience      18.21178    1.09693 2327.31784  16.602  < 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.107                                          
postn_grpRB -0.707 -0.032                                   
postn_grpTE -0.653 -0.029  0.733                            
postn_grpWR -0.731 -0.041  0.794  0.754                     
ageCentrd20 -0.504 -0.134 -0.026 -0.042 -0.009              
agCntrd20Qd  0.460  0.091 -0.025 -0.009 -0.028 -0.642       
yrs_f_xprnc  0.077 -0.025  0.092  0.084  0.071 -0.637 -0.103
Code
performance::r2(mixedModelAge_fantasyPts)
# R2 for Mixed Models

  Conditional R2: 0.670
     Marginal R2: 0.188
Code
emmeans::emmeans(mixedModelAge_fantasyPts, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             92.1 1.84 1956     88.5     95.7
 yes            79.3 2.16 3364     75.1     83.5

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 and age 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.5.3 (2026-03-11)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.3 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.0       
 [5] purrr_1.2.1        readr_2.2.0        tidyr_1.3.2        tibble_3.3.1      
 [9] ggplot2_4.0.2      tidyverse_2.0.0    emmeans_2.0.2      performance_0.16.0
[13] lmerTest_3.2-1     lme4_2.0-1         Matrix_1.7-4       nflreadr_1.5.0    
[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       sandwich_3.1-1      rlang_1.1.7        
 [7] magrittr_2.0.4      multcomp_1.4-30     otel_0.2.0         
[10] compiler_4.5.3      vctrs_0.7.1         reshape2_1.4.5     
[13] quadprog_1.5-8      pkgconfig_2.0.3     fastmap_1.2.0      
[16] backports_1.5.0     pbivnorm_0.6.0      rmarkdown_2.30     
[19] tzdb_0.5.0          nloptr_2.2.1        xfun_0.57          
[22] cachem_1.1.0        jsonlite_2.0.0      psych_2.6.1        
[25] broom_1.0.12        parallel_4.5.3      lavaan_0.6-21      
[28] cluster_2.1.8.2     R6_2.6.1            stringi_1.8.7      
[31] RColorBrewer_1.1-3  boot_1.3-32         rpart_4.1.24       
[34] numDeriv_2016.8-1.1 estimability_1.5.1  Rcpp_1.1.1         
[37] knitr_1.51          zoo_1.8-15          base64enc_0.1-6    
[40] splines_4.5.3       nnet_7.3-20         timechange_0.4.0   
[43] tidyselect_1.2.1    rstudioapi_0.18.0   yaml_2.3.12        
[46] codetools_0.2-20    lattice_0.22-9      plyr_1.8.9         
[49] withr_3.0.2         S7_0.2.1            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.5.3       
[58] reformulas_0.4.4    insight_1.4.6       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.0         
[67] Hmisc_5.2-5         tools_4.5.3         data.table_1.18.2.1
[70] mvtnorm_1.3-6       grid_4.5.3          mitools_2.4        
[73] rbibutils_2.4.1     colorspace_2.1-2    nlme_3.1-168       
[76] htmlTable_2.4.3     Formula_1.2-5       cli_3.6.5          
[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.