单细胞数据互转:Scanpy的h5ad转Seurat对象四种实践方案
2026/9/19 8:15:17 网站建设 项目流程

做单细胞分析的人,几乎都经历过这样一个场景:上游用Scanpy做完了完整的预处理、聚类和marker筛选,结果合作方或者审稿人一句“能不能用Seurat帮我们复核一下”,你就得把h5ad里那套结果搬到一个R环境里。反过来,拿到一个Seurat RDS想塞进Python的深度学习管线,也绕不开格式问题。h5ad和Seurat对象之间的互转,看着只是文件格式变化,实际牵扯到Anndata和Seurat两套完全不同的数据模型,里面对齐矩阵、恢复降维坐标、保留元信息,每一步都有坑。

我这次就把自己实际用过的四种“从Scanpy的h5ad到Seurat对象”的转换方案完整整理出来,包括代码、原理、报错处理和选型建议。分别覆盖了Bioconductor标准路线、跨语言一行命令、手动拆解重建、以及老牌Convert方案。如果你是刚接触单细胞数据互转的新手,或者已经被各种“转换后降维坐标没了”“counts全是0”折磨过,这篇文章可以直接照抄。

1. 为什么h5ad转Seurat是个“高频刚需”

1.1 单细胞分析的两大生态,逻辑完全不同

单细胞RNA测序数据分析目前基本被两大生态主导:Python生态的Scanpy,以及R生态的Seurat。Scanpy的数据结构是Anndata,以.h5ad文件落盘,核心是adata.X表达矩阵,加上obs细胞注释、var基因注释、obsm降维结果、varm基因载荷、layers多层表达矩阵、uns任意非结构化信息。Seurat则是R的S4对象,落盘一般是.rds.h5seurat,核心是assay对象,里面又细分为counts(原始计数)和data(标准化或log1p后的表达值),旁边还挂了meta.data细胞元数据、reductions降维对象、graphs细胞图结构、commands操作历史。

这个差异直接决定了互转不会像“另存为”那么简单。Anndata可以把counts和normalized数据分别放在X和layers里,Seurat却强制区分counts/data两个槽位;Anndata的降维结果是一堆矩阵存在obsm里,Seurat的降维对象则是一个正式的S4对象,带有key和assay关联;更别说Anndata里的uns字段,什么UMAP参数、marker基因表、聚类颜色全塞在里面,Seurat并没有一个平行的槽位去容纳所有这些杂散结构。

所以每次做h5ad到Seurat转换,本质上不是格式翻译,而是一个数据模型到另一个数据模型的有损迁移。你的目标是“在关键信息不丢、下游分析能接得上的前提下,把表达矩阵和核心元数据搬过去”。能无损保留多少,取决于你选了哪条转换路径,以及转换前对文件内部结构了解多少。

1.2 转换过程到底容易丢什么

我见过的转换翻车现场,问题几乎都出在下面几个地方。

第一个是最容易踩的:表达矩阵的“身份”搞混。Scanpy的adata.X未必是counts,很多流程跑完scale之后X里存的是z-score归一化的数据,原始counts放在adata.rawadata.layers["counts"]里。Seurat的CreateSeuratObject默认把第一个矩阵当作counts,如果直接把X导进去,后续所有依赖counts的分析函数都会基于错误数据跑,结果看起来正常但完全不能用。

第二个高发问题是降维坐标丢失。很多转换工具只搬运表达矩阵和细胞注释,不会管obsm里的PCA、UMAP、TSNE。结果就是转换后你得在Seurat里重新跑一遍PCA和UMAP,聚类标签虽然还在,但UMAP图和Scanpy里的完全对不上。这通常不能接受,因为下游往往要求“和原来的图保持一致”。

第三个被经常忽略的是基因名和细胞名的错位。Anndata的var_namesobs_names是索引,导出到表格时如果没有显式处理index,极容易变成数字行号。我甚至见过有人把细胞barcode列错当成index导出了矩阵,导致colnames全是NA,整个矩阵报废。

第四个问题是基本信息类型和顺序的扰动。Seurat的meta.data是个数据框,如果里面有些列在Anndata里是category类型,R自动读成因子,下游排序和绘图时的level顺序会和Python端完全不同。另外Anndata里obsm的坐标行列顺序,理论上应该和obs_names对齐,但某些工具导出时会把顺序弄乱,处理不慎就是坐标错位。

