RNA-seq counts, contrasts and uncertainty

Practical 9: Genomics, systems biology and multi-omics

Learning objectives

Align counts and metadata; distinguish known exposure from estimated normalization; fit a restricted count model and interpret log2 effects, dispersion and multiplicity.

Biological question and data

Does expression differ between the two diets in a hypothetical 48-animal experiment?

The fixture simulates negative-binomial counts for 24 genes, with independent animals, crossed diet/batch and known multiplicative exposure. The first four genes have a supplied diet effect. Gene01 also has a genotype effect. A quasi-Poisson fit below is an intentionally restricted mean-model illustration; its single variance multiplier per gene is not the generating negative-binomial variance function.

No biological download is required. Numerical inputs are hypothetical, not observed animal data. The runnable examples use base R. Count and abundance units are specified where used.

1. Load and align the count panel

Count rows are genes and columns are animals. Metadata order is verified before modelling.

Show R code
helper_dir <- if (dir.exists("R")) "R" else "../R"
stopifnot(file.exists(file.path(helper_dir,"teaching_data.R")))
source(file.path(helper_dir,"teaching_data.R"))
d <- teaching_data()
meta <- d$metadata; counts <- d$counts
stopifnot(identical(colnames(counts),meta$animal),!anyDuplicated(meta$animal))
meta$exposure <- unname(d$exposure[meta$animal])
stopifnot(all(meta$exposure>0))
counts[1:6,1:8]
       animal01 animal02 animal03 animal04 animal05 animal06 animal07 animal08
gene01       28      103       53      137       35      101       68      183
gene02       27       71       75      114       28       72       73      131
gene03       42       50       94      128       43       86       64      223
gene04       43      129       75      215       50      110       61      157
gene05       43       85       97       81       34       28       54      171
gene06       43       62       75       94       58       71       73      120
Show R code
xtabs(~diet+batch,meta)
            batch
diet          A  B
  control    12 12
  supplement 12 12

Crossed treatment and batch supply separate comparisons. These 24-feature totals are an illustrative panel, not a whole-transcriptome library.

2. Inspect exposure-adjusted values

Divide by the supplied exposure, not by a normalization factor estimated from this panel. Plot on a descriptive log2 scale.

Show R code
normalized <- sweep(counts,2,meta$exposure,"/")
stopifnot(max(abs(sweep(normalized,2,meta$exposure,"*")-counts))<1e-10)
boxplot(log2(normalized[1,]+1)~meta$diet,col=c("#86B7A3","#E5A66B"),
 xlab="Diet",ylab="log2(exposure-adjusted gene01 count + 1)")

Show R code
c(raw_panel_total_range=range(colSums(counts)))
raw_panel_total_range1 raw_panel_total_range2 
                  2969                   8050 

The +1 transformation supports a plot; it is not the response used in the count model. Real RNA-seq normalization must consider composition and cannot assume this known exposure.

3. Fit the mean model and report uncertainty

Fit each gene with diet, batch and a log-exposure offset. The diet coefficient uses the natural-log link; divide it and its standard error by log(2) to obtain log2 units.

Show R code
fit_gene <- function(y) {
 fit <- glm(y~diet+batch+offset(log(exposure)),data=meta,family=quasipoisson())
 tab <- coef(summary(fit))
 c(log2_effect=tab["dietsupplement","Estimate"]/log(2),
 SE_log2=tab["dietsupplement","Std. Error"]/log(2),
 p_value=tab["dietsupplement","Pr(>|t|)"],
 variance_multiplier=summary(fit)$dispersion)
}
result <- as.data.frame(t(apply(counts,1,fit_gene)))
result$BH <- p.adjust(result$p_value,"BH")
model_df <- nrow(meta)-qr(model.matrix(~diet+batch,meta))$rank
result$lower <- result$log2_effect-qt(.975,model_df)*result$SE_log2
result$upper <- result$log2_effect+qt(.975,model_df)*result$SE_log2
result[1:8,]
       log2_effect   SE_log2      p_value variance_multiplier           BH
