pythonforbio.
[WASM idle]
Plotting01/14

Axes Styling and Annotation

Starting sandbox…
Beginnerlesson

Axes Styling and Annotation

In one line: the difference between a plot that looks like a default and one that looks designed is about six lines of styling.

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

def style(ax, xlabel=None, ylabel=None, title=None, despine=True):
    if xlabel: ax.set_xlabel(xlabel)
    if ylabel: ax.set_ylabel(ylabel)
    if title:  ax.set_title(title, loc="left", fontsize=10)
    if despine:
        ax.spines[["top", "right"]].set_visible(False)
    ax.grid(True, alpha=0.25, lw=0.5)
    ax.set_axisbelow(True)
    ax.tick_params(labelsize=8, length=3)
    return ax

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

lesson example

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

start of pathColormaps and Color