Skip to content

Relational plots

Chapter 15 26 code blocks 6920 words

A relational plot answers one question: how does one numeric variable move with another? Seaborn groups three functions under this heading — sns.scatterplot(), sns.lineplot() and the figure-level wrapper sns.relplot(). They all live in the module seaborn.relational, and internally they share the same machinery for mapping extra columns onto colour, size and marker shape.

Everything in this chapter assumes long-form (tidy) data: one row per observation, one column per variable. That is why the calls look like sns.scatterplot(data=tips, x="total_bill", y="tip") — you name columns, and seaborn reads values, dtypes and labels out of the DataFrame for you.

python9 lines
import seaborn as sns
import matplotlib.pyplot as plt

sns.set_theme(style="whitegrid")   # seaborn defaults for colours, fonts, grid

tips = sns.load_dataset("tips")          # 244 rows: restaurant bills
penguins = sns.load_dataset("penguins")  # 344 rows: body measurements
flights = sns.load_dataset("flights")    # 144 rows: monthly air passengers 1949-1960
titanic = sns.load_dataset("titanic")    # 891 rows: passenger manifest
📌 Where these functions come from

Seaborn is a layer on top of matplotlib. sns.scatterplot() ultimately calls ax.scatter(), and sns.lineplot() calls ax.plot(). What seaborn adds is the semantic mapping layer: it inspects your DataFrame columns, decides whether a variable is categorical or numeric, builds a palette, aggregates repeated observations, and writes the legend and axis labels. That is the whole value proposition — the drawing is matplotlib, the statistics and the bookkeeping are seaborn.

The three functions at a glance

Function What it shows When to use it
sns.scatterplot() One marker per row: the joint distribution of two numeric variables Correlation, clusters, outliers; x has no natural order
sns.lineplot() Points connected in x order, with repeated x values aggregated to a mean and an uncertainty band Time series, trends, dose-response; x has a meaningful order
sns.relplot(kind="scatter") Same as scatterplot, but drawn on a grid of subplots You want col= / row= faceting, or a self-sizing figure
sns.relplot(kind="line") Same as lineplot, on a grid of subplots One time series per group, side by side
plt.scatter() / plt.plot() Raw matplotlib: no aggregation, no legend, no labels Full manual control, or when you are already deep in matplotlib
RULEAxes-level functions (scatterplot, lineplot) draw onto one matplotlib Axes and return it. Figure-level functions (relplot) create and own the whole figure and return a FacetGrid.

15.1 scatterplot

sns.scatterplot() draws one marker per row of the DataFrame. Nothing is aggregated, nothing is smoothed, no rows are dropped except those with missing x or y.

python2 lines
sns.scatterplot(data=tips, x="total_bill", y="tip")
plt.show()

The plainest scatterplot: each point is one bill.

What you see: 244 blue dots. Bills run from about 3 to 51 dollars along the x axis, tips from 1 to 10 along the y axis. The cloud tilts upward — bigger bills get bigger tips — and it fans out to the right (variance grows with the bill). A few points sit clearly above the cloud: generous tippers on small bills. The x label reads total_bill and the y label reads tip, taken straight from the column names.

Reading a scatterplot: the four things to look for

  1. Direction — does y rise or fall with x? Here it rises, so the correlation is positive (Pearson r is about 0.68).
  2. Form — straight line, curve, or no shape at all? In tips it is roughly linear.
  3. Strength / spread — a tight band means a strong relationship; a fat cloud means a weak one. Notice the funnel shape here: this is heteroscedasticity, and it matters if a student later fits ordinary least squares to this data.
  4. Outliers and structure — isolated points, gaps, stripes. In tips you can see faint horizontal stripes at whole-dollar tips: people round.

The parameters you will actually use

Parameter Meaning Typical value
data The DataFrame tips
x, y Column names (strings) for the two axes "total_bill"
hue Column mapped to marker colour "sex"
size Column mapped to marker area "size"
style Column mapped to marker shape "time"
s A single fixed marker area in points squared (matplotlib kwarg) s=15
alpha Opacity, 0 to 1 — the first fix for overplotting alpha=0.4
palette Colour scheme for hue "deep", "viridis"
sizes (min, max) marker area range for size (20, 200)
markers Marker shapes for style ["o", "X"]
legend "auto", "brief", "full", or False "brief"
ax Existing matplotlib Axes to draw into ax=axes[0]
⚠️ `s` is not `size`

