4. 数据加载与预处理:用 Scanpy/Seurat 搞定空间转录组数据

好,咱们正式开始动手干活了。数据加载和预处理,说白了就是给后续分析打好地基。我见过太多同学一上来就急着看聚类结果,结果因为预处理这步没做好,后面怎么调参数都怪怪的。今天我就把我在多个项目里踩过的坑和总结的经验,全盘托出。

4.1 环境准备:别在工具上浪费时间

我个人习惯用 conda 管理环境,干净、隔离、好复现。你想想看,要是因为包版本冲突把半天时间搭进去,多不值。

我的建议:创建独立环境,别图省事直接在 base 里装。
# 创建 scanpy 环境
conda create -n spa_scanpy python=3.10
conda activate spa_scanpy
pip install scanpy[leiden] anndata matplotlib pandas

# 或者创建 seurat 环境(R)
conda create -n spa_seurat r-base=4.3
conda activate spa_seurat
R -e "install.packages('Seurat')"

嗯,这里要注意:Scanpy 和 Seurat 的环境最好分开。我之前在同一个环境里混着装,结果 scanpy 的 matplotlib 和 seurat 的 ggplot2 互相干扰,排查了半天。

4.2 加载空间转录组数据:从原始格式到 AnnData/SeuratObject

空间转录组数据的格式五花八门。10x Visium 的数据最常见,包含三个核心文件:

  • filtered_feature_bc_matrix.h5:表达矩阵
  • spatial/tissue_positions_list.csv:spot 坐标
  • spatial/scalefactors_json.json:缩放因子

我用 Scanpy 为例,给你演示一下标准加载流程。

import scanpy as sc
import pandas as pd
import numpy as np

# 设置全局参数
sc.settings.verbosity = 2  # 让日志详细点,方便排错
sc.settings.figdir = './figures/'

# 加载单样本 10x Visium 数据
adata = sc.read_visium(
    path='./data/sample1/',
    count_file='filtered_feature_bc_matrix.h5',
    load_images=True
)

# 检查数据基本结构
print(adata)
print(adata.obs.head())
print(adata.obsm['spatial'][:5])  # 坐标信息

为什么会报错?最常见的原因是路径问题。我吃过一次亏:`count_file` 参数默认找 `raw_feature_bc_matrix.h5`,但空间转录组项目通常给的是过滤过的矩阵。所以记得指定文件名。

避坑指南:我曾经因为没检查 `adata.obs.index` 的格式,导致后续合并样本时报错。每次加载完后,先打印 `adata.obs_names[:5]` 看看是不是像 `AAACAAGTATCTCCGG-1` 这种标准格式。

4.3 合并多个样本:不是简单的堆叠

多样本合并,看起来就是把几个 adata 拼接起来。但直接 `concat` 会丢失样本信息。我建议用两种方式:

方法适用场景关键参数
按批次合并(batch-aware)技术批次效应明显batch_key='sample_id'
无批次合并同一实验批次,处理一致index_unique='-'

我个人习惯给每个样本先标记好批次信息,再合并。你看:

# 假设你有 3 个样本
samples = ['sample1', 'sample2', 'sample3']
adatas = []

for i, sample in enumerate(samples):
    adata = sc.read_visium(f'./data/{sample}/')
    adata.obs['sample_id'] = sample
    adata.obs['batch'] = f'batch_{i}'
    adatas.append(adata)

# 合并
adata_merged = sc.concat(adatas, 
                         axis=0,
                         join='outer',  # 保留所有基因
                         label='sample_id',
                         index_unique='-')

# 检查合并结果
print(f"合并后细胞数: {adata_merged.n_obs}")
print(f"合并后基因数: {adata_merged.n_vars}")
print(adata_merged.obs['sample_id'].value_counts())

注意:`join='outer'` 会保留所有样本中出现过的基因。如果你的数据是同一个平台生产的,基本没问题。但如果样本来自不同批次,里面有些低表达基因只在某个样本中出现,可能会带来噪声。

4.4 质量过滤:去掉那些不好看的 spot

合并后,第一件事就是过滤。你想想看,一个 spot 里只有几十个 UMI,或者基因数少得可怜,那很可能是个破损的组织边缘或者空spot。留着它,会让后续的归一化和聚类都跑偏。