gene01   1.1008838 0.1655865 3.391387e-08           11.142527 2.713110e-07
gene02   0.8792531 0.1188264 2.614585e-09            5.482723 5.587733e-08
gene03   0.8724419 0.1758801 1.049325e-05           13.658347 6.295950e-05
gene04   0.9903506 0.1369837 4.656444e-09            9.216960 5.587733e-08
gene05   0.1887814 0.1382823 1.789803e-01            8.275257 4.757671e-01
gene06   0.0425007 0.1139077 7.108144e-01            5.580502 7.754339e-01
gene07   0.3140806 0.1316254 2.129221e-02            8.749349 8.516884e-02
gene08  -0.1398069 0.1168759 2.378835e-01            7.893473 4.757671e-01
             lower      upper
gene01  0.76737547 1.43439220
gene02  0.63992439 1.11858172
gene03  0.51820113 1.22668257
gene04  0.71445139 1.26624989
gene05 -0.08973341 0.46729619
gene06 -0.18692109 0.27192248
gene07  0.04897350 0.57918777
gene08 -0.37520712 0.09559325
Show R code
stopifnot(all(is.finite(as.matrix(result))),all(result$BH>=result$p_value-1e-12))

The intervals and p-values are conditional on this restricted working model. Genotype variation for gene01 remains unmodelled here. A fitted association and an approximate p-value are not a causal-mechanism claim.

4. Ask what the design can estimate

Check the model matrix before interpreting a coefficient. In this hypothetical design, both diets occur in each batch. Construct a deliberately confounded alternative and compare its rank.

Show R code
X <- model.matrix(~diet+batch,meta)
confounded <- meta
confounded$batch <- factor(ifelse(confounded$diet=="control","A","B"))
X_bad <- model.matrix(~diet+batch,confounded)
data.frame(design=c("crossed","confounded"),
 columns=c(ncol(X),ncol(X_bad)),rank=c(qr(X)$rank,qr(X_bad)$rank))
      design columns rank
1    crossed       3    3
2 confounded       3    2
Show R code
stopifnot(qr(X)$rank==ncol(X),qr(X_bad)$rank<ncol(X_bad))

The second design cannot separate diet from batch. Do not solve that biological ambiguity by dropping batch and then calling the remaining coefficient a treatment effect. A simpler model can produce a number while the ambiguity persists.

5. Explore samples on a descriptive scale

PCA uses animals as observations and genes as variables. Here we use log2 exposure-adjusted counts with a pseudocount of one and centre each gene without scaling it to unit variance. This is a teaching transformation rather than a fitted variance-stabilizing transformation.

Show R code
log_expression <- log2(normalized+1)
pc <- prcomp(t(log_expression),center=TRUE,scale.=FALSE)
percent <- 100*pc$sdev^2/sum(pc$sdev^2)
diet_colour <- c(control="#267A67",supplement="#B85E32")
batch_shape <- c(A=16,B=17)
plot(pc$x[,1:2],col=diet_colour[as.character(meta$diet)],
 pch=batch_shape[as.character(meta$batch)],
 xlab=sprintf("PC1 (%.1f%%)",percent[1]),
 ylab=sprintf("PC2 (%.1f%%)",percent[2]))
legend("topright",legend=names(diet_colour),col=diet_colour,pch=16,bty="n")
legend("bottomright",legend=paste("Batch",names(batch_shape)),
 pch=batch_shape,bty="n")

Describe what you see before inspecting p-values. Do the diets overlap? Does batch appear related to either axis? Neither clustering nor separation provides a test of the fitted diet contrast. Known exposure adjustment does not remove the simulated batch effect.

6. Inspect a fitted gene

Fit gene01 again and examine its fitted means and Pearson residuals. A residual is a discrepancy between an observation and its fitted expectation. We plot residuals rather than silently excluding an unusual animal.

Show R code
gene1 <- glm(counts["gene01",]~diet+batch+offset(log(exposure)),
             data=meta,family=quasipoisson())