size="size" maps the DataFrame column named size (party size in tips) onto marker area and adds a legend. Lowercase s=60 is a matplotlib keyword that sets one fixed area for every marker and adds nothing to the legend. Students mix these up constantly, and because tips genuinely has a column called size, the confusion is doubled.

Drawing into an existing figure

Because scatterplot is axes-level, it composes with plain matplotlib. This is how you build multi-panel layouts by hand:

python9 lines
fig, axes = plt.subplots(1, 2, figsize=(11, 4))

sns.scatterplot(data=penguins, x="bill_length_mm", y="bill_depth_mm", ax=axes[0])
sns.scatterplot(data=penguins, x="flipper_length_mm", y="body_mass_g", ax=axes[1])

axes[0].set_title("Bill shape")
axes[1].set_title("Flipper vs mass")
fig.tight_layout()
plt.show()

Both panels use penguins. The left panel looks like a shapeless blob until you colour by species — that is Simpson's paradox in miniature, and section 15.3 fixes it. The right panel is a clean upward band: longer flippers, heavier birds.

💡 Return value

ax = sns.scatterplot(...) returns the matplotlib Axes. Every matplotlib method is then available: ax.set_xlim(0, 60), ax.axhline(tips["tip"].mean(), color="red", ls="--"), ax.set_title("Tips vs bill"). This is the single most useful fact about axes-level functions.

15.2 lineplot

sns.lineplot() connects points with straight segments in increasing x order. Use it when x has a meaningful ordering — time, dose, temperature, index — and the connection between consecutive points means something.

python2 lines
sns.lineplot(data=flights, x="year", y="passengers")
plt.show()

lineplot aggregates repeated x values into a mean and shades the 95% confidence interval. The band is uncertainty about the mean, not the spread of the data.

What you see: a single line climbing from roughly 125 passengers (thousands) in 1949 to roughly 475 in 1960, plus a translucent band around it that widens over time. That band is not in your data. Understanding where it comes from is the most important idea in this chapter.

lineplot aggregates by default

The flights DataFrame has 144 rows: 12 months for each of 12 years. So for x="year" there are twelve passengers values at x = 1949, twelve at x = 1950, and so on. A line can only pass through one y per x, so seaborn does this:

DEFAULTFor each unique x, compute estimator="mean" of y, then draw an errorbar=("ci", 95) band around that mean, bootstrapped with n_boot=1000 resamples.

So the line is the mean monthly passenger count per year, and the band is a 95% confidence interval for that mean. The band widens over time because within-year seasonal variation grows as the airline industry grows — not because the data is less reliable.

python6 lines
# Prove to yourself that the line is a mean:
print(flights.groupby("year")["passengers"].mean().head(3))
# year
# 1949    126.666667
# 1950    139.666667
# 1951    170.166667
Output
year
1949    126.666667
1950    139.666667
1951    170.166667
Name: passengers, dtype: float64

To plot the raw 144 points with no aggregation, either give each month its own line or turn the estimator off:

python5 lines
# One line per month: 12 lines, no aggregation needed
sns.lineplot(data=flights, x="year", y="passengers", hue="month")

# Or disable aggregation entirely (draws a zig-zag through every raw point)
sns.lineplot(data=flights, x="year", y="passengers", estimator=None)

With estimator=None you get a jagged sawtooth: the line jumps up and down twelve times inside each year because it is now visiting every raw observation in sorted x order. That ugliness is honest — it is telling you the x variable is not unique.

🧩 Analogy

Think of lineplot as a class register. You ask "what was the score in 1951?" and there are twelve students who answered. scatterplot shows you all twelve answers as separate dots. lineplot reports the class average and draws error bars showing how confident you are in that average. Neither is wrong — but if you believe the line is the twelve answers, you have misread the plot.

A properly indexed time series

When x is unique per row, no aggregation happens and no band appears:

python10 lines
import pandas as pd

# Build a proper monthly date index: one row per (year, month)
fl = flights.copy()
fl["date"] = pd.to_datetime(
    fl["year"].astype(str) + "-" + fl["month"].astype(str), format="%Y-%b"
)

sns.lineplot(data=fl, x="date", y="passengers")
plt.show()

Now you see the real series: a rising trend with a strong seasonal wave, peaking every July and August, troughing every November. No confidence band, because each date has exactly one observation. This is the version you would show a class when teaching seasonality.

lineplot parameters worth knowing

