4. 空间转录组数据格式:h5ad、STARmap、10x Visium 解析、读取与基础操作(使用 scanpy)

好,咱们开始干正事。上一章聊了聊空间转录组的基本概念,你可能已经跃跃欲试想上手操作了。但在此之前,有件事必须搞清楚——数据格式。

在我刚入行那几年,最头疼的就是数据格式不统一。各家平台各玩各的,h5ad、HDF5、JSON、CSV、TIFF……每次换个平台做分析,光读数据就得花半天。后来社区逐渐收敛,h5ad 成了事实标准,但 STARmap 和 10x Visium 的数据组织方式还是大不相同。这章我就带着你把这些格式的门道理清楚,咱们动手读一读,心里才踏实。

4.1 格式概览:它们到底存了什么?

说白了,空间转录组数据就是“一张地图 + 每个坐标上的基因表达”。不同平台的区别在于:地图是怎么画的,表达矩阵怎么放的。

我个人习惯先看数据结构的抽象层。下面这张图可以帮你快速建立认知框架:

空间转录组数据格式核心结构图 空间转录组数据格式核心结构 h5ad (AnnData) 通用容器格式 X: 表达矩阵 obs: 细胞/spot 元数据 var: 基因元数据 obsm: 空间坐标 STARmap 多文件结构 细胞 x 基因 矩阵文件 元数据 CSV 空间坐标 CSV 图像栈 (可选) 10x Visium 官方输出格式 filtered_feature_bc_matrix.h5 spatial/tissue_positions_list.csv spatial/scalefactors_json.json spatial/tissue_image.tiff scanpy 读取 -> 统一 AnnData 对象 搞定读取,后续分析代码完全一致

你看,虽然源头格式五花八门,但最终都会被 scanpy 处理成统一的 AnnData 对象。这正是 scanpy 的价值所在——它帮你屏蔽了底层格式差异。

4.2 h5ad 格式:scanpy 的母语

h5ad 是 scanpy 的原生格式,基于 HDF5 构建。你可以把它理解成一个超级容器:基因表达矩阵、细胞元数据、基因元数据、空间坐标、甚至图像,全都能塞进去。

我最推荐的做法是:不管数据从哪来,最终都存成 h5ad。这样下次再用就不用折腾了。

4.2.1 读取 h5ad

读取 h5ad 文件,一行代码就够了:

import scanpy as sc

adata = sc.read_h5ad('my_data.h5ad')
print(adata)

输出通常是这样的:

AnnData object with n_obs × n_vars = 5000 × 20000
    obs: 'cell_type', 'batch', 'n_genes_by_counts'
    var: 'gene_symbols', 'highly_variable'
    uns: 'spatial'
    obsm: 'spatial'

看到 obsm: 'spatial' 了吧?这就是空间坐标的存储位置。在 AnnData 里,obsm 专门放“观测 x 特征”的辅助矩阵,空间坐标就放在这里。

4.2.2 探究内部结构

你拿到一个 h5ad 文件,第一件事是什么?我习惯先看看里面到底有啥:

# 看看 obs 里有哪些列
print(adata.obs.columns.tolist())

# 看看 var 里有哪些列
print(adata.var.columns.tolist())

# 看看 obsm 里有哪些键
print(list(adata.obsm.keys()))

# 看看 uns 里有什么
print(list(adata.uns.keys()))

核心要点:空间坐标一定在 adata.obsm['spatial'] 里,是一个 N×2 的 numpy 数组,N 等于细胞/spot 个数。

4.2.3 构建自己的 h5ad

如果你有一堆数据想整理成 h5ad,也很简单:

import numpy as np
import pandas as pd

# 假设你准备好了这些
X = np.random.rand(100, 5000)  # 100个细胞,5000个基因
cell_metadata = pd.DataFrame({
    'cell_id': range(100),
    'region': ['cortex'] * 100
})
gene_metadata = pd.DataFrame({
    'gene_name': [f'Gene_{i}' for i in range(5000)]
})
coordinates = np.random.rand(100, 2)  # 空间坐标

# 构建 AnnData
from anndata import AnnData
adata = AnnData(X=X, obs=cell_metadata, var=gene_metadata)
adata.obsm['spatial'] = coordinates

