Skip to Content
Course content

222: Fantasy Sports Projection Models

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When people talk about "projection models" in fantasy sports, they often make it sound like they're running a hedge fund's quantitative desk. In reality, most successful models are just a series of informed guesses backed by weighted averages and regression to the mean. We aren't trying to predict the future with 100% accuracy—that's impossible—we're just trying to be slightly less wrong than our league mates.

For this lesson, we're going to build a simple projection tool for NFL Wide Receivers. Our goal is to estimate "Expected Points" based on two key metrics: Targets per Game and Points per Target. I prefer this over just looking at total points because targets are a much more stable predictor of future success than actual scoring.

Setting up the player slate

I'll start by creating a small data frame. In a real scenario, you'd be pulling this from an API or a CSV, but let's keep our focus on the logic. I'm including a mix of high-volume stars and "boom-or-bust" players to see how the model handles them.

# Our sample player data
players <- data.frame(
  name = c("Justin Jefferson", "Garrett Wilson", "Rashid Shaheed", "Zay Flowers"),
  targets_per_game = c( 10.2, 9.1, 4.5, 6.2),
  pts_per_target = c( 1.4, 1.1, 1.8, 1.2),
  games_played = c(12, 11, 10, 13)
)

The first pass at the projection

The basic logic is simple: Expected Points = Targets per Game * Points per Target. I'll use a vectorized operation here because it's what R is built for. No need for loops when we can just multiply the columns.

# Simple projection
players$proj_points <- players$targets_per_game * players$pts_per_target
print(players)

At first glance, this looks fine. But if you look at Rashid Shaheed in our list, he has a very high points-per-target (1.8). If we trust this number blindly, we're assuming he'll keep scoring an unsustainable amount of points every time he's targeted. This is where I usually mess up my first draft of a model: I trust the "efficiency" metric too much without considering the sample size.

Correcting for the "Efficiency Trap"

Here's where I made my mistake: I ignored the fact that high efficiency on low volume is usually a fluke (or "luck"). To fix this, I'm going to implement a basic "regress to the mean" adjustment. I'll define a league-average points-per-target (let's say 1.1) and blend the player's actual efficiency with that average based on how many games they've played.

I'll use a weight of 0.7 for the player's own data and 0.3 for the league average. This "shrinks" the outliers back toward the middle, making the projection more conservative and realistic.

league_avg_ppt <- 1.1

# Correcting the projection using a weighted average
players$adjusted_ppt <- (players$pts_per_target * 0.7) + (league_avg_ppt * 0.3)
players$final_proj <- players$targets_per_game * players$adjusted_ppt

# Compare the raw projection vs the adjusted one
players[, c("name", "proj_points", "final_proj")]

See the difference? The "boom" players get pulled down slightly, and the under-performers get a small bump. It's a simple tweak, but it prevents you from overvaluing a player who had two lucky long touchdowns in a three-game span.

Turning the logic into a projection engine

Since we'll likely want to run this for multiple positions or different weightings, I'll wrap this into a function. This makes the code dry and allows us to experiment with the "shrinkage" factor without rewriting the math every time.

project_fantasy_points <- function(targets, efficiency, avg_eff = 1.1, weight = 0.7) {
  adj_eff <- (efficiency * weight) + (avg_eff * (1 - weight))
  return(targets * adj_eff)
}

# Now I can apply it cleanly across the data frame
players$final_proj <- project_fantasy_points(
  players$targets_per_game, 
  players$pts_per_target
)

Now we have a repeatable process. If you find that the league average changes as the season progresses, you just update the avg_eff argument rather than hunting through your code for every instance of "1.1".




📋 Practical Task

Build a Dual-Threat QB Projection Model

Using the logic from the lesson, create a projection model for Quarterbacks. QBs are trickier because they score points from both passing and rushing.

Your task:

  1. Create a data frame of 3-5 QBs with the following columns: name, pass_atts_per_game, pts_per_pass_att, rush_atts_per_game, and pts_per_rush_att.
  2. Write a function called project_qb_points that takes these four metrics as arguments.
  3. Inside the function, apply a "regress to the mean" adjustment to the pts_per_pass_att using a league average of 0.12 and a weight of 0.8.
  4. Return the sum of the adjusted passing projection and the raw rushing projection (assume rushing efficiency is stable for this exercise).
  5. Apply this function to your data frame to generate a final_projection column.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.