第4章 标准化与归一化:CPM、LogNormalize、Seurat方法在Scanpy中的实现、scran的size factor
拿到单细胞表达矩阵后,你肯定会问:要不要标准化?归一化又是啥?说白了,这两步是为了消除细胞间测序深度差异。不同细胞捕获到的UMI总数可能差十倍,不处理的话聚类结果全被总表达量带偏了。
我刚开始做单细胞时,就吃过这个亏。记得有一次跑完聚类,发现最大的群体全是高UMI的细胞,跟生物学一点关系没有。嗯,自那以后我特别重视归一化这一步。
先理清两个概念:标准化(Normalization)通常是缩放每个细胞的表达量,让细胞间可比较;归一化(Scale/Standardization)有时指特征维度的零均值单位方差。不过单细胞社区里经常混着用,我们这里按主流说法:“归一化”指总表达量缩放+对数变换,“标准化”指基因维度的Z-score或Robust scale。本章主要讲前者。
4.1 CPM:Counts Per Million
CPM是最简单的思路:每个基因的表达量除以该细胞的总UMI,再乘以一百万。公式:
CPM = (gene_UMI / total_UMI_per_cell) * 1e6
在Scanpy里,用normalize_total设置target_sum=1e6即可:
import scanpy as sc
adata = sc.read("raw_counts.h5ad")
sc.pp.normalize_total(adata, target_sum=1e6) # 得到CPM
不过CPM没有考虑基因长度,对于UMI数据倒不是大问题(因为UMI本身是分子计数不依赖长度)。但CPM后数据还是正偏态,直接做PCA效果不好。所以我一般至少会再加一步log1p。
4.2 LogNormalize:总表达量归一化 + 对数变换
这是Seurat默认的LogNormalize方法:先缩放每个细胞到固定总数(通常是1e4),再取log(1+x)。这样做的好处是让数据接近正态分布,方差更稳定。
Scanpy里两步走:
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
实际项目中,我发现target_sum的值影响不大——1e4、1e5甚至1e6,只要后续所有数据一致就行。我习惯用1e4,跟Seurat保持一致,方便和别人讨论结果。
4.3 Seurat标准化方法在Scanpy中的复现
Seurat的NormalizeData默认就是LogNormalize。但Seurat还有个SCTransform(方差稳定变换),在Scanpy里可以用sc.pp.experimental.pp.scran? 其实SCScanpy有scanpy.external.pp.sctransform,需要装额外的包。不过我们这章只讲基础对比,就先不展开SCT了。
关键点:Seurat的LogNormalize等价于上面normalize_total(target_sum=1e4) + log1p。注意Scanpy的log1p是自然对数,而Seurat默认也是自然对数(R的log1p)。所以完全对应。
重要:如果你从Seurat转到Scanpy,千万别再做额外的ScaleData或Scale。那是基因维度的标准化,跟细胞归一化是两回事。两者顺序别搞反:先归一化,再选高变基因,最后可选择性标准化。
4.4 scran的size factor归一化
scran是R包,它提出用“池化”策略估算每个细胞的size factor,比简单总UMI更鲁棒,尤其适合有明显的细胞类型特异性总RNA含量的数据。比如有些肿瘤细胞整体转录活性高,直接用总UMI归一化会掩盖差异。
在Scanpy里用scran,需要安装scanpy.external.pp.scran。它会自动调R的scran包(确保已安装R且装了scran、BiocParallel)。用法:
import scanpy.external as sce
# 需要预先有原始counts,且计算过质控指标
sce.pp.scran(adata, n_jobs=4)
这个函数会在adata.obs里添加size_factor列,并自动把adata.X更新为归一化后的表达量(log1p后的)。如果你想保留原始counts,可以先把raw存起来。
4.5 方法对比与选择建议
| 方法 | 原理 | 优点 | 缺点 | Scanpy实现 |
|---|---|---|---|---|
| CPM | 除以总UMI×1e6 | 简单直观,保留计数比例 | 仍偏态,不适合高斯模型 | normalize_total(target_sum=1e6) |
| LogNormalize | 先缩放到1e4,再log1p | 数据近似正态,方差稳定 | 对数压缩会拉平高表达差异 | normalize_total(1e4)+log1p |
| scran size factor | 池化解卷积估算尺度因子 | 对不同RNA丰度的细胞更公平 | 计算慢,依赖R包,稀疏敏感 | sce.pp.scran() |
我自己的原则:团队里统一标准。如果同事们用Seurat,我就用LogNormalize;如果数据有明显细胞大小差异(比如巨噬细胞 vs 淋巴细胞),我会试试scran。CPM现在用得少了,主要是部分下游工具(如cellranger的减背景)需要CPM作为输入。
4.6 实战代码示例:在Scanpy中实现多种归一化
假设你已经有一个AnnData对象adata,且adata.X为原始整数counts。我们依次演示三种方法:
# 方法1:CPM
adata_cpm = adata.copy()
sc.pp.normalize_total(adata_cpm, target_sum=1e6)
# 通常还需要log1p才能做PCA
sc.pp.log1p(adata_cpm)
# 方法2:LogNormalize (推荐)
adata_lognorm = adata.copy()
sc.pp.normalize_total(adata_lognorm, target_sum=1e4)
sc.pp.log1p(adata_lognorm)
# 方法3:scran size factor
# 需要先安装R包: install.packages('scran')
# 并确保rpy2可用
import scanpy.external as sce
adata_scran = adata.copy()
sce.pp.scran(adata_scran, n_jobs=4) # 自动完成归一化和log1p
# 注意:scran会修改adata.X,并记录size_factor到adata.obs['size_factor']
贴士:如果你想保留原始counts,做scran之前一定要复制一份。我总是在项目开始时建一个adata.raw:
adata.raw = adata.copy() # 存一份raw counts
4.7 常见问题与避坑指南
- 问题:归一化后表达量出现负数? 不会,log1p后最小值是0。如果用了
sc.pp.scale()(Z-score),才会有负数。那是特征缩放,不同概念。 - 问题:scran报错“no non-zero cells” 我曾经被这个坑过。原因是filter后有些细胞所有基因为0,scran会报错。解决方法:过滤掉那些总UMI<100的细胞,或者用
sc.pp.filter_cells(min_counts=200)。 - 问题:LogNormalize后我还能做差异分析吗? 完全可以,而且强烈建议。差异分析通常基于归一化后的log表达值(如Wilcoxon秩和检验)。
- 问题:不同归一化方法对批次校正有影响吗? 有的。BBKNN和scVI都期望输入已经是归一化和对数变换后的数据(scVI内部会重新处理)。我个人推荐:先统一用LogNormalize,再跑批次校正。除非有特别大的批次技术噪音,才考虑用scran的size factor校正后再跑BBKNN。
normalize_total和log1p。