☰
单细胞测序marker基因ID转化与GO富集分析全流程详解
2026/10/5 12:03:22 网站建设 项目流程

单细胞测序流程走到第八篇,说实话已经过了最热闹的阶段。UMAP图、聚类、注释这些“出图”环节大家都乐意看,但真正到了marker基因转化和GO富集分析这一步,问问题的人明显少了,可这恰恰是决定一篇文章能不能讲出生物学故事的关键环节。前两天有位做肿瘤免疫方向的朋友发消息问我:marker基因表导出来了,想直接拿去做富集分析,结果发现基因名对不上数据库,报了一大堆错,问我是不是少做了什么步骤。我说你这不是少做,是没做转化。

这个场景我见过太多次了。很多人以为Seurat跑完FindAllMarkers,导出表格,把基因名复制到线上工具里就能做GO分析,结果要么是一大半基因匹配不上,要么是富集出来的通路毫无意义。问题的根源就在于:单细胞上游分析拿到的基因符号(SYMBOL)和富集分析工具需要的基因ID格式,压根儿不是一回事。这篇就把marker基因转化和GO富集分析这条线完整捋一遍,包括为什么必须做转化、转化时那些基因名丢了到底怎么回事、GO分析参数怎么设、结果怎么解读不翻车。

这篇文章适合三类人:正在做单细胞数据分析但卡在注释后处理阶段的研究生;刚拿到marker基因表不知道下一步该干什么的临床科研人员;以及想系统搞懂GO富集分析底层逻辑的入门者。不用你已经有很深的生信基础,只要跟过流程到注释这一步,这篇文章的代码和思路你都能直接拿去用。

1. 为什么是先做ID转化再做富集,而不是直接把marker基因丢进去

先把“转化”这件事讲透。很多人不理解这步存在的意义,觉得不都是基因嘛,换个符号而已,有什么可转化的。实际上这里的ID转化指的是把基因符号(Gene Symbol)统一映射到富集分析工具能识别的数据库ID,通常是ENTREZ ID,偶尔也会用到ENSEMBL ID。

1.1 你手上现在有什么数据

跑完FindAllMarkers之后,你会得到一张类似这样的表格:

# 以Seurat的FindAllMarkers输出为例 p_val avg_log2FC pct.1 pct.2 p_val_adj cluster gene 1 0 1.892 0.998 0.110 0 0 CD3D 2 0 1.734 0.925 0.083 0 0 CD3E 3 0 1.612 0.891 0.070 0 0 IL7R

看到gene那一列没有?CD3D、CD3E、IL7R,这些都是基因符号。人眼看得懂,但很多富集分析工具的后端数据库并不拿这套符号来做匹配。clusterProfiler的enrichGO函数在底层调用的是org.Hs.eg.db这类注释包,它内部的主键是ENTREZ ID。你可以把ENTREZ ID理解成每个基因在NCBI体系里的“身份证号”,而Gene Symbol顶多是“常用名”。常用名有重名、有别名、有历史废弃名,但身份证号是唯一的。

1.2 富集分析工具背后的ID依赖

不是说工具不能直接收SYMBOL,而是收SYMBOL需要额外一步映射,这步出错率比你想象的高得多。拿clusterProfiler来说,如果你强行把SYMBOL塞进enrichGO而不做转化,它要么报错,要么结果里大部分基因丢失,最后富集出来的通路是残缺的。类似的情况在DAVID、Metascape这些在线工具里也有,只是它们内置了映射流程,表面上看着“好像”能直接收SYMBOL,但实际上它们也是先在后台做了一次ID匹配,匹配不上的基因就悄悄丢了。

GO富集分析的本质是:你给它一组基因,它去统计这组基因在哪些GO条目(Gene Ontology term)里显著富集。如果输入的基因ID有一半匹配不上,那统计的基础就塌了一半,结论自然不可信。这就是为什么做富集前必须先做marker基因转化,且要监控转化率。

1.3 “转化”才是整条链路上最容易出错的一环

