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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.2357 -0.5458  0.0707  0.5740  3.2417 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 109.4    10.46   
 Residual              199.0    14.11   
Number of obs: 1127, groups:  player_id, 262

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)       44.4546     0.8505  239.3701  52.270   <2e-16 ***
contractYearyes   -0.2558     1.1552 1010.5146  -0.221    0.825    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.355
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_qbr, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             44.5 0.851 273     42.8     46.1
 yes            44.2 1.260 771     41.7     46.7

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.3590 -0.5087  0.0868  0.5523  3.2740 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   138.3788 11.7635       
           ageCentered20   0.5743  0.7578  -0.49
 Residual                190.8198 13.8138       
Number of obs: 1121, groups:  player_id, 259

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              39.96803    2.24564  197.39593  17.798  < 2e-16 ***
contractYearyes          -0.15679    1.19582 1002.68967  -0.131 0.895711    
ageCentered20             0.50198    0.64265  274.84098   0.781 0.435414    
ageCentered20Quadratic   -0.08134    0.02316  125.87449  -3.512 0.000619 ***
years_of_experience       1.11470    0.54597  298.60485   2.042 0.042062 *  
---
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.742 -0.062              
agCntrd20Qd  0.757  0.056 -0.616       
yrs_f_xprnc  0.122 -0.040 -0.672 -0.121
Code
performance::r2(mixedModelAge_qbr)
# R2 for Mixed Models

  Conditional R2: 0.393
     Marginal R2: 0.018
Code
emmeans::emmeans(mixedModelAge_qbr, "contractYear")
 contractYear emmean   SE  df lower.CL upper.CL
 no             44.4 0.89 248     42.7     46.2
 yes            44.3 1.28 718     41.8     46.8

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.7207 -0.4956  0.0890  0.5453  4.2919 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.542    1.594   
 Residual              4.277    2.068   
Number of obs: 1127, groups:  player_id, 262

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)      -0.8204     0.1278 225.7574  -6.419 7.96e-10 ***
contractYearyes  -0.1319     0.1698 995.7377  -0.777    0.437    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.373
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_ptsAdded, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no           -0.820 0.128 273    -1.07   -0.569
 yes          -0.952 0.187 760    -1.32   -0.586

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.8843 -0.5004  0.0933  0.5278  4.2890 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   3.76625  1.9407        
           ageCentered20 0.01592  0.1262   -0.62
 Residual                4.10248  2.0255        
Number of obs: 1121, groups:  player_id, 259

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             -1.516112   0.341012 190.293484  -4.446 1.49e-05 ***
contractYearyes         -0.135518   0.175633 986.952187  -0.772 0.440539    
ageCentered20            0.058746   0.096431 276.886585   0.609 0.542891    
ageCentered20Quadratic  -0.011709   0.003465 130.048786  -3.379 0.000959 ***
years_of_experience      0.189310   0.080823 288.058048   2.342 0.019848 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.058                     
ageCentrd20 -0.746 -0.063              
agCntrd20Qd  0.752  0.060 -0.626       
yrs_f_xprnc  0.133 -0.041 -0.674 -0.106
Code
performance::r2(mixedModelAge_ptsAdded)
# R2 for Mixed Models

  Conditional R2: 0.403
     Marginal R2: 0.017
