Bump Chart

Bump Chart #

A bump chart is a way to represent rankings, usually over time. It represents rankings using a line, where the $x$-axis are moments of time, and the $y$-axis represents relative rank. Thus, while both axes are numeric, one is ordinal and one is real.

Bump charts in Plotly #

The Python Plotly library does not have a dedicated chart type for bump charts. However, the fact that the data is represented on two quantitative axes, using a line, suggests that we should be able to construct a bump chart using the scatterplot method in Plotly. This is what we we will do in this tutorial.

Prepare the data #

Creating a bump plot requires a series of data that includes the names of things that will be ranked, and their ranking at every point of measurement.

For instance, consider if we wished to represent the NFL power rankings for the 2025 season. At present, there have been 5 weeks. There are 32 teams, and so we might represent the data as follows:

In this representation, each column would be one of the weeks, and then the relative rankings would be given by the row number.

Using this data, it would be possible to arrive at a way to make a bump plot, but it might be better to think about how the plot should be constructed, in order to best understand how to organize the data. Above, it was suggested that one could use the line functionality of Plotly’s scatterplot method. We know that the way this is done is to supply both x and y keyword arguments. If we wanted to make the rankings represented vertically, with the weeks represented on the $x$ axis, then we could use something like bumpChart.add_scatter(x = weeks, y = ranking) and then repeat this for each team.

This reasoning suggests that we should organize our data so that each team has the rankings as numbers, which we could use as the values to supply to the y argument. We can re-organize the above data to reflect this, arriving at something like this:

!

Now, each team is a row in the data, and each entry is the ranking, and there are five weeks of rankings for each team. This data (which can be downloaded here) suggests a means by which we could organize a bump chart, so let us do that!

Plot the data #

We can start simply by making a chart that works, and then we can worry about refining it to improve its design. To make the initial chart, we need to read the data, and then organize it so that we can pass the rankings for teams, one after another, to the scatter method.

We already know that we can use the genfromtxt() method of Numpy to read the data. If we look back up at the data we have, we can see that the rankings run row by row, in columns 1, 2, 3, 4, 5 (recall that Python starts counting at 0). So, the reading of the rows would be:

import numpy as np

rankings = np.genfromtxt(
	<path to data>, 
	delimiter = ",", 
	usecols = [1, 2, 3, 4, 5],
	)

This will result in an object (rankings) that holds all the rankings for the teams. It will be an array that holds 32 sub-arrays, with each of those sub-arrays holding the week-by-week rankings for a single team. These are the values we want to pass along to the y argument of add_scatter(). We then need the x values, which will be weeks, we can simply create this directly:

weeks = [1, 2, 3, 4, 5]

We are now in a place where we could create a bump chart!

The first step is to create the chart that we will add traces to:

from plotly.subplots import make_subplots
bumpChart = make_subplots()

The only thing left is to add each team’s ranking, one by one to the plot, and then display it. To add each team, we could recognize that the object rankings contains all the rankings for the teams, each held in an array. We can access these with the indices. So, we could do this by something like rankings[0] then rankings[1] and so on. But this would be annoying. Instead, we would just use the list of rankings and then for each list, add that list. We can do this using a for loop:

for r in rankings:
    bumpChart.add_scatter(x = weeks, y = r)

We first encountered for loops when working with error bars and small multiples. To remind us, however, this code will go through each item that is within rankings and then for each of these items, it assigns it to a temporary variable r. Then we use this temporary variable r to assign the rankings to the y argument for add_scatter. The end result is that we will go through each list of rankings in turn, assigning them to our chart. For each, we have the $x$ values as the week numbers and the $y$ values are the ranking. This is the chart we wanted to produce!

We can then show this chart using:

bumpChart.show("svg")

Running this will display the following:

This is a properly constructed bump chart. To be sure, it is not easy to read or well designed, but it is a bump chart.

Next, let us consider how to refine the bump plot we have made.

Refine the plot #

There are many many ways that we could choose to refine this plot, and which is the best depends on what story we are trying to tell with our data.

Since I am the one making this, and since I grew up in the Bay Area in California in the 80s and 90s, I will focus on the 49ers. I might want to know how the 49ers, and only the 49ers have faired this season so far, compared to all other teams. In this story, I don’t really care about who the other teams are, I simply care that they exist and also have rankings.

This perspective suggests that perhaps I don’t need to be able to tell all the other teams apart. I only need to types of teams: 49ers and not 49ers. This means we can color all the traces that are not the 49ers one color (i.e., grey), and the 49ers a different color (red). This change would fit well with our discussion of consistency and contrast. We can accomplish this in two lines of code:

bumpChart.update_traces(line = dict(color = "lightgrey"), 
						marker = dict(color = "lightgrey"), 
						showlegend = False)
bumpChart.update_traces(selector = 14, line = dict(color = "red", 
						width = 4), 
						marker = dict(color = "red", size = 10))

