Bulk RNA-seq 分析入门(三):QC 图、功能富集与有边界的解释 文章 方法分享 2026-10-06
有了差异表达表,分析还没有结束。下一步不是立刻从 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" ) } 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 ) 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 与计数 。
参考与继续阅读
01 Bulk RNA-seq 分析入门(一):从实验设计、FASTQ 到可信的计数表02 Bulk RNA-seq 分析入门(二):DESeq2 设计公式、对比与差异表达03 Bulk RNA-seq 分析入门(三):QC 图、功能富集与有边界的解释