Skip to content

Styling, palettes, saving

Chapter 21 33 code blocks 8394 words

Up to this chapter the goal has been correctness: pick the right plot for the question, map the right variables to the right channels. This chapter is about the last mile — making the figure readable by someone who is not you, on a projector at the back of a lecture hall, or on a black-and-white printed handout, or embedded in a report someone opens on a phone.

Seaborn's default look is deliberately good, but the defaults are tuned for one situation: a person sitting close to a laptop screen inside a Jupyter notebook. A slide is not a notebook. A printed page is not a slide. Almost everything in this chapter is about moving the figure from the notebook default into whatever medium it actually has to survive in.

There are five separate levers, and it helps to keep them mentally separate because they are controlled by different functions:

Lever Controls Set with
Style Background colour, grid lines, axis spines sns.set_theme(style=...), sns.axes_style(), sns.despine()
Context Font sizes, line widths, marker sizes sns.set_theme(context=...), font_scale=
Palette Which colours encode which values palette=, sns.set_palette(), sns.color_palette()
Size Physical width and height of the canvas plt.figure(figsize=) or height=/aspect=
Output File format, resolution, cropping plt.savefig() / g.savefig()

Everything in this chapter assumes the usual imports and one of the two teaching datasets:

python6 lines
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd

tips = sns.load_dataset("tips")
penguins = sns.load_dataset("penguins")

21.1 set_theme() and the five styles

sns.set_theme() is the single entry point for seaborn's look. Called with no arguments it applies seaborn's opinionated defaults to matplotlib's global settings — the grey-gridded background, the deep palette, notebook-sized fonts.

python6 lines
import seaborn as sns
# apply seaborn's defaults to everything drawn afterwards
sns.set_theme()

# or configure all four knobs at once
sns.set_theme(style="whitegrid", context="talk", palette="colorblind", font_scale=1.1)

The five styles. whitegrid and darkgrid help the reader read values off the axes; white and ticks suit publication figures where the grid would be clutter.

📌 `set()` vs `set_theme()`

sns.set() is the old name and still works, but it is a deprecated alias kept for backwards compatibility. Since v0.11 the documented function is sns.set_theme(). Teach and write set_theme(); recognise set() when you meet it in older Stack Overflow answers.

The five styles

A style is a bundle of background/grid/spine settings. Seaborn ships exactly five, and they are two independent binary choices (dark or white background, grid or no grid) plus one extra:

