Skip to content

Project

Optimal Play-Calling in Short Yardage Situations

Source

NFLSavant's "2023 Play By Play Data" dataset

Objective

Analyze 2023 NFL play-by-play data for short yardage situations:

  • 3rd down and 3 yards or less
  • 4th down and 3 yards or less
  • 3 yards or less from a touchdown
  • 2-point conversion

And identify:

  • Is it more advantageous to pass or run in each type of short yardage situation?
  • Is it more advantageous to pass or run for each team in short yardage situations?
  • Which run direction is overall most advantageous in short yardage situations?
  • How advantageous is each run direction in each type of short yardage situation?
  • How advantageous is each run direction for each team in short yardage situations?

1. Load the CSV file from NFLsavant.com.


1 hidden cell

2. Prepare data for run vs. pass analysis.

#import packages
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

#create function that assigns each play as run, pass, or other
def run_or_pass(row):
    if (row['PlayType'] in ['PASS','SACK','SCRAMBLE']) or (row['PlayType'] == 'TWO-POINT CONVERSION' and ('PASS' in row['Description'] or 'SACK' in row['Description'])):
        type = 'Pass'
    elif row['PlayType'] == 'RUSH' or (row['PlayType'] == 'TWO-POINT CONVERSION' and 'RUSH' in row['Description']):
        type = 'Run'
    else:
        type = 'Other'
    return type

#use function to create a modified play type column
pbp['Play'] = pbp.apply(run_or_pass, axis = 1)

#drop plays that aren't run or pass
pbp = pbp[pbp['Play'] != 'Other']

#define a short yardage threshold in case we want to adjust it
short = 3

#create function that assigns each play as 3rd and Short, 4th and Short, Goal Line, 2-Point Conversion, or Not Short Yardage
def situation(row):
    if row['PlayType'] == 'TWO-POINT CONVERSION':
        type = '2 Pt Conversion'
    elif row['YardLineFixed'] <= short and row['YardLineDirection'] == 'OPP':
        type = 'Goal Line'
    elif row['Down'] == 3 and row['ToGo'] <= short:
        type = '3rd and Short'
    elif row['Down'] == 4 and row['ToGo'] <= short:
        type = '4th and Short'
    else:
        type = 'NotShortYardage'
    return type

#use function to create a situation column
pbp['Situation'] = pbp.apply(situation, axis = 1)

#drop plays that aren't short yardage
pbp = pbp[pbp['Situation'] != 'NotShortYardage']

#create function that determines if a play was a success
def success(row):
    if (row['SeriesFirstDown'] == 1 and row['IsTwoPointConversion'] == 0) or row['IsTouchdown'] == 1 or row['IsTwoPointConversionSuccessful'] == 1:
        type = 1
    else:
        type = 0
    return type

#use function to create a situation column
pbp['Success'] = pbp.apply(success, axis = 1)

#create groupby tables to visualize short yardage situation success by situation type and by team
result_by_playtype = pbp.groupby(['Situation','Play'])['Success'].mean().reset_index()
result_by_playtype['SuccessPct'] = result_by_playtype['Success'].round(decimals = 2) * 100
result_by_team = pbp.groupby(['OffenseTeam','Play'])['Success'].mean().reset_index()
result_by_team['SuccessPct'] = result_by_team['Success'].round(decimals = 2) * 100

3. Visualize short yardage conversion rate by situation type when running vs. passing:

- 2-Point Conversion

- 3rd and Short (3 yards or less to 1st down)

- 4th and Short (3 yards or less to 1st down)

- Goal Line (3 yards or less to touchdown)

#BY-SITUATION BAR GRAPH
#set sns theme and size for by-situation chart
sns.set_theme(rc={'figure.figsize':(10,10)})
sns.set_style('white')

#establish by-situation chart
vis_situation = sns.barplot(data=result_by_playtype, 
                            x="Situation", 
                            y="SuccessPct", 
                            hue="Play", 
                            palette=['cornflowerblue', 'sandybrown'])

#set title, xlabel, ylable, tick font sizes
vis_situation.set_title('2023 Running vs. Passing Success Rates in Short Yardage Situations', 
                        fontdict = {'weight':'bold', 'fontsize' : '24'},y=1.02)
vis_situation.set_xlabel('Situation', 
                         fontdict = {'weight':'bold', 'fontsize' : '18'})
vis_situation.set_ylabel('Success Rate', 
                         fontdict = {'weight':'bold', 'fontsize' : '18'})
vis_situation.xaxis.set_tick_params(labelsize = 16)
vis_situation.yaxis.set_tick_params(labelsize = 16)

           
#adding % symbol to yticks
y_value=['{:,.0f}'.format(x) + '%' for x in vis_situation.get_yticks()]
vis_situation.set_yticklabels(y_value)

plt.show()

Observations

  • Each type of situation saw a 10%-20% higher success rate with running plays in the 2023 NFL season
  • Running was generally more advantageous than passing in short yardage situations

4. Visualize short yardage conversion rate by NFL team when running vs. passing.

#BY-TEAM BAR GRAPH
#set sns theme and size for by-team chart
sns.set_theme(rc={'figure.figsize':(10,40)})
sns.set_style('white')

#establish by-team chart
vis_team = sns.barplot(data=result_by_team, 
                            y="OffenseTeam", 
                            x="SuccessPct", 
                            hue="Play", 
                            palette=['cornflowerblue', 'sandybrown'])

#set title, xlabel, ylable, tick font sizes
vis_team.set_title('2023 Short Yardage Situations: Conversion Rate by Team', 
                        fontdict = {'weight':'bold', 'fontsize' : '24'},y=1.02)
vis_team.set_ylabel('')
vis_team.set_xlabel('Success Rate', 
                         fontdict = {'weight':'bold', 'fontsize' : '18'})
vis_team.yaxis.set_tick_params(labelsize = 16)
vis_team.xaxis.set_tick_params(labelsize = 16)
#add annotations to each bar in bar graph
for container in vis_team.containers:
    vis_team.bar_label(container)
#increase legend size
plt.legend(fontsize='x-large')
# add label to top and bottom of x axis
plt.gca().tick_params(axis="x", bottom=True, top=True, labelbottom=True, labeltop=True)
# add percent symbol to x ticks
def pct_sym(x, pos):
    return '{:.0f}%'.format(x)
plt.gca().xaxis.set_major_formatter(plt.FuncFormatter(pct_sym))

plt.show()

Observations

  • Teams that should pass in short yardage situations:
    • Packers
    • Buccaneers
  • Teams that have similar chances of converting running or passing:
    • Bengals
    • Lions
    • Chiefs
    • Chargers
    • Vikings
  • Teams that should run in short yardage situations:
    • Cardinals
    • Falcons
    • Ravens
    • Bills
    • Panthers
    • Bears
    • Browns
    • Cowboys
    • Broncos
    • Texans
    • Colts
    • Jaguars
    • Rams
    • Raiders
    • Dolphins
    • Patriots
    • Saints
    • Giants
    • Jets
    • Eagles
    • Steelers
    • Seahawks
    • 49ers
    • Titans
    • Commanders

5. Prepare data for run direction analysis.

#PREPARE DATA FOR FURTHER ANALYSIS (CONVERSION RATE BY RUN DIRECTION)

#define function that assigns a run direction regardless of right or left side, to include 2pt conversions
def run_dir(row):
    if row['IsTwoPointConversion'] == 1 and row['Play'] == 'Run':
            if 'END' in row['Description']:
                type = 'END'
            elif 'TACKLE' in row['Description']:
                type = 'TACKLE'
            elif 'GUARD' in row['Description']:
                type = 'GUARD'
            elif 'MIDDLE' in row['Description']:
                type = 'CENTER'
            else: 
                type = 'NOT_RUN'  
    elif row['PlayType'] == 'RUSH':
            if 'END' in row['RushDirection']:
                type = 'END'
            elif 'TACKLE' in row['RushDirection']:
                type = 'TACKLE'
            elif 'GUARD' in row['RushDirection']:
                type = 'GUARD'
            elif 'CENTER' in row['RushDirection']:
                type = 'CENTER'
            else: 
                type = 'NOT_RUN' 
    else:
        type = 'NOT_RUN'
    return type

#use function to create a modified run direction column
pbp['RushDirectionMod'] = pbp.apply(run_dir, axis = 1)

#new table, drop plays that aren't short yardage
pbp_runs = pbp[pbp['RushDirectionMod'] != 'NOT_RUN']

#table that groups by rush direction and calculates success rate
result_by_rundir = pbp_runs.groupby(['RushDirectionMod'])['Success'].mean().reset_index()
result_by_rundir['SuccessPct'] = result_by_rundir['Success'].round(decimals = 2) * 100

#table that groups by rush direction and situation and calculates success rate
results_playtype_rundir = pbp_runs.groupby(['RushDirectionMod','Situation'])['Success'].mean().reset_index()
results_playtype_rundir['SuccessPct'] = results_playtype_rundir['Success'].round(decimals = 2) * 100

#crosstab that provides success rate for each team and run direction combination
rundir_team_crosstab = pd.crosstab(pbp_runs['OffenseTeam'], pbp_runs['RushDirectionMod'], values=pbp_runs['Success'], aggfunc='mean')
rundir_team_crosstab = rundir_team_crosstab[['CENTER','GUARD','TACKLE','END']]

#crosstab that provides sample size for each team and run direction combination
sample_size_crosstab = pd.crosstab(pbp_runs['OffenseTeam'], pbp_runs['RushDirectionMod'],values=pbp_runs['PlayType'], aggfunc='count')
sample_size_crosstab = sample_size_crosstab[['CENTER','GUARD','TACKLE','END']]

6. Visualize success rate by run direction in short yardage situations.

#RUN DIRECTION BAR GRAPH
#set sns theme and size for by-RUN DIRECTION chart
sns.set_theme(rc={'figure.figsize':(10,10)})
sns.set_style('white')

#establish by-RUN DIRECTION chart
vis_rundir = sns.barplot(data=result_by_rundir, 
                            x="RushDirectionMod", 
                            y="SuccessPct", palette='Dark2' , order=['CENTER','GUARD','TACKLE','END'] 
                            )

#set title, xlabel, ylable, tick font sizes
vis_rundir.set_title('2023 Success Rates in Short Yardage Situations by Run Direction', 
                        fontdict = {'weight':'bold', 'fontsize' : '24'},y=1.02)
vis_rundir.set_xlabel('Run Direction', 
                         fontdict = {'weight':'bold', 'fontsize' : '18'})
vis_rundir.set_ylabel('Success Rate', 
                         fontdict = {'weight':'bold', 'fontsize' : '18'})
vis_rundir.xaxis.set_tick_params(labelsize = 16)
vis_rundir.yaxis.set_tick_params(labelsize = 16)

           
#adding % symbol to yticks
y_value=['{:,.0f}'.format(x) + '%' for x in vis_rundir.get_yticks()]
vis_rundir.set_yticklabels(y_value)

plt.show()