HUB (Hands-on
Understanding of Bioinformatics) Workshop
Bulk RNA-seq Data
Analysis
by
Bioinformatics Hub
Irmady K, Hale CR, Qadri R, et al. Blood transcriptomic signatures associated with molecular changes in the brain and clinical outcomes in Parkinson’s disease. Nat Commun. 2023;14:3956.
| Panel | Analysis | Biological question |
|---|---|---|
| Figure 1b | PCA | Do samples separate by brain region and disease status without using prior sample labels? |
| Figure 1c | Volcano plot | Which genes are differentially expressed between PD and control, and what is the magnitude of those changes? |
| Figure 1d | Bubble plot | Which biological processes are altered, rather than which individual genes? |
| Figure 1e | Box plot | Which biological process is altered in both brain regions? |
| Figure 1f | Box plot | Which biological process is altered in only one brain region? |
knownGene
annotation. This step was covered in Session 3 using a smaller dataset.
We do not repeat it here because reproducing the
original analysis would require processing approximately 150 RNA-seq
samples against the full reference transcriptome. Instead, we begin with
the gene count matrix deposited by the authors in GEO.edgeR::filterByExpr() from the
edgeR package with the default settings. We reproduce
this step exactly.CTRL_CAU,
CTRL_PUT, PD_CAU, and PD_PUT. We
reproduce this grouping exactly.~ 0 + group + death_age. This is the only
methodological deviation from the published workflow. In the
publicly released metadata, age at death has been redacted for
all control donors: all 80 control samples contain the literal
value "CTRL" rather than a numerical age. Because this
covariate cannot be reconstructed, we omit it from the design and
instead fit ~ 0 + group. In Step 5, we examine the impact
of this modification.limma::voomWithQualityWeights() from
limma, which estimates sample-specific quality weights
to down-weight lower-quality samples. We reproduce this step exactly.
This procedure is particularly important because RNA integrity numbers
(RINs) in this cohort range from 2.3 to 8.9, indicating
substantial variation in RNA quality.c5.all.v7.1.symbols.gmt, which is included with this
tutorial.limma::removeBatchEffect() to remove the effects of
sex and RNA quality (RIN), and PCA was
performed on the 500 most variable genes. We reproduce
this workflow exactly. The only implementation difference is that,
instead of using limma::correlatePCs(), we call
kruskal.test() directly. This yields the same statistical
test without relying on the wrapper.death_age because it is unavailable in the publicly
released metadata.kruskal.test() directly instead of
limma::correlatePCs().# Note: Whether you are using the Docker image or your local installation, launch R or RStudio.
R
# Note: From this point onward, all code chunks are written in R.
# Throughout this guide, file paths are given relative to the Docker image for consistency.
setwd("/workshop/data/analysis")
list.files()
## [1] "c5.all.v7.1.symbols.gmt" "GSE205450_counts.table.txt.gz"
## [3] "install_R_packages.R" "precomputed"
## [5] "sample_sheet.tsv" "source.txt"
source("/workshop/data/analysis/install_R_packages.R")

