Bulk RNA-seq 分析入门(三):QC 图、功能富集与有边界的解释

有了差异表达表,分析还没有结束。下一步不是立刻从 GO 结果里找一句最符合预期的话,而是回头确认样本、模型和候选是否值得解释,再把统计结果放回原来的生物学问题中。

本篇继续使用上一章的公开 airway 计数数据及保存的 airway_bundle.rds,只演示 bulk RNA-seq 的样本检查与功能分析。它不是单细胞教程,也不提供针对个人的医疗解释。下面代码是供读者运行的学习脚本,本机没有执行,因此文中没有虚构的 PCA 分离效果、富集通路名单或显著性数值。

1. QC 应在检验前做,结果出来后还要复核

这篇把画图安排在统计学习练习之后,是为了复用上一章保存的对象,不意味着正式项目应先差异检验、后审查样本。实际工作应先确认原始读段和计数层面的质量,发现身份存疑、失败文库或明显异常时暂停;建模后再用诊断检查模型和候选是否出现新的问题。

我习惯给异常记录分三格:观察到什么、有哪些可能原因、准备怎样验证。比如某个样本的分配到基因的计数总量偏低,可能与测序深度、rRNA、比对、参考或建库有关,不能直接写成“这个样本生物学上表达更低”。先看第一篇的处理报告,再核对原始实验记录,远比直接删样本更有解释力。

MultiQC 可以连接上游多个工具的线索;计数层面的样本图则检查输入矩阵与元数据之间是否一致。这两类 QC 互相补充,不互相替代。尤其要注意 colSums(counts(dds)) 是进入当前矩阵的基因计数和,不是原始 FASTQ 的总 reads,也不是必然等于比对 fragments。nf-core/rnaseq 3.27.0 输出定义、MultiQC 官方概览

2. 探索图回答的是结构,不自动回答显著性

直接对原始计数做欧氏距离,高表达基因和深度差异可能主导图。用于探索时通常采用方差稳定化等变换,便于观察样本间结构;差异检验仍使用计数模型,并不把转换值再塞回原始计数入口。VST 与样本距离、PCA 的关系可参照 Bioconductor 官方 RNA-seq 工作流。

PCA 上处理组分开,是整体表达结构的一种观察,不是每个基因都差异表达的证明;处理组没有分开,也不自动否定局部效应。应同时按来源、批次、时间等真实信息标记,辨认最大的变异来自哪里。blind = FALSE 使用已有设计进行变换,也不会把来源或批次从矩阵里凭空删除。

热图也需要声明选基因的方法。先用显著基因筛选,再展示两组明显分开,不能把这种图当成独立证明。本练习选高变异基因作探索并进行行中心化:颜色表示某基因在各样本相对其平均值的变化,不表示不同基因间的绝对表达量高低。

3. 完整学习脚本第一段:读取 bundle 并保存图

需要已经具备上一章的 R/Bioconductor 依赖;后半段还需要 clusterProfiler、AnnotationDbi 和 org.Hs.eg.db。代码只检查依赖,不自动安装。org.Hs.eg.db 适用于这个人类数据例子,不能不改物种就用于小鼠、植物或微生物。准备依赖的正规入口仍是 Bioconductor 安装说明。

将本节与下一节的两个 R 代码块按顺序放入同一个 airway_qc_enrichment_learning.R 文件。运行方式为 Rscript airway_qc_enrichment_learning.R "上一章生成的完整路径/airway_bundle.rds"。输出创建在当前学习工作目录下一个带唯一名称的新目录,Rscript 退出后仍保留,不覆盖上一章文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
needed <- c("DESeq2", "SummarizedExperiment", "clusterProfiler",
"AnnotationDbi", "org.Hs.eg.db")
available <- vapply(needed, requireNamespace, logical(1), quietly = TRUE)
if (!all(available)) stop("Prepare packages first: ",
paste(needed[!available], collapse = ", "))
suppressPackageStartupMessages({
library(DESeq2)
library(SummarizedExperiment)
library(clusterProfiler)
library(org.Hs.eg.db)
})
args <- commandArgs(trailingOnly = TRUE)
if (length(args) != 1L || !file.exists(args[1])) {
stop("Usage: Rscript airway_qc_enrichment_learning.R AIRWAY_BUNDLE_PATH")
}
bundle <- readRDS(args[1])
stopifnot(identical(bundle$input, "Public airway teaching dataset"))
dds <- bundle$dds
res <- bundle$res
stopifnot(inherits(dds, "DESeqDataSet"), inherits(res, "DESeqResults"),
identical(rownames(dds), rownames(res)),
identical(colnames(dds), rownames(colData(dds))))
out <- tempfile("perry-airway-qc-", tmpdir = getwd())
stopifnot(dir.create(out))

