单细胞数据h5ad,超大数据分割
上个月处理一份80GB的10x单细胞h5ad文件,我照旧用scanpy.read_h5ad()直接读,结果进程在加载阶段就被kill掉了,连个报错都没来得及留下。检查了日志才发现是OOM。服务器有256GB内存,却被一个h5ad吃干抹净,这听起来离谱,但确实发生了。后来我换成了backed模式读取加按条件分割,几十秒就能拿到目标子集,内存占用不到1GB。
这次实操记录我就围绕“h5ad超大数据分割”这个问题展开,把h5ad的文件结构、为什么直接读会爆内存、如何低内存切分子集、以及一整套可复用的Python代码和踩坑记录都整理出来。适合手里有大几十GB甚至上百GB单细胞数据、内存又不够宽裕的朋友参考,也适合第一次接触h5ad格式、需要从GEO等公共数据库下载单细胞数据来做二次分析的初学者。
1. 项目背景与问题拆解
1.1 h5ad为什么成了单细胞数据的“默认格式”
单细胞转录组数据的核心是一个细胞×基因的表达矩阵。10x平台一次实验动辄几万到几十万个细胞,基因数动辄两三万,如果用常规的CSV或者txt存,稀疏表达矩阵会膨胀得特别夸张。比如一个5万细胞×3万基因的矩阵,全量存成文本可能超过30GB,而且绝大多数位置是0,极其浪费。
h5ad本质上是AnnData对象的磁盘存储格式,底层基于HDF5。它最大的优点有三点:
- 原生支持稀疏矩阵存储,
csr_matrix和csc_matrix可以直接落盘,不膨胀; - 把表达矩阵、细胞注释(
obs)、基因注释(var)、降维结果(obsm)、基因表达层(layers)等整合到一个文件里,数据不会散落得到处都是; - HDF5本身就是为大规模科学数据设计的二进制格式,支持部分读取,不需要把整个文件都载入内存。
很多公共数据库和工具链都在向h5ad靠拢。GEO上越来越多的单细胞数据集直接提供h5ad文件,CellxGene Datasets的下载格式也是h5ad。可以说,这已经是单细胞数据分析和交付的默认容器了。
1.2 什么情况下才需要“分割”
不是所有单细胞数据都需要分割。如果你手里的h5ad只有几个GB,直接读进内存再切片完全没问题。但一旦遇到下面几种场景,分割就成了刚需:
- 文件规模超过内存可承受范围。几十GB到上百GB的h5ad,直接
read_h5ad()会把全部表达矩阵、注释、降维结果加载到内存,16GB或32GB内存的机器基本顶不住,即使服务器内存够大,也会拖慢后续所有操作; - 只关心部分细胞亚群或部分样本。比如一份多组织、多样本合并的大数据集,你只想分析某个组织的某一类细胞,全量载入显然是浪费;
- 需要按样本或条件拆分成多个子集,每个子集单独走pipeline,或者交付给不同的人做下游分析;
- 数据归档和共享。超大的h5ad在拷贝、上传、版本管理上都很痛苦,拆成按样本或按批次的子文件后,管理成本直线下降。
1.3 动手之前先想清楚分割维度
分割不是拿起代码就切,得先明确“按什么切”。我在实际中遇到的需求基本是这几类:
- 按样本/样品来源分组,这是最常见的,
obs里一般有sample或donor_id列; - 按细胞类型分组,比如只保留某个亚群;
- 按基因集过滤基因,降低后续计算量;
- 按染色质片段或批次维度切割,用于多样性分析场景。
分割维度直接决定了后续的代码逻辑,也决定了你分割出来的每个文件是否还需要保留原始元数据。所以第一步一定是先搞清楚obs和var里有哪些可用的分组列。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路与工具选型
2.1 为什么直接read再切片是最坏的做法
很多教程会教你这样写:
python复制import scanpy as sc
adata = sc.read_h5ad('large.h5ad')
subset = adata[adata.obs['sample'] == 'S1'].copy()
这条代码在数据小的时候没有任何问题,但在超大h5ad上就是一个灾难。sc.read_h5ad()默认会将X矩阵、obs、var、obsm、layers全部载入内存,80GB的文件读完可能占用150GB内存,因为HDF5解压后的稀疏矩阵在内存里还要额外开销。更关键的是,你只是想取一个子集,完全没必要把整个文件全部展开。
我后来统计过:一个74GB的h5ad,直接read_h5ad()峰值内存大概在190GB左右,用时近15分钟。而同样的文件用backed模式读取,再提取目标样本子集,内存占用不到1GB,提取耗时5秒左右,保存子集多花一点时间,但总量和直接读完全不在一个量级。
2.2 backed模式:最省内存的读取方案
scanpy从0.7.0版本开始支持backed模式,用法是:
python复制adata = sc.read_h5ad('large.h5ad', backed='r')
这里的backed='r'表示只读模式,AnnData不会把表达矩阵真正加载进内存,而是像一个“指针”一样指向磁盘上的HDF5文件。当你访问adata.X、adata.obs、adata.obsm等内容时,它才按需读取对应部分。
更重要的是,backed模式支持布尔索引和切片,例如:
python复制subset = adata[adata.obs['sample'] == 'S1']
这个过程会从磁盘上读取需要的那部分数据,拼接成一个新的内存中的AnnData对象。因为只读目标行,内存占用自然小。要注意的是,backed='r'模式下生成的切片对象仍然是内存数据,所以子集不大时可以放心处理,但如果子集本身也很大,就需要考虑分块处理,避免子集也撑爆内存。
2.3 四种方案横向对比
我在这类任务中实际试过四种方案,整理了一张表:
| 方案 | 内存占用 | 速度 | 适合场景 | 缺点 |
|---|---|---|---|---|
| 直接read_h5ad后切片 | 极高 | 慢 | 数据量很小 | 大文件直接OOM |
| backed模式读取后切片 | 低 | 快 | 按条件提取子集 | 需要scanpy和h5py支持 |
| h5py底层按块读取 | 极低 | 中等 | 需要精细控制 | 代码量大,需手动管理索引 |
| 转成zarr格式后操作 | 低 | 快 | 需要频繁增量读写 | 额外转换步骤,生态兼容性稍差 |
综合来看,backed模式是90%场景下的最优解。它在代码复杂度、内存占用、速度、生态兼容性之间取得了最好的平衡。h5py底层按块读取适合追求极致控制的人,但工作量明显更大。zarr是一种云友好的格式,支持并行读写,但如果你要交付给合作方,对方未必熟悉zarr,h5ad的普适性更好。
2.4 为什么最终选择“backed + copy + write”
用backed模式读取并切出子集后,我习惯在保存前显式做一次copy():
python复制subset = adata[adata.obs['sample'] == 'S1'].copy()
subset.write_h5ad('S1.h5ad', compression='gzip')
这里有两个关键点:
第一,copy()避免视图共享问题。 backed模式下的切片有时仍然与原对象共享底层数据,如果不复制就直接操作或继续追加内容,会引发一些难以排查的“脏数据”问题。做一次copy()相当于把子集数据完整地存到内存里,后续修改就不会影响原文件。
第二,保存时必须注意压缩参数。 稀疏表达矩阵虽然存储效率高,但如果不压缩,文件依然很大。compression='gzip'配合compression_opts=9能显著压缩文件体积,代价是保存和读取时CPU开销增加。如果后续只是追求读取速度快,也可以用compression='lzf',压缩比稍小但读写更快。这个取舍要根据后续使用频率来定。
3. 实操过程:超大数据分割完整流程
3.1 第一步:在不加载数据的前提下观察结构
面对一个h5ad文件,我的第一个操作永远是“只读元数据”,绝不直接read_h5ad()。用backed模式打开,先看清楚obs里有哪些列可选,var里有哪些基因索引,obsm和layers里有什么东西,再决定分割策略:
python复制import scanpy as sc
# 以backed只读模式打开
adata = sc.read_h5ad('large.h5ad', backed='r')
# 观察整体结构和规模
print(adata.shape) # (n_cells, n_genes),比如 (482251, 36601)
print(adata.obs.columns) # 细胞注释列名
print(adata.obs['sample'].value_counts()) # 看每个样本的细胞数
print(adata.var.head()) # 基因注释
print(adata.obsm.keys()) # 是否有UMAP、PC等降维结果
print(adata.layers.keys()) # 是否有多个表达层
这一步非常重要。很多h5ad是多个来源合并的,obs里可能会有sample、donor_id、tissue、cell_type等多个维度,你需要先确认到底按哪一列来分割。如果上来就写死sample列,结果列名不叫sample,那代码就得返工。
我在处理一份细胞图谱数据时,打开后才发现它没有sample列,只有donor_id和tissue_type,最后只能按tissue_type来切。这一步骤省下来的时间远大于多敲几行代码的时间。
3.2 第二步:检查稀疏矩阵存储格式
h5ad文件里的表达矩阵有csr_matrix和csc_matrix两种常见存储格式,这在分割时会影响效率。按细胞切分时,csr_matrix按行存储,天然适合取行子集;按基因切分时,csc_matrix按列存储,取列更方便。如果你的h5ad里是csr_matrix,而你想按基因切片几百个基因,scanpy也能自动处理,但性能上会有损耗。
可以先查看一下矩阵类型:
python复制print(type(adata.X))
如果是scipy.sparse.csr_matrix,取行子集时速度快;如果是csc_matrix,取行子集时scanpy会自动转换,数据量大时这一步会比较耗时。一般公共数据集的h5ad默认是csr或者csc都有,不用过于纠结,但心里有数能帮你预估后续取子集的耗时。
如果分割后你发现行子集提取很慢,可以考虑先把矩阵转成csr再切,但要注意转换过程中内存会短暂上升。
3.3 第三步:backed模式下按条件切分子集
确认好结构后,核心的分割代码其实很短:
python复制import scanpy as sc
import gc
# 打开大文件
adata = sc.read_h5ad('large.h5ad', backed='r')
# 假设obs中有一个sample列,我们要按照sample分割
sample_list = adata.obs['sample'].unique()
print('样本列表:', sample_list)
for sample in sample_list:
print(f'正在处理 {sample} ...')
# 按条件取子集,这一步只读取匹配的行
subset = adata[adata.obs['sample'] == sample].copy()
print(f'子集形状: {subset.shape}')
# 保存为独立的h5ad
subset.write_h5ad(f'data_{sample}.h5ad', compression='gzip')
# 释放内存
del subset
gc.collect()
这个循环看起来简单,但有几处细节值得展开讲。
细节一:adata[adata.obs['sample'] == sample]内部做了什么。 这一步会产生一个布尔掩码,然后从磁盘上读取所有符合条件的行。表达矩阵在磁盘上是分块压缩存储的,读取时只解压需要的块,所以内存占用远小于全量加载。实测中,一个48万个细胞的h5ad,提取1万个细胞子集,内存增量不到500MB。
细节二:copy()不能省。 我第一次写这段代码时没加copy(),结果后续给subset添加obs列时,警告信息显示“Trying to modify a view of AnnData”,虽然当时没爆错,但后续保存出的文件里出现了莫名其妙的重复索引问题。加了一次copy()后,所有异常消失。backed模式下的切片对象本质上是原对象的视图,如果不对视图做写操作,那可以直接使用;但只要对子集做任何修改,必须copy(),否则会污染原对象状态。
细节三:手写保存路径和命名要规范。 如果样本ID里包含特殊字符,直接拼路径可能会出问题,建议对文件名做一次清洗,或者直接用样本ID做文件名,不要手动拼目录层级。最好用os.path.join来拼路径,避免不同系统下路径分隔符问题。
细节四:保存后立刻del和gc.collect()。 这一步能及时回收内存,尤其在循环处理几十个样本时,如果不做内存清理,即使backed模式省内存,也会因为累积效应导致内存吃紧。Python的垃圾回收不是即时的,手动gc.collect()可以加速释放。
3.4 第四步:验证分割结果完整性
分割保存后,不能直接认为万事大吉。我每次都会随机抽取几个子文件重新读取验证,检查数据是否完整、注释是否对齐:
python复制import scanpy as sc
# 随机验证一个样本
check = sc.read_h5ad('data_S1.h5ad')
print(check.shape)
print(check.obs.head())
print(check.var.head())
# 对比一下原文件中该样本的细胞数
original = sc.read_h5ad('large.h5ad', backed='r')
n_original = (original.obs['sample'] == 'S1').sum()
print('原文件细胞数:', n_original)
print('子文件细胞数:', check.shape[0])
这一步虽然朴素,但能发现大量隐藏问题。有一次我分割后没有显式copy(),结果子文件里细胞数少了接近5%,就是索引错位导致的。通过验证能及时发现问题,不用等分析到下游才发现数据不对。
另外,验证时也要注意obsm和layers是否完整保留。如果原h5ad里已经算好了UMAP坐标、PCA结果,那么分割出的子集里也应该包含对应的obsm['X_umap']等条目,否则下游做可视化时还要重算,耗时耗力。可以在验证时打印一下check.obsm.keys(),确认关键降维结果没有丢失。
3.5 底层方案:h5py直接按块读写
scanpy的backed模式能满足绝大多数需求,但如果你需要更精细的控制,或者不想引入scanpy这个比较重的依赖,可以用h5py直接操作h5ad的内部结构。h5ad底层是HDF5,核心矩阵存储在/X下,一般有data、indices、indptr三个数组,对应csr_matrix的三个属性。
按细胞子集提取的核心思路是:先读取indptr找到目标细胞的行范围,再根据indptr的起始和终止位置确定该细胞对应的非零元素索引范围,然后把对应的data和indices切片出来,重新组装成子集的CSR矩阵。示例代码如下:
python复制import h5py
import numpy as np
from scipy.sparse import csr_matrix
def extract_rows_by_indices(h5_path, row_indices, out_path):
with h5py.File(h5_path, 'r') as f:
data = f['X/data'][:]
indices = f['X/indices'][:]
indptr = f['X/indptr'][:]
# 按行索引取出每个细胞的非零元素区间
row_indices = np.asarray(row_indices)
start = indptr[row_indices]
end = indptr[row_indices + 1]
# 计算所有目标细胞对应的非零元素位置
new_indptr = np.zeros(len(row_indices) + 1, dtype=indptr.dtype)
new_indptr[1:] = np.cumsum(end - start)
selected_data = np.concatenate([data[s:e] for s, e in zip(start, end)])
selected_indices = np.concatenate([indices[s:e] for s, e in zip(start, end)])
sub_csr = csr_matrix((selected_data, selected_indices, new_indptr),
shape=(len(row_indices), f['X'].attrs.get('shape', (0, f['var'].shape[0]))[1]))
# 保存新的h5ad,这里只保存了X矩阵和var/obs
# 实际使用中还需要对应处理obs、var等元数据
return sub_csr
这种方案的优点是内存占用极低,只读取需要的行;缺点是代码复杂,需要手动处理变量名、元数据、不同层的数据,而且如果h5ad里X不是csr格式,这套逻辑就不适用。因此我一般只在写一次性脚本或者嵌入式环境需要极简依赖时才会用,日常更推荐scanpy的backed模式。
3.6 如何用Python读取并处理GEO单细胞数据
热词里有人问“如何用python读取geo单细胞数据”,这里顺带说一下。GEO上下载的单细胞数据通常不是直接给你h5ad,而是提供10x格式的矩阵文件或者原始的count矩阵。常见的有GSE号下的三件套:barcodes.tsv.gz、features.tsv.gz、matrix.mtx.gz,这三个文件就是10x的标准输出格式。
在Python里读取它们最方便的方式就是scanpy:
python复制import scanpy as sc
# 读取10x格式
adata = sc.read_10x_mtx('path/to/filtered_feature_bc_matrix/', var_names='gene_symbols', make_unique=True)
# 添加样本信息
adata.obs['sample'] = 'GSM1234567'
# 保存成h5ad
adata.write_h5ad('geo_sample.h5ad')
如果GEO提供的是表达矩阵文本文件,也可以直接用pandas.read_csv()读进来,再构造成AnnData对象:
python复制import pandas as pd
import scanpy as sc
import anndata as ad
# 读取表达矩阵(行为基因,列为细胞)
expr_df = pd.read_csv('expression_matrix.csv', index_col=0)
adata = ad.AnnData(expr_df.T) # 转置成细胞×基因
无论是哪种方式,关键都是读入数据后把obs、var的注释信息补全,再存成h5ad统一管理。从公共数据库拿到的原始数据通常没有h5ad方便,但经过一次转换后,后续所有分析都能用h5ad生态的工具链。我自己处理GEO数据时,习惯把GSE号、样本ID、处理日期都写在obs里,这样数据源信息永远不会丢。
4. 常见问题与排查技巧实录
4.1 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
read_h5ad()直接OOM被kill |
大文件全量加载到内存 | 改用backed='r'模式 |
| backed模式切片后保存报错 | 切片对象是视图,不能直接写 | 先.copy()再保存 |
| 分割后细胞数不对,出现重复索引 | var_names不唯一或索引未重置 |
检查var列,用make_unique=True重新构建 |
adata.X变成numpy.ndarray导致内存暴涨 |
某些操作会把稀疏矩阵转成稠密 | 检查矩阵类型,必要时csr_matrix(adata.X)转回稀疏 |
| 保存子集后文件很大 | 没有加压缩参数 | 用compression='gzip', compression_opts=9 |
| 展示层数据丢失 | obsm和layers没有完整复制 |
验证check.obsm.keys()和check.layers.keys() |
| backed模式使用时报“invalid index” | 布尔mask与obs索引不对齐 | 确认mask的index和adata.obs.index一致 |
4.2 典型问题展开:稀疏矩阵变稠密
这是我在分割后遇到的第一个坑。某次分割出的子集只有不到2万个细胞,内存却占用了10多GB,我一开始以为是大文件残留,后来排查发现是adata.X的类型从scipy.sparse.csr.csr_matrix变成了numpy.ndarray。
原因是这样的:backed模式下,如果访问adata.X的时候没有显式保持稀疏类型,某些切片操作会把稀疏矩阵转成稠密矩阵。一旦变成稠密矩阵,2万细胞×3万基因就是6亿个元素,每个float占4字节,算下来就是2.4GB,再叠加多个副本,内存直接爆炸。
解决办法是时刻检查矩阵类型,发现变成稠密矩阵后立刻转回稀疏:
python复制from scipy.sparse import issparse, csr_matrix
if not issparse(check.X):
check.X = csr_matrix(check.X)
另外,在构造子集时,也可以用adata[:, genes].to_memory()这类方法,尽量在原始对象阶段维持稀疏格式。
4.3 典型问题展开:var_names重复导致索引错乱
有一次分割后我发现某个子集里两个不同基因的count居然一模一样,仔细查才发现是原h5ad的var_names里有重复基因名,而我在子集保存时用make_unique=False,导致scanpy在保存时自动改名,但某些内部的索引映射已经错位了。
这个问题的根源常常是原始数据来自多个平台合并,基因名匹配时出现了多个同名但不同序列的条目。处理办法是在读取或转换原始数据时设置make_unique=True,或者在分割前先统一去重:
python复制adata.var_names_make_unique()
分割和保存之前做一次var_names_make_unique(),能避免后续大量诡异问题。如果是自己构建h5ad,建议在写入前就使用Ensembl ID或Entrez ID作为var索引,再单独保留一列gene_symbols,这样不会出现重复问题。
4.4 典型问题展开:backed模式下写入限制
backed='r'是只读模式,如果你试图直接对backed对象做赋值操作,比如:
python复制adata.obs['new_col'] = 1
会直接报错。这是设计使然,因为backed模式本身就是防止意外修改大文件。如果你需要修改obs或者var,必须先把backed对象转为内存对象,比如用adata.to_memory(),但这一步同样会把数据全部载入内存。所以在大文件场景下,我通常的思路是:用backed模式完成所有读取和子集提取工作,然后在子集上做修改,绝不会直接修改原大文件。
如果需要向大文件中追加obs列且不想全量载入,可以考虑用h5py直接写HDF5里的obs数据集,但那样做风险较高,一般不推荐。更稳妥的方式是重新生成一个带注释的h5ad,但这样也会产生完整的新文件,磁盘空间和耗时都需要提前评估。
4.5 实战心得:保存压缩参数的取舍
保存分割结果时,compression参数直接影响输出文件大小和后续读取速度。这里给一个经验值:
compression='gzip', compression_opts=9:压缩率最高,文件最小,但保存和读取时CPU消耗最大;compression='gzip', compression_opts=6:压缩率和速度的平衡点,大多数场景推荐;compression='lzf':压缩率低一些,但读写速度快,适合要频繁读取的大文件;compression=None:基本不压缩,文件最大,读取最快。
我自己的习惯是:如果分割后的文件还要反复读取、多次跑pipeline,用compression='lzf';如果只是长期归档和交付,用compression='gzip', compression_opts=9。实测下来,同样是10000细胞的子集,gzip-9比lzf文件小约35%,但读取速度慢约2倍。没有绝对的好坏,看你的场景。
4.6 实战心得:批量分割时的内存管理
如果样本数量多,循环分割时一定要记得del和gc.collect()。不要小看这一步,Python对大型对象的引用计数有时不会立即释放,尤其是AnnData这种内部嵌套了多个数组的对象。我用一个循环分割34个样本,前几个样本一切正常,到第10个样本时内存逐渐升高,一度接近16GB,加上gc.collect()后回落到2GB以内。
另外,在循环内尽量避免保留不必要的中间变量。比如下面的写法:
python复制for sample in sample_list:
mask = adata.obs['sample'] == sample # mask保留在局部作用域
subset = adata[mask].copy()
...
循环结束后mask和subset会释放,但如果中间还有别的对象引用它们,内存就不会回收。使用del是更稳妥的做法。
5. 分割后的扩展应用与性能优化
5.1 分割到一半发现内存不够怎么办
有时候你提取的子集本身就很大,比如一个样本有20万细胞,内存仍然紧张。这种情况不能再用“一次性提取整个子集”的思路,而是要在分块的基础上继续做“二次分割”或者“流式处理”。scanpy本身没有提供原生的分块遍历API,但可以结合backed模式和自定义分块逻辑来实现。
一个简单的思路是按细胞编号分块读取,每块只提取目标样本的对应行,然后拼接保存。比如每次读取5万行,判断哪些细胞属于目标样本,再把这些行追加到新的h5ad。由于h5ad底层是HDF5,可以先创建一个空的AnnData骨架,然后逐块填充。这个方案代码量稍大,但能处理极端场景,比如单个子集就有上百GB。
我在处理一份全脑单细胞数据时,目标子集本身有45GB,就是想切出一个特定脑区的所有细胞,但该子集可能依然很大。最后我是先分割出脑区,再对脑区二次按细胞类型细分,分两步走,才把单次提取的数据量降下来。
5.2 并行分割:什么时候值得用多进程
我看到有人尝试用multiprocessing并行切分同一个h5ad,但实测下来收益并不明显。因为backed模式读取本身就受限于磁盘IO,多进程同时读同一个HDF5文件反而可能造成IO争抢,速度更慢。如果你的服务器有多个NVMe固态盘,可以把不同样本的输出写到不同磁盘,但输入端IO还是那个瓶颈。
真正值得并行的是“多个独立h5ad”的分割任务。比如你有10个样本,每个样本一个h5ad,需要批量提取细胞类型,这时候用multiprocessing.Pool并行处理不同文件是合理的。我建议的并行粒度是“按文件”,而不是“按细胞”。
5.3 分割之后常见下游操作提醒
分割不是终点,分割出的每个子集往往还要继续做标准化、聚类、差异表达等分析。这里提醒一个容易忽略的问题:分割后的子集在做标准化时,是否使用全局信息对子集有影响。
比如sc.pp.normalize_total默认是每个细胞独立做归一化,不依赖全局信息,所以分割后直接做没有问题。但如果你要做批次校正(比如sc.external.pp.harmony_integrate),需要把多个分割后的子集合回一个AnnData再跑。如果数据来自同一个大h5ad,建议保留原始的批次信息在obs里,分割后也不要删掉,这样后面合并回来时还能找得到索引归属。
另外,分割后丢失了部分全局基因集时,后续做marker基因注释时可能找不到某些基因,因此最好在var里额外保留一个all_genes标记,方便回溯。
5.4 格式升级:从h5ad到zarr的适配经验
如果你觉得h5ad的backed模式在分割时还不够灵活,可以尝试把数据转成zarr格式。zarr和HDF5类似,但天然支持分块读写、并行访问、云存储,更适合“多进程同时读不同块”的场景。
转换方式很简单:
python复制adata.write_zarr('large.zarr')
之后可以用:
python复制adata_zarr = sc.read_zarr('large.zarr')
zarr在分割时的优势是读取随机块更快,但压缩后文件大小通常比h5ad略大,而且不是所有人都熟悉zarr生态。我在自己的本地分析中偶尔用zarr,但对外交付仍然坚持h5ad。如果你只是想在分割时不那么揪心内存,backed模式已经足够;如果你准备把数据放到云端,让多台机器并行处理,那zarr是更好的选择。
6. 补充经验:从零构建可分割的h5ad
有些场景下,你手头还没有h5ad,而是从GEO或者其他地方下载的原始矩阵和metadata,想把它们整合成一个规整的h5ad。这个过程中如果提前设计好结构,后续分割会省很多事。
6.1 构建AnnData时注意索引唯一性
我第一次自己构建h5ad时吃了大亏。我从两个不同数据集各读了一部分表达矩阵,直接合并后写成了h5ad,结果var_names里同一个基因出现了两次,obs_names里也有重复的barcode。后续用backed模式分割时,布尔索引经常匹配到多行,导致子集细胞数凭空多出一截。
正确做法是构建前统一索引:
python复制adata.obs_names_make_unique()
adata.var_names_make_unique()
如果是从多个样本构建合并数据集,最好在obs里加一个sample列,在var里加一个gene_id列,这样每个细胞、每个基因都有清晰归属。这个习惯在后续分割时能直接作为分组列使用,省去重新整理的麻烦。
6.2 分块构建大h5ad的思路
如果你从GEO上下载了几十个样本的原始数据,想统一整合成一个大h5ad,不建议一次性把所有矩阵都读进内存再合并。更稳妥的方法是逐样本读取,先构建每个样本的小AnnData,然后只保留表达矩阵的稀疏表示,追加到一个预分配的HDF5文件中。scanpy的concat虽然方便,但合并几百个样本时内存消耗极大。
更优雅的做法是先用一个小样本构建h5ad的骨架,然后在backed模式下逐样本追加。但scanpy本身对backed模式下的追加支持有限,我一般会用h5py直接操作底层,或者绕道用zarr中转。日常学习用途,直接sc.concat到能接受的内存上限就够了;真正工业级的大规模整合,需要走更底层的方案,这里面坑不少,有机会再单独展开。
6.3 构建h5ad时的变量命名规范
变量命名看似小事,但在大数据分割时特别重要。obs里如果有多个分组列,直接用sample、tissue、cell_type这类清晰名称,比group1、group2直观得多。var里除了基因名,建议保留gene_id、gene_type、chromosome等注释,这些注释在分割后的子集分析中会反复用到。
此外,构建好h5ad后,建议在uns里写一点元数据,比如创建时间、数据来源、处理pipeline版本。这些信息不会影响分析,但在数据交付和多人协作时能救命。
7. 最后的实操建议
回到最初那个80GB的h5ad,现在我的标准流程已经固定下来:先用backed='r'打开看结构,确认分割维度,循环提取子集并加压保存,最后随机抽检。整个流程在服务器上跑完,内存峰值不超过2GB,耗时取决于磁盘速度,一般在几分钟到十几分钟不等。
如果你还没处理过超大h5ad,建议先拿一个小文件练手,比如几个GB的数据,跑通整个分割流程,感受一下backed模式和普通模式在内存占用上的差异。等真正遇到几十GB的文件时,你会感激自己提前踩过这些坑。
再分享一个细节:处理超大h5ad时,尽量让输出文件写到和输入文件不同的磁盘,或者至少不同的目录。HDF5在读取和写入同时发生时,如果同一块磁盘IO过载,会导致严重的性能下降,甚至让系统出现卡顿。我有一次把输出写到同一块机械硬盘上,分割一个80GB文件花了近40分钟,后来换到另一块NVMe上,同样的操作只需要6分钟。这个速度差异完全不能忽视。
最后提醒一句:不要觉得“这次数据是几个GB,不用backed模式也没关系”。数据处理习惯是要统一训练的。我见过很多同事在小数据上习惯了直接read,结果换到大文件时还是下意识直接read,然后线上跑批任务炸了。固定一个好的处理习惯,能帮你避免很多线上事故。