25186 151 — 25,186 genes,
and 151 columns = 150 samples plus one column of gene names.check.names = FALSE matters. The sample names start
with digits (2589_CTRL_CAU), and R’s default would silently
rewrite them to X2589_CTRL_CAU, which then fails to match
the sample sheet.counts <- read.delim("GSE205450_counts.table.txt.gz", check.names = FALSE)
dim(counts)
## [1] 25186 151
68 rows have no gene symbol. In the file on disk these
are the literal two-character text "NA" — but
read.delim converts that text into R’s missing value. This
is a real trap, because the obvious filtersum(is.na(counts$Gene_symbol))
## [1] 68
25118 150. Now it is a clean numeric matrix: genes in
rows, samples in columns, gene symbols as row names.Classifier.Rmd filters with is.na() for
exactly this reason.counts <- counts[!is.na(counts$Gene_symbol), ]
counts <- counts[!duplicated(counts$Gene_symbol), ]
rownames(counts) <- counts$Gene_symbol
counts <- as.matrix(counts[, -1])
dim(counts)
## [1] 25118 150
150 14, then the first few rows — Sample,
Patient, Diagnosis, Region,
Gender, RIN, PMI.meta <- read.delim("sample_sheet.tsv", check.names = FALSE)
dim(meta)
head(meta[, 1:7])
## [1] 150 14
## Sample Patient Diagnosis Region Gender RIN PMI
## 1 2589_CTRL_CAU CTRL_23 CTRL CAU M 6.2 15.0
## 2 2590_CTRL_PUT CTRL_23 CTRL PUT M 6.8 15.0
## 3 2653_PD_CAU PD_22 PD CAU M 6.5 9.0
## 4 2654_PD_PUT PD_22 PD PUT M 7.4 9.0
## 5 2667_CTRL_CAU CTRL_24 CTRL CAU M 2.7 24.1
## 6 2668_CTRL_PUT CTRL_24 CTRL PUT M 2.6 24.1
identical(colnames(counts), meta$Sample)
## [1] TRUE
table(meta$Diagnosis, meta$Region)
##
## CAU PUT
## CTRL 40 40
## PD 35 35
meta$group <- factor(paste(meta$Diagnosis, meta$Region, sep = "_"),
levels = c("CTRL_CAU", "CTRL_PUT", "PD_CAU", "PD_PUT"))
table(meta$group)
##
## CTRL_CAU CTRL_PUT PD_CAU PD_PUT
## 40 40 35 35
~ 0 + means “no intercept”: give me one column per
group rather than one baseline group plus differences from it. That
makes the next step readable — we can write the comparison we want as
plain subtraction. This is also where the authors had
+ death age and we do not.design <- model.matrix(~ 0 + group, data = meta)
colnames(design) <- levels(meta$group)
dim(design)
## [1] 150 4
edgeR::filterByExpr() is
group-aware. A gene expressed only in the 35 PD
caudate samples is retained because it is sufficiently
expressed within at least one experimental group. In contrast, a simple
filter based on the overall average count (e.g., average count
> 10) would likely discard such a gene, even though it may
represent a biologically meaningful, group-specific signal.dge <- edgeR::DGEList(counts = counts)
keep <- edgeR::filterByExpr(dge, group = meta$group)
sum(keep)
## [1] 18938
dge <- dge[keep, , keep.lib.sizes = FALSE]
dge <- edgeR::calcNormFactors(dge, method = "TMM")
range(dge$samples$norm.factors)
## [1] 0.2504046 1.2655525
limma::voom()
estimates the empirical mean–variance relationship in
your dataset and transforms the counts into
log2 counts per million (log-CPM), while assigning a
precision weight to every observation.limma::voomWithQualityWeights() extends this approach by
estimating an additional weight for each sample. This
is particularly important for the present dataset, where RNA integrity
numbers (RINs) range from 2.3 to 8.9, reflecting
substantial variation in RNA quality. Rather than excluding
lower-quality samples, the method retains them while appropriately
reducing their influence on downstream analyses.vv <- limma::voomWithQualityWeights(dge, design = design,
plot = TRUE, normalize.method = "none")
range(vv$targets$sample.weights)
## [1] 0.2458534 3.3757388
dim(vv$E)
## [1] 18938 150
limma::voomWithQualityWeights() to finish.# Note: If voomWithQualityWeights() did not complete successfully, load the precomputed object instead.
vv <- readRDS("precomputed/voom.rds")
dim(vv$E)

