On RNAseq normalization

R
RNA-seq
TCGA
Bioconductor
normalization
Downloading TCGA esophageal carcinoma counts, using a density heatmap to show the samples are not comparable, and then watching what CPM, upper quartile, TMM, median of ratios and quantile normalization each do to the distributions.
Author

Matthew Montierth

Published

August 29, 2026

Normalization is necessary in transcriptomic analyses because the number of reads assigned to a gene depends on how deeply the sample was sequenced and on what else was in the library, so two samples cannot be compared until those effects are removed. However, there are many different methods, and new students sometimes have a hard time understanding what each method does and when to use them. For this purpose, I wanted to show the outcomes of different normalization procedures, using a visualization I am quite fond of: densityHeatmap from Zuguang Gu’s ComplexHeatmap package.

This uses 198 esophageal carcinoma samples from TCGA: 184 primary tumor, 1 metastatic, and 13 adjacent normal. The analysis script downloads the data and produces the normalization figures; the explainer script produces the figures in the next section.

Getting the data

TCGAbiolinks queries the GDC, downloads the per-sample count files, and assembles them into a SummarizedExperiment:

q <- GDCquery(project = "TCGA-ESCA", data.category = "Transcriptome Profiling",
              data.type = "Gene Expression Quantification",
              workflow.type = "STAR - Counts")
GDCdownload(q)
se <- GDCprepare(q)
cts <- assay(se, "unstranded")

That is 198 files and about 0.8 GB. Restricting to protein-coding genes and dropping the ones with too little signal to inform anything leaves 17,884 genes across 198 samples:

cts  <- cts[rowData(se)$gene_type == "protein_coding", ]
dge  <- DGEList(counts = cts, group = factor(grp))
keep <- filterByExpr(dge, group = factor(grp))
dge  <- dge[keep, , keep.lib.sizes = FALSE]

The filtering step matters for what follows. Genes that are near zero in every sample make each density collapse into a spike at the bottom of the plot, and the differences between samples stop being visible at all.

How to read a density heatmap

ComplexHeatmap::densityHeatmap() is great for visualizing many density curves next to each other, and is my go-to tool when the number of samples exceeds ~20, where something like a ridgeplot is no longer feasible. It draws one column per sample, but I have found that it can take some extra explanation to be able to read these dense figures.

Start with one sample and one ordinary density curve. Below, the area under the curve is filled with the color scale the heatmap itself uses, so a tall part of the curve is red and the low parts are blue:

One sample’s expression values as a density curve, colored by the height of the curve.

Now turn that curve on its side so expression runs up the page, and stretch every horizontal slice out to the same width. The shape is gone and only the color is left. That strip is a single column of the heatmap:

Rotating the curve upright, then flattening it into a fixed-width strip.

The height of the curve has become the color of the strip.

It is easier to see why that works in three dimensions. Give each density curve some thickness and it becomes a solid: the profile is the curve, the height is the density, and the top face carries the color. Now rotate the camera overhead. Looking straight down the density axis, every slice of the solid projects to the same footprint however tall it is, so the height stops being visible as shape and survives only as color:

Ten samples as solids, rotated from an oblique view to straight overhead. The final frame is the heatmap.

Standing all 198 of them side by side gives the real figure.

Here is the sample from the first two figures, outlined in the finished plot:

The same sample, boxed, in the full heatmap.

The dashed lines are the quartiles of each column, so they trace the 25th, 50th and 75th percentile across samples.

Seeing that it needs normalizing

densityHeatmap(log2(cts + 1), ylim = c(-2, 16), ylab = "log2 expression")

Raw counts. The quartile lines wander across samples, and the library size bars run along the top.

Looking at this plot, there are some clear differences in the scales of some of the samples. The median runs across a range of 2.86 on the log2 scale, a factor of 7.2 between the lowest and highest sample.

The library sizes at the top span 24.5M to 124.4M reads, a 5.1-fold range, so one obvious explanation is sequencing depth.

CPM normalization

Counts per million divides each sample by its own total and multiplies by a million.

