Skip to content

Categorical plots

Chapter 17 37 code blocks 6900 words

A categorical plot is any chart where one axis holds discrete groups — days of the week, passenger class, penguin species, smoker vs non-smoker — and the other axis holds a number. Chapter 16 dealt with distributions of a single numeric variable. This chapter deals with the far more common analytical question: how does this number differ across groups?

Seaborn has nine functions in this family. They all take the same core arguments (data, x, y, hue, order) and differ only in how much of the group's distribution they show you. The whole chapter is really about one trade-off: how much detail do you show per group, and how many rows can that method survive?

CORE IDEAEvery categorical plot summarises a distribution per group. The functions differ in how aggressively they summarise: barplot shows one number, boxplot shows five, violinplot shows the whole shape, swarmplot shows every single row.

Seaborn splits these into three sub-families:

Family Functions What it shows
Categorical scatter stripplot, swarmplot Every observation as a point
Categorical distribution boxplot, violinplot, boxenplot The shape/spread of each group
Categorical estimate barplot, pointplot, countplot One summary statistic per group, with uncertainty

All nine are axes-level functions except catplot, which is the figure-level wrapper around all of them. That distinction matters and is covered in en-catplot below.

Set-up used by every example in this chapter:

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

sns.set_theme(style="whitegrid")

tips = sns.load_dataset("tips")
titanic = sns.load_dataset("titanic")
penguins = sns.load_dataset("penguins")
📌 The datasets

tips — 244 restaurant bills with total_bill, tip, sex, smoker, day, time, size. titanic — 891 passengers with survived, pclass, sex, age, fare, class, embark_town, alive. penguins — 344 birds with species, island, bill_length_mm, flipper_length_mm, body_mass_g, sex. They are small, clean, and every seaborn answer online uses them, so they are worth memorising.

17.1 countplot — counting rows

sns.countplot() answers exactly one question: how many rows are in each category? It is a bar chart of frequencies. It takes x or y, never both, because the other axis is always the count that seaborn computes for you.

python3 lines
# how many passengers in each class?
sns.countplot(data=titanic, x="class")
plt.show()

countplot counts rows. Nothing else. It is the categorical equivalent of a histogram.

Internally this is equivalent to titanic["class"].value_counts() drawn as bars. Nothing is estimated, nothing is averaged — it is a pure tally.

Horizontal bars for long labels

If category names are long, pass the categorical variable to y instead of x. This is not a stylistic nicety; rotated x-labels are genuinely harder to read.

python2 lines
sns.countplot(data=titanic, y="embark_town")
plt.show()

Splitting counts by a second variable

hue subdivides each category. By default the sub-bars are placed side by side (dodged).

python7 lines
# survival counts within each passenger class
sns.countplot(data=titanic, x="class", hue="survived")
plt.show()

# stacked instead of side by side (seaborn 0.13+)
sns.histplot(data=titanic, x="class", hue="survived", multiple="stack", shrink=.8)
plt.show()
💡 Counts vs proportions

A dodged countplot compares absolute numbers, which is misleading when group sizes differ wildly. Third class had far more passengers than first, so its survivor bar can look large even though its survival rate was terrible. To compare rates, either use stat="percent" with multiple="fill", or — better — use a barplot of the 0/1 survived column, whose mean is the survival rate. See the next section.

Annotating the bars

Students almost always want the number printed on top. Matplotlib 3.4+ makes this one line, and seaborn exposes the bar containers through the returned Axes.

python4 lines
ax = sns.countplot(data=titanic, x="class")
for container in ax.containers:
    ax.bar_label(container)
plt.show()

17.2 barplot — and what the bar height actually means

This is the single most misunderstood function in seaborn, so read this section twice.

CRITICALA seaborn barplot bar is NOT a count. Its height is the mean of y within that category, and the black line on top is a 95% confidence interval for that mean. countplot is the function that counts rows.
python3 lines
# bar height = MEAN total_bill per day, not the number of bills
sns.barplot(data=tips, x="day", y="total_bill")
plt.show()

The most common Seaborn misunderstanding. Left counts rows; right shows the mean of total_bill with a confidence interval. Same categories, completely different quantity.

In Excel and in most business dashboards a bar chart shows a sum or a count. Seaborn breaks that habit deliberately: it treats the bar as a statistical estimate. If you hand seaborn raw, unaggregated rows and expect totals, every number on your chart will be wrong — and it will look completely plausible, which is why this mistake survives into presentations.

The three moving parts

Argument Default Meaning
estimator "mean" The function collapsing each group to one number. Accepts a string ("mean", "median", "sum", "count", "max") or any callable such as np.median or len.
errorbar ("ci", 95) What the vertical line represents. Options: ("ci", 95), "sd", ("se", 1), ("pi", 95), None, or a callable.
n_boot 1000 Bootstrap resamples used to build the CI. Lower it to speed up big datasets.
python8 lines
# median instead of mean, standard deviation instead of a CI
sns.barplot(data=tips, x="day", y="total_bill",
            estimator="median", errorbar="sd")
plt.show()

# turn error bars off entirely
sns.barplot(data=tips, x="day", y="total_bill", errorbar=None)
plt.show()
⚠️ `ci=` is dead

Before v0.12 you wrote ci=95, ci="sd" or ci=None. Since v0.12 that parameter is deprecated and in current versions it is removed. Use errorbar=. Note the value shapes differ: old ci=95 becomes errorbar=("ci", 95), old ci="sd" becomes errorbar="sd", old ci=None becomes errorbar=None. Tutorials older than 2022 will silently teach you the dead API.

Where the confidence interval comes from

Seaborn does not use a textbook t-formula. It bootstraps: it resamples the rows of each group with replacement n_boot times, computes the estimator on each resample, and takes the 2.5th and 97.5th percentiles of those 1000 estimates. That is why the interval is not perfectly symmetric, why it changes slightly between runs, and why you can pin it with seed=0 for reproducible teaching material.

python2 lines
sns.barplot(data=tips, x="day", y="total_bill", seed=0)
plt.show()
📌 CI is not SD

A 95% CI describes uncertainty about the mean and shrinks as n grows. A standard deviation describes the spread of the data and does not shrink. With 244 tips rows the CI bars are short and people wrongly conclude "the data is tightly clustered". It is not — only the estimate of the average is precise. If your reader cares about spread, use errorbar="sd" or abandon bars for a boxplot.

The 0/1 trick: means that are really proportions

When y is a 0/1 indicator, its mean is a proportion. This is the cleanest way to plot rates.

python4 lines
# mean of survived (0/1) = survival RATE per class
sns.barplot(data=titanic, x="class", y="survived", errorbar=("ci", 95))
plt.ylabel("survival rate")
plt.show()

The bars come out near 0.63, 0.47 and 0.24 — an honest rate comparison that a dodged countplot cannot give you.

Colour, hue, and the palette rule

python8 lines
# correct: colour encodes a real variable
sns.barplot(data=tips, x="day", y="total_bill", hue="sex")
plt.show()

# decorative colour: hue must repeat x, and legend switched off
sns.barplot(data=tips, x="day", y="total_bill",
            hue="day", palette="viridis", legend=False)
plt.show()
⚠️ Bare `palette=` without `hue=`

In seaborn 0.13+ writing sns.barplot(data=tips, x="day", y="total_bill", palette="viridis") raises a FutureWarning: assigning colours without a hue variable is deprecated. The fix is the pattern above — set hue to the same column as x and pass legend=False. This applies to boxplot, violinplot, countplot and the rest of the family too.

Already-aggregated data

If your DataFrame has one row per category, the mean of a single value is that value, so barplot still works — and there is nothing to bootstrap, so no error bar appears. That absence is a useful signal that you are plotting summaries, not raw rows.

python3 lines
summary = tips.groupby("day", observed=True)["total_bill"].sum().reset_index()
sns.barplot(data=summary, x="day", y="total_bill")   # true totals, no CI
plt.show()

17.3 boxplot and the five-number summary

A boxplot compresses a distribution into five numbers plus outliers. Invented by John Tukey in the 1970s, it is still the fastest way to compare spread and skew across many groups on one axis.

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

Every part of a boxplot, labelled. The box is the middle 50% of the data; the whiskers reach 1.5×IQR; anything past them is drawn as an individual point.

Anatomy

o o <- outliers: points beyond the whiskers | | <- upper whisker: largest observed value | that is <= Q3 + 1.5IQR | | | | <- Q3 (75th percentile), top of the box |=====| <- MEDIAN (Q2, 50th percentile) | | |_____| <- Q1 (25th percentile), bottom of the box | | <- lower whisker: smallest observed value | that is >= Q1 - 1.5IQR o <- outlier

box height = IQR = Q3 - Q1 (the middle 50% of the data) whisker reach = at most 1.5 * IQR beyond each hinge whiskers stop at = the last REAL data point inside that reach, not at the theoretical fence itself

PartStatisticReading

Line inside boxMedian (Q2)Typical value; robust to outliers, unlike the mean Box bottomQ1, 25th percentile25% of values fall below this Box topQ3, 75th percentile75% of values fall below this Box heightIQR = Q3 − Q1Spread of the middle half of the data WhiskersFurthest point within 1.5×IQR of the hingePractical range of "ordinary" values Individual pointsBeyond the whiskersCandidate outliers — candidates, not verdicts Median off-centre—Skew. Median near the bottom = right-skewed

📌 Why 1.5?

Tukey chose 1.5 by convention, not derivation. For perfectly normal data the fences sit at about ±2.7 standard deviations, so roughly 0.7% of points are flagged as outliers by chance. For skewed data such as income or fare, far more get flagged — that is the distribution being skewed, not the data being dirty. Change the multiplier with whis=: whis=3.0 is stricter, whis=(0, 100) makes the whiskers span the full min–max range and shows no outliers at all.

python7 lines
# full-range whiskers: no points are labelled outliers
sns.boxplot(data=titanic, x="class", y="fare", whis=(0, 100))
plt.show()

# show the mean as a marker alongside the median line
sns.boxplot(data=tips, x="day", y="total_bill", showmeans=True)
plt.show()

Grouping and orientation

python6 lines
sns.boxplot(data=tips, x="day", y="total_bill", hue="smoker")
plt.show()

# horizontal: numeric on x, categories on y
sns.boxplot(data=penguins, x="body_mass_g", y="species")
plt.show()

Boxplot + points: the standard professional combination

A boxplot hides n and hides multimodality. Overlaying the raw points fixes both, and is the layered pattern interviewers like to see.

python5 lines
ax = sns.boxplot(data=tips, x="day", y="total_bill",
                 showfliers=False, boxprops={"alpha": 0.5})
sns.stripplot(data=tips, x="day", y="total_bill",
              color="black", size=3, jitter=0.2, ax=ax)
plt.show()

showfliers=False matters here: without it, outliers are drawn twice — once by the box and once by the strip — and readers count them as separate observations.

17.4 violinplot and boxenplot

Violin plots

A violin is a KDE (kernel density estimate) mirrored around a vertical axis, one per category. Where the violin is wide, many observations lie at that value. It shows everything a boxplot shows about location, plus the shape a boxplot throws away.

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

Why a boxplot can lie. These two groups have nearly the same median and quartiles, so the boxplots match — but one is two separate clusters and the other is a single peak. Only the violin shows it.

By default seaborn draws a miniature boxplot inside each violin. inner= controls that:

inner= Draws Use when
"box" (default) Mini boxplot: median dot, IQR bar General purpose
"quart" Dashed lines at Q1, median, Q3 You want quartiles readable but unobtrusive
"point" / "stick" A tick for every observation Small n; shows sample size honestly
None Shape only Many groups; maximum cleanliness

split=True — the two-sided violin

With exactly two hue levels, split=True puts one group on each half of the same violin. This halves the horizontal space needed and puts the two distributions on a shared baseline, which makes differences much easier to judge.

python3 lines
sns.violinplot(data=tips, x="day", y="total_bill",
               hue="smoker", split=True, inner="quart")
plt.show()

The cut parameter

⚠️ Violins invent data that does not exist

The KDE is smooth, so it extends past the smallest and largest observed values. cut controls how far, measured in bandwidth units, and defaults to 2. On titanic["age"] this happily draws density below age 0. On a count, a duration or a percentage this is nonsense. Set cut=0 to clip the density at the observed data range.

python2 lines
sns.violinplot(data=titanic, x="class", y="age", cut=0)
plt.show()

Other knobs worth knowing: bw_adjust (>1 smoother, <1 spikier — same meaning as in kdeplot), density_norm="width"|"area"|"count" (in v0.12 and earlier this was scale=), and common_norm.

python4 lines
# make violin WIDTH proportional to group size — restores the n
# information that violins normally hide
sns.violinplot(data=tips, x="day", y="total_bill", density_norm="count")
plt.show()

boxenplot — the letter-value plot

