pythonforbio.
[WASM idle]
Plotting09/14

Statistical Estimation in seaborn

Starting sandbox…
Beginnerlesson

Statistical Estimation in seaborn

In one line: seaborn silently aggregates and bootstraps for you — which is convenient, and dangerous if you do not know what the error bars mean.

What happens automatically

sns.lineplot(data=long, x="dose", y="response")

If long has multiple rows per dose, seaborn aggregates them (mean) and draws a bootstrap 95% confidence interval band. You did not ask for either. Most people plotting this have not thought about which uncertainty measure they are showing.

Controlling the error bars

sns.lineplot(data=long, x="dose", y="response", errorbar=("ci", 95))   # default
sns.lineplot(..., errorbar="sd")            # standard deviation
sns.lineplot(..., errorbar=("se", 1))       # standard error × 1
sns.lineplot(..., errorbar=("pi", 95))      # percentile interval — no bootstrap
sns.lineplot(..., errorbar=None)            # no band, plot every observation
sns.lineplot(..., estimator="median", errorbar=("pi", 50))   # median + IQR
sns.lineplot(..., estimator=np.median, n_boot=10_000, seed=42)
sns.lineplot(..., units="subject", estimator=None)   # one line per subject

The errorbar= API replaced the old ci= parameter in seaborn 0.12. ci="sd" still works but is deprecated.

They mean different things

Shows Shrinks with n?
SD spread of the data no
SE precision of the mean yes (1/√n)
CI plausible range for the true mean yes
PI where the data lies (percentiles) no

Choose based on your claim. If you are saying "treated samples are more variable", show SD. If you are saying "the treated mean differs from control", show CI or SE. Showing SE when you meant to describe variability makes your data look far more consistent than it is — and with n=3, SE is SD/1.73, which is a 42% visual shrink for free.

State it in the caption. "Error bars show 95% bootstrap CI" is one clause and removes all ambiguity. A figure with unlabelled error bars is uninterpretable.

Where estimation happens

Function Default behaviour
lineplot mean + 95% bootstrap CI
barplot mean + 95% bootstrap CI
pointplot mean + 95% bootstrap CI
regplot / lmplot OLS fit + 95% CI band
kdeplot Gaussian KDE, Scott's bandwidth
histplot binning (bins="auto")
boxplot quartiles + 1.5×IQR whiskers — no bootstrap
violinplot KDE of the distribution
scatterplot none — raw points

Boxplot and scatterplot show the data. Everything else shows a model of it.

Regression

sns.regplot(data=df, x="a", y="b")                        # linear OLS
sns.regplot(data=df, x="a", y="b", order=2)               # polynomial
sns.regplot(data=df, x="a", y="b", lowess=True)           # local regression
sns.regplot(data=df, x="a", y="b", robust=True)           # robust to outliers
sns.regplot(data=df, x="a", y="b", logistic=True)         # binary y
sns.regplot(data=df, x="a", y="b", x_estimator=np.mean)   # bin x first
sns.lmplot(data=df, x="a", y="b", hue="g", col="tissue")  # figure-level

sns.residplot(data=df, x="a", y="b")                       # residuals — check the fit

seaborn draws the fit but never gives you the coefficients or a p-value. It is a visualisation tool, not a statistics package. For the numbers use scipy.stats.linregress, statsmodels, or pingouin:

from scipy import stats
r = stats.linregress(df["a"], df["b"])
ax.text(0.05, 0.95, f"$r^2$ = {r.rvalue**2:.2f}\n$p$ = {r.pvalue:.2g}",
        transform=ax.transAxes, va="top", fontsize=8)

lowess=True is the honest first look: it shows the shape of the relationship without assuming linearity. If the LOWESS curve is not straight, do not report a Pearson correlation.

KDE bandwidth

sns.kdeplot(data=df, x="expr", bw_adjust=0.5)     # more detail, more noise
sns.kdeplot(data=df, x="expr", bw_adjust=2)       # smoother
sns.kdeplot(data=df, x="expr", cut=0)             # do not extend past the data
sns.kdeplot(data=df, x="af", clip=(0, 1))         # bounded data
sns.kdeplot(data=df, x="e", hue="g", common_norm=False)   # each group sums to 1

Three real hazards:

  • Bounded data. Allele frequencies and proportions live in [0,1]; a KDE draws density outside that range. Use clip=, or use histplot/ecdfplot.
  • cut. The default extends the curve past your most extreme observation, implying data you do not have. cut=0 stops at the data.
  • common_norm. With hue=, the default normalises across all groups together, so a group with fewer observations has a lower curve even if its shape is identical. common_norm=False normalises each group separately — usually what you want when comparing shapes.

sns.ecdfplot avoids all three. It has no bandwidth, no boundary artefacts, and no normalisation choice.

Aggregation across replicates

# each subject as its own line — shows individual trajectories
sns.lineplot(data=long, x="time", y="expr", units="subject",
             estimator=None, alpha=0.3)

# both: individual lines plus the group mean
fig, ax = plt.subplots()
sns.lineplot(data=long, x="time", y="expr", units="subject",
             estimator=None, alpha=0.2, color="grey", ax=ax, legend=False)
sns.lineplot(data=long, x="time", y="expr", errorbar="se", ax=ax, color="crimson")

This overlay is a much more honest plot than the mean alone. If three subjects go up and two go down, a mean line with a CI band hides that completely; the spaghetti plot shows it immediately.

Statistical annotations

seaborn does not do significance brackets. Use statannotations:

from statannotations.Annotator import Annotator

ax = sns.boxplot(data=long, x="condition", y="expr")
pairs = [("control", "treated"), ("control", "drug")]
annot = Annotator(ax, pairs, data=long, x="condition", y="expr")
annot.configure(test="Mann-Whitney", text_format="star", comparisons_correction="BH")
annot.apply_and_annotate()

Configure the multiple-testing correction explicitly. Stars without correction across many pairwise comparisons is exactly the practice that makes findings unreproducible.

Bioinformatics examples

# dose-response, replicates aggregated with visible uncertainty
sns.lineplot(data=dose, x="conc", y="viability", hue="cell_line",
             errorbar="se", marker="o")

# expression by condition — box plus every point
sns.boxplot(data=long, x="condition", y="expr", showfliers=False,
            boxprops=dict(alpha=0.5))
sns.stripplot(data=long, x="condition", y="expr", color="black", size=3)

# correlation of two assays, with the statistics printed
g = sns.jointplot(data=df, x="rnaseq", y="qpcr", kind="reg")
r = stats.spearmanr(df["rnaseq"], df["qpcr"])
g.ax_joint.text(0.05, 0.95, f"ρ = {r.statistic:.2f}\np = {r.pvalue:.1e}",
                transform=g.ax_joint.transAxes, va="top")

# distributions compared without binning artefacts
sns.ecdfplot(data=long, x="expr", hue="condition")

Common mistakes

  • Not knowing the default is a bootstrap CI.
  • Not stating the error measure in the caption.
  • SE when you meant SD.
  • Bar plots of means with n=3 and no points shown.
  • KDE on bounded data.
  • Reporting a correlation without looking at the scatter. Anscombe's quartet exists for a reason.
  • Treating seaborn's regression line as a statistical result. It has no p-value.
  • Averaging over replicates that should be modelled (pseudoreplication — technical replicates are not independent observations).
  • Significance stars without multiple-testing correction.

See also

Seaborn · Common Plot Types · Faceting · NumPy Random Generator · Applied - Quality Control Plots

scratch

No output yet — run the code to populate this drawer.