数据预处理:过滤、标准化、归一化

数据预处理这一步,我把它叫做“洗菜切菜”。说白了,原始数据拿到手,不能直接下锅炒。你得先把烂叶子摘掉,把泥洗干净,再切得大小均匀。Giotto 处理空间转录组数据也一样——过滤、标准化、归一化这三个动作,决定了你后面所有分析的质量。

我个人习惯,拿到表达矩阵和空间位置信息后,第一件事不是跑算法,而是先看一眼数据长什么样。你想想看,如果几千个基因在大部分 spot 里都是零,这种数据不做处理直接去跑聚类,结果很可能会跑偏。

第一步:质量控制与过滤

过滤的目标很明确——把质量差的细胞/spot 和表达不可靠的基因剔除掉。我曾经在一个项目中,拿着别人处理好的数据直接复现,结果聚类图谱一塌糊涂。后来回过头去查原始 QC,发现不少 spot 的 UMI 计数只有几十,明显是空 spot 或死细胞。白白浪费了两天时间。

Giotto 里常用的过滤参数,我列了个表,你可以对照着看:

过滤类型 常用参数 说明 我的建议阈值
基因过滤 min_genes, max_genes 剔除检测基因数过少或过多的 spot min_genes=200, max_genes=5000
UMI 过滤 min_umis, max_umis 剔除总 UMI 计数异常的 spot min_umis=500, max_umis=20000
线粒体比例 mito_prefix, mito_threshold 剔除线粒体基因占比高的死细胞 mito_prefix="MT-", mito_threshold=0.2
基因表达率 min_cells 剔除只在极少数 spot 中表达的基因 min_cells=3
注意:过滤阈值不是死数字。不同平台(10X Visium vs. Slide-seq)的数据质量差异很大。我建议你先做一轮探索性过滤,观察数据分布,再调参数。上来就套用别人的阈值,容易把有用的信号也剔掉。

在 Giotto 里,过滤操作其实就几行代码:

# 创建 Giotto 对象
gobject <- createGiottoObject(
    expression = raw_expr_matrix,
    spatial_locs = spatial_locs_df
)

# 过滤 spot:剔除低质量细胞
gobject <- filterGiotto(
    gobject,
    min_genes = 200,
    min_umis = 500,
    max_umis = 20000,
    mito_threshold = 0.2
)

# 过滤基因:剔除表达量太稀疏的基因
gobject <- filterGiotto(
    gobject,
    min_cells = 3
)

嗯,这里要注意filterGiotto默认是按顺序执行的。你先过滤了 spot,再过滤基因,这样保留下来的基因集合才可靠。反过来先过滤基因再过滤 spot,逻辑上也没问题,但计算效率会差一些。

第二步:标准化——消除技术偏差

标准化解决的是“不同 spot 之间测序深度不一致”的问题。说白了,一个 spot 测到 1000 个 UMI,另一个 spot 测到 10000 个 UMI,不代表后者基因表达量真的高了 10 倍。可能只是那个 spot 捕获效率更高。标准化就是把这些技术差异抹平。

Giotto 支持多种标准化方法。我做过对比测试,不同方法对下游结果影响不小,尤其是在鉴定稀有细胞类型时。你选的方法不对,可能直接就把那 1% 的细胞群给淹没了。

方法 原理 适用场景 Giotto 参数名
library_size 除以总 UMI,乘以尺度因子 通用场景,计算快 method = "library_size"
log2 library_size 后取 log2 适合下游使用正态分布模型 method = "log2"
pearson 基于 Pearson 残差标准化 高 dropout 数据,保留低表达基因 method = "pearson"
rank 将表达量转为秩次 非参数检验、鲁棒性要求高时 method = "rank"
我的经验:如果数据质量好、测序深度均匀,用 log2 就够。如果数据比较“脏”——比如有 batch effect 或者 dropout 率很高——我会优先试 pearson。它能在保留真实生物信号的同时,更好地抑制技术噪声。

标准化代码也很直接:

# 方法一:library_size + log2
gobject <- normalizeGiotto(
    gobject,
    method = "log2",
    scale_factor = 10000
)

# 方法二:Pearson 残差标准化
gobject <- normalizeGiotto(
    gobject,
    method = "pearson"
)

我记得早期做 scRNA-seq 时,大家几乎清一色用 log2。后来空间转录组兴起,dropout 率比单细胞还高,pearson 标准化才逐渐被大家接受。你想想看,一个 spot 里可能只有 50% 的基因被检测到,剩下的全是零。这种情况用 log2 做标准化,那些未被检测到的基因并不是“真的不表达”——只是没被捕获到而已。

第三步:归一化——让数据落在同一量纲

标准化之后,数据量纲还是基因级别的(每个基因有自己的表达范围)。归一化是把所有基因拉到同一个尺度上,比如均值为0、方差为1。这不影响基因之间的相对关系,但能让你在用 PCA、UMAP 这些降维方法时,不被高表达基因“带偏”。

Giotto 的归一化默认就是 Z-score(标准分数)。你基本不需要自己调参数,不过我还是习惯看一眼归一化后的分布直方图,确保没有异常值。

# 归一化(默认 Z-score)
gobject <- adjustGiotto(
    gobject,
    method = "zscore"
)

# 你也可以手动控制哪些维度做归一化
gobject <- adjustGiotto(
    gobject,
    method = "zscore",
    vars_to_regress = c("total_umis", "percent_mito")
)

归一化的另一个作用,是帮你把不想看到的技术变量“回归掉”。比如总 UMI 数和线粒体比例,它们虽然已经在过滤时被控制,但残留的影响仍然存在。通过 vars_to_regress 参数,你可以把它们作为一个协变量移除。我经常在项目里做这一步,尤其是当组织样本里有坏死区域时——坏死区域的线粒体比例会偏高,不回归掉的话,后续聚类可能会把坏死和正常组织区分成两个独立的细胞群。

避坑指南:我曾经在一个项目中,看着聚类结果怎么都不对——某个区域明明是同一种细胞类型,硬是被分成了两群。查了半天,原来是标准化用的 scale_factor 设成了 1000 而不是默认的 10000,导致高表达基因的权重被放大。后来改成 10000 重新跑,问题就解决了。所以,参数一定要做 double check。

现在,我们用一张图来梳理整个预处理流程:

数据预处理流程 原始表达矩阵 + 空间位置 过滤(QC) 剔除:低UMI、异常基因数 高线粒体比例、稀表达基因 标准化 方法:log2、pearson、rank 消除测序深度差异 归一化(Z-score) 均值为0,方差为1 可回归技术协变量 预处理后表达矩阵

预处理做完,你的 Giotto 对象里会新增两个矩阵:一个叫normalized_expr(标准化后的表达量),一个叫scaled_expr(归一化后的表达量)。这两个矩阵是后续降维、聚类、可视化操作的基础。

最后再啰嗦一句:我建议你把过滤前后的数据统计量打印出来,保存成日志。这一步不花时间,但在你回头检查结果或者写论文时,能省去很多重复劳动。

# 查看预处理前后的数据量
print(dim(raw_expr_matrix))
print(dim(gobject@expression$raw$expr))

# 打印过滤统计
print(gobject@filter_stats)