把这些问题都理解了,再看后面四种方法的差异就清晰多了。其实每种方法都是“在尽力表达矩阵和元数据的基础上,以某种方式解决上面这些坑”。

2. 动手前的关键一步:数据体检

2.1 读h5ad之前,先搞清楚里面到底有什么

不管用哪种转换方案,我都建议先跑到Python里把h5ad文件“解剖”一遍。这一步能节省后面至少一半的排错时间。

import anndata as ad adata = ad.read_h5ad("data.h5ad") print(adata.shape) print(adata.X) print("layers:", list(adata.layers.keys())) print("obsm:", list(adata.obsm.keys())) print("varm:", list(adata.varm.keys())) print("obs columns:", adata.obs.columns.tolist()) print("var columns:", adata.var.columns.tolist()) print(adata.raw is not None)

这段代码会告诉你:矩阵是稀疏还是稠密,X到底是counts还是normalized数据,有没有raw层,降维结果都存在哪些键下面。我自己的习惯是再额外看一眼adata.X.max()adata.X.min()。如果最大值是个几十几百的正整数、最小值是0,大概率是counts;如果最大值在十几左右、含负数,大概率是log1p标准化结果;如果出现负的z-score,那就是scale过了。

还有一个细节值得关注:adata.raw里往往存的是预处理后建议用于差异分析的矩阵,它可能是对数归一化后的层,也可能是counts。导出时到底用哪个作为Seurat的counts,完全取决于你下游要跑什么。通常的做法是:Seurat里的counts槽位放原始计数,data槽位放log1p后的值。如果Scanpy里没有存原始counts,只有normalized数据,那也行,但后续跑NormalizeData等函数时要非常小心,不要重复标准化。

2.2 检查完数据,再确认目标Seurat版本和槽位

Seurat从v4到v5,assay内部结构有改动。v5新引入的assay会拆分成多个layer,某些代码在v4上运行正常,在v5上需要先JoinLayers才能继续。互转场景里,我建议先在R环境里明确一下Seurat版本:

packageVersion("Seurat")

如果是v5,那么之前很多老教程里的seu@assays$RNA@counts访问方式已经不完全适用,推荐用seu[["RNA"]]$counts这种新写法。转换工具生成的Seurat对象,在v5里有时会表现为多layer的形态,这时候如果下游函数报“Bulk assays are not supported”,不要慌,先seu <- JoinLayers(seu)再跑。

另外要提前想清楚一个问题:转换后你是希望直接用Scanpy产生的降维坐标继续做可视化,还是打算在Seurat里重新做一遍全流程。如果是前者,就必须选择能保留obsm的转换方式,或者手动恢复降维对象;如果是后者,那只需要保证表达矩阵和关键元数据到位即可,PCA/UMAP坐标可以不要,反而更省事。

3. 四种高效转换方法实操详解

3.1 方法一:zellkonverter,Bioconductor标准路线

如果你偏向用R做主要工作流,zellkonverter是我最推荐的方案。它是Bioconductor官方维护的包,设计思路是把h5ad读取成SingleCellExperiment对象,再用Seurat提供的as.Seurat把SCE转换成Seurat对象。整个链条跑通后,Anndata的obsm会映射到SCE的reducedDims,再映射到Seurat的reductionsobs映射到colData并继续映射到meta.data,对应关系非常清晰。

安装和基本使用:

# BiocManager::install("zellkonverter") # BiocManager::install("SingleCellExperiment") library(zellkonverter) library(SingleCellExperiment) library(Seurat) sce <- readH5AD("data.h5ad") # 看看里面到底有哪些assay和降维结果 assayNames(sce) reducedDimNames(sce) # 转成Seurat对象 seu <- as.Seurat(sce)

这个路径最省心的地方在于,readH5AD默认会尝试把h5ad里的原始counts矩阵和归一化后的矩阵分别放入SCE的不同assay,as.Seurat时又会尽量根据assay名称识别哪个是counts哪个是data。它还会尽量把uns里的颜色映射、聚类结果等常见信息带过来,虽然不是百分之百完整,但比大多数工具做得周全。

