拿到一个过滤完低质量细胞、做过归一化处理的h5ad文件之后,单样本数据分析就进入最核心也最好玩的阶段:降维、聚类、细胞注释。很多第一次用scanpy跑单细胞转录组的同学会在这里卡住——要么不停报错,要么跑出来的图总觉得不对劲,要么注释出来一堆"Unknown"。这篇教程我会用一套完整的PBMC数据流程,把降维、聚类、细胞注释这三步的原理、参数、代码和避坑经验一次讲清楚,适合已经完成QC和标准化、想系统走完单样本分析闭环的朋友。
在往下读之前,先确认你的环境:Python 3.8以上,scanpy 1.9以上,并且已经安装了python-igraph(Leiden聚类依赖它)。如果没有装,pip install scanpy python-igraph一步到位。数据层面,我假设你已经有了一个adata对象,里面是归一化、取log之后、并且已经筛过特征基因的表达矩阵,这是进入降维前的标准姿势。
1. 降维:为什么不能直接拿两万个基因去画图
有些人会问:怎么不直接拿原始基因表达矩阵去聚类?原因很现实:单细胞表达矩阵通常有两万个左右的基因,但绝大多数基因在单个细胞里的表达是稀疏的、噪声很大,直接扔给聚类算法,计算量爆炸不说,聚类结果还会被大量无用基因干扰。所以业内标准流程是"先PCA,再邻居图,最后UMAP/t-SNE可视化",每一步都在逐步压缩信息、去噪,让下游分析更稳。
1.1 PCA:基因空间的"压缩"与去噪
PCA(主成分分析)做的事情,是把两万个基因的表达量重新组合成几个互不相关的主成分,每个主成分本质上是基因表达的一个线性组合,前几个主成分往往就捕获了数据中最大的方差来源。在单细胞分析里,我们不是拿PCA做生物学解释,而是把它当作一个"数据压缩和去噪"的前处理步骤——把维度从两万压到几十,后续的邻居计算、聚类都在这个低维空间里进行。
在scanpy里跑PCA非常直接:
import scanpy as sc # 假设 adata 已经做过 normalize_total + log1p sc.tl.pca(adata, n_comps=50, svd_solver='arpack') sc.pl.pca_variance_ratio(adata, n_pcs=50, log=True)这里n_comps=50的意思是保留前50个主成分,svd_solver='arpack'表示用ARPACK迭代方法计算部分主成分,速度比直接算全部SVD快得多。跑完之后一定要看pca_variance_ratio图,也就是每个主成分解释的方差占比。正常情况下,前几个主成分方差占比很高,后面会出现一个明显的"肘部"——拐点之后的主成分解释的方差很少,基本可以认为是噪声。
主成分个数怎么定?不同教程给的答案不一样,但我个人习惯的做法是:先看肘部图,如果肘部在10左右,那说明10到20个主成分就够;如果数据复杂(比如包含多种细胞类型、有部分批次效应残留),会适当提高到30到50。不建议一上来就选100个主成分,因为后面主成分里大多是技术噪声,聚类时反而会把本来连续的细胞群切碎。
有个细节容易被忽略:PCA的标准做法是只使用高变基因(highly variable genes),所以前面做sc.pp.highly_variable_genes时不要省。如果你发现PCA结果第一主成分和第二主成分分别把某个技术因素(比如测序深度、细胞周期打分)分开了,这说明技术噪声太强,最好回到上游做一次回归或者用后面的sc.regress_out处理,而不是硬着头皮往下走。
1.2 邻居图与UMAP:真正驱动聚类的数据结构
PCA完成之后,下一步是构建邻居图和可视化。很多新手会误以为UMAP就是聚类,其实不是。真正驱动聚类的是sc.pp.neighbors计算出来的"细胞间邻居关系图",UMAP只是把这个图投影到二维平面方便人眼观察。所以邻居图的质量直接决定聚类的好坏,而UMAP的参数只影响"看起来好不好看"。
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=30) sc.tl.umap(adata, min_dist=0.5, spread=1.0)n_neighbors是邻居图的参数,默认15,表示每个细胞跟周围的15个细胞建立连接。这个值越小,图越"精细",聚类时越容易分出小群;值越大,图越"粗糙",小群容易被吞并。建议先跑一遍15,如果后面发现分群过碎、或者大群内部被强行切开,再把n_neighbors调到20或25重新跑。
n_pcs=30表示邻居计算时使用前30个主成分,这个值要和上一步选的n_comps配套,可以留在PCA选定的范围内。有些人会问:我在PCA里保留了50个主成分,邻居图也用50个行不行?理论上可以,但实际经验是越靠后的主成分噪声越大,一般用20到30个就够,不必把50个全喂进去。
UMAP的参数里,真正值得调的是min_dist。它控制点在二维平面上的最小间距,默认0.5偏保守,群与群之间分得比较开;想要更明显的离散分群,可以降到0.1到0.3;如果只是想看整体结构,用0.5到1.0都没问题。需要注意,UMAP的投影形态不等于真实的细胞发育关系,两个群在UMAP上离得近,只表示它们的转录组相似度较高,不代表一定存在分化关系。我见过不少人拿着UMAP图硬说某个群是另一个群的"前体",这种推断需要额外的轨迹分析证据支持,不能直接靠肉眼判断。
2. 聚类:算法与分辨率的选择
降维只是给数据换了种表达方式,真正把细胞划分成不同的"类",靠的是聚类算法。在scanpy生态里,现在的主流方案已经从最早的Louvain迁移到了Leiden,2020年之后发的高分文章基本都在用后者。原因很简单:Louvain在部分随机种子下会识别出断连的"假社区",Leiden修复了这个问题,并且能保证社区内部充分连通,结果更稳定。
2.1 Leiden/Louvain怎么选,scanpy怎么跑
直接从代码层面看差异:
# 老方法:Louvain sc.tl.louvain(adata, resolution=0.5, key_added='louvain') # 推荐方法:Leiden sc.tl.leiden(adata, resolution=0.5, key_added='leiden')resolution(分辨率)是最重要的参数,它控制聚类的"颗粒度"。分辨率越小,分出来的群越少越粗;分辨率越大,群越多越细。举个生活化的例子:把一堆混杂的豆子倒进筛子,筛孔越大,捞出来的"类"越少;筛孔越小,豆子按颜色、大小分得越细。
分辨率不是一个有绝对标准答案的参数,它取决于你的生物学问题。比如PBMC样本,常见的预期细胞类型是T细胞、B细胞、NK细胞、单核/巨噬细胞、树突状细胞这么几大类,那分辨率0.5左右通常够用;如果你想进一步把T细胞分成CD4+和CD8+,或者把单核细胞分成经典和非经典亚群,就需要把分辨率调到0.8甚至1.2,让大群分裂成小亚群,再在每个亚群内部验证marker基因。
实操上,不推荐只跑一个分辨率就下结论。我习惯的做法是一次跑好几个分辨率,比如0.1、0.3、0.5、0.8、1.2,然后把这个参数写成leiden_res0.5、leiden_res1.2这样的列存进adata.obs里,再结合marker基因去判断"哪个层级的划分在生物学上解释得通"。scanpy允许你在同一份数据上反复调用sc.tl.leiden,只要每次给key_added一个不同的名字就行,不会互相覆盖。
2.2 分辨率怎么调:先看稳定性,再看marker
判断一个聚类结果好不好,靠的不是"UMAP图上看起来爽不爽",而是标记基因的表达分布。基本流程是:先对每个cluster跑sc.tl.rank_genes_groups找出差异基因,再看差异基因列表里的已知细胞类型marker,判断这个群是不是"有身份"的群。
sc.tl.rank_genes_groups(adata, groupby='leiden', method='wilcoxon', use_raw=True) sc.pl.rank_genes_groups(adata, n_genes=10, sharey=False)method我默认用wilcoxon,也就是Wilcoxon秩和检验,它对表达分布的形状要求较低,适合单细胞这种非正态、零膨胀的数据。use_raw=True表示用原始counts数据做差异检验,避免log后数据在低表达区间的伪差异干扰结果。
跑完之后,你会得到一个每个cluster对应的差异基因列表,这时候我建议马上做两件事:第一,把每个cluster的高表达基因里,属于免疫细胞典型marker的挑出来,看能不能对应上;第二,把明显冗余的cluster合并或重新聚类。举一个实际例子:某次我跑PBMC数据,分辨率0.5时0号群高表达CD3D、CD3E、TRAC,是T细胞;4号群高表达MS4A1、CD79A,是B细胞。两个群分得开、marker也清晰,这个分辨率就是可用的。但如果某个群同时高表达CD3D和CD14(单核细胞marker),那多半是双细胞或者聚类边界没切好,要么提高分辨率再看,要么考虑用双细胞预测工具兜底。
这里有个常被忽略的操作细节:如果UMAP图上的某个cluster边界模糊、和其他cluster犬牙交错,不要急着去调分辨率,先回去看neighbors的n_neighbors参数。我在B细胞和浆细胞样本里踩过坑:n_neighbors=15时浆细胞被硬塞进B细胞群里,怎么调分辨率都分不开;把n_neighbors降到8之后,浆细胞立刻自己成了一团,表达出MZB1、XBP1、JCHAIN这些非常清晰的浆细胞marker。所以算法参数和分辨率参数要配合着调整,不要只在一个维度上猛拧。
3. 细胞注释:从marker基因表到可靠的细胞类型标签
聚类结束后,每个cluster只是一堆"数字编号",细胞注释的任务是给这些编号赋予生物学身份。这一步是整个单细胞分析里最依赖专业判断的部分,也最容易翻车。我的经验是:先建立一张可靠的marker基因表,用它做人工注释,再借助自动注释工具交叉验证,最后统一命名并检查是否存在明显的错误注释。
3.1 marker基因表:来源与常用清单
marker基因表的来源主要有三块:CellMarker数据库、PanglaoDB数据库、以及文献里反复验证过的经典marker组合。数据库的好处是全面,坏处是比较杂,有些条目来自质量不高的旧文章,所以我平时会维护一张自己常用的精简清单,覆盖大多数组织样本里最常见的细胞大类:
| 细胞类型 | 常用marker基因 |
|---|---|
| T细胞 | CD3D, CD3E, CD2, TRAC |
| CD4+ T细胞 | CD4, IL7R, LEF1 |
| CD8+ T细胞 | CD8A, CD8B, GZMA |
| NK细胞 | NKG7, KLRD1, GNLY, KLRC1 |
| B细胞 | MS4A1, CD79A, CD79B, BANK1 |
| 浆细胞 | MZB1, XBP1, JCHAIN, SDC1 |
| 单核/巨噬细胞 | CD14, LYZ, FCGR3A, CSF1R |
| 树突状细胞 | FCER1A, LILRA4, CLEC9A, ITGAX |
| 血小板 | PPBP, PF4, GNG11 |
注意,这张表只是起点,不要机械照搬。不同组织、不同疾病状态的样本,marker的表达强度差异很大;同一个基因在不同细胞类型里也可能有低水平表达,比如FCGR3A在NK细胞和单核细胞里都出现,所以单看一个基因很容易误判,至少要有两个以上的marker共同指向同一类细胞,才能下结论。
3.2 人工注释与自动注释的配合:dotplot、小提琴图与评分法
拿到聚类编号后,我第一个看的图是sc.pl.dotplot,它在每个cluster里展示每个marker基因的平均表达量和阳性细胞比例,信息量很大:
cell_type_markers = { 'T cell': ['CD3D', 'CD3E', 'CD2'], 'B cell': ['MS4A1', 'CD79A', 'CD79B'], 'NK cell': ['NKG7', 'KLRD1', 'GNLY'], 'Monocyte': ['CD14', 'LYZ', 'FCGR3A'], } sc.pl.dotplot(adata, var_names=cell_type_markers, groupby='leiden')如果某个cluster的dotplot里,T细胞marker的点又大又红,B细胞marker的点又小又灰,那这个cluster可以初步判定为T细胞。如果两个marker在同一个cluster里同时高表达,比如CD3D和MS4A1都呈现高表达,那就要怀疑这个cluster是双细胞,或者聚类分辨率太低把两个群混在了一起。
人工看dotplot直观是直观,但样本里有几十个cluster的时候一个个看容易眼瞎。我通常会用sc.tl.score_genes做一个快速的自动打分注释,把每个细胞对每一类细胞类型marker的打分算出来,取最高分对应的类型作为初始注释:
for ct, genes in cell_type_markers.items(): genes = [g for g in genes if g in adata.var_names] sc.tl.score_genes(adata, gene_list=genes, score_name=f'{ct}_score') score_cols = [f'{ct}_score' for ct in cell_type_markers.keys()] adata.obs['predicted_celltype'] = adata.obs[score_cols].idxmax(axis=1).str.replace('_score', '')这段代码的原理很简单:对每个细胞,计算它在一组基因上的平均表达量(减去随机基因集的基线),得分最高的细胞类型就是它的"候选身份"。这个方法胜在快速,几分钟就能为整个数据集生成一版注释,但它不是万能的——如果一个cluster的marker列表不完整,或者几种细胞类型的marker高度重叠,就容易给出错误标签。所以自动打分结果只能作为初筛,一定要回到dotplot和小提琴图上复核。
如果嫌自己的marker列表不够权威,也可以借助SingleR、scType、CellTypist这些工具做参考注释。SingleR的思路是拿已有注释的参考数据集,和新数据做基因表达相关性比较;scType的思路是自动化地匹配组织特异性marker;CellTypist是机器学习训练出来的分类器。它们的优点是不需要人工逐群判断,缺点是依赖参考数据或训练模型,参考数据和你自己样本的组织来源不同时,结果可能更离谱。我的习惯是:自动注释至少跑两个工具,把结果一致的部分作为高置信注释直接采用,结果不一致的cluster再人工复查,这里面的"仲裁"工作,恰恰是单细胞分析里最体现经验的地方。
3.3 注释后的命名与验证
注释完成后,不要直接在adata.obs里随便起个名就完事。我推荐遵循Cell Ontology的命名约定,比如"CD4-positive, alpha-beta T cell"这类规范名,或者至少用通俗但无歧义的简写,比如"CD4 T"、"CD8 T"、"NK"、"Mono CD14"、"DC pDC",尽量别用"cluster0"这种编号当最终标签。命名统一之后,后续做差异分析、富集分析,组别比较代码会省很多事。
验证环节有一个容易被忽略的点:把注释结果以adata.obs['celltype']的形式保存下来,然后重新跑一次sc.pl.umap(adata, color='celltype'),按注释上色看整体分布是否合理。比如T细胞和NK细胞虽然在转录组上比较接近,但通常不会完全重叠;单核细胞和树突状细胞也不应该糊成一片。如果发现某两类细胞在UMAP上彻底重叠,而它们表达着截然不同的marker,那大概率是聚类参数有问题,而不是注释错了。还有个小技巧:每次注释完都adata.write('sample_annotated.h5ad')存一个带注释的完整对象,别只导出一个CSV结果表,不然回头要改分辨率、重新注释的时候,又要从头跑一遍邻居图。
4. 常见问题与排查技巧实录
单样本分析走到这里,该踩的坑基本都踩过了。我把自己在降维聚类和注释阶段遇到过的高频问题集中整理一下,算是速查手册,碰到类似情况可以直接对照排查。
4.1 聚出太多小群,或者大群怎么都分不开
先把现象说清楚:小群过多,通常表现为UMAP上有好几个三五成群的"碎片",每个碎片里只有几十个细胞,差异基因也不强,这种情况多半是resolution设得过高,或者n_neighbors设得过小。处理方法是降低分辨率到0.1到0.3,同时把n_neighbors提到20左右,让邻居关系更平滑。
反过来,大群分不开,最常见的是CD4+T细胞和CD8+T细胞糊在一起,或者B细胞和浆细胞糊在一起。这种情况不要急着调参数,先在dotplot里确认这两个群的marker是否都有表达——如果确实都表达了,说明数据里这个分辨率下本来就是连续过渡的,可以适当提高分辨率强行切;如果marker表达本来就很弱,那说明上游标准化或特征基因筛选有问题,切了也是硬切,注释出来也站不住脚。
4.2 注释结果诡异,marker表达跟预期完全对不上
遇到这种情况,第一件事不是怀疑算法,而是检查基因名。人类基因符号是全大写字母(如CD3D),小鼠基因符号是首字母大写其余小写(如Cd3d)。如果你用人的marker列表去注释小鼠数据,或者反过来,所有marker评分都会是一坨乱码。我用过一次Ensembl ID导入的矩阵,基因名全是ENSG开头,直接拿"CD3D"去var_names里找是找不到的,必须先把基因ID转换成标准symbol,这一步在scanpy里可以用sc.queries.biomart_annotations等工具做,但前提是你要能联网并正确配置参考物种。
另外,有些marker在不同数据版本里的注释符号有别名,比如老的MZB1曾被叫做PLAC1,如果在你的矩阵里找不到MZB1,可以试试用它的别名搜索。还有一个非常容易踩的坑:你用use_raw=True做差异分析时,adata.raw里的基因名和adata.var_names必须一致,否则sc.pl.dotplot会提示找不到基因,或者画出来全是灰点。
4.3 细胞周期和混杂细胞干扰聚类
细胞周期效应是单细胞聚类里绕不开的"幽灵"。同一个细胞类型,处在G1期和S期的细胞,转录组差异可能比不同细胞类型之间的差异还大。结果就是:UMAP图上会出现一个"增殖群",里面混着各种类型的细胞,注释的时候特别容易误判成某种独立的细胞类型。
处理方案有两个方向:一是用sc.tl.score_genes_cell_cycle计算每个细胞的细胞周期打分,然后用sc.regress_out把周期信号回归掉,再回到PCA那一步重新跑邻居图和聚类;二是在解释结果时把周期群单独标记为"Cycling",不强行赋予细胞类型身份。我个人倾向于先评分、再看实际影响,如果周期群的marker确实很干净(比如全是MKI67、TOP2A这类增殖基因),那直接标注成增殖细胞反而是更诚实的做法,不做回归也能让其他细胞类型的注释更清晰。
还有一类混杂细胞也容易捣乱:红细胞。红细胞高表达HBB、HBA1、HBA2这些血红蛋白基因,如果红细胞残留较多,它们会聚成一个非常耀眼的大群,把所有稀有细胞群都"挤扁"。我处理这类问题的方法是,在QC阶段就利用红细胞marker把明显的红细胞团先过滤掉,而不是等到聚类后再清理。如果在聚类后才发现,也可以手动把高表达HBB的cluster从下游分析里剔除,但这样做之前一定要确认阳性细胞比例,别误伤了正常的低表达群。
4.4 性能与运行环境问题速查
scanpy处理十万细胞级别的单样本,对内存的要求不低,但也没到离谱的程度。我遇到过几个典型的性能问题,列在下面供参考:
| 问题 | 可能原因 | 解决建议 |
|---|---|---|
sc.pp.neighbors跑得很慢 | 细胞数量大,默认CPU多线程没吃满 | 设置n_jobs参数或手动调sc.settings.n_jobs |
sc.tl.leiden报错找不到igraph | 缺少Leiden依赖 | pip install python-igraph |
| UMAP反复卡死 | sc.tl.umap默认使用umap-learn,数据量大时耗时长 | 考虑切到scanpy支持的rapids,或先对数据下采样测试参数 |
| 代码在Windows上经常报并行相关的错 | Windows对多进程支持不如Linux | 建议在WSL或Linux服务器上跑正式分析 |
一个容易被忽略的技巧:在跑大规模数据前,先对数据做一个随机子采样(比如抽2万个细胞),用子集把PCA、邻居图、Leiden参数全部调顺,确认marker注释逻辑没问题,再用全量数据正式跑一遍。这样调试迭代的周期会短很多,参数也不是靠猜,而是有据可依。
最后再分享一个小技巧:在把注释结果写进论文或报告之前,记得导出一张按细胞类型着色的UMAP图、一张核心marker的dotplot、一张每个集群细胞数量的条形图,这三张图基本就是单样本数据分析的标准配置。我个人的体会是,单样本分析最考验人的不是代码跑不跑得通,而是面对一个模糊的聚类结果时,你敢不敢根据marker证据下结论。多跑几个分辨率、多画几个图、多对照几次marker表,注释的置信度就会上去一大截。如果你在实操里遇到其他奇怪的报错,把你的adata.obs信息、聚类参数和报错截图整理好,多数问题都能从这几个环节里找到答案。