Easy Data Visualisation for Tidy Data with Seaborn#

Introduction#

Here you’ll see how to make plots quickly using the declarative plotting package seaborn. This package is perfect if you want to make a standard chart from so-called tidy data where you have one row per observation and one columnn per variable.

Note

seaborn is perhaps the quickest way to get going with plots in Python.

seaborn is actually built on top of matplotlib so you can also mix code for the two packages.

The rest of this chapter is indebted to the excellent seaborn object notation documentation.

As ever, we start by bringing in the packages we’ll need:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib as mpl
import seaborn as sns
import seaborn.objects as so


# Set seed for random numbers
seed_for_prng = 78557
prng = np.random.default_rng(seed_for_prng)  # prng=probabilistic random number generator

Quite a few of the examples we’ll see use a range of additional datasets, so let’s grab those straight away:

tips = sns.load_dataset("tips")
penguins = sns.load_dataset("penguins").dropna()
diamonds = sns.load_dataset("diamonds")
healthexp = sns.load_dataset("healthexp").sort_values(["Country", "Year"]).query("Year <= 2020")

Specifying a plot and mapping data#

The most important command in seaborn is Plot. You specify plots by instantiating a Plot object and calling its methods. Let’s see a simple example:

(
    so.Plot(penguins, x="bill_length_mm", y="bill_depth_mm")
    .add(so.Dot())
)
_images/vis-seaborn_7_0.png

This code, which produces a scatter plot, should look reasonably familiar. Just as when using seaborn.scatterplot, we passed a tidy dataframe (penguins) and assigned two of its columns to the x and y coordinates of the plot. But instead of starting with the type of chart and then adding some data assignments, here we started with the data assignments and then added a graphical element.

Setting properties#

The Dot class is an example of a Mark: an object that graphically represents data values. Each mark will have a number of properties that can be set to change its appearance:

(
    so.Plot(penguins, x="bill_length_mm", y="bill_depth_mm")
    .add(so.Dot(color="g", pointsize=4))
)
_images/vis-seaborn_9_0.png

Mapping properties

As with seaborn’s functions, it is also possible to map data values to various graphical properties:

(
    so.Plot(
        penguins, x="bill_length_mm", y="bill_depth_mm",
        color="species", pointsize="body_mass_g",
    )
    .add(so.Dot())
)
_images/vis-seaborn_11_0.png

While this basic functionality is not novel, an important difference from the function API is that properties are mapped using the same parameter names that would set them directly (instead of having hue vs. color, etc.). What matters is where the property is defined: passing a value when you initialize Dot will set it directly, whereas assigning a variable when you set up the Plot will map the corresponding data.

Beyond this difference, the objects interface also allows a much wider range of mark properties to be mapped:

(
    so.Plot(
        penguins, x="bill_length_mm", y="bill_depth_mm",
        edgecolor="sex", edgewidth="body_mass_g",
    )
    .add(so.Dot(color=".8"))
)
_images/vis-seaborn_13_0.png

Defining groups#

The Dot mark represents each data point independently, so the assignment of a variable to a property only has the effect of changing each dot’s appearance. For marks that group or connect observations, such as Line, it also determines the number of distinct graphical elements:

(
    so.Plot(healthexp, x="Year", y="Life_Expectancy", color="Country")
    .add(so.Line())
)
_images/vis-seaborn_15_0.png

It is also possible to define a grouping without changing any visual properties, by using group:

(
    so.Plot(healthexp, x="Year", y="Life_Expectancy", group="Country")
    .add(so.Line())
)
_images/vis-seaborn_17_0.png

Transforming data before plotting#

Statistical transformation#

As with many seaborn functions, the objects interface supports statistical transformations. These are performed by Stat objects, such as Agg:

(
    so.Plot(penguins, x="species", y="body_mass_g")
    .add(so.Bar(), so.Agg())
)
_images/vis-seaborn_19_0.png

In the function interface, statistical transformations are possible with some visual representations (e.g. seaborn.barplot) but not others (e.g. seaborn.scatterplot). The objects interface more cleanly separates representation and transformation, allowing you to compose Mark and Stat objects:

(
    so.Plot(penguins, x="species", y="body_mass_g")
    .add(so.Dot(pointsize=10), so.Agg())
)
_images/vis-seaborn_21_0.png

When forming groups by mapping properties, the Stat transformation is applied to each group separately:

(
    so.Plot(penguins, x="species", y="body_mass_g", color="sex")
    .add(so.Dot(pointsize=10), so.Agg())
)
_images/vis-seaborn_23_0.png

Resolving overplotting#

