1. 从“玄学”到“科学”:为什么你的单细胞注释总是不准?

做了这么久单细胞分析,你是不是也经常有这种感觉:看文献里别人的注释图清晰漂亮,一到自己手上,那些细胞亚群就像跟你捉迷藏一样,怎么都分不清楚?尤其是那些关键的、但数量又少的稀有细胞亚群,比如NKT细胞、特定的组织驻留记忆T细胞,或者某个发育阶段的祖细胞。你明明按照教科书上的经典marker基因去画图,结果信号却散得到处都是,根本没法圈出一个干净的群体。

我刚开始做单细胞注释的时候,也踩过无数这样的坑。最让我头疼的就是NKT细胞。大家都知道,鉴定NKT通常看三个标志物:CD45(通用免疫细胞标记)、CD3(T细胞标记)和CD56(NK细胞标记,在人类中是NCAM1)。我兴冲冲地把PTPRC(CD45)、CD3DCD3ECD3GNCAM1这些基因扔进FeaturePlot,结果TSNE图上,这些基因的高表达区域确实有重叠,但那个重叠区域看起来细胞稀稀拉拉,跟旁边主流的T细胞群和NK细胞群边界模糊,根本不敢 confidently 地说“这就是NKT”。

这时候,很多教程会告诉你,用Seurat的AddModuleScore函数啊!它可以把多个基因的 expression 综合成一个“模块分数”,这样就能在图上看到一个整体的“热点”区域,理论上应该比看单个基因更清晰。我也这么做了,把上面那几个基因打包成一个列表,用AddModuleScore算了个分,然后满怀期待地画图。结果呢?那个“热点”确实出现了,但它往往不在我预期的“交界处”,反而可能跑到一个很奇怪的位置,或者范围大得离谱,包含了太多明显不是目标细胞的群体。

问题到底出在哪里?我花了很长时间去琢磨,后来才彻底搞明白。AddModuleScore的核心算法,其实就是计算你输入的那一组基因在每个细胞中的平均表达值。 听起来很合理对吧?但在单细胞数据这个特殊的环境里,这个“简单平均”恰恰是最大的陷阱。

单细胞RNA-seq数据本质上是一个巨大的、极其稀疏的矩阵。所谓稀疏,就是里面充满了“0”——一个细胞中绝大多数基因是不表达的。当你计算CD45CD3CD56这几个基因的平均值时,算法会老老实实地把它们的表达值相加再除以基因个数。假设一个真正的NKT细胞,CD3表达很高(比如10),CD56表达中等(比如5),CD45作为本底也有表达(比如3)。那么它的模块分数大概是 (10+5+3)/3 = 6。但一个非免疫细胞(比如上皮细胞),它这三个基因可能都是0,分数就是0。这看起来没问题。

但实际情况更复杂。如果一个细胞只是高表达了CD45(比如8),但CD3CD56都是0,它的分数是 (8+0+0)/3 ≈ 2.7。这个分数虽然不高,但足以在颜色映射上显示为一个浅色的点,混杂在你的“热点”周围。更糟糕的是,AddModuleScore默认认为你列表里的每一个基因都同等重要。然而在生物学注释的逻辑里,这些基因的权重是截然不同的。我们鉴定NKT,是遵循一个“逻辑与”的门槛:它首先必须是免疫细胞(CD45+),在这个基础上它必须是T细胞(CD3+),最后它还必须要表达NK细胞的标志(CD56+)。CD3CD56的权重,尤其是它们的“同时表达”,远比CD45要关键。但AddModuleScore这个“平均主义”算法,完全无视了这种层级权重关系。

这就好比用流式细胞术圈门,你不会把CD45、CD3、CD56三个通道的信号简单平均一下然后画个直方图来圈门。你一定是先圈出CD45+的细胞(设个门),然后在这个门里再圈CD3+的细胞(第二个门),最后在CD3+的门里看CD56的表达(第三个门)。这是一个逐级递进、有逻辑先后的过程。而AddModuleScore的原始方法,相当于把三个通道的信号混在一起一锅炖了,自然就失去了分辨力。理解了这个根本原因,我们才能谈如何改进和进阶使用它。

