Before you start
- You have a count matrix in mind; see From Reads to a Count Matrix.
- Any term new? The Glossary has it.
Learning objectives
By the end of this lesson you will be able to: explain why raw counts cannot be compared directly, say what CPM, FPKM, and TPM each correct for, explain why TPM is preferred over FPKM, and state the rule that DESeq2 and edgeR must receive raw integer counts, not normalized values.
Why raw counts are not comparable
A raw count is simply the number of reads that mapped to a gene. Two things make raw counts unfair to compare. First, library size (sequencing depth): if sample A was sequenced twice as deeply as sample B, every gene in A will tend to show roughly twice the counts, even when nothing biological changed. Second, gene length: a long gene is a bigger target, so it collects more reads than a short gene at the same true expression level. Because of these two effects, you cannot read a count of 800 in one place and 400 in another and conclude that the first is twice as active.
Two confoundersDepth and length
Depth (library size) makes whole samples scale up or down together. Gene length makes long genes look more expressed than short ones within a sample. Each normalization method removes one or both of these.
1What each method corrects for
CPM (counts per million) divides each count by the library size and multiplies by a million. It corrects for depth only, so it makes samples comparable but still treats a long gene as if it were more expressed than a short one. RPKM and FPKM (the F is for fragments, used with paired-end reads) correct for depth and gene length. TPM (transcripts per million) also corrects for both, but in an order that gives it a useful property: within every sample, all TPM values add up to the same total (one million). That fixed total is what makes TPM easier to compare across samples than FPKM, whose per-sample totals can drift.
| Method | Corrects depth? | Corrects gene length? | Best used for |
|---|---|---|---|
| CPM | Yes | No | Quick sample-to-sample comparison and simple filtering of low-count genes |
| FPKM / RPKM | Yes | Yes | Older within-sample reports; totals can differ between samples, so use with care |
| TPM | Yes | Yes | Within-sample comparison and visualization; every sample sums to the same total |
| DESeq2 median-of-ratios (or edgeR TMM) | Yes | No (length cancels in the test) | Differential expression; computed internally from raw counts, not something you precompute |
2Why TPM beats FPKM across samples
FPKM divides by length first and by a depth factor second, and the result is that the per-sample total of all FPKM values is not fixed: it can come out different for each sample. When you then compare a gene's FPKM between two samples, you are comparing fractions of two different totals, which is subtly misleading. TPM is built so the total is always one million in every sample, so a TPM value is a true proportion of that sample's transcript pool. That shared denominator is the reason TPM is the preferred unit for comparing the same gene across samples and for heatmaps and other visuals.
3The rule that keeps DESeq2 happy
This is the part beginners get wrong most often. For differential expression, DESeq2 and edgeR do their own normalization internally, the median-of-ratios method in DESeq2 and TMM in edgeR, and they must be given raw integer counts. Never feed them CPM, FPKM, or TPM. Those values are already scaled and are no longer counts, and the statistical model that DESeq2 uses assumes count-like data with a specific mean to variance relationship. Use TPM (or CPM) for within-sample comparison, plotting, and reporting; hand DESeq2 the raw count matrix and let it normalize for you.
One-line summary
CPM corrects depth; FPKM and TPM correct depth and length; TPM is preferred across samples because every sample sums to the same total. For differential expression, give DESeq2 or edgeR raw counts and let them normalize internally.
A worked example
CPM is the simplest formula. For gene g in a sample, CPM = (count for g / total mapped reads in that sample) × 1,000,000. The example below shows that calculation in R, then shows the contrasting rule: DESeq2 receives the raw count matrix, never a normalized one.
# --- CPM by hand (within-sample / visualization use) ---
# counts: a gene-by-sample matrix of RAW integer counts
counts <- matrix(c(800, 400, 1500,
100, 50, 300),
nrow = 2, byrow = TRUE,
dimnames = list(c("geneA", "geneB"),
c("s1", "s2", "s3")))
lib_size <- colSums(counts) # total mapped reads per sample
cpm <- t(t(counts) / lib_size) * 1e6 # divide each column by its depth, scale to per-million
round(cpm, 1)
# edgeR has a built-in equivalent:
# library(edgeR); cpm(counts)
# --- The rule for differential expression ---
# DESeq2 takes the RAW count matrix and normalizes internally
# (median-of-ratios). Do NOT pass it CPM / FPKM / TPM.
library(DESeq2)
dds <- DESeqDataSetFromMatrix(countData = counts_raw, # raw integers
colData = sample_info,
design = ~ cell + dex) # dex = treatment column
dds <- DESeq(dds) # estimates size factors + dispersions internally
res <- results(dds)