数据预处理:质量控制、过滤、归一化

好,咱们进入第3章。前面聊了怎么读数据、看数据结构,现在要动真格的了——对原始数据进行预处理。

我个人认为,这步是整个流程里最容易被低估的环节。很多同学一上来就跑什么批次校正、聚类,结果出来的图乱七八糟,回头一查,原来是原始数据里混了太多垃圾。听我一句劝,预处理做扎实了,后面80%的问题都不会出现。

3.1 数据概览:先摸清家底

拿到一个空间转录组数据集,别急着上脚本。先问问自己几个问题:

  • 这是哪个平台的数据?10x Visium、Slide-seq还是MERFISH?
  • 每个spot覆盖多少个细胞?
  • 测序深度大概多少?UMI数分布怎么样?

我不建议大家直接写代码,先用手头的探索性工具看一看全局。比如用Seurat或者Scanpy快速画几个分布图。

我常用的几个快速检查指标:

  • 每个spot的总count数(UMI数)
  • 每个spot检测到的基因数
  • 线粒体基因占比
  • 空spot比例(如果有H&E染色辅助判断)

如果你发现某个样本的UMI数中位数只有几百,另一个样本有上万,那后面必须做标准化,不然所有比较都白搭。

3.2 质量控制:把脏东西筛出去

质量控制说白了就是两个维度:一个是spot级别的QC,一个是基因级别的QC。但空间转录组有个特殊之处——你没法像单细胞那样直接看细胞活性,但你可以看spot的空间位置。

Spot级别的QC指标:

注意:空间转录组的QC阈值不能照搬单细胞。比如单细胞里线粒体比例超过20%的细胞通常扔掉,但空间转录组的spot可能混了好几类细胞,线粒体比例高不一定代表该spot质量差。我在项目里遇到过一例,某个组织区域的spot线粒体比例普遍偏高,后来切片染色确认那片区域就是代谢活跃区,不是坏点。

我建议的QC流程:

  1. 总UMI数过滤:扔掉UMI数低于某个阈值的spot。阈值怎么定?看UMI分布曲线的拐点。一般在100-500之间。
  2. 检测基因数过滤:检测到基因数太少的spot,可能是组织边缘或者空位。阈值通常在200-300。
  3. 线粒体比例过滤:空间转录组我一般放到30%-50%,看具体组织类型。脑组织线粒体比例通常偏高,肝脏也是。
  4. 空间异常点过滤:这点很多教程不提。有些spot虽然UMI数正常,但位置在组织边缘或者组织空洞里。如果有组织学图像,一定要结合来看。
# 伪代码示例:Seurat风格的QC过滤
# 假设你已经有了Seurat对象
spatial_obj[["percent.mt"]] <- PercentageFeatureSet(spatial_obj, pattern = "^MT-")

# 过滤条件
spatial_obj <- subset(spatial_obj, 
    subset = nCount_Spatial > 500 & 
             nFeature_Spatial > 200 & 
             percent.mt < 30)

等等,这里有个坑。上面这个过滤是全局一刀切,但空间转录组数据经常有批次差异。我遇到过两个批次的样本,UMI数分布差了两倍——一个批次中位数800,另一个中位数2000。这种时候一刀切就不合适了。

我的做法:对每个样本单独做QC,各自看拐点、定阈值。别偷懒,每个样本跑一遍分布图,花不了几分钟。

3.3 过滤:删除低表达基因

基因级别的过滤比单细胞简单。核心原则就一条:在整个数据集中表达量极低、检测率低的基因,删掉。

为什么要删?

  • 减少计算量,你想想看几万个基因里90%都是0,算起来浪费资源
  • 去除噪声,低表达基因容易被技术噪声放大
  • 提高下游分析稳定性,比如归一化、PCA都对稀疏数据敏感

常见过滤策略:

策略 说明
最小表达spot数 基因至少在N个spot中有表达(count > 0)
最小总UMI数 基因在所有spot中的总表达量高于阈值
最大表达比例 删除在太多spot中都表达的基因(往往是housekeeping基因,没什么信息量)

避坑提醒:过滤不要过滤得太狠。我曾经在某个项目中把最小表达spot数设为50,结果把一批稀有细胞类型的marker基因全干掉了,后来重新做才找到问题。根据数据规模来:如果总共就1000个spot,设5-10就够;如果是1万个spot,可以设到50-100。

# 过滤基因:至少在3个spot中有表达
# Scanpy风格
import scanpy as sc

adata.var["n_cells"] = np.sum(adata.X > 0, axis=0).A1
adata = adata[:, adata.var["n_cells"] >= 3]

