1. 双细胞:单细胞数据分析中的“隐形杀手”

做单细胞测序分析,我们总希望每个数据点都代表一个独一无二的细胞,好让我们看清细胞群体的真实面貌。但现实很骨感,实验过程中,两个或多个细胞可能会被包裹进同一个微滴或孔里,最终测序时共享一个barcode。这种“打包”在一起的数据点,就是我们常说的双细胞,或者叫doublets。

你可以把它想象成一张合影。本来你想给每个人拍张单人照(单个细胞),结果不小心把两个人拍进了一个相框(一个barcode)。这张“合影”的基因表达谱,就成了两个人特征的混合体,既不完全是A,也不完全是B。这种混合信号一旦混入你的数据分析流程,麻烦就大了。它会在细胞聚类时制造出根本不存在的“中间态”细胞亚群,误导你发现新的细胞类型;它也会让差异表达分析的结果变得不可靠,因为你看到的差异可能只是两个细胞特征的平均,而不是某个细胞类型的真实特性。

我刚开始接触单细胞项目时,就吃过这个亏。当时聚类结果里出现了一个表达特征很奇怪的细胞群,既表达了一些上皮细胞的标记基因,又掺杂了免疫细胞的信号。我们团队一度很兴奋,以为找到了一个全新的、具有特殊功能的过渡态细胞。花了大量时间做验证实验,结果发现,这根本就是由上皮细胞和T细胞形成的双细胞造成的假象。从那以后,我对数据清洗,尤其是双细胞的剔除,就格外上心。

所以,在正式进行细胞注释、轨迹分析这些高级分析之前,把双细胞这个“隐形杀手”精准地找出来并剔除掉,是保证后续所有分析结果可信度的基石。这一步做扎实了,后面才能少走弯路。

2. DoubletFinder:你的双细胞“侦探”

在众多双细胞检测工具中,DoubletFinder 是我用得最顺手的一个。它最大的优点就是和目前最主流的单细胞分析框架 Seurat 无缝衔接,你不用在多个软件或环境之间来回倒腾数据,整个流程可以一气呵成。

DoubletFinder的工作原理非常巧妙,它模拟了“找不同”的游戏。简单来说,它的核心思路是:先人工制造一些“双细胞”,然后看看真实的细胞数据里,谁和这些“假双细胞”长得最像。

具体分三步走:

  1. 人工造“假”:算法会随机地从你的真实数据中抽取两个细胞,把它们的基因表达量加在一起,模拟出一个双细胞的表达谱。这个过程会重复很多次,生成一个“人工双细胞”的虚拟群体。
  2. 构建“邻居地图”:算法会计算每一个真实细胞(包括人工制造的双细胞)在降维空间(比如PCA空间)里的最近邻居。它会统计每个细胞的邻居里,有多少是真实细胞,有多少是人工制造的双细胞。
  3. 揪出“嫌疑犯”:对于一个真实细胞,如果它的邻居里混入了大量的人工双细胞,那就非常可疑了。这说明它在基因表达特征上,和那些“假双细胞”很相似。DoubletFinder会为每个细胞计算一个 pANN值,这个值就代表了该细胞是双细胞的概率。pANN值越高,是双细胞的可能性就越大。

这个过程听起来很靠谱,对吧?但这里有一个关键点:“邻居”的范围怎么界定? 这就是DoubletFinder里最核心、也最需要我们花心思去优化的参数——pK。pK值定义了在找邻居时,要考虑周围多少个最近的细胞。pK设得太小,邻居圈太窄,可能抓不到真正的双细胞特征;pK设得太大,邻居圈太广,又容易误伤,把一些表达谱比较特殊的真实单细胞也当成双细胞给剔除掉。

所以,使用DoubletFinder绝不是简单地安装、运行就完事了。参数pK的优化,直接决定了这位“侦探”的破案精度。 接下来,我就带你深入实战,看看怎么通过系统的参数扫描和优化,把DoubletFinder的潜力完全发挥出来。

3. 实战演练:从数据预处理到参数优化全流程

让我们手把手跑一遍完整的流程。假设你已经有了一个初步处理过的Seurat对象,我们叫它sce。我强烈建议你在运行每一步之前,都先理解这行代码在干什么,而不是盲目复制粘贴。