Code
emmeans::emmeans(mixedModelAge_ptsAdded, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no           -0.786 0.131 251    -1.04   -0.527
 yes          -0.921 0.188 717    -1.29   -0.551

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.0580 -0.5112  0.0380  0.5455  4.4075 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.514    1.586   
 Residual              2.980    1.726   
Number of obs: 1127, groups:  player_id, 262

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       1.1296     0.1199 243.1854   9.418  < 2e-16 ***
contractYearyes   0.3820     0.1431 977.2984   2.670  0.00772 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.460
     Marginal R2: 0.004
Code
emmeans::emmeans(mixedModel_epaPass, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             1.13 0.120 272    0.893     1.37
 yes            1.51 0.166 703    1.185     1.84

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.1335 -0.5073  0.0557  0.5457  4.3347 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2.373    1.541   
 Residual              2.936    1.713   
Number of obs: 1121, groups:  player_id, 259

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             4.747e-01  2.713e-01  9.855e+02   1.750 0.080400 .  
contractYearyes         2.031e-01  1.482e-01  1.001e+03   1.370 0.170964    
ageCentered20          -4.304e-02  7.955e-02  6.794e+02  -0.541 0.588666    
ageCentered20Quadratic -5.726e-03  2.592e-03  1.056e+03  -2.209 0.027363 *  
years_of_experience     2.426e-01  7.140e-02  4.137e+02   3.398 0.000744 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.068                     
ageCentrd20 -0.718 -0.060              
agCntrd20Qd  0.729  0.061 -0.547       
yrs_f_xprnc  0.168 -0.045 -0.721 -0.148
Code
performance::r2(mixedModelAge_epaPass)
# R2 for Mixed Models

  Conditional R2: 0.466
     Marginal R2: 0.034
Code
emmeans::emmeans(mixedModelAge_epaPass, "contractYear")
 contractYear emmean    SE  df lower.CL upper.CL
 no             1.27 0.121 272     1.03     1.51
 yes            1.47 0.166 700     1.15     1.80

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.7895 -0.5641 -0.0842  0.6337  2.7383 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 6240     78.99   
 Residual              5485     74.06   
Number of obs: 1127, groups:  player_id, 262

Fixed effects:
                Estimate Std. Error      df t value Pr(>|t|)    
(Intercept)      112.130      5.733 299.833  19.557  < 2e-16 ***
contractYearyes  -29.924      6.184 990.455  -4.839 1.51e-06 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.538
     Marginal R2: 0.012
Code
emmeans::emmeans(mixedModel_fantasyPtsPass, "contractYear")
 contractYear emmean   SE  df lower.CL upper.CL
 no            112.1 5.74 272    100.8    123.4
 yes            82.2 7.60 647     67.3     97.1

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.8469 -0.5734 -0.0786  0.6368  2.5779 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 6015     77.55   
 Residual              5324     72.96   
Number of obs: 1121, groups:  player_id, 259

Fixed effects:
                        Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)             141.6572    12.0931  994.9825  11.714  < 2e-16 ***
contractYearyes         -24.5306     6.3788 1003.5089  -3.846 0.000128 ***
ageCentered20           -15.2109     3.6158  767.1802  -4.207 2.90e-05 ***
ageCentered20Quadratic   -0.1379     0.1120 1045.5987  -1.230 0.218796    
years_of_experience      16.5905     3.3191  525.5976   4.998 7.88e-07 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.069                     
ageCentrd20 -0.703 -0.057              
agCntrd20Qd  0.702  0.057 -0.514       
yrs_f_xprnc  0.200 -0.042 -0.751 -0.146
Code
performance::r2(mixedModelAge_fantasyPtsPass)
# R2 for Mixed Models

  Conditional R2: 0.562
     Marginal R2: 0.066
Code
emmeans::emmeans(mixedModelAge_fantasyPtsPass, "contractYear")
 contractYear emmean   SE  df lower.CL upper.CL
 no            114.0 5.80 274    102.6      125
 yes            89.5 7.58 638     74.6      104

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-7.9730 -0.3966  0.0041  0.4011 14.7698 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.5188   0.7203  
 Residual              1.8697   1.3674  
Number of obs: 1748, groups:  player_id, 532

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)     3.906e+00  5.146e-02 5.066e+02   75.90   <2e-16 ***
contractYearyes 9.797e-03  8.165e-02 1.678e+03    0.12    0.905    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.217
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_ypc, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no             3.91 0.0515  631     3.80     4.01
 yes            3.92 0.0788 1264     3.76     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: 6366.7

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-7.7819 -0.3857 -0.0069  0.3862 14.2027 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   0.37836  0.6151        
           ageCentered20 0.01157  0.1075   -0.31
 Residual                1.79523  1.3399        
