一、写在前面
单细胞RNA-seq测的是某一瞬间每个细胞的转录组快照------但生物学过程(分化、极化、应激反应)是连续动态的。实验里我们拿不到"同一个细胞随时间变化"的纵向数据,只能拿到一群处于不同进程状态的细胞。
📌 通俗理解:把每个细胞想象成一张照片。你手里有几千张照片,拍的是同一场"细胞分化"的不同瞬间,但照片上没有时间戳。拟时序分析就是通过"照片内容的相似程度",把这些照片按剧情顺序重新排成一列,给每个细胞打上一个"拍摄于第几分钟"的标签------这个标签就是伪时间(Pseudotime)。
一个经典的生物学问题:心梗后,浸润的单核来源巨噬细胞(如Ccr2+炎症性巨噬)会逐渐向修复性表型(如Trem2+巨噬)转化。这不是一个"有/无"的开关,而是一条连续的分化轨迹。拟时序分析能回答:
-
轨迹的起点在哪?终点在哪?中间有没有分叉?
-
处理vs对照的细胞在这条轨迹上推进速度是否不同?
-
哪些基因的表达随轨迹单调变化(轨迹驱动基因)?
目前做拟时序最常用的工具就是Monocle系列------准确说是Monocle2和 Monocle3。两者底层算法完全不同。
二、Monocle2:DDRTree时代
Monocle2(Qiu et al., Nature Methods, 2017)的核心是反向图嵌入(Reversed Graph Embedding, RGE)+ DDRTree:把高维表达数据"压"进一个二维的树状骨架,再沿树计算伪时间。
2.1 原理:经典三步走
|
步骤
|
函数
|
做了什么
|
| --- | --- | --- |
|
- 选排序基因
| setOrderingFilter |
挑出几百~几千个"有故事"的基因(高离散度或差异基因),轨迹的形态几乎完全由它们决定
|
|
- 学习树
| reduceDimension(method = "DDRTree") |
用 DDRTree 把高维空间嵌成一个二维树:树干+分叉点,细胞挂在树枝上
|
|
- 算伪时间
| orderCells |
沿树从"根"到"叶"给每个细胞算伪时间;根由 root_state 指定
|
Monocle2 里有两个容易混淆的核心概念:
-
State:树上的一段"树枝",每个细胞属于一个 State;
-
Pseudotime:从根 State 出发、沿树走到每个细胞的"路程"。
2.2 代码实战(以公开数据 Paul et al. 2015 髓系分化为例)
php
library(monocle)
library(Seurat)
library(scRNAseq)
# ---- 数据准备:Paul et al. 2015 (Cell),小鼠髓系祖细胞分化数据 ----
sce <- PaulHSCData() # 返回 SingleCellExperiment 对象
head(colData(sce)) # 查看注释列(细胞类型等,列名以实际为准)
counts_mat <- as.matrix(counts(sce))
pd <- new("AnnotatedDataFrame", data = as.data.frame(colData(sce)))
fd <- new("AnnotatedDataFrame", data = data.frame(
gene_short_name = rownames(sce), row.names = rownames(sce)))
cds2 <- newCellDataSet(counts_mat,
phenoData = pd, featureData = fd,
expressionFamily = negbinomial.size()) # UMI 数据用负二项
# ---- 标准化 ----
cds2 <- estimateSizeFactors(cds2)
# ---- 第 1 步:选排序基因 -------
# 注意:博主没用 estimateDispersions() + dispersionTable()!
# Monocle2 内部仍调用 dplyr 0.6 时代的 group_by_(),在 dplyr >= 1.0 下会报错:
# "group_by_() was deprecated in dplyr 0.7.0 and is now defunct"
# 改用 Seurat 的高变基因作为排序基因,效果一致且完全兼容现代环境:
seu <- CreateSeuratObject(counts = counts_mat)
seu <- NormalizeData(seu)
seu <- FindVariableFeatures(seu, selection.method = "vst", nfeatures = 2000)
ordering_genes <- VariableFeatures(seu)
cds2 <- setOrderingFilter(cds2, ordering_genes)
# ---- 第 2 步:DDRTree 降维 ----
cds2 <- reduceDimension(cds2, max_components = 2, method = 'DDRTree')
# ---- 第 3 步:排序(先看 State 图,再指定根)----
cds2 <- orderCells(cds2)
p1 <- plot_cell_trajectory(cds2, color_by = "State") # 先看树长什么样
print(p1)
# 根据 State 图 + 生物学先验,把祖细胞所在的 State 指定为根
cds2 <- orderCells(cds2, root_state = 1) # 数字换成你看到的 State
p2 <- plot_cell_trajectory(cds2, color_by = "Pseudotime")
print(p2)
ggsave("Monocle2_Pseudotime.pdf", p2, width = 6, height = 5)
2.3 Monocle2的三大痛点
-
排序基因决定轨迹:轨迹形态几乎完全由setOrderingFilter选的基因决定,选择时需要慎重;
-
树是二维的:DDRTree 只给一个2D树状骨架,细胞过多时会导致挤压严重,分支结构丢失;
-
不维护了:该实验室已把重心转向Monocle3,Monocle2在较新的R环境下兼容性问题频发------典型的就是上面的 estimateDispersions() 报错(dplyr 1.0 移除了group_by_())。
三、Monocle3:UMAP + 主图学习
Monocle3(Cao et al., Nature, 2019)彻底换了思路:先降到UMAP,再在UMAP上"学习"出一条图(graph)作为轨迹。
3.1 原理:先降维,再连图
|
步骤
|
函数
|
做了什么
|
| --- | --- | --- |
|
- 预处理
| preprocess_cds |
PCA 降维
|
|
- UMAP
| reduce_dimension |
UMAP 降到 2D
|
|
- 聚类
| cluster_cells |
Louvain 聚类,帮助后续建图
|
|
- 学主图
| learn_graph |
在 UMAP 坐标上学习"主图(principal graph)"------细胞图的骨架
|
|
- 算伪时间
| order_cells |
计算每个细胞沿图走到根节点 的距离
|
Monocle3 与 Monocle2 的两个关键差异:
-
不依赖排序基因:learn_graph直接基于UMAP低维坐标上细胞的"距离"建图,轨迹形态由降维结果决定;
-
根可以精确到"节点":order_cells(root_pr_nodes = ...) 指定图上的节点为根,而不是Monocle2的State级别。
3.2 代码实战(同一份 Paul 数据)
php
library(monocle3)
library(scRNAseq)
library(igraph)
sce <- PaulHSCData()
cds3 <- new_cell_data_set(counts(sce),
cell_metadata = as.data.frame(colData(sce)),
gene_metadata = data.frame(
gene_short_name = rownames(sce), row.names = rownames(sce)))
# ---- 预处理 + 降维 ----
cds3 <- preprocess_cds(cds3, num_dim = 50) # 小数据 50 维即可,大数据可升到 100
cds3 <- reduce_dimension(cds3) # 默认 UMAP
# ---- 聚类 + 学主图 ----
cds3 <- cluster_cells(cds3, k = 25) # k 越大图越稀疏
cds3 <- learn_graph(cds3, use_partition = FALSE) # FALSE:全局一张图,保证起终点连通
# ---- 定位根节点:祖细胞群体所在的节点 ----
# 注意:PaulHSCData() 的 colData 只有 plate/mouse 等实验信息,没有自带细胞类型标签,
# 所以不能用"按标签找群体"的官方写法,改用"祖细胞标记基因"定位根细胞:
# 表达越高的细胞越接近轨迹起点。Gata2 是造血祖细胞标志(换成你自己的祖细胞标志基因)。
get_earliest_principal_node_marker <- function(cds, marker = "Gata2", top_frac = 0.2) {
cnt <- SingleCellExperiment::counts(cds)
if (!marker %in% rownames(cnt)) stop("marker 基因不在表达矩阵中: ", marker)
g <- as.numeric(cnt[marker, , drop = TRUE])
pos <- g[g > 0]
if (length(pos) == 0) stop("marker 基因在所有细胞中表达均为 0: ", marker)
thr <- sort(pos, decreasing = TRUE)[max(1, round(length(pos) * top_frac))]
root_cells <- colnames(cds)[g >= thr]
closest_vertex <- as.matrix(
cds@principal_graph_aux[["UMAP"]]$pr_graph_cell_proj_closest_vertex[colnames(cds), ])
igraph::V(principal_graph(cds)[["UMAP"]])$name[
as.numeric(names(which.max(table(closest_vertex[root_cells, ]))))]
}
root_node <- get_earliest_principal_node_marker(cds3, marker = "Gata2")
cds3 <- order_cells(cds3, root_pr_nodes = root_node)
p3 <- plot_cells(cds3, color_cells_by = "pseudotime",
show_trajectory_graph = TRUE, label_groups_by_cluster = FALSE)
print(p3)
ggsave("Monocle3_Pseudotime.pdf", p3, width = 6, height = 5)