我把话说直白一点:marker基因筛选做得再漂亮,富集参数调得再精细,只要ID转化这步出问题,后面全都白搭。而且这步的错误是静默的——工具不报错,但结果悄悄变差。你拿到一个看着合理的GO结果,其实背后的基因列表已经丢了三成,这种情况我见过太多。

所以判断一个人生信功底深不深,不用看他跑过多少流程,直接问他:“你的marker基因从SYMBOL转ENTREZ ID的转化率是多少?”能答上来的人,说明他真的被坑过。答不上来的,大概率还没走到这一步,迟早会回来补课。

2. marker基因的筛选口径:不是所有差异基因都能拿去做GO分析

ID转化是技术问题,但在此之前还有一个更前置的问题:你喂给转化和富集的基因列表,本身选得对不对。很多人FindAllMarkers跑完,把所有p_val_adj < 0.05的基因全导出,一个cluster动不动就好几千个基因,全塞进富集分析里——这样出来的结果会又臭又长,富集到几十页通路,根本没法看。

2.1 FindAllMarkers的产出到底是什么格式

先确认一下数据结构。FindAllMarkers本质上是对每个cluster做一个“该cluster vs 其他所有细胞”的差异表达检验,输出每一行代表一个基因在某一个cluster下的差异统计量。关键列就四个:

  • p_val:原始P值
  • avg_log2FC:平均log2倍数变化,正数表示在该cluster中高表达
  • pct.1:该cluster中表达这个基因的细胞比例
  • pct.2:其他cluster中表达这个基因的细胞比例
  • p_val_adj:校正后的P值

需要注意的是,avg_log2FC和pct.1、pct.2这三个指标才是真正的生物学筛选依据。p_val_adj只告诉你统计显著性,不告诉你效应量。一个基因可能p值极小,但avg_log2FC只有0.1,这种基因做进富集分析里就是纯噪音。

2.2 阈值怎么定才不会被显著性和效应量带偏

我给团队的推荐默认参数是这样:

markers <- FindAllMarkers(seurat_obj, only.pos = TRUE, # 只保留上调基因 min.pct = 0.25, # 至少在25%的细胞中表达 logfc.threshold = 0.5) # 平均log2FC至少0.5

然后下游再叠加一层硬筛选:

top_markers <- markers %>% dplyr::filter(p_val_adj < 0.05) %>% dplyr::filter(avg_log2FC > 1) # 更严格的效应量门槛

为什么用log2FC > 1而不是0.5?试过几次就明白了:avg_log2FC在0.5到1之间的基因,大部分是低表达基因的波动,它们富集出来的通路语义非常泛化——动不动就是“regulation of transcription”“cell differentiation”这种,看完了等于没看。但log2FC > 1以后,剩下的基因特异性明显增强,富集出来的通路和cluster本身的生物学身份高度吻合。

另外,每个cluster选多少个marker基因做GO也是门学问。我一般是每个cluster选50到300个基因。少于50个,富集统计功效不足,很难打出显著通路;多于300个,结果冗余度过高,简化都简不过来。

2.3 我给团队定的筛选模板

为了方便复现,我把筛选逻辑写成一个固定流程。你在自己的项目里直接改数据集路径就能跑:

# 筛选每个cluster的marker用于GO分析 library(dplyr) get_go_input <- function(markers, log2fc_cut = 1, padj_cut = 0.05) { markers %>% dplyr::filter(p_val_adj < padj_cut, avg_log2FC > log2fc_cut) %>% dplyr::arrange(cluster, desc(avg_log2FC)) %>% dplyr::group_by(cluster) %>% dplyr::top_n(200, wt = avg_log2FC) %>% # 每个cluster最多取200个 dplyr::ungroup() } go_input <- get_go_input(markers)

有个细节容易踩:top_n(200, wt = avg_log2FC)这里我用的是avg_log2FC而不是p_val,目的是优先保留效应量大的基因。显著性已经用前面的p_val_adj < 0.05卡过了,不需要在排序的时候再卡一遍。

3. 基因ID转化的实操:bitr这一步的坑位全记录

筛选完marker基因列表,接下来就是标题里说的核心动作:把基因符号转化成GO分析能用的ID。主流工具是clusterProfiler里的bitr函数,背后调用org.Hs.eg.db等物种注释包。这步不复杂,但“转化率”这件事藏着一堆细节。

3.1 基本操作:从SYMBOL到ENTREZID

先看最基础的一版代码:

library(clusterProfiler) library(org.Hs.eg.db) # gene_list 是筛选后的marker基因SYMBOL向量 gene_list <- unique(go_input$gene) # SYMBOL -> ENTREZID converted <- bitr(gene_list, fromType = "SYMBOL", toType = "ENTREZID", OrgDb = org.Hs.eg.db)

跑完之后,你立刻检查一下转化率:

conversion_rate <- nrow(converted) / length(unique(gene_list)) print(conversion_rate)

如果这个数字低于0.85,我建议你停下来先排查,而不是继续往下跑。为什么?因为85%以下说明你的基因注释来源和这个注释包不一致,大概率是基因名版本对不上。

3.2 为什么总是有基因转化不上

基因转化不上,我会按下面的顺序排查,每一步都有具体原因:

  1. 物种不对。这是最蠢也最常见的错误。用了人类数据,结果注释包加载的是org.Mm.eg.db(小鼠),那转化率必然惨不忍睹。反过来也是。常见物种注释包就那几个:人类org.Hs.eg.db,小鼠org.Mm.eg.db,大鼠org.Rn.eg.db,斑马鱼org.Dr.eg.db,果蝇org.Dm.eg.db。如果做的是稀有物种,麻烦更大,后面单独说。

  2. 基因版本更新导致符号废弃。人类基因组注释基本每年更新一次,每次更新都会废弃一部分旧符号。比如你用的是UCSC的旧版本注释比对出来的基因名,那在最新版的org.Hs.eg.db里可能已经被改名,甚至合并成别的基因了。这种情况没有太好的办法,只能先做个模糊匹配,或者把未匹配的基因名挑出来手动去NCBI查一下。

    unmapped <- setdiff(unique(gene_list), converted$SYMBOL) # 手动查看这些基因名 head(unmapped, 30)
  3. 线粒体基因、核糖体基因、免疫球蛋白基因片段。这类基因在注释包里有时候会被标记成特殊条目,比如MT-的基因、某些HLA区域的基因,转换时会因为名称差异对不上。

  4. 包含无法识别的字符。比如基因符号带上了版本号(如“CD3D.1”)或者引号、空格,这些在比对时会被当成完全不同的字符串。遇到这种情况,需要先清洗数据,把版本号后缀剥掉。

# 简单清洗:去掉版本号后缀 cleaned_gene <- gsub("\\..*$", "", gene_list)

3.3 多物种项目的统一处理策略

有一种情况比较特殊:你做的是跨物种分析,比如把人、小鼠的数据整合在一起做一致性聚类,那marker基因的ID转化就不能用单一的OrgDb。我建议的做法是分物种分别转化,再合并:

list_human <- bitr(human_genes, fromType = "SYMBOL", toType = "ENTREZID", OrgDb = org.Hs.eg.db) list_mouse <- bitr(mouse_genes, fromType = "SYMBOL", toType = "ENTREZID", OrgDb = org.Mm.eg.db) # 合并后建议加上物种前缀,避免后续富集的时候混淆 list_human$species <- "human" list_mouse$species <- "mouse" combined <- rbind(list_human, list_mouse)

还有一个容易忽略的坑:小鼠基因的SYMBOL首字母大写,人的也是首字母大写,但小鼠有些基因名首字母不大写。这个差异在你做跨物种合并的时候就会冒出来。比如小鼠的“Cd3d”和“cd3d”如果同时存在,而人的“CD3D”又是另一个东西,不处理好合并时就是灾难。我通常会在合并前统一大小写规则,并且这一步在比对之前做,不要等合并完了才想起来。

4. GO富集分析主流程:参数设置的逻辑

ID转化完成后,才真正进入GO富集分析。这里我用enrichGO做演示,因为它在单细胞分析里最常用,同时也最灵活。

4.1 背景基因必须交代清楚

这是全流程里最容易被忽略、但影响最大的参数。GO富集分析并不是单纯看你给的marker基因富集到了哪里,它需要知道一个“背景”——也就是你从什么基因集合里挑出这些marker的。如果不提供背景,很多工具默认用整个物种基因组作为背景,这在单细胞数据里是严重失真的。

单细胞测序本身有技术性丢失(dropout),有很多基因在多数细胞里检测不到,如果拿全基因组当背景,那些因为技术原因检测不到的基因会被算进“不显著”的池子里,干扰统计。

正确的做法是把背景设定为你这次单细胞分析实际检测到的基因全集,做法是取表达矩阵所有基因的SYMBOL,做同样的转化后作为universe参数传入:

# 从Seurat对象中提取全部基因作为背景 all_genes <- rownames(seurat_obj) all_genes_converted <- bitr(all_genes, fromType = "SYMBOL", toType = "ENTREZID", OrgDb = org.Hs.eg.db) universe <- unique(all_genes_converted$ENTREZID)

然后富集的时候传入这个背景:

ego <- enrichGO(gene = converted$ENTREZID, universe = universe, OrgDb = org.Hs.eg.db, keyType = "ENTREZID", ont = "BP", pAdjustMethod = "BH", pvalueCutoff = 0.05, qvalueCutoff = 0.05, readable = TRUE)

注意到readable = TRUE,这个参数会把结果里的ENTREZID重新映射回SYMBOL,方便你检查富集的通路到底是由哪些具体的基因驱动的。很多人不设这个参数,结果输出一列ID,看起来一头雾水,这一步还是建议打开,直接影响可读性。

4.2 enrichGO的参数选择逻辑

挨个说参数:

  • ont:GO有三个子本体——BP(生物学过程)、CC(细胞组分)、MF(分子功能)。单细胞项目里,我建议默认先跑BP,因为它最能反映细胞类型的功能特征。CC适合做亚细胞定位相关研究,MF则偏向酶活性和结合功能,一般放在补充材料里。我通常三个全跑,但主图用BP。
  • pAdjustMethod:默认BH即可,也就是Benjamini-Hochberg方法,控制假发现率(FDR)。除非你有很强的先验理由,否则不用换。
  • pvalueCutoff和qvalueCutoff:这两个阈值决定了富集条目的筛选严格度。0.05是常规选择,如果你发现结果过于稀疏,可以放宽到0.1试探一下;如果结果多到难以阅读,就收紧到0.01。

有一个比较隐蔽的点:pvalueCutoff过滤的是未校正的P值,qvalueCutoff过滤的是BH校正后的q值。两者是“且”的关系,也就是必须同时满足。很多人只调其中之一,发现结果没变化,就是这个原因。

4.3 为什么单细胞项目我推荐分开做每个cluster的富集

还有一点想强调:千万不要把所有cluster的marker基因混在一起去做一次全局GO分析。混在一起的结果就是富集到一堆所有细胞共有的基础生物学过程,比如“翻译”“RNA加工”“细胞周期”,任何一个cluster的特异性都被稀释掉了。正确做法是每个cluster分别提取marker基因,分别做ID转化,分别做GO富集,最后把结果汇总对比。

我一般是一个循环全处理完:

# 按cluster拆分后循环跑GO cluster_list <- split(go_input, go_input$cluster) ego_list <- lapply(names(cluster_list), function(cl) { genes <- cluster_list[[cl]]$gene converted <- bitr(genes, fromType = "SYMBOL", toType = "ENTREZID", OrgDb = org.Hs.eg.db) ego <- enrichGO(gene = converted$ENTREZID, universe = universe, OrgDb = org.Hs.eg.db, keyType = "ENTREZID", ont = "BP", pAdjustMethod = "BH", pvalueCutoff = 0.05, qvalueCutoff = 0.05, readable = TRUE) ego@result$cluster <- cl # 打上cluster标签 ego@result }) names(ego_list) <- names(cluster_list)

这样每一个cluster的结果独立保存,后续画图、做对比都方便。

5. GO结果解读:不是看图说话,而是看富集方向

拿到enrichGO结果之后,最经典的输出是一张dotplot。图上每个点代表一个GO条目,横轴是GeneRatio(富集到该条目的基因数占输入基因数的比例),纵轴是条目描述,颜色代表P值或q值。很多人一看到点大颜色红就觉得“结果好”,其实这只是最表层的信息。

5.1 从dotplot到实际生物学结论

我解读GO结果一般会按三个层次来看:

第一层,看富集到了哪些已知的细胞身份相关过程。比如你在注释里把某个cluster定义成了CD8阳性T细胞,那GO的BP结果里应当出现T细胞活化、T细胞介导的细胞毒性、免疫应答相关的条目。如果这些过程显著富集,说明注释方向是对的,marker基因筛选也没有跑偏。

第二层,看富集到的新信息。GO不光是验证你已经知道的东西,更重要的是提供新的生物学方向。一个cluster如果富集到“interferon-gamma production”“response to type I interferon”,这说明这群细胞可能在抗病毒免疫中扮演特殊角色,值得在后续实验中验证。

第三层,看主通路图。dotplot以外,enrichGO结果还有一个重要的输出叫Gene-Concept Network(基因-概念网络图)。它把富集到的条目和对应的基因之间的关系画出来,能直观看出一个基因是否同时出现在多个条目里。如果一个基因出现在大量通路中,你要小心:它可能是高度多效性基因,并不能代表这个cluster的真正特异性。

5.2 高冗余处理的simplify

GO条目本身存在大量层次结构上的重叠,比如“regulation of T cell activation”“positive regulation of T cell activation”“T cell activation”这三个条目在生物学上高度相关但都被统计为独立条目。结果里经常出现十个条目描述像在说同一件事。这时候就需要用simplify做冗余削减。

ego_simplified <- simplify(ego, cutoff = 0.7, measure = "Rel", semData = NULL)

这个函数用语义相似度(semantic similarity)计算条目之间的距离,把相似度大于阈值的条目合并成代表条目。cutoff设0.7是常规用法。做多个cluster的话,建议在循环里紧跟在enrichGO后面直接simplify,不然最后结果冗余度会叠加得很厉害。

5.3 那些“不该出现”的富集结果

做单细胞GO分析的时候,有几种富集结果你看到就要警惕,不是它错了,但需要额外处理:

  • 大量“ribosome”“translation”条目:这通常意味着cluster里混入了破裂细胞或者线粒体高含量细胞,它们的核糖体蛋白基因(RPS、RPL)和线粒体基因(MT-)表达量非常高,把富集分析占满。处理方式是在上游QC阶段就去掉线粒体基因比例过高的细胞,而不是在这步硬着脸皮解释。
  • 富集到“olfactory receptor activity”这种嗅觉受体条目:在非嗅觉组织里出现这种结果,大概率是基因注释背景噪音。这些基因通常低表达且在多组织都有残留检测,不是真正的marker,建议过滤后再跑一次。
  • 所有cluster富集结果几乎一模一样:这说明你筛选marker基因的阈值太松,差异信号的强度不够,导致每个cluster的输入基因列表重叠度过高。解决办法是提高log2FC阈值,或者引入pct.1和pct.2的差值筛选——只保留在该cluster中表达细胞比例显著高于其他cluster的基因。

5.4 不同cluster之间的对比策略

单细胞项目的GO分析,从来不是只有一个cluster的结果有意义。除非你只关心某一种特定细胞,否则最终要做的是横向对比多个cluster的富集结果。我常用的有两种策略:

第一种是对比“Hey,哪个cluster特异性地富集了这个通路”。最简单的方式是做一个表格,行是GO条目或通路名称,列是cluster,单元格里填是否有显著富集或者GeneRatio。一眼就能看出通路的分布模式。

# 构建一个简单的富集矩阵 library(tidyr) combined_result <- do.call(rbind, ego_list) plot_data <- combined_result %>% dplyr::select(cluster, Description, GeneRatio, p.adjust) %>% dplyr::mutate(significant = ifelse(p.adjust < 0.05, 1, 0)) %>% dplyr::filter(significant == 1) ggplot(plot_data, aes(x = cluster, y = Description, size = GeneRatio, color = p.adjust)) + geom_point() + theme_minimal() + theme(axis.text.x = element_text(angle = 45, hjust = 1))

第二种是用比较簇的结果直接展示差异:比如CD8 T细胞cluster和CD4 T细胞cluster各自富集了什么,用upset图展示交集。交集的部分代表两个cluster共享的细胞基础功能,差集部分才是那类细胞区别于其他细胞的核心身份。做这种图比较适合用在文章中的补充图,信息密度高又不需要大篇幅解释。

6. 我踩过的那些坑:几个真实案例和经验建议

写到这里,纯粹的方法论部分就差不多结束了,但我觉得还是有必要放几个真实踩坑案例,因为有些问题不是你看教程就能预判的,必须有人把烂摊子摊开给你看。

6.1 小小的一行“readable = TRUE”引发的连锁爆炸

我第一次跑富集分析的时候,没有设readable = TRUE。结果里全是ENTREZ ID,我对着NCBI一个个查,花了几个小时才搞明白那些数字都是些什么基因。后来吸取教训,每次跑都带上readable = TRUE,但很快又发现新问题:有些GO条目富集不到任何可显示的基因,因为那些基因的SYMBOL在最新注释里已经废弃了。遇到这种情况,我会手动设置drop = TRUE,把这些无基因条目在输出里剔除,不然画图的时候会多出一排空点。

6.2 我一次把全部cluster混在一起做的糟糕经历

早期有一回,我图省事,把所有cluster的marker基因合并成一个大列表去做GO,结果富集出来的通路几乎全部是线粒体翻译相关条目。当时我的第一反应是数据有问题,但查来查去才发现,问题出在输入列表——合并的基因列表里,高表达的线粒体基因占据绝对多数,统计上自然全面压制了其他信号。那次之后我彻底改了习惯,每个cluster独立成组,宁可多跑几十次循环,也不合并处理。

6.3 转化率的校验应当被纳入流程的固定动作

现在我在写分析流程脚本时,每做一个新物种、新版本数据,第一件事就是跑一个转化率日志。先给自己看,再给合作者看。不需要复杂的可视化,就一个数字,转化率多少,未匹配的基因里有没有明显的高表达基因。如果转化率不达标,不往下走。这种做法虽然简单,但帮我挡掉了至少两三次无效的富集分析。

6.4 不同版本注释包导致的重复性危机

还有一个容易被忽视的问题:org.Hs.eg.db这个包会不定期更新,版本不同注释内容也会变。今年跑和明年跑,同样的数据可能会有细微的差异。如果要发表文章,强烈建议在方法部分记录所用注释包的版本号。另外,如果有条件,把环境固定下来,比如用renv锁住R包版本,保证可重复性。不然审稿人要求复核的时候,你本地跑的代码可能已经跑不出来了。

6.5 分析之外的加分项:把GO结果和细胞注释接起来

最后分享一个能明显提升分析质量的小技巧。GO富集结果不要只停留在“哪些通路显著富集”,把它和上游细胞注释结合起来看往往会更有说服力。比如你注释出一个类似“Treg”的细胞群,它的GO结果里出现T细胞活化、免疫负调控相关的条目,那这个注释的可信度就上来了。这是给审稿人看的最有说服力的证据之一。

我在实际项目中,通常会为每一个关注的主要细胞类型单独做一个小结,内容包括:这个cluster的marker基因前20个、显著富集的GO条目top10、以及这些通路是否支持当前的注释身份。不用写多,整理清楚放在补充材料里,效果非常好。

到了这一步,marker基因转化和GO富集分析这条线就算完整走通了。回看整个过程,技术本身不复杂,复杂的是那些隐藏在参数和注释背后的细节。下一篇文章我大概率会写KEGG通路富集以及GSEA在单细胞数据里的应用,这两个和GO是黄金搭档,但如果上面的细节没处理好,它们同样会给你挖出各种坑。先把这步练扎实,再往下走不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询