2. 深入理解AddModuleScore:它的计算到底在做什么?

要想用好一个工具,甚至改造它,你必须先把它拆开看明白。我们来看看AddModuleScore在Seurat里具体是怎么算的。虽然我们平时只用一行代码,但背后有几个关键步骤,每一个都可能影响最终结果。

首先,函数并不是直接用你提供的基因在所有细胞中的原始表达值来计算平均。它会进行一个“控制背景”的操作。简单来说,算法会为你的目标基因集(比如NKT基因集),随机选取多组在表达水平分布上类似的“背景基因集”。然后,它会用你目标基因集的平均表达值,减去这些背景基因集平均表达值的均值,得到最终的“模块分数”。这个设计的初衷是好的,是为了消除细胞周期、测序深度等非生物因素造成的表达量整体偏高或偏低的影响,让分数更能反映特定基因程序(gene program)的活性。

但是,这个“背景校正”过程在稀疏的单细胞数据中,有时会引入噪音。特别是当你的目标基因集本身表达量就不高、且非常稀疏时,随机选取的背景基因集可能波动很大,导致校正后的分数出现不稳定的正负值。这解释了为什么有时候你算出来的分数,在一些明显不相关的细胞里也会有低水平的着色。

其次,也是最核心的一点,我们再来审视一下“平均”这个问题。假设我们有一个包含5个基因的模块:GeneA, GeneB, GeneC, GeneD, GeneE。在一个细胞中,它们的表达量分别是 [10, 8, 0, 0, 0]。这个细胞可能高表达了前两个关键基因,后三个不表达。它的模块分数是 (10+8+0+0+0)/5 = 3.6。另一个细胞,五个基因都有低水平表达:[2, 2, 2, 2, 2]。它的分数是 (2+2+2+2+2)/5 = 2。从分数上看,第一个细胞(3.6)比第二个细胞(2)更“像”目标细胞。

但如果我们从生物学逻辑看呢?如果GeneAGeneB是定义该亚群的两个必要且充分的“核心标记”,那么第一个细胞(强表达核心标记)应该被强烈认为是目标细胞,而第二个细胞(均匀弱表达)很可能只是噪音。然而3.6和2的差距,在可视化的颜色梯度上可能并不明显,第二个细胞仍然会以较浅的颜色出现在图上,污染你的视野。

更有甚者,如果有一个细胞只超高表达其中一个非关键基因,比如GeneE表达量为15,其他都是0,它的分数是3。这个分数和第一个真正目标细胞的分数(3.6)几乎一样高!这就会导致严重的假阳性。这就是“平均”算法在稀疏矩阵下的致命伤:它无法区分“少数基因强表达”和“多数基因弱表达”这两种截然不同的模式,更无法赋予关键基因更高的决策权重。

所以,当你发现AddModuleScore给出的热点区域又大又模糊时,不要急着怪数据质量,很可能是因为算法本身的“平权平均”与生物学“层级加权”之间的矛盾导致的。下面,我们就来聊聊怎么解决这个矛盾。

3. 策略升级:从“平权平均”到“层级加权”的marker选择

知道了病根,就能对症下药。既然AddModuleScore默认的“平均主义”不行,那我们就得在输入给它“原料”——也就是marker基因列表——的时候,动一番脑筋。目标是把生物学的层级注释逻辑,编码到我们提交的基因列表策略中去。这里我分享几种我实战中总结出来的有效策略,你可以根据自己数据的情况组合使用。

