Common Plot Types
Common Plot Types
Core Principle: Selecting appropriate visual marks depends on the statistical nature of the variables, distinguishing continuous distributions, bivariate relationships, paired comparisons, and categorical summaries.
The choice, by question
| Question | Plot | matplotlib | seaborn |
|---|---|---|---|
| How is one variable distributed? | histogram, KDE | ax.hist |
sns.histplot, sns.kdeplot |
| How do two continuous variables relate? | scatter | ax.scatter |
sns.scatterplot |
| How does y change over an ordered x? | line | ax.plot |
sns.lineplot |
| How does a distribution differ across groups? | box, violin, strip | ax.boxplot |
sns.boxplot, sns.violinplot, sns.stripplot |
| How do counts compare across categories? | bar | ax.bar |
sns.countplot, sns.barplot |
| What is the structure of a matrix? | heatmap | ax.imshow |
sns.heatmap, sns.clustermap |
| Too many points to see? | hexbin, 2-D density | ax.hexbin |
sns.histplot(..., cbin) |
| Uncertainty around a line? | line + band | ax.fill_between |
sns.lineplot (automatic) |
| Composition of a whole? | stacked bar | ax.bar(bottom=) |
: |
Distributions
ax.hist(values, bins=50, alpha=0.7, edgecolor="white")
ax.hist(values, bins=np.logspace(0, 5, 50)); ax.set_xscale("log")
sns.histplot(data=df, x="af", bins=50, hue="group", element="step")
sns.histplot(data=df, x="expr", log_scale=(True, False))
sns.kdeplot(data=df, x="expr", hue="group", fill=True, common_norm=False)
sns.ecdfplot(data=df, x="expr", hue="group")
Bin count matters more than people admit. Too few hides structure (bimodality!), too many is noise. Try several. bins="auto" uses a reasonable heuristic.
sns.ecdfplot is underused. An empirical CDF has no binning parameter, so it cannot mislead through bin choice, and comparing two ECDFs is a much fairer visual comparison than two overlaid histograms.
KDE on bounded data lies. Allele frequencies live in [0,1]; a KDE will draw density below 0 and above 1. Use histplot, an ECDF, or set clip=(0, 1).
Scatter
ax.scatter(x, y, s=8, alpha=0.4, c=colors, cmap="viridis",
edgecolors="none", rasterized=True)
sns.scatterplot(data=df, x="pc1", y="pc2", hue="batch", style="sex", s=40)
For overplotting: which is every genomics scatter: the options are, roughly in order:
ax.scatter(x, y, s=2, alpha=0.1, rasterized=True) # transparency
ax.hexbin(x, y, gridsize=50, cmap="Blues", mincnt=1) # bin it
sns.histplot(data=df, x="a", y="b", bins=60, cbar=True) # 2-D histogram
sns.kdeplot(data=df, x="a", y="b", levels=6) # contours
Alpha alone stops working past ~50,000 points. Hexbin or a 2-D histogram is the honest answer for a million-point scatter.
Lines
ax.plot(x, y, lw=1.5, ls="--", marker="o", ms=4, label="treated")
ax.fill_between(x, lo, hi, alpha=0.2)
ax.errorbar(x, y, yerr=err, capsize=3, fmt="o-")
sns.lineplot(data=long, x="time", y="expr", hue="gene", errorbar=("ci", 95))
sns.lineplot aggregates repeated x values automatically and draws a bootstrap CI band. Convenient, and worth knowing it is happening: see Statistical Estimation in seaborn.
Lines imply continuity between points. Do not connect categorical x values with a line.
Categorical comparisons
sns.boxplot(data=long, x="condition", y="expr", hue="tissue")
sns.violinplot(data=long, x="condition", y="expr", inner="quartile", cut=0)
sns.stripplot(data=long, x="condition", y="expr", size=3, alpha=0.5)
sns.swarmplot(data=long, x="condition", y="expr", size=3)
sns.pointplot(data=long, x="dose", y="response", errorbar="se")
sns.barplot(data=long, x="condition", y="expr", errorbar="sd")
sns.countplot(data=variants, x="consequence")
Overlay the points on the box. A box plot of n=4 is four numbers pretending to be a distribution:
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, alpha=0.6)
showfliers=False avoids drawing the outliers twice.
cut=0 on a violin stops the KDE extending beyond your actual data range: otherwise a violin of read counts happily extends below zero.
Bar plots of means with error bars ("dynamite plots") hide the distribution and are actively discouraged in most journals now. Box + strip, or a violin, shows the same summary plus the data.
Heatmaps
im = ax.imshow(matrix, aspect="auto", cmap="viridis", interpolation="nearest")
fig.colorbar(im, ax=ax, label="log2 CPM")
sns.heatmap(corr, cmap="vlag", center=0, vmin=-1, vmax=1,
square=True, annot=True, fmt=".2f")
sns.clustermap(mat, z_score=0, cmap="vlag", center=0,
figsize=(8, 10), col_colors=condition_colors)
center=0 with a diverging colormap for anything signed (correlations, log fold changes, z-scores): otherwise the colour scale's midpoint is arbitrary and the plot implies structure that is not there. See Colormaps and Color.
interpolation="nearest" prevents imshow from smoothing your cells into each other.
See Applied - Heatmaps and Clustermaps.
Multi-panel comparisons
sns.pairplot(df[["pc1", "pc2", "pc3"]], hue="batch", diag_kind="kde")
sns.jointplot(data=df, x="a", y="b", kind="hex")
sns.relplot(data=long, x="dose", y="expr", col="gene", col_wrap=4, kind="line")
See Faceting.
Bioinformatics-specific plots
None of these are built in; all are compositions of the above.
# volcano: scatter with thresholds
ax.scatter(log2fc, neglog10p, s=6, c=np.where(sig, "crimson", "lightgrey"))
# MA plot: log ratio vs mean expression
ax.scatter(np.log10(base_mean), log2fc, s=4, alpha=0.3)
ax.axhline(0, c="grey")
# Manhattan: position vs -log10 p, coloured by chromosome
for i, (chrom, sub) in enumerate(df.groupby("chrom", observed=True)):
ax.scatter(sub["cum_pos"], sub["neglog10p"],
s=3, c=["#4C72B0", "#DD8452"][i % 2], rasterized=True)
# QQ plot: observed vs expected p-value quantiles
expected = -np.log10(np.linspace(1/n, 1, n))
ax.scatter(expected, np.sort(neglog10p)[::-1], s=4)
ax.plot([0, expected.max()], [0, expected.max()], "r--")
# coverage track
ax.fill_between(positions, depth, step="mid", alpha=0.6)
# survival (Kaplan-Meier): use the lifelines package
Common mistakes
- Bar plots of means instead of showing the distribution.
- Box plots with n < 10 and no points overlaid.
- KDE on bounded data producing impossible values.
- Overplotted scatters where alpha is not enough.
- Truncated y-axes on bar charts. Bars encode length; a bar chart must start at zero. (Line and scatter plots may be truncated; bars may not.)
- Diverging colormaps without
center=. - Pie charts. Humans compare angles badly. A bar chart is better in essentially every case.
- Not rasterizing big scatters in vector output.
- Dual y-axes implying a correlation that is an artefact of two arbitrary scales.
See also
Figure and Axes · Colormaps and Color · Statistical Estimation in seaborn · Faceting · Applied - Differential Expression Volcano Plot · Applied - Heatmaps and Clustermaps · Applied - Quality Control Plots
Exercise
Write bin_counts(values, nbins). The first line of input is the bin count and
every remaining line holds whitespace-separated values. Draw the histogram with
ax.hist, choosing the spacing from the data: if the values are strictly
positive and span at least two orders of magnitude: hi / lo >= 100: use
nbins + 1 log-spaced edges from np.logspace and set the x scale to log;
otherwise pass nbins and let matplotlib place linear edges. np.logspace
reproduces its endpoints only to floating-point precision, so overwrite the
first and last edge with the exact minimum and maximum, or the extreme
observations fall outside the outermost bins and go uncounted.
Return three lines: counts= followed by the bar heights, read back as
integers from the Rectangle patches ax.hist returned rather than from the
counts array; edges= followed by every bin edge rounded to three decimals;
and scale=<linear|log> total=<sum of counts> n=<number of values>, where
total and n must agree. When all values are identical the range is
degenerate and matplotlib widens it by half a unit on each side: report
whatever bins that produces. Return the single line none for empty input.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 4 1204 1150 2310 2402 455 470 441 462 | counts=4 2 0 2 edges=441.0 931.25 1421.5 1911.75 2402.0 scale=linear total=8 n=8 |
| 2 | stdin | 5 1.0 12.0 150.0 900.0 15200.0 3.0 88.0 | counts=2 1 2 1 1 edges=1.0 6.861 47.069 322.928 2215.516 15200.0 scale=log total=7 n=7 |
| 3 | stdin | 3 0.51 0.49 0.32 0.21 0.44 0.38 | counts=1 2 3 edges=0.21 0.31 0.41 0.51 scale=linear total=6 n=6 |
| 4 | stdin | 2 7.5 7.5 7.5 7.5 | counts=0 4 edges=7.0 7.5 8.0 scale=linear total=4 n=4 |
Hints · 3
01Hint
`ax.hist` returns `(counts, edges, patches)`; the patches are Rectangles with `get_height()`.
02Hint
`np.logspace` takes log10 of the bounds, not the bounds themselves.
03Hint
Compare `sum(heights)` with `len(values)` while testing — a mismatch means an edge is off by a float epsilon.