3.1 环境准备与数据载入

首先,确保必要的R包都已经安装好。除了DoubletFinder和Seurat,tidyverse和patchwork能让我们的数据操作和图形排版更方便。

# 安装DoubletFinder,注意它要从GitHub安装
# devtools::install_github('chris-mcginnis-ucsf/DoubletFinder')

library(DoubletFinder)
library(Seurat)
library(tidyverse)
library(patchwork)

# 清理环境,避免旧变量干扰
rm(list=ls())

# 如果你的电脑是多核的,可以设置并行计算以加速,比如用8个核心
# 需要先加载BiocParallel包
# library(BiocParallel)
# register(MulticoreParam(workers = 8, progressbar = TRUE))

# 载入之前保存的Seurat对象
sce <- readRDS("./你的数据路径/your_seurat_object.rds")

# 一个重要的步骤:如果对象是用旧版Seurat创建的,用UpdateSeuratObject更新一下
# 这能避免很多兼容性报错
sce <- UpdateSeuratObject(sce)

数据载入后,我们需要确保它已经完成了单细胞分析的基础标准步骤。DoubletFinder是在PCA降维后的空间里工作的,所以这些预处理必不可少:

# 如果还没做,需要执行标准流程
sce <- NormalizeData(sce, normalization.method = "LogNormalize", scale.factor = 10000)
sce <- FindVariableFeatures(sce, selection.method = "vst", nfeatures = 2000)
sce <- ScaleData(sce) # 默认会回归掉线粒体基因比例的影响
sce <- RunPCA(sce, features = VariableFeatures(object = sce), verbose = FALSE)
# 也可以跑一下t-SNE或UMAP用于最终可视化,但DoubletFinder主要用PCA
sce <- RunUMAP(sce, dims = 1:30, verbose = FALSE)

3.2 核心步骤:参数pK的系统扫描与确定

这是整个流程中最关键、最需要耐心的一步。DoubletFinder提供了一个非常棒的函数 paramSweep,专门用来系统性地测试一系列pK值,并告诉我们哪个效果最好。

## 第一步:参数扫描
# 注意:这一步计算量较大,耗时较长,可以去接杯咖啡
# PCs参数:使用多少个主成分?通常用前10-30个包含主要生物信号的主成分就够了。
# sct参数:你的数据是否用了SCTransform标准化?如果是,设为TRUE。
sweep.res.list <- paramSweep(sce, PCs = 1:20, sct = FALSE)

## 第二步:汇总扫描结果
sweep.stats <- summarizeSweep(sweep.res.list, GT = FALSE)
# GT = FALSE 表示我们没有真实的双细胞标签(通常都没有)

## 第三步:找到最优pK
bcmvn <- find.pK(sweep.stats)

运行完 find.pK,你会得到一个叫 bcmvn 的数据框。里面最重要的两列是 pK 和 BCmetric。BCmetric 值最大的那一行对应的 pK,就是当前数据下的最优参数。

怎么查看和选择呢?我习惯直接画个图,一目了然:

# 可视化pK选择过程
p <- ggplot(bcmvn, aes(x = as.numeric(as.vector(pK)), y = BCmetric)) +
  geom_point() +
  geom_line() +
  theme_classic() +
  xlab("pK") + ylab("BCmetric") +
  ggtitle("Optimal pK Selection")
print(p)

# 从图中找到最高点对应的pK值,也可以用代码直接提取
optimal_pK <- as.numeric(as.vector(bcmvn$pK[which.max(bcmvn$BCmetric)]))
print(paste("The optimal pK is:", optimal_pK))

这个图能让你直观地看到不同pK值对应的检测效能。理想情况下,曲线会有一个明显的峰值。如果曲线非常平缓,没有明显峰值,那可能意味着你的数据中双细胞信号很弱,或者预处理需要再检查一下。

3.3 估算双细胞比例:另一个关键参数nExp

确定了“邻居范围”(pK),我们还需要知道大概要抓出多少个“嫌疑犯”(nExp),也就是预计的双细胞数量。DoubletFinder需要你提供一个期望的双细胞数 nExp。

这里常用的是一种经验估计法:通常认为,每捕获1000个细胞,会产生大约0.8%的双细胞。 这个比例会随着捕获细胞总数的增加而上升。我们可以这样计算:

# 方法1:经典经验公式
doublet_rate_estimate <- ncol(sce) * 8e-6 # 8 per 1000 cells -> 0.8%
nExp_poi <- round(doublet_rate_estimate * ncol(sce))

但是,这个估计值可能偏粗糙。双细胞的形成还与细胞类型有关。同型双细胞(由两个相同类型的细胞形成)在基因表达谱上更接近真实单细胞,更难被检测出来。如果我们能估计出同型双细胞的比例,就能更精准地估算需要被找出的异型双细胞的数量。

# 方法2:考虑同型双细胞比例的更优估计
# 首先,你需要对细胞有一个初步的聚类或注释
# 假设我们已经有了初步的聚类结果(sce$seurat_clusters),或者更好的,有初步的细胞类型注释(sce$celltype)
sce <- FindNeighbors(sce, dims = 1:20) %>% FindClusters(resolution = 0.8)

# 使用modelHomotypic估计同型双细胞比例
# 参数最好是细胞类型(celltype),其次是聚类分群(seurat_clusters)
homotypic_prop <- modelHomotypic(sce$seurat_clusters) # 或用 sce$celltype

# 计算调整后的期望异型双细胞数
nExp_poi <- round(ncol(sce) * 8e-6 * ncol(sce)) # 总双细胞估计
nExp_poi_adj <- round(nExp_poi * (1 - homotypic_prop)) # 异型双细胞估计

print(paste("Estimated total doublets:", nExp_poi))
print(paste("Estimated heterotypic doublets (after adjustment):", nExp_poi_adj))

modelHomotypic 函数会基于你的聚类信息,模拟细胞随机混合的情况,给出一个同型双细胞比例的估计值。用调整后的 nExp_poi_adj 作为参数,能让DoubletFinder更专注于寻找那些更容易干扰分析的异型双细胞。

4. 运行与结果解读:给细胞贴上“身份标签”

万事俱备,现在可以运行核心的 doubletFinder 函数了。这里有个小技巧:我习惯用调整前和调整后的nExp各跑一次,这样能区分出高置信度和低置信度的双细胞。

## 使用未调整的nExp(总双细胞估计)运行
sce <- doubletFinder(sce,
                     PCs = 1:20, # 与paramSweep时保持一致
                     pN = 0.25, # 默认值,表示生成人工双细胞时使用的采样比例,通常不需改动
                     pK = optimal_pK, # 我们千辛万苦找到的最优pK
                     nExp = nExp_poi, # 总双细胞估计数
                     reuse.pANN = FALSE, # 第一次运行,不重用pANN
                     sct = FALSE) # 与paramSweep时保持一致

## 使用调整后的nExp(异型双细胞估计)再运行一次
# 注意:这里reuse.pANN = TRUE,可以复用第一次计算好的pANN值,大大节省时间!
sce <- doubletFinder(sce,
                     PCs = 1:20,
                     pN = 0.25,
                     pK = optimal_pK,
                     nExp = nExp_poi_adj,
                     reuse.pANN = TRUE, # 关键!重用pANN计算
                     sct = FALSE)

跑完后,你的 sce@meta.data 里会多出两列,名字类似于 DF.classifications_0.25_0.01_XXX 和 DF.classifications_0.25_0.01_YYY。里面的“Singlet”和“Doublet”就是细胞的初步分类。

我的策略是把这两次结果结合起来,做一个更精细的分类:

# 假设第一次运行结果的列名是 DF.classifications_0.25_0.01_200
# 第二次运行结果的列名是 DF.classifications_0.25_0.01_150
# 你需要根据自己实际的列名修改

meta <- sce@meta.data
meta$DF_final <- "Singlet"

# 两次都被判定为Doublet的,是高置信度双细胞
meta$DF_final[which(meta$DF.classifications_0.25_0.01_200 == "Doublet" &
                     meta$DF.classifications_0.25_0.01_150 == "Doublet")] <- "Doublet-High Confidence"

# 仅第一次(总估计)判定为Doublet,第二次(调整后)判定为Singlet的,是低置信度双细胞
meta$DF_final[which(meta$DF.classifications_0.25_0.01_200 == "Doublet" &
                     meta$DF.classifications_0.25_0.01_150 == "Singlet")] <- "Doublet-Low Confidence"