不过zellkonverter有个前提条件,它在读取时依赖一个Python环境里的anndata库,或者走R端HDF5解析。很多人在这一步卡住,报错内容一般是use_python或者reticulate找不到Python。解决办法很简单:先确认你机器上有Python,且装了anndata和h5py,然后在R里指定路径:

library(reticulate) use_python("/usr/local/bin/python") # 换成你的Python路径

如果网络环境比较特殊,建议提前用reticulate::py_install("anndata")装好依赖,再跑转换,会顺很多。另外readH5AD有一个backend参数,可以选择用R侧的HDF5Array还是Python侧的anndata。实测下来,Python后端读取更稳定,尤其是h5ad文件版本较新的情况下。

3.2 方法二:sceasy,一行命令跨语言转换

如果你已经主要在Python流程里工作,只是偶尔需要把h5ad丢给用R的同事,sceasy是最直接的方案。它的本质是Python端通过rpy2调用R里的Seurat,然后把Anndata数据流转换成RDS文件。全程不用切到R环境,一条代码搞定。

Python端的用法:

import sceasy sceasy.convert( input_file="data.h5ad", input_format="anndata", output_file="data_seurat.rds", output_format="seurat", main_layer="counts" )

如果更习惯在R里调它的转换函数,也可以这样:

# devtools::install_github("cellgeni/sceasy") library(sceasy) convertFormat("data.h5ad", from="anndata", to="seurat", outFile="data_seurat.rds")

上述两步最终都会在本地生成一个RDS文件,之后直接在R里readRDS就能得到Seurat对象,非常方便。

main_layer这个参数值得多说一句。它决定的是哪个矩阵会被当作Seurat assay里的主数据。如果你的h5ad里adata.X是normalized数据,但实际还保留了layers["counts"],这里建议把main_layer设成"counts",让counts进入Seurat的counts槽位。如果设错了,后面还需要手动指认,多一道麻烦。

sceasy的坑主要在rpy2和R环境的联动上。最常见的报错是Python端装好了sceasy,但rpy2找不到R动态库,或者在转换时报could not find function "CreateSeuratObject"。这通常意味着rpy2调用的R环境里没有安装Seurat。解决办法是先在命令行里确认which R,然后在Python里指定R HOME:

import os os.environ["R_HOME"] = "/usr/lib/R" # 换成你的R路径

再不行就检查rpy2和R版本是否匹配。我自己的经验是,在conda环境里跑sceasy,最稳的组合是R 4.2+、rpy2 3.5+、Seurat v4/v5都行,Python版本倒是没那么多讲究。

3.3 方法三:手动拆解重建,最大程度保留自定义信息

当你的h5ad文件里有一些冷门信息,比如自定义的obsm降维名称、varm基因载荷、多层的表达矩阵、uns里特殊格式的marker表时,上面两条路线很可能照顾不到。这时候就需要手动拆解Anndata,把每个部分用通用格式导出来,再在R端一个一个重建Seurat对象。

先说Python端的导出。我习惯把表达矩阵导出成.mtx格式,其他元数据导出成.csv,而不是用write_csvs直接写。因为write_csvs在遇到稀疏矩阵时会自动转成稠密格式,大矩阵直接内存爆炸。

import anndata as ad import pandas as pd import scipy.io as sio adata = ad.read_h5ad("data.h5ad") # 表达矩阵:Seurat里习惯行是基因、列是细胞,所以这里做一次转置 expr = adata.X.T.tocsr() sio.mmwrite("counts.mtx", expr) # 基因信息 adata.var.to_csv("var.csv") # 细胞信息 adata.obs.to_csv("obs.csv") # 每个降维结果单独导出 for key in adata.obsm_keys(): pd.DataFrame(adata.obsm[key], index=adata.obs_names).to_csv(f"obsm_{key}.csv")

然后到R端构建Seurat对象:

library(Seurat) library(Matrix) counts <- Matrix::readMM("counts.mtx") counts <- t(counts) # mtx读进来是基因x细胞,但上面我们做了转置,所以再转回来 var_info <- read.csv("var.csv", row.names = 1) obs_info <- read.csv("obs.csv", row.names = 1) rownames(counts) <- rownames(var_info) colnames(counts) <- rownames(obs_info) seu <- CreateSeuratObject(counts = counts, meta.data = obs_info, min.cells = 0, min.features = 0) # 恢复降维坐标 pca <- read.csv("obsm_X_pca.csv", row.names = 1) seu[["pca"]] <- CreateDimReducObject(embeddings = as.matrix(pca), key = "PC_", assay = "RNA") umap <- read.csv("obsm_X_umap.csv", row.names = 1) seu[["umap"]] <- CreateDimReducObject(embeddings = as.matrix(umap), key = "UMAP_", assay = "RNA")