策略一:分步注释,化整为零。 这是最直接、也最符合流式圈门逻辑的方法。不要试图用一个AddModuleScore模块去鉴定一个需要多步定义的稀有亚群。以NKT为例,我们分三步走:

  1. 第一步,鉴定免疫细胞。 用一个包含PTPRC(CD45)以及其他泛免疫标记(如PTPRC本身已经足够)的模块,给所有细胞打分。你可以设定一个阈值(比如分数>0),初步圈定免疫细胞的范围。或者更简单,直接在后续分析中,只关注PTPRC表达阳性的细胞群体。
  2. 第二步,在免疫细胞中鉴定T细胞。 在第一步圈定的免疫细胞子集中(你可以在Seurat中用subset函数取出),运行一个新的AddModuleScore,这次只使用T细胞核心标记,比如CD3D, CD3E, CD3G,甚至可以加上CD2。这样得到的“T细胞分数”,就排除了非免疫细胞的干扰,会更干净。
  3. 第三步,在T细胞中鉴定NKT特征。 在第二步得到的T细胞子集中,再运行第三个AddModuleScore,这次使用能将NKT与常规T细胞区分开的标记,主要是NCAM1(CD56),也可以考虑KLRB1(CD161)、FCGR3A(CD16)等。这时,因为背景已经是纯化的T细胞,NCAM1的表达模式就会清晰得多。

这种方法相当于用多个简单的、权重单一的AddModuleScore,手动串联起来,模拟了流式的层级圈门。虽然步骤多了,但每一步的干扰都少,结果的可解释性非常强。

策略二:精心设计“加权”基因列表。 如果我们还是想用一个模块来大致观察某个亚群,可以通过“复制”关键基因来隐性地实现加权。比如,我认为在NKT鉴定中,CD3复合物的基因和NCAM1PTPRC更重要。那么我可以这样构建列表:

NKT_weighted_list <- list(c('CD3D', 'CD3E', 'CD3G', 'NCAM1', 'NCAM1', 'PTPRC'))

注意,我把NCAM1放了两次。在计算平均时,这个基因的贡献就被变相“加权”了(虽然只是乘以2)。CD3有三个基因,其总权重自然就比PTPRC高。这只是一个粗糙的近似,但有时能显著改善热点的聚集程度。你可以根据文献或先验知识,调整基因的重复次数来模拟权重。

策略三:利用差异表达,寻找“独家”标记。 原始文章里也提到了,最理想的情况是找到亚群“独有”的marker。这在实际操作中很难,尤其是对于高度相似的小亚群。但我们可以退而求其次,寻找“组合标记”。不要只依赖一个基因,而是寻找一组能共同唯一标识该亚群的基因。怎么做?在你初步聚类后,针对你怀疑含有目标亚群的cluster,与所有其他cluster做差异表达分析。然后不要只看最显著的基因,而是看那些在该cluster里共表达的基因组合。比如,你发现cluster 5高表达GeneXGeneY,而其他cluster最多只高表达其中一个。那么[GeneX, GeneY]这个组合就可以作为一个强有力的“独家组合标记”用于AddModuleScore。因为当且仅当这个cluster的细胞会同时拉高这两个基因的分数,其他细胞很难同时模仿这个模式。

4. 实战演练:在Seurat中一步步实现精准的NKT细胞注释

光说不练假把式,我们直接上代码,用一套组合拳来实战注释NKT细胞。假设我们有一个已经经过基本预处理(标准化、降维、聚类)的Seurat对象sce

第一步:可视化初探,建立直觉。 我们先画一下经典标记的图,看看情况有多“糟”。

# 绘制单个标记基因
FeaturePlot(sce, features = c('PTPRC','CD3D', 'NCAM1'), 
            pt.size = 0.1, reduction = 'tsne', ncol = 3)

这张图会让你看到每个基因的独立分布。通常PTPRC(CD45)几乎遍布所有免疫细胞,CD3D集中在T细胞区域,NCAM1可能在NK细胞和一些T细胞上有表达。记住它们的位置。

第二步:使用原始AddModuleScore,看看问题。