Style Looks like Use it when
darkgrid Light-grey panel (#EAEAF2), white grid lines, no spines. The default. Exploratory work in a notebook. The grid helps you read values; the grey keeps the data the brightest thing on screen.
whitegrid White panel, light-grey grid lines, faint spines. Printed handouts and papers. Grey panels waste toner and can band on cheap printers. Best all-round choice for handouts.
dark Grey panel, no grid. When the grid competes with dense data — heatmaps, images, 2-D density plots.
white White panel, no grid, spines on all four sides. Minimal figures where exact values do not matter, or where you will draw your own reference lines.
ticks Like white, plus small tick marks pointing outward on the axes. Journal-style figures. The ticks give the eye an anchor without a full grid. Usually paired with sns.despine().
🧩 Grid lines are a ruler

Ask whether the reader needs to read a number off the chart or only see a shape. If they will say "that bar is about 25", they need the ruler — use a grid style. If they will say "the trend goes up", the ruler is clutter — use white or ticks.

Temporary styles with a context manager

sns.axes_style() returns the style dictionary, and can be used as a with block so that only the plots created inside it are affected. This is the clean way to make one exception without disturbing the rest of the notebook.

python9 lines
sns.set_theme(style="darkgrid")

with sns.axes_style("white"):
    # only this figure is drawn on a white, gridless panel
    sns.scatterplot(data=penguins, x="bill_length_mm", y="bill_depth_mm")
    plt.show()

# back to darkgrid automatically
sns.histplot(data=penguins, x="body_mass_g")

The same pattern works for context: with sns.plotting_context("talk"):.

despine()

Spines are the black lines that box in the plotting area. The top and right spines carry no information — nothing is ever plotted against them — so removing them reduces visual noise. sns.despine() does that, and must be called after the plot is drawn.

python6 lines
sns.set_theme(style="ticks")
sns.boxplot(data=tips, x="day", y="total_bill")

sns.despine()                          # drop top and right (the default)
sns.despine(left=True)                 # also drop the left spine (nice with whitegrid)
sns.despine(offset=10, trim=True)      # pull spines away from data, cut to the data range

offset=10 detaches the axes from the data by 10 points, a look common in scientific publishing. trim=True shortens each spine so it spans only the outermost ticks. For a figure-level plot, pass the FacetGrid: g.despine(left=True) or sns.despine(fig=g.figure).

Resetting

python6 lines
# go back to raw matplotlib defaults, discarding seaborn styling
import matplotlib as mpl
mpl.rcParams.update(mpl.rcParamsDefault)

# or just re-apply seaborn's own defaults
sns.set_theme()

21.2 Context: scaling for slides and print

Context is the most under-used feature in seaborn, and the one that most improves a lecture. It scales text sizes, line widths, marker sizes and tick lengths — the elements whose readability depends on viewing distance. It does not change the figure's width and height.

RULEContext changes how big things look inside the figure. figsize changes how big the figure itself is. Changing context does not resize the canvas, and resizing the canvas does not change font sizes.
Context Scale factor Base font size Intended for
paper 0.8 ~9.6 pt Small multi-panel figures in a written report or paper, where the figure is printed at half page width.
notebook 1.0 (default) 12 pt Working in Jupyter, looking at the screen from 50 cm.
talk 1.5 18 pt Projected slides. This is the one you want for lectures.
poster 2.0 24 pt Conference posters, or a slide with a single very large chart read from the back of a big room.
python10 lines
sns.set_theme(context="talk", style="whitegrid")

# fine-tune on top of the context: 15% larger again
sns.set_theme(context="talk", font_scale=1.15)

# context alone, leaving style and palette untouched
sns.set_context("poster")

# override individual rcParams the context set
sns.set_context("talk", rc={"lines.linewidth": 3.0})

Context scales the ink, not the canvas. Use "talk" or "poster" for projected slides — default "notebook" text is unreadable from the back of a classroom.

💡 The slide test

Export the figure, then shrink it on screen to about a quarter of its width and step back two metres. If you cannot read the axis labels, raise the context, not the figure size. Enlarging a notebook-context figure only makes small text into a bigger picture of small text relative to the chart, because everything scales together.

⚠️ Context applies at draw time

set_theme() and set_context() change global matplotlib state, and that state is read when a figure is created. Setting the context after you have already built a figure does nothing to it. Set the theme in the first cell of the notebook, before any plotting.

21.3 The three kinds of palette

This is the most important section in the chapter. A colour palette is not decoration — it is an encoding. The structure of the palette must match the structure of the data, and when it does not, the reader draws conclusions the data does not support.

THE RULEUnordered categories → qualitative. A quantity running low→high → sequential. A quantity with a meaningful midpoint → diverging.

1. Qualitative — for unordered categories

Colours differ in hue but are roughly equal in lightness and saturation, so no category looks "bigger" or "more important" than another. Use for species, day of week, sex, department, country.

Palette Character First three colours
deep Seaborn's default. Medium saturation, works on both light and dark panels. #4C72B0 blue, #DD8452 orange, #55A868 green
muted Slightly softer than deep; good when many elements are filled areas. #4878D0, #EE854A, #6ACC64
colorblind Chosen for red-green colour-vision deficiency. Use this by default in teaching. #0173B2, #DE8F05, #029E73
pastel Very light. Good for large filled regions behind dark text or markers. #A1C9F4, #FFB482, #8DE5A1
bright / dark High and low lightness versions of the same hue sequence. —
Set2, Set1, Dark2, tab10 ColorBrewer and matplotlib families, available by name. Set2: #66C2A5, #FC8D62, #8DA0CB
python2 lines
sns.scatterplot(data=penguins, x="bill_length_mm", y="flipper_length_mm",
                hue="species", palette="colorblind")

The rule that prevents most bad charts. Qualitative for unordered categories, sequential for a quantity, diverging when there is a meaningful midpoint such as zero.

⚠️ Qualitative palettes run out

Most qualitative palettes hold 6–10 distinguishable colours. Asking for 20 gives you 20 colours nobody can tell apart. If a categorical variable has more than about 8 levels, do not solve it with colour — group the small levels into "Other", facet, or use position (a sorted bar chart) instead of hue.

2. Sequential — for a quantity running low → high

Lightness changes monotonically from one end to the other, so "darker" (or "brighter") reliably means "more". Use for counts, density, magnitude, population, price, any non-negative measurement.

Palette Runs from → to Notes
viridis dark purple #440154 → yellow #FDE725 Perceptually uniform, colour-blind safe, greyscale-safe. The safe default.
rocket near-black → orange → pale cream Seaborn's own; designed for heatmaps on a light background.
mako near-black → teal → pale green The cool-toned sibling of rocket.
flare light orange → deep red-purple Narrower lightness range than rocket; better for line/point hue where extremes should not vanish into the background.
crest light green-blue → deep blue Cool counterpart of flare; good for hue on scatter/line plots.
Blues, Greens, Oranges, Greys white → saturated hue Single-hue ColorBrewer ramps. Cheap to print; Greys survives photocopying.
cubehelix dark → light with rotating hue Guaranteed monotone in lightness even in pure greyscale.
python5 lines
sns.scatterplot(data=penguins, x="bill_length_mm", y="bill_depth_mm",
                hue="body_mass_g", palette="viridis")

# reverse any sequential palette by appending _r
sns.heatmap(matrix, cmap="rocket_r")
  1. Diverging — for data with a meaningful midpoint

Two sequential ramps joined at a neutral light (or dark) centre. Use when zero — or an average, or a baseline — is a real, interpretable boundary: correlations, profit and loss, change versus last year, residuals, z-scores, percentage-point swings.

Palette Low → mid → high Notes
coolwarm #3B4CC0 blue → light grey → #B40426 red Classic. High contrast, prints well.
vlag blue → near-white → red Seaborn's own; softer than coolwarm, less harsh on slides.
icefire cyan → near-black → orange Dark-centred. Good on dark backgrounds; unusual for print.
RdBu_r blue → white → red ColorBrewer. The _r matters: plain RdBu puts red at the low end, which reverses the usual reading.
Spectral, BrBG, PiYG various BrBG (brown/blue-green) is a good red-green-safe alternative.
⚠️ A diverging palette without `center=0` is a lie

If the colour scale is not centred on the meaningful midpoint, the neutral colour lands on some arbitrary value and readers will interpret slightly-positive cells as "negative" because they look blue. Always pass center=0 (or vmin/vmax symmetric about the centre).

python4 lines
corr = penguins.corr(numeric_only=True)

sns.heatmap(corr, cmap="vlag", center=0, vmin=-1, vmax=1,
            annot=True, fmt=".2f", square=True, linewidths=.5)

Why mismatches mislead

Two errors account for most bad seaborn figures:

  • Qualitative palette on ordered data. Plot income deciles with Set2 and the reader gets ten unrelated hues. There is no way to see that decile 9 is "more" than decile 3 — the ordering encoded in the data is destroyed by the encoding, and the eye invents groupings from hue similarity that do not exist.
  • Sequential palette on unordered categories. Colour three penguin species with viridis and Gentoo (bright yellow) looks like the extreme end of a scale while Adelie (dark purple) looks like the low end. Readers infer a ranking that is meaningless. It also makes the dark category physically harder to see.
🧩 The temperature dial

Sequential is a dimmer switch: one direction, more or less. Diverging is a thermostat: a comfortable middle with hot on one side and cold on the other. Qualitative is a set of coloured folder tabs: distinct, but no tab is "higher" than another. Choose the object that matches the variable.

21.4 Choosing and building palettes

sns.color_palette() is the universal constructor. It returns a list of RGB tuples that you can pass anywhere a palette is expected, and it displays as swatches in Jupyter.

python5 lines
sns.color_palette()                       # current default, 10 colours
sns.color_palette("Set2")                 # named qualitative
sns.color_palette("viridis", 8)           # 8 evenly spaced samples of a ramp
sns.color_palette("husl", 12)             # 12 evenly spaced, equal-luminance hues
sns.color_palette("viridis", as_cmap=True)# continuous colormap object, for heatmap/kdeplot
📌 Discrete list vs continuous colormap

Functions that colour a fixed set of categories (barplot, boxplot, categorical hue) want a list of colours. Functions that map a continuous number to colour (heatmap, kdeplot fills, numeric hue) want a colormap. as_cmap=True produces the second kind. Passing the wrong kind is a common source of "why is my palette argument ignored".

Inspecting a palette

python6 lines
sns.palplot(sns.color_palette("flare", 6))   # draw the swatches as a figure
plt.show()

# get hex codes, e.g. to reuse in slides or CSS
sns.color_palette("colorblind", 6).as_hex()
# ['#0173b2', '#de8f05', '#029e73', '#d55e00', '#cc78bc', '#ca9161']

Building your own

python13 lines
# sequential ramp from white to one colour
sns.light_palette("#4C72B0", n_colors=6)
sns.light_palette("seagreen", as_cmap=True)

# sequential ramp from black to one colour
sns.dark_palette("#DD8452", n_colors=6, reverse=True)

# diverging: two hues given as hue angles (0-360), meeting at a light centre
sns.diverging_palette(240, 10, s=80, l=55, n=9)   # blue -> light -> red
sns.diverging_palette(145, 300, as_cmap=True)     # green -> light -> purple

# cubehelix: monotone lightness, safe in greyscale
sns.cubehelix_palette(start=.5, rot=-.75, as_cmap=True)

Brand colours and explicit mappings

For institutional colours, pass a plain list of hex strings. To guarantee that a given category always receives the same colour across every figure in a deck, pass a dictionary instead of a list — this is essential when different figures contain different subsets of categories.

python8 lines
UNI = ["#003366", "#C8102E", "#F2A900", "#4C8C2B"]
sns.barplot(data=tips, x="day", y="total_bill", hue="day",
            palette=UNI, legend=False)

# fixed mapping: species keeps its colour even if one is missing from a subset
species_colors = {"Adelie": "#0173B2", "Chinstrap": "#DE8F05", "Gentoo": "#029E73"}
sns.scatterplot(data=penguins, x="bill_length_mm", y="flipper_length_mm",
                hue="species", palette=species_colors)

Setting a global default

python7 lines
sns.set_palette("colorblind")            # every subsequent plot uses it
sns.set_theme(palette="colorblind")      # same thing, alongside style and context

# temporarily, for one block
with sns.color_palette("Set2"):
    sns.countplot(data=tips, x="day", hue="day", legend=False)
    plt.show()
💡 Single-colour plots

When there is no hue, do not reach for a palette at all — use color="#4C72B0". A palette applied to a single series either colours every bar differently (implying a distinction that does not exist) or is silently ignored. One series, one colour.

21.5 Colour-blind safety and accessibility

About 8% of men of Northern European descent, and roughly 0.5% of women, have some form of red-green colour-vision deficiency (deuteranomaly and protanomaly are the common types). In a lecture hall of 100 students that is several people who cannot separate the red and green series in your chart. Add projector colour drift, dim rooms, and photocopied handouts, and the practical failure rate is higher still.

Rules that make figures robust

  1. Use the colorblind palette as your default for categorical hue. It is a qualitative palette built from a set (Wong's palette) selected to remain distinguishable under the common deficiencies. sns.set_theme(palette="colorblind") once, at the top of the notebook, fixes most of the problem.
  2. Never encode meaning by colour alone. Colour should be redundant with a second channel. In line plots vary style= (dash pattern) as well as hue=; in scatter plots vary markers=; in categorical plots, order the categories meaningfully or label them directly on the figure so the legend is a convenience rather than a necessity.
  3. Prefer direct labels to legends where there are few series. A label placed at the end of a line removes the colour-matching task entirely.
  4. Avoid jet and rainbow maps. They are not perceptually uniform: equal steps in the data produce wildly unequal perceived steps in colour. The bright cyan and yellow bands create false edges that readers interpret as real boundaries in the data, and the map is not monotone in lightness so it collapses into mush in greyscale.
  5. Use perceptually uniform ramps — viridis, rocket, mako, cividis, cubehelix. They increase monotonically in lightness, so they degrade gracefully to greyscale and remain ordered under any colour deficiency.
  6. Check contrast, not just hue. Pale yellow on white and dark navy on black both fail regardless of colour vision. Under a projector, contrast is what survives.
python7 lines
# redundant encoding: colour AND dash pattern AND marker
sns.lineplot(data=fmri, x="timepoint", y="signal",
             hue="event", style="event", markers=True, dashes=True)

# scatter with distinct marker shapes per species
sns.scatterplot(data=penguins, x="bill_length_mm", y="flipper_length_mm",
                hue="species", style="species", s=70, palette="colorblind")

The colorblind palette keeps categories distinguishable for red–green colour deficiency. Better still: never encode meaning by colour alone — add markers, line styles or direct labels.

💡 The greyscale test

Save the figure and convert it to greyscale (or simply print it on the department's black-and-white printer). If the series are still separable, the figure is robust for essentially every reader and every medium. This one test catches red-green problems, contrast problems and rainbow-map problems at once.

⚠️ Red and green mean things

Red-for-bad / green-for-good is a strong convention in finance and dashboards, and it is precisely the pair that red-green deficient readers cannot separate. If you must keep the semantics, add a sign, an arrow, or a text label; or switch to the blue-orange pairing used by coolwarm and vlag, which carries the same "opposite directions" reading without the deficiency problem.

21.6 Figure size, aspect and DPI

Seaborn has two families of functions and they take size differently. Getting this wrong is one of the most frequent beginner frustrations.

Family Examples How to size it
Axes-level (draws into an existing Axes) scatterplot, lineplot, boxplot, histplot, heatmap, barplot plt.figure(figsize=(w, h)) before the call, or fig, ax = plt.subplots(figsize=(w, h)) then ax=ax
Figure-level (creates its own figure) relplot, displot, catplot, lmplot, jointplot, pairplot height= and aspect= — these size each facet, not the whole figure
FORMULAfacet width (inches) = height × aspect. Total figure width ≈ number of columns × height × aspect.
python14 lines
# axes-level: size the figure first
plt.figure(figsize=(10, 6))
sns.boxplot(data=tips, x="day", y="total_bill")

# or the explicit, preferred form
fig, ax = plt.subplots(figsize=(10, 6))
sns.boxplot(data=tips, x="day", y="total_bill", ax=ax)

# figure-level: each facet is 4 tall and 4*1.4 = 5.6 wide
g = sns.relplot(data=penguins, x="bill_length_mm", y="flipper_length_mm",
                col="species", height=4, aspect=1.4)

# jointplot is square: only height
sns.jointplot(data=penguins, x="bill_length_mm", y="bill_depth_mm", height=7)
⚠️ `plt.figure(figsize=...)` is ignored by figure-level functions

relplot, catplot, displot and friends create their own Figure. Any figure you opened beforehand is left empty and discarded, and your size setting has no effect. Use height/aspect. If you truly need exact inches afterwards: g.figure.set_size_inches(12, 5).

Choosing dimensions for the medium

Medium Suggested figsize (inches) DPI
Notebook exploration (8, 5) 100 (default)
16:9 slide, chart fills the slide (13.33, 7.5) 150–200
16:9 slide, chart beside text (7, 5) 150–200
A4 handout, full width (6.5, 4) 300
Two-column paper, single column (3.5, 2.5) with context="paper" 300–600

DPI (dots per inch) multiplies inches into pixels: a 10×6 inch figure at 300 dpi is 3000×1800 pixels. Screens need roughly 100 dpi; projectors are forgiving but benefit from 150–200; print needs 300. Note that raising DPI does not make text relatively larger — it makes everything sharper at the same proportions. If text is too small, change context; if the image is fuzzy, change DPI.

python4 lines
# make every figure in the notebook render larger and sharper
plt.rcParams["figure.figsize"] = (9, 5.5)
plt.rcParams["figure.dpi"] = 110        # on-screen rendering
plt.rcParams["savefig.dpi"] = 300       # what savefig uses if you do not pass dpi

21.7 Labels, titles, ticks and legends

Seaborn labels axes with the column names, which are almost never what a reader should see. total_bill is a variable name; "Total bill (USD)" is a label. Units belong in the label.

Two equivalent styles

python13 lines
# pyplot style: acts on the "current" axes
sns.scatterplot(data=tips, x="total_bill", y="tip")
plt.xlabel("Total bill (USD)")
plt.ylabel("Tip (USD)")
plt.title("Tips rise with the bill, with widening spread", fontsize=14, pad=12)

# object style: explicit, preferred once there is more than one axes
ax = sns.scatterplot(data=tips, x="total_bill", y="tip")
ax.set(xlabel="Total bill (USD)",
       ylabel="Tip (USD)",
       title="Tips rise with the bill",
       xlim=(0, 60),
       ylim=(0, 12))

Titles on figure-level plots

A FacetGrid has per-facet titles and needs a figure-level title added above them:

python6 lines
g = sns.catplot(data=tips, x="day", y="total_bill", col="sex", kind="box", height=4)

g.set_axis_labels("Day of week", "Total bill (USD)")
g.set_titles("{col_name}")                       # per-facet template
g.figure.suptitle("Bill distribution by day and sex", y=1.03, fontsize=15)
g.tight_layout()

Tick labels

python12 lines
# rotate long category names; ha="right" keeps the label end under its tick
plt.xticks(rotation=45, ha="right")

# object style
ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha="right")

# rename ticks explicitly
ax.set_xticks([0, 1, 2, 3])
ax.set_xticklabels(["Thursday", "Friday", "Saturday", "Sunday"])

# on a FacetGrid
g.set_xticklabels(rotation=45, ha="right")

Thousands separators, currency and percentages come from matplotlib.ticker:

python10 lines
from matplotlib.ticker import FuncFormatter, PercentFormatter

ax.yaxis.set_major_formatter(FuncFormatter(lambda x, pos: f"{x:,.0f}"))
# 1250000 -> "1,250,000"

ax.yaxis.set_major_formatter(FuncFormatter(lambda x, pos: f"${x:,.0f}"))
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1.0))   # 0.42 -> "42%"