Parameter Meaning
estimator Aggregation for repeated x: "mean" (default), "median", "sum", any callable, or None to disable
errorbar Uncertainty band: ("ci", 95) default, "sd", ("se", 1), ("pi", 50), or None
err_style "band" (default, shaded ribbon) or "bars" (discrete error bars)
sort True by default — sorts rows by x before drawing. Set False for trajectories
units Column identifying repeated-measures subjects; draw one faint line each with estimator=None
markers, dashes Show point markers; control dash patterns for style
n_boot, seed Bootstrap resamples for the CI, and a seed for reproducibility
orient "x" (default) or "y" to aggregate along the other axis
⚠️ `sort=True` destroys trajectories

If you plot a path — a robot's route, a hysteresis loop, an iterative optimiser's steps — the order of the rows is the information. lineplot sorts by x first, which scrambles the path into a monotone zig-zag. Use sns.lineplot(..., sort=False, estimator=None), or just call plt.plot().

15.3 Encoding a third variable: hue, size, style

Two axes hold two variables. Seaborn gives you three more channels on the same panel: colour (hue), marker area (size) and marker shape or dash pattern (style). Each takes a column name.

python12 lines
sns.scatterplot(
    data=penguins,
    x="bill_length_mm", y="bill_depth_mm",
    hue="species",       # colour: Adelie / Chinstrap / Gentoo
    style="sex",         # marker shape: Male / Female
    size="body_mass_g",  # marker area: 2700 g to 6300 g
    sizes=(20, 200),     # smallest and largest marker area
    alpha=0.8,
)
plt.legend(bbox_to_anchor=(1.02, 1), loc="upper left", borderaxespad=0)
plt.tight_layout()
plt.show()

Four variables in one chart — and already close to the readable limit.

This is the plot that rescues the shapeless blob from section 15.1. Without hue, bill length and bill depth look almost uncorrelated. With hue="species" three clean clusters appear, each with a positive slope: Adelie (short, deep bills) upper left, Chinstrap (long, deep) upper right, Gentoo (long, shallow) lower right. The pooled relationship is negative while every within-group relationship is positive — a textbook Simpson's paradox, and an excellent five-minute lecture.

How seaborn decides what a variable is

Column dtype Treated as Colour behaviour Legend
object / category / bool Categorical Qualitative palette, one distinct colour per level One entry per level
int / float Numeric Continuous colormap (default "ch:" cubehelix ramp) A few representative ticks ("brief")

You can override the inference. To force a numeric column to be treated as categorical, cast it:

python6 lines
# pclass is 1/2/3 stored as int -> seaborn would make a continuous ramp
sns.scatterplot(
    data=titanic, x="age", y="fare",
    hue=titanic["pclass"].astype("category"),   # now 3 discrete colours
    alpha=0.6,
)

Notice you can pass a Series instead of a column name. Seaborn uses the Series' name attribute for the legend title, so the legend still reads "pclass".

Which channel for which variable

  • hue — the strongest, most readable channel. Give it your most important grouping variable. Keep it to 6 levels or fewer; beyond that colours become indistinguishable.
  • size — perceptually weak. Humans judge area badly, and small markers vanish. Reserve it for a rough magnitude (population, mass), never for a precise comparison.
  • style — weakest for scatter (shapes are hard to tell apart at small sizes) but very good for lines, where it becomes solid / dashed / dotted. Cap it at 4 levels.
💡 Redundant encoding

For printed handouts, slides projected badly, or colour-blind students, map the same column to two channels: hue="species", style="species". Now the groups differ in both colour and shape, so the figure survives greyscale printing. Seaborn merges them into a single legend automatically.

Palette control

python12 lines
# Named qualitative palette
sns.scatterplot(data=penguins, x="flipper_length_mm", y="body_mass_g",
                hue="species", palette="Set2")

# Explicit dict: guarantees the same colour for the same group across figures
species_colors = {"Adelie": "#4C72B0", "Chinstrap": "#DD8452", "Gentoo": "#55A868"}
sns.scatterplot(data=penguins, x="flipper_length_mm", y="body_mass_g",
                hue="species", palette=species_colors)

# Continuous hue: sequential colormap plus a clamped range
sns.scatterplot(data=tips, x="total_bill", y="tip",
                hue="size", palette="viridis", hue_norm=(1, 6))
⚠️ Palette / hue mismatch

Passing a list of colours whose length does not match the number of hue levels raises ValueError: The palette list has the wrong number of colors. A dict is safer: it is keyed by level name, so it tolerates missing levels and keeps colours stable when you filter the data.

15.4 relplot and axes-level vs figure-level

sns.relplot() is the figure-level interface to the same two plots. Its kind argument chooses which one:

python2 lines
sns.relplot(data=tips, x="total_bill", y="tip", kind="scatter")   # default
sns.relplot(data=flights, x="year", y="passengers", kind="line")

