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
/tmp/ipykernel_6649/4158155553.py:4: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  best_player["VONPP"] = best_player[PROJECTION_COL] - avg_next_player
/tmp/ipykernel_6649/4158155553.py:4: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  best_player["VONPP"] = best_player[PROJECTION_COL] - avg_next_player
OverallRank                     19
Name           Christian McCaffrey
Code                           RB1
Team                            SF
FantasyPts                   247.4
Position                        RB
Rank                             1
VONPP                    60.752632
Name: 18, dtype: object

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)
[('Travis Kelce', 'TE', 114.77368421052631),
 ('Josh Allen', 'QB', 81.38421052631583),
 ('Christian McCaffrey', 'RB', 60.75263157894736),
 ('Justin Jefferson', 'WR', 47.85789473684213),
 ('Dallas Cowboys', 'DST', 15.515789473684222),
 ('Justin Tucker', 'K', 8.310526315789502)]

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)
OverallRank Name Code Team FantasyPts Position Rank VONPP LookAhead
0 31 Travis Kelce TE1 KC 227.9 TE 1 122.866667 10
1 19 Christian McCaffrey RB1 SF 247.4 RB 1 88.000000 17
2 1 Josh Allen QB1 BUF 372.5 QB 1 79.000000 8
3 25 Austin Ekeler RB2 LAC 238.3 RB 2 73.500000 13
4 2 Jalen Hurts QB2 PHI 369.0 QB 2 66.866667 6
5 31 Bijan Robinson RB3 ATL 227.9 RB 3 55.133333 9
6 3 Patrick Mahomes QB3 KC 361.0 QB 3 51.933333 4
7 61 Mark Andrews TE2 BAL 171.6 TE 2 31.733333 2
8 4 Joe Burrow QB4 CIN 345.7 QB 4 28.633333 2
9 5 Lamar Jackson QB5 BAL 325.4 QB 5 8.333333 1
10 18 Justin Jefferson WR1 MIN 252.7 WR 1 84.433333 19
11 20 Tyreek Hill WR2 MIA 245.9 WR 2 76.233333 17
12 22 Ja'Marr Chase WR3 CIN 244.0 WR 3 72.966667 15
13 23 Cooper Kupp WR4 LAR 243.3 WR 4 68.200000 13
14 38 Tony Pollard RB4 DAL 221.0 RB 4 56.200000 11
15 39 Nick Chubb RB5 CLE 219.9 RB 5 53.133333 9
16 43 Derrick Henry RB6 TEN 214.2 RB 6 45.200000 7
17 46 Saquon Barkley RB7 NYG 213.6 RB 7 40.833333 5
18 48 Josh Jacobs RB8 LV 207.5 RB 8 31.700000 3
19 30 Stefon Diggs WR5 BUF 229.6 WR 5 5.300000 1
20 35 A.J. Brown WR6 PHI 222.1 WR 6 64.333333 19
21 37 Garrett Wilson WR7 NYJ 221.2 WR 7 61.975000 17
22 44 CeeDee Lamb WR8 DAL 214.1 WR 8 51.200000 15
23 45 Amon-Ra St. Brown WR9 DET 213.9 WR 9 48.766667 13
24 47 Davante Adams WR10 LV 212.9 WR 10 45.900000 11
25 49 Chris Olave WR11 NO 202.2 WR 11 33.933333 9
26 51 Tee Higgins WR12 CIN 199.5 WR 12 29.833333 7
27 52 Jaylen Waddle WR13 MIA 196.2 WR 13 25.166667 5
28 53 DK Metcalf WR14 SEA 194.3 WR 14 19.200000 3
29 54 DeVonta Smith WR15 PHI 188.7 WR 15 7.433333 1
30 55 Rhamondre Stevenson RB9 NE 183.9 RB 9 49.133333 19
31 6 Justin Herbert QB6 LAC 314.0 QB 6 42.100000 8
32 7 Justin Fields QB7 CHI 311.8 QB 7 43.366667 8
33 57 Najee Harris RB10 PIT 178.0 RB 10 32.966667 13
34 58 Jahmyr Gibbs RB11 DET 177.2 RB 11 30.533333 11
35 56 Calvin Ridley WR16 JAC 181.8 WR 16 24.033333 9
36 8 Trevor Lawrence QB8 JAC 301.4 QB 8 22.733333 4
37 9 Deshaun Watson QB9 CLE 293.2 QB 9 10.733333 2
38 99 T.J. Hockenson TE3 MIN 142.6 TE 3 7.666667 2
39 107 Darren Waller TE4 NYG 139.2 TE 4 4.266667 1
40 10 Geno Smith QB10 SEA 285.9 QB 10 48.833333 10
41 11 Daniel Jones QB11 NYG 284.1 QB 11 43.700000 8
42 12 Dak Prescott QB12 DAL 277.4 QB 12 40.333333 8
43 13 Tua Tagovailoa QB13 MIA 274.5 QB 13 34.100000 6
44 14 Kirk Cousins QB14 MIN 271.8 QB 14 34.733333 6
45 15 Anthony Richardson QB15 IND 269.4 QB 15 29.000000 4
46 16 Jared Goff QB16 DET 264.1 QB 16 27.033333 4
47 17 Aaron Rodgers QB17 NYJ 261.3 QB 17 20.900000 2
48 110 George Kittle TE5 SF 137.8 TE 5 14.300000 2
49 142 Kyle Pitts TE6 ATL 127.8 TE 6 4.300000 1

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)
OverallRank Name Code Team FantasyPts Position Rank VONPP LookAhead
0 31 Travis Kelce TE1 KC 227.9 TE 1 98.600000 5
1 19 Christian McCaffrey RB1 SF 247.4 RB 1 71.600000 10
2 25 Austin Ekeler RB2 LAC 238.3 RB 2 65.533333 10
3 31 Bijan Robinson RB3 ATL 227.9 RB 3 58.900000 10
4 1 Josh Allen QB1 BUF 372.5 QB 1 55.433333 5
5 2 Jalen Hurts QB2 PHI 369.0 QB 2 59.933333 5
6 3 Patrick Mahomes QB3 KC 361.0 QB 3 58.866667 5
7 38 Tony Pollard RB4 DAL 221.0 RB 4 54.233333 10
8 39 Nick Chubb RB5 CLE 219.9 RB 5 55.100000 10
9 4 Joe Burrow QB4 CIN 345.7 QB 4 52.200000 5
10 43 Derrick Henry RB6 TEN 214.2 RB 6 50.733333 10
11 46 Saquon Barkley RB7 NYG 213.6 RB 7 51.700000 10
12 48 Josh Jacobs RB8 LV 207.5 RB 8 48.100000 10
13 61 Mark Andrews TE2 BAL 171.6 TE 2 48.100000 5
14 18 Justin Jefferson WR1 MIN 252.7 WR 1 47.833333 10
15 20 Tyreek Hill WR2 MIA 245.9 WR 2 46.600000 10
16 22 Ja'Marr Chase WR3 CIN 244.0 WR 3 47.333333 10
17 23 Cooper Kupp WR4 LAR 243.3 WR 4 50.233333 10
18 30 Stefon Diggs WR5 BUF 229.6 WR 5 41.333333 10
19 35 A.J. Brown WR6 PHI 222.1 WR 6 40.833333 10
20 37 Garrett Wilson WR7 NYJ 221.2 WR 7 46.100000 10
21 44 CeeDee Lamb WR8 DAL 214.1 WR 8 43.066667 10
22 45 Amon-Ra St. Brown WR9 DET 213.9 WR 9 44.233333 10
23 47 Davante Adams WR10 LV 212.9 WR 10 44.633333 10
24 5 Lamar Jackson QB5 BAL 325.4 QB 5 37.666667 5
25 49 Chris Olave WR11 NO 202.2 WR 11 35.200000 10
26 51 Tee Higgins WR12 CIN 199.5 WR 12 34.366667 10
27 52 Jaylen Waddle WR13 MIA 196.2 WR 13 33.300000 10
28 53 DK Metcalf WR14 SEA 194.3 WR 14 35.075000 10
29 6 Justin Herbert QB6 LAC 314.0 QB 6 31.533333 5
30 7 Justin Fields QB7 CHI 311.8 QB 7 33.133333 5
31 54 DeVonta Smith WR15 PHI 188.7 WR 15 30.933333 10
32 55 Rhamondre Stevenson RB9 NE 183.9 RB 9 28.100000 10
33 8 Trevor Lawrence QB8 JAC 301.4 QB 8 26.833333 5
34 57 Najee Harris RB10 PIT 178.0 RB 10 26.433333 10
35 58 Jahmyr Gibbs RB11 DET 177.2 RB 11 28.300000 10
36 60 Breece Hall RB12 NYJ 172.2 RB 12 25.533333 10
37 56 Calvin Ridley WR16 JAC 181.8 WR 16 24.833333 10
38 65 Kenneth Walker III RB13 SEA 168.9 RB 13 23.866667 10
39 99 T.J. Hockenson TE3 MIN 142.6 TE 3 23.733333 5
40 107 Darren Waller TE4 NYG 139.2 TE 4 24.300000 5
41 110 George Kittle TE5 SF 137.8 TE 5 28.533333 5
42 68 Alexander Mattison RB14 MIN 165.9 RB 14 23.100000 10
43 70 Joe Mixon RB15 CIN 165.5 RB 15 24.100000 10
44 72 Aaron Jones RB16 GB 163.0 RB 16 23.600000 10
45 73 Travis Etienne Jr. RB17 JAC 161.9 RB 17 24.900000 10
46 74 Dameon Pierce RB18 HOU 160.8 RB 18 26.033333 10
47 142 Kyle Pitts TE6 ATL 127.8 TE 6 22.766667 5
48 79 Miles Sanders RB19 CAR 155.5 RB 19 22.333333 10
49 9 Deshaun Watson QB9 CLE 293.2 QB 9 21.300000 5

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