# compact millions
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, pos: f"{x/1e6:.1f}M"))
⚠️ Long labels silently overlap

Matplotlib does not detect collisions between tick labels; it just draws them on top of each other. If a category is longer than about eight characters, either rotate to 45°, or — usually better — flip the chart so the categories run down the y axis (y="category" instead of x="category"), where labels are horizontal and readable.

Legends

python13 lines
ax = sns.scatterplot(data=penguins, x="bill_length_mm", y="flipper_length_mm", hue="species")

plt.legend(title="Species", loc="lower right", frameon=True)

# move the legend outside the axes so it never covers data
plt.legend(title="Species", loc="upper left", bbox_to_anchor=(1.05, 1), borderaxespad=0)
plt.tight_layout()

# a horizontal legend above the plot — good on wide slides
plt.legend(title=None, loc="lower center", bbox_to_anchor=(0.5, 1.02), ncol=3, frameon=False)

# remove it entirely (e.g. when hue duplicates the x axis)
ax.get_legend().remove()

Figure-level functions manage the legend on the grid object:

python11 lines
g = sns.relplot(data=penguins, x="bill_length_mm", y="flipper_length_mm", hue="species")
g.legend.set_title("Species")

# draw it inside the facet instead of beside the grid
g = sns.relplot(data=penguins, x="bill_length_mm", y="flipper_length_mm",
                hue="species", legend="full")