# 传统的平权平均方法
NKT_naive_list <- list(c('PTPRC','CD3D', 'CD3E', 'CD3G', 'NCAM1'))
sce <- AddModuleScore(object = sce, features = NKT_naive_list, name = "NKT_naive")
FeaturePlot(object = sce, features = "NKT_naive1", 
            reduction='tsne', cols = c('grey','red'), pt.size=0.1) +
  ggtitle('Naive AddModuleScore for NKT')

保存这张图,作为我们的“反面教材”。你会发现红色的热点可能覆盖了很大一片区域,包括主要的T细胞群和NK细胞群。

第三步:实施分步注释策略。 我们假设通过初步观察,已经知道cluster 0-4是主要的免疫细胞集群。

# 1. 提取免疫细胞(这里简化操作,直接根据已知cluster来,更严谨的做法可以用CD45分数阈值)
immune_cells <- subset(sce, idents = c(0,1,2,3,4))

# 2. 在免疫细胞中重新进行PCA、聚类(为了获得更清晰的T细胞分布)
immune_cells <- NormalizeData(immune_cells)
immune_cells <- FindVariableFeatures(immune_cells)
immune_cells <- ScaleData(immune_cells)
immune_cells <- RunPCA(immune_cells)
immune_cells <- FindNeighbors(immune_cells, dims = 1:20)
immune_cells <- FindClusters(immune_cells, resolution = 0.5)
immune_cells <- RunTSNE(immune_cells, dims = 1:20)

# 3. 在免疫细胞子集中鉴定T细胞
Tcell_gene_list <- list(c('CD3D', 'CD3E', 'CD3G'))
immune_cells <- AddModuleScore(object = immune_cells, features = Tcell_gene_list, name = "Tcell")
FeaturePlot(object = immune_cells, features = "Tcell1", 
            reduction='tsne', cols = c('grey','red'), pt.size=0.1) +
  ggtitle('T-cell score within Immune cells')

# 设定一个阈值,比如分数>0.2,来定义T细胞
immune_cells$is_Tcell <- immune_cells$Tcell1 > 0.2
DimPlot(immune_cells, group.by = 'is_Tcell', reduction = 'tsne')

# 4. 提取T细胞
T_cells <- subset(immune_cells, subset = is_Tcell == TRUE)

# 5. 在T细胞子集中鉴定NKT特征
NKT_gene_list <- list(c('NCAM1', 'KLRB1')) # 使用更特异的NKT标记
T_cells <- AddModuleScore(object = T_cells, features = NKT_gene_list, name = "NKT")
FeaturePlot(object = T_cells, features = "NKT1", 
            reduction='tsne', cols = c('grey','red'), pt.size=0.5) +
  ggtitle('NKT signature score within T cells')

经过这样层层过滤,最后在纯化的T细胞背景上看到的NCAM1/KLRB1高分细胞,就极有可能是真正的NKT细胞。你可以根据NKT1分数的高低,在T_cells对象中定义一个新的细胞身份。

第四步:反向映射回原数据集。 找到了NKT细胞在子集里的ID,我们需要把它们标注回最初的sce对象。

# 获取被鉴定为NKT的细胞在原sce中的名称(假设我们定义NKT1分数>0.5为NKT)
nkt_cell_names <- Cells(T_cells)[which(T_cells$NKT1 > 0.5)]

# 在sce对象中新增一个metadata列
sce$celltype <- as.character(Idents(sce))
sce$celltype[Cells(sce) %in% nkt_cell_names] <- "NKT"

# 可视化最终结果
DimPlot(sce, group.by = 'celltype', reduction = 'tsne', label = TRUE)

通过这一套流程,你就能在TSNE图上看到一个清晰定位、边界相对明确的NKT细胞群了。这个方法虽然繁琐,但精准度远超一次性使用AddModuleScore

5. 超越AddModuleScore:其他辅助工具与交叉验证

AddModuleScore是一个很好的起点和可视化工具,但单细胞注释从来不应该只依赖一种方法。尤其是在我们用了各种策略优化之后,还需要一些额外的工具来交叉验证我们的结果,确保注释的可靠性。