relplot is figure-level, so col="time" splits the data into panels — something scatterplot alone cannot do.

On its own that is no better than the axes-level call. The reason relplot exists is faceting: splitting the data into small multiples with col= and row=.

python13 lines
g = sns.relplot(
    data=tips,
    x="total_bill", y="tip",
    hue="smoker",
    col="time",       # one column per level: Lunch, Dinner
    row="sex",        # one row per level: Male, Female
    height=3.5,       # height of EACH facet in inches
    aspect=1.2,       # width = height * aspect
)
g.figure.suptitle("Tipping by time of day and sex", y=1.03)
g.set_axis_labels("Total bill ($)", "Tip ($)")
g.set_titles("{row_name} — {col_name}")
plt.show()

You get a 2 by 2 grid, all four panels sharing the same x and y limits so the eye can compare them directly, with one shared legend placed to the right of the whole figure. Dinner panels are visibly denser than lunch panels; the male-dinner panel contains the largest bills.

The two interfaces, side by side

Axes-level (scatterplot, lineplot) Figure-level (relplot)
Returns matplotlib.axes.Axes seaborn.FacetGrid
Owns the figure? No — draws into current or given axes Yes — creates its own figure
Faceting Not available col=, row=, col_wrap=
Size control plt.subplots(figsize=(8, 5)) height= and aspect= per facet
ax= supported Yes No — raises TypeError
Legend placement Inside the axes Outside the grid, shared
Title ax.set_title(...) g.figure.suptitle(...)
Axis labels ax.set_xlabel(...) g.set_axis_labels(x, y)
Best for Combining with other matplotlib content Standalone exploratory small multiples

Useful FacetGrid methods

python10 lines
g = sns.relplot(data=penguins, x="flipper_length_mm", y="body_mass_g",
                hue="species", col="island", col_wrap=2, height=3.5)

g.set_axis_labels("Flipper length (mm)", "Body mass (g)")
g.set_titles("Island: {col_name}")
g.set(xlim=(160, 240), ylim=(2000, 7000))   # applies to every facet
g.figure.suptitle("Penguin size by island", y=1.02)
g.legend.set_title("Species")
g.tight_layout()
g.savefig("penguins.png", dpi=150)

Attribute / methodWhat it does

g.figureThe underlying matplotlib Figure (g.fig is the old alias) g.axesA 2-D numpy array of the Axes objects g.axes_dictDict keyed by facet value — the clean way to reach one panel g.set_titles(template)Placeholders {col_name}, {row_name}, {col_var} g.set(...)Passes any Axes setter to every facet g.map_dataframe(fn, ...)Draw an extra layer on every facet g.refline(y=0)Add a reference line to all facets g.legendThe shared legend object (None if legend=False)

Reaching into a single facet, for example to annotate only the Dream island panel:

ax = g.axes_dict["Dream"] ax.axhline(4000, color="grey", ls="--", lw=1) ax.text(165, 4100, "4 kg reference", fontsize=8, color="grey")

📌 col_wrap

col_wrap=3 wraps a long strip of facets into rows of three. It only works when row= is unset — you cannot wrap a two-dimensional grid. Use it when faceting by something with many levels, such as col="day" or a 12-level month variable.

15.5 lineplot aggregation and the errorbar band

The shaded ribbon around a lineplot is controlled by errorbar=, which replaced the old ci= parameter in seaborn 0.12. It takes either a string, or a (method, level) tuple.

Value Ribbon shows Shrinks as n grows? Use when
("ci", 95) (default) Bootstrap 95% confidence interval of the mean Yes You are making a claim about the mean
("ci", 68) 68% CI — roughly one standard error Yes Common in psychophysics and neuroimaging
"se" = ("se", 1) ±1 standard error of the mean, computed analytically Yes You want speed and a parametric interval
"sd" = ("sd", 1) ±1 standard deviation of the observations No You are describing the spread of the data itself
("pi", 50) Percentile interval — the 25th to 75th percentile of raw values No Skewed data; a non-parametric spread
None No band at all — Large data, or the band is meaningless
python16 lines
fig, axes = plt.subplots(1, 3, figsize=(14, 4), sharey=True)

sns.lineplot(data=flights, x="year", y="passengers",
             errorbar=("ci", 95), ax=axes[0])
axes[0].set_title('errorbar=("ci", 95) — CI of the mean')

sns.lineplot(data=flights, x="year", y="passengers",
             errorbar="sd", ax=axes[1])