sns.move_legend(g, "upper left", bbox_to_anchor=(.55, .45))

# sns.move_legend also works on an Axes and preserves the title
ax = sns.scatterplot(data=penguins, x="bill_length_mm", y="bill_depth_mm", hue="species")
sns.move_legend(ax, "upper left", bbox_to_anchor=(1, 1), title="Species")
📌 Use `sns.move_legend()`, not a second `plt.legend()`

Calling plt.legend(loc=...) after seaborn has built a legend rebuilds it from the artists and quietly drops seaborn's title and ordering. sns.move_legend() (v0.11.2+) relocates the existing legend and keeps its contents intact.

21.8 Annotating a plot

An annotation is how a figure states its conclusion. On a slide, the annotation often matters more than the data: the audience has 15 seconds, and the arrow tells them where to look.

Text and arrows

python14 lines
ax = sns.scatterplot(data=tips, x="total_bill", y="tip")

ax.annotate("Unusually generous tip",
            xy=(23.7, 10.0),                  # the point being pointed at
            xytext=(35, 9.5),                 # where the text sits
            arrowprops=dict(arrowstyle="->", color="#444444", lw=1.4),
            fontsize=11, color="#444444")

# plain text, no arrow; coordinates in data units
ax.text(45, 2, "n = 244", fontsize=10, style="italic", color="grey")

