Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Quick start: differential expression in bulk RNA-seq

This page runs a limma-voom analysis of simulated bulk RNA-seq counts from start to finish: filtering genes by expression, TMM normalization factors, voom precision weights, a design with named contrasts, a linear model for every gene, contrasts, empirical Bayes moderation and adjusted p-values. Each step matches the R function it mirrors, as the Validation page shows; the one deliberate difference here, exact standard errors for contrasts, is explained where it arises. Because the counts are simulated, we know which genes truly changed, and the last step checks the results against that truth.

The analysis runs in a few seconds. The first run in a new environment also compiles CytePool’s numerical routines, which takes longer once.

numpy 2.5.4 and numba 0.68.0

Simulate an experiment

Twelve samples: three treatments (control, drug A and drug B) with four replicates each, sequenced in two batches that each hold two replicates of every treatment. Data matrices in CytePool, such as counts and log expression values, are samples x features, so the counts have one row per sample and one column per gene. Results of the fits have one row per gene, as in limma.

The 6,000 genes have log-normal baseline abundances, so a minority of genes takes most of the reads and many genes have only a few. Drug A changes 600 genes, up or down, by 0.6 to 2 on the log2 scale. Drug B shares 400 of those changes and makes 300 of its own, mostly upwards; its extra reads go to those genes, so every other gene receives a smaller share of drug B’s libraries. Batch b2 shifts every gene by a small random amount. Counts are negative binomial: gamma-distributed biological variation with a coefficient of variation near 0.2, plus Poisson sampling. The libraries hold 2 to 5 million reads.

12 samples x 6,000 genes
library sizes (millions): [3.41 4.67 2.12 4.02 2.32 4.25 4.55 2.79 4.45 4.99 2.22 3.11]

Describe the design

A Design holds the sample table, the fixed effects as an R formula, and the comparisons to test. The formula ~ 0 + treatment + batch gives one column per treatment, its mean log expression in batch b1, and one column for the difference that batch b2 makes. Column names follow R’s model.matrix. Named contrasts are linear combinations of the columns, written as in limma’s makeContrasts.

Design.check looks for problems that silently ruin an analysis before anything is fitted: aliased columns, confounded factors, levels with a single sample, contrasts that cannot be estimated and more.

Design: 12 samples x 4 columns, formula '~ 0 + treatment + batch'
  columns: treatmentcontrol, treatmentdrug_a, treatmentdrug_b, batchb2
  contrasts: drug_a_vs_control, drug_b_vs_control, drug_b_vs_drug_a
Design check: 12 samples, 4 columns, rank 4, 8 residual degrees of freedom; no problems found.

Filter out genes with too few counts

Genes with only a few reads carry almost no information about differences between treatments, and they distort the mean-variance trend that voom fits. filter_by_expression applies edgeR’s filterByExpr rule: a gene is kept when it reaches 10 reads at the median library size (proportionally more in larger libraries) in at least n samples, and 15 reads in total. Given groups, n is the size of the smallest group. Given a design, it is the inverse of the design’s largest leverage: 3 here, a little less than the four replicates of each treatment, because the batch effect is estimated from the same samples.

kept 5,294 of 6,000 genes

Normalization factors

Library sizes account for sequencing depth, but not for composition: drug B’s upregulated genes take a larger share of its reads, so every other gene looks a little lower in drug B. tmm_factors computes edgeR’s trimmed mean of M values (TMM) factors, which scale the library sizes so that most genes do not change between samples. Here they are computed from the genes that were kept. The factors of the drug B samples are the smallest.

control  b1  1.049
control  b1  1.047
control  b2  1.023
control  b2  1.028
drug_a   b1  1.008
drug_a   b1  1.018
drug_a   b2  1.012
drug_a   b2  0.997
drug_b   b1  0.967
drug_b   b1  0.962
drug_b   b2  0.953
drug_b   b2  0.944

Precision weights with voom

voom turns the counts into log2 counts per million, using the library sizes times the normalization factors, and estimates how their variance depends on the expression level: it fits the design to every gene, smooths the square root of the residual standard deviations against the mean log count by LOWESS, and gives each observation the inverse of its predicted variance as its weight. Low counts are noisier on the log scale, so the trend decreases. Each local fit of the LOWESS uses a fraction of the genes, the span, which voom chooses from the number of genes as limma does and records in its result.

VoomResult(n_samples=12, n_features=5294, features_in_trend=5294, span=0.448)
Scatter plot of the square root of each gene's residual standard deviation against its mean log2 count, from about 2 to 16. A red LOWESS curve falls from about 0.85 at the lowest counts and levels off near 0.53 above a mean log2 count of about 10.

Fit a linear model to every gene

fit_linear_model regresses the log2 counts per million of each gene on the design by weighted least squares, with the voom weights, as limma’s lmFit does. Its coefficients are named after the design’s columns.

('treatmentcontrol', 'treatmentdrug_a', 'treatmentdrug_b', 'batchb2')
residual degrees of freedom: 8.0