fit_mean <- fitted(gene1)
pearson <- residuals(gene1,type="pearson")
plot(fit_mean,pearson,col=diet_colour[as.character(meta$diet)],
 pch=16,xlab="Fitted expected count",ylab="Pearson residual")
abline(h=0,lty=2)

Show R code
ord <- order(abs(pearson),decreasing=TRUE)
head(data.frame(animal=meta$animal[ord],observed=counts["gene01",ord],
 fitted=fit_mean[ord],residual=pearson[ord]),4)
           animal observed    fitted residual
animal26 animal26      170  90.31419 8.384998
animal36 animal36      263 160.93581 8.045389
animal40 animal40      254 160.93581 7.335949
animal14 animal14      146  90.31419 5.859580
Show R code
stopifnot(df.residual(gene1)==model_df)

Look for systematic changes in residual spread or isolated discrepancies. The quasi-Poisson variance is proportional to the mean, while the generator also contains a squared-mean term. A visually acceptable plot does not establish correct p-value calibration. Gene01 additionally has an omitted genotype effect; the model should be assessed against the biological question rather than only the plot.

For comparison, fit a Poisson model to the same mean structure. Its standard error assumes less biological variation. Both fits use the same observations and mean model, so this comparison isolates the variance assumption.

Show R code
poisson1 <- update(gene1,family=poisson())
se_comparison <- c(Poisson=coef(summary(poisson1))["dietsupplement","Std. Error"],
 Quasi_Poisson=coef(summary(gene1))["dietsupplement","Std. Error"])/log(2)
se_comparison
      Poisson Quasi_Poisson 
   0.04960588    0.16558652 
Show R code
c(estimated_variance_multiplier=summary(gene1)$dispersion)
estimated_variance_multiplier 
                     11.14253 

The ratio of these standard errors is the square root of the estimated quasi-Poisson variance multiplier. It need not exceed one for every conceivable dataset; compare the displayed values for this realization.

7. Report effects alongside uncertainty

Plot all genes, maintaining the feature order. These are approximate pointwise intervals from the restricted working model. They are not simultaneous intervals across all 24 genes.

Show R code
plot(result$log2_effect,seq_len(nrow(result)),pch=16,
 xlim=range(c(result$lower,result$upper)),yaxt="n",
 xlab="Supplement versus control: fitted log2 abundance ratio",ylab="Gene")
segments(result$lower,seq_len(nrow(result)),
         result$upper,seq_len(nrow(result)))
axis(2,at=seq_len(nrow(result)),labels=rownames(result),las=2,cex.axis=.65)
abline(v=0,lty=2,col="grey50")

Show R code
result$abundance_ratio <- 2^result$log2_effect
result$ratio_lower <- 2^result$lower
result$ratio_upper <- 2^result$upper
result[1:4,c("abundance_ratio","ratio_lower","ratio_upper","p_value","BH")]
       abundance_ratio ratio_lower ratio_upper      p_value           BH
gene01        2.144861    1.702170    2.702683 3.391387e-08 2.713110e-07
gene02        1.839423    1.558247    2.171334 2.614585e-09 5.587733e-08
gene03        1.830759    1.432168    2.340282 1.049325e-05 6.295950e-05
gene04        1.986668    1.640859    2.405355 4.656444e-09 5.587733e-08

A negative effect denotes lower fitted abundance under supplement; a positive effect denotes higher abundance. An interval crossing zero on the log2 scale crosses one on the ratio scale. Failure to pass a threshold does not prove identical expression.

For this fixture we know the generating diet effects, which is unusual in real data. Compare estimates with that supplied truth without judging the method from one simulation alone.

Show R code
truth <- c(rep(.65/log(2),4),rep(0,20))
comparison <- data.frame(gene=rownames(result),truth=truth,
 estimate=result$log2_effect,selected=result$BH<.05)
comparison
     gene     truth    estimate selected
