Tables#
Introduction#
The humble table is an underloved and underappreciated element of communicating analysis. While it may not be as visually engaging as a vivid graph (and is far less good for a general audience), it has the advantage of being able to convey exact numerical information. It’s also an essential part of some analysis: for example, when writing economics papers, there is usually a “table 1” that contains descriptive statistics. (For more on best practice for tables, check out the advice provided by the UK government’s Analysis Function.)
Alternatives#
Unfortunately, Python is a bit weaker on tables than it ought to be though the situation has improved markedly with the release of the gt (great tables) package by Posit, who also made an R version of the same package. The Python version is in its early days but you can already make really good HTML tables: check here for the package website and they’ll surely add other output formats before long. If you want ease and flexibility, and you don’t mind that the end product is a HTML table (for instance, you’re just working in Jupyter notebooks or you’re creating a dashboard or website), gt is well worth a try. We won’t cover it here.
Another option, if you’re happy with image output formats, is matplotlib, the infinitely flexible image package. However, your tables have to be exported as image files rather than as machine-readable text-based files. If you want a graphic table that is more striking, matplotib is brilliant. I wouldn’t say it’s simple though, as it relies on content featured in Powerful Data Visualisation with Matplotlib and Common Plots, plus more besides. So, rather than attempt to cover it here, instead we’re just going to direct you to this excellent blog post on ‘the grammar of tables’ by Karina Bartolomé that walks through creating a table with matplotlib.
The rest of this chapter#
We’re going to spend the rest of the chapter looking at a tool than can export to a wider set of formats. Yes, this means we’re back to the Swiss Army Knife of data analysis, pandas. While pandas isn’t perfect for crafting tables, using it for them means you can benefit from its incredible number of output formats, some of which are machine readable.
pandas for tables#
Imports and setup#
As ever, we’ll start by importing some key packages and initialising any settings:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Set seed for random numbers
seed_for_prng = 78557
prng = np.random.default_rng(
seed_for_prng
) # prng=probabilistic random number generator
We’ll use the penguins dataset to demonstrate the use of pandas in creating tables. These data come with the seaborn package, which you’ll need:
import seaborn as sns
pen = sns.load_dataset("penguins")
pen.head()
species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | |
---|---|---|---|---|---|---|---|
0 | Adelie | Torgersen | 39.1 | 18.7 | 181.0 | 3750.0 | Male |
1 | Adelie | Torgersen | 39.5 | 17.4 | 186.0 | 3800.0 | Female |
2 | Adelie | Torgersen | 40.3 | 18.0 | 195.0 | 3250.0 | Female |
3 | Adelie | Torgersen | NaN | NaN | NaN | NaN | NaN |
4 | Adelie | Torgersen | 36.7 | 19.3 | 193.0 | 3450.0 | Female |
Preparing and creating your table in pandas#
There are a few operations that you’ll want to do again, and again, and again to create tables. A cross-tab is one such operation! A cross-tab is just a count of the number of elements split by two groupings. Rather than just display this using the pd.crosstab()
function, we can add totals or percentages using the margins=
and normalize=
commands.
In the below, we’ll use margins and normalisation so that each row sums to 1, and the last row shows the probability mass over islands.
pd.crosstab(pen["species"], pen["island"], margins=True, normalize="index")
island | Biscoe | Dream | Torgersen |
---|---|---|---|
species | |||
Adelie | 0.289474 | 0.368421 | 0.342105 |
Chinstrap | 0.000000 | 1.000000 | 0.000000 |
Gentoo | 1.000000 | 0.000000 | 0.000000 |
All | 0.488372 | 0.360465 | 0.151163 |
The neat thing about the cross-tabs that come out is that they are themselves pandas dataframes.
Of course, the usual pandas functions can be used to create any table you need:
pen_summary = (
pen
.groupby(["species", "island"])
.agg(
median_bill=("bill_length_mm", "median"),
mean_bill=("bill_length_mm", "mean"),
std_flipper=("flipper_length_mm", "std"),
)
)
pen_summary
median_bill | mean_bill | std_flipper | ||
---|---|---|---|---|
species | island | |||
Adelie | Biscoe | 38.70 | 38.975000 | 6.729247 |
Dream | 38.55 | 38.501786 | 6.585083 | |
Torgersen | 38.90 | 38.950980 | 6.232238 | |
Chinstrap | Dream | 49.55 | 48.833824 | 7.131894 |
Gentoo | Biscoe | 47.30 | 47.504878 | 6.484976 |
For reasons that will become apparent later, we’ll replace one of these values with a missing value (pd.NA
).
pen_summary.iloc[2, 1] = pd.NA
The table we just saw, pen_summary
, is not what you’d call publication quality. The numbers have lots of superfluous digits. The names are useful for when you’re doing analysis, but might not be so obvious to someone coming to this table for the first time. So let’s see what we can do to clean it up a bit.
First, those numbers. We can apply number rounding quickly using .round()
.
pen_summary.round(2)
median_bill | mean_bill | std_flipper | ||
---|---|---|---|---|
species | island | |||
Adelie | Biscoe | 38.70 | 38.98 | 6.73 |
Dream | 38.55 | 38.50 | 6.59 | |
Torgersen | 38.90 | NaN | 6.23 | |
Chinstrap | Dream | 49.55 | 48.83 | 7.13 |
Gentoo | Biscoe | 47.30 | 47.50 | 6.48 |
This returns another dataframe. To change the names of the columns, you can just use one of the standard approaches:
pen_sum_clean = (
pen_summary
.rename(columns={"median_bill": "Median bill length (mm)", "mean_bill": "Mean bill length (mm)", "std_flipper": "Std. deviation of flipper length"})
)
pen_sum_clean
Median bill length (mm) | Mean bill length (mm) | Std. deviation of flipper length | ||
---|---|---|---|---|
species | island | |||
Adelie | Biscoe | 38.70 | 38.975000 | 6.729247 |
Dream | 38.55 | 38.501786 | 6.585083 | |
Torgersen | 38.90 | NaN | 6.232238 | |
Chinstrap | Dream | 49.55 | 48.833824 | 7.131894 |
Gentoo | Biscoe | 47.30 | 47.504878 | 6.484976 |
One tip is to always have a dictionary up your sleeve that maps between the short names that are convenient for coding, and the longer names that you need to make outputs clear. Then, just before you do any exporting, you can always map the short names into the long names.
Styling a pandas dataframe#
As well as making direct modifications to a dataframe, you can apply styles. These are much more versatile ways to achieve styling of a table for some output formats, namely HTML and, although it isn’t perfect, for Latex too. (But this doesn’t work for markdown outputs, and markdown doesn’t support such rich formatting in any case.)
Behind the scenes, when a table is displayed on a webpage like the one you’re reading right now, HTML (the language most of the internet is in) is used. Styling is a way of modifying the default HTML for showing tables so that they look nicer or better.
In the example below, you can see some of the options that are available:
precision
is like.round()
na_rep
sets how missing values are renderedthousands
sets the separator between every thousand (for readability)formatter
gives fine-grained control over the formatting of individual columns
pen_styled = (
pen_sum_clean.style
.format(precision=3, na_rep='Value missing', thousands=",",
formatter={
"Mean bill length (mm)": "{:.1f}",
"Std. deviation of flipper length (mm)": lambda x: "{:,.0f} um".format(x*1e3)
}
)
.set_caption("This is the title")
)
pen_styled
Median bill length (mm) | Mean bill length (mm) | Std. deviation of flipper length | ||
---|---|---|---|---|
species | island | |||
Adelie | Biscoe | 38.700 | 39.0 | 6.729 |
Dream | 38.550 | 38.5 | 6.585 | |
Torgersen | 38.900 | Value missing | 6.232 | |
Chinstrap | Dream | 49.550 | 48.8 | 7.132 |
Gentoo | Biscoe | 47.300 | 47.5 | 6.485 |
If you need to add more labels to either the index or the column names, you can. It’s a bit fiddly, but you can.
pen_sum_extra_col_info = pen_sum_clean.copy() # create an independent copy
pen_sum_extra_col_info.columns = [["Lengths", "Lengths", "Stds"], pen_sum_clean.columns]
pen_sum_extra_col_info_styled = (
pen_sum_extra_col_info
.style
.format(precision=3, na_rep='Value missing', thousands=",",
formatter={
"Mean bill length (mm)": "{:.1f}",
"Std. deviation of flipper length (mm)": lambda x: "{:,.0f} um".format(x*1e3)
}
)
.set_caption("This is the title")
.set_table_styles(
[{'selector': 'th', 'props': [('text-align', 'center')]}]
)
)
pen_sum_extra_col_info_styled
Lengths | Stds | |||
---|---|---|---|---|
Median bill length (mm) | Mean bill length (mm) | Std. deviation of flipper length | ||
species | island | |||
Adelie | Biscoe | 38.700 | 38.975 | 6.729 |
Dream | 38.550 | 38.502 | 6.585 | |
Torgersen | 38.900 | Value missing | 6.232 | |
Chinstrap | Dream | 49.550 | 48.834 | 7.132 |
Gentoo | Biscoe | 47.300 | 47.505 | 6.485 |
Let’s see an example of exporting this to Latex too:
pen_sum_extra_col_info_styled.to_latex()
'\\begin{table}\n\\caption{This is the title}\n\\thcenter\n\\begin{tabular}{llrrr}\n & & \\multicolumn{2}{r}{Lengths} & Stds \\\\\n & & Median bill length (mm) & Mean bill length (mm) & Std. deviation of flipper length \\\\\nspecies & island & & & \\\\\n\\multirow[c]{3}{*}{Adelie} & Biscoe & 38.700 & 38.975 & 6.729 \\\\\n & Dream & 38.550 & 38.502 & 6.585 \\\\\n & Torgersen & 38.900 & Value missing & 6.232 \\\\\nChinstrap & Dream & 49.550 & 48.834 & 7.132 \\\\\nGentoo & Biscoe & 47.300 & 47.505 & 6.485 \\\\\n\\end{tabular}\n\\end{table}\n'
You can read more about the style functionality over at the pandas Styler docs.
Another thing you might reasonably want to do is provide summary statistics for all columns, as the last column, or for all rows, as the last row. To do this for, say, the mean when not using the pd.crosstab
function, you will need to insert a summary row into the dataframe object. To start with, you need to actually create the summary row—this is achieved with the .mean(axis=0)
method to get the mean over rows. We cast this into being a dataframe using pd.DataFrame
as otherwise it would just be a single column, or Series object. Then we need to give the columns a name that’s better than the default “0
”, and we choose a multi-level (here, two level) column name recognising that the index of our original dataframe has two levels: species and island. We actually just want to put “summary” in once and we’ve arbitrarily chosen the first level for that. Note that multi-level indices and columns can get complicated but the essential trick to bear in mind is that you replace a list of strings with a list of tuples of strings, eg for the first column ["Summary:", ...]
becomes [("Summary", "summary level two"), ...]
. Putting this all together gives us a dataframe with an index that is the same as the columns of the original dataframe:
summary_row = pd.DataFrame(pen_sum_extra_col_info.mean(axis=0))
summary_row.columns = [("Summary:", "")] # note our index has two levels
summary_row
(Summary:, ) | ||
---|---|---|
Lengths | Median bill length (mm) | 42.600000 |
Mean bill length (mm) | 43.453872 | |
Stds | Std. deviation of flipper length | 6.632687 |
Next we need to transpose the new summary row (so that its columns align with those in our original data frame) using .T
, and concatenate the two dataframes together:
pd.concat([pen_sum_extra_col_info, summary_row.T], axis=0)
Lengths | Stds | |||
---|---|---|---|---|
Median bill length (mm) | Mean bill length (mm) | Std. deviation of flipper length | ||
Adelie | Biscoe | 38.70 | 38.975000 | 6.729247 |
Dream | 38.55 | 38.501786 | 6.585083 | |
Torgersen | 38.90 | NaN | 6.232238 | |
Chinstrap | Dream | 49.55 | 48.833824 | 7.131894 |
Gentoo | Biscoe | 47.30 | 47.504878 | 6.484976 |
Summary: | 42.60 | 43.453872 | 6.632687 |
Once this is done, you can apply all the usual stylings.
Writing pandas tables to file#
Writing pandas tables to file is fairly straightforward: just use one of pandas many, many output functions. These typically begin with .to_
and then the output name. The most useful output formats will be:
to_html()
to_latex()
to_string()
to_markdown()
Add the filename you’d like to write to within the brackets following these method names. For example, to write a latex table it would be:
pen_styled.to_latex(Path("outputs/table_one.tex"))
These files can then be picked up by other documents. Note that, sometimes, when exporting to Latex, the code will have “escape” characters, for example extra backslashes. In some versions of pandas you can turn these off with an escape=False
keyword argument.
It’s not perfect, but if you’re writing a table to latex and want footnotes and source notes, you can make use of the to_latex()
method’s caption=
keyword argument.
Limitations to pandas tables#
pandas tables have some limitations. It is not easy to include rich information such as images: while they can be included in the HTML rendition of a table on a webpage or in a Jupyter Notebook, it’s far harder to export this kind of information sensibly to an output file that isn’t HTML.
A more tricky general issue with them is that it can be hard to include all of the relevant information you’d expect in a table: they work extremely well for a typical table that is just rows and columns with equal-sized cells, but it you want to include, say, a long row (of a single cell) at the end for either footnotes or source notes, there isn’t an obvious way to do it. Similarly, it’s hard to make parts of a table such as the title, sub-title, and stubhead label work well in all cases.
To create a footnote or source note row, you could insert an extra row like above, but it’s a very unsatisfactory work-around as your notes can only be in one of the columns (not spread across all of them) and it will lead to you losing the data types in at least one of your original columns.
While we can eventually expect newcomer gt to make building tables that bit easier, for now we have to make do.