这里有几个容易翻车的细节。CreateDimReducObject要求传入的embeddings必须是一个矩阵,不能是data.frame;行列名要和Seurat对象的细胞名完全对应,顺序不同都会报错。key参数也必须以_结尾,否则会在后续画图时报奇怪错误。另外,如果obsm的文件名里带了斜杠或特殊字符,R读入时会默认变成点,最好在导出时就改成R友好的列名。

手动方案的优势是“你想保留什么就导出什么”,比如Scanpy里的varm["PCs"],也就是PCA的特征载荷,可以导成矩阵后用CreateDimReducObject(loadings = ...)加回到PCA对象里;adata.uns里的marker结果也能以普通数据框的形式通过AddMetaData挂到Seurat对象上。缺点也很明显:代码量大、容易漏步骤,所以这种方法只适合文件里确实有特殊内容、通用工具覆盖不了的情况。

3.4 方法四:SeuratDisk的Convert,老牌方案但要注意版本

SeuratDisk是Seurat生态里一个比较老的配套包,它提供的Convert函数在早期版本中非常流行,可以把h5ad转成h5seurat文件,再用LoadH5Seurat读入R。整体流程看起来同样简洁:

# remotes::install_github("mojaveazure/seurat-disk") library(SeuratDisk) Convert("data.h5ad", dest = "h5seurat", overwrite = TRUE) seu <- LoadH5Seurat("data.h5seurat")

但在我实测中,SeuratDisk对h5ad版本的兼容性是真的让人头疼。遇到Anndata 0.8以上版本、或者h5ad里含有多层压缩的数据,报错概率很大。最常见的错误是Unable to synchronously open attribute,本质是SeuratDisk所用的hdf5r/loom解析逻辑没能正确识别新版h5ad的HDF5结构。

如果这个错误出现了,一个比较土但有效的补救方式是:先在Python里把h5ad重新读一遍,再用旧一点的HDF5结构写出去:

import anndata as ad adata = ad.read_h5ad("data.h5ad") adata.write_h5ad("data_fix.h5ad")

然后再对data_fix.h5ad执行Convert。至于能不能成功,还是取决于SeuratDisk和当前h5ad版本之间的兼容程度。所以我现在基本只在处理老项目、或者临时帮别人转个旧文件时才会用SeuratDisk,新项目一律优先zellkonverter或sceasy。

还要强调一点,SeuratDisk的Convert在转换时默认只会迁移表达矩阵和基础元数据,obsm里的降维坐标不一定能带进Seurat。如果转换后发现Seurat里没有PCA或UMAP,那就参照上面的手动方法单独补一次降维坐标,别在Convert身上耗太久。

4. 转换成功后的校验与常见问题排查

4.1 转换结果自检清单

无论用哪种方法,转换后都必须做一轮校验。我在实际工作中已经形成了下面这套“强制身体检查”,每一条都能定位到具体问题:

# 1. 维度对不对 dim(seu) # 2. 矩阵类型和内容 class(seu[["RNA"]]$counts) seu[["RNA"]]$counts[1:3, 1:3] seu[["RNA"]]$data[1:3, 1:3] # 3. 细胞元数据有没有对齐 head(seu@meta.data) # 4. 降维坐标还在不在 Embeddings(seu, "pca")[1:3, 1:3] Embeddings(seu, "umap")[1:3, 1:3]

第一步先看维度,如果行数和基因数对不上,说明矩阵导出或者读取时顺序不对。第二步看counts和data的内容,尤其要注意counts里是不是整数、有没有负数,data里是不是明显的log1p值。第三步看meta.data,如果列数和名称跟h5ad里的obs对不上,检查一下是不是有索引列错位。第四步看降维坐标,如果pca或者umap缺失,说明转换方案没带上降维信息,需要手动补。