# 保存
adata.write('my_spatial_data.h5ad')

嗯,这里要注意:obs 的索引要跟 X 的行一一对应;var 的索引要跟 X 的列一一对应。

4.3 STARmap 格式解析

STARmap 的数据格式有点特殊。它不像 10x 那样把所有数据打包成几个标准文件,而是以多个文件形式存在。我在项目中遇到过好几次,搞混文件名就全废了。

4.3.1 典型文件结构

一个标准的 STARmap 数据集通常包含:

文件 内容
cell_barcode_count.txt 或 *.csv 细胞 x 基因 表达矩阵
cell_metadata.csv 细胞 ID、类型、区域等元信息
cell_position.csv 空间坐标信息(x, y, z)
images/ 文件夹 原始图像栈(可选)

4.3.2 读取流程

我的标准做法是三步走:先读矩阵,再读元数据,最后整合到一起:

import pandas as pd
import scanpy as sc

# 1. 读表达矩阵
counts_df = pd.read_csv('cell_barcode_count.csv', index_col=0)
# 确保是细胞名在行、基因名在列

# 2. 读空间坐标
pos_df = pd.read_csv('cell_position.csv', index_col=0)
# 通常有 'x', 'y' 两列

# 3. 读元数据
meta_df = pd.read_csv('cell_metadata.csv', index_col=0)

# 4. 整合成 AnnData
adata = sc.AnnData(counts_df)
adata.obs = meta_df
adata.obsm['spatial'] = pos_df[['x', 'y']].values

print(adata)

坑:STARmap 的细胞名在各文件里可能不一致。我曾经遇到过 cell_barcode_count.txt 里用的名字是 Cell_1,而 cell_position.csv 里用的是 cell_001。读进来之前一定要先检查索引是否对齐。

4.3.3 对齐检查技巧

我一般会加一个验证:

# 检查索引是否一致
print("矩阵细胞数量:", counts_df.shape[0])
print("坐标细胞数量:", pos_df.shape[0])
print("元数据细胞数量:", meta_df.shape[0])

# 检查索引是否完全相同
print("矩阵 vs 坐标:", counts_df.index.equals(pos_df.index))
print("矩阵 vs 元数据:", counts_df.index.equals(meta_df.index))

# 如果不一致,需要按交集对齐
common_cells = counts_df.index.intersection(pos_df.index)
common_cells = common_cells.intersection(meta_df.index)

counts_df = counts_df.loc[common_cells]
pos_df = pos_df.loc[common_cells]
meta_df = meta_df.loc[common_cells]

4.4 10x Visium 格式解析

10x Visium 是目前最常用的空间转录组平台,它的数据格式相对标准化。你下载完数据后,通常看到一个文件夹结构:

spaceranger_output/
├── filtered_feature_bc_matrix.h5
├── filtered_feature_bc_matrix/
│   ├── barcodes.tsv.gz
│   ├── features.tsv.gz
│   └── matrix.mtx.gz
└── spatial/
    ├── tissue_positions_list.csv
    ├── scalefactors_json.json
    ├── tissue_hires_image.png
    └── tissue_lowres_image.png

这里有两个关键文件:filtered_feature_bc_matrix.h5(HDF5 格式的表达矩阵)和 spatial/tissue_positions_list.csv(spot 坐标)。

4.4.1 标准读取方法

scanpy 自带的 read_visium 函数就是为这个准备的:

adata = sc.read_visium(
    'spaceranger_output/',   # 10x 输出目录的路径
    count_file='filtered_feature_bc_matrix.h5',  # 可以用 h5 或 mtx
    library_id='sample_001',  # 给样本起个名字
    load_images=True          # 是否加载组织图像
)

print(adata)

输出跟我之前展示的类似,但多了几个特殊字段:

AnnData object with n_obs × n_vars = 4000 × 25000
    obs: 'in_tissue', 'array_row', 'array_col'
    var: 'gene_symbols', 'feature_types'
    uns: 'spatial'
    obsm: 'spatial', 'spatial_original'

4.4.2 手工读取(不依赖 read_visium)

如果你装 scanpy 版本比较旧,或者想深入理解数据组织,也可以手动读:

import anndata
import pandas as pd
import scipy.io

# 读 barcodes
barcodes = pd.read_csv(
    'filtered_feature_bc_matrix/barcodes.tsv.gz',
    header=None, sep='\t'
).iloc[:, 0].tolist()

# 读 features
features = pd.read_csv(
    'filtered_feature_bc_matrix/features.tsv.gz',
    header=None, sep='\t'
).iloc[:, 1].tolist()  # 第二列是基因名

# 读矩阵
mtx = scipy.io.mmread(
    'filtered_feature_bc_matrix/matrix.mtx.gz'
).tocsr()

# 读空间坐标
positions = pd.read_csv(
    'spatial/tissue_positions_list.csv',
    header=None,
    names=['barcode', 'in_tissue', 'array_row', 'array_col', 'pxl_col_in_fullres', 'pxl_row_in_fullres']
)

# 构建 AnnData
adata = anndata.AnnData(X=mtx.T, obs=pd.DataFrame(index=barcodes), var=pd.DataFrame(index=features))

# 对齐坐标
pos_idx = positions.set_index('barcode')
adata.obs['in_tissue'] = pos_idx.loc[barcodes, 'in_tissue'].values
adata.obsm['spatial'] = pos_idx.loc[barcodes, ['pxl_col_in_fullres', 'pxl_row_in_fullres']].values

print(adata)

手动走一遍,你就能彻底理解 scanpy 的 read_visium 在背后做了什么。

4.5 用 scanpy 做点基础操作

数据读进来了,总得干点正事。我挑三个最基础、最常用的操作演示给你看。

4.5.1 查看与切片

拿到 AnnData 对象后,你首先要学会怎么看它、怎么切它:

# 查看基本统计
print(adata.obs['n_genes_by_counts'].describe())

# 选择 in_tissue 的 spot
adata_tissue = adata[adata.obs['in_tissue'] == 1].copy()

# 选择某些基因
adata_subset = adata[:, ['GeneA', 'GeneB', 'GeneC']]

# 查看某个基因的表达分布
print(adata[:, 'GeneA'].X.toarray().flatten()[:10])

切完之后记得 .copy()。AnnData 默认是引用,改切片可能会改原数据。这个坑我踩过不止一次。

4.5.2 过滤低质量 spot/细胞

扫一眼数据质量,过滤掉那些表现太差的:

sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=3)

# 手动过滤:去除高线粒体比例
adata.var['mt'] = adata.var_names.str.startswith('MT-')
sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, log1p=False)
adata = adata[adata.obs['pct_counts_mt'] < 20, :].copy()

记住过滤顺序:先过滤细胞(去掉垃圾 spot)再过滤基因(去掉不表达的基因)。反过来做会多保留很多噪声。

4.5.3 归一化与对数变换

归一化是所有定量分析的前提:

# 总 UMIs 归一化
sc.pp.normalize_total(adata, target_sum=1e4)

# 对数变换
sc.pp.log1p(adata)

# 高变基因选取(加速后续分析)
sc.pp.highly_variable_genes(adata, n_top_genes=2000)
adata = adata[:, adata.var['highly_variable']].copy()

我个人习惯把归一化和对数变换连起来做。很多新手会忘记对数化,结果 PCA 一跑全是一个方向——表达量差异太大。

4.6 三种格式的快速对比

我把核心区别整理成了一张表,方便你对照:

维度 h5ad STARmap 10x Visium
格式类型 单文件(HDF5) 多文件(CSV/txt) 多文件(HDF5 + 图像 + CSV)
读取方式 sc.read_h5ad() 手动组装 sc.read_visium()
空间坐标位置 obsm['spatial'] 单独 CSV 文件 spatial/tissue_positions_list.csv
图像嵌入 可选(h5ad 内) 单独图像文件夹 默认嵌入 uns 中
社区主推 是(scanpy 原生) 否 部分(有官方支持)
推荐做法 最终统一转 h5ad 读进来后存 h5ad 用 read_visium 直接读
数据读取流程小结 10x Visium 输出目录 sc.read_visium() AnnData (h5ad) STARmap 多文件 手动组装成 AnnData AnnData (h5ad)