Number of obs: 1748, groups:  player_id, 532

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             4.194e+00  1.696e-01  7.658e+02  24.727   <2e-16 ***
contractYearyes         9.504e-02  8.862e-02  1.609e+03   1.072   0.2837    
ageCentered20          -7.278e-02  5.913e-02  8.178e+02  -1.231   0.2187    
ageCentered20Quadratic -4.509e-03  4.281e-03  4.368e+02  -1.053   0.2928    
years_of_experience     6.632e-02  3.962e-02  5.394e+02   1.674   0.0947 .  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.158                     
ageCentrd20 -0.866 -0.153              
agCntrd20Qd  0.811  0.138 -0.797       
yrs_f_xprnc -0.053 -0.128 -0.320 -0.238
Code
performance::r2(mixedModelAge_ypc)
# R2 for Mixed Models

  Conditional R2: 0.272
     Marginal R2: 0.021
Code
emmeans::emmeans(mixedModelAge_ypc, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no             3.87 0.0549  551     3.76     3.97
 yes            3.96 0.0825 1248     3.80     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: 5070.9

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.5468 -0.5055  0.0777  0.5809  3.4198 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.1022   0.3197  
 Residual              0.9735   0.9867  
Number of obs: 1748, groups:  player_id, 532

Fixed effects:
                  Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)       -0.64999    0.03175  649.74193 -20.472   <2e-16 ***
contractYearyes    0.03814    0.05655 1745.99999   0.674      0.5    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.095
     Marginal R2: 0.000
Code
emmeans::emmeans(mixedModel_epaRush, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no           -0.650 0.0318  651   -0.712   -0.588
 yes          -0.612 0.0514 1167   -0.713   -0.511

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.5730 -0.4972  0.0720  0.5760  3.3878 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   0.190478 0.43644       
           ageCentered20 0.002303 0.04799  -0.69
 Residual                0.956386 0.97795       
Number of obs: 1748, groups:  player_id, 532

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)            -6.471e-01  1.183e-01  4.179e+02  -5.468 7.84e-08 ***
contractYearyes         7.658e-02  6.042e-02  1.545e+03   1.267   0.2052    
ageCentered20           3.829e-02  3.954e-02  4.009e+02   0.968   0.3335    
ageCentered20Quadratic -1.627e-03  2.788e-03  2.072e+02  -0.584   0.5600    
years_of_experience    -4.061e-02  2.405e-02  5.630e+02  -1.689   0.0918 .  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.156                     
ageCentrd20 -0.880 -0.176              
agCntrd20Qd  0.829  0.169 -0.832       
yrs_f_xprnc -0.061 -0.125 -0.284 -0.226
Code
performance::r2(mixedModelAge_epaRush)
# R2 for Mixed Models

  Conditional R2: 0.114
     Marginal R2: 0.005
