单细胞测序数据处理走到这一步,前面已经完成了质控、标准化、高变基因筛选、PCA降维这些前置操作,手里是一个已经Scale过的Seurat对象。接下来要做的就是把上万个细胞按照转录组相似性分堆,并且找出每一堆的“身份标签”——也就是t-SNE聚类分析和寻找marker基因。这部分是整个单细胞流程里最核心、也最容易被各种参数坑到的一环。
从我开始跑单细胞数据到现在,印象最深的就是第一次跑聚类时对着t-SNE图发呆:图是出来了,但根本分不清哪些群是有生物学意义的、哪些是技术噪音,更不知道下一步该拿什么标准给细胞命名。后来把t-SNE的降维逻辑、聚类的参数选择、marker基因的统计学检验原理都啃了一遍,才算真正理解这条流程。
这篇文章就把t-SNE聚类分析和marker基因筛选的完整流程、参数选型逻辑、常见翻车场景都整理出来,写给正在跑Seurat流程、准备做细胞注释的朋友。
1. 聚类分析前置:为什么要做t-SNE,数据从哪里来
1.1 为什么单细胞数据必须降维和聚类
单细胞转录组数据本质是一张巨大的基因表达矩阵,行是细胞,列是基因。一个10X平台出来的样本,通常能捕获一万到两万个基因,测到五千到一万个细胞。这意味着每个细胞都是一个上万维的向量,整份数据就是一个高维空间里的点云。人类没法直接理解上万维空间里的距离和密度,必须把它压缩到二维或三维,才能用眼睛找到规律。
聚类分析就是在这张表达矩阵上,根据转录组相似性把细胞分成若干个组。这个思路其实和传统的统计学聚类一脉相承——类似SPSS里的K-means、系统聚类,本质上都是把相似样本归堆。但单细胞数据有自己的特殊性:维度极高、稀疏性极强、噪音很大,直接把传统聚类工具搬过来跑,结果往往惨不忍睹。所以单细胞分析里才演化出了一套“先降维、再聚类”的标准操作。
为什么要先降维?因为高维空间里距离度量会失真,直接用原始表达矩阵算细胞间距离,大量噪音基因会淹没真实信号。降维相当于把“噪音”和“信号”做了一次分离,让聚类在更干净的低维空间里进行。这也是为什么t-SNE在这条流程里的定位不是聚类本身,而是把聚类结果“画”出来给人看。
1.2 从PCA到t-SNE的递进,两种降维的分工
在Seurat的标准流程里,降维是分两步走的。第一步是PCA,也就是主成分分析,它是线性降维,目标是把高维数据投影到方差最大的几个方向上,保留数据的主要结构。PCA输出的主成分数量通常在几十个以内,这一步的主要目的是压缩维度、去除噪音,为后续分析打底。
很多人在这里有个误区,觉得PCA降维之后就可以直接聚类。实际上PCA本身不适合直接展示细胞群体结构,因为它的线性约束让它只能捕捉全局方差,很难把复杂的非线性细胞状态分离开。t-SNE的作用就在这里体现出来:它在PCA的基础上再做一次非线性降维,把高维空间里的局部相似性映射到二维平面,让相似的细胞在图上靠得更近,不同类群之间的缝隙更明显。
t-SNE的代价也很明显:全局距离失真。t-SNE的二维坐标只反映局部的相似性,群与群之间的距离远近没有绝对意义,甚至同一次运行、不同的随机种子,画出来的图都会有些许差别。所以t-SNE在流程里的角色就是“可视化工具”,真正的分群依据还是来自聚类算法的结果,不能拿t-SNE图的视觉距离去判断细胞关系。
1.3 开始聚类前,Seurat对象需要准备到什么程度
很多新手会忽略一个关键前提:t-SNE聚类分析和寻找marker基因,是建立在一个已经完成标准预处理的Seurat对象上的。具体来说,前面的流程至少要跑完这几步:质控过滤(剔除低质量细胞)、标准化(NormalizeData)、高变基因筛选(FindVariableFeatures)、数据缩放(ScaleData)和PCA降维(RunPCA)。
如果前面的步骤没做扎实,后面跑出来的聚类图就非常容易出问题。比如质控没做好,死细胞或者双细胞的转录组特征会形成一堆“假群”,导致marker基因筛选出来的结果全是线粒体基因或核糖体基因,完全没法做注释。我踩过最狠的一次坑就是QC阈值放得太松,结果整整一个下午都在跟一个纯线粒体基因的cluster较劲。
准备到这个程度之后,我强烈建议先把对象保存成RDS文件再继续。这一步看似多余,实际上能救命——后面每调试一次参数,如果都要从头跑一遍前面的标准化和PCA,时间成本是成倍增加的。
# 假设你已经跑完了QC、Normalize、ScaleData、RunPCA saveRDS(seurat_obj, file = "seurat_after_pca.rds") # 下次直接加载,省掉前面的所有预处理时间 seurat_obj <- readRDS("seurat_after_pca.rds")需要确认的是seurat_obj@reductions里已经有pca这个降维结果,seurat_obj@assays$RNA@scale.data也已经生成。如果没有,请返回上一步补齐,不要强行往下走。
2. t-SNE聚类实操:Seurat里的标准流程与参数控制
2.1 三步走:FindNeighbors、FindClusters、RunTSNE
真正做聚类和t-SNE,Seurat里就是三段式操作:先构建细胞间的图结构,再在这个图上做社区发现聚类,最后把聚类结果放到t-SNE坐标里展示。每一步都有各自的参数,选错任何一个,结果可能就是天壤之别。
第一步是FindNeighbors。这一步会基于PCA结果计算细胞之间的相似性,并且构建KNN图。这里唯一的参数是dims,也就是使用前多少个主成分。选择标准有两个:一个是看ElbowPlot,找到主成分方差贡献率下降趋缓的拐点;另一个更实用的思路是,根据预期细胞类型复杂度来定,一般免疫细胞图谱用10到20个主成分都比较常见。主成分选得太多,引入的噪音会把聚类结果打散;选得太少,又会丢失部分真实的生物学信号。
第二步是FindClusters。这一步是在KNN图上做聚类,默认算法是Louvain算法,它通过迭代优化模块度来寻找图中的社区结构。这里最重要的参数是resolution,它直接控制聚类结果的粒度:数值越大,分出来的群越多;数值越小,群越少。很多人第一次跑的时候直接用默认的0.8,这不一定适合所有数据。
第三步是RunTSNE,它接收聚类结果,然后计算t-SNE坐标。这一步唯一需要注意的是set.seed,因为t-SNE的迭代过程是随机初始化,不同的种子会得到稍微不同的二维坐标,但这不改变聚类本身的结果。
# 标准三步走 seurat_obj <- FindNeighbors(seurat_obj, dims = 1:15) seurat_obj <- FindClusters(seurat_obj, resolution = 0.5) seurat_obj <- RunTSNE(seurat_obj, dims = 1:15, seed.use = 42) # 可视化 DimPlot(seurat_obj, reduction = "tsne", label = TRUE)这段代码看起来简单,但里面的dims和resolution才是真正决定结果质量的参数。跑完DimPlot之后,重点不是看图好不好看,而是看分群数量是否符合你对样本复杂度的预期。如果你预期样本里有十种左右的细胞类型,但画出来只有四个群,说明resolution给低了;如果出来三十个群,很多群只有一两个细胞,说明分辨率太高,需要降下来。
2.2 resolution怎么选,种子要不要设
resolution的调试,是我做聚类时花时间最多的地方。一个比较实用的方法是,从一个较低的值开始,比如0.1,然后逐步往上调,记录不同分辨率下群的数量和群内细胞数。重点观察两个指标:一是群的数量是否还符合生物学预期,二是是否存在细胞数量特别少的“碎片群”——通常少于总细胞数0.5%的群,非常可疑。
实际操作中,我一般会跑一个循环直接把多个分辨率的结果全部算出来,再统一看图。
# 同时尝试多个分辨率,比较后再选 for (res in c(0.1, 0.3, 0.5, 0.8, 1.0, 1.2)) { seurat_obj <- FindClusters(seurat_obj, resolution = res) print(paste0("resolution=", res, ", clusters=", length(unique(seurat_obj$seurat_clusters)))) }比较下来,如果0.5到0.8这个范围内群的数量已经稳定,一般就选那个中间值。但这里有一条重要的实操经验:不要盲目追求群的数量多。群的粒度应该由下游的生物学问题决定。比如你做免疫细胞图谱,想区分CD4和CD8,那分辨率至少要到能分出T细胞亚群的程度;如果你只想分大类,分辨率可以适当低一些。
至于种子,RunTSNE里一定要设。这不仅仅是为了结果可复现,更是为了调试工作流的时候不会被随机性干扰。你调试参数的过程中,如果每次跑出来的图都不一样,你会完全无法判断当前改动是改善还是恶化。设置种子之后,至少t-SNE的可视化层面是稳定的。注意,FindClusters里也可以设置random.seed,但更常见的是在开头用set.seed(42)统一控制所有随机过程。
2.3 UMAP和t-SNE怎么配合用
很多教程会把UMAP和t-SNE放在一起比较,初学者经常纠结该用哪个。说一个我自己实践下来的结论:两个都跑,但它们的用途不同。t-SNE擅长展现局部分群结构,群内部相对紧凑,适合微观观察;UMAP在保持全局结构方面做得更好,群与群的相对位置更有意义,适合整体布局判断。
在Seurat里跑UMAP很简单,RunUMAP的参数逻辑和RunTSNE几乎一样,同样需要指定dims和种子。我通常把t-SNE作为默认展示图,因为它在免疫细胞类群之间拉开的间距更明显,视觉上更容易向合作者解释;但做批次效应评估或者跨样本比较时,我会更信任UMAP的全局排列。
这里也给一个对比表格,方便你按需选:
| 维度 | t-SNE | UMAP |
|---|---|---|
| 降维类型 | 非线性,侧重局部结构 | 非线性,兼顾局部与全局 |
| 群间距离 | 不能作为定量依据 | 相对更可信 |
| 运行速度 | 大数据集上明显更慢 | 通常更快 |
| 参数敏感性 | 对perplexity较敏感 | 对n_neighbors较敏感 |
| 适用场景 | 精细分群的可视化展示 | 整体结构判断、批次评估 |
实际项目里我经常是先用UMAP判断细胞群整体的拓扑结构,选好分辨率之后再用t-SNE出正式图表,两个图一起放进报告里。
3. 寻找marker基因:从统计学逻辑到实际操作
3.1 marker基因是什么,找到它有什么意义
聚类完成之后,每个细胞都被贴上了一个数字标签(比如0、1、2、3),但数字本身没有生物学含义。接下来要做的事情,就是给每个cluster找到一个“身份证明”——也就是marker基因。marker基因指的是在某一个cluster里特异性高表达、而在其他cluster里低表达的基因。
为什么要强调“特异性高表达”而不是“平均表达高”?因为有些基因,比如核糖体蛋白基因,几乎在所有细胞里都高表达,它们不能区分细胞类型。真正的marker基因就像每个人群里的“暗号”,看到某个基因高表达,就能大概率判断这个细胞属于哪一类。比如CD3E是T细胞的经典marker,CD19和MS4A1是B细胞的marker,LYZ和S100A8则是髓系细胞的常见marker。
找marker基因的意义主要有两个:一是辅助细胞类型注释,这是最直接的用途;二是验证聚类结果的质量。如果某个cluster里筛出来的marker全是不知名的长链非编码RNA,而且表达模式乱七八糟,那大概率是技术伪迹而非真实的细胞亚群,这时候就要回头检查聚类参数了。
3.2 FindAllMarkers的关键参数和计算原理
在Seurat里,最常用的找marker函数是FindAllMarkers,它的底层逻辑是对每个cluster做一次“该cluster vs 所有其他细胞”的差异表达检验。默认使用Wilcoxon秩和检验,这是一种非参数检验方法,不假设数据符合正态分布,非常符合单细胞表达数据的分布特征。
关键参数有四个:only.pos、min.pct、logfc.threshold、test.use。only.pos = TRUE表示只返回在该cluster中上调的基因,因为注释细胞类型时最关心的就是上调基因。min.pct表示基因在两组细胞中被检测到的比例下限,默认0.1,我一般会调到0.25,过滤掉那些只在极少细胞里表达的基因。logfc.threshold表示平均表达差异的倍数变化阈值,默认0.25,如果希望结果更紧凑可以调到0.5或1。
一个很容易忽略的问题是test.use。默认是Wilcoxon,但有些文章会用MAST、DESeq2等。这里结合我自己的使用体会说明:Wilcoxon速度快、结果稳健,适合初次筛选;如果有比较强的批次效应或者样本结构复杂,可以考虑MAST,它是专门为单细胞数据设计的,对零膨胀有处理,但速度慢很多。绝大多数的场景下,Wilcoxon就足够了,没必要为了炫技换一个更慢的检验。
# 寻找所有cluster的marker基因 markers <- FindAllMarkers( seurat_obj, only.pos = TRUE, min.pct = 0.25, logfc.threshold = 0.5, test.use = "wilcox" )跑完之后,markers是一个data.frame,每一行代表一个“基因在某cluster里作为marker”的检验结果。对这个结果表的解读,是整个环节里的重头戏。
3.3 从结果表到真实marker的筛选方法
FindAllMarkers输出的结果表里,最关键的三列是cluster、avg_log2FC和p_val_adj。cluster表示基因对应哪个cluster;avg_log2FC表示该基因在这个cluster和所有其他cluster之间平均表达倍数变化的log2值,数值越大说明越特异;p_val_adj是多重检验校正后的p值,越小说明差异越显著。
筛选的第一步,是过滤掉p_val_adj不显著的基因,一般取小于0.05;第二步是过滤掉avg_log2FC过低的基因,这个阈值要根据注释的精细程度调整。注释大类细胞类型时,可以保留avg_log2FC> 0.5 的基因;注释T细胞亚群这种比较接近的类群时,可能需要把阈值降到0.25,否则可能筛不到足够多的标记。
# 筛选显著且高表达的marker sig_markers <- markers[markers$p_val_adj < 0.05, ] top_markers <- sig_markers %>% group_by(cluster) %>% arrange(desc(avg_log2FC), .by_group = TRUE) %>% slice_head(n = 10) # 按cluster查看top基因 top_markers[, c("cluster", "gene", "avg_log2FC")]这里我有一个经常使用的判断技巧:如果某个cluster的top基因里出现大量的线粒体基因(MT-开头)或者核糖体基因(RPS/RPL开头),这个cluster很可能是低质量细胞或者双细胞形成的伪群。这些基因在几乎所有细胞里都表达,它们的“高表达”往往反映的不是细胞类型特征,而是细胞状态异常。遇到这种cluster,不要急着注释,先回到数据里看看这个cluster的QC指标是不是偏低了。
4. 结果可视化与细胞类型注释
4.1 三种可视化工具各解决什么问题
marker基因筛选出来之后,还需要通过可视化来验证结果,不能只盯着统计表格。Seurat里最常用的三个可视化函数是DimPlot、FeaturePlot和VlnPlot,它们分别解决不同的问题。
DimPlot用于展示整体聚类结构,就是把t-SNE/UMAP坐标图按cluster染色,直观看到分群边界和群间关系。FeaturePlot用于展示某个基因在降维图上的表达量分布,比如你想确认CD3E到底是不是在某个cluster里特异性高表达,在t-SNE图上直接把该基因的表达量映射为颜色即可。VlnPlot则以小提琴图的形式展示某个marker基因在不同cluster中的表达分布,比FeaturePlot更能反映表达量的统计分布。
实际应用里,我一般这样配合使用:先用DimPlot看全局,再用FeaturePlot看候选marker的空间分布,最后用VlnPlot确认表达差异是否统计上可靠。三层验证都通过了,一个cluster的身份才算基本锁定。
# 三种可视化示例 DimPlot(seurat_obj, reduction = "tsne", label = TRUE) FeaturePlot(seurat_obj, features = c("CD3E", "CD19", "LYZ"), reduction = "tsne") VlnPlot(seurat_obj, features = c("CD3E", "CD19", "LYZ"), pt.size = 0)小提琴图里默认会画出每个细胞的散点,细胞数量大的时候图会很乱。这里给一个建议:看群体分布的时候把pt.size设成0,只保留密度分布曲线,这样能更清晰地比较不同cluster之间的表达差异。同时可以通过stack方式把多个marker基因的小提琴图纵向堆叠,一张图就能看清一个cluster所有的marker表达模式。
4.2 怎么把cluster ID变成真正的细胞类型
这是让很多人头疼的一步:cluster数字编号知道了,top marker也看完了,但到底该叫什么名字?我的做法是先整理一个候选marker清单,然后逐个cluster对照。比如某个cluster的top marker里同时出现CD3D、CD3E、IL7R,基本可以判定为T细胞;如果还出现CCR7、LEF1这些基因,可以进一步推断为初始T细胞或者中央记忆T细胞。
但这里要提醒一点:不要只看单个marker,要看marker组合。有些marker并不完全特异,比如CD4在单核细胞里也有一定表达,仅凭CD4一个基因不能判定这个cluster就是CD4 T细胞。靠谱的做法是,一个cluster里至少要有两到三个已知marker同时出现,而且表达模式互相印证,才能做注释。
另一个重要的经验是:先注释大类,再注释亚群。不要一上来就想着区分CD4 Naive、CD4 Memory、CD8 Effector这些精细亚群。先把T细胞、B细胞、NK细胞、单核细胞、树突状细胞这些大类分清楚,大类注释之后如果数据质量支持,再对感兴趣的细胞群做二次聚类、二次注释。我见过不少新手的分析报告,第一次注释就直接给每个cluster一个很精确的细胞类型名称,结果因为marker证据不足被审稿人问得下不来台。
4.3 手动验证和数据库辅助注释
除了自己看marker,还可以借助一些已知的标记基因数据库做辅助验证,比如CellMarker数据库、PanglaoDB、SingleR自动注释工具。这些工具可以作为参考,但不能直接替代人工判断。尤其要注意的是,不同数据库之间的marker基因命名和特异性存在差异,同一个基因在不同的数据集里特异性也不同。
在我自己的流程里,通常会先跑一个SingleR或celldex的自动注释作为粗筛,再用人工注释的marker组合去校准。如果人工注释和自动注释在大类水平上冲突明显,我会重新审视聚类参数和marker筛选阈值,而不是直接信任自动注释的结果。自动注释的结果最多只能作为线索,最终命名还是要靠marker基因的组合证据。
5. 常见问题、翻车现场与避坑技巧
5.1 为什么两次运行聚类结果不一样
不少人在复现别人代码时遇到过这种情况:两次运行FindClusters或RunTSNE,结果看起来不太一样。这里要分清两种不同情况。
第一种情况是 t-SNE图的位置发生变化,但cluster的组成基本一致。这是正常的,因为t-SNE的降维过程有随机初始化,不同的随机种子会得到略有差异的坐标排布。这种情况不影响下游结论,只要设置了种子,图就是可复现的。
第二种情况是 cluster 成员本身都变了,跟在跑两个完全不同的数据一样。这时候就要警惕,通常是因为FindClusters的随机种子没有固定。在Seurat里,Louvain算法也有随机性,如果不固定种子,每次聚类可能都不相同。解决办法是在FindClusters时加上random.seed,或者在脚本开头统一set.seed。
# 建议的写法:显式固定随机种子 set.seed(42) seurat_obj <- FindClusters(seurat_obj, resolution = 0.5, random.seed = 42)5.2 常见报错和处理速查表
我整理了平时被问得最多的一些报错,多数是参数或对象结构的问题,排错思路都写在表格里了。
| 报错/异常现象 | 可能原因 | 解决方案 |
|---|---|---|
| 找不到pca这个reduction | 没跑RunPCA或读取的是旧版本对象 | 重新运行RunPCA后继续 |
| FindClusters后没有seurat_clusters列 | Seurat版本新老差异或对象未正确保存 | 用seurat_obj$seurat_clusters手动赋值 |
| t-SNE图上所有细胞挤成一团,无分群 | 主成分数选得太少,或数据存在严重批次效应 | 增加dims,或先做批次整合 |
| marker基因全是线粒体/核糖体基因 | 低质量细胞或双细胞混入 | 回退到QC阶段收紧线粒体比例上限 |
| 某个cluster只有几个细胞 | resolution过高或聚类随机性 | 降低resolution,或检查是否过聚类 |
遇到报错不要慌,先看对象结构,再逐条排查前置步骤是否有漏。单细胞分析的大部分问题,根源都在前期的QC和标准化上,而不是聚类本身。
5.3 几条我的实操心得
最后分享几个我自己踩过之后总结出来的心得。
第一,聚类和marker筛选不是一锤子买卖。第一次跑出来的聚类结果往往不是最终的,需要反复调整resolution、重新筛marker、验证注释结果。这个迭代过程看起来耗时,但非常必要。我一般会记录下不同参数组合下的cluster数量、关键marker的富集情况,以便复现和调整。
第二,不要过度解读t-SNE图上“看上去很近”的群。t-SNE的局部结构很有欺骗性,尤其是在perplexity设置不当时,无关群体可能被强行拉近。判断cluster之间关系时,更可靠的方法是看marker基因的表达谱相似度,而不是看图上的距离。
第三,marker基因筛选的阈值不要拍脑袋定死。免疫细胞大类和上皮细胞亚群的表达差异幅度完全不同,同一个logfc.threshold不能适用于所有场景。我的建议是先跑一遍默认参数看整体结果,再根据具体注释需求调整阈值。如果筛出来的marker数量太多,就提高阈值和min.pct;如果数量太少,就降低阈值。
这套流程跑通之后,你会发现单细胞分析的“聚类+注释”其实是有节奏感的:聚类给的是线索,marker给的是证据,注释是最后的综合判断。把这个节奏掌握了,后面哪怕换一批数据、换一个物种,你也能很快跑出靠谱的结果。