Axes Styling and Annotation
Axes Styling and Annotation
Core Principle: Professional scientific visualization requires explicit axis scaling, clear physical units in labels, consistent typographic hierarchy, and purposeful data annotations.
Labels: the non-negotiables
ax.set_xlabel("log$_2$ fold change")
ax.set_ylabel("$-$log$_{10}$ adjusted $p$-value")
ax.set_title("Treated vs control", fontsize=11)
ax.set(xlabel="...", ylabel="...", title="...") # all at once
Every axis needs a label with units. "Expression" is not a label; "log₂ CPM" is. A reviewer cannot tell whether your fold changes are log₂ or linear, and the difference is a factor of 2 vs a factor of 4.
Maths uses $...$ (mathtext, a LaTeX subset). Use raw strings so backslashes survive:
ax.set_xlabel(r"$\log_2$ fold change")
ax.set_ylabel(r"Coverage (reads $\times 10^3$)")
Limits and scales
ax.set_xlim(0, 100); ax.set_ylim(bottom=0)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_yscale("symlog", linthresh=1) # log-like, but handles 0 and negatives
ax.set_xscale("logit") # for proportions
ax.margins(x=0.02, y=0.05) # padding as a fraction of the range
ax.invert_yaxis()
ax.set_aspect("equal") # essential for PCA/UMAP
set_aspect("equal") on any embedding plot. PCA and UMAP distances are only interpretable if the axes have the same scale; a stretched PCA plot invents apparent structure.
symlog is the right choice for log fold changes and any signed quantity spanning orders of magnitude: plain log cannot represent zero or negatives.
Ticks
ax.set_xticks([0, 1, 2, 3])
ax.set_xticks(pos); ax.set_xticklabels(names, rotation=45, ha="right")
ax.tick_params(axis="both", labelsize=8, length=3, width=0.8)
ax.tick_params(axis="x", rotation=45)
ax.minorticks_on()
from matplotlib.ticker import (MultipleLocator, FuncFormatter,
PercentFormatter, ScalarFormatter)
ax.xaxis.set_major_locator(MultipleLocator(10))
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1))
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v/1e6:.0f} Mb"))
That last one is the genomic-coordinate formatter you will want constantly: raw base-pair positions like 43091687 are unreadable on an axis.
ha="right" with rotated labels aligns the end of each label under its tick, which looks correct; the default centre alignment does not.
Spines and grid
ax.spines[["top", "right"]].set_visible(False) # the classic clean look
ax.spines["left"].set_linewidth(0.8)
ax.spines["bottom"].set_position(("outward", 5)) # detached axes
ax.grid(True, alpha=0.3, lw=0.5)
ax.grid(True, axis="y", alpha=0.3) # horizontal only
ax.set_axisbelow(True) # grid behind the data
sns.despine(ax=ax) # seaborn's shortcut
sns.despine(fig=fig, trim=True, offset=5)
ax.set_axisbelow(True) is the fix for gridlines drawn on top of your points. zorder alone does not work for gridlines.
Reference lines and regions
ax.axhline(0, ls="--", lw=0.8, c="grey", zorder=0)
ax.axvline(x=1, ls=":", c="red", label="threshold")
ax.axhspan(-1, 1, alpha=0.1, color="grey") # a horizontal band
ax.axvspan(exon_start, exon_end, alpha=0.2, color="steelblue")
ax.axline((0, 0), slope=1, ls="--", c="grey") # a diagonal: QQ plots
axline with slope 1 is the y=x reference for QQ plots and any observed-vs-expected comparison.
Annotation
ax.text(x, y, "TP53", fontsize=8, ha="center", va="bottom")
ax.annotate("outlier", xy=(x, y), xytext=(x+1, y+2),
arrowprops=dict(arrowstyle="->", lw=0.8, color="grey"), fontsize=8)
# statistics, positioned relative to the panel
ax.text(0.02, 0.98, f"$n$ = {n:,}\n$r$ = {r:.2f}", transform=ax.transAxes,
va="top", ha="left", fontsize=8,
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.8))
The transform=ax.transAxes + bbox combination is the standard way to put a stats box in a corner without worrying about the data range.
A styling function worth keeping
This code completed without producing output.
loc="left" on titles is a small choice that reads noticeably better in multi-panel figures than centred titles.
Better still, put the repeated settings in rcParams and Style Sheets so they apply to everything without a function call.
Bioinformatics-specific styling
# genomic coordinates in Mb
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v/1e6:.1f}"))
ax.set_xlabel("Position on chr17 (Mb)")
# chromosome boundaries on a Manhattan plot
ax.set_xticks(chrom_centres)
ax.set_xticklabels(CHROM_ORDER, rotation=0, fontsize=7)
for b in chrom_boundaries:
ax.axvline(b, c="lightgrey", lw=0.4, zorder=0)
# genome-wide significance
ax.axhline(-np.log10(5e-8), ls="--", c="red", lw=0.8)
ax.text(0.99, -np.log10(5e-8), r"$p = 5\times10^{-8}$",
transform=ax.get_yaxis_transform(), ha="right", va="bottom", fontsize=7)
# equal aspect for embeddings
ax.set_aspect("equal")
ax.set_xlabel(f"PC1 ({var[0]:.1%} variance)")
ax.set_ylabel(f"PC2 ({var[1]:.1%} variance)")
# highlight an exon on a coverage track
for s, e in exons:
ax.axvspan(s, e, alpha=0.15, color="steelblue", zorder=0)
ax.get_yaxis_transform() is a blended transform: x in Axes coordinates, y in data coordinates: which is exactly what you want for labelling a horizontal threshold line.
Putting the variance explained in the PCA axis labels is not optional. A PC1 explaining 45% and a PC1 explaining 4% look identical without it and mean completely different things.
Common mistakes
- Unlabelled axes, or labels without units.
- Not stating log base.
- PCA without equal aspect or without variance explained.
- Raw base-pair positions on an axis.
- Rotated labels without
ha="right". - Gridlines on top of the data.
- Truncated y-axis on a bar chart. Bars must start at zero.
- Font sizes set for screen, then shrunk to journal width.
- Missing
$for maths, so subscripts render as literal underscores.
See also
Matplotlib Artists · Figure and Axes · rcParams and Style Sheets · Saving Figures · Colormaps and Color · seaborn Themes and Palettes
Exercise
Write annotate_track(threshold, points), where points is a list of
(position_bp, neglog10p) pairs. Build one Axes: scatter the points, draw a
dashed horizontal reference line at threshold, set the x limits to the
minimum and maximum position, place ticks at every multiple of 500000 bp that
falls inside those limits, format them with a FuncFormatter that prints
megabases to one decimal place, label the axes, hide the top and right spines,
and annotate every point whose value is at or above the threshold with its
position as a string.
Return exactly three strings, which the driver prints one per line: the tick
labels joined by single spaces, or none when the axis carries no ticks; the
names of the still-visible spines in alphabetical order joined by commas; and
texts=<n> lines=<n> above=<n>, counting the Axes' text artists, its Line2D
artists and the points at or above the threshold. When points is empty there
are no ticks, so the first line is none, and the counts must still be
reported. Read the tick labels back off the Axes after drawing the canvas
rather than formatting them yourself.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 7.3 7500000 3.1 7676154 9.4 8100000 2.0 8600000 7.9 | 7.5 8.0 8.5 bottom,left texts=2 lines=1 above=2 |
| 2 | stdin | 5.0 43000000 4.1 43093220 4.9 43400000 3.3 43600000 6.2 | 43.0 43.5 bottom,left texts=1 lines=1 above=1 |
| 3 | stdin | 4.0 7676154 12.5 | none bottom,left texts=1 lines=1 above=1 |
| 4 | stdin | 6.0 | none bottom,left texts=0 lines=1 above=0 |
Hints · 3
01Hint
The first tick at or above `lo` is `-(-lo // step) * step` — ceiling division.
02Hint
Tick label text is only populated once the canvas has been drawn; call `fig.canvas.draw()` before reading `ax.get_xticklabels()`.
03Hint
`ax.spines.items()` yields `(name, Spine)` pairs, and each Spine has `get_visible()`.