axes[1].set_title('errorbar="sd" — spread of the data')

sns.lineplot(data=flights, x="year", y="passengers",
             errorbar=None, ax=axes[2])
axes[2].set_title("errorbar=None — mean only")

fig.tight_layout()
plt.show()

The three lines are identical; only the ribbons differ. The "sd" ribbon is noticeably wider than the ("ci", 95) ribbon because with n = 12 months per year, the standard error is the standard deviation divided by the square root of 12, roughly a third as wide. If you double the amount of data, the CI ribbon shrinks and the SD ribbon does not — that is the whole distinction, and it is a frequent interview question.

DISTINCTIONSD describes the data. SE and CI describe your estimate of the mean. A CI that shrinks with more data does not mean the data got less variable.

Custom estimators and per-subject lines

python12 lines
# Median instead of mean, with a non-parametric interval
sns.lineplot(data=flights, x="year", y="passengers",
             estimator="median", errorbar=("pi", 50))

# A callable estimator works too
import numpy as np
sns.lineplot(data=flights, x="year", y="passengers",
             estimator=lambda s: np.percentile(s, 90), errorbar=None)

# units=: one faint line per month, no aggregation
sns.lineplot(data=flights, x="year", y="passengers",
             units="month", estimator=None, alpha=0.4)

The units pattern is the standard way to show repeated-measures data: one thin translucent line per subject, so the reader can see individual trajectories rather than only the group average. Overlaying both is the best of the two:

python7 lines
ax = sns.lineplot(data=flights, x="year", y="passengers",
                  units="month", estimator=None, alpha=0.25,
                  color="grey", legend=False)
sns.lineplot(data=flights, x="year", y="passengers",
             errorbar=("ci", 95), color="crimson", lw=2.5, ax=ax)
ax.set_title("Individual months (grey) and the yearly mean (red)")
plt.show()

Cost of the bootstrap

The default CI is computed by resampling the data n_boot=1000 times for every unique x value. On a series with 5000 distinct timestamps this is five million resamples and the plot will hang for many seconds. Three fixes, in order of preference:

  • errorbar=None — you probably did not want a band on a unique-x series anyway.
  • errorbar="se" or errorbar="sd" — computed analytically, no bootstrap.
  • n_boot=200, seed=0 — fewer resamples, and reproducible output.
⚠️ `ci=` is deprecated

sns.lineplot(..., ci=95) still runs in seaborn 0.12 and 0.13 but emits a FutureWarning and will be removed. ci="sd" was the old spelling of errorbar="sd", and ci=None of errorbar=None. Update any teaching material written before 2022 — students copying old Stack Overflow answers will hit this.

15.6 Gotchas

1. Overplotting on large data

With 244 rows a scatterplot is fine. With 50,000 rows it becomes a solid black blob and the shape of the distribution is invisible — 80% of your markers may be hidden under the top layer.

python15 lines
big = tips.sample(50_000, replace=True, random_state=0)  # simulate a large dataset

# Fix 1: transparency + small markers. Density shows as darkness.
sns.scatterplot(data=big, x="total_bill", y="tip", alpha=0.02, s=8,
                edgecolor=None)

# Fix 2: plot a random subset — usually enough to see the shape
sns.scatterplot(data=big.sample(2000, random_state=0),
                x="total_bill", y="tip", alpha=0.4)

# Fix 3: bin instead of plotting points (2-D histogram)
sns.histplot(data=big, x="total_bill", y="tip", bins=40, cbar=True)

# Fix 4: contours of a 2-D density estimate
sns.kdeplot(data=big, x="total_bill", y="tip", fill=True, levels=8)

A separate flavour of overplotting happens with discrete numeric x, such as tips["size"] (values 1 to 6). Every point lands on one of six vertical lines and you cannot tell whether a line holds 5 points or 500. The fix is jitter, which scatterplot does not provide — use sns.stripplot(..., jitter=0.25) or add noise yourself:

sns.stripplot(data=tips, x="size", y="tip", jitter=0.25, alpha=0.5)

  1. lineplot silently aggregating, and the band mistaken for data

Already covered in 15.2, and it is worth repeating because it is the single most common misreading. Students call sns.lineplot(data=tips, x="size", y="tip"), see a smooth line with a ribbon, and describe it as "the tips". It is six means with six bootstrap intervals. Always ask: is x unique in this DataFrame?

python3 lines
# The diagnostic: are there duplicate x values?
print(tips["size"].duplicated().any())   # True -> lineplot WILL aggregate
print(tips.groupby("size").size())       # how many rows per x
Output
True
size
1      4
2    156
3     38
4     37
5      5
6      4
dtype: int64
  1. hue on a continuous variable

