单细胞数据分析走到比较后期的时候,大家基本都会碰上一个问题:轨迹是画出来了,细胞也沿着发育路径排好了,但接下来呢?总不能只发一张漂亮的轨迹图就收工吧。我见过不少朋友卡在这一步——Monocle2跑得很顺,拟时轨迹一眼看去也很漂亮,可一到解释“这些沿着轨迹变化的基因到底在干什么”就不知道从哪儿下手了。这篇文章就把我在实际项目里反复跑通的这套流程完整讲一遍:从Monocle2的拟时轨迹出发,把BEAM筛出来的基因按模块拆开,再做GO富集解析,一步步落地到可以写进论文的结果。适合正在用单细胞转录组做分化、发育或者疾病进展研究的同学,也适合刚接触拟时分析和功能富集、想找一套能直接复用的标准流程的新手。
1. 整体思路:从拟时轨迹到基因模块再到功能注释
先说清楚一个概念问题。拟时轨迹分析不是拿来画图的,它的核心价值在于把“离散的细胞类型”重构成“连续的发育过程”。单细胞转录组测出来的是一个一个独立的细胞,我们看到的cluster其实是人为切出来的状态;但实际上细胞从一种状态过渡到另一种状态,中间会经历一系列连续的转录组变化。Monocle2做的事情,就是根据基因表达量的相似性,在细胞之间构建一棵最小生成树,然后把每个细胞放到这棵树上的某个位置。这个位置对应的数值就是拟时值(pseudotime),代表这个细胞在发育进程里所处的阶段。
但这只是第一步。真正有生物学意义的问题永远是:哪些基因驱动了这条轨迹?这些基因在轨迹的不同位置有什么样的表达模式?它们又参与了哪些生物学过程?所以你只拿到一条轨迹是交不了差的,必须往下走两步:先把随拟时显著变化的基因筛出来,然后对它们做功能富集。
Monocle2在这个环节有一个非常好用的函数叫BEAM,全称是Branched Expression Analysis Modeling。它本质上是对每个基因拟合一个随拟时变化的广义加性模型,然后检验这个模型里表达量是不是显著依赖拟时位点。特别之处在于,BEAM还专门考虑了分支点(branch point)的情况——如果细胞轨迹在某个地方分叉成两条命运,比如造血干细胞向髓系和淋系分化,BEAM会分别检验基因在分叉前后的表达模式是否发生了显著的对应变化。这一步筛完,你会拿到一个包含所有候选基因的列表,q值排在最前面的就是和轨迹推进最相关的那些。
有了候选基因列表,接下来就要考虑复杂度的问题了。几百上千个基因直接一个个看是看不完的,而且很多基因在轨迹上呈现的表达趋势是相似的,比如都在前期高表达然后逐渐下降,或者都在分叉点之后才开始上调。把表达模式相似的基因归成一个模块,再对每个模块分别做GO富集,得到的结果会清晰很多——相当于把几百个基因压缩成三五个“主题”,每个主题对应一个或几个生物学过程。我自己的习惯是用Monocle2自带的plot_genes_branched_heatmap做模块划分,这个函数内部会用层次聚类把基因聚成指定的簇数,然后直接出热图,非常直观。
整个管线的设计逻辑其实可以总结成一条线:原始表达矩阵 → 构建CDS → 找排序基因 → 降维排序 → 轨迹图 → BEAM筛基因 → 基因模块 → GO富集 → 生物学解释。每一步的输入输出都非常清晰,出了问题也容易定位,这也是我推荐新手直接复刻这套流水线的原因。
2. 实操前的准备:环境、数据与关键参数
2.1 R环境与软件包清单
Monocle2是个R包,版本要求比较讲究。我最初在R 4.2上直接install.packages("Monocle"),结果一堆依赖冲突,最后老老实实退回R 4.0.5才顺利装完。给个建议:如果你只是想跑Monocle2的流程,直接用R 4.0.x配BiocManager是最稳妥的搭配。需要装的包包括:
Monocle(核心分析包)clusterProfiler,org.Hs.eg.db这类注释包(按物种选),DOSE(GO富集与可视化)pheatmap,ggplot2(热图和自定义绘图)dplyr,tidyr(数据处理)
安装的时候注意,Monocle2依赖DDRTree、plyr、reshape2等老牌包,如果遇到版本报错,优先检查这几个依赖是否完整。
提示:Monocle2的对象是
CellDataSet,它的数据结构和你平时用的Seurat对象完全不一样。如果你之前的主流程是Seurat,需要先把数据从Seurat对象里导出来,再重新构建Monocle的CDS对象,不能直接调用。
2.2 从Seurat到Monocle的数据转换
这个环节看起来简单,但实际是很多人翻车的地方。Monocle需要的输入有三个:表达矩阵、细胞信息表(sample sheet)、基因信息表(gene annotation)。从Seurat转换的标准做法是:
library(Seurat) library(Monocle) # 假设seurat_obj是你已经完成聚类和注释的Seurat对象 expr_matrix <- as.matrix(seurat_obj@assays$RNA@counts) sample_sheet <- seurat_obj@meta.data gene_annotation <- data.frame(gene_short_name = rownames(expr_matrix)) rownames(gene_annotation) <- rownames(expr_matrix) pd <- new("AnnotatedDataFrame", data = sample_sheet) fd <- new("AnnotatedDataFrame", data = gene_annotation) cds <- newCellDataSet(expr_matrix, phenoData = pd, featureData = fd, expressionFamily = negbinomial.size())这里有一个非常关键的参数:expressionFamily。如果你用的是UMI计数数据(10X Genomicse的矩阵基本都是),应该选negbinomial.size(),这是为UMI设计的负二项分布;如果你用的是全长转录组或者微阵列数据,表达量是连续的,要用gaussianff()。选错的话,后面的离散度估计和差异检验结果都会失真,而且很难排查,因为程序不会报错,只是结果不对劲。
基因注释表还有个容易忽略的坑:gene_short_name这一列必须存在,后续很多绘图函数都要靠它显示基因名。我第一次跑的时候只放了gene_short_name,但发现有些函数会去读gene_biotype之类的列,虽然不强制,但建议把已知的基因类型信息也加进去,省得后期要补。
2.3 排序基因的选择:决定轨迹走向的核心参数
拟时轨迹分析成立的前提是:你必须选对用来“排序”的基因。这些基因应该是在发育过程中表达量发生显著变化的基因,否则无法区分细胞在轨迹上的位置。Monocle2官方推荐的做法是用differentialGeneTest对细胞类型(或聚类分组)做差异检验,把显著差异的基因作为排序基因:
cds <- estimateSizeFactors(cds) cds <- estimateDispersions(cds) diff_test_res <- differentialGeneTest(cds, fullModelFormulaStr = "~Cluster", reducedModelFormulaStr = "~1", cores = 4) ordering_genes <- row.names(subset(diff_test_res, qval < 0.01)) cds <- setOrderingFilter(cds, ordering_genes)这个策略的思路很简单:如果某个基因在不同细胞类型之间的表达量本身没有差异,那它就不可能帮助区分细胞在发育轨迹上的先后位置,留着反而会成为噪声。实际操作中,我一般会把q值阈值压得更严一些,比如qval < 0.001,同时还会看一眼筛出来的基因数量,太少了(比如不到100个)说明分组信息或数据质量可能有问题,太多了(超过3000个)也会让降维计算变得很慢,需要适当调整阈值。
estimateDispersions这一步也提醒一下:它会根据每个基因的表达均值和方差估计离散度参数,需要基于expressionFamily做拟合。如果你的数据里存在某些基因在所有细胞里表达量都为0,这个函数可能会报错,最好的办法是提前过滤掉在极少数细胞中表达的基因。
3. 核心环节:轨迹构建与基因模块划分
3.1 降维与细胞排序的完整过程
排序基因选定后,就进入Monocle2的降维和排序阶段:
cds <- reduceDimension(cds, max_components = 2, method = "DDRTree") cds <- orderCells(cds)reduceDimension是Monocle2区别于老版Monocle的关键。max_components = 2代表把高维表达空间映射到二维平面,method = "DDRTree"是用反向图嵌入的降维方法,它会同时学习一个主树结构来拟合细胞轨迹。这里不要选method = "ICA",那是Monocle1的老方法,纯独立成分分析,没有显式的轨迹树结构,和后面的BEAM分支分析配合不好。
跑完orderCells后,第一件事是检查轨迹方向。orderCells默认会从你自己指定的root_state开始计时,如果不指定,它会随机选一个状态作为起点。实际操作里我几乎每次都发现默认起点不是我要的起点——比如我做造血分化,默认起点往往落在终末分化状态,导致整个拟时轴反了。处理方法是调用plot_cell_trajectory画出轨迹并查看状态编号,然后用:
cds <- orderCells(cds, root_state = 3) # 假设状态3是早期祖细胞把起点纠正过来。这一步直接决定后续所有基因模块的生物学含义,务必在进入BEAM之前反复确认。
3.2 BEAM分析:找出随拟时变化的基因
轨迹方向确认无误后,就可以跑BEAM了。BEAM的输入需要指定branch_point,即分支点的编号。先画出轨迹图,轨迹上会标注State 1、State 2等状态编号,如果存在分叉结构,图上会有一个明显的分支点,编号通常从1开始。确定好分支点编号后:
BEAM_res <- BEAM(cds, branch_point = 1, branch_labels = c("Branch 1", "Branch 2"), cores = 4) BEAM_res <- BEAM_res[order(BEAM_res$qval),] BEAM_res <- BEAM_res[, c("gene_short_name", "pval", "qval")]这一步返回的是每个基因的p值和q值,q值表示该基因的表达是否在不同分支间随拟时有显著差异。常规阈值取qval < 0.05,但我个人建议先看qval < 1e-5这个更严格档位筛出来的基因数量——如果数量还在300以上,就直接用这个更严的阈值,富集结果会更干净。
筛选完基因后,官方推荐用热图展示这些基因的表达模式,同时把基因模块分出来:
plot_genes_branched_heatmap(cds, gene_subset = significant_genes, branch_point = 1, num_clusters = 4, cores = 4, show_rownames = TRUE)num_clusters就是你想把基因分成多少个模块。我一般会从4到6开始试,看热图分行是否清晰、每个模块的基因数是否均匀。选太少(比如2),模块内部还是混杂着好几种表达模式;选太多(比如10),又会把本来相似的模式硬拆开,后面做GO富集时每个模块基因数太少,统计功效不足。这个参数没有绝对标准,多跑几次对比着看就是最好的办法。
3.3 从热图对象里把模块基因取出来
这里有个细节很多人不知道:plot_genes_branched_heatmap绘图的返回值其实是一个列表,里面包含了经过聚类后每个模块的基因归属信息。如果你想对每个模块单独做GO富集,就必须从这里把基因列表提出来,而不是肉眼看着热图手动挑基因名,那样既不准确又不可复现。
heatmap_list <- plot_genes_branched_heatmap(cds, gene_subset = significant_genes, branch_point = 1, num_clusters = 4) # 查看数据结构 str(heatmap_list) # 提取基因模块,heatmap_list中包含ph_tree对象 library(ggtree) phylo_tree <- heatmap_list$ph_tree # 用cutree按num_clusters切分聚类结果 module_assignments <- cutree(phylo_tree, k = 4)不过要说明一下,plot_genes_branched_heatmap内部用的聚类方式在不同版本里实现略有差异。更稳妥的做法是自己用pheatmap重新跑一次层次聚类,把基因表达矩阵按拟时顺序排列后标准化,然后手动指定聚类数切分。我在很多项目里其实是两条路都跑,比对结果一致再用,这样可以避免因为plot_genes_branched_heatmap的版本差异导致模块划分不稳定。
4. GO富集解析实战:工具选型与代码流程
4.1 为什么我优先用clusterProfiler
GO富集的工具有很多,DAVID是网页版的代表,topGO是R里老牌的经典包,clusterProfiler则是目前最主流的选择。我自己基本只用clusterProfiler,原因有三:第一,它支持bitr做ID转换,基因符号转ENTREZID一步到位;第二,它的enrichGO接口做得干净,直接传Entrez ID、指定OrgDb就能跑;第三,配套的dotplot、barplot、cnetplot可视化函数可以直接出发表级别的图,不用再自己折腾ggplot2。
topGO我只有在需要自定义GO有向无环图结构做精细统计时才回去用,日常工作里clusterProfiler出图快、统计规范、代码量少,对新手友好太多了。
4.2 模块基因做GO富集的完整代码
拿到每个模块的基因符号列表后,常规流程如下:
library(clusterProfiler) library(org.Hs.eg.db) module_genes <- c("GENE_A", "GENE_B", "GENE_C") # 某个模块的基因符号 # 第一步:ID转换 gene_entrez <- bitr(module_genes, fromType = "SYMBOL", toType = "ENTREZID", OrgDb = org.Hs.eg.db) # 第二步:GO富集分析,ont参数可选BP/CC/MF ego <- enrichGO(gene = gene_entrez$ENTREZID, OrgDb = org.Hs.eg.db, ont = "BP", pAdjustMethod = "BH", pvalueCutoff = 0.05, qvalueCutoff = 0.2, readable = TRUE) # 第三步:去冗余,保留代表性条目 ego_simple <- simplify(ego, cutoff = 0.7, by = "p.adjust", select_fun = min) # 第四步:可视化 dotplot(ego_simple, showCategory = 20)我特别想强调simplify这一步。GO的层级结构决定了富集结果里会出现大量语义高度重叠的条目,比如“regulation of cell differentiation”和“positive regulation of cell differentiation”其实是上下位关系,不处理冗余的话,图上会出现一堆看起来差不多的气泡,既占版面又看不出重点。simplify会计算条目间的语义相似度,把相似度超过cutoff的条目合并成一组,每组保留p值最小的那个代表条目,效果立竿见影。
ont参数的选择也值得说说。BP(生物学过程)是我默认的选择,因为拟时轨迹部分的基因模块,最关心的就是过程性的生物学事件,比如分化、迁移、增殖;CC(细胞组分)偶尔有用,比如你想确认某个模块的基因是不是集中定位在细胞膜上;MF(分子功能)更偏酶活性和结合能力,在轨迹分析里我几乎不用,除非在BP里完全筛不出结果。把ont选成ALL也可以,但结果会更杂,后期还得自己删,不如分开跑。
4.3 结果解读的几条实用判据
富集结果的解读比代码本身更需要经验。我自己的习惯是:先看每个模块里排名前10到20的条目是什么主题,把它们归纳成一两句话的生物学描述;然后看模块之间的富集结果是否有明显差异——比如模块1富集到“细胞周期”和“DNA复制”,模块3富集到“免疫应答”和“炎症反应”,这种对比本身就是很好的生物学故事;最后对比富集条目里的基因列表和轨迹热图里基因的表达趋势,手动抽查几个代表基因,确认表达趋势和功能注释是吻合的。这一步虽然费时间,但能帮你发现一些算法上可能漏掉的异常——比如某个基因明明在模块里被归为“分化后期高表达”,但GO注释却指向“胚胎发育早期形态发生”,那就要回去复核是不是聚类或者ID转换出了问题。
还有个小工具我经常用:clusterProfiler的cnetplot可以画出基因与富集条目的关联网络图,对探索性分析很有帮助。不过要注意,网络图不适合直接放进论文正文,太乱了,一般还是用dotplot或者barplot出最终结果图。
5. 常见问题与排查技巧实录
5.1 轨迹排序方向反了怎么办
这个问题出现的频率高到我几乎每次培训都会被问到。表现是整个热图看起来是“倒”的——本该在分化早期的基因出现在了晚期位置。解决方法分两层:如果还没跑BEAM,直接用orderCells(cds, root_state = 正确状态编号)重设根状态即可;如果BEAM已经跑完了,也不用重头跑,只要重新排序细胞后重跑BEAM就行,BEAM本身并不依赖基因模块的划分结果,所以成本不高。
这里有个经验性判断方法:去看几个你已知在早期高表达的特征基因,比如多能性相关基因POU5F1(OCT4)或NANOG,如果它们在plot_genes_branched_heatmap里的位置出现在轨迹末端,那基本可以确定方向反了,不需要犹豫,直接改根状态重跑。
5.2 GO富集结果为空,或条目少得可怜
这是模块基因数太少时最常见的现象。enrichGO要求输入的基因数不能太少,如果一个模块只有二三十个基因,跑出来大概率是空结果。我遇到过不止一次,解决办法有三个:一是放宽pvalueCutoff,从0.05放到0.1先看看趋势,但要注明这个结果是探索性的,不能直接用于正式结论;二是把minGSSize参数调小,默认一般是10,你可以改成5,允许更小的基因集参与富集检验;三是退回上一步,把num_clusters从6改成4,让每个模块的基因数更多,再从模块层面做富集。
另外提醒一点:bitr这一步会把部分基因符号丢弃,因为有些符号在标准注释数据库里查不到,这很正常,不用担心。但如果丢弃比例超过30%,就要回去查基因符号格式是否标准,比如有没有把MIRLET7A之类的特殊符号混进来。
5.3 热图模块划分看起来不干净
有时候模块划分的结果是:某个模块里明显混着两种表达模式的基因,热图上看就是一块区域里既有红又有蓝,交错在一起。这通常不是Monocle2的错,而是plot_genes_branched_heatmap内部聚类时对表达趋势的权重分配和你的预期不一致。我的处理办法是:把模块基因提取出来后,自己用pheatmap再聚一遍。先按拟时顺序排列细胞,对基因表达矩阵做z-score标准化,然后跑层次聚类,看聚类树是否能自然分成清晰的分支。如果自己聚类的结果和Monocle2的模块划分基本一致,说明模块可靠;如果不一致,就以自己聚类的结果为准,重新划分模块后再做GO富集。这个方法麻烦一点点,但能显著提升模块的生物学可解释性。
5.4 稀有细胞类型导致的轨迹断裂
还有一种情况:某些细胞群体因为数量太少,在DDRTree降维后没有形成连续的轨迹结构,导致轨迹图出现断开的片段。面对这种情况,不要急着调整算法参数。先检查这部分稀有细胞是不是技术伪影(比如低质量细胞或双细胞),如果是真实生物学存在但数量极少的群体,可以考虑在轨迹分析前不把它们纳入,或者适当放宽min_expr过滤阈值。这个选择会影响你的结论,所以最好在方法部分写清楚。
结尾
其实把整套流程拆开看,每一步都不是什么黑魔法,但串起来之后,从轨迹到基因模块再到GO富集结果,就能讲出一个完整的生物学故事。我自己在多个项目里反复跑过这套管线,最深的体会有两条:第一,拟时方向确认这个步骤一定不能省,宁可多花十分钟肉眼检查特征基因的表达位置,也不要等热图和富集都跑完了才发现方向反了;第二,GO富集结果一定要回到轨迹上看表达模式,两边对得上,这个富集结果才敢写进文章里。最后再分享一个小技巧:把每次跑的sessionInfo()保存成文本文件,和结果放在同一个目录下,这样审稿人问起版本问题,你随时都能给出准确答案,也能保证隔几个月后自己复现时不至于因为R包版本变动而对不上结果。