NHL Analytics With Python. My adventure into understanding the NHL... | by Kyle Pastor | Towards Data Science

Canada. Hockey. They are one in the same some may say, however I have never really been a huge sports fan. In the rare occasions the Maple Leafs make it to the playoffs I will wear some blue and go to the bar with my friends to await the inevitable disappointment, however when they start talking about the players and how they have performed during the season I start to feel a little bit distanced. So in an effort to get a better understanding of the game I decided to put my data analysis hat on and do a deep dive on some NHL player data.
The first thing I did was check to see what kind of data is available and oh boy I was not disappointed! There is actually an undocumented NHL data API with everything from team schedules and rosters to every single shot that was taken on the ice including locations, players involved and at what time it occurred. For those who are curious, a wonderful person created a GIT page with a summary of the API calls (NHL API Documentation). With this I had everything I needed to pull the data and start doing some player analysis.
All of my analysis is done with Python, and to make it easier I use Kaggle as my coding platform. If you have not checked Kaggle out you really should. They have a bunch of open datasets, competitions and a way to share and store your Notebooks (Jupyter). All of the code I will show here is publicly available from my Kaggle account (See bottom for the references) so feel free to explore and fork it. Since the data set is so deep, I decided to pull only the game event data from the 2019–2020 season for this analysis. This gives me access to all of the games played so far, including all on ice events (shots, hits, faceoffs etc.).
As always, the first thing to do is load packages and initialise variables. Here you will see I am using requests for getting the data from the API and pickle for saving the data for later use.
import requestsimport pickle# Set up the API call variablesgame_data = []year = '2019'season_type = '02' max_game_ID = 1290
The format of the API allows us to pass in the year of the season, choose the game type such as pre/regular/playoff (02 is regular season in this example) and finally the max_game_ID. This is the max number of game events in a given year.
Time to pull the data and store the json formatted data into a list.
# Loop over the counter and format the API callfor i in range(0,max_game_ID):    r = requests.get(url='http://statsapi.web.nhl.com/api/v1/game/'        + year + season_type +str(i).zfill(4)+'/feed/live')    data = r.json()    game_data.append(data)
Now that all of that sweet data is stored in a list we can save it as a pickle file. The reason I do this is so I can upload it onto Kaggle and have it available to anyone who would like to do some analysis. Pickle files also allow the storage of objects and have pretty fast loading times.
with open('./'+year+'FullDataset.pkl', 'wb') as f:    pickle.dump(game_data, f, pickle.HIGHEST_PROTOCOL)
With that we have a new file called 2019FullDataset.pkl which we save as a dataset on Kaggle. You can check the reference at the bottom for a link to my dataset.
Now for the fun stuff! There are so many things you could do with this, but what I wanted to do was look at the shooting efficiency of any player at different location the ice and compare them to the average success rate of the whole league.
Using the NHL data I wanted to look at where players are taking their most efficient (Goal/Total Shot %) shots compared to the league averages. To start, let’s import all of the required packages. Note I am using matplotlib for all of my plotting. I also installed pillow to import the image data.
import numpy as np import pandas as pd import pickle    import matplotlibimport matplotlib.pyplot as pltcolor_map = plt.cm.winterfrom matplotlib.patches import RegularPolygonimport mathfrom PIL import Image# Needed for custom colour mapping!from matplotlib.colors import ListedColormap,LinearSegmentedColormapimport matplotlib.colors as mcolors
For all of my plotting I am going to be using a custom colour map using the matplotlib method ListedColormap. Since I want to colour the positive values different from the negative I make two colour mappings.
c = mcolors.ColorConverter().to_rgb()positive_cm = ListedColormap([c(‘#e1e5e5’),c(‘#d63b36’)])negative_cm = ListedColormap([c(‘#e1e5e5’),c(‘#28aee4’)]) 
Now that we have that settled let’s load the pickle data file for the 2019 regular season which has all of the event data for every game. Note we are loading the pickle file we generated previously.
with open(‘../input/nhl-data/2019FullDataset.pkl’, ‘rb’) as f:    game_data = pickle.load(f)
With the data loaded I want to first calculate the league average shooting percent at each point on the ice. The data comes in using events and coordinate objects of the dictionary. In our analysis we only want to look at the “Shot” and “Goal” type events.
Here is the step by step breakdown:
First we make as dictionary to hold all of the shot and goal data coordinates for the whole league.
# Do some dictionary initialisation to hold our cleaned and condensed league dataleague_data = {};league_data[‘Shot’] = {};league_data[‘Shot’][‘x’] = [];league_data[‘Shot’][‘y’] = [];league_data[‘Goal’] = {};league_data[‘Goal’][‘x’] = [];league_data[‘Goal’][‘y’] = [];
We only want to keep the events in the data for the Shot and Goals.
event_types = ['Shot','Goal']
Finally we loop over every game that was played and extract the relevant information into our dictionary.
# First loop over the elements of game_data. Each one of these is an NHL game and contains all of the game event data.for data in game_data:   # It is possible that the game data is not assigned to the data   set, so to handle this we look for the key ‘liveData’ which    contains all of the data we are looking for, otherwise we   continue   if 'liveData' not in data:        continue   # Drilling down into the dataset to extract the play by play   information for the game   plays = data['liveData']['plays']['allPlays']         for play in plays: # For each play         for event in event_types:  # For each event (Shot,Goal)            # If the play contains one of the events            if play['result']['event'] in [event]:                # If the event contains coordinates               if 'x' in play['coordinates']:                  # Save the coordinates to the growing list                  league_data[event]                       ['x'].append(play['coordinates']['x'])                    league_data[event]                       ['y'].append(play['coordinates']['y'])
Now that we have the league data we can do the same things for a given player. The only difference is that we will filter for the Shooter sub-event type when doing the data extraction.
After consulting with some hockey fans I was told that Alex Ovechkin is a great test case. He has a very distinct area he is active in and we should be able to validate things based on that.
Player data extraction is very similar to the league data extract so I will only note on the changes here:
# Initialise the player dictionaryfull_name = 'Alex Ovechkin'player_data = {};player_data['Shot'] = {};player_data['Shot']['x'] = [];player_data['Shot']['y'] = [];player_data['Goal'] = {};player_data['Goal']['x'] = [];player_data['Goal']['y'] = [];
Now we do basically the same process as before but looking for the player data.
# Same code as before...for play in plays:   if 'players' in play:      for player in play['players']:         if player['player']['fullName'] in [full_name]              and player['playerType'] in ["Shooter","Scorer"]:            for event in event_types:                        ...
In the bold section above you can see we are now filtering for player as the Shooter or Scorer. Every play includes who was involved so we can simply look to see if Ovechkin is listed as a Shooter or Scorer. With that we can crunch some numbers and do some cool analysis.
Before we plot the location data I wanted to calculate the high level stats on the player compared to the league averages.
# Get the total number of shots made by the playerplayer_total_shots = len(player_data['Shot']['x']) +   len(player_data['Goal']['x'])# Find the players goal score percentageplayer_goal_pct = len(player_data['Goal']['x'])/player_total_shots# Find the total number of shots taken in the leagueleague_total_shots = len(league_data['Shot']['x']) +   len(league_data['Goal']['x'])# Get the league percentageleague_goal_pct = len(league_data['Goal']['x'])/league_total_shots# Calculate the spread of the SOG (Shots on Goal) %PL_e_spread = player_goal_pct-league_goal_pct
Here we have our first result. We can compare this to official numbers and it matches.
We can see that the spread of 5.85% for Ovechkin means he is an efficient scorer.
But is this true at all points on the ice? Does he have a dominant side, or any weakness? Now we can move onto the location analysis.
What we are going to do first is set up a binning grid. I want to understand where shots are taken on the ice, but I do not want to see all individual shots. By taking spatial average we can make a more insightful and visual representation. The position data from the API extends from:
X: -100 to 100 (meters)
Y: -42.5 to 42.5 (meters)
For our binning we are using hex plots from matplotlib to extract the raw binning data and will use drawn rectangles (again matplotlib) for the final visuals.
To start we define our figure dimensions and grid size:
# To keep the aspect ration correct we use a square figure sizexbnds = np.array([-100.,100.0])ybnds = np.array([-100,100])extent = [xbnds[0],xbnds[1],ybnds[0],ybnds[1]]# We are going to bin in 30 unit increments.  It is fun to play with this!  gridsize= 30;mincnt=0
Next we will find the efficiency of the league at each location on the ice. To do this we call the hexbin method and extract the location vertex and count data.
Once thing to note is that since the scorer never scores on their own net we must make sure the negative locations are flipped to always represent the attacking side.
This is all because every period the player switch sides and the coordinate system is fixed.
# First concatenate the arrays for x and y league dataleague_x_all_shots = league_data['Shot']['x']    + league_data['Goal']['x'];league_y_all_shots = league_data['Shot']['y']    + league_data['Goal']['y']# Perform the coordinate flipping!league_x_all_shots_normalized = [];league_y_all_shots_normalized = []# Enumerate the list so we can use the index for y alsofor i,s in enumerate(league_x_all_shots):    if league_x_all_shots[i] <0:        league_x_all_shots_normalized.append(-league_x_all_shots[i])        league_y_all_shots_normalized.append(-league_y_all_shots[i])    else:        league_x_all_shots_normalized.append(league_x_all_shots[i])        league_y_all_shots_normalized.append(league_y_all_shots[i])        # Do the same treatment for the goalsleague_x_goal_normalized = [];league_y_goal_normalized=[]for i,s in enumerate(league_data['Goal']['x']):    if league_data['Goal']['x'][i] <0:       league_x_goal_normalized.append(-league_data['Goal']['x'][i])       league_y_goal_normalized.append(-league_data['Goal']['y'][i])    else:       league_x_goal_normalized.append(league_data['Goal']['x'][i])       league_y_goal_normalized.append(league_data['Goal']['y'][i])
Cool. Now for the money! Calling the hexbin plot and extracting the counts and locations. This part gets a bit long but just follow along with the comments and it should be pretty clear.
# First we will used the hexbin function to simply bucket our shot data into basically a 2D histogramleague_hex_data = plt.hexbin(league_x_all_shots_normalized,   league_y_all_shots_normalized,gridsize=gridsize,   extent=extent,mincnt=mincnt,alpha=0.0)# Now we extract the bin coordinates and countsleague_verts = league_hex_data.get_offsets();league_shot_frequency = league_hex_data.get_array();# Do the same thing for the goal dataleague_goal_hex_data =  plt.hexbin(league_x_goal_normalized,   league_y_goal_normalized,gridsize=gridsize,   extent=extent,mincnt=mincnt,alpha=0.0)# Since the grid is the same we can use a shared bin coordinate set from the above. So here we just get the countsleague_goal_frequency = league_goal_hex_data.get_array();
Now that we have the locations and counts of our shot data we are going to try and show it in a useful way. First we load in a scale model image of half of an NHL rink. Then we will make sure to scale our coordinates to match the image size and therefore get an accurate representation of where the shots come from on the ice.
# Using matplotlib we create a new figure for plottingfig=plt.figure(figsize=(10,10))ax = fig.add_subplot(111)# Clean up the figure to be completely blankax.set_facecolor("white")fig.patch.set_facecolor("white")fig.patch.set_alpha(0.0)# Remove the labelling of axesax.set_xticklabels(labels = [''], fontsize = 18,   alpha = .7,minor=False)ax.set_yticklabels(labels = [''], fontsize = 18,   alpha = .7,minor=False)# Using pillow to get the rink image and extract the image sizeI = Image.open('../input/nhl-images/half.png')ax.imshow(I);width, height = I.size
At this point we should have an image of a hockey rink being plotted as a matplotlib image. Next we want to determine some scaling factors and offsets to align our image and data coordinate system.
# Calculate the scaling factor and offset (trial and error)scalingx=width/100-0.6;scalingy=height/100+0.5;x_trans=33;y_trans=height/2# We will want to scale the size of our hex bins with the image so we calculate a "radius" scaling factor hereS = 3.8*scalingx;
Finally we are ready to plot. Matplotlib allows for the adding of patch elements in a given polygon shape. So we are going to use hexagonal elements to add patches over top of the rink image.
# Loop over the locations and draw the hexfor i,v in enumerate(league_verts):   # Ignore empty locations   if league_shot_frequency[i] < 1:continue      # Normalize the shot frequency data between 0-1    scaled_league_shot_frequency =      league_shot_frequency[i]/max(league_shot_frequency)   # Scale the hexagon size based on shot frequency   radius = S*math.sqrt(scaled_league_shot_frequency)   # Finally we will plot the hexagon including the scaling and   translations we found earlier   hex = RegularPolygon((x_trans+v[0]*scalingx,       y_trans-v[1]*scalingy),numVertices=6, radius=radius,      orientation=np.radians(0),alpha=0.5, edgecolor=None)   ax.add_patch(hex)
You can see that the shots on average are pretty symmetric and occur most in front of the net. You can also see a large drop off at the blue line which also makes sense.
Now lets do the same things for the player data. The only difference is that all of the variables have the “league” prefix replaced with “player”. Eg:
player_x_all_shots = player_data['Shot']['x']   + player_data['Goal']['x'];player_y_all_shots = player_data['Shot']['y']   + player_data['Goal']['y']
Now instead of just plotting the shots we will also show the goals in green.
Very cool! We can see that he is very active on the left side above the circle. After consulting with my hockey friends it turns out that this is really his spot! But now we should look at his efficiency on the ice. Is this his spot because he just shoots a lot there?
To do this I am going to make the size of the hex the shooting frequency and the colour represent his efficiency.
# Get some lists initialised league_efficiency = []player_efficiency = []relative_efficiency = []# Looping over the league shots (which are the same in length as player)for i in range(0,len(league_shot_frequency)):    # We will only look at positions on the ice where the player or    league had more than two shots during the season    if league_shot_frequency[i]<2 or player_shot_frequency[i]<2:        continue    # Calculate the efficiencies league_efficiency.append(   league_goal_frequency[i]/league_shot_frequency[i])    player_efficiency.append(   player_goal_frequency[i]/player_shot_frequency[i])    # And the relative efficiencyrelative_efficiency.append(   (player_goal_frequency[i]/player_shot_frequency[i]-   (league_goal_frequency[i]/league_shot_frequency[i]))# Keep track of the max so we can scale the colour and radius of the hex plot aftermax_league_efficiency = max(league_efficiency)max_player_efficiency = max(player_efficiency)max_relative_efficiency = max(relative_efficiency)min_relative_efficiency = min(relative_efficiency)
Finally we are ready for the final stage of plotting the relative efficiency of shots taken at a given location.
# Loop over the locations and draw the hexfor i,v in enumerate(player_verts):    # Here we will only include locations where the player made at    least on shot.  We will adjust this later for plotting.    if player_shot_frequency[i] < 1:continue        # Scaling the frequencies    scaled_player_shot_frequency =        player_shot_frequency[i]/max(player_shot_frequency)    # Calculate a radius of the hex    radius = S*math.sqrt(scaled_player_shot_frequency)       # Find the player efficiency and relative at this point on the    ice.      player_efficiency =        player_goal_frequency[i]/player_shot_frequency[i]    league_efficiency =        league_goal_frequency[i]/league_shot_frequency[i]    # This is what we were after the whole time!       relative_efficiency = player_efficiency - league_efficiency     # Since there can be positive and negative efficiencies   (relative) we colour the more efficient locations red and the   less blue.   if relative_efficiency>0:        colour = positive_cm(math.pow(relative_efficiency,0.1))   else:        colour = negative_cm(math.pow(-relative_efficiency,0.1))      # And finally we plot!       hex = RegularPolygon((x_trans+v[0]*scalingx,       y_trans-v[1]*scalingy),numVertices=6, radius=radius,       orientation=np.radians(0),facecolor=colour,alpha=1,       edgecolor=None)   ax.add_patch(hex)
As a final step we can just increase the threshold of data points to look at. Let’s only look at locations where he took more than 4 shots in the season.
Wow! Looks pretty good now! We can clearly see Ovechkin is a super efficient player on the left side which is also where he takes most of his shots. Compared to the league average he is amazing.
One thing we do find is that he is less than average when it comes to taking shots right in front of the net (which look pretty common based on hex size). This could be something to either help him improve or use against him!
In the end I took this to my hockey friends and upon review they basically said:
“Ya well obviously. That’s totally his spot!”
and you know what? That is the greatest comment I could have received.
IMAGES
All of the images in this article were made by myself using python + Photopea