Make a Simple NBA Shot Chart with Python | by Naveen Venkatesan | Towards Data Science

Recently, I stumbled on this wonderful Python package called nba_api, which can be found here, that serves as a very simple API client to retrieve stats from www.nba.com. I have personally always wanted to create player shot charts like ones found online, but gathering the shot location data seemed like a daunting task. This is where nba_api comes to the rescue! First, let’s install the package:
pip install nba_api
Now that we have installed nba_api we can now import all the packages we will need to produce our own shot chart.
# Import packagesfrom nba_api.stats.endpoints import shotchartdetailimport jsonimport requestsimport pandas as pdimport matplotlib as mplimport matplotlib.pyplot as plt
To make our API request, we will need a player ID number and team ID number, both of which are used to retrieve shot stats. To easily pull these values, we can take advantage of another Github repository here. There are two JSON files, team.json and players.json, that contain all of the player and team ID numbers we need. We will use Python to load these JSON files into arrays of dictionary objects.
# Load teams fileteams = json.loads(requests.get('https://raw.githubusercontent.com/bttmly/nba/master/data/teams.json').text)# Load players fileplayers = json.loads(requests.get('https://raw.githubusercontent.com/bttmly/nba/master/data/players.json').text)
Now that we have all the player and team data loaded, we can create helper functions that take an input of a team name and a player’s name, and return their respective ID numbers. In both cases, if a player or team name cannot be found, the functions return -1:
# Get team ID based on team namedef get_team_id(team):  for team in teams:    if team['teamName'] == team:      return team['teamId']  return -1# Get player ID based on player namedef get_player_id(first, last):  for player in players:    if player['firstName'] == first and player['lastName'] == last:      return player['playerId']  return -1
Let’s now see our functions in action:
# Get the team ID number for the Knicksget_team_id('New York Knicks')>>> 1610612752# Get the player ID number for LeBron Jamesget_player_id('LeBron', 'James')>>> 2544
Now we have all the pieces we need to collect shot data for a particular NBA player. Let’s do an example now — as a Bay Area native and Golden State Warriors fan, I was interested in creating a shot chart for Stephen Curry’s incredible 2015–16 unanimous MVP season. We first structure our JSON request as follows:
# Create JSON requestshot_json = shotchartdetail.ShotChartDetail(            team_id = get_team_id('Golden State Warriors'),            player_id = get_player_id('Stephen', 'Curry'),            context_measure_simple = 'PTS',            season_nullable = '2015-16',            season_type_all_star = 'Regular Season')
context_measure_simple — this determined which “context” to use for searching for shots. Points (PTS) is default and will results in all made shots. If you wanted all attempts (made and missed), you can use “FGA” as your context.
season_nullable — our season of interest (2015–16)
season_type_all_star — which portion of the season we are interested in (Pre Season, Regular Season, Playoffs, All Star)
The documentation for ShotChartDetail() can be found here — as you’ll see, I have only touched upon a small fraction of the things you can do with your request and the possibilities are nearly endless!
Now we can load the JSON data into a dictionary object:
# Load data into a Python dictionaryshot_data = json.loads(shot_json.get_json())
Our relevant data can be found under the resultSets key, which is an array in itself — we need the first element of this array.
# Get the relevant data from our dictionaryrelevant_data = shot_data['resultSets'][0]
The variable relevant_data is a dictionary itself, with the key headers corresponding to the column titles, and rowSet corresponding to the actual rows of data.
# Get the headers and row dataheaders = relevant_data['headers']rows = relevant_data['rowSet']
Finally, we can put this data together into a pandas DataFrame, which is as simple as the following:
# Create pandas DataFramecurry_data = pd.DataFrame(rows)curry_data.columns = headers
There we go — now we can take a look at what columns we have access to in our newly-created DataFrame!
# Print the columns of our DataFramecurry_data.columns>>> Index(['GRID_TYPE', 'GAME_ID', 'GAME_EVENT_ID', 'PLAYER_ID', 'PLAYER_NAME', 'TEAM_ID', 'TEAM_NAME', 'PERIOD', 'MINUTES_REMAINING', 'SECONDS_REMAINING', 'EVENT_TYPE', 'ACTION_TYPE', 'SHOT_TYPE', 'SHOT_ZONE_BASIC', 'SHOT_ZONE_AREA', 'SHOT_ZONE_RANGE', 'SHOT_DISTANCE', 'LOC_X', 'LOC_Y', 'SHOT_ATTEMPTED_FLAG', 'SHOT_MADE_FLAG', 'GAME_DATE', 'HTM', 'VTM'], dtype='object')
Since all of the rows in our DataFrame correspond to made shots, we can use LOC_X and LOC_Y to plot them. From inspection of the data, the x-coordinates go from -250 to 250 (every 1/10 of a foot) and the y-coordinates go from 0 to 940 (also every 1/10 of a foot). We can use this information to plot the shot locations on an overlay of an NBA court. But first, let’s create a basketball court!
To create our basketball court, we will write a function that takes in a matplotlib axis object and a color (for the court markings) and generates a drawing of a court. I’m going to go through it fairly quickly, but it basically is a practice of mapping the real world dimensions of an NBA court to lines and shapes in our plot. We start with the function definition:
# Function to draw basketball courtdef create_court(ax, color):
We now proceed with drawing individual elements. First, we create the short, corner 3-point line, which is 22 feet, and extends 14 feet into the court (remember that we will use the shot coordinates so we are using units of 1/10 of a foot).
# Short corner 3PT linesax.plot([-220, -220], [0, 140], linewidth=2, color=color)ax.plot([220, 220], [0, 140], linewidth=2, color=color)
Now, we can connect the rest of the 3-point arc, which sits 23.75 feet away from the center of the rim. The rim itself is 6 feet inside the court — since we know that the the corner 3-point line extends 14 feet in, we can create an arc that extends vertically 15.75 feet from this point.
# 3PT Arcax.add_artist(mpl.patches.Arc((0, 140), 440, 315, theta1=0, theta2=180, facecolor='none', edgecolor=color, lw=2))
Next up is the lane and the circle at the top of the key. The lane consists of two rectangles, one with a width of 16 feet and the other with a width of 12 feet, both extending 19 feet into the court. The circle at the top of the key has a radius of 6 feet.
# Lane and Keyax.plot([-80, -80], [0, 190], linewidth=2, color=color)ax.plot([80, 80], [0, 190], linewidth=2, color=color)ax.plot([-60, -60], [0, 190], linewidth=2, color=color)ax.plot([60, 60], [0, 190], linewidth=2, color=color)ax.plot([-80, 80], [190, 190], linewidth=2, color=color)ax.add_artist(mpl.patches.Circle((0, 190), 60, facecolor='none', edgecolor=color, lw=2))
Let’s now add the rim and backboard — the backboard is 4 feet into the court, and the center of the rim is 6 feet in, with a radius of 0.75 feet.
# Rimax.add_artist(mpl.patches.Circle((0, 60), 15, facecolor='none', edgecolor=color, lw=2))    # Backboardax.plot([-30, 30], [40, 40], linewidth=2, color=color)
Finally, we remove the tick marks and set axis limits — in this case from [-250, 250] for x and [0, 470] for y (half of the court).
# Remove ticksax.set_xticks([])ax.set_yticks([])    # Set axis limitsax.set_xlim(-250, 250)ax.set_ylim(0, 470)
Now to check if we did everything correctly, we can create a blank figure and draw our court on top. To keep everything true to scale, we set the figsize such that we have the right aspect ratio:
# General plot parametersmpl.rcParams['font.family'] = 'Avenir'mpl.rcParams['font.size'] = 18mpl.rcParams['axes.linewidth'] = 2# Draw basketball courtfig = plt.figure(figsize=(4, 3.76))ax = fig.add_axes([0, 0, 1, 1])ax = create_court(ax, 'black')plt.show()
To create our shot chart, we will use a plot called hexbin which is built-in with matplotlib. This function will bin all the shots based on location into hexagonally-shaped bins, and then color them based on the number of items in each bin. After drawing the court as we did above, we use the following line to create our hexbin plot. We add 60 to the y-coordinate since all the coordinates in our dataset are relative to the center of the rim, and we know that the rim sits 6 feet inside the baseline:
# Plot hexbin of shotsax.hexbin(curry_data['LOC_X'], curry_data['LOC_Y'] + 60, gridsize=(30, 30), extent=(-300, 300, 0, 940), cmap='Blues')
gridsize — the size of the hexagonal bins (x-size, y-size)
extent — (xmin, xmax, ymin, ymax) — we use larger values than the axis limits to ensure that hexagons go all the way to the boundaries of our draw court (hexagons outside the plot area are clipped so we won’t see them)
Our hexbin plot seems to have worked well, but there is too much contrast — even though the 3-pointer numbers are large (Curry made over 400 of them), the shots clustered close to the rim dominate the coloring. Since this shot chart is purely a guide for the eyes (we cannot easily make quantitative determinations from looking at the shot chart), we can use logarithmic binning to improve the contrast. To do this, we use the following line:
# Plot hexbin of shots with logarithmic binningax.hexbin(curry_data['LOC_X'], curry_data['LOC_Y'] + 60, gridsize=(30, 30), extent=(-300, 300, 0, 940), bins='log', cmap='Blues')
Much better! Now the final thing we can do is add a text annotation on top of the chart with the player name and season:
# Annotate player name and seasonax.text(0, 1.05, 'Stephen Curry\n2015-16 Regular Season', transform=ax.transAxes, ha='left', va='baseline')
We can extend this, and plot the shot charts for the 3 MVPs since Curry’s 2015–16 season, for our reference.
# JSON request for Russell Westbrookshot_json = shotchartdetail.ShotChartDetail(            team_id = get_team_id('Oklahoma City Thunder'),            player_id = get_player_id('Russell', 'Westbrook'),            context_measure_simple = 'PTS',            season_nullable = '2016-17',            season_type_all_star = 'Regular Season')
# JSON request for James Hardenshot_json = shotchartdetail.ShotChartDetail(            team_id = get_team_id('Houston Rockets'),            player_id = get_player_id('James', 'Harden'),            context_measure_simple = 'PTS',            season_nullable = '2017-18',            season_type_all_star = 'Regular Season')
# JSON request for Giannis Antetokounmposhot_json = shotchartdetail.ShotChartDetail(            team_id = get_team_id('Milwaukee Bucks'),            player_id = get_player_id('Giannis', 'Antetokounmpo'),            context_measure_simple = 'PTS',            season_nullable = '2018-19',            season_type_all_star = 'Regular Season')
Thank you for reading! All the analyses from this article can be found at this Github repository. I appreciate any feedback, and you can find me on Twitter and connect with me on LinkedIn for more updates and articles.