Skip to content

Distribution plots

Chapter 16 23 code blocks 7119 words

A distribution plot answers one question: how are the values of a single variable spread out? Not the average, not the correlation with something else — the shape. Where do values pile up, where are they rare, is there one peak or two, is the tail long on the right, are there impossible values sitting where no value should be.

This chapter covers the four axes-level distribution functions in seaborn — sns.histplot(), sns.kdeplot(), sns.ecdfplot(), sns.rugplot() — and the figure-level wrapper sns.displot() that can produce any of the first three across a grid of subplots.

📌 `distplot` is on its way out

If you find tutorials, StackOverflow answers or old course notebooks using sns.distplot(), they are written for seaborn 0.10 or earlier. distplot was deprecated in 0.11; as of 0.13 it still runs but prints a deprecation warning, and it will be removed in a future release. Treat it as gone and never write new code with it. It tried to be a histogram, a KDE and a rug all in one function with confusing defaults (it normalised to density silently whenever the KDE was on). The replacements are: sns.histplot() for the axes-level histogram, sns.displot() for the figure-level version, sns.kdeplot() for the density curve. A literal translation of sns.distplot(x) is sns.histplot(x, kde=True, stat="density").

Everything below uses the built-in example datasets so you can run each snippet as-is:

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

sns.set_theme(style="whitegrid")

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

penguins has 344 rows with flipper_length_mm, bill_length_mm, body_mass_g, plus species, island, sex. It is the standard teaching dataset for distributions because flipper length is genuinely bimodal when you mix the species — which makes it perfect for showing how bin choice can hide the truth.

16.1 histplot — the histogram

A histogram divides the number line into consecutive intervals called bins, counts how many observations fall into each bin, and draws a bar whose height is that count.

python2 lines
sns.histplot(data=penguins, x="flipper_length_mm")
plt.show()

A histogram of penguin flipper length. The two humps are a real signal — different species.

That is the whole idea. Everything else is a knob on top of it.

🧩 Analogy

Imagine sorting exam papers into physical piles on a table: 0–10, 10–20, 20–30, and so on. The histogram is a photograph of the pile heights. If you change the pile boundaries — 0–20, 20–40 — the photograph changes, even though not a single paper moved. The data is fixed; the picture is a choice you made.

The parameters that matter

Parameter Default What it does
x / y — The variable. Pass y= instead of x= to draw the histogram horizontally.
bins "auto" Number of bins, or a list of explicit bin edges, or a numpy binning rule name.
binwidth None Width of each bin in data units. Overrides bins.
binrange None (min, max) tuple limiting where bins are placed.
stat "count" What the bar height means: "count", "frequency", "probability" (= "proportion"), "percent", "density".
kde False Overlay a smoothed density curve, rescaled to match the histogram.
element "bars" "bars", "step" (outline only), "poly" (connected midpoints).
multiple "layer" How hue groups are arranged: "layer", "dodge", "stack", "fill".
cumulative False Each bar adds the previous ones — a step version of the ECDF.
common_norm True With hue, whether normalisation is shared across groups or done per group.
common_bins True With hue, whether all groups use the same bin edges. Almost always keep True.
discrete False One bin per integer value, centred on it. Use for counts like size in tips.
shrink 1 Scale bar width, e.g. 0.8 to put gaps between discrete bars.
log_scale False True to bin on a log scale — essential for heavily right-skewed money/count data.

stat= — what the height means

This is the single most misread axis in data analysis. The five options answer different questions.

stat Bar height Bars sum to Use when
"count" Number of observations in the bin n Single group; you care about how many.
"frequency" count ÷ binwidth — Bins of unequal width.
"probability" count ÷ n 1 Comparing groups of different sizes.
"percent" 100 × count ÷ n 100 Same, but reads better to non-technical audiences.
"density" count ÷ (n × binwidth) area = 1 Comparing with a KDE or a theoretical distribution.
KEYWith "probability" and "percent" the heights add up to 1 / 100. With "density" the area adds up to 1, so heights can exceed 1 when bins are narrow. A density of 0.06 is not "6%".
python6 lines
# same data, four meanings of the y axis
fig, axes = plt.subplots(2, 2, figsize=(10, 7))
for ax, s in zip(axes.flat, ["count", "probability", "percent", "density"]):
    sns.histplot(data=penguins, x="flipper_length_mm", stat=s, binwidth=5, ax=ax)
    ax.set_title(f'stat="{s}"')
plt.tight_layout()

The four pictures have identical shapes. Only the y-axis numbers change. That is the point: stat never changes the data, only the units of the height.

element= — bars, step, poly