qc <- data.frame(
sample_id = colnames(dds),
condition = colData(dds)$condition,
subject = colData(dds)$subject,
modeled_gene_count_sum = colSums(counts(dds)),
modeled_genes_with_nonzero_count = colSums(counts(dds) > 0),
size_factor = sizeFactors(dds)
)
write.table(qc, file.path(out, "sample_count_qc.tsv"),
sep = "\t", row.names = FALSE, quote = FALSE)

vsd <- varianceStabilizingTransformation(dds, blind = FALSE)
png(file.path(out, "pca.png"), width = 1200, height = 850, res = 120)
print(plotPCA(vsd, intgroup = c("condition", "subject")))
dev.off()
vmat <- assay(vsd)
distances <- as.matrix(dist(t(vmat)))
png(file.path(out, "sample_distance.png"), width = 1100, height = 1100)
heatmap(distances, symm = TRUE, scale = "none",
main = "Airway teaching data: sample distance")
dev.off()

variances <- apply(vmat, 1, var)
top <- head(order(variances, decreasing = TRUE), 30L)
centered <- sweep(vmat[top, , drop = FALSE], 1,
rowMeans(vmat[top, , drop = FALSE]), "-")
png(file.path(out, "high_variance_genes.png"), width = 1200, height = 1200)
heatmap(centered, scale = "none", Colv = NA,
main = "High-variance genes: row-centered VST")
dev.off()
capture.output(sessionInfo(), file = file.path(out, "sessionInfo.txt"))

读图时先检查轴、颜色和样本顺序,再看聚类。样本距离图中的“近”,依赖所用的变换、特征与距离定义;不能把它直接解释成两位供者在其他生物学层面更相似。若一个样本的位置意外,先查身份、处理、文库与参考记录,再形成排除决定,并评估排除是否破坏配对或造成混杂。

4. ORA 的背景是选择机会,不是随便找的一张基因表

过度代表分析(ORA)问的是:相对于一个明确背景,候选列表里某类注释是否更常见?背景应代表此次分析中有机会成为候选、且能进入相应注释检验的基因,不应自动等于全基因组,也不能只取显著基因。否则你改变了“原本有多少机会选中这类基因”的基准,结论可能被这种选择改变。Gene Ontology 官方富集说明

本例明确规定:候选规则基于本次普通检验的有限 padj;背景取拥有有限 pvalue 和 padj、同时具有 BP 注释的全部基因,包含不显著基因。被预过滤排除或因异常而未检验的基因不进入背景;独立过滤后没有 padj 的基因在本例也不属于这次 FDR 候选选择空间。正式分析可以有不同的合格基因定义,但必须说明规则,并同步应用到背景和前景,不能在得到通路结果后随意扩大背景。

这里分别考虑正、负效应的候选,而不是把两个方向都塞进一个列表后宣称通路“上调”。为了让示例可检查,代码只运行正效应列表;读者可以按同一背景构造负效应列表另做分析,并明确那是另一个候选集合。Gene Ontology 注释也有版本与覆盖限制,必须记录注释库日期、未映射基因与纳入数量。

5. 完整学习脚本第二段:明确背景的 GO 与完整排序的 GSEA

下面沿用同一个 R 文件中的 dds、res 与 out。选择 ENSEMBL 作为 keyType,避免为了演示而将所有 ID 转成容易发生多对一的符号。正式数据若带版本号,应先核对来源,做有记录的转换并检查转换后的重复,不能盲目删后缀。