Monocle3 伪时间轨迹
▲ 图 1:Monocle3 伪时间轨迹(基于 Paul et al. 2015 髓系分化数据重绘)
散点是 UMAP 上的细胞,深灰色分支线是
learn_graph学出的主图;颜色代表伪时间------深蓝为轨迹起点(Gata2+ 祖细胞所在区域),经紫红色过渡到橙黄的末端分化群体。可以看到轨迹并非一条直线,而是从祖细胞出发分出多条分支,分别通向不同终末谱系。如果研究目标只关心其中某一条路径(比如"祖细胞 → 单核/巨噬"),就需要用下一节的choose_graph_segments把这段切出来。
📌 数据没有细胞类型标签怎么办? PaulHSCData() 的 colData 不含细胞类型(只有 plate/mouse 等实验信息),所以根节点用"祖细胞标记基因"定位(见上方函数)。如果你的数据自带标签列,改回官方写法即可:cell_ids <- which(colData(cds)[, "cell.type"] == "GMP");想要现成的带标签造血分化数据,可用scRNAseq::NestorowaHSCData()(colData 的fine列含细胞类型)。
3.3 真正的"起点 → 终点"限定轨迹:choose_graph_segments
很多项目需要定向轨迹------不是全图伪时间,而是"从A到B的这一段"。典型需求:只看Ccr2+炎症性巨噬 → Trem2+修复性巨噬这条分化路径。
Monocle2 没有原生手段做这件事(只能靠截取细胞子集重新跑);Monocle3用choose_graph_segments直接"切段":
php
# 终点节点:换成"终末分化标志基因"(Mpo 是髓系终末标志;换成你研究的终点基因)
end_node <- get_earliest_principal_node_marker(cds3, marker = "Mpo")
# 提取 root_node -> end_node 这一段轨迹
cds_sub <- choose_graph_segments(cds3,
starting_pr_node = root_node,
ending_pr_nodes = end_node)
# 段内伪时间自动重新归一化到 0~1
p4 <- plot_cells(cds_sub, color_cells_by = "pseudotime",
show_trajectory_graph = TRUE)
print(p4)
# 轨迹驱动基因:Moran's I 检验(Monocle2 对应 differentialGeneTest)
pr_test <- graph_test(cds_sub, neighbor_graph = "principal_graph", cores = 4)
head(pr_test[order(pr_test$morans_I, decreasing = TRUE), ])
四、核心差异对比表