python2 lines
sns.histplot(data=penguins, x="flipper_length_mm", hue="species",
             element="step", fill=False)

element="bars" is the default and is correct for a single group. When you overlay three groups, solid bars hide each other; element="step" with fill=False draws only the outline so all three profiles stay visible. element="poly" connects the tops of the bins with straight lines — visually close to a KDE but with no smoothing assumption, since every vertex is an actual bin count.

Discrete data

python2 lines
# party size is an integer count, not a continuous measurement
sns.histplot(data=tips, x="size", discrete=True, shrink=0.8)

Without discrete=True the automatic bin edges can fall between integers and put "2" and "3" into the same bar, or split a single integer across two bars, producing a comb pattern that means nothing. discrete=True centres one bin on each integer; shrink=0.8 adds visual gaps so it reads as a bar chart of counts.

Skewed data and log_scale

python3 lines
# fares are extremely right-skewed: most cheap, a few enormous
sns.histplot(data=titanic, x="fare", bins=30)          # one tall bar, long empty tail
sns.histplot(data=titanic, x="fare", bins=30, log_scale=True)  # readable

On the linear scale, 90% of the fares are crushed into the leftmost bar and the £512 tickets stretch the axis. log_scale=True bins in log space, giving equal-width bins in orders of magnitude and revealing the class structure. Note that log_scale cannot handle zeros or negatives — Titanic contains fares of 0, which are dropped from the log-scaled plot, so mention that in the caption rather than pretending they don't exist.

16.2 Choosing bins — the most important decision

Everything else in this chapter is styling. This section is statistics. The bin count is a smoothing parameter, and it decides what the reader concludes.

The same data, four stories

python5 lines
fig, axes = plt.subplots(1, 4, figsize=(16, 3.5), sharey=False)
for ax, b in zip(axes, [3, 12, 30, 120]):
    sns.histplot(data=penguins, x="flipper_length_mm", bins=b, ax=ax)
    ax.set_title(f"bins={b}")
plt.tight_layout()

The single most important decision in a histogram. With 4 bins the distribution looks unimodal; with 25 the two species appear; with 120 it dissolves into noise. Always try several.

Run it and read the four panels left to right:

bins What the reader sees What is wrong
3 A single lump — "flipper length is unimodal, centred near 200 mm" Oversmoothed. The two species clusters are averaged into one bar. A real, biologically meaningful feature has been erased.
12 Two clear humps around 190 mm and 215 mm Close to right. This is the honest picture.
30 Two humps, but ragged, with several small dips inside each Starting to show sampling noise as if it were structure.
120 A picket fence of height-1 and height-0 bars Undersmoothed. With 344 rows and 120 bins you are averaging about 3 points per bin; you are plotting the measurement grid, not the distribution.
THE TRADE-OFFFew bins = low variance, high bias (real features smoothed away). Many bins = low bias, high variance (noise looks like features). There is no bin count that is correct in the abstract — only one that is appropriate for your sample size and the feature you are trying to see.

Where the default comes from

Seaborn's default is bins="auto", which it hands to numpy.histogram_bin_edges. NumPy's "auto" takes the maximum bin width of two classical rules — i.e. the more conservative, smoother answer:

Rule Bin width Behaviour
Sturges range ÷ (1 + log₂ n) Assumes roughly normal data. Badly oversmooths large or skewed samples.
Freedman–Diaconis 2 × IQR ÷ n1/3 Uses the IQR, so outliers do not blow it up. Robust; the workhorse.
Scott 3.49 × σ ÷ n1/3 Like FD but uses the standard deviation, so outliers widen the bins.
⚠️ The default is a heuristic, not a truth

Every one of those formulas was derived by minimising an error criterion under the assumption that the underlying density is smooth and roughly unimodal. Bimodal data violates that assumption, which is exactly when you most need to see the second mode. The default bin count is a reasonable first guess produced by a formula that has never looked at your data's shape. Treat it as the start of the analysis, not the end of it.

Prefer binwidth to bins

python5 lines
# "30 bins" means nothing to a reader; "5 mm per bin" means something
sns.histplot(data=penguins, x="flipper_length_mm", binwidth=5)

# explicit edges when the domain has natural boundaries
sns.histplot(data=titanic, x="age", bins=[0, 12, 18, 30, 45, 60, 80])

Three reasons to reach for binwidth first:

  • It is interpretable. "Each bar is one 5 mm interval" is a sentence a reader can check. "30 bins" is not.
  • It is stable across subsets. If you facet by species, bins=30 gives each panel 30 bins over a different range, so the panels are not comparable. binwidth=5 gives every panel the same physical bin, so bar heights mean the same thing everywhere.
  • It respects measurement resolution. If flipper length is recorded to the nearest millimetre, a bin width of 0.5 mm is meaningless — half of the bins are structurally empty.