我通常分两步走:

  1. 计算 QC 指标:UMI 总数、基因数、线粒体基因比例
  2. 设置过滤阈值:基于统计分布手动或自动设定
# 计算 QC 指标
adata_merged.var['mt'] = adata_merged.var_names.str.startswith('MT-')
sc.pp.calculate_qc_metrics(adata_merged, qc_vars=['mt'])

# 检查分布
sc.pl.violin(adata_merged, 
             ['n_genes_by_counts', 'total_counts', 'pct_counts_mt'],
             groupby='sample_id',
             multi_panel=True)

嗯,看小提琴图的时候,我一般关注两点:一是不同样本间的分布是否一致,二是尾部有没有明显离群点。如果一个样本的中位数远低于其他样本,说明那个样本质量可能有问题。

过滤代码也很直接:

# 手动设置阈值(根据提琴图调整)
sc.pp.filter_cells(adata_merged, min_genes=200)
sc.pp.filter_cells(adata_merged, max_genes=6000)
sc.pp.filter_genes(adata_merged, min_cells=3)

# 过滤高线粒体比例 spot
adata_merged = adata_merged[adata_merged.obs['pct_counts_mt'] < 20].copy()

# 过滤后看看还剩下多少
print(f"过滤后细胞数: {adata_merged.n_obs}")
print(f"过滤后基因数: {adata_merged.n_vars}")
核心思路:过滤不是越严格越好。空间转录组的 spot 本来就比单细胞少,过滤掉 30% 以上就有点心疼了。我通常控制在 10-20% 的细胞损失。

4.5 基因筛选:保留有信息量的基因

过滤完 spot,下一步是挑基因。空间转录组通常有 2-5 万个基因,但大多数是低表达的 housekeeping 基因或者噪音。我们只保留高变基因,后续的降维和聚类才会聚焦于有生物学意义的变化。

Scanpy 里用 seurat_v3 方法效果不错,我实测过:

# 标准化对数化(seurat_v3 要求先做这个)
sc.pp.normalize_total(adata_merged, target_sum=1e4)
sc.pp.log1p(adata_merged)

# 挑选高变基因
sc.pp.highly_variable_genes(
    adata_merged,
    n_top_genes=2000,
    batch_key='sample_id',  # 按批次分别挑选
    flavor='seurat_v3'
)

# 看看选了哪些
print(f"高变基因数: {adata_merged.var['highly_variable'].sum()}")
print(adata_merged.var[adata_merged.var['highly_variable']].head())

为什么用 seurat_v3 而不是默认的 seurat?因为前者考虑了方差与均值的关系,对低表达基因更友好。空间转录组的数据稀疏性比单细胞更高,低表达基因可能也很重要。

我的习惯:挑选高变基因时,指定 batch_key 可以避免某个样本的 batch effect 主导基因选择。如果不指定,一个样本里的高表达基因可能会挤掉其他样本的标记基因。

最后,只保留高变基因用于后续分析:

# 保存原始全部基因,只取高变基因继续
adata_merged.raw = adata_merged
adata_merged = adata_merged[:, adata_merged.var['highly_variable']].copy()

print(f"最终用于分析的数据: {adata_merged.n_obs} spots, {adata_merged.n_vars} genes")

4.6 本章知识结构:数据加载与预处理全景图

我把整个过程画成一张图,你看一眼就能记住关键节点。

数据加载与预处理核心流程 加载原始数据 10x Visium / ST 多样本合并 concat + batch标记 QC指标计算 UMI/基因/MT% 过滤低质量 spot min/max 阈值 + MT% 过滤 高变基因筛选 seurat_v3,2000个 最终数据集 AnnData / SeuratObject,可继续下游分析 注意 过滤参数要根据 数据分布动态调整

你看,整个流程很清晰:从左到右是数据加载和合并,然后向下分流到过滤和筛选,最后汇聚成干净的数据集。每一步都有它的作用,跳一步都不行。

我曾经踩过的坑:有次我忘了在合并后做过滤,直接跑聚类,结果聚类结果全是按批次分群的。后来才发现是一个样本的边界区域有大量空 spot,它们只有几十个 UMI,但基因表达模式很相似,硬是聚成了一类。从那以后,我每次合并后都先做过滤,再做归一化。