# coordinates in axes fractions (0-1), independent of the data range
ax.text(0.02, 0.95, "Source: Seaborn tips dataset",
        transform=ax.transAxes, fontsize=9, va="top", color="grey")
💡 `transform=ax.transAxes`

Data coordinates move when the axis limits change; axes-fraction coordinates do not. Use transAxes for anything anchored to a corner — source notes, sample sizes, captions — and data coordinates only when the text must stay attached to a specific observation.

Reference lines and shaded regions

`mean_tip = tips["tip"].mean()

ax.axhline(mean_tip, color="#C44E52", linestyle="--", linewidth=1.5, label=f"Mean tip = ${mean_tip:.2f}") ax.axvline(20, color="grey", linestyle=":", linewidth=1)

highlight a band, e.g. a target range

ax.axhspan(3, 5, color="#4C72B0", alpha=0.10) ax.axvspan(30, 40, color="grey", alpha=0.08)

ax.legend()`

Value labels on bars

Since matplotlib 3.4, ax.bar_label() writes the value on each bar. On a slide this removes the need for a y-axis grid entirely.

python9 lines
ax = sns.barplot(data=tips, x="day", y="total_bill", errorbar=None,
                 hue="day", palette="crest", legend=False)

for container in ax.containers:
    ax.bar_label(container, fmt="%.1f", padding=3, fontsize=11)

sns.despine(left=True)
ax.set(ylabel="Mean bill (USD)", xlabel="")
ax.yaxis.set_visible(False)          # labels replace the axis

With hue=, seaborn creates one container per hue level, which is why the loop over ax.containers is the safe idiom. To label only the largest bar, pass a list of labels where the others are empty strings.

21.9 Saving figures properly

The canonical call, and the one to memorise:

MEMORISEplt.savefig("figure.png", dpi=300, bbox_inches="tight")
python7 lines
plt.figure(figsize=(9, 5.5))
sns.boxplot(data=tips, x="day", y="total_bill", hue="day", palette="colorblind", legend=False)
plt.title("Total bill by day")
plt.tight_layout()

plt.savefig("bill_by_day.png", dpi=300, bbox_inches="tight")
plt.show()          # show AFTER saving, never before

What each argument does

ArgumentEffect