Some seaborn functions also have mechanisms that automatically resolve overplotting, as when seaborn.barplot “dodges” bars once hue is assigned. The objects interface has less complex default behavior. Bars representing multiple groups will overlap by default:

(
    so.Plot(penguins, x="species", y="body_mass_g", color="sex")
    .add(so.Bar(), so.Agg())
)
_images/vis-seaborn_25_0.png

Nevertheless, it is possible to compose the Bar mark with the Agg stat and a second transformation, implemented by Dodge:

(
    so.Plot(penguins, x="species", y="body_mass_g", color="sex")
    .add(so.Bar(), so.Agg(), so.Dodge())
)
_images/vis-seaborn_27_0.png

The Dodge class is an example of a Move transformation, which is like a Stat but only adjusts x and y coordinates. The Move classes can be applied with any mark, and it’s not necessary to use a Stat first:

(
    so.Plot(penguins, x="species", y="body_mass_g", color="sex")
    .add(so.Dot(), so.Dodge())
)
_images/vis-seaborn_29_0.png

It’s also possible to apply multiple Move operations in sequence:

(
    so.Plot(penguins, x="species", y="body_mass_g", color="sex")
    .add(so.Dot(), so.Dodge(), so.Jitter(.3))
)
_images/vis-seaborn_31_0.png

Creating variables through transformation#

The Agg stat requires both x and y to already be defined, but variables can also be created through statistical transformation. For example, the Hist stat requires only one of x or y to be defined, and it will create the other by counting observations:

(
    so.Plot(penguins, x="species")
    .add(so.Bar(), so.Hist())
)
_images/vis-seaborn_33_0.png

The Hist stat will also create new x values (by binning) when given numeric data:

(
    so.Plot(penguins, x="flipper_length_mm")
    .add(so.Bars(), so.Hist())
)
_images/vis-seaborn_35_0.png

Notice how we used Bars, rather than Bar for the plot with the continuous x axis. These two marks are related, but Bars has different defaults and works better for continuous histograms. It also produces a different, more efficient matplotlib artist. You will find the pattern of singular/plural marks elsewhere. The plural version is typically optimized for cases with larger numbers of marks.

Some transforms accept both x and y, but add interval data for each coordinate. This is particularly relevant for plotting error bars after aggregating:

(
    so.Plot(penguins, x="body_mass_g", y="species", color="sex")
    .add(so.Range(), so.Est(errorbar="sd"), so.Dodge())
    .add(so.Dot(), so.Agg(), so.Dodge())
)
_images/vis-seaborn_37_0.png

Orienting marks and transforms#

When aggregating, dodging, and drawing a bar, the x and y variables are treated differently. Each operation has the concept of an orientation. The Plot tries to determine the orientation automatically based on the data types of the variables. For instance, if we flip the assignment of species and body_mass_g, we’ll get the same plot, but oriented horizontally:

(
    so.Plot(penguins, x="body_mass_g", y="species", color="sex")
    .add(so.Bar(), so.Agg(), so.Dodge())
)
_images/vis-seaborn_39_0.png

Sometimes, the correct orientation is ambiguous, as when both the x and y variables are numeric. In these cases, you can be explicit by passing the orient parameter to Plot.add:

(
    so.Plot(tips, x="total_bill", y="size", color="time")
    .add(so.Bar(), so.Agg(), so.Dodge(), orient="y")
)
_images/vis-seaborn_41_0.png

Building and displaying the plot#

Each example thus far has produced a single subplot with a single kind of mark on it. But Plot does not limit you to this.

Adding Multiple Layers#

More complex single-subplot graphics can be created by calling Plot.add repeatedly. Each time it is called, it defines a layer in the plot. For example, we may want to add a scatterplot (now using Dots) and then a regression fit:

(
    so.Plot(tips, x="total_bill", y="tip")
    .add(so.Dots())
    .add(so.Line(), so.PolyFit())
)
_images/vis-seaborn_44_0.png

Variable mappings that are defined in the Plot constructor will be used for all layers:

(
    so.Plot(tips, x="total_bill", y="tip", color="time")
    .add(so.Dots())
    .add(so.Line(), so.PolyFit())
)
_images/vis-seaborn_46_0.png

Layer-specific mappings#

You can also define a mapping such that it is used only in a specific layer. This is accomplished by defining the mapping within the call to Plot.add for the relevant layer:

(
    so.Plot(tips, x="total_bill", y="tip")
    .add(so.Dots(), color="time")
    .add(so.Line(color=".2"), so.PolyFit())
)
_images/vis-seaborn_48_0.png

Alternatively, define the layer for the entire plot, but remove it from a specific layer by setting the variable to None:

(
    so.Plot(tips, x="total_bill", y="tip", color="time")
    .add(so.Dots())
    .add(so.Line(color=".2"), so.PolyFit(), color=None)
)
_images/vis-seaborn_50_0.png

To recap, there are three ways to specify the value of a mark property: (1) by mapping a variable in all layers, (2) by mapping a variable in a specific layer, and (3) by setting the property directy:

from io import StringIO
from IPython.display import SVG
C = sns.color_palette("deep")
f = mpl.figure.Figure(figsize=(7, 3))
ax = f.subplots()
fontsize = 18
ax.add_artist(mpl.patches.Rectangle((.13, .53), .45, .09, color=C[0], alpha=.3))
ax.add_artist(mpl.patches.Rectangle((.22, .43), .235, .09, color=C[1], alpha=.3))
ax.add_artist(mpl.patches.Rectangle((.49, .43), .26, .09, color=C[2], alpha=.3))
ax.text(.05, .55, "Plot(data, 'x', 'y', color='var1')", size=fontsize, color=".2")
ax.text(.05, .45, ".add(Dot(pointsize=10), marker='var2')", size=fontsize, color=".2")
annots = [
    ("Mapped\nin all layers", (.35, .65), (0, 45)),
    ("Set directly", (.35, .4), (0, -45)),
    ("Mapped\nin this layer", (.63, .4), (0, -45)),
]
for i, (text, xy, xytext) in enumerate(annots):
    ax.annotate(
        text, xy, xytext,
        textcoords="offset points", fontsize=14, ha="center", va="center",
        arrowprops=dict(arrowstyle="->", color=C[i]), color=C[i],
    )
ax.set_axis_off()
f.subplots_adjust(0, 0, 1, 1)
f.savefig(s:=StringIO(), format="svg")
SVG(s.getvalue())
_images/vis-seaborn_52_0.svg

Faceting and pairing subplots#

As with seaborn’s figure-level functions (seaborn.displot, seaborn.catplot, etc.), the Plot interface can also produce figures with multiple “facets”, or subplots containing subsets of data. This is accomplished with the Plot.facet method:

(
    so.Plot(penguins, x="flipper_length_mm")
    .facet("species")
    .add(so.Bars(), so.Hist())
)
_images/vis-seaborn_54_0.png

Call Plot.facet with the variables that should be used to define the columns and/or rows of the plot:

(
    so.Plot(penguins, x="flipper_length_mm")
    .facet(col="species", row="sex")
    .add(so.Bars(), so.Hist())
)
_images/vis-seaborn_56_0.png

You can facet using a variable with a larger number of levels by “wrapping” across the other dimension:

(
    so.Plot(healthexp, x="Year", y="Life_Expectancy")
    .facet(col="Country", wrap=3)
    .add(so.Line())
)
_images/vis-seaborn_58_0.png

All layers will be faceted unless you explicitly exclude them, which can be useful for providing additional context on each subplot:

(
    so.Plot(healthexp, x="Year", y="Life_Expectancy")
    .facet("Country", wrap=3)
    .add(so.Line(alpha=.3), group="Country", col=None)
    .add(so.Line(linewidth=3))
)
_images/vis-seaborn_60_0.png

An alternate way to produce subplots is Plot.pair. Like seaborn.PairGrid, this draws all of the data on each subplot, using different variables for the x and/or y coordinates:

(
    so.Plot(penguins, y="body_mass_g", color="species")
    .pair(x=["bill_length_mm", "bill_depth_mm"])
    .add(so.Dots())
)
_images/vis-seaborn_62_0.png

You can combine faceting and pairing so long as the operations add subplots on opposite dimensions:

(
    so.Plot(penguins, y="body_mass_g", color="species")
    .pair(x=["bill_length_mm", "bill_depth_mm"])
    .facet(row="sex")
    .add(so.Dots())
)
_images/vis-seaborn_64_0.png

Integrating with matplotlib#

There may be cases where you want multiple subplots to appear in a figure with a more complex structure than what Plot.facet or Plot.pair can provide. The current solution is to delegate figure setup to matplotlib and to supply the matplotlib object that Plot should use with the Plot.on method. This object can be either a matplotlib.axes.Axes, matplotlib.figure.Figure, or matplotlib.figure.SubFigure; the latter is most useful for constructing bespoke subplot layouts:

f = mpl.figure.Figure(figsize=(8, 4))
sf1, sf2 = f.subfigures(1, 2)
(
    so.Plot(penguins, x="body_mass_g", y="flipper_length_mm")
    .add(so.Dots())
    .on(sf1)
    .plot()
)
(
    so.Plot(penguins, x="body_mass_g")
    .facet(row="sex")
    .add(so.Bars(), so.Hist())
    .on(sf2)
    .plot()
)
_images/vis-seaborn_66_0.png