Mapping a high-cardinality numeric column to hue produces a colour ramp that the eye cannot decode, and if seaborn falls back to legend="full" you can get a legend with hundreds of entries that squeezes the plot to nothing.

python16 lines
# Bad: fare has ~250 distinct values
sns.scatterplot(data=titanic, x="age", y="fare", hue="fare")

# Better 1: bin it into ordered categories
t = titanic.copy()
t["fare_band"] = pd.qcut(t["fare"], 4, labels=["Q1", "Q2", "Q3", "Q4"])
sns.scatterplot(data=t, x="age", y="fare", hue="fare_band", palette="viridis")

# Better 2: keep it continuous, drop the legend, add a real colourbar
fig, ax = plt.subplots()
sns.scatterplot(data=titanic, x="age", y="fare", hue="fare",
                palette="viridis", legend=False, ax=ax)
norm = plt.Normalize(titanic["fare"].min(), titanic["fare"].max())
sm = plt.cm.ScalarMappable(cmap="viridis", norm=norm)
fig.colorbar(sm, ax=ax, label="Fare")
plt.show()
⚠️ No automatic colourbar

Unlike plt.scatter(c=...), seaborn's relational functions never create a colourbar for a continuous hue. They create a legend with a handful of sample values instead. If you need a true colourbar you must build the ScalarMappable yourself, as above, and remember to pass ax= to fig.colorbar — omitting it raises an error in recent matplotlib.

4. plt.title() after relplot does the wrong thing

python3 lines
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day")
plt.title("Tips by day")          # WRONG: titles only the LAST facet
plt.xlabel("Bill")                # WRONG: labels only the LAST facet

plt.title() operates on the current axes, and after relplot the current axes is whichever facet was drawn last — here, Sunday. Your title lands on one small panel out of four. The figure-level equivalents are:

python5 lines
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day", col_wrap=2)
g.figure.suptitle("Tips by day", y=1.02)      # title for the whole figure
g.set_axis_labels("Total bill ($)", "Tip ($)") # labels on the outer facets
g.set_titles("{col_name}")                     # per-facet titles
g.tight_layout()

The same logic explains why plt.figure(figsize=(12, 6)) before a relplot does nothing: relplot makes its own figure and ignores yours (leaving an empty figure behind). Size a figure-level plot with height and aspect.

5. Passing numpy arrays instead of a DataFrame

python5 lines
import numpy as np
x = np.random.normal(size=200)
y = 2 * x + np.random.normal(size=200)

sns.scatterplot(x=x, y=y)   # works, but both axis labels are empty

Vectors are accepted, but a numpy array has no name, so seaborn cannot label the axes and you lose the automatic legend titles. Two fixes:

python8 lines
# Fix A: pass named pandas Series
sx = pd.Series(x, name="predictor")
sy = pd.Series(y, name="response")
sns.scatterplot(x=sx, y=sy)          # axes are labelled

# Fix B (preferred): build a DataFrame and use column names
df = pd.DataFrame({"predictor": x, "response": y})
sns.scatterplot(data=df, x="predictor", y="response")
  1. load_dataset needs the internet

sns.load_dataset("tips") downloads a CSV from the seaborn-data GitHub repository. On a locked-down lab machine, an exam network, or a plane, it raises URLError or a socket timeout, and a whole classroom stalls at cell one.

python7 lines
# Cache the CSVs once, on a machine that has internet:
for name in ["tips", "penguins", "flights", "titanic"]:
    sns.load_dataset(name).to_csv(f"data/{name}.csv", index=False)

# Then, offline, read them locally:
tips = pd.read_csv("data/tips.csv")
flights = pd.read_csv("data/flights.csv")

Two details worth knowing. First, seaborn already caches downloads in ~/seaborn-data (override with sns.load_dataset(name, data_home="./seaborn-data")), so a machine that ran the notebook once works offline afterwards. Second, load_dataset applies small fix-ups the raw CSV does not have — for example it makes flights["month"] an ordered categorical so months plot in calendar order. After pd.read_csv you must restore that yourself:

python4 lines
month_order = ["January", "February", "March", "April", "May", "June",
               "July", "August", "September", "October", "November", "December"]
flights["month"] = pd.Categorical(flights["month"], categories=month_order,
                                  ordered=True)
  1. Smaller traps worth naming