XIST and
Y-chromosome genes) are among the most variable genes in nearly every
human transcriptomic dataset. If left uncorrected, they can dominate the
leading principal components.design argument specifies the variation that should
be preserved. Here,
limma::removeBatchEffect() removes the effects of
sex and RIN while retaining the
differences between the four experimental groups.expr <- limma::removeBatchEffect(vv$E,
batch = factor(meta$Gender),
covariates = meta$RIN,
design = design)
t() because prcomp() expects samples
in rows and genes in columns.vg <- order(apply(expr, 1, var), decreasing = TRUE)[1:500]
pca <- prcomp(t(expr[vg, ]), scale. = TRUE)
pv <- round(100 * pca$sdev^2 / sum(pca$sdev^2), 1)
pv[1:2]
## [1] 17.5 9.3
PAL4 <- c(
CTRL_CAU = "#7FBEE7", # Control Caudate - light blue
CTRL_PUT = "#ECAC1F", # Control Putamen - amber
PD_CAU = "#7F509F", # PD Caudate - purple
PD_PUT = "#8A271B" # PD Putamen - dark red
)
ell <- function(x, y, level = 0.8, n = 200) {
m <- cbind(x, y)
ctr <- colMeans(m)
e <- eigen(cov(m))
r <- sqrt(qchisq(level, df = 2))
th <- seq(0, 2 * pi, length.out = n)
t(ctr + e$vectors %*% diag(sqrt(pmax(e$values, 0)) * r) %*% rbind(cos(th), sin(th)))
}
old_par <- par(no.readonly = TRUE); on.exit(par(old_par), add = TRUE)
par(mar = c(5.1, 4.1, 4.1, 9.5), xpd = NA)
plot(pca$x[, 1], pca$x[, 2], type = "n", xlab = paste0("PC1 (", pv[1], " %)"), ylab = paste0("PC2 (", pv[2], " %)"))
for (g in levels(meta$group)) {
k <- meta$group == g
polygon(
ell(pca$x[k, 1], pca$x[k, 2]),
border = PAL4[g],
lwd = 1.6
)
}
points(pca$x[, 1], pca$x[, 2], col = PAL4[as.character(meta$group)], pch = 19, cex = 1.15)
legend(
"topright",
inset = c(-0.42, 0),
legend = c("Control Caudate", "PD Caudate", "Control Putamen", "PD Putamen" ),
col = PAL4[c("CTRL_CAU", "PD_CAU", "CTRL_PUT", "PD_PUT")],
pch = 19, bty = "n", cex = 0.9, title = "Group", title.adj = 0
)
kruskal.test(pca$x[, 1] ~ factor(meta$Region))$p.value
## [1] 2.494952e-19
kruskal.test(pca$x[, 2] ~ factor(meta$Diagnosis))$p.value
## [1] 3.568223e-09
limma::lmFit() fits a linear model to each of the
18,938 genes.limma::contrasts.fit() defines the comparisons of
interest (PD vs. control in the caudate and putamen). Because each
comparison is made within a brain region, anatomical differences do not
confound the disease effect.limma::eBayes() stabilizes the gene-wise variance
estimates by borrowing information across all genes, producing more
reliable statistics than thousands of independent t-tests.fit <- limma::lmFit(vv, design)
ct <- limma::makeContrasts(CAU = PD_CAU - CTRL_CAU,
PUT = PD_PUT - CTRL_PUT,
levels = design)
fit <- limma::eBayes(limma::contrasts.fit(fit, ct))
dim(res) returns 18938 6: one row for each
retained gene and six statistical columns—logFC,
AveExpr, _t_, P.Value,
adj.P.Val, and B. Because
limma::topTable() sorts genes by significance, the first
rows contain the strongest evidence for differential expression in the
caudate.adj.P.Val, not
P.Value. Testing 18,938 genes at an uncorrected P
< 0.05 would produce approximately 950 false positives by chance
alone. adj.P.Val reports the Benjamini–Hochberg false
discovery rate used in the paper.res <- limma::topTable(fit, coef = "CAU", number = Inf)
dim(res)
## [1] 18938 6
head(res, 3)
## logFC AveExpr t P.Value adj.P.Val B
## GPCPD1 -1.428541 4.701309 -10.020171 2.018958e-18 3.823502e-14 31.28030
## TRIP10 1.371646 3.975859 9.732750 1.149311e-17 1.088283e-13 29.58397
## COL27A1 1.427383 4.454242 9.558633 3.279414e-17 2.070185e-13 28.57332
adj.P.Val < 0.05) and effect
size. Significant genes are coloured using three log2 fold-change
thresholds (0.1, 0.5, and 1), with progressively darker shades
indicating larger expression changes.logFC = 1.4 is
first assigned the lightest red, then the intermediate red, and finally
the darkest red. Genes that do not meet the significance threshold
remain black, matching the published figure.VUP <- c("#F06364", "#CC4A4B", "#EB1B24") # up at 0.1 / 0.5 / 1
VDN <- c("#4080C3", "#196DB6", "#2A4EA0") # down at 0.1 / 0.5 / 1
sig <- res$adj.P.Val < 0.05
col <- rep("black", nrow(res))
col[sig & res$logFC > 0.1] <- VUP[1]; col[sig & res$logFC < -0.1] <- VDN[1]
col[sig & res$logFC > 0.5] <- VUP[2]; col[sig & res$logFC < -0.5] <- VDN[2]
col[sig & res$logFC > 1.0] <- VUP[3]; col[sig & res$logFC < -1.0] <- VDN[3]
369, 2036, and 2978 upregulated
genes, and 464, 1568, and 3078
downregulated genes. Your counts should be similar, although not
identical, because our analysis differs slightly from the original
workflow.-log10(P.Value), using the
raw P values, whereas the point colours are determined by
adj.P.Val. In other words, the points are positioned
according to the raw P value but coloured according to the
false discovery rate. This is the convention used in the paper and is
common in volcano plots.logFC), while the y-axis reflects the strength of the
statistical evidence. Both are important: a gene can have a large fold
change but weak statistical support, or a very small fold change that is
highly significant because of the large sample size.limma::topTable(fit, coef = "PUT", number = Inf), then
repeat the same plotting steps.n <- c(sum(sig & res$logFC > 1),
sum(sig & res$logFC > 0.5 & res$logFC <= 1),
sum(sig & res$logFC > 0.1 & res$logFC <= 0.5),
sum(sig & res$logFC < -1),
sum(sig & res$logFC < -0.5 & res$logFC >= -1),
sum(sig & res$logFC < -0.1 & res$logFC >= -0.5))
par(mar = c(4.2, 4.2, 1, 1))
plot(res$logFC, -log10(res$P.Value),
col = col, pch = 19, cex = 0.4,
xlab = expression("PD/Control (log"[2]*"FC)"),
ylab = expression("-log"[10]*"(p value)"))
abline(v = c(-1, -0.5, 0.5, 1), col = "grey70", lty = 2)
legend("topleft", legend = n, col = c(rev(VUP), rev(VDN)),
pch = 19, bty = "n", cex = 0.85, y.intersp = 0.95)
sum(res$adj.P.Val < 0.05 & res$logFC > 0.1)
## [1] 5546
sum(res$adj.P.Val < 0.05 & res$logFC < -0.1)
## [1] 5535
res_put <- limma::topTable(fit, coef = "PUT", number = Inf)
sum(res_put$adj.P.Val < 0.05 & res_put$logFC > 0.1)
## [1] 5895
sum(res_put$adj.P.Val < 0.05 & res_put$logFC < -0.1)
## [1] 6205
limma::lmFit,
limma::contrasts.fit, and limma::eBayes can be
reused without modification.
10192 Gene Ontology gene sets.
Each row gives the gene-set name followed by its member genes.gs <- GSEABase::getGmt("c5.all.v7.1.symbols.gmt")
length(gs)
## [1] 10192
9689 × 150 matrix.par <- GSVA::gsvaParam(vv$E, gs, minSize = 5, maxSize = Inf, kcdf = "Gaussian")
scores <- GSVA::gsva(par, verbose = FALSE)
dim(scores)
## [1] 9689 150
5770 significant gene sets in
the caudate and 6341 in the putamen.limma::lmFit,
limma::contrasts.fit, and limma::eBayes use
the same design matrix and contrasts as before. Only the rows now
represent gene sets rather than individual genes.limma::arrayWeights replaces voom-derived weights
because GSVA scores are not counts and do not require count-specific
mean–variance modelling. Sample quality can still vary, however, so
noisier samples are down-weighted.aw <- limma::arrayWeights(scores, design = design)
fitp <- limma::lmFit(scores, design, weights = aw)
fitp <- limma::eBayes(limma::contrasts.fit(fitp, ct))
gsva_cau <- limma::topTable(fitp, coef = "CAU", number = Inf)
gsva_put <- limma::topTable(fitp, coef = "PUT", number = Inf)
sum(gsva_cau$adj.P.Val < 0.05)
## [1] 5770
sum(gsva_put$adj.P.Val < 0.05)
## [1] 6341
5334 gene sets are significant in both
regions.both <- intersect(rownames(gsva_cau)[gsva_cau$adj.P.Val < 0.05],
rownames(gsva_put)[gsva_put$adj.P.Val < 0.05])
length(both)
## [1] 5334
b <- gsva_cau[both, ]
top <- rownames(b[order(b$adj.P.Val), ])[1:12]
dfC <- gsva_cau[top, ]
dfP <- gsva_put[top, ]
nice <- function(x) gsub("_", " ", sub("^GO_", "", x))
yy <- seq_along(top)
REG2 <- c(Caudate = "#7F509F", Putamen = "#8A271B")
par(mar = c(4.5, 24, 1, 7.5))
plot(NA, xlim = range(c(dfC$logFC, dfP$logFC)) + c(-0.15, 0.15),
ylim = c(0.4, length(top) + 0.6), yaxt = "n", ylab = "",
xlab = expression("PD/Control (log"[2]*"FC)"))
axis(2, at = yy, labels = nice(top), las = 1, cex.axis = 0.70)
abline(h = yy, col = "grey93"); abline(v = 0, col = "grey55", lty = 2)
points(dfC$logFC, yy + 0.20, pch = 19,
cex = sqrt(-log10(dfC$adj.P.Val)) * 0.80, col = REG2["Caudate"])
points(dfP$logFC, yy - 0.20, pch = 17,
cex = sqrt(-log10(dfP$adj.P.Val)) * 0.80, col = REG2["Putamen"])
par(xpd = TRUE); xr <- par("usr")[2] + diff(par("usr")[1:2]) * 0.05
legend(xr, par("usr")[4], legend = names(REG2), col = REG2,
pch = c(19, 17), bty = "n", cex = 0.85,
title = "Region", title.adj = 0)
legend(xr, par("usr")[4] - diff(par("usr")[3:4]) * 0.34,
legend = c(6, 10), pt.cex = sqrt(c(6, 10)) * 0.80, pch = 19,
col = "grey45", bty = "n", cex = 0.85,
title = expression("-log"[10]*"(FDR)"), title.adj = 0)
ord4 <- c("CTRL_CAU", "PD_CAU", "CTRL_PUT", "PD_PUT")
visualize_box <- function(term) {
g <- factor(as.character(meta$group), levels = ord4)
v <- scores[term, ]
pC <- wilcox.test(v[meta$Region == "CAU"] ~
meta$Diagnosis[meta$Region == "CAU"])$p.value
pP <- wilcox.test(v[meta$Region == "PUT"] ~
meta$Diagnosis[meta$Region == "PUT"])$p.value
par(mar = c(3.2, 4.4, 4.6, 1))
boxplot(v ~ g, outline = FALSE, xlab = "", ylab = "Enrichment score",
xaxt = "n", col = PAL4[ord4], border = "grey30",
ylim = range(v) + c(0, diff(range(v)) * 0.16))
axis(1, at = 1:4, labels = c("C", "PD", "C", "PD"),
tick = FALSE, line = -0.6)
set.seed(1)
points(jitter(as.integer(g), 0.9), v, pch = 19, cex = 0.55,
col = adjustcolor(PAL4[as.character(g)], 0.75))
yt <- par("usr")[4]
dy <- diff(par("usr")[3:4])
brack <- function(x1, x2, lab, p) {
segments(x1, yt - dy * 0.055, x2, yt - dy * 0.055, col = "grey35")
text(mean(c(x1, x2)), yt - dy * 0.028, lab, cex = 0.8)
text(mean(c(x1, x2)), yt - dy * 0.095,
format(p, digits = 2), cex = 0.75, col = "grey25")
}
brack(1, 2, "Caudate", pC)
brack(3, 4, "Putamen", pP)
mtext(nice(term), side = 3, line = 3.0, cex = 0.95)
invisible(c(Caudate = pC, Putamen = pP))
}
6.7e-06 for the
caudate and 6.4e-06 for the putamen. The paper reports
1.0e-05 and 6.4e-06.p_nod2 <- visualize_box("GO_NUCLEOTIDE_BINDING_OLIGOMERIZATION_DOMAIN_CONTAINING_2_SIGNALING_PATHWAY")
p_nod2
## Caudate Putamen
## 6.744259e-06 6.369684e-06
0.0056 for the
caudate and 0.58 for the putamen, compared with
0.0049 and 0.56 in the paper.group was defined with
four levels in Step 2 rather than only separating PD from control.mtdna <- "GO_MITOCHONDRIAL_DNA_METABOLIC_PROCESS"
p_mtdna <- visualize_box(mtdna)
p_mtdna
## Caudate Putamen
## 0.005571432 0.579236509
gsva_cau[mtdna, c("logFC", "adj.P.Val")]
## logFC adj.P.Val
## GO_MITOCHONDRIAL_DNA_METABOLIC_PROCESS 0.2216521 0.0004986815
gsva_put[mtdna, c("logFC", "adj.P.Val")]
## logFC adj.P.Val
## GO_MITOCHONDRIAL_DNA_METABOLIC_PROCESS -0.06918928 0.2768603
identical() before
continuing.limma::lmFit,
limma::contrasts.fit, and limma::eBayes—can
test both genes and gene sets. Learn the framework rather than
memorizing a single recipe.