4. 数据读取与质量控制(QC)参数设置

做 Visium 分析,第一步就是把手里的原始数据喂给模型。这一步看着简单,但我见过太多人在这个环节翻车。数据没对齐、基因名格式不对、空点没过滤干净…这些问题到了聚类阶段全都会放大,最后结果根本没法看。

质量控制,说白了就是给数据“体检”。把那些明显有问题的点、噪音基因、背景污染剔除掉,咱们只保留高质量数据进行后续分析。这一步的参数设置,直接决定了聚类结果的下限。

4.1 核心数据读取函数与参数

我个人习惯用 sc.read_visium() 或 squidpy.read.visium() 来加载数据。这两个函数底层逻辑一样,都是解析 Space Ranger 输出的标准文件夹结构。

看一下典型用法:

import scanpy as sc
import squidpy as sq

# 读取原始数据,count_file 指定矩阵文件
adata = sc.read_visium(
    path="./data/151673/",         # Space Ranger 输出文件夹
    count_file="filtered_feature_bc_matrix.h5",
    load_images=True                # 同时加载组织图像信息
)

# 或者用 Squidpy 版本
adata = sq.read.visium(
    path="./data/151673/",
    library_id="V10B01-084_A1",    # 显式指定文库ID
    source_image_path="./data/151673/spatial/tissue_hires_image.png"
)

这里有几个关键点要注意:

  • count_file 选项:Space Ranger 默认输出两个 h5 文件。filtered_feature_bc_matrix.h5 是已经过滤了空液滴的版本。raw_feature_bc_matrix.h5 包含所有 barcode。我的建议:直接用 filtered 版本就行,少折腾。
  • library_id 参数:如果你一个项目里有多张切片,一定要给每张切片指定唯一的 ID。不然后续拼接或对比分析时,数据会串。
  • load_images 参数:设为 True 才能拿到组织图像坐标。这个信息对于空间特征提取很关键。
小技巧: 读取完数据后,顺手打印一下 adata 的概要信息,确认行数和列数是否符合预期。

4.2 数据文件的检验与处理

数据读进来以后,别急着往下跑。先做几项基本检验。我自己踩过坑:有一回跑聚类,结果死活不对,折腾了半天才发现是数据里混入了几千个空的 spot 坐标。

# 检验数据结构
print(adata)
print(adata.obs.head())   # 查看细胞/spot 信息
print(adata.var.head())   # 查看基因信息
print(adata.obsm.keys())  # 查看空间坐标等多元数据

常见的数据格式问题和解决办法:

问题类型表现解决办法
基因名格式不标准基因名为 ENSEMBL ID 而非符号用 sc.queries.biomart_annotations() 转换
存在重复基因名聚类特征高维冗余用 adata.var_names_make_unique() 去重
空 spot 未过滤干净大量零计数 barcode检查 adata.obs['total_counts'] 分布,手动过滤

4.3 质控参数设置详解

质量控制的核心思路就一条:识别并移除那些明显异常的数据点。我说“异常”,一般是指以下三种情况:

  • 总基因数(UMI)过少的 spot —— 可能是空液滴或组织边缘。
  • 总基因数过多的 spot —— 可能是两个 spot 融合,或区域组织破裂。
  • 线粒体基因占比过高的 spot —— 细胞状态很差,可能已经死亡。

4.3.1 基因数(n_genes_by_counts)过滤

这个参数是最基础的。空间转录组的每个 spot 里理论上只有几个细胞。如果一个 spot 检测到的基因数特别少,它很可能就是空 spot 或背景区域。

# 计算每个 spot 的基因数
sc.pp.calculate_qc_metrics(adata, percent_top=None, log1p=False, inplace=True)

# 查看基因数分布
import matplotlib.pyplot as plt
plt.figure()
sc.pl.violin(adata, 'n_genes_by_counts', groupby='library_id')
plt.show()

# 设置过滤阈值,保留基因数 > 200 且 < 5000 的 spot
adata = adata[adata.obs['n_genes_by_counts'] < 5000, :].copy()
adata = adata[adata.obs['n_genes_by_counts'] > 200, :].copy()

阈值怎么定?没有标准答案。我的经验是:先画个小提琴图看看分布,下界选在“明显掉头”的位置,上界去掉最右边 2% 的离群值。不同组织差异很大,肿瘤样本的 spot 基因数普遍偏高。

4.3.2 总UMI数(total_counts)与线粒体比例

基因数考虑的是“有多少种基因被检测到了”,总 UMI 数考虑的是“总共检测到了多少条转录本”。两者侧重点不一样。线粒体比例则是“红线指标”,一旦偏高,这个 spot 的数据基本不可信。

# 计算线粒体基因比例
adata.var['mt'] = adata.var_names.str.startswith('MT-')  # 人源
# 或 adata.var['mt'] = adata.var_names.str.startswith('mt-')  # 鼠源
sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, log1p=False, inplace=True)

# 过滤:总 UMI 数 500~30000,线粒体比例 < 20%
adata = adata[adata.obs['total_counts'] > 500, :].copy()
adata = adata[adata.obs['total_counts'] < 30000, :].copy()
adata = adata[adata.obs['pct_counts_mt'] < 20, :].copy()

线粒体比例的上限,我一般设在 20% 到 25%。如果组织样本不太新鲜,比例可能会偏高,可以放宽到 30%。但超过 30% 的 spot 我建议直接丢弃,留着只会拉低聚类信噪比。