Missing values are dropped silently. penguins has 2 rows with no measurements and 11 with no sex. Adding style="sex" quietly removes 11 points. Check with penguins.isna().sum() before you draw. Legend entries mix variables. Using hue, size and style at once produces a tall stacked legend with three sub-headings that easily overflows the axes. Move it out: plt.legend(bbox_to_anchor=(1.02, 1), loc="upper left"). Wide-form data. Passing data= a wide DataFrame with no x/y makes seaborn plot every column against the index. Convenient, but if you meant long-form you will get a nonsense plot. df.melt() first. Overlapping calls share the axes. Two scatterplot calls in the same cell draw on the same axes — sometimes what you want, often a surprise. Call plt.figure() between them, or pass explicit ax=. Datetime x on lineplot. If dates are still strings, they are treated as categories in file order and the x axis becomes meaningless. Always pd.to_datetime() first. relplot rejects ax=. sns.relplot(..., ax=ax) raises TypeError. If you need the plot inside an existing figure, you need the axes-level function.

15.7 Practice set

python55 lines
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

sns.set_theme(style="whitegrid")
tips = sns.load_dataset("tips")
penguins = sns.load_dataset("penguins")
flights = sns.load_dataset("flights")
titanic = sns.load_dataset("titanic")

# 1. BASIC SCATTER
#    Plot tip against total_bill. Set the title to "Tips vs bill" and
#    label both axes with units in dollars, using the returned Axes.
#    Then answer in one sentence: is the relationship positive or negative?

# 2. ADD A THIRD VARIABLE WITH hue
#    Same plot, coloured by "time" (Lunch / Dinner). Which meal produces
#    the largest bills? Move the legend outside the axes with
#    plt.legend(bbox_to_anchor=(1.02, 1), loc="upper left").

# 3. size AND style TOGETHER
#    Add size="size" (party size) with sizes=(20, 200) and style="smoker".
#    Note how many legend sections appear and why the figure gets crowded.

# 4. FIX OVERPLOTTING
#    Build big = tips.sample(30_000, replace=True, random_state=0).
#    Draw it three ways: (a) default, (b) alpha=0.02 with s=8,
#    (c) sns.histplot(..., bins=40, cbar=True). Which one reveals the
#    dense core of the distribution most clearly?

# 5. LINEPLOT AND SILENT AGGREGATION
#    Draw sns.lineplot(data=flights, x="year", y="passengers").
#    Then print flights.groupby("year")["passengers"].mean() and confirm
#    the line equals those means. Redraw with estimator=None and describe
#    the difference in one sentence.

# 6. errorbar VARIANTS
#    Draw the flights line three times in a 1x3 grid with sharey=True:
#    errorbar=("ci", 95), errorbar="sd", errorbar=None.
#    Explain in a comment why the "sd" band is the widest.

# 7. FIGURE-LEVEL FACETING
#    Use relplot to draw flipper_length_mm vs body_mass_g from penguins,
#    hue="sex", col="species", height=3.5, aspect=1.1.
#    Give the whole figure a title with g.figure.suptitle(), rename the
#    axes with g.set_axis_labels(), and set per-facet titles to the
#    species name only. Save it with g.savefig("penguins.png", dpi=150).

# 8. A REAL TIME SERIES, OFFLINE-SAFE
#    Build a proper date column for flights with pd.to_datetime on
#    year + "-" + month (format="%Y-%b"), plot passengers over date,
#    and confirm no confidence band appears. Then write the DataFrame to
#    data/flights.csv, reload it with pd.read_csv, restore the ordered
#    month categorical, and reproduce the plot with no internet access.

15.8 Interview questions

Q: What is the difference between an axes-level and a figure-level function in seaborn?

An axes-level function such as scatterplot or lineplot draws into a single matplotlib Axes, accepts an ax= argument, and returns that Axes, so it composes freely with other matplotlib content. A figure-level function such as relplot creates and owns the entire figure, supports col= and row= faceting, is sized with height and aspect rather than figsize, places a single shared legend outside the grid, and returns a FacetGrid. The practical consequence is that plt.title() works on an axes-level plot but titles only the last facet of a figure-level one, where you need g.figure.suptitle().

Q: You call sns.lineplot(data=flights, x="year", y="passengers") and get a shaded band. What is it, and why is it there?

flights has twelve rows per year, so there are twelve y values for each x. A line can only pass through one y per x, so seaborn aggregates: it plots estimator="mean" and draws errorbar=("ci", 95), a bootstrap 95% confidence interval of that mean using 1000 resamples. The band is a statement about uncertainty in the yearly mean, not a range of observed values. To see the raw data instead, use estimator=None, add hue="month", or make x unique with a proper date column.

