Colormaps and Color
Colormaps and Color
Core Principle: Colormap selection must reflect data topology (sequential, diverging, or categorical), ensuring perceptual uniformity and accessibility across grayscale printing and color-vision deficiencies.
The three colormap types
| Type | Use for | Examples |
|---|---|---|
| Sequential | ordered data from a baseline: counts, coverage, expression, density | viridis, magma, Blues, rocket |
| Diverging | signed data with a meaningful midpoint: log fold change, correlation, z-score | vlag, RdBu_r, coolwarm, icefire |
| Qualitative | unordered categories: sample groups, chromosomes, cell types | tab10, Set2, colorblind |
Using the wrong type is a real error, not a style preference. A sequential colormap on a log fold change puts its midpoint at an arbitrary value; a qualitative palette on ordered data destroys the ordering.
Always center diverging maps
sns.heatmap(logfc_matrix, cmap="vlag", center=0)
sns.heatmap(corr, cmap="vlag", center=0, vmin=-1, vmax=1)
from matplotlib.colors import TwoSlopeNorm
ax.imshow(m, cmap="RdBu_r", norm=TwoSlopeNorm(vcenter=0, vmin=-3, vmax=5))
Without center=0, if your data runs −1 to +5 the colormap's white point lands at +2 and every genuinely unchanged gene is drawn as up-regulated. This is one of the most common quietly-misleading things in genomics figures.
Why not jet / rainbow
The jet colormap has three documented problems:
- Perceptually non-uniform. Equal steps in data are not equal steps in apparent colour. Its sharp cyan/yellow bands create visual edges where the data is smooth: inventing boundaries that do not exist.
- Not monotonic in lightness. Printed in greyscale it becomes unreadable: the yellow and cyan map to the same grey.
- Bad for colour vision deficiency. Roughly 8% of men with northern European ancestry have red-green CVD; jet's red and green extremes are its most important values.
viridis was designed to fix all three: perceptually uniform, monotonically increasing in lightness, and CVD-safe. It is the default for a reason.
Colourblind-safe choices
sns.set_palette("colorblind")
sns.color_palette("colorblind") # 10 distinguishable colours
# sequential and diverging maps that are already safe
"viridis", "cividis", "magma", "rocket" # sequential
"vlag", "icefire", "RdBu_r" # diverging
cividis is specifically designed to appear near-identical to viewers with and without CVD.
Never encode information in colour alone. Redundantly encode with marker shape, line style, direct labels, or position:
sns.scatterplot(data=df, x="pc1", y="pc2", hue="batch", style="condition")
ax.plot(x, y1, c="C0", ls="-", marker="o", label="control")
ax.plot(x, y2, c="C1", ls="--", marker="s", label="treated")
Check your figure: convert it to greyscale, or use a CVD simulator (daltonlens, or the Coblis web tool). If two series become indistinguishable, fix it before submitting.
Specifying colours
This code completed without producing output.
Using "C0", "C1", ... rather than literal colours means your plots automatically follow whatever palette you set with sns.set_theme(). Change the theme once, every figure updates.
Palettes
sns.color_palette() # current default
sns.color_palette("colorblind", 8)
sns.color_palette("Set2", 5)
sns.color_palette("viridis", as_cmap=True)
sns.cubehelix_palette(8, start=.5, rot=-.75) # sequential, greyscale-safe
sns.diverging_palette(240, 10, as_cmap=True)
sns.light_palette("seagreen", as_cmap=True)
sns.set_palette("colorblind") # set globally
Explicit category → colour mapping is the habit that keeps a multi-panel figure coherent:
CONDITION_COLORS = {"control": "#4C72B0", "treated": "#DD8452", "drug": "#55A868"}
sns.scatterplot(data=df, x="a", y="b", hue="condition", palette=CONDITION_COLORS)
sns.boxplot(data=long, x="condition", y="expr", palette=CONDITION_COLORS)
Without this, seaborn assigns colours in the order categories appear: so "treated" can be blue in panel A and orange in panel B. Define the dict once at the top of your script.
Normalization
from matplotlib.colors import LogNorm, TwoSlopeNorm, BoundaryNorm
ax.imshow(counts, norm=LogNorm(vmin=1, vmax=1e4), cmap="viridis")
ax.imshow(logfc, norm=TwoSlopeNorm(vcenter=0), cmap="RdBu_r")
ax.imshow(m, norm=BoundaryNorm([0, 1, 5, 10, 50], ncolors=256), cmap="viridis")
ax.imshow(m, vmin=0, vmax=10) # clip the range
sns.heatmap(m, robust=True) # use 2nd/98th percentiles
LogNorm for count data. Expression and coverage span orders of magnitude; on a linear colour scale one outlier makes everything else uniformly dark.
robust=True in seaborn clips to the 2nd–98th percentile, which stops a single extreme value from consuming the entire colour range. Extremely useful, and worth disclosing in the caption.
Colorbars
im = ax.imshow(m, cmap="viridis")
cb = fig.colorbar(im, ax=ax, label="log$_2$ CPM", shrink=0.8)
cb.ax.tick_params(labelsize=8)
cb.set_ticks([0, 5, 10])
Always label the colorbar. An unlabelled colour scale is an unlabelled axis.
Transparency for overplotting
ax.scatter(x, y, alpha=0.1, s=2, rasterized=True)
Alpha stops helping past roughly 50,000 points: everything saturates to solid. At that scale use hexbin or a 2-D histogram. See Common Plot Types.
Bioinformatics conventions
# expression heatmaps: z-scored per gene, diverging, centred
sns.clustermap(mat, z_score=0, cmap="vlag", center=0)
# raw expression: sequential, log-normed
sns.heatmap(cpm, cmap="rocket", norm=LogNorm())
# correlation: diverging, full [-1, 1]
sns.heatmap(corr, cmap="vlag", center=0, vmin=-1, vmax=1, square=True)
# volcano: grey background, one accent for hits
colors = np.where(sig, "crimson", "lightgrey")
# Manhattan: two alternating neutral colours by chromosome
alt = ["#4C72B0", "#A9BCD8"]
# nucleotides: a widely used convention
BASE_COLORS = {"A": "#3DA853", "C": "#4285F4", "G": "#F9AB00", "T": "#EA4335"}
Grey for the bulk, colour for the point. A volcano plot where all 20,000 genes are coloured is 20,000 things competing for attention. Grey out the non-significant ones and the eye goes exactly where you want it.
Common mistakes
- jet / rainbow.
- Diverging colormap without
center=. - Sequential colormap on signed data.
- Qualitative palette on ordered data.
- Colour as the only encoding.
- Inconsistent category colours across panels of one figure.
- Unlabelled colorbar.
- Linear colour scale on count data spanning orders of magnitude.
- Too many categories: beyond ~8, colours stop being distinguishable. Group, facet, or label directly.
- Never checking in greyscale.
See also
Common Plot Types · seaborn Themes and Palettes · Applied - Heatmaps and Clustermaps · Axes Styling and Annotation · Saving Figures
Exercise
Write normalise(values), which takes a list of floats read from a single
whitespace-separated line and decides which normalization the data calls for.
Apply the rules in order: if the values straddle zero use
TwoSlopeNorm(vcenter=0.0, vmin=lo, vmax=hi) with a diverging colormap; if
they are all strictly positive and hi / lo is at least 100 use
LogNorm(vmin=lo, vmax=hi); otherwise use Normalize(vmin=lo, vmax=hi).
Attach the chosen norm and colormap to an image with ax.imshow([values], ...)
and read every value's position back through im.norm, so the reported numbers
come from the artist rather than from arithmetic you repeated by hand.
Return a list of lines: the normalised position of each value in input order,
formatted to three decimal places and space separated; the rule name, one of
twoslope, log, linear or degenerate; and above=<n>, the count of
positions strictly greater than 0.5. When every value is identical no norm is
well defined, so return degenerate with every position reported as 0.500
and above=0. When the input is empty return the single line none.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 2.61 0.98 0.94 -1.02 -0.04 0.01 | 1.000 0.688 0.680 0.000 0.480 0.502 twoslope above=4 |
| 2 | stdin | 1.0 19.0 120.0 143.0 88.0 15260.0 | 0.000 0.306 0.497 0.515 0.465 1.000 log above=2 |
| 3 | stdin | 1.2 1.5 0.9 2.0 | 0.273 0.545 0.000 1.000 linear above=2 |
| 4 | stdin | 3.5 3.5 3.5 | 0.500 0.500 0.500 degenerate above=0 |
Hints · 3
01Hint
`TwoSlopeNorm` requires vmin < vcenter < vmax strictly, so test `lo < 0 < hi` before constructing it.
02Hint
`LogNorm` is undefined at or below zero — guard the branch with `lo > 0`.
03Hint
A norm called on a single scalar returns a scalar; wrap it in `float()` before rounding.