enrichGO 不传 universe 时会使用数据库背景,因此这里显式传入经过检查的背景。函数参数与输出约定见 作者维护的 enrichGO 文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
ids <- rownames(res)
stopifnot(!anyDuplicated(ids), all(grepl("^ENSG[0-9]+$", ids)))
orgdb <- org.Hs.eg.db
stopifnot("ENSEMBL" %in% AnnotationDbi::keytypes(orgdb))
known <- intersect(ids, AnnotationDbi::keys(orgdb, keytype = "ENSEMBL"))
stopifnot(length(known) > 0L)
annotation <- AnnotationDbi::select(
orgdb, keys = known, keytype = "ENSEMBL",
columns = c("GOALL", "ONTOLOGYALL")
)
bp_rows <- !is.na(annotation$GOALL) & !is.na(annotation$ONTOLOGYALL) &
annotation$ONTOLOGYALL == "BP"
bp_ids <- unique(annotation$ENSEMBL[bp_rows])
fdr_eligible <- is.finite(res$pvalue) & is.finite(res$padj)
universe <- intersect(ids[fdr_eligible], bp_ids)
candidate_up <- is.finite(res$padj) & res$padj < 0.05 &
is.finite(res$log2FoldChange) & res$log2FoldChange > 0
foreground <- intersect(ids[candidate_up], universe)
stopifnot(length(universe) > 0L, all(foreground %in% universe))
writeLines(universe, file.path(out, "ora_background_ensembl.txt"))
writeLines(foreground, file.path(out, "ora_positive_foreground_ensembl.txt"))
writeLines(setdiff(ids, known), file.path(out, "not_in_annotation_keyspace.txt"))
capture.output(orgdb, file = file.path(out, "annotation_database_info.txt"))
audit <- data.frame(
definition = c("modeled_genes", "finite_p_and_padj", "BP_ORA_background",
"positive_FDR_candidates_in_background"),
n = c(length(ids), sum(fdr_eligible), length(universe), length(foreground))
)
write.table(audit, file.path(out, "enrichment_input_audit.tsv"),
sep = "\t", quote = FALSE, row.names = FALSE)

if (length(foreground) > 0L) {
ora <- enrichGO(gene = foreground, universe = universe, OrgDb = orgdb,
keyType = "ENSEMBL", ont = "BP", pAdjustMethod = "BH",
pvalueCutoff = 1, qvalueCutoff = 1,
minGSSize = 10, maxGSSize = 500, readable = FALSE)
ora_table <- as.data.frame(ora)
write.table(ora_table, file.path(out, "ora_positive_BP_returned_terms.tsv"),
sep = "\t", quote = FALSE, row.names = FALSE)
} else {
message("No foreground genes: ORA skipped, not an enrichment result")
}

# A separate eligibility rule for preranked GSEA, not the ORA foreground.
# Keep finite test statistics even if independent filtering made padj NA.
rank_ok <- is.finite(res$stat) & is.finite(res$pvalue) & ids %in% bp_ids
ranking <- setNames(res$stat[rank_ok], ids[rank_ok])
ranking <- sort(ranking, decreasing = TRUE)
stopifnot(length(ranking) >= 10L, !anyDuplicated(names(ranking)))
write.table(data.frame(gene_id = names(ranking), Wald_stat = unname(ranking)),
file.path(out, "gsea_ranked_genes.tsv"),
sep = "\t", quote = FALSE, row.names = FALSE)
# Use gseGO's fixed default seed; preserve the actual package version.
gsea <- gseGO(geneList = ranking, OrgDb = orgdb, keyType = "ENSEMBL",
ont = "BP", minGSSize = 10, maxGSSize = 500,
pvalueCutoff = 1, pAdjustMethod = "BH",
verbose = FALSE, seed = TRUE)
write.table(as.data.frame(gsea), file.path(out, "gsea_BP_returned_terms.tsv"),
sep = "\t", quote = FALSE, row.names = FALSE)
saveRDS(list(background = universe, foreground = foreground,
ranking = ranking, gsea = gsea,
gsea_seed_setting = TRUE,
contrast = bundle$contrast, dataset = bundle$input),
file.path(out, "enrichment_bundle.rds"))
message("Teaching QC/enrichment outputs: ", normalizePath(out))

pvalueCutoff = 1 与 qvalueCutoff = 1 是为了减少结果展示前的阈值截断,方便学习查看函数返回的表,并不把返回的所有行都标成显著;函数仍可能因注释、集合大小或不可用统计量不返回某些条目。报告候选富集时要另行声明校正后的阈值和所检查的条目范围。空表也是可能结果,不能编造一个通路名让练习显得成功。