首先,一定要结合差异表达分析(DEA)。 当你通过上述方法初步划定了一个NKT细胞群体后,务必把这个群体和其他所有的T细胞/NK细胞群体做一次差异表达分析。使用FindAllMarkers或针对性的FindMarkers函数。你期望看到什么?你期望看到这个群体不仅高表达你用来定义它的基因(NCAM1, KLRB1),还应该高表达其他已知的NKT相关基因,比如编码恒定TCR链的TRDCTRGC1/2,细胞因子IL2RBIL18RAP,趋化因子受体CCR6等。如果差异表达基因列表里充满了你意想不到的、与NKT功能无关的基因,或者缺乏关键的NKT特征基因,那你就需要回头检查你的注释门槛是否设得太松或太紧了。

其次,利用已知的细胞类型参考数据集进行映射。 这是一个非常强大的验证方法。你可以使用如SingleRscCATCHcellassign这样的工具。这些工具的原理是,将你的每个细胞的表达谱,与一个已经精心注释好的参考单细胞数据集(比如Blueprint、Human Primary Cell Atlas,或者领域内公认的权威数据集)进行比对,通过相关性或机器学习算法,给出一个参考注释。然后,你将SingleR预测的“NKT”细胞,与你通过AddModuleScore策略手动注释的“NKT”细胞进行比较。如果两者重叠度很高,那你的注释信心就大大增强了。如果重叠度很低,你就需要去探究原因:是参考数据集不适用你的组织或物种?还是你的手动注释有偏差?这个过程能帮你发现潜在的问题。

再者,不要忽视轨迹分析(Trajectory Analysis)的启示。 对于某些具有连续分化状态的细胞,比如从Naive T细胞到Effector Memory T细胞的过渡,硬用聚类和AddModuleScore去圈定一个离散的亚群可能很困难。这时候,像Monocle3、Slingshot这样的轨迹推断工具可以帮上忙。你可以先做轨迹分析,看看细胞在拟时间轴上的排列。然后,将你关心的标记基因(如NCAM1)的表达量投射到轨迹上。你可能会发现NCAM1的高表达只出现在轨迹的某个特定分支或末端,这从发育连续性的角度佐证了这群细胞的独特性,帮你更准确地界定它们的范围。

最后,手动检查单个细胞的基因表达。 这是最原始但也最可靠的方法。在Seurat中,你可以用VlnPlot查看你定义的“NKT”群体和其他T细胞群体在几个关键标记上的表达分布差异。更细致一点,可以用DotPlot,它能同时展示基因表达的平均水平和表达该基因的细胞比例,信息量更丰富。我经常这样做:在DotPlot里,把我定义的群体和其他类似群体放一起,看NCAM1CD3DFCGR3AKLRB1这一组基因。一个真正的NKT群体,应该在NCAM1KLRB1上既有较高的平均表达量,也有较高的表达细胞比例,同时CD3D也保持阳性。而一个常规的细胞毒性T细胞(CTL)群体,可能NCAM1的表达比例很低,平均表达量也低,但高表达GZMBPRF1等基因。这种多基因的并排对比,能给你带来最直观的信心。

说到底,单细胞注释是一个综合推理的过程,没有一劳永逸的银弹。AddModuleScore是你工具箱里的一把好用的螺丝刀,但你不能指望只用它就能组装起整个复杂的机器。理解它的原理,知道它的局限,然后巧妙地搭配其他工具和方法,结合你的生物学知识进行反复验证和调整,这才是从“玄学”走向“科学”的必经之路。我自己的项目里,对一个关键稀有亚群的注释,往往要经历“初步标记观察 -> 优化AddModuleScore策略圈定候选 -> 差异表达验证 -> 参考数据集映射比对 -> 手动检查表达谱”这样一个循环,才能最终拍板。这个过程很磨人,但当你最终在图上清晰地指认出那群细胞,并讲出一个自洽的生物学故事时,那种成就感也是无可替代的。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