Counts per million. The quartile lines are more even, but not flat.

Depth really is most of the problem in this cohort. Plotting each sample’s median against its library size shows it directly, and shows what CPM does about it:

Per-sample median against library size, before and after CPM. Both panels use the same vertical span, so the scatter is comparable.

The raw medians track sequencing depth closely, at a correlation of +0.79, which accounts for about 62% of the variance between them. CPM removes that relationship completely: the correlation drops to +0.00 and the fitted line goes flat.

It does not finish the job, though. The spread of medians falls from 2.86 to 1.35, so 47% of the original spread is still there after the depth effect is gone. That residual is composition rather than depth.

The reason a library total is an imperfect divisor is that it is not evenly sourced. In these samples the top 100 genes account for between 16% and 60% of all reads, with a median of 24%:

Cumulative share of each library held by its most-expressed genes, one line per sample.

A library total is dominated by a handful of very highly expressed genes, so dividing by it means dividing by a number that mostly reflects those genes. If one sample happens to be dominated by a few transcripts, its total is inflated and every other gene in it gets pushed down.

Scaling on more robust statistics

Upper quartile, TMM, and median of ratios all compute one scaling factor per sample, like CPM does. They differ in what they compute it from. Instead of the total, each uses a statistic that ignores the extremes.

Upper quartile scales each sample by its own 75th percentile.

Upper quartile. The 75% line is flat by construction; the others are not.

The quantile lines on this plot make the difference very easy to see. The 75% line is perfectly flat. The 25%, 50%, and 100% lines still wander, there is still variation within samples, but the upper quartile has been fixed.

TMM takes a trimmed mean of log ratios against a reference sample, discarding the most extreme genes by expression and by fold change before averaging.

TMM.

Median of ratios This it the default used within DESeq2. It divides each gene by its geometric mean across samples and takes the median of those ratios per sample.

Median of ratios.

Both flatten the middle of the distribution rather than one quantile of it, and they land in nearly the same place. The spread of medians falls to 0.51 for TMM and 0.49 for median of ratios, against 0.69 for upper quartile, so roughly 17 to 24% of what it was in the raw data.

Quantile normalization

Quantile normalization is extreme, and is almost never the right tool. It forces all quantiles to be the same, flattening out most of the data.

Quantile normalization. Every column is the same by construction.

Every other method here leaves the shape of each sample’s distribution alone and shifts it; quantile normalization overwrites the shape. The interquartile range makes the distinction concrete:

Method Spread of medians Spread of IQRs
Raw counts 2.855 2.844
CPM 1.353 2.831
Upper quartile 0.694 2.832
TMM 0.511 2.841
Median of ratios 0.493 2.837
Quantile 0.003 0.031

Every scaling method leaves the IQR spread at about 2.84, because multiplying a sample by a constant shifts its log distribution without changing its width. Only quantile normalization changes that column, and it drives it to zero.

If the samples genuinely differ in spread for a biological reason, quantile normalization removes that difference along with the technical one, and nothing downstream can recover it.

Per-sample medians under each method.

What to use

  • CPM is fine for exploratory looks and for filtering thresholds. It corrects sequencing depth, which was the largest single effect here, and leaves composition untouched.
  • TMM (edgeR) and median of ratios (DESeq2) are the defaults for differential expression for good reason. They handle composition, they agree closely, and they leave each sample’s shape intact. Use whichever matches your testing framework.
  • Upper quartile is a reasonable middle ground and is what TCGA’s own fpkm_uq_unstrand assay uses, and is also used in transcriptomic deconvolution methods like DeMixT, since it preserves variation in medians while recuing the effect of outliers.
  • Quantile normalization is standard on microarrays and is used for RNA-seq when distributions must be identical. It is the strongest assumption on this list.

Regardless of what method you use, having the tools to visualize and see how your data looks before and after normalization help to guide these decisions.

Counts are from the GDC, TCGA-ESCA, STAR - Counts workflow. Figures use ComplexHeatmap::densityHeatmap.