Q: Explain the difference between errorbar=("ci", 95), errorbar="sd" and errorbar=None.

("ci", 95) is a bootstrapped 95% confidence interval of the estimator, so it narrows as sample size grows and describes how precisely you know the mean. "sd" is plus or minus one standard deviation of the observations themselves; it describes the spread of the data and does not shrink with more data. None suppresses the band entirely, which also skips the bootstrap and is the right choice for large data or for a series where x is already unique. A related option, ("se", 1), gives one standard error computed analytically — same interpretation as a CI but far cheaper than bootstrapping.

Q: Your scatterplot of 200,000 points is a solid black rectangle. How do you fix it?

Reduce ink per point and let density show through: alpha around 0.01 to 0.05, small s, and edgecolor=None. Plot a random subsample — 2,000 to 5,000 points usually preserves the shape. Or switch representation entirely: sns.histplot(x=..., y=..., bins=50, cbar=True) for a 2-D histogram, or sns.kdeplot(x=..., y=..., fill=True) for density contours. For a discrete x, the problem is different — points stack on a few vertical lines — and the fix is jitter via sns.stripplot(..., jitter=0.25).

Q: When should hue be used, and when does it fail?

hue is the strongest of the three semantic channels; give it your most important grouping variable, and keep it under roughly six categorical levels. It fails on high-cardinality numeric columns: seaborn builds a continuous ramp whose values the eye cannot decode, and no colourbar is added automatically. Fix it by binning with pd.qcut, casting to category, or by setting legend=False and adding a real colourbar via plt.cm.ScalarMappable. It also fails on colour-blind readers unless you redundantly encode with style= as well.

Q: What does sort=True do in lineplot, and when would you turn it off?

By default lineplot sorts rows by the x variable before drawing, so the line moves monotonically left to right. That is correct for time series and dose-response curves. It is wrong whenever the row order carries meaning — a spatial trajectory, a hysteresis loop, the path of an optimiser through a parameter space — because sorting scrambles the path. Use sort=False together with estimator=None, or drop to plt.plot().

Q: Why do the axis labels disappear when you pass numpy arrays, and what is the fix?

Seaborn takes the axis labels from the names of the data it is given. A numpy array has no name, so there is nothing to label with. Either wrap the vectors in named pd.Series objects, or, better, put them in a DataFrame and pass data=df, x="col_a", y="col_b". The DataFrame route also gives you correct dtype inference for hue, automatic legend titles, and proper handling of missing values.

Q: sns.load_dataset("tips") fails on the classroom machines. What is happening and what do you do?

load_dataset downloads a CSV from the seaborn-data repository on GitHub, so it needs network access; behind a firewall or offline it raises a URLError or times out. Seaborn caches downloads under ~/seaborn-data, so pre-running the notebook once on each machine is enough. The robust classroom fix is to export the datasets to CSVs, ship them with the material, and load with pd.read_csv — remembering to restore the touches load_dataset applies, notably the ordered categorical for flights["month"].

15.9 Summary

  • scatterplot draws one marker per row and never aggregates; lineplot connects points in sorted x order and does aggregate repeated x values into a mean plus an uncertainty band.
  • The shaded ribbon on a lineplot defaults to errorbar=("ci", 95), a bootstrap CI of the mean — it is a statement about the estimate, not a range of the raw data.
  • errorbar="sd" describes the data's spread and does not shrink with n; ("ci", level) and "se" describe the estimate and do; errorbar=None removes the band and skips the bootstrap.
  • hue, size and style map extra columns onto colour, marker area and marker shape. hue is the most readable, size the least; redundantly encode hue and style for accessible figures.
  • relplot is the figure-level wrapper: it adds col=/row= faceting, sizes itself with height and aspect, and returns a FacetGrid — so use g.figure.suptitle(), g.set_axis_labels() and g.set_titles(), not plt.title().
  • Axes-level functions return a matplotlib Axes and accept ax=; that is what makes them composable inside larger figures. relplot does not accept ax=.
  • On large data, plain scatterplots lie by hiding points: fix with alpha, smaller s, subsampling, jitter for discrete x, or a 2-D histogram / KDE.
  • Always pass a DataFrame with named columns — numpy arrays cost you axis labels, legend titles and dtype inference. Check isna().sum() first, because seaborn drops missing rows silently.
  • Use modern parameters: errorbar= replaced ci= in seaborn 0.12, and g.figure replaced g.fig.

Next: Chapter 16 covers distribution plots — histplot, kdeplot and ecdfplot.

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