注意: 线粒体基因的符号在不同物种里不同。人的是 MT-,小鼠是 mt-。别搞混了,不然根本过滤不掉。

4.3.3 细胞周期评分(可选)

有些场景下,我会额外加一个细胞周期评分的质控步骤。这步不是必须的,但对于研究增殖活跃的组织(比如肿瘤或发育组织),它能帮你识别出“假阳性高表达”的 spot。

# 使用 Scanpy 内置的细胞周期基因集(人源)
cell_cycle_genes = [gene for gene in adata.var_names if 'S' in gene or 'G2M' in gene]
sc.tl.score_genes_cell_cycle(adata, s_genes=s_genes, g2m_genes=g2m_genes)

# 查看细胞周期评分的分布,异常高值可能提示分裂中的细胞团
sc.pl.violin(adata, ['S_score', 'G2M_score'])

4.4 完整的质量控制流程

把上面的步骤串起来,就是一个完整的 QC 流水线。我建议把流程写成一个函数,方便复用在多张切片上。

def run_visium_qc(adata,
                  min_genes=200,
                  max_genes=5000,
                  min_umis=500,
                  max_umis=30000,
                  max_mito=0.20,
                  species='human'):

    # Step 1: 基础统计
    if species == 'human':
        adata.var['mt'] = adata.var_names.str.startswith('MT-')
    else:
        adata.var['mt'] = adata.var_names.str.startswith('mt-')
    sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, log1p=False, inplace=True)

    # Step 2: 按阈值过滤
    mask = (adata.obs['n_genes_by_counts'] > min_genes) & \
           (adata.obs['n_genes_by_counts'] < max_genes) & \
           (adata.obs['total_counts'] > min_umis) & \
           (adata.obs['total_counts'] < max_umis) & \
           (adata.obs['pct_counts_mt'] < max_mito * 100)

    adata_filtered = adata[mask].copy()

    # Step 3: 标准化(为了下游分析)
    sc.pp.normalize_total(adata_filtered, target_sum=1e4)
    sc.pp.log1p(adata_filtered)

    print(f"QC 保留 spots: {adata_filtered.shape[0]} / {adata.shape[0]}")
    return adata_filtered

调用起来很简单:

adata_qc = run_visium_qc(adata, min_genes=100, max_genes=4000, max_mito=0.15)
核心思路: QC 参数宁可“松一点”,也不“收太狠”。保留多一些 spot,聚类时可以通过算法自动识别噪音。但如果一开始过滤得太激进,可能把组织边界那些有生物学意义的区域全删掉了。

4.5 我应该如何调整这些参数?

这可能是大家最关心的问题。我总结了几种典型场景下的参考配置,但记住:每个数据集都不一样,一定要可视化确认后再批量跑。

数据类型min_genesmax_genesmax_mito说明
高质量新鲜组织20060000.20最常用,平衡性最好
FFPE 组织10030000.30RNA降解严重,适当放宽线粒体
肿瘤样本300100000.15高异质性+高表达,收紧上界再降低线粒体
发育组织(胚胎)15050000.25细胞类型复杂,基因数差异大

另外,我强烈建议你画一张 QC 指标的联合分布图。它可以直观显示过滤前后数据的变化:

sc.pl.scatter(adata, x='total_counts', y='n_genes_by_counts', color='pct_counts_mt')

从图上你一眼就能看出异常点在哪。那些“矮胖”的点往往组织质量差,早点去掉免得干扰聚类。

4.6 避坑指南:我经历过的三个 QC 惨案

嗯,既然聊到参数设置,我就说说我犯过的错。希望你能绕开这些坑。

  • 惨案一:忽略组织图像对齐 — 有一次我直接跑默认读取,结果数据矩阵和图像坐标错位了。聚类结果一路飘红,最后才发现是初始化路劲里 library_id 没对应上。解决方案:读取后一定要验证 adata.obsm['spatial'] 的坐标是否和 H&E 图像对齐。
  • 惨案二:线粒体阈值设太死 — 在 FFPE 样本上用 15% 的线粒体上限,结果删掉了 70% 的 spot。你想想看,一个切片只剩 200 多个 spot,后面还怎么聚类?所以对不同样本类型,阈值一定要灵活。
  • 惨案三:盲目套用单细胞 RNA-seq 的 QC 标准 — 空间转录组和单细胞不一样,每个 spot 有多个细胞混合,基因数的分布范围宽得多。不要用 scRNA-seq 那套严格的 200~2500 基因数限制,否则大多数 spot 都会被过滤掉。

4.7 小结:质控参数的平衡艺术

质量控制不是越严格越好,也不是越宽松越好。关键是在保持数据量的同时,尽可能剔除噪音。我每次做一个新数据集,都会先跑 3~4 组不同的 QC 参数做对比,再决定最终方案。

你可以把今天学到的流程固化下来,做成一个可复用的脚本。未来每拿到一张新切片,直接跑一遍,看看需要微调哪些参数。相信我,这会节省你大量调试时间。

推荐实践: 保存 QC 前后的数据版本,方便回溯。我习惯在文件名上加 _raw 和 _qc 后缀。
Visium 数据读取与QC参数设置流程 数据读取 sc.read_visium() 基础统计 calculate_qc_metrics 基因数过滤 n_genes_by_counts UMI+线粒体过滤 total_counts / pct_mito 细胞周期评分 可选步骤 QC完成数据 每个步骤都需可视化确认 阈值因组织类型而异