💡 Practical procedure
  1. Draw with the default. 2. Draw again with roughly half and roughly double the bin count. 3. Any feature that survives all three is real; any feature that appears in only one is probably an artefact of binning. 4. Publish the version that shows the surviving features with the least noise, and state the bin width in the caption. If a claim in your write-up depends on the bin count, it is not a claim about the data.

The bin origin matters too, not just the width. Shifting all edges by half a bin can merge or split a peak. That instability is the strongest argument for checking your conclusion against an ECDF, which has no bins at all.

16.3 kdeplot — density and bandwidth

A kernel density estimate replaces every observation with a small smooth bump — by default a Gaussian — and adds all the bumps together. The result is a continuous curve whose total area is 1.

sns.kdeplot(data=penguins, x="flipper_length_mm", fill=True)

Bandwidth is the KDE's equivalent of bin width. Too small invents structure; too large smooths the two species into one hump.

🧩 Analogy

A histogram drops each observation into a rigid box. A KDE drops each observation as a small pile of sand; the piles overlap and merge into a dune. The bandwidth is how wide each pile of sand spreads. Wide piles give one smooth dune; narrow piles give a spiky landscape with one spike per grain.

Bandwidth: the same trade-off as bins

The bandwidth is the KDE's bin width. Seaborn picks it with Scott's rule by default and lets you scale it multiplicatively:

python4 lines
for adj in [0.25, 0.5, 1, 2]:
    sns.kdeplot(data=penguins, x="flipper_length_mm",
                bw_adjust=adj, label=f"bw_adjust={adj}")
plt.legend()

bw_adjustResult on penguin flipper length

0.25Wobbly curve with many small local peaks — mostly noise. 0.5Two clean modes, slightly sharp. Often the best view of bimodal data. 1 (default)Two modes, but the valley between them is shallow. 2A single broad hump. The bimodality has been smoothed out of existence.

⚠️ Bandwidth can delete real structure

Scott's rule is optimal for a single Gaussian. Applied to two overlapping Gaussians, it picks a bandwidth appropriate for the combined spread — which is too wide for either mode — and can smooth a genuinely bimodal distribution into one hump. If you only ever look at the default KDE, you will confidently report the wrong shape. Always cross-check a KDE against a histogram or an ECDF before claiming "unimodal".

KDE parameters

Parameter Meaning
bw_adjust Multiplier on the automatic bandwidth. Below 1 = more detail, above 1 = smoother.
bw_method "scott" (default), "silverman", or a number for an absolute bandwidth.
fill Shade under the curve. Nice for one group; muddy for four overlapping ones.
cut How far past the extreme data points the curve is drawn, in bandwidth units. Default 3.
clip (low, high) hard limits. Nothing is drawn outside them.
cumulative Draw the integrated curve — a smoothed CDF. Requires SciPy.
common_norm With hue: True makes the areas sum to 1 across all groups, False gives each group area 1.
multiple "layer" (default), "stack", "fill". No "dodge" — curves cannot dodge.
weights Per-observation weights, e.g. survey sampling weights.

The boundary problem

python9 lines
# tip amounts are strictly positive, but the default KDE does not know that
sns.kdeplot(data=tips, x="tip", fill=True)
# the curve extends below 0 -> it claims negative tips are possible

# fix 1: stop the curve at the data
sns.kdeplot(data=tips, x="tip", fill=True, cut=0)

# fix 2: hard boundary at a known physical limit
sns.kdeplot(data=tips, x="tip", fill=True, clip=(0, None))
⚠️ A KDE invents density where there is no data

Each Gaussian kernel has infinite support, so an observation at tip = 1.00 puts a little probability mass at −0.5. With cut=3 the curve is drawn three bandwidths past the smallest observation, and you get a visible non-zero density in a region where the variable cannot exist. This is not a rendering artefact — the estimator really does assign mass there, and it also drags density away from the true boundary, understating how many values sit near zero. Use cut=0 to stop drawing past the data, or clip=(0, None) to state the physical constraint. For strictly positive skewed variables, KDE on log_scale=True is often the better answer.

Histogram plus KDE

python2 lines
sns.histplot(data=penguins, x="flipper_length_mm",
             bins=20, kde=True)