# 或者用Seurat
# spatial_obj <- spatial_obj[rowSums(spatial_obj@assays$Spatial@counts > 0) >= 3, ]

3.4 归一化:让数据可比

归一化——这步我估计是争议最多的。

常规单细胞转录组里,我们通常用LogNormalize、SCTransform或者scran的pooling方法。空间转录组能不能直接用?能,但要小心。

空间转录组归一化的特殊之处:

  • 每个spot的测序深度差异更大,因为spot覆盖的细胞数不均匀
  • 空间邻居之间有生物学相关性,普通归一化可能放大局部噪声
  • 有些归一化方法(比如SCTransform)会过度拟合某些批次效应

我常用的三种归一化方法对比:

方法 适用场景 优缺点
LogNormalize 快速探索、初步分析 简单、速度快;但无法处理深度差异极大的情况
SCTransform 需要更准确的方差估计时 效果好、保留更多生物变异;但计算量大、会过度校正某些批次
scran pooling 需要稳健的size factor估计 受异常值影响小;但空间数据不一定满足pooling假设

我个人经验:最开始做空间转录组时,我习惯LogNormalize打底,后面如果需要精细分析再换SCTransform。但如果你面对的批次效应特别严重(比如不同医院、不同试剂批次),建议优先用SCTransform,它对技术噪声的建模更鲁棒。

# 常见的归一化代码
# Seurat风格
spatial_obj <- NormalizeData(spatial_obj, 
    normalization.method = "LogNormalize", 
    scale.factor = 10000)

# SCTransform
spatial_obj <- SCTransform(spatial_obj, 
    assay = "Spatial", 
    variable.features.n = 3000)

Tip:归一化之后记得检查一下。我习惯画一个violin plot,看每个样本的归一化后表达量分布是否一致。如果某个样本分布明显左偏或者右偏,说明归一化参数可能不合适,需要调整scale factor或者试试其他方法。

这里我想专门提一下 SCTransform 的坑。它默认会做正则化负二项回归,把测序深度作为协变量回归掉。这个逻辑在单细胞里没问题,但在空间转录组里,测序深度和spot内细胞数正相关,而细胞数又和某些生物学特征(比如细胞密度、组织区域)相关。你一旦回归掉深度,可能连带着把生物学信号也去掉了。

怎么预防?我一般会同时跑一个LogNormalize版本和SCTransform版本,对比关键基因的表达模式。如果发现某个已知的区域marker基因在SCTransform版本里消失了,那可能就是过度校正了。

3.5 本章知识结构

下面这张图总结了本章的数据预处理流程,从原始数据到标准化数据之间的三步走:

数据预处理三步流程 质量控制 检测指标 总UMI数 / 检测基因数 线粒体比例 / 空间位置 按样本单独定阈值 剔除异常spot 基因过滤 筛选低表达基因 最小表达spot数 ≥ 3-10 最小总UMI数 > 0 删除housekeeping基因可选 保留信息量高的基因 归一化 LogNormalize SCTransform scran pooling 选合适的 关键选择:如何决定归一化方法? • 快速验证 / 初步探索 → LogNormalize • 需要精确方差建模 / 批次效应严重 → SCTransform • 数据异常值多 / 样本量小 → scran pooling • 实在拿不准 → LogNormalize + SCTransform 对比跑一遍

你看,这个流程其实挺线性的。但实际项目里我经常要来回调整——比如过滤完之后发现某个基因没了,回头改QC阈值再跑一次。别怕重复,数据预处理就是这样。

3.6 避坑总结

嗯,最后总结几个我踩过的坑:

  • 别用单细胞的QC阈值套空间数据。空间spot的线粒体比例普遍更高、UMI数分布更宽。强行套用会导致大量spot被误删。
  • 过滤基因要留余地。有些基因在数据里表达很低,但可能就是你要找的稀有细胞marker。宁可多留几个让后面的方法去筛选,不要一刀切砍光。
  • 归一化后要验证。我习惯对每个样本画一个归一化后的UMI分布图,如果某个样本明显偏离,说明scale factor或者方法选错了。
  • SCTransform不是万能的。它会回归掉测序深度,而空间数据的深度和细胞数是关联的。深度差异本身可能就是生物学信号。用的时候多留个心眼。

最后一个小建议:把预处理脚本写成可重复执行的pipeline,每次改参数后能一键重跑。我自己用Snakemake管理,每次改QC阈值、过滤条件或者归一化方法都很方便。这样在探索阶段可以快速迭代不同的参数组合。