dpi=300Resolution. 100 for screen/web, 150–200 for projected slides, 300 for print, 600 for journal line art. Multiplies figsize into pixels. bbox_inches="tight"Recomputes the bounding box to include everything actually drawn. This is what rescues rotated tick labels, long y-labels, a suptitle, and a legend pushed outside with bbox_to_anchor — all of which are otherwise cropped off. pad_inches=0.1Margin left around the tight bounding box. Use 0 for a flush edge-to-edge slide image. transparent=TrueDrops the figure and axes background so the slide's own background shows through. Essential for dark-themed decks. Note: it makes white text invisible on white and black text invisible on black — check on the actual slide. facecolor="white"The opposite: force an opaque white background, useful when a dark notebook theme would otherwise bleed into the file. bbox_extra_artists=[leg]Rarely needed alongside bbox_inches="tight"; forces specific artists to be included in the bounding box.

Choosing a format

FormatTypeUse forWatch out

PNGRasterWeb, README files, anything pasted into chat or a documentFixed resolution — enlarging blurs. Set dpi deliberately. SVGVectorSlides, web pages that need infinite zoom, further editing in Inkscape or IllustratorHuge files for scatter plots with 100k points; some office software renders text oddly. PDFVectorPrint, LaTeX documents, journal submissionSame size problem with very dense plots. JPGRaster, lossyEssentially never for chartsCompression artefacts smear thin lines and text; no transparency.

💡 Vector for print, raster for the web, and one exception

Prefer PDF or SVG whenever the figure will be printed or projected — text stays crisp at any zoom. The exception is a plot with tens of thousands of individual marks: rasterise the data layer and keep the text vector with ax.set_rasterized(True) plus plt.savefig("f.pdf", dpi=300).

Figure-level plots

python7 lines
g = sns.relplot(data=penguins, x="bill_length_mm", y="flipper_length_mm",
                col="species", hue="sex", height=4, aspect=1.1)
g.set_axis_labels("Bill length (mm)", "Flipper length (mm)")
g.figure.suptitle("Penguin bill and flipper dimensions", y=1.04)

g.savefig("penguins.png", dpi=300, bbox_inches="tight")
# equivalent: g.figure.savefig(...)

Saving several formats at once

python6 lines
def save_all(name, dpi=300):
    for ext in ("png", "pdf", "svg"):
        plt.savefig(f"{name}.{ext}", dpi=dpi, bbox_inches="tight")

save_all("figures/bill_by_day")
plt.show()

21.10 Dropping down to matplotlib

Seaborn is a layer on top of matplotlib, not a replacement for it. Every seaborn function returns a matplotlib object, and once you hold that object the whole matplotlib API is available. This is the answer to nearly every "seaborn cannot do X" question.

Seaborn call Returns Get the figure with
sns.scatterplot(...) and other axes-level matplotlib.axes.Axes ax.figure
sns.relplot/catplot/displot/lmplot FacetGrid g.figure, individual axes via g.axes / g.axes_dict
sns.jointplot JointGrid g.ax_joint, g.ax_marg_x, g.ax_marg_y
sns.pairplot PairGrid g.axes (2-D array)
python9 lines
ax = sns.scatterplot(data=penguins, x="bill_length_mm", y="body_mass_g", hue="species")

# now it is a plain matplotlib Axes — do anything matplotlib can do
ax.set_yscale("log")
ax.grid(axis="y", alpha=.3)
ax.spines["top"].set_visible(False)
ax.set_facecolor("#FAFAFA")
ax.tick_params(axis="x", labelsize=11, length=4)
ax.figure.set_size_inches(9, 5)

Placing seaborn plots into your own subplot grid

Axes-level functions accept ax=, which is how you compose a multi-panel figure by hand. Figure-level functions do not accept ax= — that is the fundamental trade-off between the two families.

python14 lines
fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharey=True)

sns.boxplot(data=tips, x="day", y="total_bill", ax=axes[0],
            hue="day", palette="crest", legend=False)
axes[0].set_title("Distribution by day")
axes[0].set(xlabel="", ylabel="Total bill (USD)")

sns.scatterplot(data=tips, x="total_bill", y="tip", hue="time", ax=axes[1])
axes[1].set_title("Tip vs bill")
axes[1].set(xlabel="Total bill (USD)", ylabel="Tip (USD)")

fig.suptitle("Restaurant tipping behaviour", fontsize=15)
fig.tight_layout()
fig.savefig("panel.png", dpi=300, bbox_inches="tight")
python12 lines
# uneven layouts: 2 rows, second row spanning both columns
fig = plt.figure(figsize=(12, 8))
gs = fig.add_gridspec(2, 2)

ax1 = fig.add_subplot(gs[0, 0])
ax2 = fig.add_subplot(gs[0, 1])
ax3 = fig.add_subplot(gs[1, :])

sns.histplot(data=tips, x="total_bill", ax=ax1)
sns.histplot(data=tips, x="tip", ax=ax2)
sns.scatterplot(data=tips, x="total_bill", y="tip", hue="day", ax=ax3)
fig.tight_layout()

Reaching individual facets of a grid

python11 lines
g = sns.catplot(data=tips, x="day", y="total_bill", col="sex", kind="box", height=4)

# by position
g.axes[0, 0].axhline(tips["total_bill"].mean(), color="red", ls="--")

# by name, which is safer when facets are reordered
g.axes_dict["Male"].set_title("Male customers")

# apply something to every facet
for ax in g.axes.flat:
    ax.tick_params(labelrotation=30)

21.11 Gotchas

⚠️ `savefig()` after `show()` writes a blank image

With most backends, plt.show() renders and then clears the current figure. A plt.savefig() that follows it operates on a new, empty figure and produces a blank white file — with no error message. Always save before showing. If you have already shown it, re-run the whole plotting cell. In scripts, the safest habit is to build the figure, save it, then plt.close(fig).

