Plotting01/14
Starting sandbox…
Beginner·4 graded tests

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

[ ]python
out1ms

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.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdin7.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
2stdin5.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
3stdin4.0 7676154 12.5 none bottom,left texts=1 lines=1 above=1
4stdin6.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()`.