With kde=True, seaborn computes the density and rescales it to sit on whatever stat the bars use, so the curve visually matches the bars. This is a useful sanity display for teaching — bars show the raw evidence, the curve shows the smoothed reading. It is also a fair way to reveal disagreement: if the curve says "one mode" and the bars say "two", you have learned something about your bandwidth.

16.4 ecdfplot and rugplot

The empirical cumulative distribution function

sns.ecdfplot(data=penguins, x="flipper_length_mm")

The ECDF has no bins and no bandwidth — nothing to tune, so nothing to accidentally mislead with. Read it as “what fraction is below this value?”

For every value v on the x axis, the ECDF plots the proportion of observations that are less than or equal to v. It is a staircase that starts at 0 on the left and ends at 1 on the right, with one step of height 1/n at each data point.

WHY IT MATTERSThe ECDF has no smoothing parameter. No bins, no bandwidth, nothing to tune. Two analysts given the same column produce byte-identical ECDFs. It is the only one of the three plots that cannot be accidentally — or deliberately — tuned to support a conclusion.

Reading an ECDF takes a minute of practice, and then it becomes the fastest plot in your toolbox:

  • Medians and quantiles are direct reads. Draw a horizontal line at 0.5 and drop to the axis: that is the median. At 0.9: the 90th percentile.
  • Steep = dense, flat = sparse. A vertical jump means many observations at one value; a long horizontal stretch means a gap in the data.
  • Bimodality looks like two steep sections separated by a flat one. Less obvious than two humps, but it is there and it is not an artefact.
  • Group comparison is exact. Two ECDFs that never cross mean one group is stochastically larger at every quantile — a much stronger statement than "the means differ".
python7 lines
sns.ecdfplot(data=penguins, x="flipper_length_mm", hue="species")

# complementary ECDF: proportion ABOVE v — natural for survival/latency
sns.ecdfplot(data=titanic, x="fare", complementary=True)

# count instead of proportion, when absolute numbers matter
sns.ecdfplot(data=titanic, x="age", stat="count")

Its cost is honesty about shape: an ECDF is bad at communicating "this is roughly normal" to a general audience, and modes are much harder to point at. Use it for analysis and for precise group comparison; use a histogram when the audience needs the shape at a glance.

The rug plot

python2 lines
sns.kdeplot(data=penguins, x="flipper_length_mm", fill=True)
sns.rugplot(data=penguins, x="flipper_length_mm", height=0.05)

A rug draws one short tick per observation along the axis. On its own it is nearly useless; layered under a KDE it is one of the most valuable habits you can build, because it shows exactly where the data actually is. Any part of the KDE curve that sits above a stretch of empty rug is pure extrapolation by the smoother. With large n the ticks overplot into a solid band — set alpha=0.3, or switch to a histogram.

Both histplot and kdeplot can also draw rugs indirectly through displot(..., rug=True).

The comparison table

histplot kdeplot ecdfplot
Shows Counts per interval Smooth estimated density Cumulative proportion
Tuning parameter bins / binwidth bandwidth (bw_adjust) none
Hides Structure finer than one bin; sensitive to edge placement The raw data; boundaries; sample size Modes are hard to see
Invents Nothing — bars are actual counts Density in empty regions and past physical limits Nothing
Sample size visible? Yes (with stat="count") No — a 5-point KDE looks as confident as a 5000-point one Yes (step size = 1/n)
Comparing 2–3 groups Fair, needs element="step" or dodge Good, clean overlay Best — precise and exact
Comparing 5+ groups Poor Poor Still workable
Reading quantiles Hard Impossible Direct
Audience Anyone Technical Technical
Use it when First look; showing counts; discrete data Overlaying several groups; smooth shape for a report Precise comparison; quantiles; you distrust the smoothing
💡 The three-plot habit

For any new numeric column, draw all three. If the histogram, the KDE and the ECDF all tell the same story, you can believe it. If they disagree, the disagreement is the finding — usually a bandwidth that is too wide, a bin width that is too coarse, or a spike of repeated values (rounded ages, default zeros) that the KDE has smeared into a smooth bump.

16.5 displot — the figure-level interface

sns.displot() is not a fourth kind of plot. It is a wrapper that calls histplot, kdeplot or ecdfplot for you, on its own figure, optionally split into a grid of subplots.

python8 lines
sns.displot(data=penguins, x="flipper_length_mm")                 # kind="hist" default
sns.displot(data=penguins, x="flipper_length_mm", kind="kde")
sns.displot(data=penguins, x="flipper_length_mm", kind="ecdf")

# the real reason to use it: faceting
g = sns.displot(data=penguins, x="flipper_length_mm",
                col="species", row="sex",
                binwidth=5, height=3, aspect=1.2)