⚠️ `set_theme()` mutates global state

sns.set_theme(), sns.set_palette() and sns.set_context() write into matplotlib.rcParams, which is global and process-wide. A theme set in cell 3 silently changes every figure produced in cells 4 to 60, including ones you wrote before changing it and then re-ran. Set the theme once at the top; use with sns.axes_style(...) for exceptions; and remember that importing someone else's module can change your styling.

⚠️ `plt.figure(figsize=...)` does nothing for `relplot`/`catplot`/`displot`

Figure-level functions build their own figure, orphaning yours. Use height= and aspect=, remembering that they size a single facet: a 3-column grid with height=4, aspect=1.5 is 18 inches wide.

⚠️ The legend covering the data

Matplotlib's loc="best" tries to avoid the data but only checks a handful of candidate positions, and on dense plots it loses. Move the legend outside with bbox_to_anchor=(1.05, 1) and save with bbox_inches="tight" so the wider box is not cropped — the two go together.

⚠️ Blurry slide images

Right-clicking "save image" in Jupyter gives you the on-screen render, typically 100 dpi, which looks soft on a projector and terrible when scaled up. Always export with plt.savefig(..., dpi=200) or higher, or use SVG/PDF and let the projector rasterise at its native resolution.

⚠️ Palette/data mismatch, again

A sequential palette on unordered categories invents a ranking; a qualitative palette on ordered data destroys one. Both are silent — the code runs, the figure looks colourful, and the reader is misinformed. Before every palette=, ask what kind of variable it is encoding.

⚠️ `tight_layout()` is not `bbox_inches="tight"`

plt.tight_layout() rearranges subplots inside the existing canvas; bbox_inches="tight" crops the saved file to the drawn content. They solve different problems and are often both needed. tight_layout() also warns or misbehaves with legends placed outside the axes — in that case rely on bbox_inches="tight" alone, or use constrained_layout=True when creating the figure.

⚠️ `sns.load_dataset()` needs internet

The teaching datasets are downloaded from GitHub on first use. In a locked-down lab or an offline classroom this raises a URL error mid-lecture. Cache them once while online — sns.load_dataset("tips").to_csv("tips.csv", index=False) — and load with pd.read_csv("tips.csv") in class. Alternatively clone the seaborn-data repository and point sns.load_dataset(..., data_home="path/to/seaborn-data", cache=True) at it. Datasets already fetched are cached under ~/seaborn-data.

⚠️ Figures accumulate in loops

Creating figures inside a loop without closing them leaks memory and triggers matplotlib's "more than 20 figures have been opened" warning. Call plt.close(fig) — or plt.close("all") — after saving each one.

📌 Fonts and non-ASCII labels

If category labels contain characters the default font lacks, matplotlib prints "missing from font" warnings and draws empty boxes. Set a font that covers your alphabet before plotting: plt.rcParams["font.family"] = "DejaVu Sans" (broad coverage, bundled with matplotlib), or any installed family by name. Setting it via sns.set_theme(font="DejaVu Sans") works too.

21.12 Practice

python38 lines
# 1. Draw the same boxplot of total_bill by day five times, once per style
#    (darkgrid, whitegrid, dark, white, ticks). Write one sentence for each
#    saying which medium it suits best. Add sns.despine() to the "ticks" version.

# 2. Plot penguins bill_length_mm vs flipper_length_mm coloured by species,
#    once in context="notebook" and once in context="talk", both at figsize=(8,5).
#    Confirm that the canvas size is identical and only the text/lines scaled.

# 3. Colour penguins by body_mass_g using (a) "Set2" and (b) "viridis".
#    Then colour by species using (a) "viridis" and (b) "colorblind".
#    Explain in writing which two of the four are wrong and what a reader
#    would incorrectly conclude from each.

# 4. Compute penguins.corr(numeric_only=True) and draw it as a heatmap with
#    cmap="vlag", center=0, vmin=-1, vmax=1, annot=True. Redraw it without
#    center= and describe how the reading of the weak correlations changes.

# 5. Build a custom palette from three hex codes of your university's brand
#    colours, apply it to a countplot of tips["day"], and print the palette's
#    hex codes back out with .as_hex().

# 6. Make a barplot of mean total_bill by day. Add value labels with
#    ax.bar_label(), a dashed red axhline at the overall mean, an annotate()
#    arrow pointing at the highest bar, and a source note in axes coordinates.

# 7. Create fig, axes = plt.subplots(1, 2, figsize=(12,5)). Put a histplot of
#    total_bill on the left and a scatterplot of tip vs total_bill on the right,
#    give each a title, add a suptitle, and move the scatter's legend outside
#    the axes with bbox_to_anchor=(1.05, 1).

# 8. FINAL: produce one presentation-ready figure and save it.
#    - sns.set_theme(style="whitegrid", context="talk", palette="colorblind")
#    - figsize (11, 6), penguins, x=species, y=body_mass_g, hue=sex
#    - human-readable axis labels with units, a title that states the finding
#    - legend outside the axes, sns.despine(left=True)
#    - save as slide.png (dpi=200, transparent=True) AND handout.pdf
#      (bbox_inches="tight"), saving BEFORE plt.show()
#    - open both files and check nothing is cropped.

21.13 Interview questions

Q: What is the difference between style and context in seaborn?

Style controls the appearance of the plot background: panel colour, grid lines and axis spines — darkgrid, whitegrid, dark, white, ticks. Context controls the scale of the elements: font sizes, line widths, marker sizes — paper, notebook, talk, poster. They are independent, both set through sns.set_theme(), and neither of them changes the figure's physical dimensions.

