How to Win in the NFL with Machine Learning | by Rich Folsom | Towards Data Science

You have been hired as the defensive coordinator for the Dallas Cowboys, and you are coaching against the New England Patriots in the Super Bowl. I realize this is a highly unlikely scenario given that the Cowboys don’t look anything like a Super Bowl contender. However, we can still use this as an example project for the steps to create an AI/ML model.
The Patriots have been the best team in the NFL over the last two decades, and Tom Brady, the Patriots’ quarterback has been arguably the best player in the NFL over the same timespan. As a defensive coordinator, it will be a huge task to stop the Patriots. However, we have an advantage on our side: AI/ML. Let’s lay out a process to see if we can predict the plays before they happen, which will enable us to adjust our defensive scheme accordingly. We will be using Python and sklearn for this example. Here is the process we will follow:
Acquire or generate dataPrepare the data for Exploratory Data Analysis (EDA).Analyze the data to look for potential patterns.Prepare the data for AI/ML models.Train and evaluate your chosen AI/ML model.
Acquire or generate data
Prepare the data for Exploratory Data Analysis (EDA).
Analyze the data to look for potential patterns.
Prepare the data for AI/ML models.
Train and evaluate your chosen AI/ML model.
The first thing you’re going to need is a dataset. If you don’t have access to a relevant dataset, you can generate a sample dataset using a process like this article. For our example, we will be using this dataset containing information on every NFL play that has been run since 2009. It’s hosted on Kaggle. If you’re not familiar with Kaggle, it’s a coding competition website with many different datasets; this is usually the first place I check when I want to find some data.
Once we have downloaded and unzipped the file, we need it in a format that enables us to explore the data. For this, we will be using Pandas, a Python library that contains numerous functions to modify and explore the dataset. Here is the code to load the dataframe from our CSV file:
import pandas as pdimport matplotlibfrom sklearn import preprocessing%matplotlib inlinedf = pd.read_csv('data/nfl_plays.csv')
EDA is the process of exploring the data for issues (missing values, incorrectly typed data, etc...) and hopefully, we can see some trends in the data.
First off, let’s see how many rows and columns we’ve loaded:
df.shape
which gives:
(449371, 255)
As you can see, there is a lot of information in this data set (255 columns). It would be nice to know what each field represents. It took some digging, but I ended up finding this page, which describes all of the columns and what they mean. I am interested in these fields:
posteam — the team with possession (in this case, we only want NE)
game_seconds_remaining — how much time remains in the game
yardline_100 — what yard line is the team on (scaled to 100)
down — what down are we on(1.0, 2.0, 3.0, 4.0)
ydstogo — yards needed for a first down
shotgun — are they lined up in the shotgun formation?
score_differential — their score-our score
play_type — the type of play(run, pass, etc...)
run_location — which way did they run(left, middle, right)
pass_location — which way did they pass(left, middle, right)
There are other fields that we could use to help predict, but let’s use these for starters. Here’s the code to filter by these rows and columns
#filter rowsnedf = df[(df.posteam=='NE') & (df.down.isin([1.0, 2.0, 3.0, 4.0])) & ((df.play_type=='run') | (df.play_type == 'pass'))]#filter columnsnedf = nedf[['game_seconds_remaining', 'yardline_100', 'down', 'ydstogo', 'shotgun', 'score_differential', 'play_type', 'pass_length', 'pass_location', 'run_location']]nedf.head()
At this point, we have one more step before we can begin EDA. The value we’re trying to predict is represented by four columns which are dependent on the play_type. For example:
If the play type is ‘run,’ run_location would be populated, but pass_location would not be.
If the play type is ‘pass,’ pass_location would be populated but run_location would not be.
We need to combine all of these values into a single field. Here’s the code to do that (note, we are also filtering out location=’unknown’):
import numpy as npdef get_full_play_type(play):    play_type, pass_location, run_location = play    if(play_type == 'run'):        return play_type+'_'+ run_location    else:        return play_type+'_'+ pass_locationnedf = nedf.replace(np.nan, 'unknown', regex=True)    nedf['full_play_type'] = nedf[['play_type','pass_location', 'run_location']].apply(get_full_play_type, axis=1)nedf = nedf[(nedf.full_play_type.isin(['pass_left', 'pass_middle','pass_right','run_left', 'run_middle', 'run_right']))]
This code will create a column called full_play_type, which is what we’re trying to predict. Let’s take one last look at our data to make sure everything looks ok:
nedf.head()
First, let’s see what values our full_play_type contains:
nedf.groupby(['full_play_type']).count()[['play_type']]
As you can see, if we were to randomly guess the next play, we’d have a one in six chance (16.66%) of being correct. We will use this as our baseline to see if we can improve.
Sometimes it helps to view this in chart format:
nedf.groupby(['full_play_type']).count()[['play_type']].plot(kind='bar')
Next, let’s get an idea of which plays are called by percent of total:
nedf.groupby(['full_play_type']).count()[['play_type']].apply(lambda x:100 * x / float(x.sum()))
We’ve already got improvement in our predictions! If we predict pass_left for every play, we’d be correct 23% of the time vs. 16.66% of the time. We can still do better.
AI/ML models require numeric inputs and outputs. They also work better when the scale of the numbers are similar. In this section, we will utilize some different techniques to achieve this.
First, we will use sklearn’s preprocessing library to convert our full_play_types to numeric:
le = preprocessing.LabelEncoder()le.fit(nedf.full_play_type)nedf['full_play_type_code'] = le.transform(nedf['full_play_type'])
This creates a column called full_play_type_code, which is a numeric representation of full_play_type. Let’s look at the full_play_type_code data and make sure that it still matches full_play_type.
nedf.groupby(['full_play_type_code']).count()[['down']].plot(kind='bar')
As you can see, the chart matches the one above, but now the x-axis is a numeric value, instead of text.
Next, I want to simplify the ydstogo value. Technically speaking this can be a value between 1 and 99. However, I want to put this distance into some buckets (1–4yds, 5–8yds, 9–12yds, 13–16yds, ≥17yds) which should enable our model to identify patterns. The ranges I picked are arbitrary and can be easily modified.
def bucketize(val, size, count):    i=0    for i in range(count):        if val <= (i+1)*size:            return i    return idef bucketize_df(df):    df['ydstogo'] = [bucketize(x, 4, 5) for x in df['ydstogo']]    return dfnedf = bucketize_df(nedf)
Now, I want to one hot encode the down and ydstogo columns, which means we will ‘pivot’ the rows to columns and populate the column containing the value we’re encoding with a ‘1’, otherwise zeros. For instance, there are four downs in football. When we one hot encode the down column, we will end up with four columns in which a one will be populated only once. If we were to one hot encode a row that represented third down, we’d end up with these values for our new columns[0, 0, 1, 0]. Notice the 3rd column is the only one to contain a one. The advantage of doing this is that our down and ydstogo data is now represented as zeros and ones, which should also help our model learn. The code for this is very simple in sklearn:
nedf = pd.concat([nedf, pd.get_dummies(nedf['down'], prefix='down')], axis=1)nedf = pd.concat([nedf, pd.get_dummies(nedf['ydstogo'], prefix='ydstogo')], axis=1)
Here’s what a sample of our data looks like at this point:
Now, you can see that ydstogo_* and down_* are represented by zeroes and ones. However, the game_seconds_remaining, yardline_100 and score differential columns are still on a different scale than zero and one. For instance, game seconds remaining contains a value between zero and 3600.
We can fix that by adding some math to the columns. First, let’s see what the ranges of values these columns contain.
nedf.describe()
For this exercise, we will be looking at the min and max values and use those to normalize the data to a value between zero and one. The min for game_seconds_remaining is 3, while the max is 3600. We will divide game_seconds_remaining by 3600. Yardline_100 will be divided by 100. For score differential, we’ll apply this formula:
(score_diff + min_diff) / (max_diff-min_diff)
However, to simplify, we’ll just use -50 for min_diff, and 50 for max_diff. Our logic is that if you’re winning or losing by more than 50 points, you’ve got other things on your mind at this point. Here’s our code to apply these formulas:
nedf['game_seconds_remaining']/=3600nedf['yardline_100']/=100nedf['score_differential']=(nedf['score_differential']+50)/100
Now, if we look at our dataset, here’s what we get:
We now have all of our input fields converted to values between zero and one, so we’re ready to try it out on a model.
For this tutorial, we will be using the RandomForestClassifier to see if we can predict the next play. But first, we need to get rid of all the columns we don’t want for our model. Then, we need to split our data into a train and test set, each containing an X (input data) and y(result data). This can be done quickly with three lines of Python.
from sklearn.model_selection import train_test_split#select important columns for inputX=nedf[['yardline_100', 'shotgun', 'score_differential', 'game_seconds_remaining', 'down_1.0', 'down_2.0', 'down_3.0', 'down_4.0','ydstogo_0','ydstogo_1','ydstogo_2','ydstogo_3','ydstogo_4']]#select result column for outputY=nedf['full_play_type_code']#split data for train and testtrain_x, test_x, train_y, test_y = train_test_split(X, Y, random_state = 0)
At this point, we can train our model to fit the input data.
from sklearn.ensemble import RandomForestClassifierthe_clf=RandomForestClassifier(max_depth=8, n_estimators=64)the_clf.fit(train_x, train_y)
Now that our model has been trained, let’s see how it scores on our test data.
from sklearn.metrics import accuracy_scorepred = the_clf.predict(test_x)acc =accuracy_score(test_y, pred)print(acc)
We are now able to predict the correct play 31% of the time, which may not sound great, but keep in mind that it’s almost double where we started by randomly guessing (16.66%) and 25% better than just guessing the play they like to run the most (pass_left, 23%).
With just a few lines of Python code, we have almost doubled our coaching skills. You may have noticed, however, that the majority of the code we’ve written is related to organizing and curating our data. Now that we’ve done that, we can use this data to train more complex models.
In our next article, we will explore four techniques to generate even better predictions utilizing the data we have processed:
Oversampling/data augmentationModel selectionModel evaluationModel tuning
Oversampling/data augmentation
Model selection
Model evaluation
Model tuning
Thanks for reading, the code for this article will be hosted on github.