Axes-level (histplot)Figure-level (displot)

Returnsmatplotlib.axes.Axes``seaborn.FacetGrid Draws onCurrent axes, or ax=A new figure it creates and owns Accepts ax=YesNo — this is the defining difference Faceting (col/row)NoYes Size controlplt.subplots(figsize=...)``height= and aspect= per facet Choose plot typePick the functionkind="hist"/"kde"/"ecdf" Use in a multi-panel dashboardYesNo

Extra keyword arguments pass straight through to the underlying function, so binwidth, stat, multiple, bw_adjust, element all work on displot — as long as they belong to the kind you chose. Passing bins= with kind="kde" raises a TypeError.

python7 lines
g = sns.displot(data=tips, x="total_bill", col="time",
                kind="hist", binwidth=3, kde=True, height=4)

g.figure.suptitle("Bill distribution by service", y=1.03)   # NOT plt.title()
g.set_axis_labels("Total bill ($)", "Count")
g.set_titles("{col_name}")
g.savefig("bills.png", dpi=150)
⚠️ `plt.title()` after `displot`

plt.title() sets the title of the current axes, which after a faceted displot is only the last subplot. Your figure title lands on the bottom-right panel, or on an empty figure if you drew something else in between. Use g.figure.suptitle(...) for the whole figure and g.set_titles(...) for the panels. The same applies to plt.xlabel (use g.set_axis_labels) and plt.savefig (use g.savefig). In seaborn 0.12+ use g.figure; the older g.fig still works but is the deprecated spelling.

16.6 Comparing groups

Nearly every real question is comparative: do Adelie and Gentoo penguins differ in flipper length; do lunch and dinner bills differ; did survivors differ in age from non-survivors. The mechanism is hue=, and the way you configure it decides whether the comparison is fair.

multiple= — four layouts

python6 lines
fig, axes = plt.subplots(2, 2, figsize=(11, 7))
for ax, m in zip(axes.flat, ["layer", "dodge", "stack", "fill"]):
    sns.histplot(data=penguins, x="flipper_length_mm", hue="species",
                 multiple=m, binwidth=5, ax=ax)
    ax.set_title(f'multiple="{m}"')
plt.tight_layout()

layer overlaps (hard to read), stack shows the total, fill shows proportions. Pick the one that matches your question.

ValueLayoutGood forFails when

"layer" (default)Bars drawn on top of each other, semi-transparent2 groups with little overlap3+ groups: overlapping translucent bars produce muddy colours that match no legend entry, and you cannot tell whether a dark region is one tall bar or three short ones. "dodge"Bars side by side within each bin2–3 groups, few bins, exact per-group heightsMany bins: bars become hairlines and the bin structure is lost. "stack"Bars piled verticallyShowing the total and its compositionComparing group shapes — only the bottom group sits on a flat baseline, so the others are hard to read. "fill"Stacked and normalised to 100% per bin"What share of observations in this range are Gentoo?"Hides sample size completely: a bin containing 2 observations looks as authoritative as one containing 200.

⚠️ `multiple="layer"` with three groups

The default is the worst choice for exactly the case beginners hit first. With three species layered, the region where all three overlap is a colour that appears nowhere in the legend, and the visible height of the front group is not its count. Fix it one of three ways: element="step", fill=False to draw outlines; multiple="dodge" for exact heights; or col="species" to give each group its own panel. For 4+ groups, faceting or a KDE overlay is the only readable option.

Groups of different sizes: the normalisation trap

This is the most common quiet error in student work.

python11 lines
penguins["species"].value_counts()
# Adelie 152, Gentoo 124, Chinstrap 68

# WRONG: raw counts. Chinstrap looks "rare" at every flipper length
sns.histplot(data=penguins, x="flipper_length_mm", hue="species",
             element="step", fill=False)

# RIGHT: each group normalised to its own total
sns.histplot(data=penguins, x="flipper_length_mm", hue="species",
             stat="density", common_norm=False,
             element="step", fill=False)

With stat="count", the Chinstrap curve is short everywhere simply because there are 68 of them. A reader comparing peak heights concludes "Chinstrap flippers are less common", which is a statement about sampling, not about penguins. The question you actually asked is "given that a penguin is a Chinstrap, how is its flipper length distributed?" — a within-group question, which requires within-group normalisation.

RULEComparing the shape of groups with unequal n: stat="density" (or "probability" / "percent") plus common_norm=False. You need both. stat="density" alone with the default common_norm=True normalises across all groups combined, so the size imbalance survives the rescaling.
common_norm Meaning Question it answers
True (default) All groups share one normalisation; the whole plot sums to 1 "What fraction of all penguins are Gentoo with ~215 mm flippers?" — group size is part of the answer.
False Each group normalised separately; each sums to 1 "How is flipper length distributed within each species?" — group size removed.

