HUB (Hands-on
Understanding of Bioinformatics) Workshop
Bulk RNA-seq Data
Analysis
by
Bioinformatics Hub
“Using a group of 70% of our control and donor PD caudates as a training dataset, we discovered a set of 19 classifier genes which identified clinical diagnosis with 86% accuracy. The same set of classifier genes identified clinical diagnosis in the putamen with 90% accuracy.”
“To identify classifier genes for PD and control patients, we used the MLSeq Bioconductor package (version 2.12.0) to implement a nearest shrunken centroid classifier on the voom variance stabilized caudate RNA-seq data. We used 70% of all samples as our training set and 30% as our test dataset. The accuracy of models on test data were recorded. Following this, the accuracy of the caudate classifier was also assessed in the putamen.”
# Demonstration only; do not run
method = "voomNSC"
normalize = "deseq"
ref = "CTRL"
control = MLSeq::voomControl(method = "repeatedcv", number = 5, repeats = 10, tuneLength = 10)
set.seed(1710)
voomNSC combines two ideas:

MLSeq::classify() and edgeR::cpm(), so their
source is clear and the document does not depend on packages being
attached with library().counts and meta are already present and
correctly aligned, proceed to the regional subset below.setwd("/workshop/data/analysis")
counts <- read.delim("GSE205450_counts.table.txt.gz", check.names = FALSE)
counts <- counts[!is.na(counts$Gene_symbol), ]
counts <- counts[!duplicated(counts$Gene_symbol), ]
rownames(counts) <- counts$Gene_symbol
counts <- as.matrix(counts[, -1])
meta <- read.delim("sample_sheet.tsv", check.names = FALSE)
meta$group <- factor(paste(meta$Diagnosis, meta$Region, sep = "_"),
levels = c("CTRL_CAU", "CTRL_PUT", "PD_CAU", "PD_PUT"))
identical(colnames(counts), meta$Sample)
## [1] TRUE
cau <- meta$Region == "CAU"
put <- meta$Region == "PUT"
c(caudate = sum(cau), putamen = sum(put))
## caudate putamen
## 75 75

set.seed(1710)
d <- counts[, cau]
y <- factor(meta$Diagnosis[cau], levels = c("CTRL", "PD"))
nTest <- ceiling(ncol(d) * 0.3)
ind <- sample(ncol(d), nTest, FALSE)
c(train = ncol(d) - nTest, test = nTest)
## train test
## 52 23
+ 1 adds a pseudocount before
normalization. This prevents zeros from invalidating gene-wise geometric
means used during size-factor estimation.DESeqDataSet is used as the data
container expected by MLSeq::classify(). This does
not imply that a DESeq2 differential-expression
analysis is being performed.train <- DESeq2::DESeqDataSetFromMatrix(d[, -ind] + 1, S4Vectors::DataFrame(condition = y[-ind]), ~ condition)
test <- DESeq2::DESeqDataSetFromMatrix(d[, ind] + 1, S4Vectors::DataFrame(condition = y[ ind]), ~ condition)

number = 5 requests five-fold cross-validation: the
training set is partitioned into five folds, four are used for fitting,
and the remaining fold is used for validation.repeats = 10 repeats this process ten times with
different fold assignments, reducing sensitivity to a favourable or
unfavourable partition.tuneLength = 10 evaluates ten candidate shrinkage
thresholds.Cross-validation is performed entirely within the 52-sample training set. The 23 held-out samples do not influence model selection. Once a test set affects tuning, feature selection, or any other analytical choice, it can no longer provide an unbiased estimate of predictive performance.
ctrl <- MLSeq::voomControl(method = "repeatedcv", number = 5, repeats = 10, tuneLength = 10)
fit <- MLSeq::classify(data = train, method = "voomNSC", normalize = "deseq", ref = "CTRL", control = ctrl)
# Note: Load the precomputed object if MLSeq::classify() did not complete
fit <- readRDS("precomputed/classifier_voomNSC_CAU.rds")
genes <- MLSeq::selectedGenes(fit)
length(genes)
## [1] 19
VGF encodes a neurosecretory protein associated with
neurodegenerative processes, whereas TUBB3 is enriched in
neurons. The presence of the haemoglobin genes HBA1,
HBA2, and HBB requires more cautious
interpretation: they may reflect vascular or blood-derived signal,
technical variation, or systematic differences in tissue collection. A
predictive model can exploit any reproducible
association, regardless of whether it is mechanistically
related to disease.sort(genes)
## [1] "ANGPT2" "ANGPTL4" "ANKRD20A2" "CIRBP" "DEPP1" "GPCPD1"
## [7] "HBA1" "HBA2" "HBB" "LBH" "LINC01338" "MT1M"
## [13] "MYBPC1" "NUPR1" "SEMA3G" "TARBP1" "TRIP10" "TUBB3"
## [19] "VGF"
pred <- MLSeq::predictClassify(fit, test)
table(predicted = pred, actual = y[ind])
## actual
## predicted CTRL PD
## CTRL 10 1
## PD 1 11
mean(pred == y[ind])
## [1] 0.9130435

put_dds <- DESeq2::DESeqDataSetFromMatrix(
counts[, put] + 1,
S4Vectors::DataFrame(condition = factor(meta$Diagnosis[put], levels = c("CTRL","PD"))), ~ condition)
pred_put <- MLSeq::predictClassify(fit, put_dds)
mean(pred_put == meta$Diagnosis[put])
## [1] 0.8933333
lcpm <- edgeR::cpm(counts, log = TRUE)
mat <- lcpm[genes, ]
pheatmap:
scale = "row" standardizes each gene
to mean 0 and standard deviation 1. This prevents highly expressed
haemoglobin genes from dominating the display and emphasizes relative
expression patterns across samples, although absolute expression
differences between genes are no longer represented.cluster_cols = FALSE preserves the specified
sample order—control before PD and caudate before putamen—so
the group-level block structure remains visible. Column clustering would
answer a different, unsupervised question.PAL4 <- c(CTRL_CAU = "#7FBEE7", CTRL_PUT = "#ECAC1F",
PD_CAU = "#7F509F", PD_PUT = "#8A271B")
lab <- c(CTRL_CAU = "Control Caudate", CTRL_PUT = "Control Putamen",
PD_CAU = "PD Caudate", PD_PUT = "PD Putamen")
ann <- data.frame(
Region = factor(lab[as.character(meta$group)], levels = lab),
Diagnosis = factor(ifelse(meta$Diagnosis == "CTRL", "Control", "PD"),
levels = c("Control", "PD")))
rownames(ann) <- meta$Sample
ann_col <- list(
Region = setNames(unname(PAL4[names(lab)]), lab),
Diagnosis = c(Control = "#93C73D", PD = "#2A4EA0"))
ord <- order(meta$Diagnosis, meta$Region)
pheatmap::pheatmap(mat[, ord],
annotation_col = ann,
annotation_colors = ann_col,
cluster_cols = FALSE,
scale = "row",
show_colnames = FALSE,
fontsize_row = 8,
color = colorRampPalette(rev(RColorBrewer::brewer.pal(11, "RdYlBu")))(100),
breaks = seq(-3, 3, length.out = 101))
| Quantity | Ours | Paper |
|---|---|---|
| Classifier genes | 19 | 19 |
| The gene list | identical | identical |
| Caudate test accuracy | 91.3% | 86% |
| Putamen accuracy | 89.3% | 90% |
set.seed(1710), allowing the same 52/23 partition to be
reconstructed.set.seed(2024)