rds和h5ad互转这个话题我上一篇写过基础流程,今天这篇算是姊妹篇,但我要把视角拉得更深一点。在单细胞转录组这个领域混久了,几乎每个项目都会遇到这种局面:合作方用R的Seurat跑完了整套QC和聚类,交付给你一个.rds文件,而你自己的主分析管线在Python里,后面想做velocity、做跨平台整合或者维护一个大型Atlas,都离不开h5ad。两边数据格式不通,最直接的办法就是做格式互转。
这个需求对谁最有用?两类人。一类是偏R的,拿到Python生态出来的h5ad不知道如何塞回Seurat继续跑;另一类是偏Python的,被塞了一个.rds却要融入Scanpy流程。不管你是哪一类,核心诉求其实都一样——把对象完整、无损地搬到对方那边,尤其是metadata、降维坐标、邻接图这些“分析结论”不能丢。这篇我重点讲清楚底层的结构对应关系、手动转换的完整代码、几个容易翻车的隐蔽问题,以及怎么设计一个可靠的转换验证环节。
1. 转换思路:先搞清楚两边数据结构在“装”什么
1.1 rds到底装了什么
rds是R的序列化格式,本质上是把一个对象用saveRDS打包。一个常见的Seurat对象并不是一个简单的二维表,而是一个S4类型的复合结构。我拆解它的时候,重点会看这么几个区域:
- assays:RNA、蛋白、ATAC等不同的测定单元,每个assay内部又有多个表达矩阵,典型的包括counts、data(通常是log1p后的归一化数据)、scale.data(用于热图的标准化矩阵)。
- meta.data:细胞注释,一行对应一个barcode。聚类编号、样本分组、批次信息都在这里。
- reductions:降维结果,比如PCA、UMAP、tSNE。每个reduction内部有cell.embeddings(细胞在主成分或低维空间的坐标)和feature.loadings(PCA的基因载荷,UMAP一般没有这个字段)。
- graphs:细胞间的邻接图,比如RNA_snn,理论上可以理解成一个细胞×细胞的稀疏矩阵。
- command:Seurat运行过的命令记录,有版本和时间信息,属于辅助内容。
- misc:其他杂项,可能塞了不少自定义数据。
我拆这些槽位的时候,最常犯的错是只盯着表达量矩阵,忽略了reductions和graphs。尤其是别人交付的rds,聚类注释基本都是画在UMAP上的,结果你只转了表达矩阵,换个环境重新算一遍UMAP,坐标对不上,注释全部错位。互转的核心不是“把表达矩阵搬过去”,而是“把分析结论完整搬过去”。
1.2 h5ad的结构与命名习惯
h5ad是anndata的HDF5存储格式,它的结构设计得比较规整,有明确的硬盘读取接口。它通常包含:
- X:细胞×基因的主矩阵,可以是稠密、稀疏或分块存储。
- obs:细胞注释DataFrame,行索引与X的行一一对应。
- var:基因注释DataFrame,列索引与X的列一一对应。
- obsm / varm:细胞和基因的低维嵌入,比如obsm里有X_pca、X_umap。
- obsp / varp:细胞对细胞、基因对基因的pairwise结构,connectivities邻接图通常放在obsp里。
- layers:多层表达矩阵,可以同时存放counts、log1p、scale.data或者去批次后的校正矩阵。
- uns:非结构化metadata,聚类颜色、marker基因列表、差异分析结果都可以丢这里。
两边的对应关系在逻辑上其实相当直接:Seurat的meta.data对应adata.obs,assay内部的counts和data对应adata.layers里的counts和lognorm(或者干脆用data作为X),reductions对应obsm。只有当你用这种视角去拆一个Seurat对象时,代码写起来才顺手——不是想着“用一个函数一键转”,而是思考“每一块信息究竟要搬去哪个目标结构”。
1.3 索引从1到0,方向从基因×细胞到细胞×基因
这里额外强调两个最容易被忽略的基础细节。
一个是索引规则。R是1-based索引,Python是0-based索引,所以转换之后任何基于位置的矩阵切片,边界都要重新核对。最直观的表现是,某些在R里看着正常的代码搬到Python里,发现最后一行或最后一列永远对不上,而且这类问题极难定位,它在向量长度不一致时才会爆出来,如果恰好遇到长度相同的情况,错误值会悄悄混进下游分析。
另一个更关键的是维度方向。Seurat的assay矩阵默认是基因×细胞,而anndata要求X是细胞×基因。简单说,表达量矩阵必须做转置,转置后原矩阵的列名(barcode)进入adata.obs_names,原矩阵的行名(基因)进入adata.var_names。我第一次用SeuratDisk时以为它内部已经处理好了,结果拿到h5ad后一检查,有个assay的行列方向是反的。手动组装最大的好处就在这里,每一块数据的方向都是由你自己控制的。
2. 工具选型解析:为什么我“嫌弃”SeuratDisk却还要提它
大概所有人第一反应都是用SeuratDisk。它提供Convert和SaveH5Seurat系列函数,理论上可以把Seurat对象保存为.h5seurat再转成.h5ad。我早期用这条路径做过不少数据转换,但项目做多了,慢慢总结出几条绕不开的痛点。
第一个痛点是版本兼容混乱。SeuratDisk在社区维护上明显没有跟上Seurat自身演进的节奏,Seurat版本迭代一轮之后,默认assay结构已经变化了好几次,老版本的SeuratDisk写出来的h5seurat文件在新环境下打不开,或者新版本Seurat写出的文件,在老SeuratDisk上读不进去。遇到这种情况,第一反应是升级或降级某一个包,但升级之后其他依赖又要适配,问题就像滚雪球一样膨胀。对于长期项目来说,这种不确定性是很耗精力的。
第二个痛点,也是我最介意的,是信息丢失。它有固定的默认映射规则,并不会自动把你没显式告诉它的字段放进去。实际测试中我遇到过一次,转换后的h5ad里logs矩阵不在预期位置,某个降维坐标直接缺失,还有一个图的邻接信息放进了uns而不是obsp。这种“看上去转了但数据不完整”的结果最坑人——后续分析不是立即报错,而是在某个隐性环节才出问题,排查起来比一开始就手动组装要痛苦得多。
第三个痛点是控制力不足。不同来源的Seurat对象里meta.data字段风格千差万别,有的是factor型,有的列名带空格,有的列名是中文。SeuratDisk并不会自动清洗这些信息,也不支持你中途介入处理。所以就算走SeuratDisk路线,清洗工作终归要自己做,那还不如直接走手动管线,至少每一步都知道发生了什么。
但我也承认它在简单场景下的价值。比如快速试读一下数据、粗略确认基因名和细胞数量,用SeuratDisk转一次是很快的。只是如果你追求可靠性和完整性,我强烈建议直接手动组装。下面这套方案就是我目前在实际项目中稳定使用的转换流程。
3. 实操流程:从Seurat对象到h5ad的完整转换方案
3.1 R侧准备:把Seurat拆成“标准零件”
我这里以最常见的单细胞RNA Seurat对象为例,代码逻辑可以直接推广到其他assay。
第一步,确认RNA assay存在,导出三个关键矩阵:
library(Seurat) library(Matrix) seu <- readRDS("seurat_object.rds") # 表达量矩阵 counts <- GetAssayData(seu, assay = "RNA", slot = "counts") lognorm <- GetAssayData(seu, assay = "RNA", slot = "data") # 检查维度:R侧通常是基因 × 细胞 dim(counts)第二步,处理meta.data。有两个问题在这里必须提前处理:一是factor列,二是列名规范。factor列如果直接导出,内部存储是整数加levels属性,序列化过程如果没把levels信息带出去,对方拿到的就是一堆整数,非常容易误读。列名里如果带空格、括号、减号,scanpy里有些操作会直接因为语法问题报错。
meta <- seu@meta.data # 把factor列统一转成字符串,避免整数编码 meta[sapply(meta, is.factor)] <- lapply(meta[sapply(meta, is.factor)], as.character) # 列名规范化 names(meta) <- make.names(names(meta))第三步,导出降维结果和邻接图:
pca_emb <- seu@reductions$pca@cell.embeddings umap_emb <- seu@reductions$umap@cell.embeddings pca_loadings <- seu@reductions$pca@feature.loadings snn <- seu@graphs$RNA_snn第四步,做矩阵转置并保存中间结果。这一步很关键:Seurat矩阵是基因×细胞,anndata需要的是细胞×基因,所以转置是必须的。
counts_t <- t(counts) lognorm_t <- t(lognorm) # 确保是稀疏矩阵 counts_t <- as(counts_t, "CsparseMatrix") lognorm_t <- as(lognorm_t, "CsparseMatrix") save(counts_t, lognorm_t, meta, pca_emb, umap_emb, pca_loadings, snn, file = "seurat_exported.rda")这里值得多说一句:为什么强调CsparseMatrix。R中dgCMatrix就是CSC格式,转置之后再显式转一次CsparseMatrix,是为了确保下游Python端拿到的是稀疏列压缩格式。anndata底层用CSC和CSR都支持,但scanpy读入后很多算法对CSC做切片和矩阵乘法更顺手,而且h5ad在写入时对CSC的支持也比较成熟。当然,如果你在R侧本来就习惯了dgCMatrix,t()之后本身就是CSC,不需要额外转换,但千万不要用as.matrix()把它变密。几万细胞乘几万基因的矩阵一旦变稠密,内存瞬间爆掉。
3.2 Python侧组装:构造AnnData并写入h5ad
接下来是Python端的接收脚本:
import anndata as ad import scipy.sparse as sp import numpy as np import pandas as pd # 读取R侧保存的中间结果,这里用rpy2或者转换成csv/h5格式都可以 # 这里我用load_rda做示意,实际项目中可以直接用h5文件交换 adata = ad.AnnData( X=sp.csc_matrix(lognorm_t), obs=meta_df, var=pd.DataFrame(index=gene_names), layers={"counts": sp.csc_matrix(counts_t)} ) # 索引统一转成字符串 adata.obs_names = [str(x) for x in adata.obs_names] adata.var_names = [str(x) for x in adata.var_names] adata.var_names_make_unique() # 降维结果写入obsm/varm adata.obsm["X_pca"] = pca_emb adata.obsm["X_umap"] = umap_emb if pca_loadings is not None: adata.varm["loadings"] = pca_loadings # 邻接图写入obsp adata.obsp["connectivities"] = snn # 可选的uns信息,把聚类注释也放进去 adata.uns["orig_seurat_clusters"] = adata.obs["seurat_clusters"].tolist() # 写文件 adata.write_h5ad("converted.h5ad")组装时还有几个细节要单独讲一下。
第一,obs和var的索引类型。R的barcode和基因名到了Python里通常是字符串,但偶尔会出现全数字barcode。scanpy内部很多函数是按字符串索引去匹配obs/var的,如果全数字barcode被读成int,后面取子集会非常难受。所以我习惯在组装前统一用str()包一下。
第二,var_names_make_unique不是万能药。它对于少量重复基因名确实有效,但如果原始数据里有一堆空的、NA的基因名,它的拼接逻辑会产生大量带“-1”“-2”后缀的名字,后面做marker基因筛选时会让你头疼不已。所以最佳策略是在源头清洗基因名,不要让空值进入组装环节。
第三,scale.data要不要放进h5ad。我的建议是如果后续要复用Seurat的scale.data做差异分析,再考虑放layers["scale"];否则不建议放,因为它是dense矩阵,体积大,而scanpy的大多数流程不会主动调用它。如果你确实要放,记得转float32,不然内存开销会上涨得很快。
3.3 大对象的backed模式:别一次性load进内存
再来专门说说大规模数据。如果R端导出的矩阵是5万细胞×3万基因这种级别,直接全量读进内存再转Python,中途很容易崩掉。我的处理习惯是两步走:
R侧不要用saveRDS一次性打包,分块把稀疏矩阵写入一个独立的h5文件,每个assay对应一组数据集,细胞注释单独写一个CSV。这样Python端读取时可以先读细胞注释,再按需抽取矩阵块。
Python侧用anndata.read_h5ad(..., backed="r")先以只读模式打开文件,不急着把全部矩阵load进内存,等真正需要计算的时候再对某个层做to_memory()或只抽取需要的子集。
backed模式有两个隐藏的坑必须说明。第一,backed模式是只读的,不能直接在读取的backed对象上修改obs、obsm等属性。如果你想往里加一列注释,先得把它读进内存,或者用write_h5ad生成新文件后再继续操作。第二,某些scanpy函数是不支持backed对象的,比如运行harmony整合时,你发现它正在悄悄把整个矩阵load进来,内存又爆了。所以backed模式适合“轻量探索”,不适合“直接跑全套分析”,这个边界要心里有数。
4. 反向转换:从h5ad回rds的实操要点
4.1 读取h5ad的两种路线
从h5ad回rds,我也试过两条路线。
路线一是用R的anndata包,底层靠reticulate调Python环境里的anndata库直接读h5ad,然后拆给Seurat。这个思路最直接,前提是你的R环境里有可用的Python环境,而且anndata的Python版本与R包的reticulate兼容。我用下来感觉它适合临时性读取,不适合做正式交付——因为你的R脚本多了一个Python运行依赖,换一台机器可能就起不来了。
路线二是用zellkonverter先把h5ad读成SingleCellExperiment,再用Seurat::as.Seurat(sce)转成Seurat对象。zellkonverter的底层依赖管理更干净,basilisk会自动搭建好独立Python环境,不用你手动去指定路径。而且SingleCellExperiment作为中间格式,可以在后期做很多质控检查,转换过程也更透明。
我推荐路线二,下面代码以zellkonverter为例。
4.2 从h5ad到SingleCellExperiment再到Seurat
library(zellkonverter) library(SingleCellExperiment) library(Seurat) sce <- readH5AD("converted.h5ad") sce seu <- as.Seurat(sce)但这条路线有几个隐含细节需要处理。
第一个是X和layers的映射。readH5AD默认把adata.X读成assay(sce, "X"),layers会变成其他assays。如果你希望Seurat对象的counts是原始counts而data是log1p,建议在as.Seurat之前手动指定:
seu <- as.Seurat(sce, counts = "counts", data = "X")前提是sce的assays里确实存在counts。如果源h5ad里只有X没有layers,那你得根据X的类型决定是否能用expm1等函数逆推counts。这里我要特别提醒:不同来源的h5ad对X的定义不一致,有的X是counts,有的是log1p,还有的甚至做了z-score标准化。转回R之前必须先确认X到底是什么。否则配出来的Seurat对象看着没问题,跑FindMarkers时结果却完全不符合预期,因为数据口径根本不是同一种。
第二个是obsm到reductions的映射。as.Seurat会把reducedDims映射成Seurat的reductions,但名字可能是X_pca、X_umap,而不是pca、umap。如果后续代码里按名字取reduction,建议转完后统一重命名。另外某些Scanpy里常见的嵌入如X_diffmap,放进Seurat里没有对应的原生类型,Seurat会保留为未识别的reduction,不影响对象完整性,但你可能需要在分析前决定是否保留。
第三个是obs中factor和字符串的处理。h5ad里的obs列是字符串或数值,到了SingleCellExperiment变成DataFrame,再转Seurat,列名中带特殊字符或全角符号的问题依然存在,建议转完后用make.names统一清洗。另外注意一个高频现象:很多h5ad在导出时把缺失值写成了字符串“NA”,在R里会被当作普通字符串,而不是真正的NA。如果你后面要用is.na()做过滤,这种字符串会漏掉,需要先判断是否要替换回NA。
4.3 逆变换和稀疏矩阵转置
反向转换同样要处理维度方向。h5ad的X是细胞×基因,R的assay要求是基因×细胞,所以要用Matrix::t()做转置,而不是base::t()。base::t()会把稀疏矩阵转成稠密矩阵,几万细胞的数据直接爆内存,这个坑我踩过不止一次。
counts_gene_cell <- Matrix::t(counts_cell_gene)如果是counts层,也要做一样的转置。转完之后如果数据变成了dgTMatrix或其他格式,建议统一用as(counts_gene_cell, "dgCMatrix")转回dgCMatrix,因为Seurat的很多函数默认对dgCMatrix做处理。这一步在代码里只多一行,但对后续的稳定性影响非常大。
5. 常见问题与排查实录
5.1 基因名变成日期格式
这个坑有极强的迷惑性。某次我在R侧把基因名写成CSV再读回来,发现“1-Mar”“2-May”这类基因名全部被读成了日期格式。其实readRDS不会出现这问题,但一旦你在某个环节里走了CSV,或者某个同事习惯性把metadata写成文本文件再导入,就会碰到。解决方法是read.csv时加check.names=FALSE,读入后强制row.names = as.character(row.names)。这种问题排查起来比较隐蔽,因为只有部分基因名受影响,而且在R里可能不会立即报错,等到下游比对基因名时才露馅。
5.2 factor导出后变成整数编码
这个在前面已经提到。再展开一个场景:你从某个公共数据集的Seurat对象里取meta.data,发现上面有seurat_clusters、cell_type、sample_id等列,其中很多是factor。如果直接构造AnnData,obs里的值会变成0、1、2这样的整数,等你用scanpy画图时会发现图例是数字而不是标签,再做分组时也会出错。建议在R侧加一行保险代码:
meta[sapply(meta, is.factor)] <- lapply(meta[sapply(meta, is.factor)], as.character)Python侧也可以事后补救:
for col in adata.obs.columns: if str(adata.obs[col].dtype) == "category": adata.obs[col] = adata.obs[col].astype(str)5.3 h5ad读取后var_names对不上
有一回我拿到别人转好的h5ad,用scanpy读取后var_names末尾多了一堆“-1”“-2”,明显是之前做var_names_make_unique时把原本唯一的基因名也改了。排查发现原始数据里有少量空基因名和重复的“NA”字符串。这类问题在做marker基因筛选时会让结果非常难以对应。我的建议是:如果源数据来自公共数据库,基因注释一般没问题;如果是自己拼的矩阵,一定在源头清洗基因名,不要让空值进入组装环节。如果已经进入h5ad了,再清洗的成本就高很多。
5.4 降维坐标丢失或放错位置
这个归因于SeuratDisk的默认映射问题。实际案例中,某同事用SeuratDisk把一个带UMAP坐标和聚类注释的Seurat对象转成h5ad,结果读取后scanpy画图坐标疯狂报错。最终定位到原因是UMAP嵌入没有被正确写入obsm,而是被塞进了uns。手动组装方案不会出现这个问题,因为每一块都是你显式放进去的。转换后检查几个关键字段是否存在,应该是流程的一部分,而不是等到画图时报错了才发现。
5.5 邻接图方向搞反
Seurat里的graph(例如RNA_snn)是细胞×细胞,anndata里的obsp也是细胞×细胞,方向一致,按理说不需要转置。但如果某些自定义图结构是“基因×细胞”或者从其他软件导出的图矩阵,方向就可能颠倒。一旦方向错了,下游用connectivities做聚类或可视化邻接关系时会出现非常离谱的结果。建议转换后在两边各抽一个小子集,手动核对某几个细胞之间的关系值,确认以后再继续下游工作。这一步虽然繁琐,但在我的经验里值得做,因为方向搞反带来的问题往往不是报错,而是静默的坏结果。
5.6 backed模式导致的只读误解
如果使用了backed="r"读取h5ad,要特别注意它只能读、不能原地改。很多新手会直接对backed对象赋值obs列,运行时报错“object is read-only”,然后一头雾水。正确做法是先copy到内存,或者用write_h5ad生成新文件后再继续操作。这一点对大规模数据尤其重要,因为很多人当初就是为了省内存才用backed模式,结果赋值obs时又不知不觉把整个对象load进来,内存直接炸了。记住一个模式:backed模式适合“只读探索”,不适合“修改属性”。
6. 验证环节:转换之后如何确保数据没丢
转换本身只是开始,真正决定交付质量的是验证。我每次交付转换脚本,都会在脚本里加入一个“验证区块”,检查三件事:
第一,表达量矩阵是否一致。在R端和Python端各抽同一个细胞子集,用同样的基因子集,做sum和mean对比。数值不要求完全一样,但浮点误差应该控制在极小范围内。如果log1p矩阵来自Seurat,而Python端用的是scanpy自己的标准化函数重算的结果,那数值可能有系统性偏差,这不是转换的锅,而是算法口径不一致。
第二,降维坐标与注释是否对齐。抽几个已知的cluster,查看UMAP坐标和聚类标签是否在同一批细胞上。如果一个cluster中某个细胞的坐标在R端和Python端差异很大,往往是样本顺序错位,而不是数值精度问题。这种错位最容易发生在obs或var的排序被某些中间步骤打乱的情况下。
第三,邻接图结构是否完整。检查obsp里connectivities矩阵的维度、非零元素数量,以及稀疏度是否和R端保持一致。非零元素数量差异过大,说明边缘或者细胞过滤信息丢了。
我每次都会在转换脚本最后打印一份简单的“转换报告”,记录细胞数、基因数、矩阵稀疏度、降维坐标维度、邻接图非零数。这份报告随h5ad一起交付,协作者拿到数据后只要不是直接报错,都可以用它做初步核验。虽然看起来只是几行print,但在实际协作中能省下不少沟通时间。
再补充一点,反向转换回Seurat时,验证方向是一样的,只是对调。我会在R端重新检查adata.obs里某几个关键字段是否完整,UMAP坐标是否是源h5ad里的值。整个过程不需要写特别复杂的逻辑,难的是养成“转完必查”的习惯。数据格式互转这件事,数值精度可以接受微小误差,但结构完整性必须百分之百对齐。
7. 结尾里的一点实操体会
做的次数多了,我越来越觉得rds和h5ad互转本质上不是格式翻译,而是理解两个生态对“分析结果”这个概念的封装方式不同。Seurat侧重把对象做成一个自包含的容器,所有中间产物都堆在里面;anndata则更偏向把不同层次的数据物理分开放置,靠命名规范把它们关联起来。理解了这层差异,手动组装反而比黑盒工具更可靠。
最后再分享一个小技巧:如果转换是团队内部的高频需求,不要每次都在临时脚本里复制粘贴代码,可以把R侧导出和Python侧组装各自封装成两三个函数,接口只留输入rds路径和输出h5ad路径。这样内部处理逻辑统一,以后遇到新的metdata字段类型或者新的assay结构,只需要在一个地方做扩展。我在实际项目中就是这么做下来的,一年多没再因为格式转换问题被人凌晨喊醒过。数据搬运这件事,虽然不够炫酷,但稳定性做好了,能让上下游合作顺畅很多。