With the 2015 NBA Draft in the books (#knickstaps) I wanted to take a look at some data from previous drafts and explore it as means of learning some Python and some of its libraries.
In this first part we'll be scraping and cleaning data from the 1966 draft (the first year without territorial picks) to the 2014 draft.
To scrape the data we want, we will use the BeautifulSoup
library. It allows us to search through the HTML of a webpage and extract the information we need. We'll then take the scraped data and then store it in a DataFrame
using the pandas
library.
from urllib.request import urlopen
from bs4 import BeautifulSoup
import pandas as pd
Scraping the Data¶
Lets get a feel for using Beautiful Soup
by just scraping the data for the 2014 NBA draft.
First we need to get the HTML document we will be scraping. To do that we will use urlopen
that we imported from the urllib.request
library.
# url that we are scraping
url = "http://www.basketball-reference.com/draft/NBA_2014.html"
# this is the html from the given url
html = urlopen(url)
Now we create a BeautifulSoup
object by passing through html
to the BeautifulSoup()
constructor.
soup = BeautifulSoup(html)
type(soup) # we see that soup is a BeautifulSoup object
Getting Column Headers¶
First lets grab the information that will become the column headers of our DataFrame
. That information is found in the content of a table header cell HTML element (which are denoted by the
tags), which is found within a table row element (
) of a table header element () of a table element (
).
The HTML structure would look something like this:
The text we want is between these HTML tags
As an example, lets take a look at the table header cell element for the 'Player' column header:
Player
We want to extract the text content (which would be 'Player' in above example) from all elements like one above and store them into a list.
By inspecting the HTML document (in Chrome you do this by right-clinking on the webpage and selecting "Inspect element" from the dropdown menu) we see that the 2nd table row is the one that contains the table headers we want.
Using BeautifulSoup
to get this information is pretty straightfoward. All we have to do is:
Use the
findAll()
method to find the first 2 rows on the page.- We pass the element we want as the first argument, in this case
'tr'
- We set the
limit
parameter to 2 so we only get the first 2 table row elements. - So we have something like this:
soup.findAll('tr', limit=2)
- The
findAll()
method above returns us a list ofBeautifulSoup Tag
objects. In this case it returns theTag
objects that represent the table rows we wanted.
- We pass the element we want as the first argument, in this case
We want the 2nd table row so we have to extract the 2nd element of the list of
Tag
objects, which is done by just indexing the list as follows:soup.findAll('tr', limit=2)[1]
Since the above returns us a
Tag
object we can just callfindAll()
on it to extract the table header cell elements like this:soup.findAll('tr', limit=2)[1].findAll('th')
Now we have a list of containing the table header cell elements that can iterate through and extract the text we want via the
getText()
method. Lets extract that text and construct a list of the column headers using list comprehension .
column_headers = [th.getText() for th in
soup.findAll('tr', limit=2)[1].findAll('th')]
column_headers # our column headers
NOTE Some of the column headers (or names or labels) need to be renamed, but we won't be doing that until we scrape the date from 1966 and on.
Getting the Data¶
We now need to extract the data from the HTML table and fill up our DataFrame
.
We can follow a similar process to the one we used to exract the header information.
In this case the data we want is found in the table row elements after the first two header rows. So lets get the list of table rows as follows:
data_rows = soup.findAll('tr')[2:] # skip the first 2 header rows
type(data_rows) # now we have a list of table rows
The difference with extracting the the player data versus the column headers is that the the player data is in a 2-dimensional format (or a matrix), so we have to construct a 2-dimensional list. We can do this using a nested list comprehension
player_data = [[td.getText() for td in data_rows[i].findAll('td')]
for i in range(len(data_rows))]
Lets take a look at what the above does.
The outer for loop
for i in range(len(data_rows))
gets each table row which represents each draft pick.
The for loop in the inner list comprehension
[td.getText() for td in data_rows[i].findAll('td')]
extracts the text from the table data element (
) for each row. This text represents the column data for each draft pick.
You could also construct the above list without a list comprehension:
player_data_02 = [] # create an empty list to hold all the data
for i in range(len(data_rows)): # for each table row
player_row = [] # create an empty list for each pick/player
# for each table data element from each table row
for td in data_rows[i].findAll('td'):
# get the text content and append to the player_row
player_row.append(td.getText())
# then append each pick/player to the player_data matrix
player_data_02.append(player_row)
Both player_data
and player_data_02
are equivalent.
player_data == player_data_02
Now that we have the column labels and the data we can construct a pandas DataFrame
.
We pass in the player_data
as the first parameter in the DataFrame
contructor and set the columns
paramter to column_headers
.
df = pd.DataFrame(player_data, columns=column_headers)
df.head() # head() lets us see the 1st 5 rows of our DataFrame by default
df.head(3) # we can change from the default view by passing in an integer
Cleaning the data¶
There are few things we have to do make our data usable:
Get rid of a couple of rows (that were header rows) that contain only
NoneType
values.Rename some of the columns
Change the data in each column to their proper data type.
Deal with some more missing values in the form of NaNs.
Add and drop a certain columns.
Getting rid of the rows with missing values¶
Now lets find the rows containing NoneType
values. To do this we can use pandas
boolean indexing. We can find the the rows we want by calling isnull()
method (which return True
if there is a NoneType
or NaN
) from the 'Pk' column. If 'Pk' value is missing then there isn't a draft pick in that row so we can get rid of that row.
# Finding the None rows
df[df['Pk'].isnull()]
We get rid of those rows by reassigning df
a subset of itself that doesn't have the above rows.
Note how we can accese the same column of our DataFrame in different ways. In the cell below we are accessing the column as an attribute.
df = df[df.Player.notnull()]
Now there aren't any rows that are full of missing values.
df[df['Pk'].isnull()]
Renaming the columns¶
We should rename some of the columns since Python is not happy with having '%' or '/' in identifiers.
Lets rename the WS/48 column to WS_per_48 using the rename()
method. We set the columns
parmeter to a diction with the key being the column we want to rename and the the value being the new name for that column.
df.rename(columns={'WS/48':'WS_per_48'}, inplace=True)
Since there are multiple columns with the '%' character lets use the built-in string method replace()
to replace '%' with '_Perc' for those columns
# get the column names and replace all '%' with '_Perc'
df.columns = df.columns.str.replace('%', '_Perc')
We also need to differentiate between per game stats and total cumulative career stats. For example, we have two MP columns, one is the career total minutes played by that player and the other is his per game average.
To do this we can append '_per_G' to the per game stats using a list comprehension.
# Get the columns we want by slicing the list of column names
# and then replace them with the appended names
df.columns.values[14:18] = [df.columns.values[14:18][col] +
"_per_G" for col in range(4)]
print(df.columns)
Change Data to Proper Data Type¶
df.dtypes # Take a look at data typse in each column
We can see that our DataFrame
consists of alot columns with data type object
. We can call on the DataFrame
method convert_objects()
and pass in the parameter convert_numeric=True
to convert the columns with numerical strings to the most suitable numeric data type.
df = df.convert_objects(convert_numeric=True)
df.dtypes
Not everything is exactly as we want it to be. We should convert the columns Yrs, G, MP, PTS, TRB, and AST to integers using astype()
. But before we can do that we need to deal with some NaN
s (Not a Number) or else we will get an error like this:
ValueError: Cannot convert NA
Dealing with the NaN values¶
The NaN
s in our data indicate that a player has not played in the NBA. We should replace these NaN
s with 0s to indicate that the player has not accumulated any stats.
To replace NaN
values with 0s we just pass in the value 0 into the fillna()
.
df = df[:].fillna(0) # index all the columns and fill in the 0s
Now that there are no more missing values in our data we can call astype()
.
To get the columns we will convert we can use loc
, which allows us to access the rows and columns of our DataFrame
by using their labels. Using loc
we can slice consecutive rows or columns in a similar mannner that we slice lists
, except the slice ranges in .loc
are inclusive on both ends of the indicated slice.
The typical format for slicing with loc
is as follows:
df.loc[first_row_label:last_row_label , first_col_label:last_col_label]
(Read more about indexing pandas DataFrames
here)
Since the columns Yrs, G, MP, PTS, TRB, and AST are consecutive columns we can retrieve them by slicing our DataFrame
using loc
and then convert their data type.
df.loc[:,'Yrs':'AST'] = df.loc[:,'Yrs':'AST'].astype(int)
df.head() # All NaNs are now replaced with 0s
df.dtypes # and we have the datatyps we want
Adding and Deleting Columns¶
Now lets finally add a Draft_Yr column to indicate the draft class year. We do this by using the insert()
method. The first parameter is where we are inserting the column. The second parameter we pass in is the name of the column. The third parameter is the value(s) for that column
df.insert(0, 'Draft_Yr', 2014)
Now to delete the Rk column as it is redundant since we have the Pk column. To delete a column we use the drop()
method. We pass in the name of the column we want to delete, the axis we are deleting along, and set inplace
to True
so that the changes occur inplace and we don't have to reassign the df to itself.
df.drop('Rk', axis='columns', inplace=True)
df.columns # checkout our revised columns
Scraping and Cleaning Data for Drafts from 1966¶
Scraping and cleaning the data from all previous drafts follows a similar procedure to the one we used for the 2014 draft. The main difference is that we have to to do it multiple times and then combine the data into one large DataFrame
.
Scraping¶
First lets create a URL template that will allow us to access the web page for each year.
url_template = "http://www.basketball-reference.com/draft/NBA_{year}.html"
# create an empty DataFrame
draft_df = pd.DataFrame()
Now we are going to create a for
loop to scrape and append our data to our big DataFrame
. It does take a bit to get all the data.
for year in range(1966, 2015): # for each year
url = url_template.format(year=year) # get the url
html = urlopen(url) # get the html
soup = BeautifulSoup(html, 'html5lib') # create our BS object
# get our player data
data_rows = soup.findAll('tr')[2:]
player_data = [[td.getText() for td in data_rows[i].findAll('td')]
for i in range(len(data_rows))]
# Turn yearly data into a DatFrame
year_df = pd.DataFrame(player_data, columns=column_headers)
# create and insert the Draft_Yr column
year_df.insert(0, 'Draft_Yr', year)
# Append to the big dataframe
draft_df = draft_df.append(year_df, ignore_index=True)
NOTE: I had to pass html5lib
as an extra argument into the BeautifulSoup
contructor. html5lib
is a third-party Python parser that Beautiful Soup can use. The reason I had to do this was because my default parser (lxml
, another thrid party parser) was not extracting all of the table rows for some of the years.
For more about parsers and Beautiful Soup check out the documentation
Lets take a look at the first and last few rows of our data
draft_df.head()
draft_df.tail()
Now we clean¶
# Convert data to proper data types
draft_df = draft_df.convert_objects(convert_numeric=True)
# Get rid of the rows full of null values
draft_df = draft_df[draft_df.Player.notnull()]
# Replace NaNs with 0s
draft_df = draft_df.fillna(0)
# Rename Columns
draft_df.rename(columns={'WS/48':'WS_per_48'}, inplace=True)
# Change % symbol
draft_df.columns = draft_df.columns.str.replace('%', '_Perc')
# Add per_G to per game stats
draft_df.columns.values[15:19] = [draft_df.columns.values[15:19][col] +
"_per_G" for col in range(4)]
# Changing the Data Types to int
draft_df.loc[:,'Yrs':'AST'] = draft_df.loc[:,'Yrs':'AST'].astype(int)
# Delete the 'Rk' column
draft_df.drop('Rk', axis='columns', inplace=True)
Let's see how our data looks now.
draft_df.dtypes
draft_df['Pk'] = draft_df['Pk'].astype(int) # change Pk to int
draft_df.dtypes
draft_df.isnull().sum() # No missing values in our DataFrame
Writing Out Data to a CSV File¶
Using the to_csv()
method we can easily write out our DataFrame
to a csv file.
draft_df.to_csv("draft_data_1966_to_2014.csv")
Finally, lets print out the version of Python and versions of the libraries we used.
# sys allows us to get the info for the version of Python we use
import sys
import urllib.request
import bs4
print('Python version:', sys.version_info)
print('Urllib.request version:', urllib.request.__version__)
print('BeautifulSoup version:', bs4.__version__)
print('Pandas version:', pd.__version__)
You can find the ipython notebook and data for this post here.
Comments
comments powered by Disqus