五、到底该怎么选?
✅ 仍然用 Monocle2 的场景
-
复现/对照早期文献的流程与图(2014--2019年的文章几乎全是 Monocle2的DDRTree图);
-
细胞数少(几百~几千)、追求整洁的树状分叉图。
✅ 优先用Monocle3的场景
-
新项目起步:维护活跃、文档全、与Seurat生态无缝衔接;
-
需要定向轨迹(如"炎症巨噬→ 修复巨噬"这一段),用 choose_graph_segments;
-
细胞量大;
-
想在UMAP上直接看轨迹,保留与分群图一致的视觉语言。
六、避坑清单(汇总)
-
Monocle2 的排序基因不要用"全部基因":用 dispersionTable 选高离散基因或差异基因(几百到两三千个),全基因会让树失去结构。
-
根 State 不能盲选 root_state = 1:先画color_by = "State" 的图,结合生物学先验(祖细胞 / 起始群体)指定根,否则伪时间方向可能完全相反。
-
Monocle3 默认 use_partition = TRUE:细胞会被拆成多个不连通的partition,根和终点可能不在同一张图里。要做全局/定向轨迹,记得 learn_graph(..., use_partition = FALSE)。
-
全图伪时间 ≠ 轨迹段伪时间:order_cells给的是"到根节点的图距离";定向轨迹必须用choose_graph_segments重新切段,段内伪时间才是0→1 的"A 到B"进程。
-
根节点的选择必须有生物学依据:不要图省事选"最左边"的节点。用祖细胞标记群体(如GMP / Ccr2+起始群体)定位节点。
-
复用 Seurat 的 UMAP 要谨慎:Seurat的UMAP是为分群优化的,未必适合画轨迹。若轨迹形态奇怪,改用Monocle3自行preprocess_cds的UMAP(两套口径统一再比较)。
-
细胞数太少学不出轨迹:主图学习至少需要几百个细胞,样本少时建议把同组织各样本合并(并注意去批次)。
总结
|
场景
|
推荐工具
|
说明
|
| --- | --- | --- |
|
新项目、大细胞数、定向轨迹
| Monocle3 | choose_graph_segments
切段 + 根节点精确控制
|
|
复现旧文献、整洁树图
| Monocle2 |
DDRTree 经典风格,但需手动选排序基因
|
🚫 最重要的提醒 :无论用哪个工具,根节点的选择很关键。伪时间是"从根出发的距离",根选错,整条轨迹方向就是反的------工具只是画图的手,生物学先验才是定方向的大脑。