The same pair applies to kdeplot, where the default common_norm=True means the areas under the three curves sum to 1, not 1 each — so a small group's curve is squashed for a reason that has nothing to do with its shape.

python2 lines
sns.kdeplot(data=penguins, x="flipper_length_mm", hue="species",
            common_norm=False, fill=True, alpha=0.35)
💡 Keep the sample sizes visible

Once you normalise, the plot no longer shows n — and n is what tells the reader how much to trust a bumpy tail. Put it in the legend labels or the caption: "Adelie (n=152), Gentoo (n=124), Chinstrap (n=68)". Normalising is the right analytical choice; hiding the counts entirely is not.

Faceting instead of overlaying

python3 lines
# when overlay stops working, split — with shared bins and shared axes
sns.displot(data=titanic, x="age", col="class", row="sex",
            binwidth=5, height=3, facet_kws={"sharey": True})

Faceting trades direct comparison (harder — the eye must jump between panels) for clarity (each distribution is clean). Keep binwidth fixed and axes shared, otherwise the panels are drawn on different scales and comparing them is meaningless.

16.7 Two-dimensional distributions

Pass both x and y and the same functions switch to bivariate mode.

python9 lines
# 2D histogram: a heatmap of counts in rectangular cells
sns.histplot(data=penguins, x="bill_length_mm", y="bill_depth_mm")

# 2D KDE: contour lines of equal density
sns.kdeplot(data=penguins, x="bill_length_mm", y="bill_depth_mm")

# filled contours read better for a single group
sns.kdeplot(data=penguins, x="bill_length_mm", y="bill_depth_mm",
            fill=True, thresh=0.05, levels=8, cmap="mako")

The bivariate histogram darkens cells by count and uses bins/binwidth as a pair (binwidth=(2, 0.5) sets x and y separately). Its main use is large data where a scatter plot overplots: with 100 000 points a scatter is a black blob, while a 2D histogram still shows where the mass is.

The bivariate KDE draws contours, each enclosing a region of equal estimated density. Useful extra parameters:

Parameter Effect
levels Number of contour lines, or a list of iso-proportions like [.05, .25, .5, .75].
thresh Lowest density drawn, as a proportion of the peak. Raise it to drop faint outer contours.
fill Filled bands instead of lines.
cbar True adds a colour bar (both histplot 2D and filled kdeplot).
hue One contour set per group. Readable up to about three groups, then it becomes spaghetti.
python11 lines
# separate contour sets per species reveal three distinct clusters
sns.kdeplot(data=penguins, x="bill_length_mm", y="bill_depth_mm",
            hue="species", levels=5, thresh=0.1)

# jointplot: bivariate centre plus univariate margins in one call
sns.jointplot(data=penguins, x="bill_length_mm", y="bill_depth_mm",
              hue="species", kind="kde")

# same via the figure-level distribution interface
sns.displot(data=penguins, x="bill_length_mm", y="bill_depth_mm",
            col="species", kind="hist", cbar=True)
⚠️ 2D KDE on small samples

A bivariate density estimate needs far more data than a univariate one — the space to fill grows with the square of the resolution. With 68 Chinstrap penguins the contours are smooth and confident-looking, and are largely an artefact of the kernel. Overlay a scatter (sns.scatterplot on the same axes) or use a 2D histogram so the reader can see how many points support each contour. As a rough guide, be sceptical of 2D contours below a few hundred points per group.

16.8 Gotchas

1. The KDE claims impossible values exist

Covered above and worth repeating because it appears in almost every student report about prices, ages, durations or counts. A Gaussian kernel has infinite support, so a strictly positive variable gets non-zero density below zero. Fix with cut=0, clip=(0, None), or log_scale=True. If a reviewer sees a density curve crossing x=0 for "tip amount", they stop trusting the rest of the figure.

2. The bandwidth smoothed away the finding

Default bandwidth on bimodal data can produce one hump. Always cross-check with bw_adjust=0.5 and with a histogram before writing "the distribution is unimodal".

3. Raw counts across unequal groups

stat="density", common_norm=False. Both parts. Setting only one gives a plot that looks corrected but is not.

4. multiple="layer" with several groups

The default becomes unreadable at three groups and misleading at four. Switch to element="step", fill=False, to dodge, or to facets.

5. plt.title() after displot

