Fantasy football, using draft order to identify relative opportunities
- Overview
- Player rankings and projections are constant
- Value over next possible player
- VONPP exercise
- Ranking the entire pool of players
- Looking at the results of our rankings
Overview
An ongoing thought process I've had within fantasy football has been the concept of relative opportunity. Even if one player is projected to score more points, the introduction of positions means we need to consider how many points we lose out in if we opt for Outstanding QB and Decent WR versus Outstanding WR and Decent QB. At any particular draft position, we must consider what players we can draft now versus what players we will be able to draft later.
A big question mark becomes, how do we simply characterize the next-best-possible player when examining the relative opportunity of best-possible player versus next-best-possible? How can this be used to determine a draft strategy?
Player rankings and projections are constant
Before a draft begins, under a particular league setting (0.5 PPR), there is a true ranking and projection of football players. During the draft, any arbitrary selection of players does not affect the projections and rankings of the remaining football players. If we utilize any set of scoring projections, like this one I found, we assume these to be correct.
Further, among any particular position, there will always be a well-defined best player available. We know all WR1-30 and their associated order. If WR1 is available, then WR1 is the best player available. At no other point would we ever consider WR2 unless WR1 was drafted. Similarly, we would never consider WR3 unless both WR1 and WR2 have been drafted. Thus, at any moment in time, we never need to consider the body of all possible players; we only need to consider the best available QB/RB/WR/TE and pick 1 player among the 4.
Among the best available QB/RB/WR/TE, we then turn to relative opportunity amongst these 4 players.
Value over next possible player
We characterize this relative opportunity with the Value Over Next Possible Player (VONPP).
If we have the first round first pick, our pool of players is QB1, RB1, WR1, TE1. If we are in a 10 team league, then we also need to consider what players are available 19 picks later: what is next-QB, next-RB, next-WR, next-TE that we could potentially draft?
The relative opportunities at each position then become:
- QB1 vs next-QB
- RB1 vs next-RB
- WR1 vs next-WR
- TE1 vs next-TE
If we compare the fantasy output between these two players, we can quantify the VONPP.
VONPP exercise
We can load the following half PPR projections and take a look into the RB position
from pathlib import Path
import re
import numpy as np
import pandas as pd
POSITIONS = ["QB", "WR", "RB", "TE", "K", "D/ST"]
def parse_code(val):
match_pattern = r"([A-Z]*)([0-9]*)"
matched = re.match(match_pattern, val)
parsed = {"Position": matched.group(1), "Rank": int(matched.group(2))}
return pd.Series(parsed)
def load_betiq():
df = (
pd.read_csv("files/2023fantasypointsprojections.csv")
.rename(columns={"Rank": "OverallRank"})
)
parsed_codes = df["Code"].apply(parse_code)
df = df.merge(parsed_codes, left_index=True, right_index=True)
return df
df = load_betiq()
PROJECTION_COL = "FantasyPts"
subdf = df.groupby("Position").get_group("RB")
In a 10-team league:
If we draft at 1, we don't draft again until 20. We need to understand the next possible player we might draft. This could be drafting the 2nd best RB or the 20th best RB. To understand the opportunity, we to compare the rank 1 player against a representative player from rank 2-20.
In this exercise, our representative player has the fantasy output of the average of ranks 2-20. Our RB1, McCaffrey, has fantasy output of 247.4, which is 60.8 above our next possible player
best_player = subdf.iloc[0]
count_forward = 20
avg_next_player = subdf.iloc[1:count_forward][PROJECTION_COL].mean()
best_player["VONPP"] = best_player[PROJECTION_COL] - avg_next_player
best_player
We can extend this VONPP approach to all other positions.
Again, since we only have to consider the best available player at each position, our draft choices are restricted to 6 players (well really only 4 because D/ST and K are low priority)
best_players_per_position = []
for pos, subdf in df.groupby("Position"):
best_player = subdf.iloc[0]
count_forward = 20
avg_next_player = subdf.iloc[1:count_forward][PROJECTION_COL].mean()
vonpp = best_player[PROJECTION_COL] - avg_next_player
best_players_per_position.append((best_player["Name"], best_player["Position"], vonpp))
sorted(best_players_per_position, key=lambda combo: combo[2], reverse=True)
Ranking the entire pool of players
Building a consolidated ranking of all players based on VONPP.
- Assume our look-ahead period for our next possible player is always the next 20 spots?
- Can look ahead X spots based on a snake draft.
- Can look ahead a fixed Y spots to keep things simple.
- If we're looking at QB/TE, we can look ahead a fewer number of spots (assume no one will draft 2 QBs or 2 TEs probably)
- For each position, compute the VONPP of the best-available player against the next possible player
- To aggregate the different positions, first look at the highest ranking, available player in each position. For example, the rank 1 player for QB/WR/RB/TE. This is the pool of players we may draft from
- With 4 players and their associated VONPPs, we can select the player with the highest VONPP.
- The pool of available players has shrunk by one. More importantly, the one of the best available players will change because he ha been drafted.
- For example, assume a WR gets drafted because he has the highest VONPP.
- Our subsequent pool of players becomes rank 1 player for QB/RB/TE and rank 2 player for WR.
- This can be implemented by using a combination of queues
class PlayerRanker:
def __init__(self, df, n_teams=10, log=True):
self.draft_ranking = [] # Store our ranking
self.n_teams = n_teams
# For each position, create a queue of players
# where the better players are at the top of the queue
self.position_queue = dict()
for pos in POSITIONS:
self.position_queue[pos] = list(
df[df["Position"]==pos]
.to_dict(orient="index")
.values()
)
# Create a 6-pair dictionary summarizing the various available positions
self.best_player_available = {
pos: None
for pos in POSITIONS
}
self.log = log
def find_best_player_available(self):
""" Use the position queue to fill the best player available dictionary """
for pos in POSITIONS:
if (
(self.best_player_available[pos] is None) and
(len(self.position_queue[pos]) > 0)
):
self.best_player_available[pos] = self.position_queue[pos].pop(0)
elif len(self.position_queue[pos]) == 0:
self.best_player_available[pos] = None
else:
pass
def compute_vonpp(self, df, size=100, look_ahead_strategy="hybrid", blur=1):
""" Iteratively select players based on VONPP
This means, one-by-one, we try drafting players using the VONPP strategy
and modify the pool of available players as we draft.
Understanding that fantasy projections and picking a representative player is not perfect,
we include a `blur` to say we identify the next possible player by taking our lookahead +/- 1,
so we look at 3 players to derive the next possible player
"""
for pick in range(1, size):
# Find the best player available at each position
self.find_best_player_available()
# Given the pick number, deduece the round and draft position
round_number = 1 + ((pick - 1) // self.n_teams)
is_odd_round = (round_number % 2 == 1)
draft_position = (pick % self.n_teams)
if draft_position == 0:
draft_position = self.n_teams
# Derive the "look ahead" (how we identify the next possible player)
if look_ahead_strategy == "simulate":
# If we simulate, then our look ahead is based on the draft position
# and where we end up next in a snake draft
look_ahead = (2 * (self.n_teams - draft_position)) + 1
elif isinstance(look_ahead_strategy, int):
# For simplicity, can also hard-sepcify a look ahead integer
look_ahead = look_ahead_strategy
else:
raise ValueError(f"Lookahead strategy {look_ahead_strategy} invalid")
# Our player pool is a compilation of best availble players
# and associated VONPPs
player_pool = []
for pos in POSITIONS:
best_player_available = self.best_player_available[pos]
if best_player_available is not None:
# Modify our lookahead based on the position
if pos in ["QB", "TE"]:
count_forward = max(round(look_ahead/2), 1)
else:
count_forward = look_ahead
# Determine our next possible player
# By looking at the players around a certain rank
avg_next_player = (
df[
(df["Position"] == pos) &
(
(df["Rank"] >= best_player_available["Rank"] + count_forward - blur) &
(df["Rank"] <= best_player_available["Rank"] + count_forward + blur)
)
]
[PROJECTION_COL]
.mean()
)
# Compute VONPP by looking at best available player and
# next possible player
vonpp = best_player_available[PROJECTION_COL] - avg_next_player
player_pool.append( (best_player_available, vonpp, count_forward) )
if self.log:
print(f"Candidate position: {pos}")
print(f"Best player available: {best_player_available}")
print(f"VONPP: {vonpp}")
print(f"Count forward: {count_forward}")
# From the player pool, find the player with the highest VONPP
person_to_draft = max(
player_pool,
key=lambda combos: combos[1]
)
# Add to our draft list
to_draft = {**person_to_draft[0], "VONPP": person_to_draft[1], "LookAhead": person_to_draft[2]}
self.draft_ranking.append(to_draft)
self.best_player_available[to_draft["Position"]] = None
# Ranking strategy by simulating a snake draft
ranker = PlayerRanker(df, n_teams=10, log=False)
ranker.compute_vonpp(df, size=150, look_ahead_strategy="simulate")
simulated_ranking = pd.DataFrame(ranker.draft_ranking)
# Ranking strategy just by looking 10 places ahead (RB1 versus RB11, WR2 versus WR12)
ranker = PlayerRanker(df, n_teams=10, log=False)
ranker.compute_vonpp(df, size=150, look_ahead_strategy=10)
lookahead_ranking = pd.DataFrame(ranker.draft_ranking)
Looking at the results of our rankings
First, we look at our simulated ranking, where we flexibly defined our next possible player based on where we were in our picks.
This can be a little risky, because our look aheads are variable and might not reflect real draft events, thus the lookaheads are even more incorrect. We assume first round second pick has to look 17 places down the WR/TE/QB list, which is a worst-case estimate if everyone else drafts the same position as you. We may have deduced WR1 versus WR18 to have a huge gap (and acted upon that), but perhaps other league managers will draft other positions, so we really should have been comparing WR1 versus WR14 if that came out to be next-best available WR
Interestingly, we see WR1 is not drafted until 2.01. Lots of QBs drafted in the first round, and even TE2 drafted first round.
I think what I am trying to most rationalize is the early QB. It's true that QB quality can quickly fall off after the top3-5 QBs, so there can be a strong opportunity here.
However, this drafting approach doesn't strongly look into roster compositions, where we generally fill a roster with lots of WR/RB, so we go deep into the wR/RB rankings.
What I think this helps us obtain is a highly opportunistic, high trade value team. It seems following this ranking system could mean we end up with a lot of QBs. While we can't play all of our QBs, we've properly seized on the opportunity to draft them if, at the time, they were the relatively best players available at the time
simulated_ranking.head(50)
The fixed lookahead of 10 positions yields a somewhat different ranking -- bumping RBs up, QBs down, WRs slightly down.
This highlights the sensitivty of our lookahead periods in our VONPP approach.
lookahead_ranking.head(50)
Post-mortem comments
After thinking about this more, if people in your league don't draft best player available by your rankings, it's possible that WR2 and WR3 might get drafted before WR1. In this scenario, WR1's VONPP has gone up, as the rest of the field has declined relative to WR1.
I've also made some changes to account for superflex and fixed some code to be a little cleaner.
I refer readers to the full-fledged repo https://github.com/ahy3nz/ffdraftbuddy/tree/main to see the most up-to-date implementation