1  gene01 0.9377518  1.10088384     TRUE
2  gene02 0.9377518  0.87925305     TRUE
3  gene03 0.9377518  0.87244185     TRUE
4  gene04 0.9377518  0.99035064     TRUE
5  gene05 0.0000000  0.18878139    FALSE
6  gene06 0.0000000  0.04250070    FALSE
7  gene07 0.0000000  0.31408064    FALSE
8  gene08 0.0000000 -0.13980694    FALSE
9  gene09 0.0000000 -0.01719375    FALSE
10 gene10 0.0000000  0.16952084    FALSE
11 gene11 0.0000000  0.12130240    FALSE
12 gene12 0.0000000  0.17920113    FALSE
13 gene13 0.0000000 -0.05916063    FALSE
14 gene14 0.0000000  0.16561606    FALSE
15 gene15 0.0000000  0.05469150    FALSE
16 gene16 0.0000000 -0.12018751    FALSE
17 gene17 0.0000000 -0.07538930    FALSE
18 gene18 0.0000000  0.13099466    FALSE
19 gene19 0.0000000  0.04829025    FALSE
20 gene20 0.0000000 -0.02537889    FALSE
21 gene21 0.0000000 -0.11144276    FALSE
22 gene22 0.0000000 -0.09875196    FALSE
23 gene23 0.0000000  0.22060154    FALSE
24 gene24 0.0000000 -0.42658259     TRUE
Show R code
with(comparison,table(supplied_effect=truth!=0,selected))
               selected
supplied_effect FALSE TRUE
          FALSE    19    1
          TRUE      0    4

Sampling variation, the omitted genotype term and the working variance model all matter. A method-validation study would require repeated simulations spanning relevant designs, with bias, coverage and error rates evaluated explicitly. Counting discoveries in this single panel is an interpretation exercise.

8. Write the analysis record

For your report, state the experimental unit, sample numbers, tested features, supplied exposure, design formula, contrast direction, variance model and multiplicity procedure. Report one effect with its interval and distinguish the fitted tissue-level comparison from a mechanism claim. Record sessionInfo() when reproducing the practical.

A full real-data extension should retain this reporting discipline while replacing the simplified analysis with an appropriate documented RNA-seq workflow. Keep raw-count input, descriptive transformations and normalized displays conceptually separate.

Interpretation and limits

This practical teaches design, exposure, contrasts and uncertainty; it is not a production differential-expression pipeline. The DESeq2 workflow adds composition-aware normalization and a gene-level negative-binomial dispersion framework. Gene filtering, relatedness, batch design and larger-scale validation need explicit decisions for real data.

Exercises

  1. What supplied log2 effect corresponds to .65 on the natural-log scale?
  2. Why is Poisson variance too restrictive for these generated counts?
  3. Change the seed and explain why estimates and adjusted p-values change.
  4. What would a complete treatment–batch confounding problem prevent?
  5. Why might normalized counts still show batch separation in PCA?
  6. Convert the gene01 estimate and interval to abundance ratios and write a result sentence.
  7. Does selecting a gene at BH < .05 establish a 95% probability that its diet effect is real?
  8. Why should an unusual residual trigger inspection of records before exclusion?
  1. .65/log(2), about .94; a finite-sample estimate need not equal it.
  2. The generating variance is mu + mu squared/12, greater than mu.
  3. Sampling realization changes, while the generating parameters remain the same.
  4. Separate estimation of diet and batch effects.
  5. Exposure adjustment changes the count scale; it does not remove systematic batch differences.
  6. Use 2^estimate, 2^lower and 2^upper; describe the supplement-versus-control tissue-level comparison conditional on batch and the working model.
  7. No. The BH procedure addresses false discovery rate across a family of tests under its assumptions, not an individual posterior probability.
  8. An unusual observation may be biological, technical or a model discrepancy. Exclusion needs an independently justified reason and a sensitivity assessment.

Further reading

The Harvard Introduction to Differential Gene Expression Analysis offers a complementary teaching sequence. The calculations and figures here are original hypothetical examples. Consult the DESeq2 vignette when moving to its implementation.

Continue learning

B02 Design, B06 Transcriptomics and B12 Statistics.

Continue with pathway interpretation, or use the Multi-Omics extension on assay QC. Return to the practical index or the unified map.