displot returns a FacetGrid, not an Axes. Use g.figure.suptitle(), g.set_titles(), g.set_axis_labels(), g.savefig(). And displot does not accept ax= — if you need a distribution inside an existing subplot grid, use histplot(..., ax=axes[0, 1]).

6. NaNs are dropped silently

penguins["flipper_length_mm"].isna().sum() # 2 missing Seaborn removes missing values without warning, so a bar chart of "count" is a count of non-missing rows. With 2 of 344 that is harmless; with 40% missing your "distribution" describes a self-selected subset. Check the NaN count before plotting, every time.

7. Repeated / rounded values look like a smooth mode

Titanic ages include many estimated values recorded as .5 and many round numbers. A KDE turns those spikes into a gentle bump and the reader sees a natural mode where there is really a data-entry convention. A histogram with binwidth=1, or a rug, exposes it immediately.

8. The ECDF is the honest one — use it as the referee

Because it has no tuning parameter, the ECDF cannot be adjusted into a nicer story. If your histogram and your KDE disagree, the ECDF settles the argument: a flat stretch between two steep stretches confirms real bimodality; a smooth S-curve says the second mode was an artefact.

9. sns.load_dataset() needs internet

python10 lines
# fetches a CSV from the seaborn-data GitHub repo; fails offline
penguins = sns.load_dataset("penguins")

# offline workaround 1: cache once, then read locally
# penguins.to_csv("penguins.csv", index=False)
import pandas as pd
penguins = pd.read_csv("penguins.csv")

# offline workaround 2: point seaborn at a local clone of seaborn-data
penguins = sns.load_dataset("penguins", data_home="./seaborn-data", cache=True)

On a first successful call seaborn caches the file under ~/seaborn-data, so later calls work offline — but the first call in an exam hall or on a locked-down machine will raise a URL error. Download the datasets once beforehand, or ship CSVs with the notebook.

10. Legend handling differs between levels

histplot puts the legend on the axes and it can be moved with ax.legend_.set_title(...) or matplotlib calls; displot places a single figure legend outside the grid, controlled by legend=False or g.legend. Reaching for plt.legend() after a seaborn plot usually replaces seaborn's legend with an empty or wrong one.

16.9 Practice set

python43 lines
# Setup
import seaborn as sns
import matplotlib.pyplot as plt
penguins = sns.load_dataset("penguins")
tips     = sns.load_dataset("tips")
titanic  = sns.load_dataset("titanic")

# 1. Basic histogram.
#    Plot the distribution of body_mass_g in penguins with the default bins.
#    Write one sentence: is it symmetric, right-skewed or left-skewed?

# 2. Bins change the story.
#    Draw flipper_length_mm four times side by side with bins = 3, 12, 30, 120.
#    Which panels show two modes? Which bin count would you publish, and why?

# 3. binwidth beats bins.
#    Redraw exercise 2 using binwidth=2 and binwidth=8 instead.
#    Explain in one line why binwidth is easier to justify in a caption.

# 4. The stat axis.
#    Plot total_bill from tips with stat="count", "probability", "percent",
#    "density" using the same binwidth. Confirm the shape never changes,
#    and state what the number 0.03 means under stat="density".

# 5. Bandwidth.
#    Overlay four KDEs of flipper_length_mm with bw_adjust = 0.25, 0.5, 1, 2.
#    At which value does the bimodality disappear?

# 6. The boundary problem.
#    KDE of tips["tip"] with defaults. Does the curve go below zero?
#    Redraw with cut=0, then with clip=(0, None), and describe the difference.

# 7. Fair group comparison.
#    Compare flipper_length_mm across species, first with raw counts,
#    then with stat="density" + common_norm=False and element="step".
#    Which conclusion changes, and why was the first version misleading?

# 8. Put it together.
#    Using titanic: facet age by class (columns) and survived (rows) with
#    displot, binwidth=5, shared y axis. Title the figure with
#    g.figure.suptitle(). Then draw the equivalent ecdfplot with
#    hue="class" on a single axes and say which of the two you would
#    show a non-technical audience, and which you would use yourself.

16.10 Interview questions

Q: What replaced sns.distplot() and why was it replaced?

It was deprecated in 0.11 and still runs in 0.13 with a deprecation warning, but it is on its way out and should never be used in new code. It combined a histogram, KDE and rug in one function with defaults that silently changed the y axis meaning — turning the KDE on switched the bars from counts to density. It was replaced by histplot (axes-level histogram), kdeplot (density), ecdfplot (cumulative) and displot (figure-level wrapper with kind=). The literal equivalent of sns.distplot(x) is sns.histplot(x, kde=True, stat="density").

Q: How do you choose the number of bins?

