The End of All (most) Fantasy Football Arguments

Show code
import os
import numpy as np
import pandas as pd
from scipy.optimize import curve_fit
from collections import defaultdict
Show code
import plotly as py
from plotly.subplots import make_subplots
import plotly.graph_objs as go
import plotly.express as px
import plotly.figure_factory as ff

For the 60M in North America who participate in weekly, head-to-head fantasy football leagues, a certain genre of arguments rages weekly. The team that boasts the week’s second-highest score, but draws the short straw in facing the week’s juggernaut righly considers themselves ‘unlucky.’ Conversely, the team posting the week’s second-lowest score, but emerging victorious improbably by virtue of matching up with the week’s poorest offering has seen the fates smile upon them.

When these outcomes occur, the justifications, rationalizations, recriminations, and kernels of multi-year grudges begin heating. Or, as a famously narcissistic wide receiver once said, “get your popcorn ready!” Well, we want to put an end to that (the arguments, not the pontifications of narcissistic wide receivers, those are just too much fun!).

Read more about methods, philosophies, and inappropriate comments here.

The Next Generation of Fantasy Football

The rise in popularity of daily fantasy football leagues exists, in part, because it allows thousands to compete, rather than 10-12. However, in so doing, the frenetic drafts and auctions, followed by season-long management disappears. A league of 1,000 cannot exist in a season-long format - there simply aren’t enough players roaming real-life gridirons on Sunday afternoon! However, given a standard league size and scoring system, and given some baseline managerial competence, we form divisions that all can compete for a title. The “real standings” tell a story independent of opponent. The scale of daily fantasy, the thrill of season-long drafts, auctions, and management!

Show code
params = {} # In case any bespoke parameters are required for future feature development
Show code
# Read some sample data
f_dir = os.path.join('raw_inputs')
f_name = 'ffb_games_hackathon.csv'
ffb_games = pd.read_csv(os.path.join(f_dir, f_name))
Show code
ffb_games
Points Team Win Loss
0 170.76 AgencyIncreasers 1 0
1 165.76 Burninators 1 0
2 159.48 Cantakerouses 1 0
3 158.82 Doomscrolls 1 0
4 158.56 Exorcists 1 0
... ... ... ... ...
379 103.44 Hackathoners 1 0
380 94.04 Instigators 0 1
381 50.54 JQueries 0 0
382 81.26 Kodachromes 0 1
383 125.28 LowlyLlamas 1 0

384 rows × 4 columns

Show code
# For the sigmoid fitting, partition the example data into the subset containing wins and losses
wins = ffb_games[ffb_games.Win == 1] 
losses = ffb_games[ffb_games.Loss == 1]
Show code
def sigmoid(x, x0, k):
    """Fit the two parameters of a sigmoid function to a 1-D array of data"""
    y = 1 / (1 + np.exp(-k*(x-x0)))
    return (y)
Show code
def add_real_wins(ffb_games, popt):
    """Given an object containing team names and scores, return the expected wins associated with each,
    given the sigmoid function we have recently fit."""
    ffb_games['real_wins'] = [1 / (1 + np.exp(-1*popt[1]*(x-popt[0]))) for x in ffb_games.Points]
Show code
def generate_standings(ffb_games):
    """Given an object containing team_names and game scores, run the sigmoid 
    model and return the binary and expected number of wins and losses for each roster"""
    real_standings = defaultdict(list)
    for team in pd.unique(ffb_games.Team):
        real_standings['team'].append(team)
        
        team_games = ffb_games[ffb_games.Team == team]
        real_wins = np.sum(team_games.real_wins)
        real_losses = len(team_games) - real_wins
        wins = np.sum(team_games.Win)
        losses = np.sum(team_games.Loss)
        
        real_standings['real_wins'].append(real_wins)
        real_standings['real_losses'].append(real_losses)
        real_standings['wins'].append(wins)
        real_standings['losses'].append(losses)
    return pd.DataFrame(real_standings)
Show code
def tabulate_luck(standings):
    """Given an object containing team names and calculated luck values, return a table of 'luck', 
    sorting from most to least lucky"""
    standings['luck'] = [(w - rw) for w,rw in zip(standings.wins, standings.real_wins)]
    return standings.sort_values('luck', ascending=False)
Show code
def visualize_luck(standings):
    """Given an object containing team names and calculated luck values, generate a bar chart, 
    sorting from most to least lucky"""
    fig = px.bar(standings, x='team', y='luck', color='luck', 
                 color_continuous_scale=px.colors.sequential.Bluered[::-1], 
                 labels={
                     "luck": "Wins Above Expectation (Luck)",
                     "team": "Rosters (Sorted from Luckiest to Unluckiest)"
                 },
                 title='Did you get lucky (well did you?)')
    fig.update_coloraxes(showscale=False)
    fig.show()
Show code
# Fit a sigmoid, using a median number of points as the initial center, then deploy scipy.optimize's curve_fit
first_guess = [np.median(ffb_games.Points), 1] # this is an mandatory initial guess
popt, pcov = curve_fit(sigmoid, ffb_games.Points, ffb_games.Win, first_guess, method='dogbox')
Show code
add_real_wins(ffb_games, popt) # Use the sigmoid, general "real" wins
Show code
# Generate the standings in terms of "real" wins rather than the binary versions, then tally who was "lucky"
standings = generate_standings(ffb_games) 
standings = tabulate_luck(standings)
Show code
visualize_luck(standings)
Show code
with pd.option_context('precision', 2):
    display(standings.sort_values('real_wins', ascending=False))
team real_wins real_losses wins losses luck
0 AgencyIncreasers 18.92 13.08 18 12 -0.92
1 Burninators 18.58 13.42 18 13 -0.58
4 Exorcists 18.46 13.54 17 13 -1.46
6 GoodTrippers 18.41 13.59 17 14 -1.41
11 LowlyLlamas 16.12 15.88 13 18 -3.12
8 Instigators 15.25 16.75 15 16 -0.25
2 Cantakerouses 15.05 16.95 17 14 1.95
5 Flagella 14.48 17.52 17 14 2.52
3 Doomscrolls 14.45 17.55 14 15 -0.45
10 Kodachromes 13.12 18.88 13 19 -0.12
9 JQueries 11.37 20.63 12 19 0.63
7 Hackathoners 10.71 21.29 13 18 2.29
Show code
xs = np.linspace(0,200,201)
sigmoid_ests = [1 / (1 + np.exp(-1*popt[1]*(x-popt[0]))) for x in xs]
Show code
# Build figure
fig = go.Figure()

# Add scatter trace with medium sized markers
fig.add_trace(
    go.Scatter(
        mode='markers',
        x=wins.Points,
        y=wins.Win,
        marker=dict(
            color='Blue',
            size=10,
            opacity=0.2,
            line=dict(
                color='DarkBlue',
                width=1
            )
        ),
        showlegend=False
    )
)


# Add trace with large markers
fig.add_trace(
    go.Scatter(
        mode='markers',
        x=losses.Points,
        y=[0 for p in losses.Points],
        marker=dict(
            color='Red',
            size=10,
            opacity=0.2,
            line=dict(
                color='DarkRed',
                width=1
            )
        ),
        showlegend=False
    )
)

# Add trace with large markers
fig.add_trace(
    go.Scatter(
        x=xs,
        y=sigmoid_ests,
        mode='lines',
        showlegend=False
    )
)



fig.layout.update(title='Causes of Arguments and Insults', hovermode= 'closest', 
                xaxis = dict(title= 'Points Scored', range=[30,180], zeroline= False),
                yaxis = dict(title= 'Win/Loss'))
fig.show()