单细胞测序做到今天,最让分析人员头疼的其实不是拿不到数据,而是同一批细胞里,转录组告诉你“这群细胞像T细胞”,表面蛋白却告诉你“它其实是个还没活化的naive亚群”,两张皮对不上。过去几年,Seurat生态里针对这类多模态数据的处理思路逐渐成熟,其中跨模态整合(Weighted Nearest Neighbor,简称WNN)几乎成了绕不开的一环。它做的事情很具体:把同一批细胞测到的转录组、表面蛋白、染色质可及性等不同来源的信息,按每个细胞自身的可信度加权后拼成一张统一的近邻图,再拿这张图去做聚类和降维。适合谁看?如果你手头有CITE-seq、多组学或者同批次的多模态数据,已经能跑通单模态的Seurat标准流程,但一遇到“两种模态聚类结果打架”“ADT维度怎么选”“权重图该不该信”这类问题就卡壳,那这篇内容基本就是为你准备的。下面我按数据准备、实操流程、结果解读、排查踩坑四个部分,把我在几个模拟项目里反复验证过的做法摊开讲。
1. 跨模态整合到底在解决什么问题
1.1 单模态分析在免疫和发育场景里的三个典型困境
先说清楚痛点,不然后面讲参数会觉得是在背文档。转录组聚类最大的问题之一是分辨率与生物学真实性之间的矛盾:把resolution往上调,CD4和CD8分开了,但naive和memory也一起被切碎;往下调,亚群又糊成一片。第二是低表达基因的噪声,像某些转录因子在单细胞里检出率极低,靠它做标记基因基本是碰运气。第三是模态之间相互矛盾,最常见的就是RNA聚类把一群细胞划进某个亚群,但ADT上的谱系标记根本不支持这个判断。
这些问题的根源在于,单模态数据只捕捉了一个维度的信息,而细胞状态本身是多维的。你做聚类时,实际上是在一个信息不完整的空间里做距离判断,噪声和缺失值都会把近邻关系带偏。
1.2 多模态数据带来的信息增量具体在哪里
拿最常见的CITE-seq场景来说,同一批细胞同时测了mRNA和细胞表面蛋白。蛋白这一层的特点是表达量高、动态范围宽、检出稳定,特别适合用来界定谱系和活化状态;转录组这一层优势在于覆盖全基因组,能发现蛋白面板覆盖不到的亚状态。两边的互补性非常强。
问题在于不能简单地把两套矩阵拼起来做PCA。为什么?因为RNA矩阵动辄两万维、稀疏、服从负二项分布,ADT矩阵只有一两百维、稠密、走的是计数比例,两者的量纲、噪声结构、维度数量完全不在一个量级。硬拼的结果就是高维的RNA把低维的ADT彻底淹没,ADT的信息增量等于白测。WNN的思路正好绕开了这个问题:它不拼接原始数据,而是先让每个模态独立降维,再在低维空间里为每个细胞分别评估“这个模态对它的区分度有多高”,最后加权融合。
1.3 WNN的加权思路为什么值得用
每个细胞都会得到一组模态权重,权重反映的是该细胞在对应模态的近邻结构里有多“可信”。举个直观的例子:一个典型的B细胞,ADT上CD19、CD20信号很强且干净,蛋白模态对它的权重就会偏高;而一个处于过渡态的细胞,表面标记可能模糊,转录组提供的近邻关系反而更稳,RNA权重就占主导。这种细胞级别的自适应加权是WNN和早期“两模态各算一半”的粗暴融合最大的区别,也是它能在真实数据里稳定出结果的关键。
代价是计算量。WNN要为每个细胞做一次权重优化,细胞数上万时内存和耗时都不小,后面会专门讲资源控制。
2. 数据准备与预处理的关键环节
2.1 两种模态必须独立预处理,不能共用一套流程
这是最容易翻车的地方。很多新手图省事,把ADT当成一个额外的assay塞进对象,然后直接对整体做NormalizeData和ScaleData,结果ADT的信号被RNA的标准化参数带偏,聚类出来的权重图完全没法看。
正确做法是按模态分别走各自适合的标准化路径。RNA用常规的LogNormalize,选高变基因、缩放、PCA;ADT必须用CLR(centered log-ratio)归一化,而且margin参数要设成2。这里解释一下为什么:CLR默认margin=1是按特征(也就是每个蛋白)做中心化对数比,这在蛋白数量少的时候会让每个蛋白的分布被强行拉平;margin=2是按细胞做,意思是“同一个细胞内部各个蛋白的相对比例”,这更符合ADT数据的生成机制——每个细胞的抗体捕获总量本身受实验条件影响,细胞内的相对丰度才是生物学信号。
library(Seurat) # 假设对象 obj 已经包含 RNA 和 ADT 两个 assay DefaultAssay(obj) <- "RNA" obj <- NormalizeData(obj, normalization.method = "LogNormalize", scale.factor = 1e4) obj <- FindVariableFeatures(obj, selection.method = "vst", nfeatures = 2000) obj <- ScaleData(obj, features = rownames(obj)) obj <- RunPCA(obj, features = VariableFeatures(obj), npcs = 50) DefaultAssay(obj) <- "ADT" obj <- NormalizeData(obj, normalization.method = "CLR", margin = 2) # ADT特征少,缩放时用全部特征即可 obj <- ScaleData(obj, features = rownames(obj)) obj <- RunPCA(obj, features = rownames(obj), npcs = 30, reduction.name = "pca_adt", reduction.key = "pcaadt_")注意PCA的reduction名字要显式改掉,否则会和RNA的pca冲突,后面FindMultiModalNeighbors取reduction时就会报错或者取错。
2.2 barcode对齐是隐形炸弹
多模态数据里,RNA和ADT虽然是同一批液滴,但建库和测序是分开的,最后合并时靠barcode匹配。如果上游已经给了合并好的对象,这一步通常没问题;但如果你自己从两个矩阵文件读进来再合并,一定要先检查两边的细胞数是否一致、barcode命名规则是否统一(有的流程会给barcode加后缀,有的不会)。
# 合并前先对齐 common_cells <- intersect(colnames(rna_mat), colnames(adt_mat)) cat("RNA细胞数:", ncol(rna_mat), "ADT细胞数:", ncol(adt_mat), "交集:", length(common_cells), "\n") obj <- CreateSeuratObject(counts = rna_mat[, common_cells], assay = "RNA") adt_assay <- CreateAssayObject(counts = adt_mat[, common_cells]) obj[["ADT"]] <- adt_assay注意:如果交集细胞数比任一单模态少了一大截,先别急着往下跑,回头查barcode格式。我在一个模拟项目里遇到过RNA的barcode带“-1”后缀、ADT不带的情况,交集只剩个位数,排查了半天才发现是命名规则不一致。
2.3 模态特异性的质控门槛要分开设
RNA的质控看nFeature_RNA、percent.mt,ADT的质控看总计数和背景蛋白。ADT的背景信号主要来自非特异性结合,判断方法是看那些理论上不该表达的对照蛋白(比如同型对照)的分布。如果某个细胞的ADT总计数异常高但对照蛋白也高,大概率是背景污染,应该剔除。
我的经验是,ADT的质控阈值不要照搬RNA。RNA可以卡nFeature_RNA > 200,ADT这边更该关注的是每个细胞检出的蛋白数和对照蛋白占比。一般要求检出蛋白数不少于面板总数的三到四成,对照蛋白占比控制在个位数百分比以内。这些数字不是硬标准,得看你用的抗体面板和建库质量,最好先画几张分布图再定。
3. WNN整合的完整实操流程
3.1 构建多模态对象并确认两个reduction都在
在跑整合之前,先确认obj里同时存在pca和pca_adt两个reduction,且维度合理。RNA的PCA维度按常规经验取肘部拐点附近,比如30;ADT因为特征数本来就不多(常见面板一百多个蛋白),PCA维度取到15到20就够了,取太多会把噪声也当成主成分,反而稀释信号。
# 确认reduction存在 Reductions(obj) # [1] "pca" "pca_adt" # 看ADT的碎石图判断维度 ElbowPlot(obj, reduction = "pca_adt", ndims = 30)3.2 核心函数FindMultiModalNeighbors的参数怎么定
这是整个流程的核心。关键参数就三个:reduction.list、dims.list、modality.weight.name。reduction.list按顺序给两个模态的降维结果,dims.list一一对应给维度范围,modality.weight.name是让函数把算出来的权重存进meta.data,方便后面可视化和解读。
obj <- FindMultiModalNeighbors( obj, reduction.list = list("pca", "pca_adt"), dims.list = list(1:30, 1:18), modality.weight.name = "RNA.weight", verbose = TRUE )跑完之后obj里会多出一个weighted.nn的邻居对象,还有一个wsnn的共享邻居图。这两个是后续降维和聚类的输入,不要搞混。weighted.nn用于RunUMAP,wsnn用于FindClusters。
3.3 基于加权图的降维和聚类
# 用加权近邻图做UMAP obj <- RunUMAP(obj, nn.name = "weighted.nn", reduction.name = "wnn.umap", reduction.key = "wnnUMAP_") # 用共享加权图做聚类 obj <- FindClusters(obj, graph.name = "wsnn", algorithm = 3, resolution = 0.6, verbose = FALSE) # 出图 DimPlot(obj, reduction = "wnn.umap", label = TRUE, repel = TRUE)这里有几个细节值得说。RunUMAP必须指定nn.name="weighted.nn",否则它默认用RNA的pca,等于白做整合。FindClusters必须指定graph.name="wsnn",不指定的话它会去找默认的RNA_snn,聚类结果和加权图无关。这两处是最常见的“跑了但没生效”的原因,我在帮别人看代码时见过不止一次。
algorithm=3是SLM算法,比默认的Louvain在大图上更稳、更快,多模态数据细胞数通常不小,建议直接用它。resolution从0.6起步,根据聚类数和标记基因的合理性再调。
3.4 两模态之外的第三种整合场景:桥接整合
WNN解决的是“同一批细胞、多个模态”的问题。但实际项目里还有一种常见情况:你有一批CITE-seq数据作为参考,另有一批只有RNA的普通scRNA-seq数据,想让后者借助前者的蛋白信息做标签迁移。这时候用的不是WNN,而是FindBridgeIntegration。
它的思路是先拿一个同时有两种模态的桥接数据集,分别建立两套锚点,再把query数据映射进去。这个函数对桥接数据的质量要求比较高,桥接数据最好覆盖你要研究的细胞类型,否则映射出来的标签可信度会打折扣。
# 桥接整合示意(桥接数据集需同时含RNA和ADT) bridge <- FindBridgeIntegration( anchorset = anchors, reference = bridge_obj, query = query_obj, reduction = "wnn.umap" )这条路径和WNN是互补关系,不是替代关系,选哪条取决于你手头数据的模态结构。
4. 结果解读与下游分析
4.1 权重图该怎么看才算看懂了
权重算出来之后,最直接的检查方式是按聚类分组画小提琴图。RNA.weight的值在0到1之间,越接近1说明这个类群的近邻结构越依赖转录组。
VlnPlot(obj, features = "RNA.weight", group.by = "seurat_clusters", pt.size = 0.1) + NoLegend()正常的模式应该是:不同类群的权重有明显分化。比如某些亚群权重接近0.8,说明转录组在这些细胞上区分度好;另一些亚群权重只有0.2到0.3,说明蛋白信息在这群细胞里起了主导作用。如果你看到所有类群的权重都挤在0.5附近,那就要警惕了——要么两个模态的信息高度冗余(比如蛋白面板选得和RNA标记基因高度重合),要么某个模态的预处理出了问题,比如ADT的PCA维度取太少,导致蛋白结构没被充分捕捉。
提示:权重图是一张诊断图,不是一个“好看就行”的展示图。权重分布异常往往比聚类结果异常更早暴露数据问题。
4.2 整合后的差异分析和标记基因
聚类定下来之后,差异分析要在RNA assay上做,这是常规操作。但如果你的目的是找“蛋白层面特异、转录组层面不明显”的亚群,就得把两个模态的标记基因都拿出来对照看。
Idents(obj) <- "seurat_clusters" # RNA标记基因 rna_markers <- FindAllMarkers(obj, assay = "RNA", only.pos = TRUE, min.pct = 0.25, logfc.threshold = 0.25) # ADT标记蛋白 DefaultAssay(obj) <- "ADT" adt_markers <- FindAllMarkers(obj, assay = "ADT", only.pos = TRUE, min.pct = 0.1, logfc.threshold = 0.1)ADT的FindAllMarkers参数要和RNA区分开:min.pct可以放低,因为蛋白检出本身就稀疏;logfc阈值也要放低,因为CLR变换后的数值范围比RNA小得多。直接套用RNA的参数,很可能一个标记蛋白都找不出来。
两套结果对照着看,理想状态是每个类群既有RNA标记又有蛋白标记,且两者在生物学上能对上。如果某类群只有蛋白标记、RNA上找不到明显差异基因,这类群往往值得单独拎出来深挖,因为它可能是转录组分辨率抓不到的稀有状态。
4.3 权重信息怎么反哺下游分析
权重不只是诊断工具,还能直接用于下游筛选。一个实用的做法是:把权重极端偏斜的细胞单独提出来看。比如RNA.weight < 0.2的细胞,说明这类细胞的近邻关系几乎全靠蛋白支撑,转录组信号很弱。这类细胞有可能是低质量细胞(RNA捕获差),也有可能是真实的特殊状态(比如某些终末分化细胞转录组整体下调)。判断方法是对这批细胞单独看线粒体比例和nFeature_RNA,如果质控指标正常,那更可能是真实生物学信号,值得保留。
# 提取RNA权重偏低的细胞 low_rna <- WhichCells(obj, expression = RNA.weight < 0.2) summary(obj$nFeature_RNA[low_rna]) summary(obj$percent.mt[low_rna])这一步我建议每个项目都做一遍,花不了几分钟,但经常能发现一些被默认流程忽略掉的细胞群。
5. 常见问题与排查实录
5.1 权重分布一边倒怎么办
最常见的现象是所有权重都接近0或都接近1。前者说明ADT模态在近邻结构里几乎没贡献,后者说明RNA独大。排查顺序我一般是这样走:
先确认两个PCA的维度是否匹配。dims.list里第一个给30、第二个给18,如果实际算出来的pca_adt只有15维,函数会报错或者截断,权重计算就不可靠了。再看ADT的CLR是否用了margin=2,用错margin会让蛋白信号被拉平。最后看抗体面板和细胞类型的匹配度——如果你研究的细胞类型正好不在面板覆盖范围内,ADT自然提供不了区分度,这时候权重偏低是合理的,不该硬调参数。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 权重全部<0.3 | ADT预处理异常或面板不匹配 | 检查CLR的margin参数、PCA维度 |
| 权重全部>0.8 | ADT信号被噪声淹没 | 检查ADT质控、对照蛋白占比 |
| 权重集中在0.5 | 两模态信息高度冗余 | 对照标记基因,判断是否需要WNN |
| 部分类群权重缺失 | 该类群细胞在某一模态被过滤 | 回溯质控步骤 |
5.2 聚类结果和单模态对不上,该信哪个
这个问题没有标准答案,得看你的生物学问题。如果两个单模态聚类只是在亚群细分上有差异,WNN的结果通常更稳,因为它综合了两边的证据。但如果WNN把两个在RNA上分得很开的类群合并了,那就要怀疑是不是ADT的噪声把距离拉近了。
我的处理办法是做一次三方对照:RNA单独聚类、ADT单独聚类、WNN聚类,把三套标签放在同一张UMAP上画并排图。差异最大的那批细胞单独提出来,看它们的权重值是多少。权重偏低的细胞,WNN的归属更可信;权重居中的细胞,说明两个模态给出的信号本身就冲突,这类细胞往往处于过渡态,单独标出来做轨迹分析反而更有价值。
5.3 内存和耗时报错怎么压
细胞数上万之后,FindMultiModalNeighbors很容易吃满内存。几个实际有效的压缩手段:第一,把RNA的PCA维度从50降到30,ADT从30降到15,维度降下来内存占用是平方级的下降;第二,在跑之前把不必要的assay清掉,比如原始counts如果已经用不上,可以只保留data;第三,如果只是探索性分析,可以先随机下采样一部分细胞跑通流程,确认参数合理后再上全量。
# 下采样探索 set.seed(42) subset_cells <- sample(colnames(obj), size = min(5000, ncol(obj))) obj_sub <- subset(obj, cells = subset_cells)注意:下采样只用于调参探索,最终结果一定要用全量数据重跑。权重是细胞级别的,下采样会改变近邻结构,两者结果不能混用。
还有一种情况是跑完之后对象体积暴涨,原因是weighted.nn和wsnn这两个图对象本身占空间。如果后续不再需要重新聚类,可以在保存前把它们删掉,只保留聚类标签和UMAP坐标。
5.4 一个容易被忽略的坑:批次效应和模态整合的先后顺序
如果数据跨多个批次,很多人会先做批次校正再做WNN,这个顺序本身没问题,但要注意校正的对象。批次校正应该在每个模态内部单独做,而不是在整合之后对加权图做。因为WNN的权重是针对细胞算的,如果先整合再校正,校正算法会破坏权重结构。
正确顺序是:RNA内部做批次校正(比如用CCA或Harmony)→ ADT内部单独处理 → 两个模态都干净了再跑FindMultiModalNeighbors。这个顺序我在一个跨批次模拟项目里验证过,反过来做的话,权重图会出现明显的批次相关偏斜,同一类群在不同批次里权重差异很大,聚类也跟着乱。
6. 几个我实际踩过的经验点
最后分享几个文档里不会写、但实际项目里很影响结果的小细节。
第一,ADT面板的对照蛋白一定要参与预处理,但不要参与聚类。对照蛋白在CLR归一化时需要保留,因为它影响细胞内的相对丰度计算;但在选PCA特征时要排除掉,否则对照蛋白的噪声会混进主成分。
第二,WNN的resolution不要和单模态共用一套经验值。因为加权图的近邻结构比单模态更紧致,同样的resolution在WNN上往往聚出更多类群。我的习惯是先固定0.6跑一版,看聚类数是否落在预期范围,再上下微调。
第三,保存对象时把权重列单独导出成csv。权重是解读结果的关键证据,但Seurat对象动辄几个G,合作者不一定愿意为了看一列权重去加载整个对象。导出成表格,配合聚类标签一起给出去,沟通效率会高很多。
weight_df <- data.frame( cell = colnames(obj), cluster = obj$seurat_clusters, rna_weight = obj$RNA.weight ) write.csv(weight_df, "wnn_weights.csv", row.names = FALSE)第四,跑完WNN后回头再看一眼单模态的标记基因。整合的价值在于互补,如果整合后某个类群的标记基因在两个模态上都能自洽解释,那这个类群的置信度就很高;如果只有一个模态支持,建议在报告里标注出来,别当成铁结论。
这套流程我在几个模拟项目里来回跑过,参数不是死的,但排查逻辑是通用的:先看预处理干不干净,再看权重分不分散,最后看聚类和标记基因对不对得上。三步走完,绝大多数“整合了但结果怪”的问题都能定位到具体环节。