Processing and visualizing multiple categorical variables with Python — NBA’s schedule challenges | by JP Hwang | Towards Data Science

Today, I would like to discuss various ways to process, visualise and review categorical variables. Processing and visualising data when there are multiple categorical variables can be tricky.
You might have seen criss-crossing line plots with multiple colours and marker shapes, or maybe it was a grid of subplots. These solutions can work (and work well), but they are far from the only types of plots available for this particular task, and today we’ll explore some of the other ways to do so.
As to the data, we will use basketball (NBA) win/loss data. More specifically, we will look at three key externalities that is said to affect basketball outcomes; home court advantage, travel, and rest days.
As ever, while this article utilises about basketball data, it is first and foremost a data analysis and visualisation article. So the basketball-specific discussion is kept at a relative minimum, and prior basketball/NBA knowledge is not necessary (although it is both appreciated and encouraged 😉).
I include the code and data in my GitLab repo here, (in the nba_travel_impact directory ) and you should be able to easily follow along by downloading/cloning the repo if you wish.
I assume you’re familiar with python. Even if you’re relatively new, this tutorial shouldn’t be too tricky. (Please find me on twitter and let me know if you would prefer more guidance.)
You’ll need pandas and plotly. Install each (in your virtual environment) with a simple pip install [PACKAGE_NAME].
You can use any graphics packages, obviously, but I personally prefer the simplicity and power you get with Plotly.
Each NBA regular season runs between October and April. (The 2019–20 season runs between October 22 and April 15, for instance.) In that time, each team plays 82 games, which averages out to be only about 2.15 days per game. It’s barely a day of rest between games.
Not only that, NBA stadiums are located all across the US and Toronto like so. Having to play half of their games away from home to balance the schedule, teams must commute between them like we catch the metro.
In fact, NBA teams travel roughly between 60,000 and 85,000 km/year (37,000–53,000 miles/year), and their flights put together look something like this:
(My article on visualising travel data, and an interactive, html version of the map)
And the result of all of this is that travel, the venue of play and rest are huge parts of NBA schedules.
Each team plays half of the 82 games at home, and the other half away. Even many of the home games would not always involve must rest — it might involve travel to come back overnight, and they might even leave again shortly for the next game.
For us mere mortals who would have to go through commercial flights, this would be a nightmare. However, modern NBA players fly in private team jets, and their health and well being is so well-managed that it is not a given that travel would impact them significantly. Let’s find out.
Before we can analyse the impact of rest, play and home advantage, we need to collect or load our data. For convenience I use last season’s, but (you could use this season’s, or multiple seasons’ worth also.
Although you can simply load the data which I’ve pre-processed (skip to where I load box_df if you would prefer to do this), if you are interested — this is how I compiled the data:
I obtained the schedule data from basketball-reference.com, and you can simply load the data as follows from my repo:
schedule_df = pd.read_csv('srcdata/2019_nba_schedule.csv', index_col=0)
The data includes game tip-off time in EST (est_time), so I create a column for date.
schedule_df = schedule_df.assign(game_date=schedule_df.est_time.dt.date)
Then, game_dates = schedule_df.game_date.unique() obtains a list of unique game dates, after which the basketball_reference_web_scraper package is used to download our data for each date. You’ll need to install it with pip if you are to try this code.
What I am doing here is downloading the data, marking each game as a home game, or an away game, and saving the dataframe.
In any case, let’s load a file that I prepared earlier for consistency:
box_df = pd.read_csv('srcdata/2019_nba_team_box_scores.csv', index_col=0)
Take a look at box_df, and you’ll see that it contains 2460 rows, which matches up with 30 teams’ 82 games, as each row shows a team’s box score.
I’ve processed the data here to include boolean columns for whether the team was playing at home, whether they had travelled, and whether they won (win). I also created a categorised variable for the number of rest days (rest_cats), with anything longer than 2 getting grouped together, including opening day.
For our first visualisations, let’s take a look at how many games fall into each categories for rest days, and travel. I’ll skip the home/away splits, which are 50:50.
import plotly.express as px# How many games in each category?temp_grp_df = box_df.groupby('rest_cats').count().reset_index(drop=False)fig = px.bar(temp_grp_df, y='rest_cats', x='outcome', orientation='h')fig.show()
# How many games in each category? Rest days & travel.temp_grp_df = box_df.groupby(['rest_cats', 'travelled']).count().reset_index(drop=False)temp_grp_df = temp_grp_df.assign(travelled=temp_grp_df.travelled.astype(str))fig = px.bar(temp_grp_df, y='rest_cats', x='outcome', orientation='h', color='travelled', barmode='group')fig.show()
That’s simple enough, and categorical variables can be concisely represented here as grouped bars.
As to the outputs, there are a couple of observations to be made — the NBA schedule is such that the majority of games have 1 day of rest, and there are not many back-to-back (0 days’ rest) games.
When there are, they appear to be largely home games (as in, no travel is involved), which is good for the teams.
Now, let’s we take a look at the impact of rest, and travel on wins.The code to do that is largely the same as previous, except for now creating a WIN% column for each group, which becomes our dependent variable.
travel_cats = ['rest_cats', 'travelled']travel_grp_df = box_df.groupby(travel_cats).sum()travel_grp_df = travel_grp_df.assign(games=box_df.groupby(travel_cats)['minutes_played'].count())travel_grp_df['WIN%'] = travel_grp_df.win/travel_grp_df.games * 100travel_grp_df.reset_index(drop=False, inplace=True)travel_grp_df = travel_grp_df.assign(travelled=travel_grp_df.travelled.astype(str))fig = px.bar(travel_grp_df, y='rest_cats', x='WIN%', orientation='h', color='travelled', barmode='group')fig.show()
The trend is as expected — travelling is bad for wins, and more rest is better. But, interestingly — teams that had travelled (red) is winning almost half of its games when given 2+ days of rest!
Now, adding further complexity to these charts is where the analysis starts to get challenging.
We have three categorical variables that we are trying to analyse for, and we need to choose how to visualise them all, while also indicating the dependent variables (win %, for example). With a bar graph, one option is to use subplots as mentioned:
travel_cats = ['home', 'rest_cats', 'travelled']...fig = px.bar(travel_grp_df, y='rest_cats', x='WIN%', facet_col='home', orientation='h', color='travelled', barmode='group')
This is fine, but it is still missing quite a bit of information that would be useful to us. For instance, it doesn’t show us how these categorical variables overlap, or interact. Also, subplots do not lend themselves to easy comparisons along an axis.
Instead, let’s explore a parallel categories chart.
Since they’re somewhat unusual, let’s plot one and then discuss what they contain. Luckily, it’s relatively easy to plot parallel category plots with Plotly. In fact, Plotly Express simply takes as argument the original box score dataframe and compiles the data based on categories which we pass on.
Take a look at this simple example, with binary categories for home and travelled.
# PARALLEL CATEGORIES - THREE CATEGORIES AND WIN %box_df = box_df.assign(win_num=box_df.win.astype(int))travel_cats = ['home', 'travelled']fig = px.parallel_categories(box_df, dimensions=travel_cats, color='win_num')
You may not have seen many parallel categories charts before. They can look complicated, but they are quite simple once you get the hang of them.
Each vertical column represents a category, and each band shows a permutation of categorical variables along each column. The chart above represents two categorical variables, and I have also separated them based on the outcome (win or loss), representing three variables.
Here, it shows that there were 329 games (band) where the team was at home (left column), had travelled before the game (right column), and resulted in a win (colour).
The nature of the charts is that, each added category adds to complexity of the chart, potentially exponentially. Take a look below at the same chart, with categories added for the length of rest days.
Plotted below is an example showing two highlighted paths as an illustration.
The top, dark, path shows the number of games lost where the team was away (home==false), travelled to get to the game (travel==true), and had 2 days or more of rest (rest_cats == 2+ Days).
The lower, yellow, path shows numbers of games of the same criteria, but where the team won the game.
(Here is an interactive version of this for you to try & have a play.)
Once we are used to the format, exploring multi-dimensional categorical relationships through parallel categories chart can be quite refreshing. Here, each vertical bar shows the overall winning percentages, and simply following thicker lines reveal where strong co-occurrences lie.
For instance, let’s take a look at games where the team was playing with no rest, and had travelled. This screenshot highlights records from perspectives fo teams who played away from home.
It is already clear that the losses far outweigh the wins. The away team wins only about 32% of these games (out of 237).
On the other hand, flipping that around, a home team on no rest & travel day wins 69% of 83 games (I will leave that as an exercise — have a look for yourself here).
I find parallel category plots quite useful, especially for data exploration. This chart for instance I found quite useful and visually striking also (I prepared it for this article).
This chart shows all of Klay Thompson’s shots, by shot location, and who the assist came from. While it initially looks complicated, look at how easy it is to spot the dominant combinations of categories. Parallel category plots make it easy to spot trends and co-occurrences as shown here.
Admittedly, while parallel categories charts can be great for some cases, it has its limitations. In my experience, it can require some work by the reader to understand what is going on, and it can be visually confronting and intimidating for many.
Another great, possibly more simple, way to visualise this type of data is treemaps.
Treemaps
Treemaps are a fantastic way to explore hierarchical, categorised data sets. Just take a look at this example, and see how easy it is to understand.
Although treemaps are typically used to evaluate the impact of hierarchical data structures, it is also a good tool for multivariate cases such as this one.
We can produce treemaps here conveniently showing sizes of each group, and variations in winning percentages as we drill down deeper and deeper into each category.
Because treemaps require a hierarchy, we explore by plotting two different treemaps here:
In the first, the hierarchy is based on home/away, travelled/not, and days of rest.
In the second, the hierarchy is changed to be based on home/away, days of rest and travelled/not.
The treemap layout does a great job in showing differences in sample sizes for each subset, as well as changes in percentages by colours.
In both plots, it is clear that a much bigger impact on win percentages generally come from the amount of rest, and simply being home or away, rather than from whether they travelled at all.
Take a look at the top graph, in the second tier of boxes (labelled travelled / stayed) for home games. The overall win / loss percentages for games where the home team returned home (i.e. travelled) before the game is very similar to where they did not travel.
In the second graph, the overall winning percentages for days of rest are drastically different for all boxes, for instance away games with 0 days of rest being the worst (as we saw earlier in the parallel categories graph).
Let’s just explore one more point, and wrap up today’s analysis. We’ve to date looked at this data from one team’s perspective (except for home/away stats, which is complementary). What happens if the opponents’ rest data is also ?
I’ve got another dataset for just this reason — where the opponents’ rest days are added into opp_rest_cats column.
box_df = pd.read_csv('srcdata/2019_nba_team_box_scores_2.csv', index_col=0)travel_cats = ['home', 'rest_cats', 'opp_rest_cats']travel_grp_df = box_df.groupby(travel_cats).sum()travel_grp_df = travel_grp_df.assign(games=box_df.groupby(travel_cats)['minutes_played'].count())travel_grp_df.reset_index(drop=False, inplace=True)chart_cats = ['home_str', 'rest_cats', 'opp_rest_cats']home_away = pd.Series(['Home ' * i for i in travel_grp_df.home.values]) + pd.Series(['Away ' * (1-i) for i in travel_grp_df.home.values])travel_grp_df['home_str'] = home_awaytravel_grp_df['rest_cats'] = travel_grp_df['rest_cats'] + "' Rest"travel_grp_df['opp_rest_cats'] = 'Opponent: <BR>' + travel_grp_df['opp_rest_cats'] + "'<BR>Rest"travel_grp_df['WIN%'] = travel_grp_df.win/travel_grp_df.games * 100fig = px.treemap(    travel_grp_df, path=['home_str', 'rest_cats', 'opp_rest_cats'], values='games', color='WIN%', color_continuous_midpoint=50, color_continuous_scale=px.colors.diverging.Portland)fig.show()
Look at that! The best and the worst win/loss percentages happen when the rest disparity is the greatest.
Have a look at the ‘away’ stats on the left, where our teams have 0 days’ rest, and the opponents have 2+ days, and the opposite, where our teams have 2+ days of rest and the opponents have 0 days of rest. They represent the extreme opposites. The pattern persists throughout, where rest disparity is a huge factor in win percentages.
Even when the home team is on a back-to-back, they on average crush the other back-to-back team, showing the value of home cooking. About the most neutral setup appears to be having the home team on a back-to-back, and the away team on extended rest.
Isn’t that interesting!? As it turns out, travel turns out to be relatively a small factor, and the largest factors in win/loss percentages are rest differential, and being home/away. This was easily made discoverable through these treemaps.
All in all, I hope the above demonstrated some useful examples and techniques for drilling into datasets where there are multiple categorical variables.
In my opinion, parallel category plots and treemaps are useful tools in doing so, and potentially underutilised ones.
Treemaps are effective in visualising data in terms of drilldowns and changes in a property as data was subdivided, whereas parallel category plots were excellent for identifying cooccurrences, and flow of data points. Try these out for yourself with your own data sets, and I would love to hear more about them.
If you liked this, pls to say 👋 / follow on twitter, or follow here for updates. I also wrote this article about mapping NBA travel data using Plotly, if you haven’t read it before:
medium.com
And also this article about my favourite data visualisation books.
towardsdatascience.com
Thanks and see you next time!