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 usehistplot/ecdfplot. cut. The default extends the curve past your most extreme observation, implying data you do not have.cut=0stops at the data.common_norm. Withhue=, 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=Falsenormalises 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