代码使用该人类数据例子可识别的基因 ID;它的检查刻意严格,不是可直接接收任意物种矩阵的万能脚本。AnnotationDbi::select 返回一对多注释是正常现象,因为一个基因可以属于多个 GO 条目;不应把这些注释行误当成多个独立基因。注释查询接口与映射关系可参考 AnnotationDbi 官方说明。

6. GSEA 不只使用显著基因,也不靠随机抖动修饰排名

ORA 需要先定义候选集合;预排序 GSEA 则使用具有可用统计量的完整合格排序列表,不先截成显著基因。本例采用带方向的 Wald 统计量,越靠顶部越支持 treated 相对 control 的正方向,底部支持负方向。不能用绝对值排名后还把 NES 解释成处理上调/下调;也不要用不带符号的 p 值排名期待保留方向。GSEA 官方排序与方向说明

排序中要有唯一基因 ID、有限数值和明确方向;注释与排序 keyType 要一致。遇到很多并列排名,先检查数据分辨率、过滤和排序统计量,并保留软件警告,不要为了没有警告而随意添加随机噪声。基因集合版本、集合大小范围和随机设置都应记录;不同版本返回不同结果时,有这些记录才可以调查。作者维护的 gseGO 参数文档

还要区分预排序方法和基于样本标签置换的标准 GSEA,它们使用的信息和假设不同;不能认为本例会自动解决小样本、配对或基因相关性带来的所有问题。复杂研究需要根据设计选择适合的基因集方法,富集分析本身也不能补救不可信的差异表达输入。

7. 富集不是“通路已激活”,显著不是“机制已证明”

假设一个候选列表富集到与免疫相关的条目,谨慎的表述是:在这个候选定义、背景和注释版本下,这类基因出现得比背景预期更集中。它没有自动证明免疫功能增强,也没有说明改变发生在哪种细胞。bulk 样本的变化可能来自细胞组成、细胞状态或二者同时改变,单靠这一张表不能区分。

同样,GSEA 的正 NES 表示某个集合倾向排序顶部,不等于所有成员都增加,更不等于生化通路的净活性增强。应查看贡献基因、它们的方向与效应、集合里的抑制/促进关系和实验背景,再决定可以提出什么后续假设。选择一个预期相关的条目作图,也不能隐藏同时检验的其他条目。

GO 的多个条目可能共享很多基因。几十个相关名称不一定是几十个独立发现,图上的气泡数量更不是机制数量。解释时可以按主题整理冗余条目,但保存原始表、规则和贡献基因,避免整理过程只留下最符合故事的词。对机制结论,还需要独立干预、时间顺序、蛋白或功能层面的证据,而不是继续给同一批差异基因换一个富集工具。

8. 把结果写成可检查的四句话

第一句说明样本、实验单位与对比方向。第二句说明预处理、过滤、设计公式、QC 和任何排除。第三句报告效应与不确定性,并说明 FDR、候选规则、ORA 背景或 GSEA 排序方法。第四句区分已观察到的结构、统计支持的关联和仍需验证的机制解释。

例如可以先写“本练习使用公开 airway 数据,比较 treated 相对 control,并在设计中考虑来源配对”,再根据实际运行的结果填入检查与数字。不要把“运行完成”“文件存在”写成“结果正确”,也不要从代码模板自动生成尚未观察到的生物学结论。

9. 三个值得自己动手的自检

先数一数 ORA 背景与前景的大小,并检查前景确实是背景子集。然后只在教学副本中把背景错误地改为前景,观察为什么这改变了问题;不要将错误实验作为正式分析。最后把 GSEA 排序方向反过来,查同一个条目的方向如何随对比改变,并写出其含义,而不是只看显著性是否保留。

也可以把上一章 padj 为 NA 的行按原因归类,比较它们是否有 p 值和统计量。理解这些行为什么不被同一规则纳入 ORA,却可能仍在合格 GSEA 排名中,比机械删掉所有 NA 更有帮助。

至此,一个完整的入门交付包包括:原始输入与元数据、处理和参考版本、QC 与模型记录、全量差异表、明确的候选/背景/排名文件、注释版本、图与解释边界。能重新生成它们、能说明每个选择,比只拥有一张漂亮的火山图更接近可信分析。

上一章:DESeq2 设计公式与对比;课程起点:实验设计、FASTQ 与计数。

参考与继续阅读