Simple Little Tables with Matplotlib | by Michael Demastrie | Towards Data Science

Sometimes, I need to create an image for a document that contains a few rows and columns of data. Like most people, I’ll just use a screen capture to create the image, but this approach doesn’t lend well to automated tasks and requires hand-tweaking to get it ready to present. It’s absurd that I’m working in my Jupyter notebook with Pandas and Matplotlib, and I’m snapping screenshots to capture tables for slide decks and written reports. Matplotlib renders nice graphs. Why not nice, little tables?
One alternative is to generate the report using LaTeX, which has powerful table capabilities. Adopting LaTeX for reporting just to render a little table is overkill and often a yak shave. I’d really like to just use Matplotlib to generate my little grids with nicely centered titles; no extraneous text; a nice, clean border; and maybe a little footer with the date.
Pandas can output decent-looking dataframes within Jupyter notebooks, thanks to Jupyter’s output cell rendering. It can even style these Pandas dataframes using the pandas.Dataframe.style library. The challenge to using these tools to build styled tables is they render the output in HTML. Getting a file into an image format requires workflows that call other external tools, such as wkhtmltoimage, to translate the HTML to images.
Matplotlib can save charts as images. If you search the Matplotlib documentation, you will find it has a matplotlib.pyplot.table package, which looks promising. It is useful. I used it for this article, in fact. The challenge is that matplotlib.pyplot.table creates tables that often hang beneath stacked bar charts to provide readers insight into the data above. You want to show just the chart and you want the chart to look nice.
The first thing that you’ll probably notice when attempting to use matplotlib.pyplot.table is that the tables are rather ugly and don't manage vertical cell padding well. Second, if you try to hide the graph to just show the table grid, alignment issues rear their ugly heads and keep you from ever getting the nicely styled little table you aimed for. It takes some work to get the table to look nice, and I will share with you what I learned from doing this work myself.
The Matplotlib pyplot.table example code creates a table, but does not show how to present data with just a simple table. It generates a table used as an extension to a stacked bar chart. If you follow your intuition and only hide the chart, you will have a poorly formatted image, unsuitable for your article or slide presentation.
In this article, I take you step-by-step through the conversion of the example provided by the Matplotlib example writer to some simple table code for your projects. I provide explanation of the changes I made along the way, which should help you make enhancements. I provide the full code at the end of the article.
From the example, you can see the table feature is used to display data related to an associated stacked bar graph.
Source code for the example (from the Matplotlib Table Demo):
import numpy as npimport matplotlib.pyplot as pltdata = [[ 66386, 174296,  75131, 577908,  32015],        [ 58230, 381139,  78045,  99308, 160454],        [ 89135,  80552, 152558, 497981, 603535],        [ 78415,  81858, 150656, 193263,  69638],        [139361, 331509, 343164, 781380,  52269]]columns = ('Freeze', 'Wind', 'Flood', 'Quake', 'Hail')rows = ['%d year' % x for x in (100, 50, 20, 10, 5)]values = np.arange(0, 2500, 500)value_increment = 1000# Get some pastel shades for the colorscolors = plt.cm.BuPu(np.linspace(0, 0.5, len(rows)))n_rows = len(data)index = np.arange(len(columns)) + 0.3bar_width = 0.4# Initialize the vertical-offset for the stacked bar chart.y_offset = np.zeros(len(columns))# Plot bars and create text labels for the tablecell_text = []for row in range(n_rows):    plt.bar(index, data[row], bar_width, bottom=y_offset, color=colors[row])    y_offset = y_offset + data[row]    cell_text.append(['%1.1f' % (x / 1000.0) for x in y_offset])# Reverse colors and text labels to display the last value at the top.colors = colors[::-1]cell_text.reverse()# Add a table at the bottom of the axesthe_table = plt.table(cellText=cell_text,                      rowLabels=rows,                      rowColours=colors,                      colLabels=columns,                      loc='bottom')# Adjust layout to make room for the table:plt.subplots_adjust(left=0.2, bottom=0.2)plt.ylabel("Loss in ${0}'s".format(value_increment))plt.yticks(values * value_increment, ['%d' % val for val in values])plt.xticks([])plt.title('Loss by Disaster')# plt.show()# Create image. plt.savefig ignores figure edge and face colors, so map them.fig = plt.gcf()plt.savefig('pyplot-table-original.png',            bbox_inches='tight',            dpi=150            )
Now, let’s get down to work. First, I’ll break the table free of the chart. I reformatted the chart data to my tastes and removed data specific to the stacked bar chart. This step is optional for you. When I load small amounts of data from a Python list in the code, I prefer including the labels within the data arrays and popping them off at the right time. I can read it more easily when I come back months later. You will probably import the data from an external CSV file or database and manipulate it in a Pandas dataframe. Having the data in a two-dimension list keeps it simple for the example.
I also removed the plt.show() instruction and added code to write the image to a png file. If you called plt.show() before plt.savefig(), the image would be blank, since plt.show() resets the pyplot figure object references. Remember that plt.savefig() can output other image types by detecting the extension of the filename you provide (jpeg, svg, eps, etc.).
Note: I also made minor changes to some data and labels during my experimenting. I forgot to roll them back before publishing for consistency with the original code. For example, “100 year” became “40 year.”
data =  [            [         'Freeze', 'Wind', 'Flood', 'Quake', 'Hail'],            [ '5 year',  66386, 174296,   75131,  577908,  32015],            ['10 year',  58230, 381139,   78045,   99308, 160454],            ['20 year',  89135,  80552,  152558,  497981, 603535],            ['30 year',  78415,  81858,  150656,  193263,  69638],            ['40 year', 139361, 331509,  343164,  781380,  52269],        ]# Pop the headers from the data arraycolumn_headers = data.pop(0)row_headers = [x.pop(0) for x in data]# Table data needs to be non-numeric text. Format the data# while I'm at it.cell_text = []for row in data:    cell_text.append([f'{x/1000:1.1f}' for x in row])...fig = plt.gcf()plt.savefig('pyplot-table-original.png',            bbox_inches='tight',            dpi=150            )
Let’s get to work on isolating and neatening the table. It will take a few steps. I’ll take you through them.
First, let’s hide the x and y axes, with all their ticks and labels.
ax = plt.gca()ax.get_xaxis().set_visible(False)ax.get_yaxis().set_visible(False)
The axes went away, but we’re left with a border around the space where the bar chart used to be. We can easily turn it off.
plt.box(on=None)
It’s getting cleaner, but we see a ton of whitespace between the title and the table. All we have to do is change the existing bottom alignment to center. This will center the table over the hidden axes area. We should also get rid of the subplot position tweak from the example.
Delete this line:
plt.subplots_adjust(left=0.2, bottom=0.2)
And change the plt.table line for the centered loc:
the_table = plt.table(cellText=cell_text,                      rowLabels=row_headers,                      rowColours=rcolors,                      colLabels=column_headers,                      loc='center')
That change pulls the table upward nicely. Now, let’s address the overly tight vertical space within the cells. pylot.table doesn't allow for specifying cell padding, but we can scale the height 1.5 times with the y argument of the scale(x, y) function. It gives our cell data a little breathing room. You can play with the x and y values to your taste.
the_table.scale(1, 1.5)
Let’s set column colors and set the row header horizontal alignment to right. We'll fill two lists of colors, one for every row header cell and another for every column header cell. We'll just use the plt.cm.BuPm color map that the example used, even though there are better choices than a linear color map for decorative colors. We will set the row and column label colors and alignments when creating the table.
rcolors = plt.cm.BuPu(np.full(len(row_headers), 0.1))ccolors = plt.cm.BuPu(np.full(len(column_headers), 0.1))...the_table = plt.table(cellText=cell_text,                      rowLabels=row_headers,                      rowColours=rcolors,                      rowLoc='right',                      colColours=ccolors,                      colLabels=column_headers,                      loc='center')
We can explicitly declare the figure to get easy control of its border and background color. I’ll use some labeled colors here as an example of that approach in contrast to the color map used above. I’d normally just use white for the background, but sky blue is nice for demonstrating how to set its color.
fig_background_color = 'skyblue'fig_border = 'steelblue'...plt.figure(linewidth=2,           edgecolor=fig_border,           facecolor=fig_background_color          )...plt.savefig('pyplot-table-figure-style.png',            bbox_inches='tight',            edgecolor=fig.get_edgecolor(),            facecolor=fig.get_facecolor(),            dpi=150            )
Pretty. Like a summer day. You probably noticed that the title centers on the axes data, excluding the row headers. We’ll need center it on the figure with three code changes. These changes also improve the centering consistency of the table within the figure.
Set the title on the figure instead of the axes, using plt.suptitle()`.Set a small pad value for tight_layout in the figure declaration. Since we are now declaring a tight layout here, we should remove bbox='tight' from savefig. Leaving it in seems to worsen the centering of the table within the figure. This bbox setting is one to experiment with, if you have trouble with centering during image generation. (Experimenters: note that setting pad_inches=1 in save_fig does not have the same effect as tight_layout={'pad':1} called in the figure declaration. Someday, I'll figure out why.)Optionally, call plt.draw() before saving the figure. This can solve problems with figure border clipping and title centering during image rendering. Sometimes it has no effect.
Set the title on the figure instead of the axes, using plt.suptitle()`.
Set a small pad value for tight_layout in the figure declaration. Since we are now declaring a tight layout here, we should remove bbox='tight' from savefig. Leaving it in seems to worsen the centering of the table within the figure. This bbox setting is one to experiment with, if you have trouble with centering during image generation. (Experimenters: note that setting pad_inches=1 in save_fig does not have the same effect as tight_layout={'pad':1} called in the figure declaration. Someday, I'll figure out why.)
Optionally, call plt.draw() before saving the figure. This can solve problems with figure border clipping and title centering during image rendering. Sometimes it has no effect.
title_text = 'Loss by Disaster'...plt.figure(linewidth=2,           edgecolor=fig_border,           facecolor=fig_background_color,           tight_layout={'pad':1}          )...plt.suptitle(title_text)...plt.draw()...plt.savefig('pyplot-table-tighten-figsize.png',            #bbox_inches='tight',            edgecolor=fig.get_edgecolor(),            facecolor=fig.get_facecolor(),            dpi=150            )
We’ll just use figure text to create a footer and play with the position values until it looks right.
footer_text = 'June 24, 2020'...plt.figtext(0.95, 0.05, footer_text, horizontalalignment='right', size=6, weight='light')
The table image looks pretty good, but the automatic layout doesn’t always produce whitespacing to my taste. Playing with an explicit figure size can often dial this in. Guessing the figure size takes experimentation, and can improve or degrade your table image.
You may also need to add back in the bbox_inches='tight' to savefig to recalculate the bounding box. Otherwise, the image may not have correct right and left padding. You might find the image looks fine in a Jupyter notebook output cell without this call, but is misaligned in your saved image. Making all renderers happy is a balancing act. You should always check the final output images, even if they look great in your IDE's preview.
plt.figure(linewidth=2,           edgecolor=fig_border,           facecolor=fig_background_color,           tight_layout={'pad':1},           figsize=(5,3)          )...plt.savefig('pyplot-table-tighten-figsize.png',            bbox_inches='tight',            edgecolor=fig.get_edgecolor(),            facecolor=fig.get_facecolor(),            dpi=150            )
This final source code does not include the explicit figure size declaration. I prefer the roomier look.
import numpy as npimport matplotlib.pyplot as plttitle_text = 'Loss by Disaster'footer_text = 'June 24, 2020'fig_background_color = 'skyblue'fig_border = 'steelblue'data =  [            [         'Freeze', 'Wind', 'Flood', 'Quake', 'Hail'],            [ '5 year',  66386, 174296,   75131,  577908,  32015],            ['10 year',  58230, 381139,   78045,   99308, 160454],            ['20 year',  89135,  80552,  152558,  497981, 603535],            ['30 year',  78415,  81858,  150656,  193263,  69638],            ['40 year', 139361, 331509,  343164,  781380,  52269],        ]# Pop the headers from the data arraycolumn_headers = data.pop(0)row_headers = [x.pop(0) for x in data]# Table data needs to be non-numeric text. Format the data# while I'm at it.cell_text = []for row in data:    cell_text.append([f'{x/1000:1.1f}' for x in row])# Get some lists of color specs for row and column headersrcolors = plt.cm.BuPu(np.full(len(row_headers), 0.1))ccolors = plt.cm.BuPu(np.full(len(column_headers), 0.1))# Create the figure. Setting a small pad on tight_layout# seems to better regulate white space. Sometimes experimenting# with an explicit figsize here can produce better outcome.plt.figure(linewidth=2,           edgecolor=fig_border,           facecolor=fig_background_color,           tight_layout={'pad':1},           #figsize=(5,3)          )# Add a table at the bottom of the axesthe_table = plt.table(cellText=cell_text,                      rowLabels=row_headers,                      rowColours=rcolors,                      rowLoc='right',                      colColours=ccolors,                      colLabels=column_headers,                      loc='center')# Scaling is the only influence we have over top and bottom cell padding.# Make the rows taller (i.e., make cell y scale larger).the_table.scale(1, 1.5)# Hide axesax = plt.gca()ax.get_xaxis().set_visible(False)ax.get_yaxis().set_visible(False)# Hide axes borderplt.box(on=None)# Add titleplt.suptitle(title_text)# Add footerplt.figtext(0.95, 0.05, footer_text, horizontalalignment='right', size=6, weight='light')# Force the figure to update, so backends center objects correctly within the figure.# Without plt.draw() here, the title will center on the axes and not the figure.plt.draw()# Create image. plt.savefig ignores figure edge and face colors, so map them.fig = plt.gcf()plt.savefig('pyplot-table-demo.png',            #bbox='tight',            edgecolor=fig.get_edgecolor(),            facecolor=fig.get_facecolor(),            dpi=150            )
I shared a step-by-step transformation of the Matplotlib pyplot.tables example into a simple, neat, little table, free of an attached chart. The table is well-aligned and suitable for your publication or presentation. The steps included code changes to hide the chart, center the table, set colors, insert a footer, and tips to improve the alignment and spacing of the final image file. Maybe these steps will save you time someday.