The first of these colors all traces grey (including that of the 49ers). The second line colors only a specific trace (the 49ers trace, which is trace 14) red. How did I know that the 49ers trace was #14? Well, from looking at the data, I know the order the data was in, and I know that the for loop added the data in order of the rankings, so this makes the 49er trace 14 (recall we start counting at 0).

In addition to these changes, these lines of code removed the legend from the plot (showlegend = False). Since we are not trying to distinguish between all the teams, the legend is not needed. Additionally, we increased the width of the lines and the size of the markers for the 49ers trace. This helps create a bit more contrast for the 49ers data.

Something else we can do is to consider the axes. If we don’t really care about the exact rankings of all teams, but just the relative ranking of the 49ers relative to all other teams, then we don’t really need all the tick marks on the $y$-axis. We can remove those and their labels. If we don’t have tick marks we don’t need the axis lines. Thinking about lines, we probably don’t need the axis line for the $x$-axis, and we can replace the tick marks with gridlines for a stronger vertical indicator of weeks. Finally, returning to the $y$-axis, the conventional visual metaphor for ranking is that #1 is on top, and the last place on the bottom. This is the opposite of what we see in our our initial plot, so we can also reverse the direction of the $y$-axis, using the autorange = 'reversed' keyword argument within the update_yaxes method. Together all these considerations can be accomplished in two lines as follows:

bumpChart.update_yaxes(autorange = 'reversed', showticklabels = False, showline = False, ticks = "", )
bumpChart.update_xaxes(nticks = 5, showgrid = True, ticks = "", showline = False, tickprefix = "week #")

If we remove the labels from the $y$-axis, we do declutter it, but we also lose some information. For instance, a viewer would not know that that the top position is #1 and the bottom is #32. Thus, we should add back in labels for the top and bottom ranks. While we are at it, we might add in a label for the team of interest, in this case the 49ers. All of this can be done using annotations, as follows:

bumpChart.add_annotation(text = "49ers", x = 0.95, y = 15, showarrow = False, xanchor = "right", yanchor = "middle", font = dict(color = "red"))
bumpChart.add_annotation(text = "#1", x = 0.95, y = 1, showarrow = False, xanchor = "right", yanchor = "middle", font = dict(color = "grey"))
bumpChart.add_annotation(text = "#32", x = 0.95, y = 32, showarrow = False, xanchor = "right", yanchor = "middle", font = dict(color = "grey"))

Here, we have made the annotations for the top and bottom grey, but the one that labels the 49ers red. The position of the 49ers label comes from their initial rank in the season, which we can see is 15, as shown in the above data.

Finally, we might consider the overall design of the figure. We should include a title that makes some sort of claim about the data. We might consider adjusting the margins, especially since so much room is not needed for the legend, and we might consider using a template that is much more simple in appearance than the default. All of this can be done within the update_layout() method of the Plotly chart object, as follows:

bumpChart.update_layout(title = "2025 49ers power ranking has stayed flat", title_x = 0.14, template = "simple_white", font = dict(size = 16), margin = dict(l=50, r = 10, t = 50, b = 10))

Displaying the results of these changes will produce the figure shown below.

Final code #

We can collect all the code in a more cohesive manner, to produce the final bump chart.

import numpy as np
from plotly.subplots import make_subplots

rankings = np.genfromtxt(<path to data>, delimiter = ",", usecols = [1, 2, 3, 4, 5])

weeks = [1, 2, 3, 4, 5]

bumpChart = make_subplots()
for r in rankings:
    bumpChart.add_scatter(x = weeks, y = r)
    
bumpChart.update_traces(line = dict(color = "lightgrey"), marker = dict(color = "lightgrey"), showlegend = False)
bumpChart.update_traces(selector = 14, line = dict(color = "red", width = 4), marker = dict(color = "red", size = 10))


bumpChart.update_yaxes(autorange = 'reversed', showticklabels = False, showline = False, ticks = "", )
bumpChart.update_xaxes(nticks = 5, showgrid = True, ticks = "", showline = False, tickprefix = "week #")

bumpChart.add_annotation(text = "49ers", x = 0.95, y = 15, showarrow = False, xanchor = "right", yanchor = "middle", font = dict(color = "red"))
bumpChart.add_annotation(text = "#1", x = 0.95, y = 1, showarrow = False, xanchor = "right", yanchor = "middle", font = dict(color = "grey"))
bumpChart.add_annotation(text = "#32", x = 0.95, y = 32, showarrow = False, xanchor = "right", yanchor = "middle", font = dict(color = "grey"))

bumpChart.update_layout(title = "2025 49ers power ranking has stayed flat", title_x = 0.14, template = "simple_white", font = dict(size = 16), margin = dict(l=50, r = 10, t = 50, b = 10))

bumpChart.show("svg")