There is no single correct answer; the bin count is a smoothing parameter trading bias against variance. Too few bins hide real features, too many show sampling noise. Seaborn's bins="auto" uses NumPy's rule — the wider of Freedman–Diaconis and Sturges — which assumes a smooth, roughly unimodal density, exactly the assumption that fails when you most need the plot. In practice: draw the default plus half and double, keep only the features that survive all three, prefer binwidth because it is interpretable and stable across facets, and report the bin width in the caption.

Q: A KDE of order value shows density at negative prices. Explain and fix.

The KDE places a Gaussian kernel on every observation, and a Gaussian has infinite support, so observations near zero spread mass into the negative region. cut=3 then draws the curve three bandwidths past the smallest data point, making it visible. The estimator is also biased near the boundary, understating density at the true edge. Fixes: cut=0 to stop drawing beyond the data, clip=(0, None) to impose the physical limit, use log_scale=True for a positive skewed variable, or just use a histogram, which cannot place a bar where there are no observations.

Q: When would you prefer an ECDF over a histogram?

When precision matters more than intuitive shape. The ECDF has no bins or bandwidth, so it is fully reproducible and cannot be tuned to favour a conclusion; quantiles and medians are read off directly; and two groups can be compared exactly at every point — non-crossing ECDFs mean one group is stochastically larger everywhere, which is stronger than a difference in means. It is also usable with more groups than an overlaid histogram. The trade-off is that modes and skew are much harder for a general audience to see, so histograms remain better for presentation.

Q: You compare three groups with 5000, 500 and 50 rows. What do you set?

stat="density" (or "probability") together with common_norm=False, so each group's distribution is normalised to its own total and the comparison is about shape, not sample size. Both parts are required: with the default common_norm=True, normalisation is shared across groups and the imbalance survives. I would also keep common_bins=True so the bars are comparable, note the n values in the legend, and be cautious about the 50-row group — its tails are noise, and a KDE of it would look just as smooth and confident as the 5000-row one.

Q: Difference between histplot and displot?

histplot is axes-level: it draws on the current or supplied Axes, accepts ax=, returns an Axes, and composes into any matplotlib figure. displot is figure-level: it creates and owns a figure, returns a FacetGrid, refuses ax=, supports col=/row= faceting and height=/aspect= sizing, and can switch between hist, kde and ecdf via kind=. Use displot for faceted standalone figures, histplot when the plot is one panel of a larger layout.

Q: plt.title() after displot puts the title on the wrong panel. Why?

plt.title() targets the current axes, and after a faceted displot that is the last subplot drawn. Use g.figure.suptitle("...") for a figure title and g.set_titles("{col_name}") for panel titles, plus g.set_axis_labels() and g.savefig(). The general rule: figure-level functions return a grid object, and you must go through that object rather than the pyplot state machine.

Q: When is multiple="layer" a bad default, and what do you use instead?

As soon as there are three or more hue groups, or when groups overlap heavily. Translucent bars stack into colours that appear nowhere in the legend, and the visible height of a front group is not its count. Alternatives: element="step", fill=False for outlines; multiple="dodge" when exact per-group heights matter and there are few bins; multiple="stack" only if the total is the point; and faceting with col= once you pass three or four groups. Overlaid KDEs with common_norm=False are also a clean option when you can defend the smoothing.

16.11 Summary

  • distplot is deprecated (it still runs in 0.13 but warns, and will be removed); use histplot, kdeplot, ecdfplot at axes level and displot(kind=...) at figure level.
  • Bin count is a smoothing parameter, not a setting: the same data can look unimodal or bimodal depending on it. Try several, keep what survives, prefer binwidth and state it in the caption.
  • stat= changes only the meaning of the y axis — "count", "probability", "percent" sum over bars; "density" makes the area equal 1.
  • KDE bandwidth (bw_adjust) is the histogram's bin width in disguise; the default can smooth genuine bimodality away, so always cross-check.
  • A KDE invents density where no data exists, including past physical limits — fix with cut=0, clip=, or log_scale=True.
  • ECDF has no tuning parameter, so it is the most honest and the best referee when the histogram and the KDE disagree; a rug plot under a KDE shows where the data really is.
  • Comparing unequal groups requires stat="density" and common_norm=False; keep common_bins=True and report the n values.
  • multiple="layer" breaks at three or more groups — use element="step", dodge, or facets.
  • displot returns a FacetGrid: use g.figure.suptitle(), g.set_titles(), g.savefig(), and remember it does not accept ax=.

Next: Chapter 17 covers categorical plots — bar, box, violin, strip and swarm.

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