sce@meta.data <- meta

# 查看分类统计
table(sce@meta.data$DF_final)

这样分类的好处是,你可以根据后续分析的严格程度,决定是只剔除高置信度的双细胞,还是把低置信度的也一并剔除。对于探索性分析,我有时会保留低置信度的观察一下;但对于要发表的关键分析,我倾向于全部剔除。

最后,让我们可视化一下结果:

# 在UMAP图上查看双细胞的分布
p1 <- DimPlot(sce, reduction = "umap", group.by = "DF_final",
              cols = c("Singlet" = "grey80",
                       "Doublet-Low Confidence" = "gold",
                       "Doublet-High Confidence" = "red")) +
  ggtitle("DoubletFinder Classification")

# 通常双细胞在UMAP图上会位于不同细胞群的中间位置,或者形成一些分散的“离群点”
print(p1)

5. 高级技巧与避坑指南

踩过几次坑之后,我总结了一些能让DoubletFinder用得更稳、更准的经验。

技巧一:当数据量极大时 对于细胞数超过1万,甚至达到数万的大数据集,paramSweep 会跑得非常慢。这时可以采取抽样策略:

# 随机抽取一部分细胞进行参数扫描,确定最优pK
set.seed(123) # 固定随机种子保证可重复
sub_cells <- sample(colnames(sce), size = 8000) # 抽取8000个细胞
sce_sub <- subset(sce, cells = sub_cells)

# 在子集上运行paramSweep找最优pK
sweep.res.list_sub <- paramSweep(sce_sub, PCs = 1:20, sct = FALSE)
sweep.stats_sub <- summarizeSweep(sweep.res.list_sub, GT = FALSE)
bcmvn_sub <- find.pK(sweep.stats_sub)
optimal_pK_sub <- as.numeric(as.vector(bcmvn_sub$pK[which.max(bcmvn_sub$BCmetric)]))

# 将这个pK应用到整个数据集上
# 注意nExp还是要基于总细胞数计算

技巧二:处理特殊批次或样本 如果你的数据来自多个样本或批次,最好分样本独立运行DoubletFinder。因为不同样本的细胞密度、组成不同,最优的pK和双细胞比例也可能不同。混在一起分析,参数可能会偏向于某个主导样本。

# 假设sce对象里有一个meta.data列叫“sample_id”
sample_list <- unique(sce$sample_id)
doublet_list <- list()

for (sample in sample_list) {
  sce_sample <- subset(sce, subset = sample_id == sample)
  # 独立对该样本执行完整的DoubletFinder流程:paramSweep -> find.pK -> doubletFinder
  # ...
  # 将鉴定出的双细胞名保存到doublet_list中
}

# 最后将所有样本的双细胞名合并,从原对象中剔除
all_doublets <- unlist(doublet_list)
sce_clean <- subset(sce, cells = setdiff(colnames(sce), all_doublets))

避坑一:pK扫描结果不理想 如果 find.pK 给出的BCmetric曲线没有明显峰值,或者最优pK出现在边界值(比如测试范围的最小或最大值),这可能提示:

  1. 你的数据中双细胞比例极低或极高。
  2. 预处理可能有问题,比如PCA降维没有很好地捕捉细胞间的生物学差异。
  3. 可以尝试调整 paramSweep 中使用的PCs范围,或者检查标准化、高变基因选择步骤。

避坑二:剔除双细胞后细胞类型丢失 这是最需要警惕的情况。有时,某些稀有细胞类型因为细胞数量少、表达谱特殊,容易被误判为双细胞。在剔除后,一定要快速检查一下已知的细胞标记基因在“双细胞”群体中的表达情况。如果某个“双细胞”群体高表达某种稀有细胞的特异性标记,就要小心了,它们可能是被冤枉的“良民”。这时,可能需要手动将这些细胞从双细胞列表中拯救回来。

参数优化不是一劳永逸的,它需要结合你对实验体系和生物学背景的理解。DoubletFinder给出了一个强大的、基于统计的框架,但最终那双判断的眼睛,还是得靠分析者自己。多看看结果图,多问问“这个细胞为什么会被标记为双细胞”,你的直觉和经验,会随着项目积累变得越来越准。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