Code
emmeans::emmeans(mixedModelAge_epaRush, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no           -0.663 0.0328  571   -0.728   -0.599
 yes          -0.587 0.0536 1186   -0.692   -0.482

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.1357 -0.5059 -0.1723  0.4153  3.8941 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 3557     59.64   
 Residual              3044     55.18   
Number of obs: 1846, groups:  player_id, 549

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       81.736      3.056  654.624  26.747  < 2e-16 ***
contractYearyes  -14.037      3.476 1592.046  -4.038 5.64e-05 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.541
     Marginal R2: 0.006
Code
emmeans::emmeans(mixedModel_fantasyPtsRush, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             81.7 3.06  597     75.7     87.7
 yes            67.7 4.06 1252     59.7     75.7

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.4826 -0.4970 -0.1465  0.4146  3.6037 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   6477.91  80.49         
           ageCentered20   58.84   7.67    -0.77
 Residual                2644.97  51.43         
Number of obs: 1846, groups:  player_id, 549

Fixed effects:
                        Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)              72.7332     8.6156  711.9018   8.442  < 2e-16 ***
contractYearyes         -11.5548     3.6113 1570.7539  -3.200   0.0014 ** 
ageCentered20            -3.3029     2.9726  950.1386  -1.111   0.2668    
ageCentered20Quadratic   -1.1888     0.1833  521.2083  -6.487 2.04e-10 ***
years_of_experience      18.8737     2.0589  691.5663   9.167  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.159                     
ageCentrd20 -0.856 -0.141              
agCntrd20Qd  0.735  0.160 -0.749       
yrs_f_xprnc  0.198 -0.117 -0.528 -0.098
Code
performance::r2(mixedModelAge_fantasyPtsRush)
# R2 for Mixed Models

  Conditional R2: 0.606
     Marginal R2: 0.105
Code
emmeans::emmeans(mixedModelAge_fantasyPtsRush, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             83.5 2.97  606     77.7     89.3
 yes            71.9 3.93 1256     64.2     79.7

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-4.8547 -0.5285 -0.1103  0.5071  4.5642 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 280.4    16.74   
 Residual              182.5    13.51   
Number of obs: 3849, groups:  player_id, 1088

Fixed effects:
                 Estimate Std. Error        df t value Pr(>|t|)    
(Intercept)       25.1074     0.5904 1296.8371  42.526  < 2e-16 ***
contractYearyes   -4.0835     0.5560 3258.2826  -7.345 2.59e-13 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.609
     Marginal R2: 0.007
Code
emmeans::emmeans(mixedModel_receivingYards, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             25.1 0.59 1190     23.9     26.3
 yes            21.0 0.71 2076     19.6     22.4

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-2.9029 -0.5276 -0.0953  0.4800  3.9583 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   520.008  22.804        
           ageCentered20   5.898   2.429   -0.71
 Residual                136.761  11.694        
Number of obs: 3849, groups:  player_id, 1088

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              15.03683    1.51901 1488.09149   9.899  < 2e-16 ***
contractYearyes          -3.24593    0.54450 3060.78317  -5.961 2.79e-09 ***
ageCentered20             1.18353    0.52352 2137.46690   2.261   0.0239 *  
ageCentered20Quadratic   -0.44276    0.02687 1555.27042 -16.477  < 2e-16 ***
years_of_experience       5.03286    0.41161 1324.28706  12.227  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.115                     
ageCentrd20 -0.817 -0.132              
agCntrd20Qd  0.682  0.073 -0.646       
yrs_f_xprnc  0.259  0.001 -0.654 -0.081
Code
performance::r2(mixedModelAge_receivingYards)
# R2 for Mixed Models

  Conditional R2: 0.741
     Marginal R2: 0.146
Code
emmeans::emmeans(mixedModelAge_receivingYards, "contractYear")
 contractYear emmean    SE   df lower.CL upper.CL
 no             24.6 0.605 1210     23.5     25.8
 yes            21.4 0.696 1926     20.0     22.8

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-5.5816 -0.5693 -0.0361  0.5369  3.8982 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 0.5522   0.7431  
 Residual              1.3004   1.1404  
Number of obs: 3774, groups:  player_id, 1071

Fixed effects:
                  Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)        0.66056    0.03340 1444.06144  19.778  < 2e-16 ***
contractYearyes   -0.16409    0.04521 3588.40814  -3.629 0.000288 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.300
     Marginal R2: 0.003
Code
emmeans::emmeans(mixedModel_epaReceiving, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no            0.661 0.0334 1270    0.595    0.726
 yes           0.496 0.0456 2457    0.407    0.586

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-5.4597 -0.5652 -0.0294  0.5305  3.8994 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   0.911124 0.95453       
           ageCentered20 0.006103 0.07812  -0.68
 Residual                1.246288 1.11637       
Number of obs: 3774, groups:  player_id, 1071

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             3.575e-01  1.023e-01  1.130e+03   3.494 0.000494 ***
contractYearyes        -1.735e-01  4.766e-02  3.500e+03  -3.640 0.000276 ***
ageCentered20           9.670e-03  3.352e-02  1.258e+03   0.288 0.773025    
ageCentered20Quadratic -1.093e-02  1.898e-03  4.972e+02  -5.757 1.50e-08 ***
years_of_experience     1.678e-01  2.380e-02  1.222e+03   7.053 2.93e-12 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.137                     
ageCentrd20 -0.842 -0.186              
agCntrd20Qd  0.778  0.123 -0.736       
yrs_f_xprnc  0.102  0.001 -0.502 -0.155
Code
performance::r2(mixedModelAge_epaReceiving)
# R2 for Mixed Models

  Conditional R2: 0.335
     Marginal R2: 0.031
Code
emmeans::emmeans(mixedModelAge_epaReceiving, "contractYear")
 contractYear emmean     SE   df lower.CL upper.CL
 no            0.683 0.0340 1198    0.616     0.75
 yes           0.509 0.0461 2486    0.419     0.60

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.2569 -0.5264 -0.1439  0.4776  4.6031 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 2938     54.20   
 Residual              2464     49.64   
Number of obs: 3849, groups:  player_id, 1088

Fixed effects:
                Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       76.082      1.977 1334.416  38.491  < 2e-16 ***
contractYearyes  -14.174      2.027 3339.944  -6.994 3.21e-12 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

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

  Conditional R2: 0.547
     Marginal R2: 0.008
Code
emmeans::emmeans(mixedModel_fantasyPtsReceiving, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             76.1 1.98 1208     72.2     80.0
 yes            61.9 2.44 2199     57.1     66.7

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.0232 -0.5011 -0.1202  0.4586  4.9595 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   5542.78  74.450        
           ageCentered20   62.56   7.909   -0.73
 Residual                1958.83  44.259        
Number of obs: 3849, groups:  player_id, 1088

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)              43.92080    5.32048 1446.59427   8.255 3.38e-16 ***
contractYearyes         -11.38928    2.02175 3200.81182  -5.633 1.92e-08 ***
ageCentered20             2.81433    1.80559 2093.49796   1.559    0.119    
ageCentered20Quadratic   -1.43481    0.09652 1345.60860 -14.865  < 2e-16 ***
years_of_experience      17.66607    1.37263 1289.05418  12.870  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) cntrcY agCn20 agC20Q
contrctYrys  0.120                     
ageCentrd20 -0.826 -0.143              
agCntrd20Qd  0.708  0.083 -0.676       
yrs_f_xprnc  0.221  0.000 -0.614 -0.094
Code
performance::r2(mixedModelAge_fantasyPtsReceiving)
# R2 for Mixed Models

  Conditional R2: 0.674
     Marginal R2: 0.143
Code
emmeans::emmeans(mixedModelAge_fantasyPtsReceiving, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             75.1 2.01 1210     71.1     79.0
 yes            63.7 2.38 2086     59.0     68.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: 71246.5

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.7607 -0.5148 -0.1425  0.4754  4.2032 

Random effects:
 Groups    Name        Variance Std.Dev.
 player_id (Intercept) 3360     57.97   
 Residual              2940     54.22   
Number of obs: 6342, groups:  player_id, 1803

Fixed effects:
                 Estimate Std. Error       df t value Pr(>|t|)    
(Intercept)       154.301      5.296 1926.481   29.14   <2e-16 ***
contractYearyes   -16.377      1.757 5508.788   -9.32   <2e-16 ***
position_groupRB  -72.000      6.020 1907.095  -11.96   <2e-16 ***
position_groupTE  -93.584      6.345 1893.898  -14.75   <2e-16 ***
position_groupWR  -69.240      5.851 1906.016  -11.83   <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.084                     
postn_grpRB -0.875  0.013              
postn_grpTE -0.829 -0.003  0.729       
postn_grpWR -0.899  0.002  0.791  0.750
Code
performance::r2(mixedModel_fantasyPts)
# R2 for Mixed Models

  Conditional R2: 0.579
     Marginal R2: 0.097
Code
emmeans::emmeans(mixedModel_fantasyPts, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             95.6 1.90 1914     91.9     99.3
 yes            79.2 2.28 3308     74.7     83.7

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

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-3.7078 -0.5030 -0.1205  0.4497  4.4839 

Random effects:
 Groups    Name          Variance Std.Dev. Corr 
 player_id (Intercept)   5990.83  77.400        
           ageCentered20   70.11   8.373   -0.73
 Residual                2435.30  49.349        
Number of obs: 6342, groups:  player_id, 1803

Fixed effects:
                         Estimate Std. Error         df t value Pr(>|t|)    
(Intercept)             128.08019    6.62585 2845.54840  19.330  < 2e-16 ***
contractYearyes         -12.64221    1.78301 5365.14944  -7.090 1.51e-12 ***
position_groupRB        -63.76276    5.86196 1835.98555 -10.877  < 2e-16 ***
position_groupTE        -86.12368    6.14762 1788.80770 -14.009  < 2e-16 ***
position_groupWR        -63.06668    5.69118 1823.91009 -11.081  < 2e-16 ***
ageCentered20            -1.67954    1.44430 3188.30661  -1.163    0.245    
ageCentered20Quadratic   -1.18708    0.07448 1895.71369 -15.938  < 2e-16 ***
years_of_experience      18.59106    1.13641 2221.16240  16.359  < 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.100                                          
postn_grpRB -0.705 -0.027                                   
postn_grpTE -0.651 -0.026  0.738                            
postn_grpWR -0.729 -0.037  0.797  0.757                     
ageCentrd20 -0.510 -0.126 -0.027 -0.046 -0.004              
agCntrd20Qd  0.468  0.085 -0.026 -0.011 -0.030 -0.651       
yrs_f_xprnc  0.071 -0.031  0.097  0.093  0.067 -0.626 -0.104
Code
performance::r2(mixedModelAge_fantasyPts)
# R2 for Mixed Models

  Conditional R2: 0.672
     Marginal R2: 0.190
Code
emmeans::emmeans(mixedModelAge_fantasyPts, "contractYear")
 contractYear emmean   SE   df lower.CL upper.CL
 no             92.9 1.89 1865     89.2     96.7
 yes            80.3 2.25 3250     75.9     84.7

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.1 (2025-06-13)
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.4    forcats_1.0.0      stringr_1.5.1      dplyr_1.1.4       
 [5] purrr_1.1.0        readr_2.1.5        tidyr_1.3.1        tibble_3.3.0      
 [9] ggplot2_3.5.2      tidyverse_2.0.0    emmeans_1.11.2-8   performance_0.15.1
[13] lmerTest_3.1-3     lme4_1.1-37        Matrix_1.7-3       nflreadr_1.5.0    
[17] petersenlab_1.2.0 

loaded via a namespace (and not attached):
 [1] tidyselect_1.2.1    psych_2.5.6         viridisLite_0.4.2  
 [4] farver_2.1.2        fastmap_1.2.0       TH.data_1.1-4      
 [7] digest_0.6.37       rpart_4.1.24        timechange_0.3.0   
[10] estimability_1.5.1  lifecycle_1.0.4     cluster_2.1.8.1    
[13] survival_3.8-3      magrittr_2.0.3      compiler_4.5.1     
[16] rlang_1.1.6         Hmisc_5.2-3         tools_4.5.1        
[19] yaml_2.3.10         data.table_1.17.8   knitr_1.50         
[22] htmlwidgets_1.6.4   mnormt_2.1.1        plyr_1.8.9         
[25] RColorBrewer_1.1-3  multcomp_1.4-28     withr_3.0.2        
[28] foreign_0.8-90      numDeriv_2016.8-1.1 nnet_7.3-20        
[31] grid_4.5.1          stats4_4.5.1        lavaan_0.6-19      
[34] xtable_1.8-4        colorspace_2.1-1    scales_1.4.0       
[37] MASS_7.3-65         insight_1.4.2       cli_3.6.5          
[40] mvtnorm_1.3-3       rmarkdown_2.29      reformulas_0.4.1   
[43] generics_0.1.4      rstudioapi_0.17.1   tzdb_0.5.0         
[46] reshape2_1.4.4      minqa_1.2.8         DBI_1.2.3          
[49] cachem_1.1.0        splines_4.5.1       parallel_4.5.1     
[52] base64enc_0.1-3     mitools_2.4         vctrs_0.6.5        
[55] sandwich_3.1-1      boot_1.3-31         jsonlite_2.0.0     
[58] hms_1.1.3           pbkrtest_0.5.5      Formula_1.2-5      
[61] htmlTable_2.4.3     glue_1.8.0          nloptr_2.2.1       
[64] codetools_0.2-20    stringi_1.8.7       gtable_0.3.6       
[67] quadprog_1.5-8      pillar_1.11.0       htmltools_0.5.8.1  
[70] R6_2.6.1            Rdpack_2.6.4        mix_1.0-13         
[73] evaluate_1.0.5      pbivnorm_0.6.0      lattice_0.22-7     
[76] rbibutils_2.3       backports_1.5.0     broom_1.0.9        
[79] memoise_2.0.1       Rcpp_1.1.0          coda_0.19-4.1      
[82] gridExtra_2.3       nlme_3.1-168        checkmate_2.3.3    
[85] xfun_0.53           zoo_1.8-14          pkgconfig_2.0.3    

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.