Contrasts

contrasts_fit re-expresses every gene’s fit in terms of the named contrasts, as limma’s contrasts.fit does: the coefficients become log2 fold changes. By default, the standard error of each contrast comes from the gene’s own covariance matrix of the coefficients, which is exact. limma instead combines the gene’s standard errors with the correlations of the coefficients in the design without weights. The two agree when one factorisation of the design serves every gene (no missing values, and no weights or only sample weights) and for contrasts of uncorrelated coefficients. Here voom’s weights differ between genes and the batch column correlates the treatment coefficients, so limma’s standard errors are approximate; method="limma" reproduces them.

('drug_a_vs_control', 'drug_b_vs_control', 'drug_b_vs_drug_a')

Empirical Bayes moderation

With eight residual degrees of freedom, each gene’s variance is poorly estimated on its own. moderated_statistics estimates a prior distribution of the variances from all genes and replaces each gene’s variance by a weighted average of that variance and the prior variance, with their degrees of freedom as weights (limma’s eBayes and its posterior variances). The moderated t-statistics have the residual and the prior degrees of freedom combined.

prior degrees of freedom: 13.46
prior variance: 0.9854

Adjusted p-values and top genes

adjust_p_values with the Benjamini-Hochberg method controls the false discovery rate. axis=0 adjusts each contrast (each column) as its own family of tests, as limma’s topTable does for one coefficient.

drug_a_vs_control: 462 genes at 5% FDR, 256 up and 206 down
drug_b_vs_control: 555 genes at 5% FDR, 380 up and 175 down
drug_b_vs_drug_a: 386 genes at 5% FDR, 258 up and 128 down

The genes with the smallest p-values for drug A against control, with the columns of limma’s topTable: the log2 fold change, the mean log2 CPM, the moderated t-statistic, its p-value, the adjusted p-value and the B-statistic (the log-odds that the gene changed). This is the order of topTable(sort.by = "P"); by default topTable sorts by B, which can order genes with different weights slightly differently.

gene           logFC   AveExpr         t   P.Value adj.P.Val         B
gene_3774       2.68      6.96        13  1.28e-11  6.77e-08      16.7
gene_1529       2.11      6.43      12.2  3.85e-11  1.02e-07      15.6
gene_0118       1.99      11.4      11.9  6.47e-11  1.14e-07      15.2
gene_0744       2.25      9.19      11.5  1.18e-10  1.56e-07      14.6
gene_1858       2.26      5.98      11.2  1.92e-10  1.62e-07      14.1
gene_4276       1.93      10.2      11.1  2.29e-10  1.62e-07      13.9
gene_5034      -2.29       5.9     -11.1  2.31e-10  1.62e-07      13.9
gene_2462       1.77      10.7        11  2.56e-10  1.62e-07      13.8
gene_4195       1.97      8.52        11  2.75e-10  1.62e-07      13.7
gene_4928       2.26      4.92      10.7  4.57e-10  2.31e-07      13.2

Compare with the truth

The simulation knows which genes changed in each comparison. At a 5% false discovery rate, the share of discoveries that are false should be about 5% or less, and most of the genes that changed should be found.

drug_a_vs_control: found 441 of 544 changed genes; 21 of 462 discoveries are false (4.5%)
drug_b_vs_control: found 535 of 636 changed genes; 20 of 555 discoveries are false (3.6%)
drug_b_vs_drug_a: found 372 of 458 changed genes; 14 of 386 discoveries are false (3.6%)

The same analysis in R

Each step corresponds to an edgeR or limma function. R stores genes in rows, so its count matrix is the transpose of the one used here. Library sizes are the totals of the genes that were kept, in both.

StepCytePoolR
Filter geneskeep = filter_by_expression(counts, design=design), then counts[:, keep]keep <- filterByExpr(y, design), then y[keep, , keep.lib.sizes = FALSE]
Normalization factorstmm_factors(counts)calcNormFactors(y)
Design and contrastsDesign(samples, "~ 0 + treatment + batch", contrasts=...)model.matrix(~ 0 + treatment + batch) and makeContrasts(...)
Precision weightsvoom(counts, design, norm_factors=factors)voom(y, design) in limma 3.66 or later, voom(y, design, adaptive.span = TRUE) in 3.60 to 3.65
Linear modelsfit_linear_model(v.log_cpm, design, weights=v.weights)lmFit(v, design)
Contrastscontrasts_fit(fit, design.contrasts, method="limma")contrasts.fit(fit, contrasts)
Moderationmoderated_statistics(fit)eBayes(fit)
Adjusted p-valuesadjust_p_values(p, "bh", axis=0)topTable(fit, coef = j, sort.by = "P"), which adjusts by BH

moderated_statistics estimates the prior as eBayes does by default: by limma’s moment estimator when all genes have the same residual degrees of freedom, as here, and by its likelihood estimator when they differ. The API reference documents every function, and the Validation page shows how closely each one agrees with R.