Building and displaying the plot#

An important thing to know is that Plot methods clone the object they are called on and return that clone instead of updating the object in place. This means that you can define a common plot spec and then produce several variations on it.

So, take this basic specification:

p = so.Plot(healthexp, "Year", "Spending_USD", color="Country")

We could use it to draw a line plot:

p.add(so.Line())
_images/vis-seaborn_70_0.png

Or perhaps a stacked area plot:

p.add(so.Area(), so.Stack())
_images/vis-seaborn_72_0.png

The Plot methods are fully declarative. Calling them updates the plot spec, but it doesn’t actually do any plotting. One consequence of this is that methods can be called in any order, and many of them can be called multiple times.

When does the plot actually get rendered? Plot is optimized for use in notebook environments. The rendering is automatically triggered when the Plot gets displayed in the Jupyter REPL. That’s why we didn’t see anything in the example above, where we defined a Plot but assigned it to p rather than letting it return out to the REPL.

To see a plot in a notebook, either return it from the final line of a cell or call Jupyter’s built-in display function on the object. The notebook integration bypasses :mod:matplotlib.pyplot entirely, but you can use its figure-display machinery in other contexts by calling Plot.show.

You can also save the plot to a file (or buffer) by calling Plot.save.

Customising the appearance#

The new interface aims to support a deep amount of customisation through Plot, reducing the need to switch gears and use matplotlib functionality directly. (But please be patient; not all of the features needed to achieve this goal have been implemented!)

Parameterising scales#

All of the data-dependent properties are controlled by the concept of a Scale and the Plot.scale method. This method accepts several different types of arguments. One possibility, which is closest to the use of scales in matplotlib, is to pass the name of a function that transforms the coordinates:

(
    so.Plot(diamonds, x="carat", y="price")
    .add(so.Dots())
    .scale(y="log")
)
_images/vis-seaborn_75_0.png

Plot.scale can also control the mappings for semantic properties like color. You can directly pass it any argument that you would pass to the palette parameter in seaborn’s function interface:

(
    so.Plot(diamonds, x="carat", y="price", color="clarity")
    .add(so.Dots())
    .scale(color="flare")
)
_images/vis-seaborn_77_0.png

Another option is to provide a tuple of (min, max) values, controlling the range that the scale should map into. This works both for numeric properties and for colors:

(
    so.Plot(diamonds, x="carat", y="price", color="clarity", pointsize="carat")
    .add(so.Dots())
    .scale(color=("#88c", "#555"), pointsize=(2, 10))
)
_images/vis-seaborn_79_0.png

For additional control, you can pass a Scale object. There are several different types of Scale, each with appropriate parameters. For example, Continuous lets you define the input domain (norm), the output range (values), and the function that maps between them (trans), while Nominal allows you to specify an ordering:

(
    so.Plot(diamonds, x="carat", y="price", color="carat", marker="cut")
    .add(so.Dots())
    .scale(
        color=so.Continuous("crest", norm=(0, 3), trans="sqrt"),
        marker=so.Nominal(["o", "+", "x"], order=["Ideal", "Premium", "Good"]),
    )
)
_images/vis-seaborn_81_0.png

Customising legends and ticks#

The Scale objects are also how you specify which values should appear as tick labels / in the legend, along with how they appear. For example, the Continuous.tick method lets you control the density or locations of the ticks, and the Continuous.label method lets you modify the format:

(
    so.Plot(diamonds, x="carat", y="price", color="carat")
    .add(so.Dots())
    .scale(
        x=so.Continuous().tick(every=0.5),
        y=so.Continuous().label(like="${x:.0f}"),
        color=so.Continuous().tick(at=[1, 2, 3, 4]),
    )
)
_images/vis-seaborn_83_0.png

Customising limits, labels, and titles#

Plot has a number of methods for simple customisation, including Plot.label, Plot.limit, and Plot.share:

(
    so.Plot(penguins, x="body_mass_g", y="species", color="island")
    .facet(col="sex")
    .add(so.Dot(), so.Jitter(.5))
    .share(x=False)
    .limit(y=(2.5, -.5))
    .label(
        x="Body mass (g)", y="",
        color=str.capitalize,
        title="{} penguins".format,
    )
)
_images/vis-seaborn_85_0.png

Theme customisation#

Finally, Plot supports data-independent theming through the Plot.theme method. Currently, this method accepts a dictionary of matplotlib rc parameters. You can set them directly and/or pass a package of parameters from seaborn’s theming functions:

from seaborn import axes_style
so.Plot().theme({**axes_style("whitegrid"), "grid.linestyle": ":"})
_images/vis-seaborn_87_0.png