sns.boxenplot() (Hofmann, Wickham & Kafadar's "letter-value plot") is a boxplot designed for large n. Instead of stopping at quartiles it keeps splitting the tails: it draws boxes for the median, then the quartiles, then the eighths, sixteenths, and so on, each box narrower than the last. The result is a staircase that describes the tails in detail rather than dumping them into a cloud of "outliers".

python2 lines
sns.boxenplot(data=titanic, x="class", y="fare")
plt.show()
🧩 A mental model for the three

Think of describing a city's buildings. A boxplot tells you the height of the middle building and the middle half of the skyline. A violin shows you the silhouette of the whole skyline. A boxen plot is a boxplot that keeps zooming into the tallest few buildings instead of calling them all "unusual" — which only pays off when there are thousands of buildings to describe.

⚠️ A boxplot can hide a second peak entirely

Take a group whose values cluster at 20 and at 80 with nothing in between. Its median is 50, its quartiles look ordinary, and the boxplot is unremarkable — while the truth is that no observation is anywhere near the median. This is the classic "several very different distributions, one identical boxplot" demonstration: uniform, bimodal, normal and skewed samples can be constructed to share the same five-number summary. A violin, a strip overlay or a histogram exposes the difference instantly. Never present a boxplot of an unfamiliar variable without having looked at its distribution some other way first.

17.5 stripplot and swarmplot

These two draw every single observation. No summary, no estimation. They are the most honest categorical plots and the least scalable.

stripplot

A scatterplot with a categorical axis. Because every point in a group shares one x-coordinate, points would stack exactly on top of each other, so seaborn adds random horizontal jitter (default True, about 0.1 of the category width).

python6 lines
sns.stripplot(data=tips, x="day", y="total_bill", jitter=0.25, alpha=0.6)
plt.show()

# jitter=False: exact alignment, heavy overplotting
sns.stripplot(data=tips, x="day", y="total_bill", jitter=False)
plt.show()

Both show every individual observation. swarmplot arranges points so none overlap — lovely on small data, but it warns and drops points once the data gets large.

Jitter is decoration on the horizontal axis only — the vertical position is always the true value. Never let a student read meaning into a point's left/right offset.

swarmplot

sns.swarmplot() ("beeswarm") solves the same overlap problem deterministically: it runs a layout algorithm that nudges points sideways just enough that none overlap. The resulting outline is itself a density estimate, and no randomness is involved, so the plot is reproducible.

python2 lines
sns.swarmplot(data=penguins, x="species", y="body_mass_g", hue="sex")
plt.show()
⚠️ "X% of the points cannot be placed"

The swarm algorithm needs horizontal room. Past roughly 500–1000 points per category — or whenever the figure is narrow — it runs out and emits UserWarning: X% of the points cannot be placed; you may want to decrease the size of the markers or use stripplot. Those points are silently moved or hidden, so the chart no longer represents the data. Fixes, in order of preference: use stripplot with alpha, switch to violinplot, plot a random sample, widen the figure, or shrink size=. Never ship a chart that produced this warning.

Overlay patterns

python6 lines
# violin + swarm: shape and every observation together
ax = sns.violinplot(data=penguins, x="species", y="body_mass_g",
                    inner=None, cut=0, color="lightgray")
sns.swarmplot(data=penguins, x="species", y="body_mass_g",
              size=3, ax=ax)
plt.show()

Decision table: which categorical plot, and at what n?

Question you are answeringPlotRows per group it toleratesNotes

How many rows per group?countplotAnyThe only counter in the family What is the average per group? Is the difference reliable?barplotAny (needs >1 for a CI)Bar = mean, not count. Baseline must be 0 Every value, nothing hiddenswarmplot< ~500Deterministic; warns and drops points above that Every value, large-ish datastripplot + alpha~50 – 5,000Jitter is random; overplotting still possible Compare spread and skew across many groupsboxplot~20 – 100,000+Needs ~20+ per group for quartiles to mean anything Is the distribution bimodal / oddly shaped?violinplot~50 – 100,000+Set cut=0; misleading with tiny groups Describe the tails of a big datasetboxenplot> ~10,000Wasted on small samples Compare trends of means across an ordered factorpointplotAnyOnly when the x-axis has a real order Any of the above, split into panelscatplot(kind=...)AnyFigure-level; returns a FacetGrid

💡 The sample-size rule of thumb

Under ~10 observations per group, do not summarise at all — plot the points. Quartiles computed from 6 numbers are noise dressed as statistics, and a violin fitted to 6 numbers is a smooth curve invented out of nothing.

17.6 pointplot

sns.pointplot() computes the same estimate as barplot — mean by default, with a bootstrapped 95% CI — but draws a dot with an error bar instead of a bar, and connects the dots of each hue level with a line.

python3 lines
sns.pointplot(data=tips, x="day", y="total_bill", hue="sex",
              errorbar=("ci", 95), dodge=True)
plt.show()

Two things make it worth having as a separate function:

  • No bar area to anchor. A bar chart must start at zero, otherwise the visual length lies. A pointplot encodes position only, so you may zoom the y-axis onto the region where the differences live. This is how small but real effects are shown honestly.
  • The connecting line makes interaction visible. Parallel lines mean the effect of hue is the same at every level of x. Crossing or converging lines mean an interaction — the classic reason psychology and biology papers use pointplots for factorial designs.
python4 lines
# classic interaction plot: does the day effect differ by time of day?
sns.pointplot(data=tips, x="day", y="tip", hue="time",
              dodge=0.3, markers=["o", "s"], linestyles=["-", "--"])
plt.show()

dodge=True (or a float offset) is important: without it, hue levels sit on the same x position and their error bars overlap into an unreadable mess.

⚠️ The line implies a trend that may not exist

A line between "Thur" and "Fri" suggests continuity — that there is something meaningful between them. For unordered categories such as island, embark_town or species that is false and readers still see a trend. Use pointplot only when x has a genuine order (day, class, dose, month, age band), or pass linestyle="none" to get bare dots with intervals.

python3 lines
# dots and CIs, no misleading connecting line
sns.pointplot(data=penguins, x="species", y="body_mass_g", linestyle="none")
plt.show()
📌 Naming churn

In seaborn 0.13 pointplot's join=False was replaced by linestyle="none", and scale= was replaced by markersize= / linewidth=. Older answers on Stack Overflow use the removed names.

17.7 catplot — the figure-level interface

Every function so far is axes-level: it draws onto one matplotlib Axes and returns that Axes. sns.catplot() is figure-level: it creates and owns an entire figure, can split the data into a grid of panels, and returns a FacetGrid object.

python3 lines
# kind= chooses which of the nine drawing functions is used
sns.catplot(data=tips, x="day", y="total_bill", kind="box")
plt.show()

catplot(kind=...) is the figure-level door to every categorical plot — change kind to "violin", "bar" or "swarm" and everything else stays the same.

kind=Calls

"strip" (default)stripplot "swarm"``swarmplot "box"``boxplot "violin"``violinplot "boxen"``boxenplot "point"``pointplot "bar"``barplot "count"``countplot (pass only x or only y)

Any keyword the underlying function accepts can be passed straight through catplot.

The reason it exists: faceting

python9 lines
# one panel per lunch/dinner (columns), one per sex (rows)
g = sns.catplot(
    data=tips, x="day", y="total_bill",
    kind="box", col="time", row="sex",
    height=3.5, aspect=1.2, sharey=True,
)
g.figure.suptitle("Bill by day, split by meal and customer sex", y=1.02)
g.set_axis_labels("Day of week", "Total bill (USD)")
plt.show()
⚠️ `plt.title()` does the wrong thing on a `catplot`

plt.title() targets the current axes, which for a faceted grid is whichever panel was drawn last — so your title lands on the bottom-right panel. Use g.figure.suptitle("...") for a figure title, or g.set_titles("{col_name}") to control per-panel titles. The same applies to plt.xlabel(): use g.set_axis_labels(x, y). And note that catplot ignores ax= — if you need a plot inside an existing subplot, call the axes-level function instead.

Concern Axes-level (boxplot) Figure-level (catplot)
Returns matplotlib.axes.Axes seaborn.FacetGrid
Accepts ax= Yes No
Faceting via col/row No Yes
Figure size plt.figure(figsize=...) height= and aspect= per panel
Legend Inside the axes Outside the grid, managed by the grid
Combine with other plots Easy — pass the same ax Awkward; use g.map_dataframe()
💡 Choosing between them

One panel that must sit next to other panels you control: axes-level. A standalone figure that needs to be repeated across a grouping variable: figure-level. Deciding early saves a rewrite, because the two are not interchangeable at the last minute.

17.8 Controlling category order

By default seaborn orders categories by first appearance for object dtype columns — and effectively alphabetically once you sort or if the column is a pandas Categorical, it uses the category order. Either way, the default is rarely the order your reader needs. "Fri, Sat, Sun, Thur" is alphabetical nonsense for days of the week.

1. Explicit order= and hue_order=

python6 lines
sns.boxplot(
    data=tips, x="day", y="total_bill",
    order=["Thur", "Fri", "Sat", "Sun"],
    hue="smoker", hue_order=["No", "Yes"],
)
plt.show()

order= also filters: any category you leave out is not drawn. That is a convenient way to plot only the levels you care about.

python3 lines
# weekend only
sns.boxplot(data=tips, x="day", y="total_bill", order=["Sat", "Sun"])
plt.show()
  1. Sort by a computed value

Ranked bars are far easier to read than unranked ones. Compute the order from the data and feed it in.

python7 lines
order = (tips.groupby("day", observed=True)["total_bill"]
             .mean()
             .sort_values(ascending=False)
             .index)

sns.barplot(data=tips, x="day", y="total_bill", order=order, errorbar=None)
plt.show()

The same idea, sorted by frequency for a countplot:

python3 lines
order = titanic["embark_town"].value_counts().index
sns.countplot(data=titanic, y="embark_town", order=order)
plt.show()
📌 `observed=True`

When the grouping column is a pandas Categorical, groupby historically returned a row for every declared category, including empty ones — producing NaN means that then break sorting. Pass observed=True to keep only categories that actually occur. In pandas 2.x this is becoming the default, but being explicit costs nothing.

3. Bake the order into the DataFrame with pd.Categorical

The most durable fix. Declare the order once on the column and every plot, groupby and pivot afterwards respects it — no order= repeated in ten places.

python6 lines
day_order = ["Thur", "Fri", "Sat", "Sun"]
tips["day"] = pd.Categorical(tips["day"], categories=day_order, ordered=True)

sns.boxplot(data=tips, x="day", y="total_bill")      # already correct
sns.countplot(data=tips, x="day")                    # also correct
plt.show()

With ordered=True you additionally get working comparisons (tips["day"] > "Fri") and correct sorting. For ordinal survey scales this is the right representation regardless of plotting.

python3 lines
size_order = ["small", "medium", "large"]
df["size"] = pd.Categorical(df["size"], categories=size_order, ordered=True)
df.sort_values("size")   # small, medium, large — not alphabetical
  1. Reordering an existing categorical

tips["day"] = tips["day"].cat.reorder_categories( ["Sun", "Sat", "Fri", "Thur"], ordered=True )

  1. Order in a catplot

order, hue_order, col_order and row_order all work at the figure level.

python7 lines
g = sns.catplot(
    data=tips, x="day", y="total_bill", kind="bar",
    order=["Thur", "Fri", "Sat", "Sun"],
    col="time", col_order=["Lunch", "Dinner"],
    errorbar="se", height=4,
)
plt.show()

17.9 Gotchas

  1. barplot heights read as counts Already stated, worth repeating because it is the number-one error: the bar is a mean. A chart titled "Sales by region" built with sns.barplot(data=df, x="region", y="sales") shows the average sale per transaction, not total sales — and the two rankings can be opposite. If you want totals, use estimator="sum", and if you want counts use countplot. Always label the y-axis with the statistic: plt.ylabel("mean total bill").

2. Error bars are 95% CIs, not standard deviations

Default errorbar=("ci", 95). Short bars mean the mean is well estimated, not that the data is tight. State which one you used in the caption; a chart with unlabelled error bars is unreadable in a scientific context.

3. Boxplots hide multimodality

Four different-shaped samples can share an identical five-number summary. Cross-check with a violin, a strip overlay or a histogram before you trust a boxplot of an unfamiliar variable.

4. swarmplot drops points on large data

Watch for UserWarning: ...% of the points cannot be placed. Switch to stripplot(jitter=True, alpha=0.4) or violinplot.

5. cut makes violins extend past the data

Default cut=2 draws density up to two bandwidths beyond the extremes — negative ages, ratings above 5, durations below zero. Use cut=0 for bounded variables.

6. Alphabetical order for ordered categories

"Fri, Sat, Sun, Thur", "eight, five, four, nine, one" and "High, Low, Medium" are all defaults that destroy meaning. Fix with order= or pd.Categorical.

Use linestyle="none", or use bars, when x has no natural sequence.

8. plt.title() on a catplot lands on one panel

Use g.figure.suptitle(). And catplot does not accept ax= at all.

9. NaNs are dropped silently

penguins has missing sex and body_mass_g; titanic["age"] is missing for 177 passengers. Seaborn drops those rows without a word, so the boxplot for a group can be computed from far fewer rows than you believe. Check df.isna().sum() before plotting, and consider annotating each group with its real n.

10. Numeric-looking categories

tips["size"] and titanic["pclass"] are integers. Seaborn's categorical functions coerce the x variable to categories, so they are treated as labels 1, 2, 3 with equal spacing. That is usually what you want here — but do not expect the spacing to reflect numeric distance. Convert with .astype(str) to be unambiguous, or use a numeric plot if the spacing matters.

11. Empty hue combinations misalign

If a hue level does not occur in some category, dodged elements can be positioned oddly. dodge=True plus explicit hue_order= makes the layout deterministic.

12. sns.load_dataset() needs the internet

It downloads CSVs from the mwaskom/seaborn-data GitHub repository and caches them in ~/seaborn-data. Offline (exam hall, locked-down lab, no proxy) it raises a URL error. Workarounds:

python12 lines
# 1) Pre-download once on a connected machine; the cache lives in
#    ~/seaborn-data (C:\Users\<name>\seaborn-data on Windows).
#    Copy that folder to the offline machine and load_dataset works.

# 2) Point at a local clone of the data repo
tips = sns.load_dataset("tips", data_home="D:/data/seaborn-data")

# 3) Skip seaborn entirely and read the CSV with pandas
tips = pd.read_csv("D:/data/seaborn-data/tips.csv")

# 4) List what is available online (also needs internet)
print(sns.get_dataset_names())
Output
['anagrams', 'anscombe', 'attention', 'brain_networks', 'car_crashes', 'diamonds', 'dots', 'dowjones', 'exercise', 'flights', 'fmri', 'geyser', 'glue', 'healthexp', 'iris', 'mpg', 'penguins', 'planets', 'seaice', 'taxis', 'tips', 'titanic']

17.10 Practice set

python40 lines
# Load first:
# tips = sns.load_dataset("tips")
# titanic = sns.load_dataset("titanic")
# penguins = sns.load_dataset("penguins")

# 1. Draw a countplot of titanic "class" split by "sex", then annotate
#    every bar with its count using ax.bar_label(). Explain in one
#    sentence why these counts alone cannot answer "who survived more".

# 2. Draw two charts side by side for tips: a countplot of "day" and a
#    barplot of "day" vs "total_bill". Label each y-axis with exactly
#    what it shows. Which day has the most bills? Which has the
#    largest average bill? Are they the same day?

# 3. Plot survival RATE by passenger class using barplot on the 0/1
#    "survived" column. Then redraw it three times with
#    errorbar=("ci", 95), errorbar="sd" and errorbar=None, and write
#    down what each version claims.

# 4. Boxplot penguins body_mass_g by species. For Gentoo, read off the
#    median, Q1, Q3 and IQR from the chart, then verify them with
#    penguins.groupby("species")["body_mass_g"].describe().

# 5. Draw a boxplot and a violinplot of titanic "age" by "class" side
#    by side. Use cut=0 on the violin. Name one feature of the age
#    distribution that the violin shows and the boxplot hides.

# 6. Overlay a swarmplot on a violinplot (inner=None) of penguins
#    flipper_length_mm by species. Then try the same swarm on the full
#    titanic "fare" by "class" and record the warning you get and how
#    you would fix it.

# 7. Make a pointplot of tips "tip" by "day" with hue="time" and
#    dodge=True. Are the lines parallel? State what parallel vs
#    crossing lines would mean here.

# 8. Build one catplot: kind="bar", x="class", y="fare", hue="sex",
#    col="embark_town", with col_order set explicitly and the days-of-
#    week style ordering applied to class. Give the figure a title with
#    g.figure.suptitle() and set axis labels with g.set_axis_labels().

17.11 Interview questions

Q: What does the height of a bar in sns.barplot() represent?

The mean of the y variable within that category — not a count and not a sum. The default estimator is "mean", and the vertical line on top is a bootstrapped 95% confidence interval for that mean. countplot is the function that counts rows; barplot(estimator="sum") gives totals.

Q: What is the difference between countplot and barplot?

countplot takes one categorical variable and computes frequencies itself, so you pass only x or only y. barplot takes a categorical and a numeric variable and aggregates the numeric one. sns.countplot(x="day") is exactly sns.barplot(x="day", y=<anything>, estimator="count") in spirit.

Q: Explain every element of a boxplot.

The line inside the box is the median (Q2). The box spans Q1 to Q3, so its height is the interquartile range, the middle 50% of the data. The whiskers extend to the furthest observed point still within 1.5×IQR of the nearer hinge — they stop at real data, not at the fence itself. Anything beyond is drawn individually as a candidate outlier. A median sitting low in the box indicates right skew. The plot shows nothing about sample size or multimodality.

Q: When would you choose a violin plot over a boxplot?

When the shape of the distribution matters — specifically to detect bimodality or strong asymmetry, which a five-number summary cannot express. Several radically different distributions can share an identical boxplot. The costs are that a violin's KDE is a smoothed estimate governed by a bandwidth, that cut>0 draws density beyond the observed range, and that it is unreliable with small groups.

Q: stripplot or swarmplot — how do you decide?

Both draw every observation. stripplot displaces points with random jitter: cheap, works at any size, but points can still overlap. swarmplot computes a non-overlapping layout: more readable and deterministic, but it needs horizontal space and warns that a percentage of points "cannot be placed" once groups exceed roughly a few hundred rows — at which point it is no longer showing the data. Swarm for small samples, strip with alpha for larger ones, a violin or box beyond that.

Q: What is the difference between an axes-level function and a figure-level one, and which is catplot? catplot is figure-level. Axes-level functions like boxplot draw into a single Axes, accept ax=, and return that Axes, so they compose freely inside a plt.subplots() layout. catplot creates its own figure, supports col=/row= faceting, sizes panels with height/aspect, places the legend outside, and returns a FacetGrid. Consequently plt.title() only titles the last panel — use g.figure.suptitle() — and ax= is not accepted at all.

Q: The days on your x-axis come out as Fri, Sat, Sun, Thur. Why, and how do you fix it?

The column is plain strings, so seaborn has no notion of a calendar order. Three fixes: pass order=["Thur", "Fri", "Sat", "Sun"] to the plotting call; convert the column once with pd.Categorical(tips["day"], categories=day_order, ordered=True) so every downstream operation inherits the order; or compute the order from the data, e.g. df.groupby("day")["total_bill"].mean().sort_values().index, when a value-ranked chart is what you want.

Q: By default, what do the error bars in barplot and pointplot show, and how do you change them?

A 95% confidence interval for the estimator, produced by bootstrapping 1000 resamples of each group — not a standard deviation. Change it with errorbar="sd", errorbar=("se", 1), errorbar=("pi", 95) for a percentile interval of the raw data, or errorbar=None. The old ci= parameter was deprecated in v0.12 and removed later. Use seed= for reproducible intervals.

Q: Why must a bar chart start at zero while a pointplot need not?

Bars encode value by area/length, so truncating the axis distorts the ratio a reader perceives between bars. A pointplot encodes value by position only; zooming the axis changes the resolution but not the implied ratios. That is why pointplot is the honest choice when differences are small relative to the absolute level.

17.12 Summary

  • countplot counts rows; barplot shows the mean of y per category with a bootstrapped 95% CI. Confusing the two produces charts that are silently wrong.
  • estimator= defaults to "mean" and accepts "median", "sum", "count" or any callable; errorbar= replaced the removed ci= and defaults to ("ci", 95), not "sd".
  • A boxplot is median, Q1, Q3, whiskers at the last point within 1.5×IQR, and individual points beyond — five numbers that say nothing about sample size or multimodality.
  • Violins add the distribution's shape via a KDE; set cut=0 so the density stops at the observed range, and use split=True for two hue levels. boxenplot is the large-n boxplot that details the tails.
  • swarmplot is deterministic and readable but warns and drops points above a few hundred per group; stripplot with jitter and alpha scales further.
  • pointplot shows the same estimate as barplot without a zero baseline, and its connecting lines reveal interactions — but only use them when the x-axis has a real order.
  • catplot(kind=...) is the figure-level wrapper over all eight; it facets with col/row, returns a FacetGrid, ignores ax=, and needs g.figure.suptitle() instead of plt.title().
  • Control order with order=/hue_order=, with a sorted groupby index, or permanently with pd.Categorical(..., ordered=True); order= also filters categories out.
  • Match the plot to the sample size: points for tiny groups, box or violin for medium, boxen for large — and never present a summary plot of an unfamiliar variable without checking its distribution some other way.

Next: Chapter 18 covers heatmaps and correlation matrices.

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