Q: When would you use a diverging palette rather than a sequential one?

When the variable has a meaningful midpoint that the reader should be able to locate at a glance — zero for profit and loss or for correlation, a baseline for change-versus-last-year, the mean for a z-score. The neutral centre colour marks that midpoint and the two hues show direction. It must be anchored with center=0 (or symmetric vmin/vmax); otherwise the neutral colour falls on an arbitrary value and readers misjudge the sign of small effects. For a quantity that only runs from low to high with no special middle — counts, prices, populations — a sequential palette is correct.

Q: Why is jet considered a poor colormap?

It is not perceptually uniform. Equal steps in the data produce very unequal perceived colour changes, so the map compresses some ranges and exaggerates others. Its bright cyan and yellow bands read as sharp edges, making readers see boundaries that do not exist in the data. Lightness is not monotone, so it collapses into an ambiguous mess in greyscale or when photocopied, and it is not colour-blind safe. viridis, rocket, mako and cividis were designed to fix exactly these problems.

Q: How do you set the figure size for sns.relplot(), and why does plt.figure(figsize=...) not work? relplot is a figure-level function: it creates its own Figure internally, so any figure opened beforehand is abandoned. Size it with height= and aspect=, which set the dimensions of one facet — facet width equals height * aspect, and the whole figure is roughly the number of columns times that. If exact inches are required, adjust afterwards with g.figure.set_size_inches(w, h).

Q: Your saved PNG is blank. What happened?

Almost certainly plt.savefig() was called after plt.show(). show() flushes and clears the current figure, so savefig() writes an empty canvas — silently, with no error. The fix is to save first and show afterwards, or to keep an explicit handle (fig, ax = plt.subplots()) and call fig.savefig(), which is bound to that specific figure and unaffected by the ordering.

Q: What does bbox_inches="tight" do, and when is it essential?

It recomputes the saved image's bounding box from the artists actually drawn, rather than using the nominal figure rectangle. It is essential whenever content extends beyond the default box: 45-degree rotated tick labels, long axis labels, a suptitle placed above the axes, and above all a legend moved outside with bbox_to_anchor. Without it those elements are cropped off in the file even though they looked fine on screen. It is distinct from plt.tight_layout(), which rearranges subplots inside the existing canvas.

Q: How do you make a chart accessible to colour-blind readers?

Use the colorblind qualitative palette or a perceptually uniform sequential map such as viridis; never rely on colour as the only channel — add marker shapes, dash patterns or direct labels so the encoding is redundant; avoid the red/green pairing for opposing meanings, preferring blue/orange; keep lightness contrast high; and verify by converting the figure to greyscale, which catches colour-vision, contrast and rainbow-map problems in one test.

Q: Seaborn cannot do something you need — for example a log y axis, a shaded region, or a 2×2 mixed panel. What do you do?

Drop to matplotlib. Axes-level functions return a matplotlib.axes.Axes, so ax = sns.scatterplot(...) then ax.set_yscale("log"), ax.axhspan(...), ax.spines[...] and the rest of the API are all available. For layouts, build the grid yourself with fig, axes = plt.subplots(2, 2) and pass ax=axes[0, 1] to each seaborn call. Figure-level functions do not take ax=, but expose their axes through g.axes, g.axes_dict and g.figure.

Q: What is the practical difference between saving PNG and SVG?

PNG is raster: a fixed pixel grid whose sharpness is decided by dpi at save time; enlarging it blurs. SVG (and PDF) are vector: shapes and text are stored as instructions and re-rendered crisply at any zoom, and remain editable in vector software. Use vector for print, slides and papers; use PNG for the web, chat and documents, or for plots with tens of thousands of marks where a vector file would be enormous — in that case rasterise just the data layer with ax.set_rasterized(True) and keep the text vector.

Summary

  • sns.set_theme(style=, context=, palette=, font_scale=) is the one entry point; sns.set() is its deprecated alias. It mutates global matplotlib state, so set it once at the top and use with sns.axes_style(...) for exceptions.
  • Five styles: darkgrid for exploration, whitegrid for handouts, dark/white when a grid competes with dense data, ticks plus sns.despine() for publication-style figures.
  • Context scales fonts, lines and markers — not the canvas. talk (1.5×) and poster (2×) are what projected slides need; font_scale= fine-tunes on top.
  • Match the palette to the variable: qualitative (colorblind, deep, Set2) for unordered categories, sequential (viridis, rocket, crest) for low→high quantities, diverging (vlag, coolwarm, RdBu_r) for data with a meaningful midpoint — always with center=0. A mismatch misleads silently.
  • Design for the ~8% with red-green colour deficiency: default to colorblind, never encode by colour alone, avoid jet, and test in greyscale.
  • Size axes-level plots with plt.figure(figsize=(w, h)) and figure-level plots with height=/aspect= (facet width = height × aspect); DPI controls sharpness, not relative text size.
  • Always relabel axes in human language with units, rotate or flip long category labels, and move a crowded legend outside with bbox_to_anchor=(1.05, 1) or sns.move_legend().
  • State the conclusion on the figure using annotate(), axhline(), ax.text(transform=ax.transAxes) and ax.bar_label().
  • Save with plt.savefig(name, dpi=300, bbox_inches="tight") — before plt.show(), never after — using g.savefig() for figure-level plots, vector formats for print and slides, PNG for the web.
  • Anything seaborn cannot do, matplotlib can: seaborn returns real Axes, Figure and grid objects, and ax= lets axes-level plots slot into any subplot layout you build.

Next: Chapter 22 puts it all together into a complete EDA workflow.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents