Applied Workflows02/13
Starting sandbox…
Beginner·4 graded tests

Applied - Differential Expression Volcano Plot

Applied - Differential Expression Volcano Plot

Core Principle: Volcano plots display differential gene expression by graphing effect size (log⁡2\log_2 fold change) against statistical significance (−log⁡10-\log_{10} adjusted pp-value), highlighting biologically significant targets.

Uses: Matplotlib · Seaborn · Colormaps and Color · Matplotlib Artists · Saving Figures · DataFrame

The data

A DESeq2/edgeR-style result table: gene, baseMean, log2FoldChange, pvalue, padj.

import numpy as np, pandas as pd, matplotlib.pyplot as plt, seaborn as sns

de = pd.read_csv("deseq2_results.tsv", sep="\t")

de = de.assign(
    neglog10p=lambda d: -np.log10(d["padj"].clip(lower=1e-300)),
    sig=lambda d: (d["padj"] < 0.05) & (d["log2FoldChange"].abs() > 1),
    direction=lambda d: np.select(
        [(d["padj"] < 0.05) & (d["log2FoldChange"] > 1),
         (d["padj"] < 0.05) & (d["log2FoldChange"] < -1)],
        ["up", "down"], default="ns"),
)

Three details in those four lines:

  • .clip(lower=1e-300): -log10(0) is inf, which breaks the axis limits and silently drops points. Very small p-values do underflow to 0 in float64.
  • padj, not pvalue: plotting raw p-values on a 20,000-gene test is not a defensible figure.
  • DESeq2 sets padj to NA for genes it filtered out. Do not fill those with 1: that makes untested genes look tested-and-not-significant. Drop them and say how many.
n_na = de["padj"].isna().sum()
de = de.dropna(subset=["padj"])
print(f"{n_na:,} genes excluded by independent filtering")

The plot

sns.set_theme(style="ticks", context="paper")
import matplotlib as mpl
mpl.rcParams.update({"pdf.fonttype": 42, "savefig.bbox": "tight"})

PALETTE = {"ns": "#CCCCCC", "up": "#C44E52", "down": "#4C72B0"}

fig, ax = plt.subplots(figsize=(4.5, 4))

for cat in ["ns", "down", "up"]:            # grey FIRST so hits draw on top
    sub = de[de["direction"] == cat]
    ax.scatter(sub["log2FoldChange"], sub["neglog10p"],
               s=6, c=PALETTE[cat], alpha=0.6 if cat == "ns" else 0.9,
               edgecolors="none", rasterized=True, label=f"{cat} ({len(sub):,})")

ax.axhline(-np.log10(0.05), ls="--", lw=0.8, c="grey", zorder=0)
ax.axvline(-1, ls="--", lw=0.8, c="grey", zorder=0)
ax.axvline(1, ls="--", lw=0.8, c="grey", zorder=0)

ax.set(xlabel=r"log$_2$ fold change",
       ylabel=r"$-$log$_{10}$ adjusted $p$")
ax.spines[["top", "right"]].set_visible(False)
ax.legend(frameon=False, fontsize=7, markerscale=2, loc="upper left")

rasterized=True is not optional. A vector PDF with 20,000 individually-stored scatter points is enormous and crashes Illustrator; rasterizing that one Artist keeps the text and axes as vectors. See Saving Figures.

Draw order matters. Plotting "ns" first means the significant points land on top rather than being buried under grey.

Labelling the top genes

top = pd.concat([
    de.query("direction == 'up'").nlargest(8, "neglog10p"),
    de.query("direction == 'down'").nlargest(8, "neglog10p"),
])

try:
    from adjustText import adjust_text
    texts = [ax.text(r.log2FoldChange, r.neglog10p, r.gene, fontsize=6)
             for r in top.itertuples()]
    adjust_text(texts, ax=ax,
                arrowprops=dict(arrowstyle="-", lw=0.4, color="grey"))
except ImportError:
    for r in top.itertuples():
        ax.annotate(r.gene, (r.log2FoldChange, r.neglog10p), fontsize=6,
                    xytext=(3, 3), textcoords="offset points")

adjustText repels overlapping labels iteratively. Without it, your 16 labels become an unreadable pile in exactly the region you most want to read.

Label ~10–20 genes, not 100. The point of labelling is to direct attention; labelling everything directs it nowhere.

Capping the axes honestly

XCAP, YCAP = 6, 50
de["x_plot"] = de["log2FoldChange"].clip(-XCAP, XCAP)
de["y_plot"] = de["neglog10p"].clip(upper=YCAP)
de["capped"] = (de["log2FoldChange"].abs() > XCAP) | (de["neglog10p"] > YCAP)

# draw capped points as triangles so it is visible
capped = de[de["capped"]]
ax.scatter(capped["x_plot"], capped["y_plot"], marker="^", s=20,
           c=[PALETTE[d] for d in capped["direction"]], edgecolors="black", lw=0.3)

A few extreme genes otherwise compress everything else into a horizontal smear. Capping is legitimate; capping silently is not. Use a distinct marker and state it in the caption.

Saving

fig.savefig("volcano.pdf", dpi=300, bbox_inches="tight")
fig.savefig("volcano.png", dpi=300, bbox_inches="tight")
plt.close(fig)

Both formats every time: PDF for the manuscript, PNG for the slide.

A reusable function

[ ]python
out1ms

This code completed without producing output.

The ax=None pattern is what makes it composable into a multi-panel figure:

fig, axes = plt.subplots(1, 3, figsize=(13, 4), sharey=True, layout="constrained")
for ax, (name, sub) in zip(axes, contrasts.items()):
    volcano(sub, ax=ax, title=name)

See pyplot vs Object-Oriented API.

The companion plots

A volcano alone is not enough for a methods-complete figure.

[2]python
out1ms
<Axes: title={'center': 'Volcano'}, xlabel='log$_2$ fold change', ylabel='$-$log$_{10}$ adj. $p$'>
Figure 1

The p-value histogram is the most diagnostic plot in DE analysis and the most often omitted. Expected shape: uniform, with a spike near 0 for the true positives.

  • Uniform with a spike at 0: healthy.
  • Uniform with no spike: no signal.
  • U-shaped or a spike near 1: model misspecification, usually unmodelled covariates or variance overestimation.
  • Bumpy / multimodal: often a batch effect or a discreteness artefact.

Look at this before you look at the volcano. A beautiful volcano built on a pathological p-value distribution is a beautiful wrong figure.

Common mistakes

  • -log10(0) = inf. Clip first.
  • Raw p-values instead of adjusted.
  • Filling DESeq2 NA padj with 1.
  • Not rasterizing, producing a 200 MB PDF.
  • Labelling too many genes.
  • Silent axis capping.
  • Significant points buried under grey. Draw order.
  • Only fold-change thresholds, no significance (or vice versa).
  • Skipping the p-value histogram.
  • Red/green colouring: the worst possible choice for colourblind readers. See Colormaps and Color.

See also

Common Plot Types · Matplotlib Artists · Colormaps and Color · Saving Figures · Applied - Expression Matrices with NumPy · Applied - Heatmaps and Clustermaps · Statistical Estimation in seaborn

Exercise

seaborn is not installed in this sandbox, so build the volcano with matplotlib alone, and assert on the numbers the Axes carries rather than on pixels. Use the Agg backend and never call show.

Write volcano(text), returning a list of output lines. The first input line holds three whitespace-separated values: the absolute log2 fold-change threshold, the adjusted p-value threshold, and the number of genes to label. The rest is a tab-separated table with the header gene, baseMean, log2FoldChange, pvalue, padj; a padj of NA means the gene was filtered by independent filtering and must be dropped before anything else. Classify each remaining gene as up when the fold change is at or above the threshold and padj is below the p threshold, down when it is at or below the negative threshold and equally significant, and ns otherwise. Plot each class as its own scatter, set the x limits symmetrically to plus and minus round(max(abs(log2FoldChange)) + 0.5, 2), and label the most significant genes, ranked by padj ascending then absolute fold change descending then gene name ascending.

Print plotted=<n> up=<n> down=<n> ns=<n>, then xlim=<low>,<high> reading the limits back from the Axes and rounding each to two decimals, then ymax=<v> for the largest plotted value of -log10(padj) rounded to two decimals, then labels=<g1,g2,...> in the ranked order, or labels=none when no gene is labelled. Clip padj at 1e-300 before taking the logarithm so a zero cannot produce infinity. If every gene was dropped, print the first line with all counts zero and then no genes to plot, and nothing else.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdin1.0 0.05 3 gene baseMean log2FoldChange pvalue padj MYC 534.2 2.61 1.2e-16 3.4e-14 TP53 1766.5 0.98 8.9e-06 4.1e-04 KRAS 1310.8 0.94 1.7e-04 5.2e-03 EGFR 2247.0 -1.02 6.3e-08 9.8e-06 BRCA1 457.0 -0.04 0.88 0.94 GAPDH 15260.0 0.01 0.95 0.97 plotted=6 up=1 down=1 ns=4 xlim=-3.11,3.11 ymax=13.47 labels=MYC,EGFR,TP53
2stdin0.5 0.01 2 gene baseMean log2FoldChange pvalue padj A 10.0 3.00 1e-30 0.0 B 20.0 -3.00 1e-30 1e-25 C 30.0 0.10 0.5 NA D 40.0 0.60 0.02 0.02 plotted=3 up=1 down=1 ns=1 xlim=-3.5,3.5 ymax=300.0 labels=A,B
3stdin1.0 0.05 5 gene baseMean log2FoldChange pvalue padj X 1.0 0.10 0.9 NA Y 2.0 -0.20 0.8 NA plotted=0 up=0 down=0 ns=0 no genes to plot
4stdin2.0 0.05 1 gene baseMean log2FoldChange pvalue padj ONLY 5.0 -0.30 0.4 0.60 plotted=1 up=0 down=0 ns=1 xlim=-0.8,0.8 ymax=0.22 labels=ONLY

Hints · 3

01Hint

matplotlib.use("Agg") must come before importing pyplot.

02Hint

Read the limits back with ax.get_xlim() so the assertion tests the Axes, not your variable.

03Hint

sorted(genes, key=lambda g: (g.padj, -abs(g.lfc), g.name)) gives the labelling order in one step.