还有一个小技巧,转换前后各算一次表达矩阵的非零元素个数。Anndata和Seurat的稀疏矩阵格式不同,但非零元素总数应该一致。如果这个数对不上,说明矩阵在搬运过程中出了错,必须回头检查导出时的转置和排序问题。

sum(seu[["RNA"]]$counts != 0)

Python端对应地算:

import scipy.sparse as sp if sp.issparse(adata.X): print(adata.X.nnz) else: print((adata.X != 0).sum())

非零数一致是“数据没被改坏”的底线,值得养成习惯。

4.2 高频报错与解决实录

互转过程中的报错,很多是因为工具版本、数据结构、格式约定之间的“地基冲突”,整理成速查表可以节省大量排查时间。

现象/报错可能原因处理办法
转换后counts全为0把normalized层当counts用了,或h5ad里的X不是原始计数adata.rawlayers["counts"]导出,用main_layer指认
基因名全是数字行号导出var时没有保留index列var.to_csv前加index=True,R端读入后rownames设置正确
UMAP坐标缺失转换工具只迁移表达矩阵手动导出obsm["X_umap"],用CreateDimReducObject重建
rpy2报找不到Seuratrpy2调用的R环境没有安装Seurat在R里library(Seurat)确认可用,再设置R_HOME环境变量
Convert读不了h5adSeuratDisk与新版anndata/HDF5不兼容用Python重写一次h5ad,或改走zellkonverter
转换后细胞顺序乱掉obs_names没有作为索引保存,R端排序方式不同每次导出都显式带上索引,不要依赖默认行号

列一个我自己踩过的真实案例。之前帮同事转一个包含10万细胞的h5ad,用sceasy转换后,Seurat对象里有18万个基因、10万个细胞,维度看着完全正常。但一做UMAP可视化,发现图上有一半细胞的位置漂移到了原点附近。排查半天,发现是obsm["X_umap"]里的细胞顺序和obs_names不一致,转换工具没有做索引对齐,直接把矩阵倒进Seurat的reductions里。后来我在手动导出降维坐标时,强制用index=adata.obs_names重建DataFrame,再导入R后还要匹配一次细胞名,问题就消失了。

4.3 性能对比与选型建议

四种方法在实际使用中各有取舍。我在几万细胞到十几万细胞的中等数据集上大概踩过一遍,简单总结如下:

方法依赖复杂度转换速度信息保留适合场景
zellkonverter中(Bioconductor + Python后端)较快R端为主要分析环境,最推荐
sceasy中(Python + rpy2 + R包)中高Python流程为主,需要快速交付RDS
手动拆解低(无额外依赖,自己写脚本)慢,取决于IO视人工处理程度文件包含自定义信息、需要精细控制
SeuratDisk Convert中(但版本兼容性问题多)老项目、旧环境

速度方面,zellkonverter和sceasy在几万细胞规模的数据上都很快,瓶颈基本在磁盘IO和Python/R进程启动上。手动方案因为要写稀疏矩阵再读回来,速度最慢,而且矩阵越大越明显。但如果遇到的是几十万甚至百万级细胞的数据集,我反而觉得手动方案更可控,因为你能直接用分块导出和读取,避免工具在大文件上内存崩溃。

选型建议就一句话:默认走zellkonverter,快速交付走sceasy,特殊要求走手动,老环境才考虑SeuratDisk。如果在R里做全流程分析,zellkonverter带来的信息保留程度是其他方案很难比的;如果只是给同事一个能打开的Seurat对象,sceasy一行命令足够。

在我个人实际操作中,最满意的组合其实是“先解剖h5ad,再决定走哪条路”。数据里降维坐标重要,就用zellkonverter,它映射obsm的稳定性最好;数据里的raw层结构复杂,就手动拆一次配置好main layer;数据是旧版h5ad且环境里已经装了SeuratDisk,也不排斥顺手用Convert。没有银弹,但把这四种方法都吃透以后,遇到任何互转需求都不会再发怵。

最后再分享一个小技巧:每次互转完成的Seurat对象,我都会用saveRDS立刻存一份快照,并在旁边附一个简单的meta.txt,记录原始h5ad里X层是什么、counts从哪一层来、降维坐标是否经过手动校正。这个习惯帮我躲过了好多次“转换完几天后才发现数据没对齐”的惨案。互转这件事,工具选哪个固然重要,更重要的还是你对数据本身的理解够不够清楚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询